Merge pull request #2 from shish/master

Update
This commit is contained in:
MikChan 2020-03-19 01:02:11 +03:00 committed by GitHub
commit e7c779796e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
616 changed files with 41020 additions and 40006 deletions

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<buildpath>
<buildpathentry kind="src" path=""/>
<buildpathentry kind="con" path="org.eclipse.php.core.LANGUAGE"/>
</buildpath>

7
.dockerignore Normal file
View file

@ -0,0 +1,7 @@
vendor
.git
*.phar
data
images
thumbs
*.sqlite

21
.editorconfig Normal file
View file

@ -0,0 +1,21 @@
# In retrospect I'm less of a fan of tabs for indentation, because
# while they're better when they work, they're worse when they don't
# work, and so many people use terrible editors when they don't work
# that everything is inconsistent... but tabs are what Shimmie went
# with back in the 90's, so that's what we use now, and we deal with
# the pain of making sure everybody configures their editor properly
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
[*.{js,css,php}]
charset = utf-8
indent_style = space
indent_size = 4

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
*.php text eol=lf

30
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,30 @@
---
name: Bug report
about: Create a report to help us improve
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Server Software, if you're the server admin (please complete the following information):**
- Shimmie version
- Database [mysql, postgres, ...]
- Web server [apache, nginx, ...]
**Client Software (please complete the following information):**
- Device [e.g. iphone, windows desktop]
- Browser [e.g. chrome, safari]

View file

@ -0,0 +1,17 @@
---
name: Feature request
about: Suggest an idea for this project
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

13
.github/workflows/docker.yml vendored Normal file
View file

@ -0,0 +1,13 @@
name: Docker Push
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Publish to Registry
uses: elgohr/Publish-Docker-Github-Action@master
with:
name: shish2k/shimmie2
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

76
.github/workflows/tests.yml vendored Normal file
View file

@ -0,0 +1,76 @@
name: Unit Tests
on: [push, pull_request]
jobs:
build:
name: PHP ${{ matrix.php }} / DB ${{ matrix.database }}
strategy:
max-parallel: 3
fail-fast: false
matrix:
php: ['7.3']
database: ['pgsql', 'mysql', 'sqlite']
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up PHP
uses: shivammathur/setup-php@master
with:
php-version: ${{ matrix.php }}
coverage: pcov
extensions: mbstring
- name: Set up database
run: |
mkdir -p data/config
if [[ "${{ matrix.database }}" == "pgsql" ]]; then
sudo apt update && sudo apt-get install -y postgresql postgresql-client ;
psql --version ;
sudo -u postgres psql -c "SELECT set_config('log_statement', 'all', false);" -U postgres ;
sudo -u postgres psql -c "CREATE USER shimmie WITH PASSWORD 'shimmie';" -U postgres ;
sudo -u postgres psql -c "CREATE DATABASE shimmie WITH OWNER shimmie;" -U postgres ;
fi
if [[ "${{ matrix.database }}" == "mysql" ]]; then
sudo systemctl start mysql ;
mysql --version ;
mysql -e "SET GLOBAL general_log = 'ON';" -uroot -proot ;
mysql -e "CREATE DATABASE shimmie;" -uroot -proot ;
fi
if [[ "${{ matrix.database }}" == "sqlite" ]]; then
sudo apt update && sudo apt-get install -y sqlite3 ;
sqlite3 --version ;
fi
- name: Check versions
run: php -v && composer -V
- name: Validate composer.json and composer.lock
run: composer validate
- name: Install PHP dependencies
run: composer install --prefer-dist --no-progress --no-suggest
- name: Install shimmie
run: php index.php
- name: Run test suite
run: |
if [[ "${{ matrix.database }}" == "pgsql" ]]; then
export DSN="pgsql:user=shimmie;password=shimmie;host=127.0.0.1;dbname=shimmie"
fi
if [[ "${{ matrix.database }}" == "mysql" ]]; then
export DSN="mysql:user=root;password=root;host=127.0.0.1;dbname=shimmie"
fi
if [[ "${{ matrix.database }}" == "sqlite" ]]; then
export DSN="sqlite:data/shimmie.sqlite"
fi
vendor/bin/phpunit --configuration tests/phpunit.xml --coverage-clover=data/coverage.clover
- name: Upload coverage
run: |
wget https://scrutinizer-ci.com/ocular.phar
php ocular.phar code-coverage:upload --format=php-clover data/coverage.clover

6
.gitignore vendored
View file

@ -2,10 +2,11 @@ backup
data data
images images
thumbs thumbs
!lib/images
*.phar *.phar
*.sqlite *.sqlite
/lib/vendor/ *.cache
.devcontainer
trace.json
#Composer #Composer
composer.phar composer.phar
@ -54,7 +55,6 @@ Icon
*.un~ *.un~
Session.vim Session.vim
.netrwhist .netrwhist
*~
### PhpStorm ### ### PhpStorm ###

View file

@ -17,8 +17,8 @@
# rather than link to images/ha/hash and have an ugly filename, # rather than link to images/ha/hash and have an ugly filename,
# we link to images/hash/tags.ext; mod_rewrite splits things so # we link to images/hash/tags.ext; mod_rewrite splits things so
# that shimmie sees hash and the user sees tags.ext # that shimmie sees hash and the user sees tags.ext
RewriteRule ^_images/([0-9a-f]{2})([0-9a-f]{30}).*$ images/$1/$1$2 [L] RewriteRule ^_images/([0-9a-f]{2})([0-9a-f]{30}).*$ data/images/$1/$1$2 [L]
RewriteRule ^_thumbs/([0-9a-f]{2})([0-9a-f]{30}).*$ thumbs/$1/$1$2 [L] RewriteRule ^_thumbs/([0-9a-f]{2})([0-9a-f]{30}).*$ data/thumbs/$1/$1$2 [L]
# any requests for files which don't physically exist should be handled by index.php # any requests for files which don't physically exist should be handled by index.php
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
@ -27,7 +27,7 @@
<IfModule mod_expires.c> <IfModule mod_expires.c>
ExpiresActive On ExpiresActive On
<FilesMatch "([0-9a-f]{32}|\.(gif|jpe?g|png|css|js))$"> <FilesMatch "([0-9a-f]{32}|\.(gif|jpe?g|png|webp|css|js))$">
<IfModule mod_headers.c> <IfModule mod_headers.c>
Header set Cache-Control "public, max-age=2629743" Header set Cache-Control "public, max-age=2629743"
</IfModule> </IfModule>
@ -46,6 +46,7 @@
AddType image/jpeg jpg jpeg AddType image/jpeg jpg jpeg
AddType image/gif gif AddType image/gif gif
AddType image/png png AddType image/png png
AddType image/webp webp
#EXT: handle_ico #EXT: handle_ico
AddType image/x-icon ico ani cur AddType image/x-icon ico ani cur

19
.php_cs.dist Normal file
View file

@ -0,0 +1,19 @@
<?php
$finder = PhpCsFixer\Finder::create()
->exclude('ext/amazon_s3/lib')
->exclude('vendor')
->exclude('data')
->in(__DIR__)
;
return PhpCsFixer\Config::create()
->setRules([
'@PSR2' => true,
//'strict_param' => true,
'array_syntax' => ['syntax' => 'short'],
])
->setFinder($finder)
;
?>

View file

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>Shimmie 2</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.wst.validation.validationbuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.dltk.core.scriptbuilder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.php.core.PHPNature</nature>
</natures>
</projectDescription>

View file

@ -3,7 +3,17 @@ imports:
- php - php
filter: filter:
excluded_paths: [lib/*,ext/*/lib/*,ext/tagger/script.js,ext/chatbox/*] excluded_paths: [ext/*/lib/*,ext/tagger/script.js,tests/*]
build:
nodes:
analysis:
tests:
before:
- mkdir -p data/config
- cp tests/defines.php data/config/shimmie.conf.php
override:
- php-scrutinizer-run
tools: tools:
external_code_coverage: true external_code_coverage: true

View file

@ -1,62 +0,0 @@
language: php
php:
- 5.6
- 7.0
- 7.1
sudo: false
env:
matrix:
- DB=mysql
- DB=pgsql
- DB=sqlite
allow_failures:
- DB=sqlite
cache:
directories:
- vendor
- $HOME/.composer/cache
before_install:
- travis_retry composer self-update && composer --version #travis is bad at updating composer
- if [ -n "$GH_TOKEN" ]; then composer config github-oauth.github.com ${GH_TOKEN}; fi;
install:
- mkdir -p data/config
- |
if [[ "$DB" == "pgsql" ]]; then
psql -c "SELECT set_config('log_statement', 'all', false);" -U postgres ;
psql -c "CREATE DATABASE shimmie;" -U postgres ;
echo '<?php define("DATABASE_DSN", "pgsql:user=postgres;password=;host=;dbname=shimmie");' > data/config/auto_install.conf.php ;
fi
- |
if [[ "$DB" == "mysql" ]]; then
mysql -e "SET GLOBAL general_log = 'ON';" -uroot ;
mysql -e "CREATE DATABASE shimmie;" -uroot ;
echo '<?php define("DATABASE_DSN", "mysql:user=root;password=;host=localhost;dbname=shimmie");' > data/config/auto_install.conf.php ;
fi
- if [[ "$DB" == "sqlite" ]]; then echo '<?php define("DATABASE_DSN", "sqlite:shimmie.sqlite");' > data/config/auto_install.conf.php ; fi
- composer install
- php install.php
script:
- vendor/bin/phpunit --configuration tests/phpunit.xml --coverage-clover=data/coverage.clover
after_failure:
- head -n 100 data/config/*
- ls /var/run/mysql*
# All of the below commands require sudo, which we can't use without losing some speed & caching.
# SEE: https://docs.travis-ci.com/user/workers/container-based-infrastructure/
# - ls /var/log/*mysql*
# - cat /var/log/mysql.err
# - cat /var/log/mysql.log
# - cat /var/log/mysql/error.log
# - cat /var/log/mysql/slow.log
# - ls /var/log/postgresql
# - cat /var/log/postgresql/postgresql*
after_script:
- wget https://scrutinizer-ci.com/ocular.phar
- php ocular.phar code-coverage:upload --format=php-clover data/coverage.clover

21
Dockerfile Normal file
View file

@ -0,0 +1,21 @@
FROM debian:stable-slim
ENV DEBIAN_FRONTEND=noninteractive
EXPOSE 8000
RUN apt update && apt install -y curl
HEALTHCHECK --interval=5m --timeout=3s CMD curl --fail http://127.0.0.1:8000/ || exit 1
RUN apt install -y php7.3-cli php7.3-gd php7.3-pgsql php7.3-mysql php7.3-sqlite3 php7.3-zip php7.3-dom php7.3-mbstring php-xdebug
RUN apt install -y composer imagemagick vim zip unzip
COPY composer.json composer.lock /app/
WORKDIR /app
RUN composer install
COPY . /app/
RUN echo '=== Installing ===' && mkdir -p data/config && echo "<?php \$dsn = \"sqlite:data/shimmie.sqlite\";" > data/config/auto_install.conf.php && php index.php && \
echo '=== Smoke Test ===' && php index.php get-page /post/list && \
echo '=== Unit Tests ===' && ./vendor/bin/phpunit --configuration tests/phpunit.xml && \
echo '=== Coverage ===' && ./vendor/bin/phpunit --configuration tests/phpunit.xml --coverage-text && \
echo '=== Cleaning ===' && rm -rf data
RUN chmod +x /app/tests/docker-init.sh
CMD "/app/tests/docker-init.sh"

View file

@ -10,15 +10,9 @@
# Shimmie # Shimmie
[![Build Status](https://travis-ci.org/shish/shimmie2.svg?branch=master)](https://travis-ci.org/shish/shimmie2) [![Unit Tests](https://github.com/shish/shimmie2/workflows/Unit%20Tests/badge.svg)](https://github.com/shish/shimmie2/actions)
[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/shish/shimmie2/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/shish/shimmie2/?branch=master) [![Code Quality](https://scrutinizer-ci.com/g/shish/shimmie2/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/shish/shimmie2/?branch=master)
[![Code Coverage](https://scrutinizer-ci.com/g/shish/shimmie2/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/shish/shimmie2/?branch=master) [![Code Coverage](https://scrutinizer-ci.com/g/shish/shimmie2/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/shish/shimmie2/?branch=master)
(master)
[![Build Status](https://travis-ci.org/shish/shimmie2.svg?branch=develop)](https://travis-ci.org/shish/shimmie2)
[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/shish/shimmie2/badges/quality-score.png?b=develop)](https://scrutinizer-ci.com/g/shish/shimmie2/?branch=develop)
[![Code Coverage](https://scrutinizer-ci.com/g/shish/shimmie2/badges/coverage.png?b=develop)](https://scrutinizer-ci.com/g/shish/shimmie2/?branch=develop)
(develop)
This is the main branch of Shimmie, if you know anything at all about running This is the main branch of Shimmie, if you know anything at all about running
websites, this is the version to use. websites, this is the version to use.
@ -28,8 +22,11 @@ check out one of the versioned branches.
# Requirements # Requirements
- MySQL/MariaDB 5.1+ (with experimental support for PostgreSQL 9+ and SQLite 3) - These are generally based on "whatever is in Debian Stable", because that's
- [Stable PHP](https://en.wikipedia.org/wiki/PHP#Release_history) (5.6+ as of writing) conservative without being TOO painfully out of date, and is a nice target
for the unit test Docker build.
- A database: PostgreSQL 11+ / MariaDB 10.3+ / SQLite 3.27+
- [Stable PHP](https://en.wikipedia.org/wiki/PHP#Release_history) (7.3+ as of writing)
- GD or ImageMagick - GD or ImageMagick
# Installation # Installation
@ -44,46 +41,41 @@ check out one of the versioned branches.
# Installation (Development) # Installation (Development)
1. Download shimmie via the "Download Zip" button on the [develop](https://github.com/shish/shimmie2/tree/develop) branch. 1. Download shimmie via the "Download Zip" button on the [master](https://github.com/shish/shimmie2/tree/master) branch.
2. Unzip shimmie into a folder on the web host 2. Unzip shimmie into a folder on the web host
3. Install [Composer](https://getcomposer.org/). (If you don't already have it) 3. Install [Composer](https://getcomposer.org/). (If you don't already have it)
4. Run `composer install` in the shimmie folder. 4. Run `composer install` in the shimmie folder.
5. Follow instructions noted in "Installation" starting from step 3. 5. Follow instructions noted in "Installation" starting from step 3.
## Upgrade from 2.3.X # Docker
1. Backup your current files and database! Useful for testing in a known-good environment, this command will build a
2. Unzip into a clean folder simple debian image and run all the unit tests inside it:
3. Copy across the images, thumbs, and data folders
4. Move `old/config.php` to `new/data/config/shimmie.conf.php`
5. Edit `shimmie.conf.php` to use the new database connection format:
OLD Format: ```
```php docker build -t shimmie .
$database_dsn = "<proto>://<username>:<password>@<host>/<database>";
``` ```
NEW Format: Once you have an image which has passed all tests, you can then run it to get
```php a live system:
define("DATABASE_DSN", "<proto>:user=<username>;password=<password>;host=<host>;dbname=<database>");
```
docker run -p 0.0.0.0:8123:8000 shimmie
``` ```
The rest should be automatic~ Then you can visit your server on port 8123 to see the site.
If there are any errors with the upgrade process, `in_upgrade=true` will
be left in the config table and the process will be paused for the admin
to investigate.
Deleting this config entry and refreshing the page should continue the upgrade from where it left off.
Note that the docker image is entirely self-contained and has no persistence
(assuming you use the sqlite database); each `docker run` will give a clean
un-installed image.
### Upgrade from earlier versions ### Upgrade from earlier versions
I very much recommend going via each major release in turn (eg, 2.0.6 I very much recommend going via each major release in turn (eg, 2.0.6
-> 2.1.3 -> 2.2.4 -> 2.3.0 rather than 2.0.6 -> 2.3.0). -> 2.1.3 -> 2.2.4 -> 2.3.0 rather than 2.0.6 -> 2.3.0).
While the basic database and file formats haven't changed *completely*, it's different While the basic database and file formats haven't changed *completely*, it's
enough to be a pain. different enough to be a pain.
## Custom Configuration ## Custom Configuration
@ -91,7 +83,7 @@ enough to be a pain.
Various aspects of Shimmie can be configured to suit your site specific needs Various aspects of Shimmie can be configured to suit your site specific needs
via the file `data/config/shimmie.conf.php` (created after installation). via the file `data/config/shimmie.conf.php` (created after installation).
Take a look at `core/sys_config.inc.php` for the available options that can Take a look at `core/sys_config.php` for the available options that can
be used. be used.
@ -100,35 +92,36 @@ be used.
User classes can be added to or altered by placing them in User classes can be added to or altered by placing them in
`data/config/user-classes.conf.php`. `data/config/user-classes.conf.php`.
For example, one can override the default anonymous "allow nothing" permissions like so: For example, one can override the default anonymous "allow nothing"
permissions like so:
```php ```php
new UserClass("anonymous", "base", array( new UserClass("anonymous", "base", [
"create_comment" => True, Permissions::CREATE_COMMENT => True,
"edit_image_tag" => True, Permissions::EDIT_IMAGE_TAG => True,
"edit_image_source" => True, Permissions::EDIT_IMAGE_SOURCE => True,
"create_image_report" => True, Permissions::CREATE_IMAGE_REPORT => True,
)); ]);
``` ```
For a moderator class, being a regular user who can delete images and comments: For a moderator class, being a regular user who can delete images and comments:
```php ```php
new UserClass("moderator", "user", array( new UserClass("moderator", "user", [
"delete_image" => True, Permissions::DELETE_IMAGE => True,
"delete_comment" => True, Permissions::DELETE_COMMENT => True,
)); ]);
``` ```
For a list of permissions, see `core/userclass.class.php` For a list of permissions, see `core/permissions.php`
# Development Info # Development Info
ui-* cookies are for the client-side scripts only; in some configurations ui-\* cookies are for the client-side scripts only; in some configurations
(eg with varnish cache) they will be stripped before they reach the server (eg with varnish cache) they will be stripped before they reach the server
shm-* CSS classes are for javascript to hook into; if you're customising shm-\* CSS classes are for javascript to hook into; if you're customising
themes, be careful with these, and avoid styling them, eg: themes, be careful with these, and avoid styling them, eg:
- shm-thumb = outermost element of a thumbnail - shm-thumb = outermost element of a thumbnail
@ -141,16 +134,12 @@ themes, be careful with these, and avoid styling them, eg:
- shm-clink = a link to a comment, flash the target element when clicked - shm-clink = a link to a comment, flash the target element when clicked
* data-clink-sel * data-clink-sel
Documentation: http://shimmie.shishnet.org/doc/
Please tell me if those docs are lacking in any way, so that they can be Please tell me if those docs are lacking in any way, so that they can be
improved for the next person who uses them improved for the next person who uses them
# Contact # Contact
IRC: `#shimmie` on [Freenode](irc.freenode.net)
Email: webmaster at shishnet.org Email: webmaster at shishnet.org
Issue/Bug tracker: http://github.com/shish/shimmie2/issues Issue/Bug tracker: http://github.com/shish/shimmie2/issues

65
SPEED.md Normal file
View file

@ -0,0 +1,65 @@
Notes for any sites which require extra performance
===================================================
Image Serving
-------------
Firstly, make sure your webserver is configured properly and nice URLs are
enabled, so that images will be served straight from disk by the webserver
instead of via PHP. If you're serving images via PHP, then your site might
melt under the load of 5 concurrent users...
Add a Cache
-----------
eg installing memcached, then setting
`define("CACHE_DSN", "memcache://127.0.0.1:11211")` - a bunch of stuff will
get served from the high-speed cache instead of the SQL database.
`SPEED_HAX`
-----------
Setting this to true will make a bunch of changes which reduce the correctness
of the software and increase admin workload for the sake of speed. You almost
certainly don't want to set this, but if you do (eg you're trying to run a
site with 10,000 concurrent users on a single server), it can be a huge help.
Notable behaviour changes:
- Database schema upgrades are no longer automatic; you'll need to run
`php index.php db-upgrade` from the CLI each time you update the code.
- Mapping from Events to Extensions is cached - you'll need to delete
`data/cache/shm_event_listeners.php` after each code change, and after
enabling or disabling any extensions.
- Tag lists (eg alphabetic, popularity, map) are cached and you'll need
to delete them manually when you feel like it
- Anonymous users can only search for 3 tags at once
- We only show the first 500 pages of results for any query, except for
the most simple (no tags, or one positive tag)
- We only ever show the first 5,000 results for complex queries
- Only comments from the past 24 hours show up in /comment/list
- Web crawlers are blocked from creating too many nonsense searches
- The first 10 pages in the index get extra caching
- RSS is limited to 10 pages
- HTML for thumbnails is cached
`WH_SPLITS`
-----------
Store files as `images/ab/cd/...` instead of `images/ab/...`, which can
reduce filesystem load when you have millions of images.
Multiple Image Servers
----------------------
Image links don't have to be `/images/$hash.$ext` on the local server, they
can be full URLs, and include weighted random parts, eg:
`https://{fred=3,leo=1}.mysite.com/images/$hash.$ext` - the software will then
use consistent hashing to map 75% of the files to `fred.mysite.com` and 25% to
`leo.mysite.com` - then you can install Varnish or Squid or something as a
caching reverse-proxy.
Profiling
---------
`define()`'ing `TRACE_FILE` to a filename and `TRACE_THRESHOLD` to a number
of seconds will result in JSON event traces being dumped into that file
whenever a page takes longer than the threshold to load. These traces can
then be loaded into the chrome trace viewer (chrome://tracing/) and you'll
get a breakdown of page performance by extension, event, database, and cache
queries.

View file

@ -1,6 +1,8 @@
{ {
"name": "shish/shimmie2",
"description": "A tag-based image gallery",
"type" : "project", "type" : "project",
"license" : "GPL-2.0", "license" : "GPL-2.0-or-later",
"minimum-stability" : "dev", "minimum-stability" : "dev",
"repositories" : [ "repositories" : [
@ -23,46 +25,43 @@
], ],
"require" : { "require" : {
"php" : ">=5.6", "php" : ">=7.3",
"ext-pdo": "*",
"ext-json": "*",
"flexihash/flexihash" : "^2.0.0", "flexihash/flexihash" : "^2.0.0",
"ifixit/php-akismet" : "1.*", "ifixit/php-akismet" : "1.*",
"google/recaptcha" : "~1.1", "google/recaptcha" : "~1.1",
"dapphp/securimage" : "3.6.*", "dapphp/securimage" : "3.6.*",
"shish/eventtracer-php" : "dev-master",
"shish/ffsphp" : "0.0.*",
"shish/microcrud" : "dev-master",
"shish/microhtml" : "^1.0.0",
"enshrined/svg-sanitize" : "0.13.*",
"bower-asset/jquery" : "1.12.3", "bower-asset/jquery" : "1.12.*",
"bower-asset/jquery-timeago" : "1.5.2", "bower-asset/jquery-timeago" : "1.5.*",
"bower-asset/tablesorter" : "dev-master", "bower-asset/tablesorter" : "dev-master",
"bower-asset/mediaelement" : "2.21.1", "bower-asset/mediaelement" : "2.21.*",
"bower-asset/js-cookie" : "2.1.1" "bower-asset/js-cookie" : "2.1.*"
}, },
"require-dev" : { "require-dev" : {
"phpunit/phpunit" : "5.*" "phpunit/phpunit" : "8.*"
}, },
"vendor-copy": { "suggest": {
"vendor/bower-asset/jquery/dist/jquery.min.js" : "lib/vendor/js/jquery-1.12.3.min.js", "ext-memcache": "memcache caching",
"vendor/bower-asset/jquery/dist/jquery.min.map" : "lib/vendor/js/jquery-1.12.3.min.map", "ext-memcached": "memcached caching",
"vendor/bower-asset/jquery-timeago/jquery.timeago.js" : "lib/vendor/js/jquery.timeago.js", "ext-apc": "apc caching",
"vendor/bower-asset/tablesorter/jquery.tablesorter.min.js" : "lib/vendor/js/jquery.tablesorter.min.js", "ext-redis": "redis caching",
"vendor/bower-asset/mediaelement/build/flashmediaelement.swf" : "lib/vendor/swf/flashmediaelement.swf", "ext-dom": "some extensions",
"vendor/bower-asset/js-cookie/src/js.cookie.js" : "lib/vendor/js/js.cookie.js" "ext-curl": "some extensions",
}, "ext-ctype": "some extensions",
"ext-json": "some extensions",
"scripts": { "ext-zip": "self-updater extension",
"pre-install-cmd" : [ "ext-zlib": "anti-spam",
"php -r \"array_map('unlink', array_merge(glob('lib/vendor/js/j*.{js,map}', GLOB_BRACE), glob('lib/vendor/css/*.css'), glob('lib/vendor/swf/*.swf')));\"" "ext-xml": "some extensions",
], "ext-gd": "GD-based thumbnailing"
"pre-update-cmd" : [
"php -r \"array_map('unlink', array_merge(glob('lib/vendor/js/j*.{js,map}', GLOB_BRACE), glob('lib/vendor/css/*.css'), glob('lib/vendor/swf/*.swf')));\""
],
"post-install-cmd" : [
"php -r \"array_map('copy', array_keys(json_decode(file_get_contents('composer.json'), TRUE)['vendor-copy']), json_decode(file_get_contents('composer.json'), TRUE)['vendor-copy']);\""
],
"post-update-cmd" : [
"php -r \"array_map('copy', array_keys(json_decode(file_get_contents('composer.json'), TRUE)['vendor-copy']), json_decode(file_get_contents('composer.json'), TRUE)['vendor-copy']);\""
]
} }
} }

1225
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,51 +0,0 @@
<?php
/*
* Load all the files into memory, sanitise the environment, but don't
* actually do anything as far as the app is concerned
*/
global $config, $database, $user, $page;
require_once "core/sys_config.inc.php";
require_once "core/util.inc.php";
require_once "lib/context.php";
require_once "vendor/autoload.php";
require_once "core/imageboard.pack.php";
// set up and purify the environment
_version_check();
_sanitise_environment();
// load base files
ctx_log_start("Opening files");
$_shm_files = array_merge(
zglob("core/*.php"),
zglob("ext/{".ENABLED_EXTS."}/main.php")
);
foreach($_shm_files as $_shm_filename) {
if(basename($_shm_filename)[0] != "_") {
require_once $_shm_filename;
}
}
unset($_shm_files);
unset($_shm_filename);
ctx_log_endok();
// connect to the database
ctx_log_start("Connecting to DB");
$database = new Database();
$config = new DatabaseConfig($database);
ctx_log_endok();
// load the theme parts
ctx_log_start("Loading themelets");
foreach(_get_themelet_files(get_theme()) as $themelet) {
require_once $themelet;
}
unset($themelet);
$page = class_exists("CustomPage") ? new CustomPage() : new Page();
ctx_log_endok();
// hook up event handlers
_load_event_listeners();
send_event(new InitExtEvent());

686
core/basepage.php Normal file
View file

@ -0,0 +1,686 @@
<?php declare(strict_types=1);
require_once "core/event.php";
/**
* \page themes Themes
*
* Each extension has a theme with a specific name -- eg. the extension Setup
* which is stored in ext/setup/main.php will have a theme called SetupTheme
* stored in ext/setup/theme.php. If you want to customise it, create a class
* in the file themes/mytheme/setup.theme.php called CustomSetupTheme which
* extends SetupTheme and overrides some of its methods.
*
* Generally an extension should only deal with processing data; whenever it
* wants to display something, it should pass the data to be displayed to the
* theme object, and the theme will add the data into the global $page
* structure.
*
* A page should make sure that all the data it outputs is free from dangerous
* data by using html_escape(), url_escape(), or int_escape() as appropriate.
*
* Because some HTML can be placed anywhere according to the theme, coming up
* with the correct way to link to a page can be hard -- thus we have the
* make_link() function, which will take a path like "post/list" and turn it
* into a full and correct link, eg /myboard/post/list, /foo/index.php?q=post/list,
* etc depending on how things are set up. This should always be used to link
* to pages rather than hardcoding a path.
*
* Various other common functions are available as part of the Themelet class.
*/
abstract class PageMode
{
const REDIRECT = 'redirect';
const DATA = 'data';
const PAGE = 'page';
const FILE = 'file';
}
/**
* Class Page
*
* A data structure for holding all the bits of data that make up a page.
*
* The various extensions all add whatever they want to this structure,
* then Layout turns it into HTML.
*/
class BasePage
{
/** @var string */
public $mode = PageMode::PAGE;
/** @var string */
private $type = "text/html; charset=utf-8";
/**
* Set what this page should do; "page", "data", or "redirect".
*/
public function set_mode(string $mode): void
{
$this->mode = $mode;
}
/**
* Set the page's MIME type.
*/
public function set_type(string $type): void
{
$this->type = $type;
}
public function __construct()
{
if (@$_GET["flash"]) {
$this->flash[] = $_GET['flash'];
unset($_GET["flash"]);
}
}
// ==============================================
/** @var string; public only for unit test */
public $data = "";
/** @var string */
private $file = null;
/** @var bool */
private $file_delete = false;
/** @var string */
private $filename = null;
private $disposition = null;
/**
* Set the raw data to be sent.
*/
public function set_data(string $data): void
{
$this->data = $data;
}
public function set_file(string $file, bool $delete = false): void
{
$this->file = $file;
$this->file_delete = $delete;
}
/**
* Set the recommended download filename.
*/
public function set_filename(string $filename, string $disposition = "attachment"): void
{
$this->filename = $filename;
$this->disposition = $disposition;
}
// ==============================================
/** @var string */
public $redirect = "";
/**
* Set the URL to redirect to (remember to use make_link() if linking
* to a page in the same site).
*/
public function set_redirect(string $redirect): void
{
$this->redirect = $redirect;
}
// ==============================================
/** @var int */
public $code = 200;
/** @var string */
public $title = "";
/** @var string */
public $heading = "";
/** @var string */
public $subheading = "";
/** @var string[] */
public $html_headers = [];
/** @var string[] */
public $http_headers = [];
/** @var string[][] */
public $cookies = [];
/** @var Block[] */
public $blocks = [];
/** @var string[] */
public $flash = [];
/**
* Set the HTTP status code
*/
public function set_code(int $code): void
{
$this->code = $code;
}
public function set_title(string $title): void
{
$this->title = $title;
}
public function set_heading(string $heading): void
{
$this->heading = $heading;
}
public function set_subheading(string $subheading): void
{
$this->subheading = $subheading;
}
public function flash(string $message): void
{
$this->flash[] = $message;
}
/**
* Add a line to the HTML head section.
*/
public function add_html_header(string $line, int $position = 50): void
{
while (isset($this->html_headers[$position])) {
$position++;
}
$this->html_headers[$position] = $line;
}
/**
* Add a http header to be sent to the client.
*/
public function add_http_header(string $line, int $position = 50): void
{
while (isset($this->http_headers[$position])) {
$position++;
}
$this->http_headers[$position] = $line;
}
/**
* The counterpart for get_cookie, this works like php's
* setcookie method, but prepends the site-wide cookie prefix to
* the $name argument before doing anything.
*/
public function add_cookie(string $name, string $value, int $time, string $path): void
{
$full_name = COOKIE_PREFIX . "_" . $name;
$this->cookies[] = [$full_name, $value, $time, $path];
}
public function get_cookie(string $name): ?string
{
$full_name = COOKIE_PREFIX . "_" . $name;
if (isset($_COOKIE[$full_name])) {
return $_COOKIE[$full_name];
} else {
return null;
}
}
/**
* Get all the HTML headers that are currently set and return as a string.
*/
public function get_all_html_headers(): string
{
$data = '';
ksort($this->html_headers);
foreach ($this->html_headers as $line) {
$data .= "\t\t" . $line . "\n";
}
return $data;
}
/**
* Add a Block of data to the page.
*/
public function add_block(Block $block): void
{
$this->blocks[] = $block;
}
/**
* Find a block which contains the given text
* (Useful for unit tests)
*/
public function find_block(string $text): ?Block
{
foreach ($this->blocks as $block) {
if ($block->header == $text) {
return $block;
}
}
return null;
}
// ==============================================
/**
* Display the page according to the mode and data given.
*/
public function display(): void
{
header("HTTP/1.0 {$this->code} Shimmie");
header("Content-type: " . $this->type);
header("X-Powered-By: Shimmie-" . VERSION);
if (!headers_sent()) {
foreach ($this->http_headers as $head) {
header($head);
}
foreach ($this->cookies as $c) {
setcookie($c[0], $c[1], $c[2], $c[3]);
}
} else {
print "Error: Headers have already been sent to the client.";
}
switch ($this->mode) {
case PageMode::PAGE:
usort($this->blocks, "blockcmp");
$this->add_auto_html_headers();
$this->render();
break;
case PageMode::DATA:
header("Content-Length: " . strlen($this->data));
if (!is_null($this->filename)) {
header('Content-Disposition: ' . $this->disposition . '; filename=' . $this->filename);
}
print $this->data;
break;
case PageMode::FILE:
if (!is_null($this->filename)) {
header('Content-Disposition: ' . $this->disposition . '; filename=' . $this->filename);
}
// https://gist.github.com/codler/3906826
$size = filesize($this->file); // File size
$length = $size; // Content length
$start = 0; // Start byte
$end = $size - 1; // End byte
header("Content-Length: " . $size);
header('Accept-Ranges: bytes');
if (isset($_SERVER['HTTP_RANGE'])) {
list(, $range) = explode('=', $_SERVER['HTTP_RANGE'], 2);
if (strpos($range, ',') !== false) {
header('HTTP/1.1 416 Requested Range Not Satisfiable');
header("Content-Range: bytes $start-$end/$size");
break;
}
if ($range == '-') {
$c_start = $size - (int)substr($range, 1);
$c_end = $end;
} else {
$range = explode('-', $range);
$c_start = (int)$range[0];
$c_end = (isset($range[1]) && is_numeric($range[1])) ? (int)$range[1] : $size;
}
$c_end = ($c_end > $end) ? $end : $c_end;
if ($c_start > $c_end || $c_start > $size - 1 || $c_end >= $size) {
header('HTTP/1.1 416 Requested Range Not Satisfiable');
header("Content-Range: bytes $start-$end/$size");
break;
}
$start = $c_start;
$end = $c_end;
$length = $end - $start + 1;
header('HTTP/1.1 206 Partial Content');
}
header("Content-Range: bytes $start-$end/$size");
header("Content-Length: " . $length);
try {
stream_file($this->file, $start, $end);
} finally {
if ($this->file_delete === true) {
unlink($this->file);
}
}
break;
case PageMode::REDIRECT:
if ($this->flash) {
$this->redirect .= (strpos($this->redirect, "?") === false) ? "?" : "&";
$this->redirect .= "flash=" . url_escape(implode("\n", $this->flash));
}
header('Location: ' . $this->redirect);
print 'You should be redirected to <a href="' . $this->redirect . '">' . $this->redirect . '</a>';
break;
default:
print "Invalid page mode";
break;
}
}
/**
* This function grabs all the CSS and JavaScript files sprinkled throughout Shimmie's folders,
* concatenates them together into two large files (one for CSS and one for JS) and then stores
* them in the /cache/ directory for serving to the user.
*
* Why do this? Two reasons:
* 1. Reduces the number of files the user's browser needs to download.
* 2. Allows these cached files to be compressed/minified by the admin.
*
* TODO: This should really be configurable somehow...
*/
public function add_auto_html_headers(): void
{
global $config;
$data_href = get_base_href();
$theme_name = $config->get_string(SetupConfig::THEME, 'default');
$this->add_html_header("<script type='text/javascript'>base_href = '$data_href';</script>", 40);
# static handler will map these to themes/foo/static/bar.ico or ext/static_files/static/bar.ico
$this->add_html_header("<link rel='icon' type='image/x-icon' href='$data_href/favicon.ico'>", 41);
$this->add_html_header("<link rel='apple-touch-icon' href='$data_href/apple-touch-icon.png'>", 42);
//We use $config_latest to make sure cache is reset if config is ever updated.
$config_latest = 0;
foreach (zglob("data/config/*") as $conf) {
$config_latest = max($config_latest, filemtime($conf));
}
/*** Generate CSS cache files ***/
$css_latest = $config_latest;
$css_files = array_merge(
zglob("ext/{" . Extension::get_enabled_extensions_as_string() . "}/style.css"),
zglob("themes/$theme_name/style.css")
);
foreach ($css_files as $css) {
$css_latest = max($css_latest, filemtime($css));
}
$css_md5 = md5(serialize($css_files));
$css_cache_file = data_path("cache/style/{$theme_name}.{$css_latest}.{$css_md5}.css");
if (!file_exists($css_cache_file)) {
$css_data = "";
foreach ($css_files as $file) {
$file_data = file_get_contents($file);
$pattern = '/url[\s]*\([\s]*["\']?([^"\'\)]+)["\']?[\s]*\)/';
$replace = 'url("../../../' . dirname($file) . '/$1")';
$file_data = preg_replace($pattern, $replace, $file_data);
$css_data .= $file_data . "\n";
}
file_put_contents($css_cache_file, $css_data);
}
$this->add_html_header("<link rel='stylesheet' href='$data_href/$css_cache_file' type='text/css'>", 43);
/*** Generate JS cache files ***/
$js_latest = $config_latest;
$js_files = array_merge(
[
"vendor/bower-asset/jquery/dist/jquery.min.js",
"vendor/bower-asset/jquery-timeago/jquery.timeago.js",
"vendor/bower-asset/tablesorter/jquery.tablesorter.min.js",
"vendor/bower-asset/js-cookie/src/js.cookie.js",
"ext/static_files/modernizr-3.3.1.custom.js",
],
zglob("ext/{" . Extension::get_enabled_extensions_as_string() . "}/script.js"),
zglob("themes/$theme_name/script.js")
);
foreach ($js_files as $js) {
$js_latest = max($js_latest, filemtime($js));
}
$js_md5 = md5(serialize($js_files));
$js_cache_file = data_path("cache/script/{$theme_name}.{$js_latest}.{$js_md5}.js");
if (!file_exists($js_cache_file)) {
$js_data = "";
foreach ($js_files as $file) {
$js_data .= file_get_contents($file) . "\n";
}
file_put_contents($js_cache_file, $js_data);
}
$this->add_html_header("<script defer src='$data_href/$js_cache_file' type='text/javascript'></script>", 44);
}
protected function get_nav_links()
{
$pnbe = send_event(new PageNavBuildingEvent());
$nav_links = $pnbe->links;
$active_link = null;
// To save on event calls, we check if one of the top-level links has already been marked as active
foreach ($nav_links as $link) {
if ($link->active===true) {
$active_link = $link;
break;
}
}
$sub_links = null;
// If one is, we just query for sub-menu options under that one tab
if ($active_link!==null) {
$psnbe = send_event(new PageSubNavBuildingEvent($active_link->name));
$sub_links = $psnbe->links;
} else {
// Otherwise we query for the sub-items under each of the tabs
foreach ($nav_links as $link) {
$psnbe = send_event(new PageSubNavBuildingEvent($link->name));
// Now we check for a current link so we can identify the sub-links to show
foreach ($psnbe->links as $sub_link) {
if ($sub_link->active===true) {
$sub_links = $psnbe->links;
break;
}
}
// If the active link has been detected, we break out
if ($sub_links!==null) {
$link->active = true;
break;
}
}
}
$sub_links = $sub_links??[];
usort($nav_links, "sort_nav_links");
usort($sub_links, "sort_nav_links");
return [$nav_links, $sub_links];
}
/**
* turns the Page into HTML
*/
public function render()
{
$head_html = $this->head_html();
$body_html = $this->body_html();
print <<<EOD
<!doctype html>
<html class="no-js" lang="en">
$head_html
$body_html
</html>
EOD;
}
protected function head_html(): string
{
$html_header_html = $this->get_all_html_headers();
return "
<head>
<title>{$this->title}</title>
$html_header_html
</head>
";
}
protected function body_html(): string
{
$left_block_html = "";
$main_block_html = "";
$sub_block_html = "";
foreach ($this->blocks as $block) {
switch ($block->section) {
case "left":
$left_block_html .= $block->get_html(true);
break;
case "main":
$main_block_html .= $block->get_html(false);
break;
case "subheading":
$sub_block_html .= $block->get_html(false);
break;
default:
print "<p>error: {$block->header} using an unknown section ({$block->section})";
break;
}
}
$wrapper = "";
if (strlen($this->heading) > 100) {
$wrapper = ' style="height: 3em; overflow: auto;"';
}
$footer_html = $this->footer_html();
$flash_html = $this->flash ? "<b id='flash'>".nl2br(html_escape(implode("\n", $this->flash)))."</b>" : "";
return "
<body>
<header>
<h1$wrapper>{$this->heading}</h1>
$sub_block_html
</header>
<nav>
$left_block_html
</nav>
<article>
$flash_html
$main_block_html
</article>
<footer>
$footer_html
</footer>
</body>
";
}
protected function footer_html(): string
{
$debug = get_debug_info();
$contact_link = contact_link();
$contact = empty($contact_link) ? "" : "<br><a href='$contact_link'>Contact</a>";
return "
Images &copy; their respective owners,
<a href=\"https://code.shishnet.org/shimmie2/\">Shimmie</a> &copy;
<a href=\"https://www.shishnet.org/\">Shish</a> &amp;
<a href=\"https://github.com/shish/shimmie2/graphs/contributors\">The Team</a>
2007-2020,
based on the Danbooru concept.
$debug
$contact
";
}
}
class PageNavBuildingEvent extends Event
{
public $links = [];
public function add_nav_link(string $name, Link $link, string $desc, ?bool $active = null, int $order = 50)
{
$this->links[] = new NavLink($name, $link, $desc, $active, $order);
}
}
class PageSubNavBuildingEvent extends Event
{
public $parent;
public $links = [];
public function __construct(string $parent)
{
parent::__construct();
$this->parent= $parent;
}
public function add_nav_link(string $name, Link $link, string $desc, ?bool $active = null, int $order = 50)
{
$this->links[] = new NavLink($name, $link, $desc, $active, $order);
}
}
class NavLink
{
public $name;
public $link;
public $description;
public $order;
public $active = false;
public function __construct(String $name, Link $link, String $description, ?bool $active = null, int $order = 50)
{
global $config;
$this->name = $name;
$this->link = $link;
$this->description = $description;
$this->order = $order;
if ($active==null) {
$query = ltrim(_get_query(), "/");
if ($query === "") {
// This indicates the front page, so we check what's set as the front page
$front_page = trim($config->get_string(SetupConfig::FRONT_PAGE), "/");
if ($front_page === $link->page) {
$this->active = true;
} else {
$this->active = self::is_active([$link->page], $front_page);
}
} elseif ($query===$link->page) {
$this->active = true;
} else {
$this->active = self::is_active([$link->page]);
}
} else {
$this->active = $active;
}
}
public static function is_active(array $pages_matched, string $url = null): bool
{
/**
* Woo! We can actually SEE THE CURRENT PAGE!! (well... see it highlighted in the menu.)
*/
$url = $url??ltrim(_get_query(), "/");
$re1='.*?';
$re2='((?:[a-z][a-z_]+))';
if (preg_match_all("/".$re1.$re2."/is", $url, $matches)) {
$url=$matches[1][0];
}
$count_pages_matched = count($pages_matched);
for ($i=0; $i < $count_pages_matched; $i++) {
if ($url == $pages_matched[$i]) {
return true;
}
}
return false;
}
}
function sort_nav_links(NavLink $a, NavLink $b)
{
return $a->order - $b->order;
}

View file

@ -1,166 +0,0 @@
<?php
/**
* Class BaseThemelet
*
* A collection of common functions for theme parts
*/
class BaseThemelet {
/**
* Generic error message display
*
* @param int $code
* @param string $title
* @param string $message
*/
public function display_error(/*int*/ $code, /*string*/ $title, /*string*/ $message) {
global $page;
$page->set_code($code);
$page->set_title($title);
$page->set_heading($title);
$has_nav = false;
foreach($page->blocks as $block) {
if($block->header == "Navigation") {
$has_nav = true;
break;
}
}
if(!$has_nav) {
$page->add_block(new NavBlock());
}
$page->add_block(new Block("Error", $message));
}
/**
* A specific, common error message
*/
public function display_permission_denied() {
$this->display_error(403, "Permission Denied", "You do not have permission to access this page");
}
/**
* Generic thumbnail code; returns HTML rather than adding
* a block since thumbs tend to go inside blocks...
*
* @param Image $image
* @return string
*/
public function build_thumb_html(Image $image) {
global $config;
$i_id = (int) $image->id;
$h_view_link = make_link('post/view/'.$i_id);
$h_thumb_link = $image->get_thumb_link();
$h_tip = html_escape($image->get_tooltip());
$h_tags = html_escape(strtolower($image->get_tag_list()));
$extArr = array_flip(array('swf', 'svg', 'mp3')); //List of thumbless filetypes
if(!isset($extArr[$image->ext])){
$tsize = get_thumbnail_size($image->width, $image->height);
}else{
//Use max thumbnail size if using thumbless filetype
$tsize = get_thumbnail_size($config->get_int('thumb_width'), $config->get_int('thumb_height'));
}
$custom_classes = "";
if(class_exists("Relationships")){
if(property_exists($image, 'parent_id') && $image->parent_id !== NULL){ $custom_classes .= "shm-thumb-has_parent "; }
if(property_exists($image, 'has_children') && bool_escape($image->has_children)){ $custom_classes .= "shm-thumb-has_child "; }
}
return "<a href='$h_view_link' class='thumb shm-thumb shm-thumb-link {$custom_classes}' data-tags='$h_tags' data-post-id='$i_id'>".
"<img id='thumb_$i_id' title='$h_tip' alt='$h_tip' height='{$tsize[1]}' width='{$tsize[0]}' src='$h_thumb_link'>".
"</a>\n";
}
/**
* Add a generic paginator.
*
* @param Page $page
* @param string $base
* @param string $query
* @param int $page_number
* @param int $total_pages
* @param bool $show_random
*/
public function display_paginator(Page $page, $base, $query, $page_number, $total_pages, $show_random = FALSE) {
if($total_pages == 0) $total_pages = 1;
$body = $this->build_paginator($page_number, $total_pages, $base, $query, $show_random);
$page->add_block(new Block(null, $body, "main", 90, "paginator"));
}
/**
* Generate a single HTML link.
*
* @param string $base_url
* @param string $query
* @param string $page
* @param string $name
* @return string
*/
private function gen_page_link($base_url, $query, $page, $name) {
$link = make_link($base_url.'/'.$page, $query);
return '<a href="'.$link.'">'.$name.'</a>';
}
/**
* @param string $base_url
* @param string $query
* @param string $page
* @param int $current_page
* @param string $name
* @return string
*/
private function gen_page_link_block($base_url, $query, $page, $current_page, $name) {
$paginator = "";
if($page == $current_page) $paginator .= "<b>";
$paginator .= $this->gen_page_link($base_url, $query, $page, $name);
if($page == $current_page) $paginator .= "</b>";
return $paginator;
}
/**
* Build the paginator.
*
* @param int $current_page
* @param int $total_pages
* @param string $base_url
* @param string $query
* @param bool $show_random
* @return string
*/
private function build_paginator($current_page, $total_pages, $base_url, $query, $show_random) {
$next = $current_page + 1;
$prev = $current_page - 1;
$at_start = ($current_page <= 1 || $total_pages <= 1);
$at_end = ($current_page >= $total_pages);
$first_html = $at_start ? "First" : $this->gen_page_link($base_url, $query, 1, "First");
$prev_html = $at_start ? "Prev" : $this->gen_page_link($base_url, $query, $prev, "Prev");
$random_html = "-";
if($show_random) {
$rand = mt_rand(1, $total_pages);
$random_html = $this->gen_page_link($base_url, $query, $rand, "Random");
}
$next_html = $at_end ? "Next" : $this->gen_page_link($base_url, $query, $next, "Next");
$last_html = $at_end ? "Last" : $this->gen_page_link($base_url, $query, $total_pages, "Last");
$start = $current_page-5 > 1 ? $current_page-5 : 1;
$end = $start+10 < $total_pages ? $start+10 : $total_pages;
$pages = array();
foreach(range($start, $end) as $i) {
$pages[] = $this->gen_page_link_block($base_url, $query, $i, $current_page, $i);
}
$pages_html = implode(" | ", $pages);
return $first_html.' | '.$prev_html.' | '.$random_html.' | '.$next_html.' | '.$last_html
.'<br>&lt;&lt; '.$pages_html.' &gt;&gt;';
}
}

149
core/basethemelet.php Normal file
View file

@ -0,0 +1,149 @@
<?php declare(strict_types=1);
/**
* Class BaseThemelet
*
* A collection of common functions for theme parts
*/
class BaseThemelet
{
/**
* Generic error message display
*/
public function display_error(int $code, string $title, string $message): void
{
global $page;
$page->set_code($code);
$page->set_title($title);
$page->set_heading($title);
$has_nav = false;
foreach ($page->blocks as $block) {
if ($block->header == "Navigation") {
$has_nav = true;
break;
}
}
if (!$has_nav) {
$page->add_block(new NavBlock());
}
$page->add_block(new Block("Error", $message));
}
/**
* A specific, common error message
*/
public function display_permission_denied(): void
{
$this->display_error(403, "Permission Denied", "You do not have permission to access this page");
}
/**
* Generic thumbnail code; returns HTML rather than adding
* a block since thumbs tend to go inside blocks...
*/
public function build_thumb_html(Image $image): string
{
global $config;
$i_id = (int) $image->id;
$h_view_link = make_link('post/view/'.$i_id);
$h_thumb_link = $image->get_thumb_link();
$h_tip = html_escape($image->get_tooltip());
$h_tags = html_escape(strtolower($image->get_tag_list()));
$extArr = array_flip(['swf', 'svg', 'mp3']); //List of thumbless filetypes
if (!isset($extArr[$image->ext])) {
$tsize = get_thumbnail_size($image->width, $image->height);
} else {
//Use max thumbnail size if using thumbless filetype
$tsize = get_thumbnail_size($config->get_int(ImageConfig::THUMB_WIDTH), $config->get_int(ImageConfig::THUMB_WIDTH));
}
$custom_classes = "";
if (class_exists("Relationships")) {
if (property_exists($image, 'parent_id') && $image->parent_id !== null) {
$custom_classes .= "shm-thumb-has_parent ";
}
if (property_exists($image, 'has_children') && bool_escape($image->has_children)) {
$custom_classes .= "shm-thumb-has_child ";
}
}
return "<a href='$h_view_link' class='thumb shm-thumb shm-thumb-link {$custom_classes}' data-tags='$h_tags' data-post-id='$i_id'>".
"<img id='thumb_$i_id' title='$h_tip' alt='$h_tip' height='{$tsize[1]}' width='{$tsize[0]}' src='$h_thumb_link'>".
"</a>\n";
}
public function display_paginator(Page $page, string $base, ?string $query, int $page_number, int $total_pages, bool $show_random = false)
{
if ($total_pages == 0) {
$total_pages = 1;
}
$body = $this->build_paginator($page_number, $total_pages, $base, $query, $show_random);
$page->add_block(new Block(null, $body, "main", 90, "paginator"));
$page->add_html_header("<link rel='first' href='".make_http(make_link($base.'/1', $query))."'>");
if ($page_number < $total_pages) {
$page->add_html_header("<link rel='prefetch' href='".make_http(make_link($base.'/'.($page_number+1), $query))."'>");
$page->add_html_header("<link rel='next' href='".make_http(make_link($base.'/'.($page_number+1), $query))."'>");
}
if ($page_number > 1) {
$page->add_html_header("<link rel='previous' href='".make_http(make_link($base.'/'.($page_number-1), $query))."'>");
}
$page->add_html_header("<link rel='last' href='".make_http(make_link($base.'/'.$total_pages, $query))."'>");
}
private function gen_page_link(string $base_url, ?string $query, int $page, string $name): string
{
$link = make_link($base_url.'/'.$page, $query);
return '<a href="'.$link.'">'.$name.'</a>';
}
private function gen_page_link_block(string $base_url, ?string $query, int $page, int $current_page, string $name): string
{
$paginator = "";
if ($page == $current_page) {
$paginator .= "<b>";
}
$paginator .= $this->gen_page_link($base_url, $query, $page, $name);
if ($page == $current_page) {
$paginator .= "</b>";
}
return $paginator;
}
private function build_paginator(int $current_page, int $total_pages, string $base_url, ?string $query, bool $show_random): string
{
$next = $current_page + 1;
$prev = $current_page - 1;
$at_start = ($current_page <= 1 || $total_pages <= 1);
$at_end = ($current_page >= $total_pages);
$first_html = $at_start ? "First" : $this->gen_page_link($base_url, $query, 1, "First");
$prev_html = $at_start ? "Prev" : $this->gen_page_link($base_url, $query, $prev, "Prev");
$random_html = "-";
if ($show_random) {
$rand = mt_rand(1, $total_pages);
$random_html = $this->gen_page_link($base_url, $query, $rand, "Random");
}
$next_html = $at_end ? "Next" : $this->gen_page_link($base_url, $query, $next, "Next");
$last_html = $at_end ? "Last" : $this->gen_page_link($base_url, $query, $total_pages, "Last");
$start = $current_page-5 > 1 ? $current_page-5 : 1;
$end = $start+10 < $total_pages ? $start+10 : $total_pages;
$pages = [];
foreach (range($start, $end) as $i) {
$pages[] = $this->gen_page_link_block($base_url, $query, $i, $current_page, (string)$i);
}
$pages_html = implode(" | ", $pages);
return $first_html.' | '.$prev_html.' | '.$random_html.' | '.$next_html.' | '.$last_html
.'<br>&lt;&lt; '.$pages_html.' &gt;&gt;';
}
}

View file

@ -1,108 +0,0 @@
<?php
/**
* Class Block
*
* A basic chunk of a page.
*/
class Block {
/**
* The block's title.
*
* @var string
*/
public $header;
/**
* The content of the block.
*
* @var string
*/
public $body;
/**
* Where the block should be placed. The default theme supports
* "main" and "left", other themes can add their own areas.
*
* @var string
*/
public $section;
/**
* How far down the section the block should appear, higher
* numbers appear lower. The scale is 0-100 by convention,
* though any number or string will work.
*
* @var int
*/
public $position;
/**
* A unique ID for the block.
*
* @var string
*/
public $id;
/**
* Should this block count as content for the sake of
* the 404 handler
*
* @var boolean
*/
public $is_content = true;
/**
* Construct a block.
*
* @param string $header
* @param string $body
* @param string $section
* @param int $position
* @param null|int $id A unique ID for the block (generated automatically if null).
*/
public function __construct($header, $body, /*string*/ $section="main", /*int*/ $position=50, $id=null) {
$this->header = $header;
$this->body = $body;
$this->section = $section;
$this->position = $position;
if(is_null($id)) {
$id = (empty($header) ? md5($body) : $header) . $section;
}
$this->id = preg_replace('/[^\w]/', '',str_replace(' ', '_', $id));
}
/**
* Get the HTML for this block.
*
* @param bool $hidable
* @return string
*/
public function get_html($hidable=false) {
$h = $this->header;
$b = $this->body;
$i = $this->id;
$html = "<section id='$i'>";
$h_toggler = $hidable ? " shm-toggler" : "";
if(!empty($h)) $html .= "<h3 data-toggle-sel='#$i' class='$h_toggler'>$h</h3>";
if(!empty($b)) $html .= "<div class='blockbody'>$b</div>";
$html .= "</section>\n";
return $html;
}
}
/**
* Class NavBlock
*
* A generic navigation block with a link to the main page.
*
* Used because "new NavBlock()" is easier than "new Block('Navigation', ..."
*
*/
class NavBlock extends Block {
public function __construct() {
parent::__construct("Navigation", "<a href='".make_link()."'>Index</a>", "left", 0);
}
}

105
core/block.php Normal file
View file

@ -0,0 +1,105 @@
<?php declare(strict_types=1);
/**
* Class Block
*
* A basic chunk of a page.
*/
class Block
{
/**
* The block's title.
*
* @var string
*/
public $header;
/**
* The content of the block.
*
* @var string
*/
public $body;
/**
* Where the block should be placed. The default theme supports
* "main" and "left", other themes can add their own areas.
*
* @var string
*/
public $section;
/**
* How far down the section the block should appear, higher
* numbers appear lower. The scale is 0-100 by convention,
* though any number will work.
*
* @var int
*/
public $position;
/**
* A unique ID for the block.
*
* @var string
*/
public $id;
/**
* Should this block count as content for the sake of
* the 404 handler
*
* @var boolean
*/
public $is_content = true;
public function __construct(string $header=null, string $body=null, string $section="main", int $position=50, string $id=null)
{
$this->header = $header;
$this->body = $body;
$this->section = $section;
$this->position = $position;
if (is_null($id)) {
$id = (empty($header) ? md5($body ?? '') : $header) . $section;
}
$this->id = preg_replace('/[^\w-]/', '', str_replace(' ', '_', $id));
}
/**
* Get the HTML for this block.
*/
public function get_html(bool $hidable=false): string
{
$h = $this->header;
$b = $this->body;
$i = $this->id;
$html = "<section id='$i'>";
$h_toggler = $hidable ? " shm-toggler" : "";
if (!empty($h)) {
$html .= "<h3 data-toggle-sel='#$i' class='$h_toggler'>$h</h3>";
}
if (!empty($b)) {
$html .= "<div class='blockbody'>$b</div>";
}
$html .= "</section>\n";
return $html;
}
}
/**
* Class NavBlock
*
* A generic navigation block with a link to the main page.
*
* Used because "new NavBlock()" is easier than "new Block('Navigation', ..."
*
*/
class NavBlock extends Block
{
public function __construct()
{
parent::__construct("Navigation", "<a href='".make_link()."'>Index</a>", "left", 0);
}
}

199
core/cacheengine.php Normal file
View file

@ -0,0 +1,199 @@
<?php declare(strict_types=1);
interface CacheEngine
{
public function get(string $key);
public function set(string $key, $val, int $time=0);
public function delete(string $key);
}
class NoCache implements CacheEngine
{
public function get(string $key)
{
return false;
}
public function set(string $key, $val, int $time=0)
{
}
public function delete(string $key)
{
}
}
class MemcachedCache implements CacheEngine
{
/** @var ?Memcached */
public $memcache=null;
public function __construct(string $args)
{
$hp = explode(":", $args);
$this->memcache = new Memcached;
#$this->memcache->setOption(Memcached::OPT_COMPRESSION, False);
#$this->memcache->setOption(Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_PHP);
#$this->memcache->setOption(Memcached::OPT_PREFIX_KEY, phpversion());
$this->memcache->addServer($hp[0], (int)$hp[1]);
}
public function get(string $key)
{
$key = urlencode($key);
$val = $this->memcache->get($key);
$res = $this->memcache->getResultCode();
if ($res == Memcached::RES_SUCCESS) {
return $val;
} elseif ($res == Memcached::RES_NOTFOUND) {
return false;
} else {
error_log("Memcached error during get($key): $res");
return false;
}
}
public function set(string $key, $val, int $time=0)
{
$key = urlencode($key);
$this->memcache->set($key, $val, $time);
$res = $this->memcache->getResultCode();
if ($res != Memcached::RES_SUCCESS) {
error_log("Memcached error during set($key): $res");
}
}
public function delete(string $key)
{
$key = urlencode($key);
$this->memcache->delete($key);
$res = $this->memcache->getResultCode();
if ($res != Memcached::RES_SUCCESS && $res != Memcached::RES_NOTFOUND) {
error_log("Memcached error during delete($key): $res");
}
}
}
class APCCache implements CacheEngine
{
public function __construct(string $args)
{
// $args is not used, but is passed in when APC cache is created.
}
public function get(string $key)
{
return apc_fetch($key);
}
public function set(string $key, $val, int $time=0)
{
apc_store($key, $val, $time);
}
public function delete(string $key)
{
apc_delete($key);
}
}
class RedisCache implements CacheEngine
{
private $redis=null;
public function __construct(string $args)
{
$this->redis = new Redis();
$hp = explode(":", $args);
$this->redis->pconnect($hp[0], (int)$hp[1]);
$this->redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_PHP);
$this->redis->setOption(Redis::OPT_PREFIX, 'shm:');
}
public function get(string $key)
{
return $this->redis->get($key);
}
public function set(string $key, $val, int $time=0)
{
if ($time > 0) {
$this->redis->setEx($key, $time, $val);
} else {
$this->redis->set($key, $val);
}
}
public function delete(string $key)
{
$this->redis->del($key);
}
}
class Cache
{
public $engine;
public $hits=0;
public $misses=0;
public $time=0;
public function __construct(?string $dsn)
{
$matches = [];
$c = null;
if ($dsn && preg_match("#(.*)://(.*)#", $dsn, $matches) && !isset($_GET['DISABLE_CACHE'])) {
if ($matches[1] == "memcached") {
$c = new MemcachedCache($matches[2]);
} elseif ($matches[1] == "apc") {
$c = new APCCache($matches[2]);
} elseif ($matches[1] == "redis") {
$c = new RedisCache($matches[2]);
}
} else {
$c = new NoCache();
}
$this->engine = $c;
}
public function get(string $key)
{
global $_tracer;
$_tracer->begin("Cache Query", ["key"=>$key]);
$val = $this->engine->get($key);
if ($val !== false) {
$res = "hit";
$this->hits++;
} else {
$res = "miss";
$this->misses++;
}
$_tracer->end(null, ["result"=>$res]);
return $val;
}
public function set(string $key, $val, int $time=0)
{
global $_tracer;
$_tracer->begin("Cache Set", ["key"=>$key, "time"=>$time]);
$this->engine->set($key, $val, $time);
$_tracer->end();
}
public function delete(string $key)
{
global $_tracer;
$_tracer->begin("Cache Delete", ["key"=>$key]);
$this->engine->delete($key);
$_tracer->end();
}
public function get_hits(): int
{
return $this->hits;
}
public function get_misses(): int
{
return $this->misses;
}
}

60
core/captcha.php Normal file
View file

@ -0,0 +1,60 @@
<?php declare(strict_types=1);
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\
* CAPTCHA abstraction *
\* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
use ReCaptcha\ReCaptcha;
function captcha_get_html(): string
{
global $config, $user;
if (DEBUG && ip_in_range($_SERVER['REMOTE_ADDR'], "127.0.0.0/8")) {
return "";
}
$captcha = "";
if ($user->is_anonymous() && $config->get_bool("comment_captcha")) {
$r_publickey = $config->get_string("api_recaptcha_pubkey");
if (!empty($r_publickey)) {
$captcha = "
<div class=\"g-recaptcha\" data-sitekey=\"{$r_publickey}\"></div>
<script type=\"text/javascript\" src=\"https://www.google.com/recaptcha/api.js\"></script>";
} else {
session_start();
$captcha = Securimage::getCaptchaHtml(['securimage_path' => './vendor/dapphp/securimage/']);
}
}
return $captcha;
}
function captcha_check(): bool
{
global $config, $user;
if (DEBUG && ip_in_range($_SERVER['REMOTE_ADDR'], "127.0.0.0/8")) {
return true;
}
if ($user->is_anonymous() && $config->get_bool("comment_captcha")) {
$r_privatekey = $config->get_string('api_recaptcha_privkey');
if (!empty($r_privatekey)) {
$recaptcha = new ReCaptcha($r_privatekey);
$resp = $recaptcha->verify($_POST['g-recaptcha-response'], $_SERVER['REMOTE_ADDR']);
if (!$resp->isSuccess()) {
log_info("core", "Captcha failed (ReCaptcha): " . implode("", $resp->getErrorCodes()));
return false;
}
} else {
session_start();
$securimg = new Securimage();
if ($securimg->check($_POST['captcha_code']) === false) {
log_info("core", "Captcha failed (Securimage)");
return false;
}
}
}
return true;
}

View file

@ -1,429 +0,0 @@
<?php
/**
* Interface Config
*
* An abstract interface for altering a name:value pair list.
*/
interface Config {
/**
* Save the list of name:value pairs to wherever they came from,
* so that the next time a page is loaded it will use the new
* configuration.
*
* @param null|string $name
* @return mixed|void
*/
public function save(/*string*/ $name=null);
//@{ /*--------------------------------- SET ------------------------------------------------------*/
/**
* Set a configuration option to a new value, regardless of what the value is at the moment.
* @param string $name
* @param null|int $value
* @return void
*/
public function set_int(/*string*/ $name, $value);
/**
* Set a configuration option to a new value, regardless of what the value is at the moment.
* @param string $name
* @param null|string $value
* @return void
*/
public function set_string(/*string*/ $name, $value);
/**
* Set a configuration option to a new value, regardless of what the value is at the moment.
* @param string $name
* @param null|bool|string $value
* @return void
*/
public function set_bool(/*string*/ $name, $value);
/**
* Set a configuration option to a new value, regardless of what the value is at the moment.
* @param string $name
* @param array $value
* @return void
*/
public function set_array(/*string*/ $name, $value);
//@} /*--------------------------------------------------------------------------------------------*/
//@{ /*-------------------------------- SET DEFAULT -----------------------------------------------*/
/**
* Set a configuration option to a new value, if there is no value currently.
*
* Extensions should generally call these from their InitExtEvent handlers.
* This has the advantage that the values will show up in the "advanced" setup
* page where they can be modified, while calling get_* with a "default"
* parameter won't show up.
*
* @param string $name
* @param int $value
* @return void
*/
public function set_default_int(/*string*/ $name, $value);
/**
* Set a configuration option to a new value, if there is no value currently.
*
* Extensions should generally call these from their InitExtEvent handlers.
* This has the advantage that the values will show up in the "advanced" setup
* page where they can be modified, while calling get_* with a "default"
* parameter won't show up.
*
* @param string $name
* @param string|null $value
* @return void
*/
public function set_default_string(/*string*/ $name, $value);
/**
* Set a configuration option to a new value, if there is no value currently.
*
* Extensions should generally call these from their InitExtEvent handlers.
* This has the advantage that the values will show up in the "advanced" setup
* page where they can be modified, while calling get_* with a "default"
* parameter won't show up.
*
* @param string $name
* @param bool $value
* @return void
*/
public function set_default_bool(/*string*/ $name, /*bool*/ $value);
/**
* Set a configuration option to a new value, if there is no value currently.
*
* Extensions should generally call these from their InitExtEvent handlers.
* This has the advantage that the values will show up in the "advanced" setup
* page where they can be modified, while calling get_* with a "default"
* parameter won't show up.
*
* @param string $name
* @param array $value
* @return void
*/
public function set_default_array(/*string*/ $name, $value);
//@} /*--------------------------------------------------------------------------------------------*/
//@{ /*--------------------------------- GET ------------------------------------------------------*/
/**
* Pick a value out of the table by name, cast to the appropriate data type.
* @param string $name
* @param null|int $default
* @return int
*/
public function get_int(/*string*/ $name, $default=null);
/**
* Pick a value out of the table by name, cast to the appropriate data type.
* @param string $name
* @param null|string $default
* @return string
*/
public function get_string(/*string*/ $name, $default=null);
/**
* Pick a value out of the table by name, cast to the appropriate data type.
* @param string $name
* @param null|bool|string $default
* @return bool
*/
public function get_bool(/*string*/ $name, $default=null);
/**
* Pick a value out of the table by name, cast to the appropriate data type.
* @param string $name
* @param array|null $default
* @return array
*/
public function get_array(/*string*/ $name, $default=array());
//@} /*--------------------------------------------------------------------------------------------*/
}
/**
* Class BaseConfig
*
* Common methods for manipulating the list, loading and saving is
* left to the concrete implementation
*/
abstract class BaseConfig implements Config {
public $values = array();
/**
* @param string $name
* @param int|null $value
* @return void
*/
public function set_int(/*string*/ $name, $value) {
$this->values[$name] = parse_shorthand_int($value);
$this->save($name);
}
/**
* @param string $name
* @param null|string $value
* @return void
*/
public function set_string(/*string*/ $name, $value) {
$this->values[$name] = $value;
$this->save($name);
}
/**
* @param string $name
* @param bool|null|string $value
* @return void
*/
public function set_bool(/*string*/ $name, $value) {
$this->values[$name] = (($value == 'on' || $value === true) ? 'Y' : 'N');
$this->save($name);
}
/**
* @param string $name
* @param array $value
* @return void
*/
public function set_array(/*string*/ $name, $value) {
assert(isset($value) && is_array($value));
$this->values[$name] = implode(",", $value);
$this->save($name);
}
/**
* @param string $name
* @param int $value
* @return void
*/
public function set_default_int(/*string*/ $name, $value) {
if(is_null($this->get($name))) {
$this->values[$name] = parse_shorthand_int($value);
}
}
/**
* @param string $name
* @param null|string $value
* @return void
*/
public function set_default_string(/*string*/ $name, $value) {
if(is_null($this->get($name))) {
$this->values[$name] = $value;
}
}
/**
* @param string $name
* @param bool $value
* @return void
*/
public function set_default_bool(/*string*/ $name, /*bool*/ $value) {
if(is_null($this->get($name))) {
$this->values[$name] = (($value == 'on' || $value === true) ? 'Y' : 'N');
}
}
/**
* @param string $name
* @param array $value
* @return void
*/
public function set_default_array(/*string*/ $name, $value) {
assert(isset($value) && is_array($value));
if(is_null($this->get($name))) {
$this->values[$name] = implode(",", $value);
}
}
/**
* @param string $name
* @param null|int $default
* @return int
*/
public function get_int(/*string*/ $name, $default=null) {
return (int)($this->get($name, $default));
}
/**
* @param string $name
* @param null|string $default
* @return null|string
*/
public function get_string(/*string*/ $name, $default=null) {
return $this->get($name, $default);
}
/**
* @param string $name
* @param null|bool|string $default
* @return bool
*/
public function get_bool(/*string*/ $name, $default=null) {
return bool_escape($this->get($name, $default));
}
/**
* @param string $name
* @param array $default
* @return array
*/
public function get_array(/*string*/ $name, $default=array()) {
return explode(",", $this->get($name, ""));
}
/**
* @param string $name
* @param null|mixed $default
* @return null|mixed
*/
private function get(/*string*/ $name, $default=null) {
if(isset($this->values[$name])) {
return $this->values[$name];
}
else {
return $default;
}
}
}
/**
* Class HardcodeConfig
*
* For testing, mostly.
*/
class HardcodeConfig extends BaseConfig {
public function __construct($dict) {
$this->values = $dict;
}
/**
* @param null|string $name
* @return mixed|void
*/
public function save(/*string*/ $name=null) {
// static config is static
}
}
/**
* Class StaticConfig
*
* Loads the config list from a PHP file; the file should be in the format:
*
* <?php
* $config['foo'] = "bar";
* $config['baz'] = "qux";
* ?>
*/
class StaticConfig extends BaseConfig {
/**
* @param string $filename
* @throws Exception
*/
public function __construct($filename) {
if(file_exists($filename)) {
$config = array();
require_once $filename;
if(!empty($config)) {
$this->values = $config;
}
else {
throw new Exception("Config file '$filename' doesn't contain any config");
}
}
else {
throw new Exception("Config file '$filename' missing");
}
}
/**
* @param null|string $name
* @return mixed|void
*/
public function save(/*string*/ $name=null) {
// static config is static
}
}
/**
* Class DatabaseConfig
*
* Loads the config list from a table in a given database, the table should
* be called config and have the schema:
*
* \code
* CREATE TABLE config(
* name VARCHAR(255) NOT NULL,
* value TEXT
* );
* \endcode
*/
class DatabaseConfig extends BaseConfig {
/** @var Database */
private $database = null;
/**
* Load the config table from a database.
*
* @param Database $database
*/
public function __construct(Database $database) {
$this->database = $database;
$cached = $this->database->cache->get("config");
if($cached) {
$this->values = $cached;
}
else {
$this->values = array();
foreach($this->database->get_all("SELECT name, value FROM config") as $row) {
$this->values[$row["name"]] = $row["value"];
}
$this->database->cache->set("config", $this->values);
}
}
/**
* Save the current values as the new config table.
*
* @param null|string $name
* @return mixed|void
*/
public function save(/*string*/ $name=null) {
if(is_null($name)) {
reset($this->values); // rewind the array to the first element
foreach($this->values as $name => $value) {
$this->save(/*string*/ $name);
}
}
else {
$this->database->Execute("DELETE FROM config WHERE name = :name", array("name"=>$name));
$this->database->Execute("INSERT INTO config VALUES (:name, :value)", array("name"=>$name, "value"=>$this->values[$name]));
}
// rather than deleting and having some other request(s) do a thundering
// herd of race-conditioned updates, just save the updated version once here
$this->database->cache->set("config", $this->values);
}
}
/**
* Class MockConfig
*/
class MockConfig extends HardcodeConfig {
/**
* @param array $config
*/
public function __construct($config=array()) {
$config["db_version"] = "999";
$config["anon_id"] = "0";
parent::__construct($config);
}
}

338
core/config.php Normal file
View file

@ -0,0 +1,338 @@
<?php declare(strict_types=1);
/**
* Interface Config
*
* An abstract interface for altering a name:value pair list.
*/
interface Config
{
/**
* Save the list of name:value pairs to wherever they came from,
* so that the next time a page is loaded it will use the new
* configuration.
*/
public function save(string $name=null): void;
//@{ /*--------------------------------- SET ------------------------------------------------------*/
/**
* Set a configuration option to a new value, regardless of what the value is at the moment.
*/
public function set_int(string $name, ?int $value): void;
/**
* Set a configuration option to a new value, regardless of what the value is at the moment.
*/
public function set_float(string $name, ?float $value): void;
/**
* Set a configuration option to a new value, regardless of what the value is at the moment.
*/
public function set_string(string $name, ?string $value): void;
/**
* Set a configuration option to a new value, regardless of what the value is at the moment.
*/
public function set_bool(string $name, ?bool $value): void;
/**
* Set a configuration option to a new value, regardless of what the value is at the moment.
*/
public function set_array(string $name, array $value): void;
//@} /*--------------------------------------------------------------------------------------------*/
//@{ /*-------------------------------- SET DEFAULT -----------------------------------------------*/
/**
* Set a configuration option to a new value, if there is no value currently.
*
* Extensions should generally call these from their InitExtEvent handlers.
* This has the advantage that the values will show up in the "advanced" setup
* page where they can be modified, while calling get_* with a "default"
* parameter won't show up.
*/
public function set_default_int(string $name, int $value): void;
/**
* Set a configuration option to a new value, if there is no value currently.
*
* Extensions should generally call these from their InitExtEvent handlers.
* This has the advantage that the values will show up in the "advanced" setup
* page where they can be modified, while calling get_* with a "default"
* parameter won't show up.
*/
public function set_default_float(string $name, float $value): void;
/**
* Set a configuration option to a new value, if there is no value currently.
*
* Extensions should generally call these from their InitExtEvent handlers.
* This has the advantage that the values will show up in the "advanced" setup
* page where they can be modified, while calling get_* with a "default"
* parameter won't show up.
*/
public function set_default_string(string $name, string $value): void;
/**
* Set a configuration option to a new value, if there is no value currently.
*
* Extensions should generally call these from their InitExtEvent handlers.
* This has the advantage that the values will show up in the "advanced" setup
* page where they can be modified, while calling get_* with a "default"
* parameter won't show up.
*/
public function set_default_bool(string $name, bool $value): void;
/**
* Set a configuration option to a new value, if there is no value currently.
*
* Extensions should generally call these from their InitExtEvent handlers.
* This has the advantage that the values will show up in the "advanced" setup
* page where they can be modified, while calling get_* with a "default"
* parameter won't show up.
*/
public function set_default_array(string $name, array $value): void;
//@} /*--------------------------------------------------------------------------------------------*/
//@{ /*--------------------------------- GET ------------------------------------------------------*/
/**
* Pick a value out of the table by name, cast to the appropriate data type.
*/
public function get_int(string $name, ?int $default=null): ?int;
/**
* Pick a value out of the table by name, cast to the appropriate data type.
*/
public function get_float(string $name, ?float $default=null): ?float;
/**
* Pick a value out of the table by name, cast to the appropriate data type.
*/
public function get_string(string $name, ?string $default=null): ?string;
/**
* Pick a value out of the table by name, cast to the appropriate data type.
*/
public function get_bool(string $name, ?bool $default=null): ?bool;
/**
* Pick a value out of the table by name, cast to the appropriate data type.
*/
public function get_array(string $name, ?array $default=[]): ?array;
//@} /*--------------------------------------------------------------------------------------------*/
}
/**
* Class BaseConfig
*
* Common methods for manipulating the list, loading and saving is
* left to the concrete implementation
*/
abstract class BaseConfig implements Config
{
public $values = [];
public function set_int(string $name, ?int $value): void
{
$this->values[$name] = is_null($value) ? null : $value;
$this->save($name);
}
public function set_float(string $name, ?float $value): void
{
$this->values[$name] = $value;
$this->save($name);
}
public function set_string(string $name, ?string $value): void
{
$this->values[$name] = $value;
$this->save($name);
}
public function set_bool(string $name, ?bool $value): void
{
$this->values[$name] = $value ? 'Y' : 'N';
$this->save($name);
}
public function set_array(string $name, ?array $value): void
{
if ($value!=null) {
$this->values[$name] = implode(",", $value);
} else {
$this->values[$name] = null;
}
$this->save($name);
}
public function set_default_int(string $name, int $value): void
{
if (is_null($this->get($name))) {
$this->values[$name] = $value;
}
}
public function set_default_float(string $name, float $value): void
{
if (is_null($this->get($name))) {
$this->values[$name] = $value;
}
}
public function set_default_string(string $name, string $value): void
{
if (is_null($this->get($name))) {
$this->values[$name] = $value;
}
}
public function set_default_bool(string $name, bool $value): void
{
if (is_null($this->get($name))) {
$this->values[$name] = $value ? 'Y' : 'N';
}
}
public function set_default_array(string $name, array $value): void
{
if (is_null($this->get($name))) {
$this->values[$name] = implode(",", $value);
}
}
public function get_int(string $name, ?int $default=null): ?int
{
return (int)($this->get($name, $default));
}
public function get_float(string $name, ?float $default=null): ?float
{
return (float)($this->get($name, $default));
}
public function get_string(string $name, ?string $default=null): ?string
{
$val = $this->get($name, $default);
if (!is_string($val) && !is_null($val)) {
throw new SCoreException("$name is not a string: $val");
}
return $val;
}
public function get_bool(string $name, ?bool $default=null): ?bool
{
return bool_escape($this->get($name, $default));
}
public function get_array(string $name, ?array $default=[]): ?array
{
return explode(",", $this->get($name, ""));
}
private function get(string $name, $default=null)
{
if (isset($this->values[$name])) {
return $this->values[$name];
} else {
return $default;
}
}
}
/**
* Class DatabaseConfig
*
* Loads the config list from a table in a given database, the table should
* be called config and have the schema:
*
* \code
* CREATE TABLE config(
* name VARCHAR(255) NOT NULL,
* value TEXT
* );
* \endcode
*/
class DatabaseConfig extends BaseConfig
{
/** @var Database */
private $database = null;
private $table_name;
private $sub_column;
private $sub_value;
public function __construct(
Database $database,
string $table_name = "config",
string $sub_column = null,
string $sub_value = null
) {
global $cache;
$this->database = $database;
$this->table_name = $table_name;
$this->sub_value = $sub_value;
$this->sub_column = $sub_column;
$cache_name = "config";
if (!empty($sub_value)) {
$cache_name .= "_".$sub_value;
}
$cached = $cache->get($cache_name);
if ($cached) {
$this->values = $cached;
} else {
$this->values = [];
$query = "SELECT name, value FROM {$this->table_name}";
$args = [];
if (!empty($sub_column)&&!empty($sub_value)) {
$query .= " WHERE $sub_column = :sub_value";
$args["sub_value"] = $sub_value;
}
foreach ($this->database->get_all($query, $args) as $row) {
$this->values[$row["name"]] = $row["value"];
}
$cache->set($cache_name, $this->values);
}
}
public function save(string $name=null): void
{
global $cache;
if (is_null($name)) {
reset($this->values); // rewind the array to the first element
foreach ($this->values as $name => $value) {
$this->save($name);
}
} else {
$query = "DELETE FROM {$this->table_name} WHERE name = :name";
$args = ["name"=>$name];
$cols = ["name","value"];
$params = [":name",":value"];
if (!empty($this->sub_column)&&!empty($this->sub_value)) {
$query .= " AND $this->sub_column = :sub_value";
$args["sub_value"] = $this->sub_value;
$cols[] = $this->sub_column;
$params[] = ":sub_value";
}
$this->database->Execute($query, $args);
$args["value"] =$this->values[$name];
$this->database->Execute(
"INSERT INTO {$this->table_name} (".join(",", $cols).") VALUES (".join(",", $params).")",
$args
);
}
// rather than deleting and having some other request(s) do a thundering
// herd of race-conditioned updates, just save the updated version once here
$cache->set("config", $this->values);
}
}

View file

@ -1,963 +0,0 @@
<?php
/** @privatesection */
// Querylet {{{
class Querylet {
/** @var string */
public $sql;
/** @var array */
public $variables;
/**
* @param string $sql
* @param array $variables
*/
public function __construct($sql, $variables=array()) {
$this->sql = $sql;
$this->variables = $variables;
}
/**
* @param \Querylet $querylet
*/
public function append($querylet) {
assert('!is_null($querylet)');
$this->sql .= $querylet->sql;
$this->variables = array_merge($this->variables, $querylet->variables);
}
/**
* @param string $sql
*/
public function append_sql($sql) {
$this->sql .= $sql;
}
/**
* @param mixed $var
*/
public function add_variable($var) {
$this->variables[] = $var;
}
}
class TagQuerylet {
/** @var string */
public $tag;
/** @var bool */
public $positive;
/**
* @param string $tag
* @param bool $positive
*/
public function __construct($tag, $positive) {
$this->tag = $tag;
$this->positive = $positive;
}
}
class ImgQuerylet {
/** @var \Querylet */
public $qlet;
/** @var bool */
public $positive;
/**
* @param \Querylet $qlet
* @param bool $positive
*/
public function __construct($qlet, $positive) {
$this->qlet = $qlet;
$this->positive = $positive;
}
}
// }}}
// {{{ db engines
class DBEngine {
/** @var null|string */
public $name = null;
/**
* @param \PDO $db
*/
public function init($db) {}
/**
* @param string $scoreql
* @return string
*/
public function scoreql_to_sql($scoreql) {
return $scoreql;
}
/**
* @param string $name
* @param string $data
* @return string
*/
public function create_table_sql($name, $data) {
return 'CREATE TABLE '.$name.' ('.$data.')';
}
}
class MySQL extends DBEngine {
/** @var string */
public $name = "mysql";
/**
* @param \PDO $db
*/
public function init($db) {
$db->exec("SET NAMES utf8;");
}
/**
* @param string $data
* @return string
*/
public function scoreql_to_sql($data) {
$data = str_replace("SCORE_AIPK", "INTEGER PRIMARY KEY auto_increment", $data);
$data = str_replace("SCORE_INET", "VARCHAR(45)", $data);
$data = str_replace("SCORE_BOOL_Y", "'Y'", $data);
$data = str_replace("SCORE_BOOL_N", "'N'", $data);
$data = str_replace("SCORE_BOOL", "ENUM('Y', 'N')", $data);
$data = str_replace("SCORE_DATETIME", "DATETIME", $data);
$data = str_replace("SCORE_NOW", "\"1970-01-01\"", $data);
$data = str_replace("SCORE_STRNORM", "", $data);
$data = str_replace("SCORE_ILIKE", "LIKE", $data);
return $data;
}
/**
* @param string $name
* @param string $data
* @return string
*/
public function create_table_sql($name, $data) {
$data = $this->scoreql_to_sql($data);
$ctes = "ENGINE=InnoDB DEFAULT CHARSET='utf8'";
return 'CREATE TABLE '.$name.' ('.$data.') '.$ctes;
}
}
class PostgreSQL extends DBEngine {
/** @var string */
public $name = "pgsql";
/**
* @param \PDO $db
*/
public function init($db) {
if(array_key_exists('REMOTE_ADDR', $_SERVER)) {
$db->exec("SET application_name TO 'shimmie [{$_SERVER['REMOTE_ADDR']}]';");
}
else {
$db->exec("SET application_name TO 'shimmie [local]';");
}
$db->exec("SET statement_timeout TO 10000;");
}
/**
* @param string $data
* @return string
*/
public function scoreql_to_sql($data) {
$data = str_replace("SCORE_AIPK", "SERIAL PRIMARY KEY", $data);
$data = str_replace("SCORE_INET", "INET", $data);
$data = str_replace("SCORE_BOOL_Y", "'t'", $data);
$data = str_replace("SCORE_BOOL_N", "'f'", $data);
$data = str_replace("SCORE_BOOL", "BOOL", $data);
$data = str_replace("SCORE_DATETIME", "TIMESTAMP", $data);
$data = str_replace("SCORE_NOW", "current_timestamp", $data);
$data = str_replace("SCORE_STRNORM", "lower", $data);
$data = str_replace("SCORE_ILIKE", "ILIKE", $data);
return $data;
}
/**
* @param string $name
* @param string $data
* @return string
*/
public function create_table_sql($name, $data) {
$data = $this->scoreql_to_sql($data);
return "CREATE TABLE $name ($data)";
}
}
// shimmie functions for export to sqlite
function _unix_timestamp($date) { return strtotime($date); }
function _now() { return date("Y-m-d h:i:s"); }
function _floor($a) { return floor($a); }
function _log($a, $b=null) {
if(is_null($b)) return log($a);
else return log($a, $b);
}
function _isnull($a) { return is_null($a); }
function _md5($a) { return md5($a); }
function _concat($a, $b) { return $a . $b; }
function _lower($a) { return strtolower($a); }
function _rand() { return rand(); }
function _ln($n) { return log($n); }
class SQLite extends DBEngine {
/** @var string */
public $name = "sqlite";
/**
* @param \PDO $db
*/
public function init($db) {
ini_set('sqlite.assoc_case', 0);
$db->exec("PRAGMA foreign_keys = ON;");
$db->sqliteCreateFunction('UNIX_TIMESTAMP', '_unix_timestamp', 1);
$db->sqliteCreateFunction('now', '_now', 0);
$db->sqliteCreateFunction('floor', '_floor', 1);
$db->sqliteCreateFunction('log', '_log');
$db->sqliteCreateFunction('isnull', '_isnull', 1);
$db->sqliteCreateFunction('md5', '_md5', 1);
$db->sqliteCreateFunction('concat', '_concat', 2);
$db->sqliteCreateFunction('lower', '_lower', 1);
$db->sqliteCreateFunction('rand', '_rand', 0);
$db->sqliteCreateFunction('ln', '_ln', 1);
}
/**
* @param string $data
* @return string
*/
public function scoreql_to_sql($data) {
$data = str_replace("SCORE_AIPK", "INTEGER PRIMARY KEY", $data);
$data = str_replace("SCORE_INET", "VARCHAR(45)", $data);
$data = str_replace("SCORE_BOOL_Y", "'Y'", $data);
$data = str_replace("SCORE_BOOL_N", "'N'", $data);
$data = str_replace("SCORE_BOOL", "CHAR(1)", $data);
$data = str_replace("SCORE_NOW", "\"1970-01-01\"", $data);
$data = str_replace("SCORE_STRNORM", "lower", $data);
$data = str_replace("SCORE_ILIKE", "LIKE", $data);
return $data;
}
/**
* @param string $name
* @param string $data
* @return string
*/
public function create_table_sql($name, $data) {
$data = $this->scoreql_to_sql($data);
$cols = array();
$extras = "";
foreach(explode(",", $data) as $bit) {
$matches = array();
if(preg_match("/(UNIQUE)? ?INDEX\s*\((.*)\)/", $bit, $matches)) {
$uni = $matches[1];
$col = $matches[2];
$extras .= "CREATE $uni INDEX {$name}_{$col} ON {$name}({$col});";
}
else {
$cols[] = $bit;
}
}
$cols_redone = implode(", ", $cols);
return "CREATE TABLE $name ($cols_redone); $extras";
}
}
// }}}
// {{{ cache engines
interface CacheEngine {
/**
* @param string $key
* @return mixed
*/
public function get($key);
/**
* @param string $key
* @param mixed $val
* @param integer $time
* @return void
*/
public function set($key, $val, $time=0);
/**
* @return void
*/
public function delete($key);
/**
* @return integer
*/
public function get_hits();
/**
* @return integer
*/
public function get_misses();
}
class NoCache implements CacheEngine {
public function get($key) {return false;}
public function set($key, $val, $time=0) {}
public function delete($key) {}
public function get_hits() {return 0;}
public function get_misses() {return 0;}
}
class MemcacheCache implements CacheEngine {
/** @var \Memcache|null */
public $memcache=null;
/** @var int */
private $hits=0;
/** @var int */
private $misses=0;
/**
* @param string $args
*/
public function __construct($args) {
$hp = explode(":", $args);
$this->memcache = new Memcache;
@$this->memcache->pconnect($hp[0], $hp[1]);
}
/**
* @param string $key
* @return array|bool|string
*/
public function get($key) {
assert('!is_null($key)');
$val = $this->memcache->get($key);
if((DEBUG_CACHE === true) || (is_null(DEBUG_CACHE) && @$_GET['DEBUG_CACHE'])) {
$hit = $val === false ? "miss" : "hit";
file_put_contents("data/cache.log", "Cache $hit: $key\n", FILE_APPEND);
}
if($val !== false) {
$this->hits++;
return $val;
}
else {
$this->misses++;
return false;
}
}
/**
* @param string $key
* @param mixed $val
* @param integer $time
*/
public function set($key, $val, $time=0) {
assert('!is_null($key)');
$this->memcache->set($key, $val, false, $time);
if((DEBUG_CACHE === true) || (is_null(DEBUG_CACHE) && @$_GET['DEBUG_CACHE'])) {
file_put_contents("data/cache.log", "Cache set: $key ($time)\n", FILE_APPEND);
}
}
/**
* @param string $key
*/
public function delete($key) {
assert('!is_null($key)');
$this->memcache->delete($key);
if((DEBUG_CACHE === true) || (is_null(DEBUG_CACHE) && @$_GET['DEBUG_CACHE'])) {
file_put_contents("data/cache.log", "Cache delete: $key\n", FILE_APPEND);
}
}
/**
* @return int
*/
public function get_hits() {return $this->hits;}
/**
* @return int
*/
public function get_misses() {return $this->misses;}
}
class MemcachedCache implements CacheEngine {
/** @var \Memcached|null */
public $memcache=null;
/** @var int */
private $hits=0;
/** @var int */
private $misses=0;
/**
* @param string $args
*/
public function __construct($args) {
$hp = explode(":", $args);
$this->memcache = new Memcached;
#$this->memcache->setOption(Memcached::OPT_COMPRESSION, False);
#$this->memcache->setOption(Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_PHP);
#$this->memcache->setOption(Memcached::OPT_PREFIX_KEY, phpversion());
$this->memcache->addServer($hp[0], $hp[1]);
}
/**
* @param string $key
* @return array|bool|string
*/
public function get($key) {
assert('!is_null($key)');
$key = urlencode($key);
$val = $this->memcache->get($key);
$res = $this->memcache->getResultCode();
if((DEBUG_CACHE === true) || (is_null(DEBUG_CACHE) && @$_GET['DEBUG_CACHE'])) {
$hit = $res == Memcached::RES_SUCCESS ? "hit" : "miss";
file_put_contents("data/cache.log", "Cache $hit: $key\n", FILE_APPEND);
}
if($res == Memcached::RES_SUCCESS) {
$this->hits++;
return $val;
}
else if($res == Memcached::RES_NOTFOUND) {
$this->misses++;
return false;
}
else {
error_log("Memcached error during get($key): $res");
}
}
/**
* @param string $key
* @param mixed $val
* @param int $time
*/
public function set($key, $val, $time=0) {
assert('!is_null($key)');
$key = urlencode($key);
$this->memcache->set($key, $val, $time);
$res = $this->memcache->getResultCode();
if((DEBUG_CACHE === true) || (is_null(DEBUG_CACHE) && @$_GET['DEBUG_CACHE'])) {
file_put_contents("data/cache.log", "Cache set: $key ($time)\n", FILE_APPEND);
}
if($res != Memcached::RES_SUCCESS) {
error_log("Memcached error during set($key): $res");
}
}
/**
* @param string $key
*/
public function delete($key) {
assert('!is_null($key)');
$key = urlencode($key);
$this->memcache->delete($key);
$res = $this->memcache->getResultCode();
if((DEBUG_CACHE === true) || (is_null(DEBUG_CACHE) && @$_GET['DEBUG_CACHE'])) {
file_put_contents("data/cache.log", "Cache delete: $key\n", FILE_APPEND);
}
if($res != Memcached::RES_SUCCESS && $res != Memcached::RES_NOTFOUND) {
error_log("Memcached error during delete($key): $res");
}
}
/**
* @return int
*/
public function get_hits() {return $this->hits;}
/**
* @return int
*/
public function get_misses() {return $this->misses;}
}
class APCCache implements CacheEngine {
public $hits=0, $misses=0;
public function __construct($args) {
// $args is not used, but is passed in when APC cache is created.
}
public function get($key) {
assert('!is_null($key)');
$val = apc_fetch($key);
if($val) {
$this->hits++;
return $val;
}
else {
$this->misses++;
return false;
}
}
public function set($key, $val, $time=0) {
assert('!is_null($key)');
apc_store($key, $val, $time);
}
public function delete($key) {
assert('!is_null($key)');
apc_delete($key);
}
public function get_hits() {return $this->hits;}
public function get_misses() {return $this->misses;}
}
// }}}
/** @publicsection */
/**
* A class for controlled database access
*/
class Database {
/**
* The PDO database connection object, for anyone who wants direct access.
* @var null|PDO
*/
private $db = null;
/**
* @var float
*/
public $dbtime = 0.0;
/**
* Meta info about the database engine.
* @var DBEngine|null
*/
private $engine = null;
/**
* The currently active cache engine.
* @var CacheEngine|null
*/
public $cache = null;
/**
* A boolean flag to track if we already have an active transaction.
* (ie: True if beginTransaction() already called)
*
* @var bool
*/
public $transaction = false;
/**
* How many queries this DB object has run
*/
public $query_count = 0;
/**
* For now, only connect to the cache, as we will pretty much certainly
* need it. There are some pages where all the data is in cache, so the
* DB connection is on-demand.
*/
public function __construct() {
$this->connect_cache();
}
private function connect_cache() {
$matches = array();
if(defined("CACHE_DSN") && CACHE_DSN && preg_match("#(memcache|memcached|apc)://(.*)#", CACHE_DSN, $matches)) {
if($matches[1] == "memcache") {
$this->cache = new MemcacheCache($matches[2]);
}
else if($matches[1] == "memcached") {
$this->cache = new MemcachedCache($matches[2]);
}
else if($matches[1] == "apc") {
$this->cache = new APCCache($matches[2]);
}
}
else {
$this->cache = new NoCache();
}
}
private function connect_db() {
# FIXME: detect ADODB URI, automatically translate PDO DSN
/*
* Why does the abstraction layer act differently depending on the
* back-end? Because PHP is deliberately retarded.
*
* http://stackoverflow.com/questions/237367
*/
$matches = array(); $db_user=null; $db_pass=null;
if(preg_match("/user=([^;]*)/", DATABASE_DSN, $matches)) $db_user=$matches[1];
if(preg_match("/password=([^;]*)/", DATABASE_DSN, $matches)) $db_pass=$matches[1];
// https://bugs.php.net/bug.php?id=70221
$ka = DATABASE_KA;
if(version_compare(PHP_VERSION, "6.9.9") == 1 && $this->get_driver_name() == "sqlite") {
$ka = false;
}
$db_params = array(
PDO::ATTR_PERSISTENT => $ka,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
);
$this->db = new PDO(DATABASE_DSN, $db_user, $db_pass, $db_params);
$this->connect_engine();
$this->engine->init($this->db);
$this->beginTransaction();
}
private function connect_engine() {
if(preg_match("/^([^:]*)/", DATABASE_DSN, $matches)) $db_proto=$matches[1];
else throw new SCoreException("Can't figure out database engine");
if($db_proto === "mysql") {
$this->engine = new MySQL();
}
else if($db_proto === "pgsql") {
$this->engine = new PostgreSQL();
}
else if($db_proto === "sqlite") {
$this->engine = new SQLite();
}
else {
die('Unknown PDO driver: '.$db_proto);
}
}
public function beginTransaction() {
if ($this->transaction === false) {
$this->db->beginTransaction();
$this->transaction = true;
}
}
/**
* @return boolean|null
* @throws SCoreException
*/
public function commit() {
if(!is_null($this->db)) {
if ($this->transaction === true) {
$this->transaction = false;
return $this->db->commit();
}
else {
throw new SCoreException("<p><b>Database Transaction Error:</b> Unable to call commit() as there is no transaction currently open.");
}
}
}
/**
* @return boolean|null
* @throws SCoreException
*/
public function rollback() {
if(!is_null($this->db)) {
if ($this->transaction === true) {
$this->transaction = false;
return $this->db->rollback();
}
else {
throw new SCoreException("<p><b>Database Transaction Error:</b> Unable to call rollback() as there is no transaction currently open.");
}
}
}
/**
* @param string $input
* @return string
*/
public function escape($input) {
if(is_null($this->db)) $this->connect_db();
return $this->db->Quote($input);
}
/**
* @param string $input
* @return string
*/
public function scoreql_to_sql($input) {
if(is_null($this->engine)) $this->connect_engine();
return $this->engine->scoreql_to_sql($input);
}
/**
* @return null|string
*/
public function get_driver_name() {
if(is_null($this->engine)) $this->connect_engine();
return $this->engine->name;
}
/**
* @param null|PDO $db
* @param string $sql
*/
private function count_execs($db, $sql, $inputarray) {
if((DEBUG_SQL === true) || (is_null(DEBUG_SQL) && @$_GET['DEBUG_SQL'])) {
$sql = trim(preg_replace('/\s+/msi', ' ', $sql));
if(isset($inputarray) && is_array($inputarray) && !empty($inputarray)) {
$text = $sql." -- ".join(", ", $inputarray)."\n";
}
else {
$text = $sql."\n";
}
file_put_contents("data/sql.log", $text, FILE_APPEND);
}
if(!is_array($inputarray)) $this->query_count++;
# handle 2-dimensional input arrays
else if(is_array(reset($inputarray))) $this->query_count += sizeof($inputarray);
else $this->query_count++;
}
private function count_time($method, $start) {
if((DEBUG_SQL === true) || (is_null(DEBUG_SQL) && @$_GET['DEBUG_SQL'])) {
$text = $method.":".(microtime(true) - $start)."\n";
file_put_contents("data/sql.log", $text, FILE_APPEND);
}
$this->dbtime += microtime(true) - $start;
}
/**
* Execute an SQL query and return an PDO result-set.
*
* @param string $query
* @param array $args
* @return PDOStatement
* @throws SCoreException
*/
public function execute($query, $args=array()) {
try {
if(is_null($this->db)) $this->connect_db();
$this->count_execs($this->db, $query, $args);
$stmt = $this->db->prepare($query);
if (!array_key_exists(0, $args)) {
foreach($args as $name=>$value) {
if(is_numeric($value)) {
$stmt->bindValue(':'.$name, $value, PDO::PARAM_INT);
}
else {
$stmt->bindValue(':'.$name, $value, PDO::PARAM_STR);
}
}
$stmt->execute();
}
else {
$stmt->execute($args);
}
return $stmt;
}
catch(PDOException $pdoe) {
throw new SCoreException($pdoe->getMessage()."<p><b>Query:</b> ".$query);
}
}
/**
* Execute an SQL query and return a 2D array.
*
* @param string $query
* @param array $args
* @return array
*/
public function get_all($query, $args=array()) {
$_start = microtime(true);
$data = $this->execute($query, $args)->fetchAll();
$this->count_time("get_all", $_start);
return $data;
}
/**
* Execute an SQL query and return a single row.
*
* @param string $query
* @param array $args
* @return array|null
*/
public function get_row($query, $args=array()) {
$_start = microtime(true);
$row = $this->execute($query, $args)->fetch();
$this->count_time("get_row", $_start);
return $row ? $row : null;
}
/**
* Execute an SQL query and return the first column of each row.
*
* @param string $query
* @param array $args
* @return array
*/
public function get_col($query, $args=array()) {
$_start = microtime(true);
$stmt = $this->execute($query, $args);
$res = array();
foreach($stmt as $row) {
$res[] = $row[0];
}
$this->count_time("get_col", $_start);
return $res;
}
/**
* Execute an SQL query and return the the first row => the second rown.
*
* @param string $query
* @param array $args
* @return array
*/
public function get_pairs($query, $args=array()) {
$_start = microtime(true);
$stmt = $this->execute($query, $args);
$res = array();
foreach($stmt as $row) {
$res[$row[0]] = $row[1];
}
$this->count_time("get_pairs", $_start);
return $res;
}
/**
* Execute an SQL query and return a single value.
*
* @param string $query
* @param array $args
* @return mixed
*/
public function get_one($query, $args=array()) {
$_start = microtime(true);
$row = $this->execute($query, $args)->fetch();
$this->count_time("get_one", $_start);
return $row[0];
}
/**
* Get the ID of the last inserted row.
*
* @param string|null $seq
* @return int
*/
public function get_last_insert_id($seq) {
if($this->engine->name == "pgsql") {
return $this->db->lastInsertId($seq);
}
else {
return $this->db->lastInsertId();
}
}
/**
* Create a table from pseudo-SQL.
*
* @param string $name
* @param string $data
*/
public function create_table($name, $data) {
if(is_null($this->engine)) { $this->connect_engine(); }
$data = trim($data, ", \t\n\r\0\x0B"); // mysql doesn't like trailing commas
$this->execute($this->engine->create_table_sql($name, $data));
}
/**
* Returns the number of tables present in the current database.
*
* @return int|null
*/
public function count_tables() {
if(is_null($this->db) || is_null($this->engine)) $this->connect_db();
if($this->engine->name === "mysql") {
return count(
$this->get_all("SHOW TABLES")
);
} else if ($this->engine->name === "pgsql") {
return count(
$this->get_all("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'")
);
} else if ($this->engine->name === "sqlite") {
return count(
$this->get_all("SELECT name FROM sqlite_master WHERE type = 'table'")
);
} else {
// Hard to find a universal way to do this...
return NULL;
}
}
}
class MockDatabase extends Database {
/** @var int */
private $query_id = 0;
/** @var array */
private $responses = array();
/** @var \NoCache|null */
public $cache = null;
/**
* @param array $responses
*/
public function __construct($responses = array()) {
$this->cache = new NoCache();
$this->responses = $responses;
}
/**
* @param string $query
* @param array $params
* @return PDOStatement
*/
public function execute($query, $params=array()) {
log_debug("mock-database",
"QUERY: " . $query .
"\nARGS: " . var_export($params, true) .
"\nRETURN: " . var_export($this->responses[$this->query_id], true)
);
return $this->responses[$this->query_id++];
}
/**
* @param string $query
* @param array $args
* @return PDOStatement
*/
public function get_all($query, $args=array()) {return $this->execute($query, $args);}
/**
* @param string $query
* @param array $args
* @return PDOStatement
*/
public function get_row($query, $args=array()) {return $this->execute($query, $args);}
/**
* @param string $query
* @param array $args
* @return PDOStatement
*/
public function get_col($query, $args=array()) {return $this->execute($query, $args);}
/**
* @param string $query
* @param array $args
* @return PDOStatement
*/
public function get_pairs($query, $args=array()) {return $this->execute($query, $args);}
/**
* @param string $query
* @param array $args
* @return PDOStatement
*/
public function get_one($query, $args=array()) {return $this->execute($query, $args);}
/**
* @param null|string $seq
* @return int|string
*/
public function get_last_insert_id($seq) {return $this->query_id;}
/**
* @param string $sql
* @return string
*/
public function scoreql_to_sql($sql) {return $sql;}
public function create_table($name, $def) {}
public function connect_engine() {}
}

326
core/database.php Normal file
View file

@ -0,0 +1,326 @@
<?php declare(strict_types=1);
use FFSPHP\PDO;
abstract class DatabaseDriver
{
public const MYSQL = "mysql";
public const PGSQL = "pgsql";
public const SQLITE = "sqlite";
}
/**
* A class for controlled database access
*/
class Database
{
/** @var string */
private $dsn;
/**
* The PDO database connection object, for anyone who wants direct access.
* @var null|PDO
*/
private $db = null;
/**
* @var float
*/
public $dbtime = 0.0;
/**
* Meta info about the database engine.
* @var DBEngine|null
*/
private $engine = null;
/**
* A boolean flag to track if we already have an active transaction.
* (ie: True if beginTransaction() already called)
*
* @var bool
*/
public $transaction = false;
/**
* How many queries this DB object has run
*/
public $query_count = 0;
public function __construct(string $dsn)
{
$this->dsn = $dsn;
}
private function connect_db(): void
{
$this->db = new PDO($this->dsn, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$this->connect_engine();
$this->engine->init($this->db);
$this->beginTransaction();
}
private function connect_engine(): void
{
if (preg_match("/^([^:]*)/", $this->dsn, $matches)) {
$db_proto=$matches[1];
} else {
throw new SCoreException("Can't figure out database engine");
}
if ($db_proto === DatabaseDriver::MYSQL) {
$this->engine = new MySQL();
} elseif ($db_proto === DatabaseDriver::PGSQL) {
$this->engine = new PostgreSQL();
} elseif ($db_proto === DatabaseDriver::SQLITE) {
$this->engine = new SQLite();
} else {
die('Unknown PDO driver: '.$db_proto);
}
}
public function beginTransaction(): void
{
if ($this->transaction === false) {
$this->db->beginTransaction();
$this->transaction = true;
}
}
public function commit(): bool
{
if (!is_null($this->db) && $this->transaction === true) {
$this->transaction = false;
return $this->db->commit();
} else {
throw new SCoreException("Unable to call commit() as there is no transaction currently open.");
}
}
public function rollback(): bool
{
if (!is_null($this->db) && $this->transaction === true) {
$this->transaction = false;
return $this->db->rollback();
} else {
throw new SCoreException("Unable to call rollback() as there is no transaction currently open.");
}
}
public function scoreql_to_sql(string $input): string
{
if (is_null($this->engine)) {
$this->connect_engine();
}
return $this->engine->scoreql_to_sql($input);
}
public function scoresql_value_prepare($input)
{
if (is_null($this->engine)) {
$this->connect_engine();
}
if ($input===true) {
return $this->engine->BOOL_Y;
} elseif ($input===false) {
return $this->engine->BOOL_N;
}
return $input;
}
public function get_driver_name(): string
{
if (is_null($this->engine)) {
$this->connect_engine();
}
return $this->engine->name;
}
private function count_time(string $method, float $start, string $query, ?array $args): void
{
global $_tracer, $tracer_enabled;
$dur = microtime(true) - $start;
if ($tracer_enabled) {
$query = trim(preg_replace('/^[\t ]+/m', '', $query)); // trim leading whitespace
$_tracer->complete($start * 1000000, $dur * 1000000, "DB Query", ["query"=>$query, "args"=>$args, "method"=>$method]);
}
$this->query_count++;
$this->dbtime += $dur;
}
public function set_timeout(int $time): void
{
$this->engine->set_timeout($this->db, $time);
}
public function execute(string $query, array $args = []): PDOStatement
{
try {
if (is_null($this->db)) {
$this->connect_db();
}
return $this->db->execute(
"-- " . str_replace("%2F", "/", urlencode($_GET['q'] ?? '')). "\n" .
$query,
$args
);
} catch (PDOException $pdoe) {
throw new SCoreException($pdoe->getMessage(), $query);
}
}
/**
* Execute an SQL query and return a 2D array.
*/
public function get_all(string $query, array $args = []): array
{
$_start = microtime(true);
$data = $this->execute($query, $args)->fetchAll();
$this->count_time("get_all", $_start, $query, $args);
return $data;
}
/**
* Execute an SQL query and return a iterable object for use with generators.
*/
public function get_all_iterable(string $query, array $args = []): PDOStatement
{
$_start = microtime(true);
$data = $this->execute($query, $args);
$this->count_time("get_all_iterable", $_start, $query, $args);
return $data;
}
/**
* Execute an SQL query and return a single row.
*/
public function get_row(string $query, array $args = []): ?array
{
$_start = microtime(true);
$row = $this->execute($query, $args)->fetch();
$this->count_time("get_row", $_start, $query, $args);
return $row ? $row : null;
}
/**
* Execute an SQL query and return the first column of each row.
*/
public function get_col(string $query, array $args = []): array
{
$_start = microtime(true);
$res = $this->execute($query, $args)->fetchAll(PDO::FETCH_COLUMN);
$this->count_time("get_col", $_start, $query, $args);
return $res;
}
/**
* Execute an SQL query and return the first column of each row as a single iterable object.
*/
public function get_col_iterable(string $query, array $args = []): Generator
{
$_start = microtime(true);
$stmt = $this->execute($query, $args);
$this->count_time("get_col_iterable", $_start, $query, $args);
foreach ($stmt as $row) {
yield $row[0];
}
}
/**
* Execute an SQL query and return the the first column => the second column.
*/
public function get_pairs(string $query, array $args = []): array
{
$_start = microtime(true);
$res = $this->execute($query, $args)->fetchAll(PDO::FETCH_KEY_PAIR);
$this->count_time("get_pairs", $_start, $query, $args);
return $res;
}
/**
* Execute an SQL query and return a single value, or null.
*/
public function get_one(string $query, array $args = [])
{
$_start = microtime(true);
$row = $this->execute($query, $args)->fetch();
$this->count_time("get_one", $_start, $query, $args);
return $row ? $row[0] : null;
}
/**
* Execute an SQL query and returns a bool indicating if any data was returned
*/
public function exists(string $query, array $args = []): bool
{
$_start = microtime(true);
$row = $this->execute($query, $args)->fetch();
$this->count_time("exists", $_start, $query, $args);
if ($row==null) {
return false;
}
return true;
}
/**
* Get the ID of the last inserted row.
*/
public function get_last_insert_id(string $seq): int
{
if ($this->engine->name == DatabaseDriver::PGSQL) {
$id = $this->db->lastInsertId($seq);
} else {
$id = $this->db->lastInsertId();
}
assert(is_numeric($id));
return (int)$id;
}
/**
* Create a table from pseudo-SQL.
*/
public function create_table(string $name, string $data): void
{
if (is_null($this->engine)) {
$this->connect_engine();
}
$data = trim($data, ", \t\n\r\0\x0B"); // mysql doesn't like trailing commas
$this->execute($this->engine->create_table_sql($name, $data));
}
/**
* Returns the number of tables present in the current database.
*
* @throws SCoreException
*/
public function count_tables(): int
{
if (is_null($this->db) || is_null($this->engine)) {
$this->connect_db();
}
if ($this->engine->name === DatabaseDriver::MYSQL) {
return count(
$this->get_all("SHOW TABLES")
);
} elseif ($this->engine->name === DatabaseDriver::PGSQL) {
return count(
$this->get_all("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'")
);
} elseif ($this->engine->name === DatabaseDriver::SQLITE) {
return count(
$this->get_all("SELECT name FROM sqlite_master WHERE type = 'table'")
);
} else {
throw new SCoreException("Can't count tables for database type {$this->engine->name}");
}
}
public function raw_db(): PDO
{
return $this->db;
}
}

219
core/dbengine.php Normal file
View file

@ -0,0 +1,219 @@
<?php declare(strict_types=1);
abstract class SCORE
{
const AIPK = "SCORE_AIPK";
const INET = "SCORE_INET";
const BOOL_Y = "SCORE_BOOL_Y";
const BOOL_N = "SCORE_BOOL_N";
const BOOL = "SCORE_BOOL";
}
abstract class DBEngine
{
/** @var null|string */
public $name = null;
public $BOOL_Y = null;
public $BOOL_N = null;
public function init(PDO $db)
{
}
public function scoreql_to_sql(string $scoreql): string
{
return $scoreql;
}
public function create_table_sql(string $name, string $data): string
{
return 'CREATE TABLE '.$name.' ('.$data.')';
}
abstract public function set_timeout(PDO $db, int $time);
}
class MySQL extends DBEngine
{
/** @var string */
public $name = DatabaseDriver::MYSQL;
public $BOOL_Y = 'Y';
public $BOOL_N = 'N';
public function init(PDO $db)
{
$db->exec("SET NAMES utf8;");
}
public function scoreql_to_sql(string $data): string
{
$data = str_replace(SCORE::AIPK, "INTEGER PRIMARY KEY auto_increment", $data);
$data = str_replace(SCORE::INET, "VARCHAR(45)", $data);
$data = str_replace(SCORE::BOOL_Y, "'$this->BOOL_Y'", $data);
$data = str_replace(SCORE::BOOL_N, "'$this->BOOL_N'", $data);
$data = str_replace(SCORE::BOOL, "ENUM('Y', 'N')", $data);
return $data;
}
public function create_table_sql(string $name, string $data): string
{
$data = $this->scoreql_to_sql($data);
$ctes = "ENGINE=InnoDB DEFAULT CHARSET='utf8'";
return 'CREATE TABLE '.$name.' ('.$data.') '.$ctes;
}
public function set_timeout(PDO $db, int $time): void
{
// These only apply to read-only queries, which appears to be the best we can to mysql-wise
// $db->exec("SET SESSION MAX_EXECUTION_TIME=".$time.";");
}
}
class PostgreSQL extends DBEngine
{
/** @var string */
public $name = DatabaseDriver::PGSQL;
public $BOOL_Y = "true";
public $BOOL_N = "false";
public function init(PDO $db)
{
if (array_key_exists('REMOTE_ADDR', $_SERVER)) {
$db->exec("SET application_name TO 'shimmie [{$_SERVER['REMOTE_ADDR']}]';");
} else {
$db->exec("SET application_name TO 'shimmie [local]';");
}
$this->set_timeout($db, DATABASE_TIMEOUT);
}
public function scoreql_to_sql(string $data): string
{
$data = str_replace(SCORE::AIPK, "INTEGER NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY", $data);
$data = str_replace(SCORE::INET, "INET", $data);
$data = str_replace(SCORE::BOOL_Y, "true", $data);
$data = str_replace(SCORE::BOOL_N, "false", $data);
$data = str_replace(SCORE::BOOL, "BOOL", $data);
return $data;
}
public function create_table_sql(string $name, string $data): string
{
$data = $this->scoreql_to_sql($data);
return "CREATE TABLE $name ($data)";
}
public function set_timeout(PDO $db, int $time): void
{
$db->exec("SET statement_timeout TO ".$time.";");
}
}
// shimmie functions for export to sqlite
function _unix_timestamp($date)
{
return strtotime($date);
}
function _now()
{
return date("Y-m-d H:i:s");
}
function _floor($a)
{
return floor($a);
}
function _log($a, $b=null)
{
if (is_null($b)) {
return log($a);
} else {
return log($a, $b);
}
}
function _isnull($a)
{
return is_null($a);
}
function _md5($a)
{
return md5($a);
}
function _concat($a, $b)
{
return $a . $b;
}
function _lower($a)
{
return strtolower($a);
}
function _rand()
{
return rand();
}
function _ln($n)
{
return log($n);
}
class SQLite extends DBEngine
{
/** @var string */
public $name = DatabaseDriver::SQLITE;
public $BOOL_Y = 'Y';
public $BOOL_N = 'N';
public function init(PDO $db)
{
ini_set('sqlite.assoc_case', '0');
$db->exec("PRAGMA foreign_keys = ON;");
$db->sqliteCreateFunction('UNIX_TIMESTAMP', '_unix_timestamp', 1);
$db->sqliteCreateFunction('now', '_now', 0);
$db->sqliteCreateFunction('floor', '_floor', 1);
$db->sqliteCreateFunction('log', '_log');
$db->sqliteCreateFunction('isnull', '_isnull', 1);
$db->sqliteCreateFunction('md5', '_md5', 1);
$db->sqliteCreateFunction('concat', '_concat', 2);
$db->sqliteCreateFunction('lower', '_lower', 1);
$db->sqliteCreateFunction('rand', '_rand', 0);
$db->sqliteCreateFunction('ln', '_ln', 1);
}
public function scoreql_to_sql(string $data): string
{
$data = str_replace(SCORE::AIPK, "INTEGER PRIMARY KEY", $data);
$data = str_replace(SCORE::INET, "VARCHAR(45)", $data);
$data = str_replace(SCORE::BOOL_Y, "'$this->BOOL_Y'", $data);
$data = str_replace(SCORE::BOOL_N, "'$this->BOOL_N'", $data);
$data = str_replace(SCORE::BOOL, "CHAR(1)", $data);
return $data;
}
public function create_table_sql(string $name, string $data): string
{
$data = $this->scoreql_to_sql($data);
$cols = [];
$extras = "";
foreach (explode(",", $data) as $bit) {
$matches = [];
if (preg_match("/(UNIQUE)? ?INDEX\s*\((.*)\)/", $bit, $matches)) {
$uni = $matches[1];
$col = $matches[2];
$extras .= "CREATE $uni INDEX {$name}_{$col} ON {$name}({$col});";
} else {
$cols[] = $bit;
}
}
$cols_redone = implode(", ", $cols);
return "CREATE TABLE $name ($cols_redone); $extras";
}
public function set_timeout(PDO $db, int $time): void
{
// There doesn't seem to be such a thing for SQLite, so it does nothing
}
}

View file

@ -1,138 +0,0 @@
<?php
/**
* Class Email
*
* A generic email.
*/
class Email {
/** @var string */
public $to;
/** @var string */
public $subject;
/** @var string */
public $header;
/** @var null|string */
public $style;
/** @var null|string */
public $header_img;
/** @var null|string */
public $sitename;
/** @var null|string */
public $sitedomain;
/** @var null|string */
public $siteemail;
/** @var string */
public $date;
/** @var string */
public $body;
/** @var null|string */
public $footer;
/**
* @param string $to
* @param string $subject
* @param string $header
* @param string $body
*/
public function __construct($to, $subject, $header, $body) {
global $config;
$this->to = $to;
$sub_prefix = $config->get_string("mail_sub");
if(!isset($sub_prefix)){
$this->subject = $subject;
}
else{
$this->subject = $sub_prefix." ".$subject;
}
$this->style = $config->get_string("mail_style");
$this->header = html_escape($header);
$this->header_img = $config->get_string("mail_img");
$this->sitename = $config->get_string("site_title");
$this->sitedomain = make_http(make_link(""));
$this->siteemail = $config->get_string("site_email");
$this->date = date("F j, Y");
$this->body = $body;
$this->footer = $config->get_string("mail_fot");
}
public function send() {
$headers = "From: ".$this->sitename." <".$this->siteemail.">\r\n";
$headers .= "Reply-To: ".$this->siteemail."\r\n";
$headers .= "X-Mailer: PHP/" . phpversion(). "\r\n";
$headers .= "errors-to: ".$this->siteemail."\r\n";
$headers .= "Date: " . date(DATE_RFC2822);
$headers .= 'MIME-Version: 1.0' . "\r\n";
$headers .= 'Content-type: text/html; charset=iso-8859-1' . "\r\n";
$message = '
<html>
<head>
<link rel="stylesheet" href="'.$this->style.'" type="text/css">
</head>
<body leftmargin="0" marginwidth="0" topmargin="0" marginheight="0" offset="0" bgcolor="#EEEEEE" >
<table width="100%" cellpadding="10" cellspacing="0" class="backgroundTable" bgcolor="#EEEEEE" >
<tr>
<td valign="top" align="center">
<table width="550" cellpadding="0" cellspacing="0">
<tr>
<td style="background-color:#FFFFFF;border-top:0px solid #333333;border-bottom:10px solid #FFFFFF;"><center><a href="'.$this->sitedomain.'"><IMG SRC="'.$this->header_img.'" alt="'.$this->sitename.'" name="Header" BORDER="0" align="center" title="'.$this->sitename.'"></a>
</center></td>
</tr>
</table>
<table width="550" cellpadding="20" cellspacing="0" bgcolor="#FFFFFF">
<tr>
<td bgcolor="#FFFFFF" valign="top" style="font-size:12px;color:#000000;line-height:150%;font-family:trebuchet ms;">
<p>
<span style="font-size:20px; font-weight:bold; color:#3399FF; font-family:arial; line-height:110%;">'.$this->header.'</span><br>
<span style="font-size:11px;font-weight:normal;color:#666666;font-style:italic;font-family:arial;">'.$this->date.'</span><br>
</p>
<p>'.$this->body.'</p>
<p>'.$this->footer.'</p>
</td>
</tr>
<tr>
<td style="background-color:#FFFFCC;border-top:10px solid #FFFFFF;" valign="top">
<span style="font-size:10px;color:#996600;line-height:100%;font-family:verdana;">
This email was sent to you since you are a member of <a href="'.$this->sitedomain.'">'.$this->sitename.'</a>. To change your email preferences, visit your <a href="'.make_http(make_link("preferences")).'">Account preferences</a>.<br />
<br />
Contact us:<br />
<a href="'.$this->siteemail.'">'.$this->siteemail.'</a><br /><br />
Copyright (C) <a href="'.$this->sitedomain.'">'.$this->sitename.'</a><br />
</span></td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
';
$sent = mail($this->to, $this->subject, $message, $headers);
if($sent){
log_info("mail", "Sent message '$this->subject' to '$this->to'");
}
else{
log_info("mail", "Error sending message '$this->subject' to '$this->to'");
}
return $sent;
}
}

View file

@ -1,325 +0,0 @@
<?php
/**
* Generic parent class for all events.
*
* An event is anything that can be passed around via send_event($blah)
*/
abstract class Event {
public function __construct() {}
}
/**
* A wake-up call for extensions. Upon recieving an InitExtEvent an extension
* should check that it's database tables are there and install them if not,
* and set any defaults with Config::set_default_int() and such.
*
* This event is sent before $user is set to anything
*/
class InitExtEvent extends Event {}
/**
* A signal that a page has been requested.
*
* User requests /view/42 -> an event is generated with $args = array("view",
* "42"); when an event handler asks $event->page_matches("view"), it returns
* true and ignores the matched part, such that $event->count_args() = 1 and
* $event->get_arg(0) = "42"
*/
class PageRequestEvent extends Event {
/**
* @var array
*/
public $args;
/**
* @var int
*/
public $arg_count;
/**
* @var int
*/
public $part_count;
/**
* @param string $path
*/
public function __construct($path) {
global $config;
// trim starting slashes
$path = ltrim($path, "/");
// if path is not specified, use the default front page
if(empty($path)) { /* empty is faster than strlen */
$path = $config->get_string('front_page');
}
// break the path into parts
$args = explode('/', $path);
// voodoo so that an arg can contain a slash; is
// this still needed?
if(strpos($path, "^") !== FALSE) {
$unescaped = array();
foreach($args as $part) {
$unescaped[] = _decaret($part);
}
$args = $unescaped;
}
$this->args = $args;
$this->arg_count = count($args);
}
/**
* Test if the requested path matches a given pattern.
*
* If it matches, store the remaining path elements in $args
*
* @param string $name
* @return bool
*/
public function page_matches(/*string*/ $name) {
$parts = explode("/", $name);
$this->part_count = count($parts);
if($this->part_count > $this->arg_count) {
return false;
}
for($i=0; $i<$this->part_count; $i++) {
if($parts[$i] != $this->args[$i]) {
return false;
}
}
return true;
}
/**
* Get the n th argument of the page request (if it exists.)
*
* @param int $n
* @return string|null The argument (string) or NULL
*/
public function get_arg(/*int*/ $n) {
$offset = $this->part_count + $n;
if($offset >= 0 && $offset < $this->arg_count) {
return $this->args[$offset];
}
else {
return null;
}
}
/**
* Returns the number of arguments the page request has.
* @return int
*/
public function count_args() {
return int_escape($this->arg_count - $this->part_count);
}
/*
* Many things use these functions
*/
/**
* @return array
*/
public function get_search_terms() {
$search_terms = array();
if($this->count_args() === 2) {
$search_terms = Tag::explode($this->get_arg(0));
}
return $search_terms;
}
/**
* @return int
*/
public function get_page_number() {
$page_number = 1;
if($this->count_args() === 1) {
$page_number = int_escape($this->get_arg(0));
}
else if($this->count_args() === 2) {
$page_number = int_escape($this->get_arg(1));
}
if($page_number === 0) $page_number = 1; // invalid -> 0
return $page_number;
}
/**
* @return int
*/
public function get_page_size() {
global $config;
return $config->get_int('index_images');
}
}
/**
* Sent when index.php is called from the command line
*/
class CommandEvent extends Event {
/**
* @var string
*/
public $cmd = "help";
/**
* @var array
*/
public $args = array();
/**
* @param string[] $args
*/
public function __construct(/*array(string)*/ $args) {
global $user;
$opts = array();
$log_level = SCORE_LOG_WARNING;
$arg_count = count($args);
for($i=1; $i<$arg_count; $i++) {
switch($args[$i]) {
case '-u':
$user = User::by_name($args[++$i]);
if(is_null($user)) {
die("Unknown user");
}
break;
case '-q':
$log_level += 10;
break;
case '-v':
$log_level -= 10;
break;
default:
$opts[] = $args[$i];
break;
}
}
define("CLI_LOG_LEVEL", $log_level);
if(count($opts) > 0) {
$this->cmd = $opts[0];
$this->args = array_slice($opts, 1);
}
else {
print "\n";
print "Usage: php {$args[0]} [flags] [command]\n";
print "\n";
print "Flags:\n";
print " -u [username]\n";
print " Log in as the specified user\n";
print " -q / -v\n";
print " Be quieter / more verbose\n";
print " Scale is debug - info - warning - error - critical\n";
print " Default is to show warnings and above\n";
print " \n";
print "Currently known commands:\n";
}
}
}
/**
* A signal that some text needs formatting, the event carries
* both the text and the result
*/
class TextFormattingEvent extends Event {
/**
* For reference
*
* @var string
*/
public $original;
/**
* with formatting applied
*
* @var string
*/
public $formatted;
/**
* with formatting removed
*
* @var string
*/
public $stripped;
/**
* @param string $text
*/
public function __construct(/*string*/ $text) {
$h_text = html_escape(trim($text));
$this->original = $h_text;
$this->formatted = $h_text;
$this->stripped = $h_text;
}
}
/**
* A signal that something needs logging
*/
class LogEvent extends Event {
/**
* a category, normally the extension name
*
* @var string
*/
public $section;
/**
* See python...
*
* @var int
*/
public $priority = 0;
/**
* Free text to be logged
*
* @var string
*/
public $message;
/**
* The time that the event was created
*
* @var int
*/
public $time;
/**
* Extra data to be held separate
*
* @var array
*/
public $args;
/**
* @param string $section
* @param int $priority
* @param string $message
* @param array $args
*/
public function __construct($section, $priority, $message, $args) {
$this->section = $section;
$this->priority = $priority;
$this->message = $message;
$this->args = $args;
$this->time = time();
}
}

339
core/event.php Normal file
View file

@ -0,0 +1,339 @@
<?php declare(strict_types=1);
/**
* Generic parent class for all events.
*
* An event is anything that can be passed around via send_event($blah)
*/
abstract class Event
{
public $stop_processing = false;
public function __construct()
{
}
public function __toString()
{
return var_export($this, true);
}
}
/**
* A wake-up call for extensions. Upon recieving an InitExtEvent an extension
* should check that it's database tables are there and install them if not,
* and set any defaults with Config::set_default_int() and such.
*
* This event is sent before $user is set to anything
*/
class InitExtEvent extends Event
{
}
/**
* A signal that a page has been requested.
*
* User requests /view/42 -> an event is generated with $args = array("view",
* "42"); when an event handler asks $event->page_matches("view"), it returns
* true and ignores the matched part, such that $event->count_args() = 1 and
* $event->get_arg(0) = "42"
*/
class PageRequestEvent extends Event
{
/**
* @var array
*/
public $args;
/**
* @var int
*/
public $arg_count;
/**
* @var int
*/
public $part_count;
public function __construct(string $path)
{
parent::__construct();
global $config;
// trim starting slashes
$path = ltrim($path, "/");
// if path is not specified, use the default front page
if (empty($path)) { /* empty is faster than strlen */
$path = $config->get_string(SetupConfig::FRONT_PAGE);
}
// break the path into parts
$args = explode('/', $path);
$this->args = $args;
$this->arg_count = count($args);
}
/**
* Test if the requested path matches a given pattern.
*
* If it matches, store the remaining path elements in $args
*/
public function page_matches(string $name): bool
{
$parts = explode("/", $name);
$this->part_count = count($parts);
if ($this->part_count > $this->arg_count) {
return false;
}
for ($i=0; $i<$this->part_count; $i++) {
if ($parts[$i] != $this->args[$i]) {
return false;
}
}
return true;
}
/**
* Get the n th argument of the page request (if it exists.)
*/
public function get_arg(int $n): string
{
$offset = $this->part_count + $n;
if ($offset >= 0 && $offset < $this->arg_count) {
return $this->args[$offset];
} else {
throw new SCoreException("Requested an invalid argument #$n");
}
}
public function try_page_num(int $n): int
{
if ($this->count_args() > $n) {
$i = $this->get_arg($n);
if (is_numeric($i) && int_escape($i) > 0) {
return int_escape($i);
} else {
return 1;
}
} else {
return 1;
}
}
/**
* Returns the number of arguments the page request has.
*/
public function count_args(): int
{
return $this->arg_count - $this->part_count;
}
/*
* Many things use these functions
*/
public function get_search_terms(): array
{
$search_terms = [];
if ($this->count_args() === 2) {
$search_terms = Tag::explode(Tag::decaret($this->get_arg(0)));
}
return $search_terms;
}
public function get_page_number(): int
{
$page_number = 1;
if ($this->count_args() === 1) {
$page_number = int_escape($this->get_arg(0));
} elseif ($this->count_args() === 2) {
$page_number = int_escape($this->get_arg(1));
}
if ($page_number === 0) {
$page_number = 1;
} // invalid -> 0
return $page_number;
}
public function get_page_size(): int
{
global $config;
return $config->get_int(IndexConfig::IMAGES);
}
}
/**
* Sent when index.php is called from the command line
*/
class CommandEvent extends Event
{
/**
* @var string
*/
public $cmd = "help";
/**
* @var array
*/
public $args = [];
/**
* #param string[] $args
*/
public function __construct(array $args)
{
parent::__construct();
global $user;
$opts = [];
$log_level = SCORE_LOG_WARNING;
$arg_count = count($args);
for ($i=1; $i<$arg_count; $i++) {
switch ($args[$i]) {
case '-u':
$user = User::by_name($args[++$i]);
if (is_null($user)) {
die("Unknown user");
} else {
send_event(new UserLoginEvent($user));
}
break;
case '-q':
$log_level += 10;
break;
case '-v':
$log_level -= 10;
break;
default:
$opts[] = $args[$i];
break;
}
}
if (!defined("CLI_LOG_LEVEL")) {
define("CLI_LOG_LEVEL", $log_level);
}
if (count($opts) > 0) {
$this->cmd = $opts[0];
$this->args = array_slice($opts, 1);
} else {
print "\n";
print "Usage: php {$args[0]} [flags] [command]\n";
print "\n";
print "Flags:\n";
print "\t-u [username]\n";
print "\t\tLog in as the specified user\n";
print "\t-q / -v\n";
print "\t\tBe quieter / more verbose\n";
print "\t\tScale is debug - info - warning - error - critical\n";
print "\t\tDefault is to show warnings and above\n";
print "\n";
print "Currently known commands:\n";
}
}
}
/**
* A signal that some text needs formatting, the event carries
* both the text and the result
*/
class TextFormattingEvent extends Event
{
/**
* For reference
*
* @var string
*/
public $original;
/**
* with formatting applied
*
* @var string
*/
public $formatted;
/**
* with formatting removed
*
* @var string
*/
public $stripped;
public function __construct(string $text)
{
parent::__construct();
// We need to escape before formatting, instead of at display time,
// because formatters will add their own HTML tags into the mix and
// we don't want to escape those.
$h_text = html_escape(trim($text));
$this->original = $h_text;
$this->formatted = $h_text;
$this->stripped = $h_text;
}
}
/**
* A signal that something needs logging
*/
class LogEvent extends Event
{
/**
* a category, normally the extension name
*
* @var string
*/
public $section;
/**
* See python...
*
* @var int
*/
public $priority = 0;
/**
* Free text to be logged
*
* @var string
*/
public $message;
/**
* The time that the event was created
*
* @var int
*/
public $time;
/**
* Extra data to be held separate
*
* @var array
*/
public $args;
public function __construct(string $section, int $priority, string $message)
{
parent::__construct();
$this->section = $section;
$this->priority = $priority;
$this->message = $message;
$this->time = time();
}
}
class DatabaseUpgradeEvent extends Event
{
}

View file

@ -1,29 +0,0 @@
<?php
/**
* Class SCoreException
*
* A base exception to be caught by the upper levels.
*/
class SCoreException extends Exception {}
/**
* Class PermissionDeniedException
*
* A fairly common, generic exception.
*/
class PermissionDeniedException extends SCoreException {}
/**
* Class ImageDoesNotExist
*
* This exception is used when an Image cannot be found by ID.
*
* Example: Image::by_id(-1) returns null
*/
class ImageDoesNotExist extends SCoreException {}
/*
* For validate_input()
*/
class InvalidInput extends SCoreException {}

83
core/exceptions.php Normal file
View file

@ -0,0 +1,83 @@
<?php declare(strict_types=1);
/**
* Class SCoreException
*
* A base exception to be caught by the upper levels.
*/
class SCoreException extends RuntimeException
{
/** @var string|null */
public $query;
/** @var string */
public $error;
public function __construct(string $msg, ?string $query=null)
{
parent::__construct($msg);
$this->error = $msg;
$this->query = $query;
}
}
class InstallerException extends RuntimeException
{
/** @var string */
public $title;
/** @var string */
public $body;
/** @var int */
public $code;
public function __construct(string $title, string $body, int $code)
{
parent::__construct($body);
$this->title = $title;
$this->body = $body;
$this->code = $code;
}
}
/**
* Class PermissionDeniedException
*
* A fairly common, generic exception.
*/
class PermissionDeniedException extends SCoreException
{
}
/**
* Class ImageDoesNotExist
*
* This exception is used when an Image cannot be found by ID.
*
* Example: Image::by_id(-1) returns null
*/
class ImageDoesNotExist extends SCoreException
{
}
/*
* For validate_input()
*/
class InvalidInput extends SCoreException
{
}
/*
* This is used by the image resizing code when there is not enough memory to perform a resize.
*/
class InsufficientMemoryException extends SCoreException
{
}
/*
* This is used by the image resizing code when there is an error while resizing
*/
class ImageResizeException extends SCoreException
{
}

View file

@ -1,297 +0,0 @@
<?php
/**
* \page eande Events and Extensions
*
* An event is a little blob of data saying "something happened", possibly
* "something happened, here's the specific data". Events are sent with the
* send_event() function. Since events can store data, they can be used to
* return data to the extension which sent them, for example:
*
* \code
* $tfe = new TextFormattingEvent($original_text);
* send_event($tfe);
* $formatted_text = $tfe->formatted;
* \endcode
*
* An extension is something which is capable of reacting to events.
*
*
* \page hello The Hello World Extension
*
* \code
* // ext/hello/main.php
* public class HelloEvent extends Event {
* public function __construct($username) {
* $this->username = $username;
* }
* }
*
* public class Hello extends Extension {
* public function onPageRequest(PageRequestEvent $event) { // Every time a page request is sent
* global $user; // Look at the global "currently logged in user" object
* send_event(new HelloEvent($user->name)); // Broadcast a signal saying hello to that user
* }
* public function onHello(HelloEvent $event) { // When the "Hello" signal is recieved
* $this->theme->display_hello($event->username); // Display a message on the web page
* }
* }
*
* // ext/hello/theme.php
* public class HelloTheme extends Themelet {
* public function display_hello($username) {
* global $page;
* $h_user = html_escape($username); // Escape the data before adding it to the page
* $block = new Block("Hello!", "Hello there $h_user"); // HTML-safe variables start with "h_"
* $page->add_block($block); // Add the block to the page
* }
* }
*
* // ext/hello/test.php
* public class HelloTest extends SCorePHPUnitTestCase {
* public function testHello() {
* $this->get_page("post/list"); // View a page, any page
* $this->assert_text("Hello there"); // Check that the specified text is in that page
* }
* }
*
* // themes/mytheme/hello.theme.php
* public class CustomHelloTheme extends HelloTheme { // CustomHelloTheme overrides HelloTheme
* public function display_hello($username) { // the display_hello() function is customised
* global $page;
* $h_user = html_escape($username);
* $page->add_block(new Block(
* "Hello!",
* "Hello there $h_user, look at my snazzy custom theme!"
* );
* }
* }
* \endcode
*
*/
/**
* Class Extension
*
* send_event(BlahEvent()) -> onBlah($event)
*
* Also loads the theme object into $this->theme if available
*
* The original concept came from Artanis's Extension extension
* --> http://github.com/Artanis/simple-extension/tree/master
* Then re-implemented by Shish after he broke the forum and couldn't
* find the thread where the original was posted >_<
*/
abstract class Extension {
/** @var array which DBs this ext supports (blank for 'all') */
protected $db_support = [];
/** @var Themelet this theme's Themelet object */
public $theme;
public function __construct() {
$this->theme = $this->get_theme_object(get_called_class());
}
/**
* @return boolean
*/
public function is_live() {
global $database;
return (
empty($this->db_support) ||
in_array($database->get_driver_name(), $this->db_support)
);
}
/**
* Find the theme object for a given extension.
*
* @param string $base
* @return Themelet
*/
private function get_theme_object($base) {
$custom = 'Custom'.$base.'Theme';
$normal = $base.'Theme';
if(class_exists($custom)) {
return new $custom();
}
elseif(class_exists($normal)) {
return new $normal();
}
else {
return null;
}
}
/**
* Override this to change the priority of the extension,
* lower numbered ones will recieve events first.
*
* @return int
*/
public function get_priority() {
return 50;
}
}
/**
* Class FormatterExtension
*
* Several extensions have this in common, make a common API.
*/
abstract class FormatterExtension extends Extension {
/**
* @param TextFormattingEvent $event
*/
public function onTextFormatting(TextFormattingEvent $event) {
$event->formatted = $this->format($event->formatted);
$event->stripped = $this->strip($event->stripped);
}
/**
* @param string $text
* @return string
*/
abstract public function format(/*string*/ $text);
/**
* @param string $text
* @return string
*/
abstract public function strip(/*string*/ $text);
}
/**
* Class DataHandlerExtension
*
* This too is a common class of extension with many methods in common,
* so we have a base class to extend from.
*/
abstract class DataHandlerExtension extends Extension {
/**
* @param DataUploadEvent $event
* @throws UploadException
*/
public function onDataUpload(DataUploadEvent $event) {
$supported_ext = $this->supported_ext($event->type);
$check_contents = $this->check_contents($event->tmpname);
if($supported_ext && $check_contents) {
move_upload_to_archive($event);
send_event(new ThumbnailGenerationEvent($event->hash, $event->type));
/* Check if we are replacing an image */
if(array_key_exists('replace', $event->metadata) && isset($event->metadata['replace'])) {
/* hax: This seems like such a dirty way to do this.. */
/* Validate things */
$image_id = int_escape($event->metadata['replace']);
/* Check to make sure the image exists. */
$existing = Image::by_id($image_id);
if(is_null($existing)) {
throw new UploadException("Image to replace does not exist!");
}
if ($existing->hash === $event->metadata['hash']) {
throw new UploadException("The uploaded image is the same as the one to replace.");
}
// even more hax..
$event->metadata['tags'] = $existing->get_tag_list();
$image = $this->create_image_from_data(warehouse_path("images", $event->metadata['hash']), $event->metadata);
if(is_null($image)) {
throw new UploadException("Data handler failed to create image object from data");
}
$ire = new ImageReplaceEvent($image_id, $image);
send_event($ire);
$event->image_id = $image_id;
}
else {
$image = $this->create_image_from_data(warehouse_path("images", $event->hash), $event->metadata);
if(is_null($image)) {
throw new UploadException("Data handler failed to create image object from data");
}
$iae = new ImageAdditionEvent($image);
send_event($iae);
$event->image_id = $iae->image->id;
// Rating Stuff.
if(!empty($event->metadata['rating'])){
$rating = $event->metadata['rating'];
send_event(new RatingSetEvent($image, $rating));
}
// Locked Stuff.
if(!empty($event->metadata['locked'])){
$locked = $event->metadata['locked'];
send_event(new LockSetEvent($image, !empty($locked)));
}
}
}
elseif($supported_ext && !$check_contents){
throw new UploadException("Invalid or corrupted file");
}
}
/**
* @param ThumbnailGenerationEvent $event
*/
public function onThumbnailGeneration(ThumbnailGenerationEvent $event) {
if($this->supported_ext($event->type)) {
if (method_exists($this, 'create_thumb_force') && $event->force == true) {
$this->create_thumb_force($event->hash);
}
else {
$this->create_thumb($event->hash);
}
}
}
/**
* @param DisplayingImageEvent $event
*/
public function onDisplayingImage(DisplayingImageEvent $event) {
global $page;
if($this->supported_ext($event->image->ext)) {
$this->theme->display_image($page, $event->image);
}
}
/*
public function onSetupBuilding(SetupBuildingEvent $event) {
$sb = $this->setup();
if($sb) $event->panel->add_block($sb);
}
protected function setup() {}
*/
/**
* @param string $ext
* @return bool
*/
abstract protected function supported_ext($ext);
/**
* @param string $tmpname
* @return bool
*/
abstract protected function check_contents($tmpname);
/**
* @param string $filename
* @param array $metadata
* @return Image|null
*/
abstract protected function create_image_from_data($filename, $metadata);
/**
* @param string $hash
* @return bool
*/
abstract protected function create_thumb($hash);
}

505
core/extension.php Normal file
View file

@ -0,0 +1,505 @@
<?php declare(strict_types=1);
/**
* \page eande Events and Extensions
*
* An event is a little blob of data saying "something happened", possibly
* "something happened, here's the specific data". Events are sent with the
* send_event() function. Since events can store data, they can be used to
* return data to the extension which sent them, for example:
*
* \code
* $tfe = send_event(new TextFormattingEvent($original_text));
* $formatted_text = $tfe->formatted;
* \endcode
*
* An extension is something which is capable of reacting to events.
*
*
* \page hello The Hello World Extension
*
* \code
* // ext/hello/main.php
* public class HelloEvent extends Event {
* public function __construct($username) {
* $this->username = $username;
* }
* }
*
* public class Hello extends Extension {
* public function onPageRequest(PageRequestEvent $event) { // Every time a page request is sent
* global $user; // Look at the global "currently logged in user" object
* send_event(new HelloEvent($user->name)); // Broadcast a signal saying hello to that user
* }
* public function onHello(HelloEvent $event) { // When the "Hello" signal is recieved
* $this->theme->display_hello($event->username); // Display a message on the web page
* }
* }
*
* // ext/hello/theme.php
* public class HelloTheme extends Themelet {
* public function display_hello($username) {
* global $page;
* $h_user = html_escape($username); // Escape the data before adding it to the page
* $block = new Block("Hello!", "Hello there $h_user"); // HTML-safe variables start with "h_"
* $page->add_block($block); // Add the block to the page
* }
* }
*
* // ext/hello/test.php
* public class HelloTest extends SCorePHPUnitTestCase {
* public function testHello() {
* $this->get_page("post/list"); // View a page, any page
* $this->assert_text("Hello there"); // Check that the specified text is in that page
* }
* }
*
* // themes/mytheme/hello.theme.php
* public class CustomHelloTheme extends HelloTheme { // CustomHelloTheme overrides HelloTheme
* public function display_hello($username) { // the display_hello() function is customised
* global $page;
* $h_user = html_escape($username);
* $page->add_block(new Block(
* "Hello!",
* "Hello there $h_user, look at my snazzy custom theme!"
* );
* }
* }
* \endcode
*
*/
/**
* Class Extension
*
* send_event(BlahEvent()) -> onBlah($event)
*
* Also loads the theme object into $this->theme if available
*
* The original concept came from Artanis's Extension extension
* --> http://github.com/Artanis/simple-extension/tree/master
* Then re-implemented by Shish after he broke the forum and couldn't
* find the thread where the original was posted >_<
*/
abstract class Extension
{
/** @var string */
public $key;
/** @var Themelet */
protected $theme;
/** @var ExtensionInfo */
public $info;
private static $enabled_extensions = [];
public function __construct($class = null)
{
$class = $class ?? get_called_class();
$this->theme = $this->get_theme_object($class);
$this->info = ExtensionInfo::get_for_extension_class($class);
if ($this->info===null) {
throw new ScoreException("Info class not found for extension $class");
}
$this->key = $this->info->key;
}
/**
* Find the theme object for a given extension.
*/
private function get_theme_object(string $base): ?Themelet
{
$custom = 'Custom'.$base.'Theme';
$normal = $base.'Theme';
if (class_exists($custom)) {
return new $custom();
} elseif (class_exists($normal)) {
return new $normal();
} else {
return null;
}
}
/**
* Override this to change the priority of the extension,
* lower numbered ones will receive events first.
*/
public function get_priority(): int
{
return 50;
}
public static function determine_enabled_extensions()
{
self::$enabled_extensions = [];
foreach (array_merge(
ExtensionInfo::get_core_extensions(),
explode(",", EXTRA_EXTS)
) as $key) {
$ext = ExtensionInfo::get_by_key($key);
if ($ext===null || !$ext->is_supported()) {
continue;
}
// FIXME: error if one of our dependencies isn't supported
self::$enabled_extensions[] = $ext->key;
if (!empty($ext->dependencies)) {
foreach ($ext->dependencies as $dep) {
self::$enabled_extensions[] = $dep;
}
}
}
}
public static function is_enabled(string $key): ?bool
{
return in_array($key, self::$enabled_extensions);
}
public static function get_enabled_extensions(): array
{
return self::$enabled_extensions;
}
public static function get_enabled_extensions_as_string(): string
{
return implode(",", self::$enabled_extensions);
}
protected function get_version(string $name): int
{
global $config;
return $config->get_int($name, 0);
}
protected function set_version(string $name, int $ver)
{
global $config;
$config->set_int($name, $ver);
log_info("upgrade", "Set version for $name to $ver");
}
}
abstract class ExtensionInfo
{
// Every credit you get costs us RAM. It stops now.
public const SHISH_NAME = "Shish";
public const SHISH_EMAIL = "webmaster@shishnet.org";
public const SHIMMIE_URL = "http://code.shishnet.org/shimmie2/";
public const SHISH_AUTHOR = [self::SHISH_NAME=>self::SHISH_EMAIL];
public const LICENSE_GPLV2 = "GPLv2";
public const LICENSE_MIT = "MIT";
public const LICENSE_WTFPL = "WTFPL";
public const VISIBLE_ADMIN = "admin";
public const VISIBLE_HIDDEN = "hidden";
private const VALID_VISIBILITY = [self::VISIBLE_ADMIN, self::VISIBLE_HIDDEN];
public $key;
public $core = false;
public $beta = false;
public $name;
public $authors = [];
public $link;
public $license;
public $version;
public $dependencies = [];
public $visibility;
public $description;
public $documentation;
/** @var array which DBs this ext supports (blank for 'all') */
public $db_support = [];
/** @var bool */
private $supported = null;
/** @var string */
private $support_info = null;
public function is_supported(): bool
{
if ($this->supported===null) {
$this->check_support();
}
return $this->supported;
}
public function get_support_info(): string
{
if ($this->supported===null) {
$this->check_support();
}
return $this->support_info;
}
private static $all_info_by_key = [];
private static $all_info_by_class = [];
private static $core_extensions = [];
protected function __construct()
{
assert(!empty($this->key), "key field is required");
assert(!empty($this->name), "name field is required for extension $this->key");
assert(empty($this->visibility) || in_array($this->visibility, self::VALID_VISIBILITY), "Invalid visibility for extension $this->key");
assert(is_array($this->db_support), "db_support has to be an array for extension $this->key");
assert(is_array($this->authors), "authors has to be an array for extension $this->key");
assert(is_array($this->dependencies), "dependencies has to be an array for extension $this->key");
}
public function is_enabled(): bool
{
return Extension::is_enabled($this->key);
}
private function check_support()
{
global $database;
$this->support_info = "";
if (!empty($this->db_support) && !in_array($database->get_driver_name(), $this->db_support)) {
$this->support_info .= "Database not supported. ";
}
// Additional checks here as needed
$this->supported = empty($this->support_info);
}
public static function get_all(): array
{
return array_values(self::$all_info_by_key);
}
public static function get_all_keys(): array
{
return array_keys(self::$all_info_by_key);
}
public static function get_core_extensions(): array
{
return self::$core_extensions;
}
public static function get_by_key(string $key): ?ExtensionInfo
{
if (array_key_exists($key, self::$all_info_by_key)) {
return self::$all_info_by_key[$key];
} else {
return null;
}
}
public static function get_for_extension_class(string $base): ?ExtensionInfo
{
$normal = $base.'Info';
if (array_key_exists($normal, self::$all_info_by_class)) {
return self::$all_info_by_class[$normal];
} else {
return null;
}
}
public static function load_all_extension_info()
{
foreach (getSubclassesOf("ExtensionInfo") as $class) {
$extension_info = new $class();
if (array_key_exists($extension_info->key, self::$all_info_by_key)) {
throw new ScoreException("Extension Info $class with key $extension_info->key has already been loaded");
}
self::$all_info_by_key[$extension_info->key] = $extension_info;
self::$all_info_by_class[$class] = $extension_info;
if ($extension_info->core===true) {
self::$core_extensions[] = $extension_info->key;
}
}
}
}
/**
* Class FormatterExtension
*
* Several extensions have this in common, make a common API.
*/
abstract class FormatterExtension extends Extension
{
public function onTextFormatting(TextFormattingEvent $event)
{
$event->formatted = $this->format($event->formatted);
$event->stripped = $this->strip($event->stripped);
}
abstract public function format(string $text): string;
abstract public function strip(string $text): string;
}
/**
* Class DataHandlerExtension
*
* This too is a common class of extension with many methods in common,
* so we have a base class to extend from.
*/
abstract class DataHandlerExtension extends Extension
{
protected $SUPPORTED_EXT = [];
protected function move_upload_to_archive(DataUploadEvent $event)
{
$target = warehouse_path(Image::IMAGE_DIR, $event->hash);
if (!@copy($event->tmpname, $target)) {
$errors = error_get_last();
throw new UploadException(
"Failed to copy file from uploads ({$event->tmpname}) to archive ($target): ".
"{$errors['type']} / {$errors['message']}"
);
}
}
public function onDataUpload(DataUploadEvent $event)
{
$supported_ext = $this->supported_ext($event->type);
$check_contents = $this->check_contents($event->tmpname);
if ($supported_ext && $check_contents) {
$this->move_upload_to_archive($event);
send_event(new ThumbnailGenerationEvent($event->hash, $event->type));
/* Check if we are replacing an image */
if (array_key_exists('replace', $event->metadata) && isset($event->metadata['replace'])) {
/* hax: This seems like such a dirty way to do this.. */
/* Validate things */
$image_id = int_escape($event->metadata['replace']);
/* Check to make sure the image exists. */
$existing = Image::by_id($image_id);
if (is_null($existing)) {
throw new UploadException("Image to replace does not exist!");
}
if ($existing->hash === $event->metadata['hash']) {
throw new UploadException("The uploaded image is the same as the one to replace.");
}
// even more hax..
$event->metadata['tags'] = $existing->get_tag_list();
$image = $this->create_image_from_data(warehouse_path(Image::IMAGE_DIR, $event->metadata['hash']), $event->metadata);
if (is_null($image)) {
throw new UploadException("Data handler failed to create image object from data");
}
try {
send_event(new MediaCheckPropertiesEvent($image));
} catch (MediaException $e) {
throw new UploadException("Unable to scan media properties: ".$e->getMessage());
}
send_event(new ImageReplaceEvent($image_id, $image));
$event->image_id = $image_id;
} else {
$image = $this->create_image_from_data(warehouse_path(Image::IMAGE_DIR, $event->hash), $event->metadata);
if (is_null($image)) {
throw new UploadException("Data handler failed to create image object from data");
}
try {
send_event(new MediaCheckPropertiesEvent($image));
} catch (MediaException $e) {
throw new UploadException("Unable to scan media properties: ".$e->getMessage());
}
$iae = send_event(new ImageAdditionEvent($image));
$event->image_id = $iae->image->id;
$event->merged = $iae->merged;
// Rating Stuff.
if (!empty($event->metadata['rating'])) {
$rating = $event->metadata['rating'];
send_event(new RatingSetEvent($image, $rating));
}
// Locked Stuff.
if (!empty($event->metadata['locked'])) {
$locked = $event->metadata['locked'];
send_event(new LockSetEvent($image, !empty($locked)));
}
}
} elseif ($supported_ext && !$check_contents) {
// We DO support this extension - but the file looks corrupt
throw new UploadException("Invalid or corrupted file");
}
}
public function onThumbnailGeneration(ThumbnailGenerationEvent $event)
{
$result = false;
if ($this->supported_ext($event->type)) {
if ($event->force) {
$result = $this->create_thumb($event->hash, $event->type);
} else {
$outname = warehouse_path(Image::THUMBNAIL_DIR, $event->hash);
if (file_exists($outname)) {
return;
}
$result = $this->create_thumb($event->hash, $event->type);
}
}
if ($result) {
$event->generated = true;
}
}
public function onDisplayingImage(DisplayingImageEvent $event)
{
global $page;
if ($this->supported_ext($event->image->ext)) {
/** @noinspection PhpPossiblePolymorphicInvocationInspection */
$this->theme->display_image($page, $event->image);
}
}
public function onMediaCheckProperties(MediaCheckPropertiesEvent $event)
{
if ($this->supported_ext($event->ext)) {
$this->media_check_properties($event);
}
}
protected function create_image_from_data(string $filename, array $metadata): Image
{
global $config;
$image = new Image();
$image->filesize = $metadata['size'];
$image->hash = $metadata['hash'];
$image->filename = (($pos = strpos($metadata['filename'], '?')) !== false) ? substr($metadata['filename'], 0, $pos) : $metadata['filename'];
if ($config->get_bool("upload_use_mime")) {
$image->ext = get_extension(getMimeType($filename));
} else {
$image->ext = (($pos = strpos($metadata['extension'], '?')) !== false) ? substr($metadata['extension'], 0, $pos) : $metadata['extension'];
}
$image->tag_array = is_array($metadata['tags']) ? $metadata['tags'] : Tag::explode($metadata['tags']);
$image->source = $metadata['source'];
return $image;
}
abstract protected function media_check_properties(MediaCheckPropertiesEvent $event): void;
abstract protected function check_contents(string $tmpname): bool;
abstract protected function create_thumb(string $hash, string $type): bool;
protected function supported_ext(string $ext): bool
{
return in_array(strtolower($ext), $this->SUPPORTED_EXT);
}
public static function get_all_supported_exts(): array
{
$arr = [];
foreach (getSubclassesOf("DataHandlerExtension") as $handler) {
$arr = array_merge($arr, (new $handler())->SUPPORTED_EXT);
}
return $arr;
}
}

File diff suppressed because it is too large Load diff

149
core/imageboard/event.php Normal file
View file

@ -0,0 +1,149 @@
<?php declare(strict_types=1);
/**
* An image is being added to the database.
*/
class ImageAdditionEvent extends Event
{
/** @var User */
public $user;
/** @var Image */
public $image;
public $merged = false;
/**
* Inserts a new image into the database with its associated
* information. Also calls TagSetEvent to set the tags for
* this new image.
*/
public function __construct(Image $image)
{
parent::__construct();
$this->image = $image;
}
}
class ImageAdditionException extends SCoreException
{
}
/**
* An image is being deleted.
*/
class ImageDeletionEvent extends Event
{
/** @var Image */
public $image;
/** @var bool */
public $force = false;
/**
* Deletes an image.
*
* Used by things like tags and comments handlers to
* clean out related rows in their tables.
*/
public function __construct(Image $image, bool $force = false)
{
parent::__construct();
$this->image = $image;
$this->force = $force;
}
}
/**
* An image is being replaced.
*/
class ImageReplaceEvent extends Event
{
/** @var int */
public $id;
/** @var Image */
public $image;
/**
* Replaces an image.
*
* Updates an existing ID in the database to use a new image
* file, leaving the tags and such unchanged. Also removes
* the old image file and thumbnail from the disk.
*/
public function __construct(int $id, Image $image)
{
parent::__construct();
$this->id = $id;
$this->image = $image;
}
}
class ImageReplaceException extends SCoreException
{
}
/**
* Request a thumbnail be made for an image object.
*/
class ThumbnailGenerationEvent extends Event
{
/** @var string */
public $hash;
/** @var string */
public $type;
/** @var bool */
public $force;
/** @var bool */
public $generated;
/**
* Request a thumbnail be made for an image object
*/
public function __construct(string $hash, string $type, bool $force=false)
{
parent::__construct();
$this->hash = $hash;
$this->type = $type;
$this->force = $force;
$this->generated = false;
}
}
/*
* ParseLinkTemplateEvent:
* $link -- the formatted text (with each element URL Escape'd)
* $text -- the formatted text (not escaped)
* $original -- the formatting string, for reference
* $image -- the image who's link is being parsed
*/
class ParseLinkTemplateEvent extends Event
{
/** @var string */
public $link;
/** @var string */
public $text;
/** @var string */
public $original;
/** @var Image */
public $image;
public function __construct(string $link, Image $image)
{
parent::__construct();
$this->link = $link;
$this->text = $link;
$this->original = $link;
$this->image = $image;
}
public function replace(string $needle, ?string $replace): void
{
if (!is_null($replace)) {
$this->link = str_replace($needle, url_escape($replace), $this->link);
$this->text = str_replace($needle, $replace, $this->text);
}
}
}

983
core/imageboard/image.php Normal file
View file

@ -0,0 +1,983 @@
<?php declare(strict_types=1);
/**
* Class Image
*
* An object representing an entry in the images table.
*
* As of 2.2, this no longer necessarily represents an
* image per se, but could be a video, sound file, or any
* other supported upload type.
*/
class Image
{
public const IMAGE_DIR = "images";
public const THUMBNAIL_DIR = "thumbs";
public static $order_sql = null; // this feels ugly
/** @var null|int */
public $id = null;
/** @var int */
public $height;
/** @var int */
public $width;
/** @var string */
public $hash;
/** @var int */
public $filesize;
/** @var string */
public $filename;
/** @var string */
public $ext;
/** @var string[]|null */
public $tag_array;
/** @var int */
public $owner_id;
/** @var string */
public $owner_ip;
/** @var string */
public $posted;
/** @var string */
public $source;
/** @var boolean */
public $locked = false;
/** @var boolean */
public $lossless = null;
/** @var boolean */
public $video = null;
/** @var boolean */
public $image = null;
/** @var boolean */
public $audio = null;
/** @var int */
public $length = null;
public static $bool_props = ["locked", "lossless", "video", "audio"];
public static $int_props = ["id", "owner_id", "height", "width", "filesize", "length"];
/**
* One will very rarely construct an image directly, more common
* would be to use Image::by_id, Image::by_hash, etc.
*/
public function __construct(?array $row=null)
{
if (!is_null($row)) {
foreach ($row as $name => $value) {
// some databases use table.name rather than name
$name = str_replace("images.", "", $name);
// hax, this is likely the cause of much scrutinizer-ci complaints.
if (is_null($value)) {
$this->$name = null;
} elseif (in_array($name, self::$bool_props)) {
$this->$name = bool_escape((string)$value);
} elseif (in_array($name, self::$int_props)) {
$this->$name = int_escape((string)$value);
} else {
$this->$name = $value;
}
}
}
}
public static function by_id(int $id): ?Image
{
global $database;
$row = $database->get_row("SELECT * FROM images WHERE images.id=:id", ["id"=>$id]);
return ($row ? new Image($row) : null);
}
public static function by_hash(string $hash): ?Image
{
global $database;
$hash = strtolower($hash);
$row = $database->get_row("SELECT images.* FROM images WHERE hash=:hash", ["hash"=>$hash]);
return ($row ? new Image($row) : null);
}
public static function by_id_or_hash(string $id): ?Image
{
return (is_numeric($id) && strlen($id) != 32) ? Image::by_id((int)$id) : Image::by_hash($id);
}
public static function by_random(array $tags=[], int $limit_range=0): ?Image
{
$max = Image::count_images($tags);
if ($max < 1) {
return null;
} // From Issue #22 - opened by HungryFeline on May 30, 2011.
if ($limit_range > 0 && $max > $limit_range) {
$max = $limit_range;
}
$rand = mt_rand(0, $max-1);
$set = Image::find_images($rand, 1, $tags);
if (count($set) > 0) {
return $set[0];
} else {
return null;
}
}
private static function find_images_internal(int $start = 0, ?int $limit = null, array $tags=[]): iterable
{
global $database, $user, $config;
if ($start < 0) {
$start = 0;
}
if ($limit!=null && $limit < 1) {
$limit = 1;
}
if (SPEED_HAX) {
if (!$user->can(Permissions::BIG_SEARCH) and count($tags) > 3) {
throw new SCoreException("Anonymous users may only search for up to 3 tags at a time");
}
}
$order = (Image::$order_sql ?: "images.".$config->get_string(IndexConfig::ORDER));
$querylet = Image::build_search_querylet($tags, $order, $limit, $start);
$result = $database->get_all_iterable($querylet->sql, $querylet->variables);
Image::$order_sql = null;
return $result;
}
/**
* Search for an array of images
*
* #param string[] $tags
* #return Image[]
*/
public static function find_images(int $start, ?int $limit = null, array $tags=[]): array
{
$result = self::find_images_internal($start, $limit, $tags);
$images = [];
foreach ($result as $row) {
$images[] = new Image($row);
}
return $images;
}
/**
* Search for an array of images, returning a iterable object of Image
*/
public static function find_images_iterable(int $start = 0, ?int $limit = null, array $tags=[]): Generator
{
$result = self::find_images_internal($start, $limit, $tags);
foreach ($result as $row) {
yield new Image($row);
}
}
/*
* Image-related utility functions
*/
public static function count_total_images(): int
{
global $cache, $database;
$total = $cache->get("image-count");
if (!$total) {
$total = (int)$database->get_one("SELECT COUNT(*) FROM images");
$cache->set("image-count", $total, 600);
}
return $total;
}
public static function count_tag(string $tag): int
{
global $database;
return (int)$database->get_one(
"SELECT count FROM tags WHERE LOWER(tag) = LOWER(:tag)",
["tag"=>$tag]
);
}
/**
* Count the number of image results for a given search
*
* #param string[] $tags
*/
public static function count_images(array $tags=[]): int
{
global $cache, $database;
$tag_count = count($tags);
if ($tag_count === 0) {
// total number of images in the DB
$total = self::count_total_images();
} elseif ($tag_count === 1 && !preg_match("/[:=><\*\?]/", $tags[0])) {
if (!startsWith($tags[0], "-")) {
// one tag - we can look that up directly
$total = self::count_tag($tags[0]);
} else {
// one negative tag - subtract from the total
$total = self::count_total_images() - self::count_tag(substr($tags[0], 1));
}
} else {
// complex query
// implode(tags) can be too long for memcache...
$cache_key = "image-count:" . md5(Tag::implode($tags));
$total = $cache->get($cache_key);
if (!$total) {
if (Extension::is_enabled(RatingsInfo::KEY)) {
$tags[] = "rating:*";
}
$querylet = Image::build_search_querylet($tags);
$total = (int)$database->get_one("SELECT COUNT(*) AS cnt FROM ($querylet->sql) AS tbl", $querylet->variables);
if (SPEED_HAX && $total > 5000) {
// when we have a ton of images, the count
// won't change dramatically very often
$cache->set($cache_key, $total, 3600);
}
}
}
if (is_null($total)) {
return 0;
}
return $total;
}
/**
* Count the number of pages for a given search
*
* #param string[] $tags
*/
public static function count_pages(array $tags=[]): int
{
global $config;
return (int)ceil(Image::count_images($tags) / $config->get_int(IndexConfig::IMAGES));
}
private static function terms_to_conditions(array $terms): array
{
$tag_conditions = [];
$img_conditions = [];
/*
* Turn a bunch of strings into a bunch of TagCondition
* and ImgCondition objects
*/
$stpe = send_event(new SearchTermParseEvent(null, $terms));
if ($stpe->is_querylet_set()) {
foreach ($stpe->get_querylets() as $querylet) {
$img_conditions[] = new ImgCondition($querylet, true);
}
}
foreach ($terms as $term) {
$positive = true;
if (is_string($term) && !empty($term) && ($term[0] == '-')) {
$positive = false;
$term = substr($term, 1);
}
if (strlen($term) === 0) {
continue;
}
$stpe = send_event(new SearchTermParseEvent($term, $terms));
if ($stpe->is_querylet_set()) {
foreach ($stpe->get_querylets() as $querylet) {
$img_conditions[] = new ImgCondition($querylet, $positive);
}
} else {
// if the whole match is wild, skip this
if (str_replace("*", "", $term) != "") {
$tag_conditions[] = new TagCondition($term, $positive);
}
}
}
return [$tag_conditions, $img_conditions];
}
/*
* Accessors & mutators
*/
/**
* Find the next image in the sequence.
*
* Rather than simply $this_id + 1, one must take into account
* deleted images and search queries
*
* #param string[] $tags
*/
public function get_next(array $tags=[], bool $next=true): ?Image
{
global $database;
if ($next) {
$gtlt = "<";
$dir = "DESC";
} else {
$gtlt = ">";
$dir = "ASC";
}
if (count($tags) === 0) {
$row = $database->get_row('
SELECT images.*
FROM images
WHERE images.id '.$gtlt.' '.$this->id.'
ORDER BY images.id '.$dir.'
LIMIT 1
');
} else {
$tags[] = 'id'. $gtlt . $this->id;
$querylet = Image::build_search_querylet($tags);
$querylet->append_sql(' ORDER BY images.id '.$dir.' LIMIT 1');
$row = $database->get_row($querylet->sql, $querylet->variables);
}
return ($row ? new Image($row) : null);
}
/**
* The reverse of get_next
*
* #param string[] $tags
*/
public function get_prev(array $tags=[]): ?Image
{
return $this->get_next($tags, false);
}
/**
* Find the User who owns this Image
*/
public function get_owner(): User
{
return User::by_id($this->owner_id);
}
/**
* Set the image's owner.
*/
public function set_owner(User $owner): void
{
global $database;
if ($owner->id != $this->owner_id) {
$database->execute("
UPDATE images
SET owner_id=:owner_id
WHERE id=:id
", ["owner_id"=>$owner->id, "id"=>$this->id]);
log_info("core_image", "Owner for Image #{$this->id} set to {$owner->name}");
}
}
public function save_to_db()
{
global $database, $user;
$cut_name = substr($this->filename, 0, 255);
if (is_null($this->id)) {
$database->execute(
"INSERT INTO images(
owner_id, owner_ip,
filename, filesize,
hash, ext,
width, height,
posted, source
)
VALUES (
:owner_id, :owner_ip,
:filename, :filesize,
:hash, :ext,
0, 0,
now(), :source
)",
[
"owner_id" => $user->id, "owner_ip" => $_SERVER['REMOTE_ADDR'],
"filename" => $cut_name, "filesize" => $this->filesize,
"hash" => $this->hash, "ext" => strtolower($this->ext),
"source" => $this->source
]
);
$this->id = $database->get_last_insert_id('images_id_seq');
} else {
$database->execute(
"UPDATE images SET ".
"filename = :filename, filesize = :filesize, hash = :hash, ".
"ext = :ext, width = 0, height = 0, source = :source ".
"WHERE id = :id",
[
"filename" => $cut_name,
"filesize" => $this->filesize,
"hash" => $this->hash,
"ext" => strtolower($this->ext),
"source" => $this->source,
"id" => $this->id,
]
);
}
$database->execute(
"UPDATE images SET ".
"lossless = :lossless, ".
"video = :video, audio = :audio,image = :image, ".
"height = :height, width = :width, ".
"length = :length WHERE id = :id",
[
"id" => $this->id,
"width" => $this->width ?? 0,
"height" => $this->height ?? 0,
"lossless" => $database->scoresql_value_prepare($this->lossless),
"video" => $database->scoresql_value_prepare($this->video),
"image" => $database->scoresql_value_prepare($this->image),
"audio" => $database->scoresql_value_prepare($this->audio),
"length" => $this->length
]
);
}
/**
* Get this image's tags as an array.
*
* #return string[]
*/
public function get_tag_array(): array
{
global $database;
if (!isset($this->tag_array)) {
$this->tag_array = $database->get_col("
SELECT tag
FROM image_tags
JOIN tags ON image_tags.tag_id = tags.id
WHERE image_id=:id
ORDER BY tag
", ["id"=>$this->id]);
}
return $this->tag_array;
}
/**
* Get this image's tags as a string.
*/
public function get_tag_list(): string
{
return Tag::implode($this->get_tag_array());
}
/**
* Get the URL for the full size image
*/
public function get_image_link(): string
{
return $this->get_link(ImageConfig::ILINK, '_images/$hash/$id%20-%20$tags.$ext', 'image/$id.$ext');
}
/**
* Get the nicely formatted version of the file name
*/
public function get_nice_image_name(): string
{
$plte = new ParseLinkTemplateEvent('$id - $tags.$ext', $this);
send_event($plte);
return $plte->text;
}
/**
* Get the URL for the thumbnail
*/
public function get_thumb_link(): string
{
global $config;
$ext = $config->get_string(ImageConfig::THUMB_TYPE);
return $this->get_link(ImageConfig::TLINK, '_thumbs/$hash/thumb.'.$ext, 'thumb/$id.'.$ext);
}
/**
* Check configured template for a link, then try nice URL, then plain URL
*/
private function get_link(string $template, string $nice, string $plain): string
{
global $config;
$image_link = $config->get_string($template);
if (!empty($image_link)) {
if (!(strpos($image_link, "://") > 0) && !startsWith($image_link, "/")) {
$image_link = make_link($image_link);
}
$chosen = $image_link;
} elseif ($config->get_bool('nice_urls', false)) {
$chosen = make_link($nice);
} else {
$chosen = make_link($plain);
}
return $this->parse_link_template($chosen);
}
/**
* Get the tooltip for this image, formatted according to the
* configured template.
*/
public function get_tooltip(): string
{
global $config;
$plte = new ParseLinkTemplateEvent($config->get_string(ImageConfig::TIP), $this);
send_event($plte);
return $plte->text;
}
/**
* Figure out where the full size image is on disk.
*/
public function get_image_filename(): string
{
return warehouse_path(self::IMAGE_DIR, $this->hash);
}
/**
* Figure out where the thumbnail is on disk.
*/
public function get_thumb_filename(): string
{
return warehouse_path(self::THUMBNAIL_DIR, $this->hash);
}
/**
* Get the original filename.
*/
public function get_filename(): string
{
return $this->filename;
}
/**
* Get the image's mime type.
*/
public function get_mime_type(): string
{
return getMimeType($this->get_image_filename(), $this->get_ext());
}
/**
* Get the image's filename extension
*/
public function get_ext(): string
{
return $this->ext;
}
/**
* Get the image's source URL
*/
public function get_source(): ?string
{
return $this->source;
}
/**
* Set the image's source URL
*/
public function set_source(string $new_source): void
{
global $database;
$old_source = $this->source;
if (empty($new_source)) {
$new_source = null;
}
if ($new_source != $old_source) {
$database->execute("UPDATE images SET source=:source WHERE id=:id", ["source"=>$new_source, "id"=>$this->id]);
log_info("core_image", "Source for Image #{$this->id} set to: $new_source (was $old_source)");
}
}
/**
* Check if the image is locked.
*/
public function is_locked(): bool
{
return $this->locked;
}
public function set_locked(bool $tf): void
{
global $database;
$ln = $tf ? "Y" : "N";
$sln = $database->scoreql_to_sql('SCORE_BOOL_'.$ln);
$sln = str_replace("'", "", $sln);
$sln = str_replace('"', "", $sln);
if (bool_escape($sln) !== $this->locked) {
$database->execute("UPDATE images SET locked=:yn WHERE id=:id", ["yn"=>$sln, "id"=>$this->id]);
log_info("core_image", "Setting Image #{$this->id} lock to: $ln");
}
}
/**
* Delete all tags from this image.
*
* Normally in preparation to set them to a new set.
*/
public function delete_tags_from_image(): void
{
global $database;
if ($database->get_driver_name() == DatabaseDriver::MYSQL) {
//mysql < 5.6 has terrible subquery optimization, using EXISTS / JOIN fixes this
$database->execute(
"
UPDATE tags t
INNER JOIN image_tags it ON t.id = it.tag_id
SET count = count - 1
WHERE it.image_id = :id",
["id"=>$this->id]
);
} else {
$database->execute("
UPDATE tags
SET count = count - 1
WHERE id IN (
SELECT tag_id
FROM image_tags
WHERE image_id = :id
)
", ["id"=>$this->id]);
}
$database->execute("
DELETE
FROM image_tags
WHERE image_id=:id
", ["id"=>$this->id]);
}
/**
* Set the tags for this image.
*/
public function set_tags(array $unfiltered_tags): void
{
global $cache, $database, $page;
$unfiltered_tags = array_unique($unfiltered_tags);
$tags = [];
foreach ($unfiltered_tags as $tag) {
if (mb_strlen($tag, 'UTF-8') > 255) {
$page->flash("Can't set a tag longer than 255 characters");
continue;
}
if (startsWith($tag, "-")) {
$page->flash("Can't set a tag which starts with a minus");
continue;
}
$tags[] = $tag;
}
if (count($tags) <= 0) {
throw new SCoreException('Tried to set zero tags');
}
if (Tag::implode($tags) != $this->get_tag_list()) {
// delete old
$this->delete_tags_from_image();
$written_tags = [];
// insert each new tags
foreach ($tags as $tag) {
$id = $database->get_one(
"
SELECT id
FROM tags
WHERE LOWER(tag) = LOWER(:tag)
",
["tag"=>$tag]
);
if (empty($id)) {
// a new tag
$database->execute(
"INSERT INTO tags(tag) VALUES (:tag)",
["tag"=>$tag]
);
$database->execute(
"INSERT INTO image_tags(image_id, tag_id)
VALUES(:id, (SELECT id FROM tags WHERE LOWER(tag) = LOWER(:tag)))",
["id"=>$this->id, "tag"=>$tag]
);
} else {
// check if tag has already been written
if (in_array($id, $written_tags)) {
continue;
}
$database->execute("
INSERT INTO image_tags(image_id, tag_id)
VALUES(:iid, :tid)
", ["iid"=>$this->id, "tid"=>$id]);
array_push($written_tags, $id);
}
$database->execute(
"
UPDATE tags
SET count = count + 1
WHERE LOWER(tag) = LOWER(:tag)
",
["tag"=>$tag]
);
}
log_info("core_image", "Tags for Image #{$this->id} set to: ".Tag::implode($tags));
$cache->delete("image-{$this->id}-tags");
}
}
/**
* Delete this image from the database and disk
*/
public function delete(): void
{
global $database;
$this->delete_tags_from_image();
$database->execute("DELETE FROM images WHERE id=:id", ["id"=>$this->id]);
log_info("core_image", 'Deleted Image #'.$this->id.' ('.$this->hash.')');
unlink($this->get_image_filename());
unlink($this->get_thumb_filename());
}
/**
* This function removes an image (and thumbnail) from the DISK ONLY.
* It DOES NOT remove anything from the database.
*/
public function remove_image_only(): void
{
log_info("core_image", 'Removed Image File ('.$this->hash.')');
@unlink($this->get_image_filename());
@unlink($this->get_thumb_filename());
}
public function parse_link_template(string $tmpl, int $n=0): string
{
$plte = send_event(new ParseLinkTemplateEvent($tmpl, $this));
$tmpl = $plte->link;
return load_balance_url($tmpl, $this->hash, $n);
}
private static function tag_or_wildcard_to_ids(string $tag): array
{
global $database;
$sq = "SELECT id FROM tags WHERE LOWER(tag) LIKE LOWER(:tag)";
if ($database->get_driver_name() === DatabaseDriver::SQLITE) {
$sq .= "ESCAPE '\\'";
}
return $database->get_col($sq, ["tag" => Tag::sqlify($tag)]);
}
/**
* #param string[] $terms
*/
private static function build_search_querylet(
array $tags,
?string $order=null,
?int $limit=null,
?int $offset=null
): Querylet {
list($tag_conditions, $img_conditions) = self::terms_to_conditions($tags);
$positive_tag_count = 0;
$negative_tag_count = 0;
foreach ($tag_conditions as $tq) {
if ($tq->positive) {
$positive_tag_count++;
} else {
$negative_tag_count++;
}
}
/*
* Turn a bunch of Querylet objects into a base query
*
* Must follow the format
*
* SELECT images.*
* FROM (...) AS images
* WHERE (...)
*
* ie, return a set of images.* columns, and end with a WHERE
*/
// no tags, do a simple search
if ($positive_tag_count === 0 && $negative_tag_count === 0) {
$query = new Querylet("SELECT images.* FROM images WHERE 1=1");
}
// one tag sorted by ID - we can fetch this from the image_tags table,
// and do the offset / limit there, which is 10x faster than fetching
// all the image_tags and doing the offset / limit on the result.
elseif (
(
($positive_tag_count === 1 && $negative_tag_count === 0)
|| ($positive_tag_count === 0 && $negative_tag_count === 1)
)
&& empty($img_conditions)
&& ($order == "id DESC" || $order == "images.id DESC")
&& !is_null($offset)
&& !is_null($limit)
) {
$in = $positive_tag_count === 1 ? "IN" : "NOT IN";
// IN (SELECT id FROM tags) is 100x slower than doing a separate
// query and then a second query for IN(first_query_results)??
$tag_array = self::tag_or_wildcard_to_ids($tag_conditions[0]->tag);
if (count($tag_array) == 0) {
if ($positive_tag_count == 1) {
$query = new Querylet("SELECT images.* FROM images WHERE 1=0");
} else {
$query = new Querylet("SELECT images.* FROM images WHERE 1=1");
}
} else {
$set = implode(', ', $tag_array);
$query = new Querylet("
SELECT images.*
FROM images INNER JOIN (
SELECT it.image_id
FROM image_tags it
WHERE it.tag_id $in ($set)
ORDER BY it.image_id DESC
LIMIT :limit OFFSET :offset
) a on a.image_id = images.id
ORDER BY images.id DESC
", ["limit"=>$limit, "offset"=>$offset]);
// don't offset at the image level because
// we already offset at the image_tags level
$order = null;
$limit = null;
$offset = null;
}
}
// more than one positive tag, or more than zero negative tags
else {
$positive_tag_id_array = [];
$positive_wildcard_id_array = [];
$negative_tag_id_array = [];
foreach ($tag_conditions as $tq) {
$tag_ids = self::tag_or_wildcard_to_ids($tq->tag);
$tag_count = count($tag_ids);
if ($tq->positive) {
if ($tag_count== 0) {
# one of the positive tags had zero results, therefor there
# can be no results; "where 1=0" should shortcut things
return new Querylet("SELECT images.* FROM images WHERE 1=0");
} elseif ($tag_count==1) {
// All wildcard terms that qualify for a single tag can be treated the same as non-wildcards
$positive_tag_id_array[] = $tag_ids[0];
} else {
// Terms that resolve to multiple tags act as an OR within themselves
// and as an AND in relation to all other terms,
$positive_wildcard_id_array[] = $tag_ids;
}
} else {
// Unlike positive criteria, negative criteria are all handled in an OR fashion,
// so we can just compile them all into a single sub-query.
$negative_tag_id_array = array_merge($negative_tag_id_array, $tag_ids);
}
}
assert($positive_tag_id_array || $positive_wildcard_id_array || $negative_tag_id_array, @$_GET['q']);
if (!empty($positive_tag_id_array) || !empty($positive_wildcard_id_array)) {
$inner_joins = [];
if (!empty($positive_tag_id_array)) {
foreach ($positive_tag_id_array as $tag) {
$inner_joins[] = "= $tag";
}
}
if (!empty($positive_wildcard_id_array)) {
foreach ($positive_wildcard_id_array as $tags) {
$positive_tag_id_list = join(', ', $tags);
$inner_joins[] = "IN ($positive_tag_id_list)";
}
}
$first = array_shift($inner_joins);
$sub_query = "SELECT it.image_id FROM image_tags it ";
$i = 0;
foreach ($inner_joins as $inner_join) {
$i++;
$sub_query .= " INNER JOIN image_tags it$i ON it$i.image_id = it.image_id AND it$i.tag_id $inner_join ";
}
if (!empty($negative_tag_id_array)) {
$negative_tag_id_list = join(', ', $negative_tag_id_array);
$sub_query .= " LEFT JOIN image_tags negative ON negative.image_id = it.image_id AND negative.tag_id IN ($negative_tag_id_list) ";
}
$sub_query .= "WHERE it.tag_id $first ";
if (!empty($negative_tag_id_array)) {
$sub_query .= " AND negative.image_id IS NULL";
}
$sub_query .= " GROUP BY it.image_id ";
$query = new Querylet("
SELECT images.*
FROM images
INNER JOIN ($sub_query) a on a.image_id = images.id
");
} elseif (!empty($negative_tag_id_array)) {
$negative_tag_id_list = join(', ', $negative_tag_id_array);
$query = new Querylet("
SELECT images.*
FROM images
LEFT JOIN image_tags negative ON negative.image_id = images.id AND negative.tag_id in ($negative_tag_id_list)
WHERE negative.image_id IS NULL
");
} else {
throw new SCoreException("No criteria specified");
}
}
/*
* Merge all the image metadata searches into one generic querylet
* and append to the base querylet with "AND blah"
*/
if (!empty($img_conditions)) {
$n = 0;
$img_sql = "";
$img_vars = [];
foreach ($img_conditions as $iq) {
if ($n++ > 0) {
$img_sql .= " AND";
}
if (!$iq->positive) {
$img_sql .= " NOT";
}
$img_sql .= " (" . $iq->qlet->sql . ")";
$img_vars = array_merge($img_vars, $iq->qlet->variables);
}
$query->append_sql(" AND ");
$query->append(new Querylet($img_sql, $img_vars));
}
if (!is_null($order)) {
$query->append(new Querylet(" ORDER BY ".$order));
}
if (!is_null($limit)) {
$query->append(new Querylet(" LIMIT :limit ", ["limit" => $limit]));
$query->append(new Querylet(" OFFSET :offset ", ["offset" => $offset]));
}
return $query;
}
}

162
core/imageboard/misc.php Normal file
View file

@ -0,0 +1,162 @@
<?php declare(strict_types=1);
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\
* Misc functions *
\* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/**
* Add a directory full of images
*
* @param string $base
* @return array
*/
function add_dir(string $base): array
{
$results = [];
foreach (list_files($base) as $full_path) {
$short_path = str_replace($base, "", $full_path);
$filename = basename($full_path);
$tags = path_to_tags($short_path);
$result = "$short_path (".str_replace(" ", ", ", $tags).")... ";
try {
add_image($full_path, $filename, $tags);
$result .= "ok";
} catch (UploadException $ex) {
$result .= "failed: ".$ex->getMessage();
}
$results[] = $result;
}
return $results;
}
/**
* Sends a DataUploadEvent for a file.
*
* @param string $tmpname
* @param string $filename
* @param string $tags
* @throws UploadException
*/
function add_image(string $tmpname, string $filename, string $tags): void
{
assert(file_exists($tmpname));
$pathinfo = pathinfo($filename);
$metadata = [];
$metadata['filename'] = $pathinfo['basename'];
if (array_key_exists('extension', $pathinfo)) {
$metadata['extension'] = $pathinfo['extension'];
}
$metadata['tags'] = Tag::explode($tags);
$metadata['source'] = null;
send_event(new DataUploadEvent($tmpname, $metadata));
}
/**
* Given a full size pair of dimensions, return a pair scaled down to fit
* into the configured thumbnail square, with ratio intact.
* Optionally uses the High-DPI scaling setting to adjust the final resolution.
*
* @param int $orig_width
* @param int $orig_height
* @param bool $use_dpi_scaling Enables the High-DPI scaling.
* @return array
*/
function get_thumbnail_size(int $orig_width, int $orig_height, bool $use_dpi_scaling = false): array
{
global $config;
if ($orig_width === 0) {
$orig_width = 192;
}
if ($orig_height === 0) {
$orig_height = 192;
}
if ($orig_width > $orig_height * 5) {
$orig_width = $orig_height * 5;
}
if ($orig_height > $orig_width * 5) {
$orig_height = $orig_width * 5;
}
if ($use_dpi_scaling) {
list($max_width, $max_height) = get_thumbnail_max_size_scaled();
} else {
$max_width = $config->get_int(ImageConfig::THUMB_WIDTH);
$max_height = $config->get_int(ImageConfig::THUMB_HEIGHT);
}
$output = get_scaled_by_aspect_ratio($orig_width, $orig_height, $max_width, $max_height);
if ($output[2] > 1 && $config->get_bool('thumb_upscale')) {
return [(int)$orig_width, (int)$orig_height];
} else {
return $output;
}
}
function get_scaled_by_aspect_ratio(int $original_width, int $original_height, int $max_width, int $max_height) : array
{
$xscale = ($max_width/ $original_width);
$yscale = ($max_height/ $original_height);
$scale = ($yscale < $xscale) ? $yscale : $xscale ;
return [(int)($original_width*$scale), (int)($original_height*$scale), $scale];
}
/**
* Fetches the thumbnails height and width settings and applies the High-DPI scaling setting before returning the dimensions.
*
* @return array [width, height]
*/
function get_thumbnail_max_size_scaled(): array
{
global $config;
$scaling = $config->get_int(ImageConfig::THUMB_SCALING);
$max_width = $config->get_int(ImageConfig::THUMB_WIDTH) * ($scaling/100);
$max_height = $config->get_int(ImageConfig::THUMB_HEIGHT) * ($scaling/100);
return [$max_width, $max_height];
}
function create_image_thumb(string $hash, string $type, string $engine = null)
{
$inname = warehouse_path(Image::IMAGE_DIR, $hash);
$outname = warehouse_path(Image::THUMBNAIL_DIR, $hash);
$tsize = get_thumbnail_max_size_scaled();
create_scaled_image($inname, $outname, $tsize, $type, $engine);
}
function create_scaled_image(string $inname, string $outname, array $tsize, string $type, ?string $engine)
{
global $config;
if (empty($engine)) {
$engine = $config->get_string(ImageConfig::THUMB_ENGINE);
}
$output_format = $config->get_string(ImageConfig::THUMB_TYPE);
if ($output_format=="webp") {
$output_format = Media::WEBP_LOSSY;
}
send_event(new MediaResizeEvent(
$engine,
$inname,
$type,
$outname,
$tsize[0],
$tsize[1],
false,
$output_format,
$config->get_int(ImageConfig::THUMB_QUALITY),
true,
$config->get_bool('thumb_upscale', false)
));
}

View file

@ -0,0 +1,58 @@
<?php declare(strict_types=1);
class Querylet
{
/** @var string */
public $sql;
/** @var array */
public $variables;
public function __construct(string $sql, array $variables=[])
{
$this->sql = $sql;
$this->variables = $variables;
}
public function append(Querylet $querylet): void
{
$this->sql .= $querylet->sql;
$this->variables = array_merge($this->variables, $querylet->variables);
}
public function append_sql(string $sql): void
{
$this->sql .= $sql;
}
public function add_variable($var): void
{
$this->variables[] = $var;
}
}
class TagCondition
{
/** @var string */
public $tag;
/** @var bool */
public $positive;
public function __construct(string $tag, bool $positive)
{
$this->tag = $tag;
$this->positive = $positive;
}
}
class ImgCondition
{
/** @var Querylet */
public $qlet;
/** @var bool */
public $positive;
public function __construct(Querylet $qlet, bool $positive)
{
$this->qlet = $qlet;
$this->positive = $positive;
}
}

218
core/imageboard/tag.php Normal file
View file

@ -0,0 +1,218 @@
<?php declare(strict_types=1);
/**
* Class Tag
*
* A class for organising the tag related functions.
*
* All the methods are static, one should never actually use a tag object.
*
*/
class Tag
{
public static function implode(array $tags): string
{
sort($tags);
$tags = implode(' ', $tags);
return $tags;
}
/**
* Turn a human-supplied string into a valid tag array.
*
* #return string[]
*/
public static function explode(string $tags, bool $tagme=true): array
{
global $database;
$tags = explode(' ', trim($tags));
/* sanitise by removing invisible / dodgy characters */
$tag_array = self::sanitize_array($tags);
/* if user supplied a blank string, add "tagme" */
if (count($tag_array) === 0 && $tagme) {
$tag_array = ["tagme"];
}
/* resolve aliases */
$new = [];
$i = 0;
$tag_count = count($tag_array);
while ($i<$tag_count) {
$tag = $tag_array[$i];
$negative = '';
if (!empty($tag) && ($tag[0] == '-')) {
$negative = '-';
$tag = substr($tag, 1);
}
$newtags = $database->get_one(
"
SELECT newtag
FROM aliases
WHERE LOWER(oldtag)=LOWER(:tag)
",
["tag"=>$tag]
);
if (empty($newtags)) {
//tag has no alias, use old tag
$aliases = [$tag];
} else {
$aliases = explode(" ", $newtags); // Tag::explode($newtags); - recursion can be infinite
}
foreach ($aliases as $alias) {
if (!in_array($alias, $new)) {
if ($tag == $alias) {
$new[] = $negative.$alias;
} elseif (!in_array($alias, $tag_array)) {
$tag_array[] = $negative.$alias;
$tag_count++;
}
}
}
$i++;
}
/* remove any duplicate tags */
$tag_array = array_iunique($new);
/* tidy up */
sort($tag_array);
return $tag_array;
}
public static function sanitize(string $tag): string
{
$tag = preg_replace("/\s/", "", $tag); # whitespace
$tag = preg_replace('/\x20[\x0e\x0f]/', '', $tag); # unicode RTL
$tag = preg_replace("/\.+/", ".", $tag); # strings of dots?
$tag = preg_replace("/^(\.+[\/\\\\])+/", "", $tag); # trailing slashes?
$tag = trim($tag, ", \t\n\r\0\x0B");
if($tag == ".") $tag = ""; // hard-code one bad case...
if (mb_strlen($tag, 'UTF-8') > 255) {
throw new ScoreException("The tag below is longer than 255 characters, please use a shorter tag.\n$tag\n");
}
return $tag;
}
public static function compare(array $tags1, array $tags2): bool
{
if (count($tags1)!==count($tags2)) {
return false;
}
$tags1 = array_map("strtolower", $tags1);
$tags2 = array_map("strtolower", $tags2);
natcasesort($tags1);
natcasesort($tags2);
for ($i = 0; $i < count($tags1); $i++) {
if ($tags1[$i]!==$tags2[$i]) {
var_dump($tags1);
var_dump($tags2);
return false;
}
}
return true;
}
public static function get_diff_tags(array $source, array $remove): array
{
$before = array_map('strtolower', $source);
$remove = array_map('strtolower', $remove);
$after = [];
foreach ($before as $tag) {
if (!in_array($tag, $remove)) {
$after[] = $tag;
}
}
return $after;
}
public static function sanitize_array(array $tags): array
{
global $page;
$tag_array = [];
foreach ($tags as $tag) {
try {
$tag = Tag::sanitize($tag);
} catch (Exception $e) {
$page->flash($e->getMessage());
continue;
}
if (!empty($tag)) {
$tag_array[] = $tag;
}
}
return $tag_array;
}
public static function sqlify(string $term): string
{
global $database;
if ($database->get_driver_name() === DatabaseDriver::SQLITE) {
$term = str_replace('\\', '\\\\', $term);
}
$term = str_replace('_', '\_', $term);
$term = str_replace('%', '\%', $term);
$term = str_replace('*', '%', $term);
// $term = str_replace("?", "_", $term);
return $term;
}
/**
* Kind of like urlencode, but using a custom scheme so that
* tags always fit neatly between slashes in a URL. Use this
* when you want to put an arbitrary tag into a URL.
*/
public static function caret(string $input): string
{
$to_caret = [
"^" => "^",
"/" => "s",
"\\" => "b",
"?" => "q",
"&" => "a",
"." => "d",
];
foreach ($to_caret as $from => $to) {
$input = str_replace($from, '^' . $to, $input);
}
return $input;
}
/**
* Use this when you want to get a tag out of a URL
*/
public static function decaret(string $str): string
{
$from_caret = [
"^" => "^",
"s" => "/",
"b" => "\\",
"q" => "?",
"a" => "&",
"d" => ".",
];
$out = "";
$length = strlen($str);
for ($i=0; $i<$length; $i++) {
if ($str[$i] == "^") {
$i++;
$out .= $from_caret[$str[$i]] ?? '';
} else {
$out .= $str[$i];
}
}
return $out;
}
}

364
core/install.php Normal file
View file

@ -0,0 +1,364 @@
<?php
/**
* Shimmie Installer
*
* @package Shimmie
* @copyright Copyright (c) 2007-2015, Shish et al.
* @author Shish [webmaster at shishnet.org], jgen [jeffgenovy at gmail.com]
* @link http://code.shishnet.org/shimmie2/
* @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License v2
*
* Initialise the database, check that folder
* permissions are set properly.
*
* This file should be independent of the database
* and other such things that aren't ready yet
*/
function install()
{
date_default_timezone_set('UTC');
define("DATABASE_TIMEOUT", 10000);
if (is_readable("data/config/shimmie.conf.php")) {
exit_with_page(
"Shimmie is already installed.",
"data/config/shimmie.conf.php exists, how did you get here?"
);
}
if (!file_exists("vendor/")) {
exit_with_page("Install Error", "
<p>Shimmie is unable to find the composer vendor directory.</p>
<p>Have you followed the composer setup instructions found in the
<a href=\"https://github.com/shish/shimmie2#installation-development\">README</a>?</p>
<p>If you are not intending to do any development with Shimmie,
it is highly recommend you use one of the pre-packaged releases
found on <a href=\"https://github.com/shish/shimmie2/releases\">Github</a> instead.</p>
");
}
// Pull in necessary files
require_once "vendor/autoload.php";
global $_tracer;
$_tracer = new EventTracer();
require_once "core/exceptions.php";
require_once "core/cacheengine.php";
require_once "core/dbengine.php";
require_once "core/database.php";
require_once "core/util.php";
$dsn = get_dsn();
if ($dsn) {
do_install($dsn);
} else {
ask_questions();
}
}
function get_dsn()
{
if (file_exists("data/config/auto_install.conf.php")) {
$dsn = null;
/** @noinspection PhpIncludeInspection */
require_once "data/config/auto_install.conf.php";
} elseif (@$_POST["database_type"] == DatabaseDriver::SQLITE) {
/** @noinspection PhpUnhandledExceptionInspection */
$id = bin2hex(random_bytes(5));
$dsn = "sqlite:data/shimmie.{$id}.sqlite";
} elseif (isset($_POST['database_type']) && isset($_POST['database_host']) && isset($_POST['database_user']) && isset($_POST['database_name'])) {
$dsn = "{$_POST['database_type']}:user={$_POST['database_user']};password={$_POST['database_password']};host={$_POST['database_host']};dbname={$_POST['database_name']}";
} else {
$dsn = null;
}
return $dsn;
}
function do_install($dsn)
{
try {
create_dirs();
create_tables(new Database($dsn));
write_config($dsn);
} catch (InstallerException $e) {
exit_with_page($e->title, $e->body, $e->code);
}
}
function ask_questions()
{
$warnings = [];
$errors = [];
if (check_gd_version() == 0 && check_im_version() == 0) {
$errors[] = "
No thumbnailers could be found - install the imagemagick
tools (or the PHP-GD library, if imagemagick is unavailable).
";
} elseif (check_im_version() == 0) {
$warnings[] = "
The 'convert' command (from the imagemagick package)
could not be found - PHP-GD can be used instead, but
the size of thumbnails will be limited.
";
}
if (!function_exists('mb_strlen')) {
$errors[] = "
The mbstring PHP extension is missing - multibyte languages
(eg non-english languages) may not work right.
";
}
$drivers = PDO::getAvailableDrivers();
if (
!in_array(DatabaseDriver::MYSQL, $drivers) &&
!in_array(DatabaseDriver::PGSQL, $drivers) &&
!in_array(DatabaseDriver::SQLITE, $drivers)
) {
$errors[] = "
No database connection library could be found; shimmie needs
PDO with either Postgres, MySQL, or SQLite drivers
";
}
$db_m = in_array(DatabaseDriver::MYSQL, $drivers) ? '<option value="'. DatabaseDriver::MYSQL .'">MySQL</option>' : "";
$db_p = in_array(DatabaseDriver::PGSQL, $drivers) ? '<option value="'. DatabaseDriver::PGSQL .'">PostgreSQL</option>' : "";
$db_s = in_array(DatabaseDriver::SQLITE, $drivers) ? '<option value="'. DatabaseDriver::SQLITE .'">SQLite</option>' : "";
$warn_msg = $warnings ? "<h3>Warnings</h3>".implode("\n<p>", $warnings) : "";
$err_msg = $errors ? "<h3>Errors</h3>".implode("\n<p>", $errors) : "";
exit_with_page(
"Install Options",
<<<EOD
$warn_msg
$err_msg
<h3>Database Install</h3>
<form action="index.php" method="POST">
<div style="text-align: center;">
<table class='form'>
<tr>
<th>Type:</th>
<td><select name="database_type" id="database_type" onchange="update_qs();">
$db_m
$db_p
$db_s
</select></td>
</tr>
<tr class="dbconf mysql pgsql">
<th>Host:</th>
<td><input type="text" name="database_host" size="40" value="localhost"></td>
</tr>
<tr class="dbconf mysql pgsql">
<th>Username:</th>
<td><input type="text" name="database_user" size="40"></td>
</tr>
<tr class="dbconf mysql pgsql">
<th>Password:</th>
<td><input type="password" name="database_password" size="40"></td>
</tr>
<tr class="dbconf mysql pgsql">
<th>DB&nbsp;Name:</th>
<td><input type="text" name="database_name" size="40" value="shimmie"></td>
</tr>
<tr><td colspan="2"><input type="submit" value="Go!"></td></tr>
</table>
</div>
<script>
document.addEventListener('DOMContentLoaded', update_qs);
function q(n) {
return document.querySelectorAll(n);
}
function update_qs() {
Array.prototype.forEach.call(q('.dbconf'), function(el, i){
el.style.display = 'none';
});
let seldb = q("#database_type")[0].value || "none";
Array.prototype.forEach.call(q('.'+seldb), function(el, i){
el.style.display = null;
});
}
</script>
</form>
<h3>Help</h3>
<p class="dbconf mysql pgsql">
Please make sure the database you have chosen exists and is empty.<br>
The username provided must have access to create tables within the database.
</p>
<p class="dbconf sqlite">
For SQLite the database name will be a filename on disk, relative to
where shimmie was installed.
</p>
<p class="dbconf none">
Drivers can generally be downloaded with your OS package manager;
for Debian / Ubuntu you want php-pgsql, php-mysql, or php-sqlite.
</p>
EOD
);
}
function create_dirs()
{
$data_exists = file_exists("data") || mkdir("data");
$data_writable = is_writable("data") || chmod("data", 0755);
if (!$data_exists || !$data_writable) {
throw new InstallerException(
"Directory Permissions Error:",
"<p>Shimmie needs to have a 'data' folder in its directory, writable by the PHP user.</p>
<p>If you see this error, if probably means the folder is owned by you, and it needs to be writable by the web server.</p>
<p>PHP reports that it is currently running as user: ".$_ENV["USER"]." (". $_SERVER["USER"] .")</p>
<p>Once you have created this folder and / or changed the ownership of the shimmie folder, hit 'refresh' to continue.</p>",
7
);
}
}
function create_tables(Database $db)
{
try {
if ($db->count_tables() > 0) {
throw new InstallerException(
"Warning: The Database schema is not empty!",
"<p>Please ensure that the database you are installing Shimmie with is empty before continuing.</p>
<p>Once you have emptied the database of any tables, please hit 'refresh' to continue.</p>",
2
);
}
$db->create_table("aliases", "
oldtag VARCHAR(128) NOT NULL,
newtag VARCHAR(128) NOT NULL,
PRIMARY KEY (oldtag)
");
$db->execute("CREATE INDEX aliases_newtag_idx ON aliases(newtag)", []);
$db->create_table("config", "
name VARCHAR(128) NOT NULL,
value TEXT,
PRIMARY KEY (name)
");
$db->create_table("users", "
id SCORE_AIPK,
name VARCHAR(32) UNIQUE NOT NULL,
pass VARCHAR(250),
joindate TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
class VARCHAR(32) NOT NULL DEFAULT 'user',
email VARCHAR(128)
");
$db->execute("CREATE INDEX users_name_idx ON users(name)", []);
$db->execute("INSERT INTO users(name, pass, joindate, class) VALUES(:name, :pass, now(), :class)", ["name" => 'Anonymous', "pass" => null, "class" => 'anonymous']);
$db->execute("INSERT INTO config(name, value) VALUES(:name, :value)", ["name" => 'anon_id', "value" => $db->get_last_insert_id('users_id_seq')]);
if (check_im_version() > 0) {
$db->execute("INSERT INTO config(name, value) VALUES(:name, :value)", ["name" => 'thumb_engine', "value" => 'convert']);
}
$db->create_table("images", "
id SCORE_AIPK,
owner_id INTEGER NOT NULL,
owner_ip SCORE_INET NOT NULL,
filename VARCHAR(64) NOT NULL,
filesize INTEGER NOT NULL,
hash CHAR(32) UNIQUE NOT NULL,
ext CHAR(4) NOT NULL,
source VARCHAR(255),
width INTEGER NOT NULL,
height INTEGER NOT NULL,
posted TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
locked SCORE_BOOL NOT NULL DEFAULT SCORE_BOOL_N,
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT
");
$db->execute("CREATE INDEX images_owner_id_idx ON images(owner_id)", []);
$db->execute("CREATE INDEX images_width_idx ON images(width)", []);
$db->execute("CREATE INDEX images_height_idx ON images(height)", []);
$db->execute("CREATE INDEX images_hash_idx ON images(hash)", []);
$db->create_table("tags", "
id SCORE_AIPK,
tag VARCHAR(64) UNIQUE NOT NULL,
count INTEGER NOT NULL DEFAULT 0
");
$db->execute("CREATE INDEX tags_tag_idx ON tags(tag)", []);
$db->create_table("image_tags", "
image_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
UNIQUE(image_id, tag_id),
FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
");
$db->execute("CREATE INDEX images_tags_image_id_idx ON image_tags(image_id)", []);
$db->execute("CREATE INDEX images_tags_tag_id_idx ON image_tags(tag_id)", []);
$db->execute("INSERT INTO config(name, value) VALUES('db_version', 11)");
$db->commit();
} catch (PDOException $e) {
throw new InstallerException(
"PDO Error:",
"<p>An error occurred while trying to create the database tables necessary for Shimmie.</p>
<p>Please check and ensure that the database configuration options are all correct.</p>
<p>{$e->getMessage()}</p>",
3
);
}
}
function write_config($dsn)
{
$file_content = "<" . "?php\ndefine('DATABASE_DSN', '$dsn');\n";
if (!file_exists("data/config")) {
mkdir("data/config", 0755, true);
}
if (file_put_contents("data/config/shimmie.conf.php", $file_content, LOCK_EX)) {
header("Location: index.php");
exit_with_page(
"Installation Successful",
"<p>If you aren't redirected, <a href=\"index.php\">click here to Continue</a>."
);
} else {
$h_file_content = htmlentities($file_content);
throw new InstallerException(
"File Permissions Error:",
"The web server isn't allowed to write to the config file; please copy
the text below, save it as 'data/config/shimmie.conf.php', and upload it into the shimmie
folder manually. Make sure that when you save it, there is no whitespace
before the \"&lt;?php\".
<p><textarea cols='80' rows='2'>$h_file_content</textarea>
<p>Once done, <a href='index.php'>click here to Continue</a>.",
0
);
}
}
function exit_with_page($title, $body, $code=0)
{
print("<!DOCTYPE html>
<html lang='en'>
<head>
<title>Shimmie Installer</title>
<link rel=\"shortcut icon\" href=\"ext/static_files/static/favicon.ico\">
<link rel=\"stylesheet\" href=\"ext/static_files/style.css\" type=\"text/css\">
</head>
<body>
<div id=\"installer\">
<h1>Shimmie Installer</h1>
<h3>$title</h3>
<div class=\"container\">
$body
</div>
</div>
</body>
</html>");
exit($code);
}

73
core/logging.php Normal file
View file

@ -0,0 +1,73 @@
<?php declare(strict_types=1);
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\
* Logging convenience *
\* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
define("SCORE_LOG_CRITICAL", 50);
define("SCORE_LOG_ERROR", 40);
define("SCORE_LOG_WARNING", 30);
define("SCORE_LOG_INFO", 20);
define("SCORE_LOG_DEBUG", 10);
define("SCORE_LOG_NOTSET", 0);
/**
* A shorthand way to send a LogEvent
*
* When parsing a user request, a flash message should give info to the user
* When taking action, a log event should be stored by the server
* Quite often, both of these happen at once, hence log_*() having $flash
*/
function log_msg(string $section, int $priority, string $message, ?string $flash=null)
{
global $page;
send_event(new LogEvent($section, $priority, $message));
$threshold = defined("CLI_LOG_LEVEL") ? CLI_LOG_LEVEL : 0;
if ((PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') && ($priority >= $threshold)) {
print date("c")." $section: $message\n";
ob_flush();
}
if (!is_null($flash)) {
$page->flash($flash);
}
}
// More shorthand ways of logging
function log_debug(string $section, string $message, ?string $flash=null)
{
log_msg($section, SCORE_LOG_DEBUG, $message, $flash);
}
function log_info(string $section, string $message, ?string $flash=null)
{
log_msg($section, SCORE_LOG_INFO, $message, $flash);
}
function log_warning(string $section, string $message, ?string $flash=null)
{
log_msg($section, SCORE_LOG_WARNING, $message, $flash);
}
function log_error(string $section, string $message, ?string $flash=null)
{
log_msg($section, SCORE_LOG_ERROR, $message, $flash);
}
function log_critical(string $section, string $message, ?string $flash=null)
{
log_msg($section, SCORE_LOG_CRITICAL, $message, $flash);
}
/**
* Get a unique ID for this request, useful for grouping log messages.
*/
function get_request_id(): string
{
static $request_id = null;
if (!$request_id) {
// not completely trustworthy, as a user can spoof this
if (@$_SERVER['HTTP_X_VARNISH']) {
$request_id = $_SERVER['HTTP_X_VARNISH'];
} else {
$request_id = "P" . uniqid();
}
}
return $request_id;
}

View file

@ -1,424 +0,0 @@
<?php
/**
* \page themes Themes
*
* Each extension has a theme with a specific name -- eg. the extension Setup
* which is stored in ext/setup/main.php will have a theme called SetupTheme
* stored in ext/setup/theme.php. If you want to customise it, create a class
* in the file themes/mytheme/setup.theme.php called CustomSetupTheme which
* extends SetupTheme and overrides some of its methods.
*
* Generally an extension should only deal with processing data; whenever it
* wants to display something, it should pass the data to be displayed to the
* theme object, and the theme will add the data into the global $page
* structure.
*
* A page should make sure that all the data it outputs is free from dangerous
* data by using html_escape(), url_escape(), or int_escape() as appropriate.
*
* Because some HTML can be placed anywhere according to the theme, coming up
* with the correct way to link to a page can be hard -- thus we have the
* make_link() function, which will take a path like "post/list" and turn it
* into a full and correct link, eg /myboard/post/list, /foo/index.php?q=post/list,
* etc depending on how things are set up. This should always be used to link
* to pages rather than hardcoding a path.
*
* Various other common functions are available as part of the Themelet class.
*/
/**
* Class Page
*
* A data structure for holding all the bits of data that make up a page.
*
* The various extensions all add whatever they want to this structure,
* then Layout turns it into HTML.
*/
class Page {
/** @name Overall */
//@{
/** @var string */
public $mode = "page";
/** @var string */
public $type = "text/html; charset=utf-8";
/**
* Set what this page should do; "page", "data", or "redirect".
* @param string $mode
*/
public function set_mode($mode) {
$this->mode = $mode;
}
/**
* Set the page's MIME type.
* @param string $type
*/
public function set_type($type) {
$this->type = $type;
}
//@}
// ==============================================
/** @name "data" mode */
//@{
/** @var string; public only for unit test */
public $data = "";
/** @var string; public only for unit test */
public $filename = null;
/**
* Set the raw data to be sent.
* @param string $data
*/
public function set_data($data) {
$this->data = $data;
}
/**
* Set the recommended download filename.
* @param string $filename
*/
public function set_filename($filename) {
$this->filename = $filename;
}
//@}
// ==============================================
/** @name "redirect" mode */
//@{
/** @var string */
private $redirect = "";
/**
* Set the URL to redirect to (remember to use make_link() if linking
* to a page in the same site).
* @param string $redirect
*/
public function set_redirect($redirect) {
$this->redirect = $redirect;
}
//@}
// ==============================================
/** @name "page" mode */
//@{
/** @var int */
public $code = 200;
/** @var string */
public $title = "";
/** @var string */
public $heading = "";
/** @var string */
public $subheading = "";
/** @var string */
public $quicknav = "";
/** @var string[] */
public $html_headers = array();
/** @var string[] */
public $http_headers = array();
/** @var string[][] */
public $cookies = array();
/** @var Block[] */
public $blocks = array();
/**
* Set the HTTP status code
* @param int $code
*/
public function set_code($code) {
$this->code = $code;
}
/**
* Set the window title.
* @param string $title
*/
public function set_title($title) {
$this->title = $title;
}
/**
* Set the main heading.
* @param string $heading
*/
public function set_heading($heading) {
$this->heading = $heading;
}
/**
* Set the sub heading.
* @param string $subheading
*/
public function set_subheading($subheading) {
$this->subheading = $subheading;
}
/**
* Add a line to the HTML head section.
* @param string $line
* @param int $position
*/
public function add_html_header($line, $position=50) {
while(isset($this->html_headers[$position])) $position++;
$this->html_headers[$position] = $line;
}
/**
* Add a http header to be sent to the client.
* @param string $line
* @param int $position
*/
public function add_http_header($line, $position=50) {
while(isset($this->http_headers[$position])) $position++;
$this->http_headers[$position] = $line;
}
/**
* The counterpart for get_cookie, this works like php's
* setcookie method, but prepends the site-wide cookie prefix to
* the $name argument before doing anything.
*
* @param string $name
* @param string $value
* @param int $time
* @param string $path
*/
public function add_cookie($name, $value, $time, $path) {
$full_name = COOKIE_PREFIX."_".$name;
$this->cookies[] = array($full_name, $value, $time, $path);
}
/**
* @param string $name
* @return string|null
*/
public function get_cookie(/*string*/ $name) {
$full_name = COOKIE_PREFIX."_".$name;
if(isset($_COOKIE[$full_name])) {
return $_COOKIE[$full_name];
}
else {
return null;
}
}
/**
* Get all the HTML headers that are currently set and return as a string.
* @return string
*/
public function get_all_html_headers() {
$data = '';
ksort($this->html_headers);
foreach ($this->html_headers as $line) {
$data .= "\t\t" . $line . "\n";
}
return $data;
}
/**
* Removes all currently set HTML headers (Be careful..).
*/
public function delete_all_html_headers() {
$this->html_headers = array();
}
/**
* Add a Block of data to the page.
* @param Block $block
*/
public function add_block(Block $block) {
$this->blocks[] = $block;
}
//@}
// ==============================================
/**
* Display the page according to the mode and data given.
*/
public function display() {
global $page, $user;
header("HTTP/1.0 {$this->code} Shimmie");
header("Content-type: ".$this->type);
header("X-Powered-By: SCore-".SCORE_VERSION);
if (!headers_sent()) {
foreach($this->http_headers as $head) {
header($head);
}
foreach($this->cookies as $c) {
setcookie($c[0], $c[1], $c[2], $c[3]);
}
} else {
print "Error: Headers have already been sent to the client.";
}
switch($this->mode) {
case "page":
if(CACHE_HTTP) {
header("Vary: Cookie, Accept-Encoding");
if($user->is_anonymous() && $_SERVER["REQUEST_METHOD"] == "GET") {
header("Cache-control: public, max-age=600");
header('Expires: ' . gmdate('D, d M Y H:i:s', time() + 600) . ' GMT');
}
else {
#header("Cache-control: private, max-age=0");
header("Cache-control: no-cache");
header('Expires: ' . gmdate('D, d M Y H:i:s', time() - 600) . ' GMT');
}
}
#else {
# header("Cache-control: no-cache");
# header('Expires: ' . gmdate('D, d M Y H:i:s', time() - 600) . ' GMT');
#}
if($this->get_cookie("flash_message") !== null) {
$this->add_cookie("flash_message", "", -1, "/");
}
usort($this->blocks, "blockcmp");
$this->add_auto_html_headers();
$layout = new Layout();
$layout->display_page($page);
break;
case "data":
header("Content-Length: ".strlen($this->data));
if(!is_null($this->filename)) {
header('Content-Disposition: attachment; filename='.$this->filename);
}
print $this->data;
break;
case "redirect":
header('Location: '.$this->redirect);
print 'You should be redirected to <a href="'.$this->redirect.'">'.$this->redirect.'</a>';
break;
default:
print "Invalid page mode";
break;
}
}
/**
* This function grabs all the CSS and JavaScript files sprinkled throughout Shimmie's folders,
* concatenates them together into two large files (one for CSS and one for JS) and then stores
* them in the /cache/ directory for serving to the user.
*
* Why do this? Two reasons:
* 1. Reduces the number of files the user's browser needs to download.
* 2. Allows these cached files to be compressed/minified by the admin.
*
* TODO: This should really be configurable somehow...
*/
public function add_auto_html_headers() {
global $config;
$data_href = get_base_href();
$theme_name = $config->get_string('theme', 'default');
$this->add_html_header("<script type='text/javascript'>base_href = '$data_href';</script>", 40);
# 404/static handler will map these to themes/foo/bar.ico or lib/static/bar.ico
$this->add_html_header("<link rel='icon' type='image/x-icon' href='$data_href/favicon.ico'>", 41);
$this->add_html_header("<link rel='apple-touch-icon' href='$data_href/apple-touch-icon.png'>", 42);
//We use $config_latest to make sure cache is reset if config is ever updated.
$config_latest = 0;
foreach(zglob("data/config/*") as $conf) {
$config_latest = max($config_latest, filemtime($conf));
}
/*** Generate CSS cache files ***/
$css_lib_latest = $config_latest;
$css_lib_files = zglob("lib/vendor/css/*.css");
foreach($css_lib_files as $css) {
$css_lib_latest = max($css_lib_latest, filemtime($css));
}
$css_lib_md5 = md5(serialize($css_lib_files));
$css_lib_cache_file = data_path("cache/style.lib.{$theme_name}.{$css_lib_latest}.{$css_lib_md5}.css");
if(!file_exists($css_lib_cache_file)) {
$css_lib_data = "";
foreach($css_lib_files as $file) {
$file_data = file_get_contents($file);
$pattern = '/url[\s]*\([\s]*["\']?([^"\'\)]+)["\']?[\s]*\)/';
$replace = 'url("../../'.dirname($file).'/$1")';
$file_data = preg_replace($pattern, $replace, $file_data);
$css_lib_data .= $file_data . "\n";
}
file_put_contents($css_lib_cache_file, $css_lib_data);
}
$this->add_html_header("<link rel='stylesheet' href='$data_href/$css_lib_cache_file' type='text/css'>", 43);
$css_latest = $config_latest;
$css_files = array_merge(zglob("lib/shimmie.css"), zglob("ext/{".ENABLED_EXTS."}/style.css"), zglob("themes/$theme_name/style.css"));
foreach($css_files as $css) {
$css_latest = max($css_latest, filemtime($css));
}
$css_md5 = md5(serialize($css_files));
$css_cache_file = data_path("cache/style.main.{$theme_name}.{$css_latest}.{$css_md5}.css");
if(!file_exists($css_cache_file)) {
$css_data = "";
foreach($css_files as $file) {
$file_data = file_get_contents($file);
$pattern = '/url[\s]*\([\s]*["\']?([^"\'\)]+)["\']?[\s]*\)/';
$replace = 'url("../../'.dirname($file).'/$1")';
$file_data = preg_replace($pattern, $replace, $file_data);
$css_data .= $file_data . "\n";
}
file_put_contents($css_cache_file, $css_data);
}
$this->add_html_header("<link rel='stylesheet' href='$data_href/$css_cache_file' type='text/css'>", 100);
/*** Generate JS cache files ***/
$js_lib_latest = $config_latest;
$js_lib_files = zglob("lib/vendor/js/*.js");
foreach($js_lib_files as $js) {
$js_lib_latest = max($js_lib_latest, filemtime($js));
}
$js_lib_md5 = md5(serialize($js_lib_files));
$js_lib_cache_file = data_path("cache/script.lib.{$theme_name}.{$js_lib_latest}.{$js_lib_md5}.js");
if(!file_exists($js_lib_cache_file)) {
$js_data = "";
foreach($js_lib_files as $file) {
$js_data .= file_get_contents($file) . "\n";
}
file_put_contents($js_lib_cache_file, $js_data);
}
$this->add_html_header("<script src='$data_href/$js_lib_cache_file' type='text/javascript'></script>", 45);
$js_latest = $config_latest;
$js_files = array_merge(zglob("lib/shimmie.js"), zglob("ext/{".ENABLED_EXTS."}/script.js"), zglob("themes/$theme_name/script.js"));
foreach($js_files as $js) {
$js_latest = max($js_latest, filemtime($js));
}
$js_md5 = md5(serialize($js_files));
$js_cache_file = data_path("cache/script.main.{$theme_name}.{$js_latest}.{$js_md5}.js");
if(!file_exists($js_cache_file)) {
$js_data = "";
foreach($js_files as $file) {
$js_data .= file_get_contents($file) . "\n";
}
file_put_contents($js_cache_file, $js_data);
}
$this->add_html_header("<script src='$data_href/$js_cache_file' type='text/javascript'></script>", 100);
}
}
class MockPage extends Page {
}

94
core/permissions.php Normal file
View file

@ -0,0 +1,94 @@
<?php declare(strict_types=1);
abstract class Permissions
{
public const CHANGE_SETTING = "change_setting"; # modify web-level settings, eg the config table
public const OVERRIDE_CONFIG = "override_config"; # modify sys-level settings, eg shimmie.conf.php
public const BIG_SEARCH = "big_search"; # search for more than 3 tags at once (speed mode only)
public const MANAGE_EXTENSION_LIST = "manage_extension_list";
public const MANAGE_ALIAS_LIST = "manage_alias_list";
public const MANAGE_AUTO_TAG = "manage_auto_tag";
public const MASS_TAG_EDIT = "mass_tag_edit";
public const VIEW_IP = "view_ip"; # view IP addresses associated with things
public const BAN_IP = "ban_ip";
public const CREATE_USER = "create_user";
public const EDIT_USER_NAME = "edit_user_name";
public const EDIT_USER_PASSWORD = "edit_user_password";
public const EDIT_USER_INFO = "edit_user_info"; # email address, etc
public const EDIT_USER_CLASS = "edit_user_class";
public const DELETE_USER = "delete_user";
public const CREATE_COMMENT = "create_comment";
public const DELETE_COMMENT = "delete_comment";
public const BYPASS_COMMENT_CHECKS = "bypass_comment_checks"; # spam etc
public const REPLACE_IMAGE = "replace_image";
public const CREATE_IMAGE = "create_image";
public const EDIT_IMAGE_TAG = "edit_image_tag";
public const EDIT_IMAGE_SOURCE = "edit_image_source";
public const EDIT_IMAGE_OWNER = "edit_image_owner";
public const EDIT_IMAGE_LOCK = "edit_image_lock";
public const EDIT_IMAGE_TITLE = "edit_image_title";
public const EDIT_IMAGE_RELATIONSHIPS = "edit_image_relationships";
public const EDIT_IMAGE_ARTIST = "edit_image_artist";
public const BULK_EDIT_IMAGE_TAG = "bulk_edit_image_tag";
public const BULK_EDIT_IMAGE_SOURCE = "bulk_edit_image_source";
public const DELETE_IMAGE = "delete_image";
public const BAN_IMAGE = "ban_image";
public const VIEW_EVENTLOG = "view_eventlog";
public const IGNORE_DOWNTIME = "ignore_downtime";
public const CREATE_IMAGE_REPORT = "create_image_report";
public const VIEW_IMAGE_REPORT = "view_image_report"; # deal with reported images
public const WIKI_ADMIN = "wiki_admin";
public const EDIT_WIKI_PAGE = "edit_wiki_page";
public const DELETE_WIKI_PAGE = "delete_wiki_page";
public const MANAGE_BLOCKS = "manage_blocks";
public const MANAGE_ADMINTOOLS = "manage_admintools";
public const SEND_PM = "send_pm";
public const READ_PM = "read_pm";
public const VIEW_OTHER_PMS = "view_other_pms";
public const EDIT_FEATURE = "edit_feature";
public const BULK_EDIT_VOTE = "bulk_edit_vote";
public const EDIT_OTHER_VOTE = "edit_other_vote";
public const VIEW_SYSINTO = "view_sysinfo";
public const HELLBANNED = "hellbanned";
public const VIEW_HELLBANNED = "view_hellbanned";
public const PROTECTED = "protected"; # only admins can modify protected users (stops a moderator changing an admin's password)
public const EDIT_IMAGE_RATING = "edit_image_rating";
public const BULK_EDIT_IMAGE_RATING = "bulk_edit_image_rating";
public const VIEW_TRASH = "view_trash";
public const PERFORM_BULK_ACTIONS = "perform_bulk_actions";
public const BULK_ADD = "bulk_add";
public const EDIT_FILES = "edit_files";
public const EDIT_TAG_CATEGORIES = "edit_tag_categories";
public const RESCAN_MEDIA = "rescan_media";
public const SEE_IMAGE_VIEW_COUNTS = "see_image_view_counts";
public const EDIT_FAVOURITES = "edit_favourites";
public const ARTISTS_ADMIN = "artists_admin";
public const BLOTTER_ADMIN = "blotter_admin";
public const FORUM_ADMIN = "forum_admin";
public const NOTES_ADMIN = "notes_admin";
public const POOLS_ADMIN = "pools_admin";
public const TIPS_ADMIN = "tips_admin";
public const CRON_ADMIN = "cron_admin";
public const APPROVE_IMAGE = "approve_image";
public const APPROVE_COMMENT = "approve_comment";
}

822
core/polyfills.php Normal file
View file

@ -0,0 +1,822 @@
<?php declare(strict_types=1);
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\
* Things which should be in the core API *
\* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/**
* Return the unique elements of an array, case insensitively
*/
function array_iunique(array $array): array
{
$ok = [];
foreach ($array as $element) {
$found = false;
foreach ($ok as $existing) {
if (strtolower($element) == strtolower($existing)) {
$found = true;
break;
}
}
if (!$found) {
$ok[] = $element;
}
}
return $ok;
}
/**
* Figure out if an IP is in a specified range
*
* from http://uk.php.net/network
*/
function ip_in_range(string $IP, string $CIDR): bool
{
list($net, $mask) = explode("/", $CIDR);
$ip_net = ip2long($net);
$ip_mask = ~((1 << (32 - $mask)) - 1);
$ip_ip = ip2long($IP);
$ip_ip_net = $ip_ip & $ip_mask;
return ($ip_ip_net == $ip_net);
}
/**
* Delete an entire file heirachy
*
* from a patch by Christian Walde; only intended for use in the
* "extension manager" extension, but it seems to fit better here
*/
function deltree(string $f): void
{
//Because Windows (I know, bad excuse)
if (PHP_OS === 'WINNT') {
$real = realpath($f);
$path = realpath('./').'\\'.str_replace('/', '\\', $f);
if ($path != $real) {
rmdir($path);
} else {
foreach (glob($f.'/*') as $sf) {
if (is_dir($sf) && !is_link($sf)) {
deltree($sf);
} else {
unlink($sf);
}
}
rmdir($f);
}
} else {
if (is_link($f)) {
unlink($f);
} elseif (is_dir($f)) {
foreach (glob($f.'/*') as $sf) {
if (is_dir($sf) && !is_link($sf)) {
deltree($sf);
} else {
unlink($sf);
}
}
rmdir($f);
}
}
}
/**
* Copy an entire file hierarchy
*
* from a comment on http://uk.php.net/copy
*/
function full_copy(string $source, string $target): void
{
if (is_dir($source)) {
@mkdir($target);
$d = dir($source);
while (false !== ($entry = $d->read())) {
if ($entry == '.' || $entry == '..') {
continue;
}
$Entry = $source . '/' . $entry;
if (is_dir($Entry)) {
full_copy($Entry, $target . '/' . $entry);
continue;
}
copy($Entry, $target . '/' . $entry);
}
$d->close();
} else {
copy($source, $target);
}
}
/**
* Return a list of all the regular files in a directory and subdirectories
*/
function list_files(string $base, string $_sub_dir=""): array
{
assert(is_dir($base));
$file_list = [];
$files = [];
$dir = opendir("$base/$_sub_dir");
while ($f = readdir($dir)) {
$files[] = $f;
}
closedir($dir);
sort($files);
foreach ($files as $filename) {
$full_path = "$base/$_sub_dir/$filename";
if (!is_link($full_path) && is_dir($full_path)) {
if (!($filename == "." || $filename == "..")) {
//subdirectory found
$file_list = array_merge(
$file_list,
list_files($base, "$_sub_dir/$filename")
);
}
} else {
$full_path = str_replace("//", "/", $full_path);
$file_list[] = $full_path;
}
}
return $file_list;
}
function stream_file(string $file, int $start, int $end): void
{
$fp = fopen($file, 'r');
try {
set_time_limit(0);
fseek($fp, $start);
$buffer = 1024 * 1024;
while (!feof($fp) && ($p = ftell($fp)) <= $end) {
if ($p + $buffer > $end) {
$buffer = $end - $p + 1;
}
echo fread($fp, $buffer);
if (!defined("UNITTEST")) {
@ob_flush();
}
flush();
// After flush, we can tell if the client browser has disconnected.
// This means we can start sending a large file, and if we detect they disappeared
// then we can just stop and not waste any more resources or bandwidth.
if (connection_status() != 0) {
break;
}
}
} finally {
fclose($fp);
}
}
if (!function_exists('http_parse_headers')) { #http://www.php.net/manual/en/function.http-parse-headers.php#112917
/**
* #return string[]
*/
function http_parse_headers(string $raw_headers): array
{
$headers = []; // $headers = [];
foreach (explode("\n", $raw_headers) as $i => $h) {
$h = explode(':', $h, 2);
if (isset($h[1])) {
if (!isset($headers[$h[0]])) {
$headers[$h[0]] = trim($h[1]);
} elseif (is_array($headers[$h[0]])) {
$tmp = array_merge($headers[$h[0]], [trim($h[1])]);
$headers[$h[0]] = $tmp;
} else {
$tmp = array_merge([$headers[$h[0]]], [trim($h[1])]);
$headers[$h[0]] = $tmp;
}
}
}
return $headers;
}
}
/**
* HTTP Headers can sometimes be lowercase which will cause issues.
* In cases like these, we need to make sure to check for them if the camelcase version does not exist.
*/
function findHeader(array $headers, string $name): ?string
{
if (!is_array($headers)) {
return null;
}
$header = null;
if (array_key_exists($name, $headers)) {
$header = $headers[$name];
} else {
$headers = array_change_key_case($headers); // convert all to lower case.
$lc_name = strtolower($name);
if (array_key_exists($lc_name, $headers)) {
$header = $headers[$lc_name];
}
}
return $header;
}
if (!function_exists('mb_strlen')) {
// TODO: we should warn the admin that they are missing multibyte support
function mb_strlen($str, $encoding)
{
return strlen($str);
}
function mb_internal_encoding($encoding)
{
}
function mb_strtolower($str)
{
return strtolower($str);
}
}
const MIME_TYPE_MAP = [
'jpg' => 'image/jpeg',
'gif' => 'image/gif',
'png' => 'image/png',
'tif' => 'image/tiff',
'tiff' => 'image/tiff',
'ico' => 'image/x-icon',
'swf' => 'application/x-shockwave-flash',
'flv' => 'video/x-flv',
'svg' => 'image/svg+xml',
'pdf' => 'application/pdf',
'zip' => 'application/zip',
'gz' => 'application/x-gzip',
'tar' => 'application/x-tar',
'bz' => 'application/x-bzip',
'bz2' => 'application/x-bzip2',
'txt' => 'text/plain',
'asc' => 'text/plain',
'htm' => 'text/html',
'html' => 'text/html',
'css' => 'text/css',
'js' => 'text/javascript',
'xml' => 'text/xml',
'xsl' => 'application/xsl+xml',
'ogg' => 'application/ogg',
'mp3' => 'audio/mpeg',
'wav' => 'audio/x-wav',
'avi' => 'video/x-msvideo',
'mpg' => 'video/mpeg',
'mpeg' => 'video/mpeg',
'mov' => 'video/quicktime',
'php' => 'text/x-php',
'mp4' => 'video/mp4',
'ogv' => 'video/ogg',
'webm' => 'video/webm',
'webp' => 'image/webp',
'bmp' =>'image/x-ms-bmp',
'psd' => 'image/vnd.adobe.photoshop',
'mkv' => 'video/x-matroska'
];
/**
* Get MIME type for file
*
* The contents of this function are taken from the __getMimeType() function
* from the "Amazon S3 PHP class" which is Copyright (c) 2008, Donovan Schönknecht
* and released under the 'Simplified BSD License'.
*/
function getMimeType(string $file, string $ext=""): string
{
// Static extension lookup
$ext = strtolower($ext);
if (array_key_exists($ext, MIME_TYPE_MAP)) {
return MIME_TYPE_MAP[$ext];
}
$type = false;
// Fileinfo documentation says fileinfo_open() will use the
// MAGIC env var for the magic file
if (extension_loaded('fileinfo') && isset($_ENV['MAGIC']) &&
($finfo = finfo_open(FILEINFO_MIME, $_ENV['MAGIC'])) !== false) {
if (($type = finfo_file($finfo, $file)) !== false) {
// Remove the charset and grab the last content-type
$type = explode(' ', str_replace('; charset=', ';charset=', $type));
$type = array_pop($type);
$type = explode(';', $type);
$type = trim(array_shift($type));
}
finfo_close($finfo);
// If anyone is still using mime_content_type()
} elseif (function_exists('mime_content_type')) {
$type = trim(mime_content_type($file));
}
if ($type !== false && strlen($type) > 0) {
return $type;
}
return 'application/octet-stream';
}
function get_extension(?string $mime_type): ?string
{
if (empty($mime_type)) {
return null;
}
$ext = array_search($mime_type, MIME_TYPE_MAP);
return ($ext ? $ext : null);
}
/** @noinspection PhpUnhandledExceptionInspection */
function getSubclassesOf(string $parent)
{
$result = [];
foreach (get_declared_classes() as $class) {
$rclass = new ReflectionClass($class);
if (!$rclass->isAbstract() && is_subclass_of($class, $parent)) {
$result[] = $class;
}
}
return $result;
}
/**
* Like glob, with support for matching very long patterns with braces.
*/
function zglob(string $pattern): array
{
$results = [];
if (preg_match('/(.*)\{(.*)\}(.*)/', $pattern, $matches)) {
$braced = explode(",", $matches[2]);
foreach ($braced as $b) {
$sub_pattern = $matches[1].$b.$matches[3];
$results = array_merge($results, zglob($sub_pattern));
}
return $results;
} else {
$r = glob($pattern);
if ($r) {
return $r;
} else {
return [];
}
}
}
/**
* Figure out the path to the shimmie install directory.
*
* eg if shimmie is visible at http://foo.com/gallery, this
* function should return /gallery
*
* PHP really, really sucks.
*/
function get_base_href(): string
{
if (defined("BASE_HREF") && !empty(BASE_HREF)) {
return BASE_HREF;
}
$possible_vars = ['SCRIPT_NAME', 'PHP_SELF', 'PATH_INFO', 'ORIG_PATH_INFO'];
$ok_var = null;
foreach ($possible_vars as $var) {
if (isset($_SERVER[$var]) && substr($_SERVER[$var], -4) === '.php') {
$ok_var = $_SERVER[$var];
break;
}
}
assert(!empty($ok_var));
$dir = dirname($ok_var);
$dir = str_replace("\\", "/", $dir);
$dir = str_replace("//", "/", $dir);
$dir = rtrim($dir, "/");
return $dir;
}
function startsWith(string $haystack, string $needle): bool
{
$length = strlen($needle);
return (substr($haystack, 0, $length) === $needle);
}
function endsWith(string $haystack, string $needle): bool
{
$length = strlen($needle);
$start = $length * -1; //negative
return (substr($haystack, $start) === $needle);
}
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\
* Input / Output Sanitising *
\* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/**
* Make some data safe for printing into HTML
*/
function html_escape(?string $input): string
{
if (is_null($input)) {
return "";
}
return htmlentities($input, ENT_QUOTES, "UTF-8");
}
/**
* Unescape data that was made safe for printing into HTML
*/
function html_unescape(string $input): string
{
return html_entity_decode($input, ENT_QUOTES, "UTF-8");
}
/**
* Make sure some data is safe to be used in integer context
*/
function int_escape(?string $input): int
{
/*
Side note, Casting to an integer is FASTER than using intval.
http://hakre.wordpress.com/2010/05/13/php-casting-vs-intval/
*/
if (is_null($input)) {
return 0;
}
return (int)$input;
}
/**
* Make sure some data is safe to be used in URL context
*/
function url_escape(?string $input): string
{
if (is_null($input)) {
return "";
}
$input = rawurlencode($input);
return $input;
}
/**
* Turn all manner of HTML / INI / JS / DB booleans into a PHP one
*/
function bool_escape($input): bool
{
/*
Sometimes, I don't like PHP -- this, is one of those times...
"a boolean FALSE is not considered a valid boolean value by this function."
Yay for Got'chas!
http://php.net/manual/en/filter.filters.validate.php
*/
if (is_bool($input)) {
return $input;
} elseif (is_int($input)) {
return ($input === 1);
} else {
$value = filter_var($input, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if (!is_null($value)) {
return $value;
} else {
$input = strtolower(trim($input));
return (
$input === "y" ||
$input === "yes" ||
$input === "t" ||
$input === "true" ||
$input === "on" ||
$input === "1"
);
}
}
}
/**
* Some functions require a callback function for escaping,
* but we might not want to alter the data
*/
function no_escape(string $input): string
{
return $input;
}
function clamp(?int $val, ?int $min=null, ?int $max=null): int
{
if (!is_numeric($val) || (!is_null($min) && $val < $min)) {
$val = $min;
}
if (!is_null($max) && $val > $max) {
$val = $max;
}
if (!is_null($min) && !is_null($max)) {
assert($val >= $min && $val <= $max, "$min <= $val <= $max");
}
return $val;
}
function xml_tag(string $name, array $attrs=[], array $children=[]): string
{
$xml = "<$name ";
foreach ($attrs as $k => $v) {
$xv = str_replace('&#039;', '&apos;', htmlspecialchars((string)$v, ENT_QUOTES));
$xml .= "$k=\"$xv\" ";
}
if (count($children) > 0) {
$xml .= ">\n";
foreach ($children as $child) {
$xml .= xml_tag($child);
}
$xml .= "</$name>\n";
} else {
$xml .= "/>\n";
}
return $xml;
}
/**
* Original PHP code by Chirp Internet: www.chirp.com.au
* Please acknowledge use of this code by including this header.
*/
function truncate(string $string, int $limit, string $break=" ", string $pad="..."): string
{
// return with no change if string is shorter than $limit
if (strlen($string) <= $limit) {
return $string;
}
// is $break present between $limit and the end of the string?
if (false !== ($breakpoint = strpos($string, $break, $limit))) {
if ($breakpoint < strlen($string) - 1) {
$string = substr($string, 0, $breakpoint) . $pad;
}
}
return $string;
}
/**
* Turn a human readable filesize into an integer, eg 1KB -> 1024
*/
function parse_shorthand_int(string $limit): int
{
if (preg_match('/^([\d\.]+)([gmk])?b?$/i', (string)$limit, $m)) {
$value = $m[1];
if (isset($m[2])) {
switch (strtolower($m[2])) {
/** @noinspection PhpMissingBreakStatementInspection */
case 'g': $value *= 1024; // fall through
/** @noinspection PhpMissingBreakStatementInspection */
// no break
case 'm': $value *= 1024; // fall through
/** @noinspection PhpMissingBreakStatementInspection */
// no break
case 'k': $value *= 1024; break;
default: $value = -1;
}
}
return (int)$value;
} else {
return -1;
}
}
/**
* Turn an integer into a human readable filesize, eg 1024 -> 1KB
*/
function to_shorthand_int(int $int): string
{
assert($int >= 0);
if ($int >= pow(1024, 3)) {
return sprintf("%.1fGB", $int / pow(1024, 3));
} elseif ($int >= pow(1024, 2)) {
return sprintf("%.1fMB", $int / pow(1024, 2));
} elseif ($int >= 1024) {
return sprintf("%.1fKB", $int / 1024);
} else {
return (string)$int;
}
}
const TIME_UNITS = ["s"=>60,"m"=>60,"h"=>24,"d"=>365,"y"=>PHP_INT_MAX];
function format_milliseconds(int $input): string
{
$output = "";
$remainder = floor($input / 1000);
foreach (TIME_UNITS as $unit=>$conversion) {
$count = $remainder % $conversion;
$remainder = floor($remainder / $conversion);
if ($count==0&&$remainder<1) {
break;
}
$output = "$count".$unit." ".$output;
}
return trim($output);
}
/**
* Turn a date into a time, a date, an "X minutes ago...", etc
*/
function autodate(string $date, bool $html=true): string
{
$cpu = date('c', strtotime($date));
$hum = date('F j, Y; H:i', strtotime($date));
return ($html ? "<time datetime='$cpu'>$hum</time>" : $hum);
}
/**
* Check if a given string is a valid date-time. ( Format: yyyy-mm-dd hh:mm:ss )
*/
function isValidDateTime(string $dateTime): bool
{
if (preg_match("/^(\d{4})-(\d{2})-(\d{2}) ([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])$/", $dateTime, $matches)) {
if (checkdate((int)$matches[2], (int)$matches[3], (int)$matches[1])) {
return true;
}
}
return false;
}
/**
* Check if a given string is a valid date. ( Format: yyyy-mm-dd )
*/
function isValidDate(string $date): bool
{
if (preg_match("/^(\d{4})-(\d{2})-(\d{2})$/", $date, $matches)) {
// checkdate wants (month, day, year)
if (checkdate((int)$matches[2], (int)$matches[3], (int)$matches[1])) {
return true;
}
}
return false;
}
function validate_input(array $inputs): array
{
$outputs = [];
foreach ($inputs as $key => $validations) {
$flags = explode(',', $validations);
if (in_array('bool', $flags) && !isset($_POST[$key])) {
$_POST[$key] = 'off';
}
if (in_array('optional', $flags)) {
if (!isset($_POST[$key]) || trim($_POST[$key]) == "") {
$outputs[$key] = null;
continue;
}
}
if (!isset($_POST[$key]) || trim($_POST[$key]) == "") {
throw new InvalidInput("Input '$key' not set");
}
$value = trim($_POST[$key]);
if (in_array('user_id', $flags)) {
$id = int_escape($value);
if (in_array('exists', $flags)) {
if (is_null(User::by_id($id))) {
throw new InvalidInput("User #$id does not exist");
}
}
$outputs[$key] = $id;
} elseif (in_array('user_name', $flags)) {
if (strlen($value) < 1) {
throw new InvalidInput("Username must be at least 1 character");
} elseif (!preg_match('/^[a-zA-Z0-9-_]+$/', $value)) {
throw new InvalidInput(
"Username contains invalid characters. Allowed characters are ".
"letters, numbers, dash, and underscore"
);
}
$outputs[$key] = $value;
} elseif (in_array('user_class', $flags)) {
global $_shm_user_classes;
if (!array_key_exists($value, $_shm_user_classes)) {
throw new InvalidInput("Invalid user class: ".html_escape($value));
}
$outputs[$key] = $value;
} elseif (in_array('email', $flags)) {
$outputs[$key] = trim($value);
} elseif (in_array('password', $flags)) {
$outputs[$key] = $value;
} elseif (in_array('int', $flags)) {
$value = trim($value);
if (empty($value) || !is_numeric($value)) {
throw new InvalidInput("Invalid int: ".html_escape($value));
}
$outputs[$key] = (int)$value;
} elseif (in_array('bool', $flags)) {
$outputs[$key] = bool_escape($value);
} elseif (in_array('date', $flags)) {
$outputs[$key] = date("Y-m-d H:i:s", strtotime(trim($value)));
} elseif (in_array('string', $flags)) {
if (in_array('trim', $flags)) {
$value = trim($value);
}
if (in_array('lower', $flags)) {
$value = strtolower($value);
}
if (in_array('not-empty', $flags)) {
throw new InvalidInput("$key must not be blank");
}
if (in_array('nullify', $flags)) {
if (empty($value)) {
$value = null;
}
}
$outputs[$key] = $value;
} else {
throw new InvalidInput("Unknown validation '$validations'");
}
}
return $outputs;
}
/**
* Translates all possible directory separators to the appropriate one for the current system,
* and removes any duplicate separators.
*/
function sanitize_path(string $path): string
{
return preg_replace('|[\\\\/]+|S', DIRECTORY_SEPARATOR, $path);
}
/**
* Combines all path segments specified, ensuring no duplicate separators occur,
* as well as converting all possible separators to the one appropriate for the current system.
*/
function join_path(string ...$paths): string
{
$output = "";
foreach ($paths as $path) {
if (empty($path)) {
continue;
}
$path = sanitize_path($path);
if (empty($output)) {
$output = $path;
} else {
$output = rtrim($output, DIRECTORY_SEPARATOR);
$path = ltrim($path, DIRECTORY_SEPARATOR);
$output .= DIRECTORY_SEPARATOR . $path;
}
}
return $output;
}
/**
* Perform callback on each item returned by an iterator.
*/
function iterator_map(callable $callback, iterator $iter): Generator
{
foreach ($iter as $i) {
yield call_user_func($callback, $i);
}
}
/**
* Perform callback on each item returned by an iterator and combine the result into an array.
*/
function iterator_map_to_array(callable $callback, iterator $iter): array
{
return iterator_to_array(iterator_map($callback, $iter));
}
function stringer($s)
{
if (is_array($s)) {
if (isset($s[0])) {
return "[" . implode(", ", array_map("stringer", $s)) . "]";
} else {
$pairs = [];
foreach ($s as $k=>$v) {
$pairs[] = "\"$k\"=>" . stringer($v);
}
return "[" . implode(", ", $pairs) . "]";
}
}
if (is_string($s)) {
return "\"$s\""; // FIXME: handle escaping quotes
}
return (string)$s;
}

128
core/send_event.php Normal file
View file

@ -0,0 +1,128 @@
<?php declare(strict_types=1);
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\
* Event API *
\* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/** @private */
global $_shm_event_listeners;
$_shm_event_listeners = [];
function _load_event_listeners(): void
{
global $_shm_event_listeners;
$cache_path = data_path("cache/shm_event_listeners.php");
if (SPEED_HAX && file_exists($cache_path)) {
require_once($cache_path);
} else {
_set_event_listeners();
if (SPEED_HAX) {
_dump_event_listeners($_shm_event_listeners, $cache_path);
}
}
}
function _clear_cached_event_listeners(): void
{
if (file_exists(data_path("cache/shm_event_listeners.php"))) {
unlink(data_path("cache/shm_event_listeners.php"));
}
}
function _set_event_listeners(): void
{
global $_shm_event_listeners;
$_shm_event_listeners = [];
foreach (getSubclassesOf("Extension") as $class) {
/** @var Extension $extension */
$extension = new $class();
// skip extensions which don't support our current database
if (!$extension->info->is_supported()) {
continue;
}
foreach (get_class_methods($extension) as $method) {
if (substr($method, 0, 2) == "on") {
$event = substr($method, 2) . "Event";
$pos = $extension->get_priority() * 100;
while (isset($_shm_event_listeners[$event][$pos])) {
$pos += 1;
}
$_shm_event_listeners[$event][$pos] = $extension;
}
}
}
}
function _dump_event_listeners(array $event_listeners, string $path): void
{
$p = "<"."?php\n";
foreach (getSubclassesOf("Extension") as $class) {
$p .= "\$$class = new $class(); ";
}
$p .= "\$_shm_event_listeners = array(\n";
foreach ($event_listeners as $event => $listeners) {
$p .= "\t'$event' => array(\n";
foreach ($listeners as $id => $listener) {
$p .= "\t\t$id => \$".get_class($listener).",\n";
}
$p .= "\t),\n";
}
$p .= ");\n";
file_put_contents($path, $p);
}
/** @private */
global $_shm_event_count;
$_shm_event_count = 0;
/**
* Send an event to all registered Extensions.
*/
function send_event(Event $event): Event
{
global $tracer_enabled;
global $_shm_event_listeners, $_shm_event_count, $_tracer;
if (!isset($_shm_event_listeners[get_class($event)])) {
return $event;
}
$method_name = "on".str_replace("Event", "", get_class($event));
// send_event() is performance sensitive, and with the number
// of times tracer gets called the time starts to add up
if ($tracer_enabled) {
$_tracer->begin(get_class($event));
}
// SHIT: http://bugs.php.net/bug.php?id=35106
$my_event_listeners = $_shm_event_listeners[get_class($event)];
ksort($my_event_listeners);
foreach ($my_event_listeners as $listener) {
if ($tracer_enabled) {
$_tracer->begin(get_class($listener));
}
if (method_exists($listener, $method_name)) {
$listener->$method_name($event);
}
if ($tracer_enabled) {
$_tracer->end();
}
if ($event->stop_processing===true) {
break;
}
}
$_shm_event_count++;
if ($tracer_enabled) {
$_tracer->end();
}
return $event;
}

View file

@ -1,53 +0,0 @@
<?php
/*
* First, load the user-specified settings
*/
@include_once "data/config/shimmie.conf.php";
@include_once "data/config/extensions.conf.php";
/**
* For any values that aren't defined in the above files, Shimmie
* will set the values to their defaults
*
* All of these can be over-ridden by placing a 'define' in data/config/shimmie.conf.php
*
* Do NOT change them in this file. These are the defaults only!
*
* Example:
* define("SPEED_HAX", true);
*
*/
/** @private */
function _d($name, $value) {if(!defined($name)) define($name, $value);}
_d("DATABASE_DSN", null); // string PDO database connection details
_d("DATABASE_KA", true); // string Keep database connection alive
_d("CACHE_DSN", null); // string cache connection details
_d("DEBUG", false); // boolean print various debugging details
_d("DEBUG_SQL", false); // boolean dump SQL queries to data/sql.log
_d("DEBUG_CACHE", false); // boolean dump cache queries to data/cache.log
_d("COVERAGE", false); // boolean activate xdebug coverage monitor
_d("CONTEXT", null); // string file to log performance data into
_d("CACHE_HTTP", false); // boolean output explicit HTTP caching headers
_d("COOKIE_PREFIX", 'shm'); // string if you run multiple galleries with non-shared logins, give them different prefixes
_d("SPEED_HAX", false); // boolean do some questionable things in the name of performance
_d("COMPILE_ELS", false); // boolean pre-build the list of event listeners
_d("NICE_URLS", false); // boolean force niceurl mode
_d("SEARCH_ACCEL", false); // boolean use search accelerator
_d("WH_SPLITS", 1); // int how many levels of subfolders to put in the warehouse
_d("VERSION", '2.6.1'); // string shimmie version
_d("TIMEZONE", null); // string timezone
_d("CORE_EXTS", "bbcode,user,mail,upload,image,view,handle_pixel,ext_manager,setup,upgrade,handle_404,comment,tag_list,index,tag_edit,alias_editor"); // extensions to always enable
_d("EXTRA_EXTS", ""); // string optional extra extensions
_d("BASE_URL", null); // string force a specific base URL (default is auto-detect)
_d("MIN_PHP_VERSION", '5.6');// string minium supported PHP version
/*
* Calculated settings - you should never need to change these
* directly, only the things they're built from
*/
_d("SCORE_VERSION", 'develop/'.VERSION); // string SCore version
_d("ENABLED_EXTS", CORE_EXTS.",".EXTRA_EXTS);

33
core/sys_config.php Normal file
View file

@ -0,0 +1,33 @@
<?php declare(strict_types=1);
/**
* For any values that aren't defined in data/config/*.php,
* Shimmie will set the values to their defaults
*
* All of these can be over-ridden by placing a 'define' in
* data/config/shimmie.conf.php
*
* Do NOT change them in this file. These are the defaults only!
*
* Example:
* define("SPEED_HAX", true);
*/
function _d(string $name, $value): void
{
if (!defined($name)) {
define($name, $value);
}
}
_d("DATABASE_DSN", null); // string PDO database connection details
_d("DATABASE_TIMEOUT", 10000);// int Time to wait for each statement to complete
_d("CACHE_DSN", null); // string cache connection details
_d("DEBUG", false); // boolean print various debugging details
_d("COOKIE_PREFIX", 'shm'); // string if you run multiple galleries with non-shared logins, give them different prefixes
_d("SPEED_HAX", false); // boolean do some questionable things in the name of performance
_d("WH_SPLITS", 1); // int how many levels of subfolders to put in the warehouse
_d("VERSION", '2.8-dev'); // string shimmie version
_d("TIMEZONE", null); // string timezone
_d("EXTRA_EXTS", ""); // string optional extra extensions
_d("BASE_HREF", null); // string force a specific base URL (default is auto-detect)
_d("TRACE_FILE", null); // string file to log performance data into
_d("TRACE_THRESHOLD", 0.0); // float log pages which take more time than this many seconds

View file

@ -0,0 +1,51 @@
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;
require_once "core/basepage.php";
class BasePageTest extends TestCase
{
public function test_page()
{
$page = new BasePage();
$page->set_mode(PageMode::PAGE);
ob_start();
$page->display();
ob_end_clean();
$this->assertTrue(true); // doesn't crash
}
public function test_file()
{
$page = new BasePage();
$page->set_mode(PageMode::FILE);
$page->set_file("tests/pbx_screenshot.jpg");
ob_start();
$page->display();
ob_end_clean();
$this->assertTrue(true); // doesn't crash
}
public function test_data()
{
$page = new BasePage();
$page->set_mode(PageMode::DATA);
$page->set_data("hello world");
ob_start();
$page->display();
ob_end_clean();
$this->assertTrue(true); // doesn't crash
}
public function test_redirect()
{
$page = new BasePage();
$page->set_mode(PageMode::REDIRECT);
$page->set_redirect("/new/page");
ob_start();
$page->display();
ob_end_clean();
$this->assertTrue(true); // doesn't crash
}
}

17
core/tests/block.test.php Normal file
View file

@ -0,0 +1,17 @@
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;
require_once "core/block.php";
class BlockTest extends TestCase
{
public function test_basic()
{
$b = new Block("head", "body");
$this->assertEquals(
"<section id='headmain'><h3 data-toggle-sel='#headmain' class=''>head</h3><div class='blockbody'>body</div></section>\n",
$b->get_html()
);
}
}

View file

@ -0,0 +1,223 @@
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;
require_once "core/polyfills.php";
class PolyfillsTest extends TestCase
{
public function test_html_escape()
{
$this->assertEquals(
html_escape("Foo & <waffles>"),
"Foo &amp; &lt;waffles&gt;"
);
$this->assertEquals(
html_unescape("Foo &amp; &lt;waffles&gt;"),
"Foo & <waffles>"
);
$x = "Foo &amp; &lt;waffles&gt;";
$this->assertEquals(html_escape(html_unescape($x)), $x);
}
public function test_int_escape()
{
$this->assertEquals(int_escape(""), 0);
$this->assertEquals(int_escape("1"), 1);
$this->assertEquals(int_escape("-1"), -1);
$this->assertEquals(int_escape("-1.5"), -1);
$this->assertEquals(int_escape(null), 0);
}
public function test_url_escape()
{
$this->assertEquals(url_escape("^\o/^"), "%5E%5Co%2F%5E");
$this->assertEquals(url_escape(null), "");
}
public function test_bool_escape()
{
$this->assertTrue(bool_escape(true));
$this->assertFalse(bool_escape(false));
$this->assertTrue(bool_escape("true"));
$this->assertFalse(bool_escape("false"));
$this->assertTrue(bool_escape("t"));
$this->assertFalse(bool_escape("f"));
$this->assertTrue(bool_escape("T"));
$this->assertFalse(bool_escape("F"));
$this->assertTrue(bool_escape("yes"));
$this->assertFalse(bool_escape("no"));
$this->assertTrue(bool_escape("Yes"));
$this->assertFalse(bool_escape("No"));
$this->assertTrue(bool_escape("on"));
$this->assertFalse(bool_escape("off"));
$this->assertTrue(bool_escape(1));
$this->assertFalse(bool_escape(0));
$this->assertTrue(bool_escape("1"));
$this->assertFalse(bool_escape("0"));
}
public function test_clamp()
{
$this->assertEquals(clamp(0, 5, 10), 5);
$this->assertEquals(clamp(5, 5, 10), 5);
$this->assertEquals(clamp(7, 5, 10), 7);
$this->assertEquals(clamp(10, 5, 10), 10);
$this->assertEquals(clamp(15, 5, 10), 10);
}
public function test_xml_tag()
{
$this->assertEquals(
"<test foo=\"bar\" >\n<cake />\n</test>\n",
xml_tag("test", ["foo"=>"bar"], ["cake"])
);
}
public function test_truncate()
{
$this->assertEquals(truncate("test words", 10), "test words");
$this->assertEquals(truncate("test...", 9), "test...");
$this->assertEquals(truncate("test...", 6), "test...");
$this->assertEquals(truncate("te...", 2), "te...");
}
public function test_to_shorthand_int()
{
$this->assertEquals(to_shorthand_int(1231231231), "1.1GB");
$this->assertEquals(to_shorthand_int(2), "2");
}
public function test_parse_shorthand_int()
{
$this->assertEquals(parse_shorthand_int("foo"), -1);
$this->assertEquals(parse_shorthand_int("32M"), 33554432);
$this->assertEquals(parse_shorthand_int("43.4KB"), 44441);
$this->assertEquals(parse_shorthand_int("1231231231"), 1231231231);
}
public function test_format_milliseconds()
{
$this->assertEquals("", format_milliseconds(5));
$this->assertEquals("5s", format_milliseconds(5000));
$this->assertEquals("1y 213d 16h 53m 20s", format_milliseconds(50000000000));
}
public function test_autodate()
{
$this->assertEquals(
"<time datetime='2012-06-23T16:14:22+00:00'>June 23, 2012; 16:14</time>",
autodate("2012-06-23 16:14:22")
);
}
public function test_validate_input()
{
$_POST = [
"foo" => " bar ",
"to_null" => " ",
"num" => "42",
];
$this->assertEquals(
["foo"=>"bar"],
validate_input(["foo"=>"string,trim,lower"])
);
//$this->assertEquals(
// ["to_null"=>null],
// validate_input(["to_null"=>"string,trim,nullify"])
//);
$this->assertEquals(
["num"=>42],
validate_input(["num"=>"int"])
);
}
public function test_sanitize_path()
{
$this->assertEquals(
"one",
sanitize_path("one")
);
$this->assertEquals(
"one".DIRECTORY_SEPARATOR."two",
sanitize_path("one\\two")
);
$this->assertEquals(
"one".DIRECTORY_SEPARATOR."two",
sanitize_path("one/two")
);
$this->assertEquals(
"one".DIRECTORY_SEPARATOR."two",
sanitize_path("one\\\\two")
);
$this->assertEquals(
"one".DIRECTORY_SEPARATOR."two",
sanitize_path("one//two")
);
$this->assertEquals(
"one".DIRECTORY_SEPARATOR."two",
sanitize_path("one\\\\\\two")
);
$this->assertEquals(
"one".DIRECTORY_SEPARATOR."two",
sanitize_path("one///two")
);
$this->assertEquals(
DIRECTORY_SEPARATOR."one".DIRECTORY_SEPARATOR."two".DIRECTORY_SEPARATOR,
sanitize_path("\\/one/\\/\\/two\\/")
);
}
public function test_join_path()
{
$this->assertEquals(
"one",
join_path("one")
);
$this->assertEquals(
"one".DIRECTORY_SEPARATOR."two",
join_path("one", "two")
);
$this->assertEquals(
"one".DIRECTORY_SEPARATOR."two".DIRECTORY_SEPARATOR."three",
join_path("one", "two", "three")
);
$this->assertEquals(
"one".DIRECTORY_SEPARATOR."two".DIRECTORY_SEPARATOR."three",
join_path("one/two", "three")
);
$this->assertEquals(
DIRECTORY_SEPARATOR."one".DIRECTORY_SEPARATOR."two".DIRECTORY_SEPARATOR."three".DIRECTORY_SEPARATOR,
join_path("\\/////\\\\one/\///"."\\//two\/\\//\\//", "//\/\\\/three/\\/\/")
);
}
public function test_stringer()
{
$this->assertEquals(
'["foo"=>"bar", "baz"=>[1, 2, 3], "qux"=>["a"=>"b"]]',
stringer(["foo"=>"bar", "baz"=>[1,2,3], "qux"=>["a"=>"b"]])
);
}
}

22
core/tests/tag.test.php Normal file
View file

@ -0,0 +1,22 @@
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;
require_once "core/imageboard/tag.php";
class TagTest extends TestCase
{
public function test_caret()
{
$this->assertEquals("foo", Tag::decaret("foo"));
$this->assertEquals("foo?", Tag::decaret("foo^q"));
$this->assertEquals("a^b/c\\d?e&f", Tag::decaret("a^^b^sc^bd^qe^af"));
}
public function test_decaret()
{
$this->assertEquals("foo", Tag::caret("foo"));
$this->assertEquals("foo^q", Tag::caret("foo?"));
$this->assertEquals("a^^b^sc^bd^qe^af", Tag::caret("a^b/c\\d?e&f"));
}
}

42
core/tests/urls.test.php Normal file
View file

@ -0,0 +1,42 @@
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;
require_once "core/urls.php";
class UrlsTest extends TestCase
{
public function test_make_link()
{
$this->assertEquals(
"/test/foo",
make_link("foo")
);
$this->assertEquals(
"/test/foo",
make_link("/foo")
);
}
public function test_make_http()
{
// relative to shimmie install
$this->assertEquals(
"http://<cli command>/test/foo",
make_http("foo")
);
// relative to web server
$this->assertEquals(
"http://<cli command>/foo",
make_http("/foo")
);
// absolute
$this->assertEquals(
"http://foo.com",
make_http("http://foo.com")
);
}
}

86
core/tests/util.test.php Normal file
View file

@ -0,0 +1,86 @@
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;
require_once "core/util.php";
class UtilTest extends TestCase
{
public function test_warehouse_path()
{
$hash = "7ac19c10d6859415";
$this->assertEquals(
join_path(DATA_DIR, "base", $hash),
warehouse_path("base", $hash, false, 0)
);
$this->assertEquals(
join_path(DATA_DIR, "base", "7a", $hash),
warehouse_path("base", $hash, false, 1)
);
$this->assertEquals(
join_path(DATA_DIR, "base", "7a", "c1", $hash),
warehouse_path("base", $hash, false, 2)
);
$this->assertEquals(
join_path(DATA_DIR, "base", "7a", "c1", "9c", $hash),
warehouse_path("base", $hash, false, 3)
);
$this->assertEquals(
join_path(DATA_DIR, "base", "7a", "c1", "9c", "10", $hash),
warehouse_path("base", $hash, false, 4)
);
$this->assertEquals(
join_path(DATA_DIR, "base", "7a", "c1", "9c", "10", "d6", $hash),
warehouse_path("base", $hash, false, 5)
);
$this->assertEquals(
join_path(DATA_DIR, "base", "7a", "c1", "9c", "10", "d6", "85", $hash),
warehouse_path("base", $hash, false, 6)
);
$this->assertEquals(
join_path(DATA_DIR, "base", "7a", "c1", "9c", "10", "d6", "85", "94", $hash),
warehouse_path("base", $hash, false, 7)
);
$this->assertEquals(
join_path(DATA_DIR, "base", "7a", "c1", "9c", "10", "d6", "85", "94", "15", $hash),
warehouse_path("base", $hash, false, 8)
);
$this->assertEquals(
join_path(DATA_DIR, "base", "7a", "c1", "9c", "10", "d6", "85", "94", "15", $hash),
warehouse_path("base", $hash, false, 9)
);
$this->assertEquals(
join_path(DATA_DIR, "base", "7a", "c1", "9c", "10", "d6", "85", "94", "15", $hash),
warehouse_path("base", $hash, false, 10)
);
}
public function test_load_balance_url()
{
$hash = "7ac19c10d6859415";
$ext = "jpg";
// pseudo-randomly select one of the image servers, balanced in given ratio
$this->assertEquals(
"https://baz.mycdn.com/7ac19c10d6859415.jpg",
load_balance_url("https://{foo=10,bar=5,baz=5}.mycdn.com/$hash.$ext", $hash)
);
// N'th and N+1'th results should be different
$this->assertNotEquals(
load_balance_url("https://{foo=10,bar=5,baz=5}.mycdn.com/$hash.$ext", $hash, 0),
load_balance_url("https://{foo=10,bar=5,baz=5}.mycdn.com/$hash.$ext", $hash, 1)
);
}
}

118
core/urls.php Normal file
View file

@ -0,0 +1,118 @@
<?php declare(strict_types=1);
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\
* HTML Generation *
\* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
class Link
{
public $page;
public $query;
public function __construct(?string $page=null, ?string $query=null)
{
$this->page = $page;
$this->query = $query;
}
public function make_link(): string
{
return make_link($this->page, $this->query);
}
}
/**
* Figure out the correct way to link to a page, taking into account
* things like the nice URLs setting.
*
* eg make_link("post/list") becomes "/v2/index.php?q=post/list"
*/
function make_link(?string $page=null, ?string $query=null): string
{
global $config;
if (is_null($page)) {
$page = $config->get_string(SetupConfig::MAIN_PAGE);
}
$install_dir = get_base_href();
if (SPEED_HAX || $config->get_bool('nice_urls', false)) {
$base = $install_dir;
} else {
$base = "$install_dir/index.php?q=";
}
if (is_null($query)) {
return str_replace("//", "/", $base.'/'.$page);
} else {
if (strpos($base, "?")) {
return $base .'/'. $page .'&'. $query;
} elseif (strpos($query, "#") === 0) {
return $base .'/'. $page . $query;
} else {
return $base .'/'. $page .'?'. $query;
}
}
}
/**
* Take the current URL and modify some parameters
*/
function modify_current_url(array $changes): string
{
return modify_url($_SERVER['QUERY_STRING'], $changes);
}
function modify_url(string $url, array $changes): string
{
// SHIT: PHP is officially the worst web API ever because it does not
// have a built-in function to do this.
// SHIT: parse_str is magically retarded; not only is it a useless name, it also
// didn't return the parsed array, preferring to overwrite global variables with
// whatever data the user supplied. Thankfully, 4.0.3 added an extra option to
// give it an array to use...
$params = [];
parse_str($url, $params);
if (isset($changes['q'])) {
$base = $changes['q'];
unset($changes['q']);
} else {
$base = _get_query();
}
if (isset($params['q'])) {
unset($params['q']);
}
foreach ($changes as $k => $v) {
if (is_null($v) and isset($params[$k])) {
unset($params[$k]);
}
$params[$k] = $v;
}
return make_link($base, http_build_query($params));
}
/**
* Turn a relative link into an absolute one, including hostname
*/
function make_http(string $link): string
{
if (strpos($link, "://") > 0) {
return $link;
}
if (strlen($link) > 0 && $link[0] != '/') {
$link = get_base_href() . '/' . $link;
}
$protocol = is_https_enabled() ? "https://" : "http://";
$link = $protocol . $_SERVER["HTTP_HOST"] . $link;
$link = str_replace("/./", "/", $link);
return $link;
}

View file

@ -1,300 +0,0 @@
<?php
/**
* @private
* @param mixed $row
* @return User
*/
function _new_user($row) {
return new User($row);
}
/**
* Class User
*
* An object representing a row in the "users" table.
*
* The currently logged in user will always be accessible via the global variable $user.
*/
class User {
/** @var int */
public $id;
/** @var string */
public $name;
/** @var string */
public $email;
public $join_date;
/** @var string */
public $passhash;
/** @var UserClass */
public $class;
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
* Initialisation *
* *
* User objects shouldn't be created directly, they should be *
* fetched from the database like so: *
* *
* $user = User::by_name("bob"); *
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/**
* One will very rarely construct a user directly, more common
* would be to use User::by_id, User::by_session, etc.
*
* @param mixed $row
* @throws SCoreException
*/
public function __construct($row) {
global $_shm_user_classes;
$this->id = int_escape($row['id']);
$this->name = $row['name'];
$this->email = $row['email'];
$this->join_date = $row['joindate'];
$this->passhash = $row['pass'];
if(array_key_exists($row["class"], $_shm_user_classes)) {
$this->class = $_shm_user_classes[$row["class"]];
}
else {
throw new SCoreException("User '{$this->name}' has invalid class '{$row["class"]}'");
}
}
/**
* Construct a User by session.
*
* @param string $name
* @param string $session
* @return null|User
*/
public static function by_session(/*string*/ $name, /*string*/ $session) {
global $config, $database;
$row = $database->cache->get("user-session:$name-$session");
if(!$row) {
if($database->get_driver_name() === "mysql") {
$query = "SELECT * FROM users WHERE name = :name AND md5(concat(pass, :ip)) = :sess";
}
else {
$query = "SELECT * FROM users WHERE name = :name AND md5(pass || :ip) = :sess";
}
$row = $database->get_row($query, array("name"=>$name, "ip"=>get_session_ip($config), "sess"=>$session));
$database->cache->set("user-session:$name-$session", $row, 600);
}
return is_null($row) ? null : new User($row);
}
/**
* Construct a User by session.
* @param int $id
* @return null|User
*/
public static function by_id(/*int*/ $id) {
assert('is_numeric($id)', var_export($id, true));
global $database;
if($id === 1) {
$cached = $database->cache->get('user-id:'.$id);
if($cached) return new User($cached);
}
$row = $database->get_row("SELECT * FROM users WHERE id = :id", array("id"=>$id));
if($id === 1) $database->cache->set('user-id:'.$id, $row, 600);
return is_null($row) ? null : new User($row);
}
/**
* Construct a User by name.
* @param string $name
* @return null|User
*/
public static function by_name(/*string*/ $name) {
assert('is_string($name)', var_export($name, true));
global $database;
$row = $database->get_row($database->scoreql_to_sql("SELECT * FROM users WHERE SCORE_STRNORM(name) = SCORE_STRNORM(:name)"), array("name"=>$name));
return is_null($row) ? null : new User($row);
}
/**
* Construct a User by name and password.
* @param string $name
* @param string $pass
* @return null|User
*/
public static function by_name_and_pass(/*string*/ $name, /*string*/ $pass) {
assert('is_string($name)', var_export($name, true));
assert('is_string($pass)', var_export($pass, true));
$user = User::by_name($name);
if($user) {
if($user->passhash == md5(strtolower($name) . $pass)) {
$user->set_password($pass);
}
if(password_verify($pass, $user->passhash)) {
return $user;
}
}
}
/* useful user object functions start here */
/**
* @param string $ability
* @return bool
*/
public function can($ability) {
return $this->class->can($ability);
}
/**
* Test if this user is anonymous (not logged in).
*
* @return bool
*/
public function is_anonymous() {
global $config;
return ($this->id === $config->get_int('anon_id'));
}
/**
* Test if this user is logged in.
*
* @return bool
*/
public function is_logged_in() {
global $config;
return ($this->id !== $config->get_int('anon_id'));
}
/**
* Test if this user is an administrator.
*
* @return bool
*/
public function is_admin() {
return ($this->class->name === "admin");
}
/**
* @param string $class
*/
public function set_class(/*string*/ $class) {
assert('is_string($class)', var_export($class, true));
global $database;
$database->Execute("UPDATE users SET class=:class WHERE id=:id", array("class"=>$class, "id"=>$this->id));
log_info("core-user", 'Set class for '.$this->name.' to '.$class);
}
/**
* @param string $name
* @throws Exception
*/
public function set_name(/*string*/ $name) {
global $database;
if(User::by_name($name)) {
throw new Exception("Desired username is already in use");
}
$old_name = $this->name;
$this->name = $name;
$database->Execute("UPDATE users SET name=:name WHERE id=:id", array("name"=>$this->name, "id"=>$this->id));
log_info("core-user", "Changed username for {$old_name} to {$this->name}");
}
/**
* @param string $password
*/
public function set_password(/*string*/ $password) {
global $database;
$hash = password_hash($password, PASSWORD_BCRYPT);
if(is_string($hash)) {
$this->passhash = $hash;
$database->Execute("UPDATE users SET pass=:hash WHERE id=:id", array("hash"=>$this->passhash, "id"=>$this->id));
log_info("core-user", 'Set password for '.$this->name);
}
else {
throw new SCoreException("Failed to hash password");
}
}
/**
* @param string $address
*/
public function set_email(/*string*/ $address) {
global $database;
$database->Execute("UPDATE users SET email=:email WHERE id=:id", array("email"=>$address, "id"=>$this->id));
log_info("core-user", 'Set email for '.$this->name);
}
/**
* Get a snippet of HTML which will render the user's avatar, be that
* a local file, a remote file, a gravatar, a something else, etc.
*
* @return String of HTML
*/
public function get_avatar_html() {
// FIXME: configurable
global $config;
if($config->get_string("avatar_host") === "gravatar") {
if(!empty($this->email)) {
$hash = md5(strtolower($this->email));
$s = $config->get_string("avatar_gravatar_size");
$d = urlencode($config->get_string("avatar_gravatar_default"));
$r = $config->get_string("avatar_gravatar_rating");
$cb = date("Y-m-d");
return "<img class=\"avatar gravatar\" src=\"http://www.gravatar.com/avatar/$hash.jpg?s=$s&d=$d&r=$r&cacheBreak=$cb\">";
}
}
return "";
}
/**
* Get an auth token to be used in POST forms
*
* password = secret, avoid storing directly
* passhash = bcrypt(password), so someone who gets to the database can't get passwords
* sesskey = md5(passhash . IP), so if it gets sniffed it can't be used from another IP,
* and it can't be used to get the passhash to generate new sesskeys
* authtok = md5(sesskey, salt), presented to the user in web forms, to make sure that
* the form was generated within the session. Salted and re-hashed so that
* reading a web page from the user's cache doesn't give access to the session key
*
* @return string A string containing auth token (MD5sum)
*/
public function get_auth_token() {
global $config;
$salt = DATABASE_DSN;
$addr = get_session_ip($config);
return md5(md5($this->passhash . $addr) . "salty-csrf-" . $salt);
}
public function get_auth_html() {
$at = $this->get_auth_token();
return '<input type="hidden" name="auth_token" value="'.$at.'">';
}
public function check_auth_token() {
return (isset($_POST["auth_token"]) && $_POST["auth_token"] == $this->get_auth_token());
}
}
class MockUser extends User {
public function __construct($name) {
$row = array(
"name" => $name,
"id" => 1,
"email" => "",
"joindate" => "",
"pass" => "",
"class" => "admin",
);
parent::__construct($row);
}
}

259
core/user.php Normal file
View file

@ -0,0 +1,259 @@
<?php declare(strict_types=1);
function _new_user(array $row): User
{
return new User($row);
}
/**
* Class User
*
* An object representing a row in the "users" table.
*
* The currently logged in user will always be accessible via the global variable $user.
*/
class User
{
/** @var int */
public $id;
/** @var string */
public $name;
/** @var string */
public $email;
public $join_date;
/** @var string */
public $passhash;
/** @var UserClass */
public $class;
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
* Initialisation *
* *
* User objects shouldn't be created directly, they should be *
* fetched from the database like so: *
* *
* $user = User::by_name("bob"); *
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/**
* One will very rarely construct a user directly, more common
* would be to use User::by_id, User::by_session, etc.
*
* @throws SCoreException
*/
public function __construct(array $row)
{
global $_shm_user_classes;
$this->id = int_escape((string)$row['id']);
$this->name = $row['name'];
$this->email = $row['email'];
$this->join_date = $row['joindate'];
$this->passhash = $row['pass'];
if (array_key_exists($row["class"], $_shm_user_classes)) {
$this->class = $_shm_user_classes[$row["class"]];
} else {
throw new SCoreException("User '{$this->name}' has invalid class '{$row["class"]}'");
}
}
public static function by_session(string $name, string $session): ?User
{
global $cache, $config, $database;
$row = $cache->get("user-session:$name-$session");
if (!$row) {
if ($database->get_driver_name() === DatabaseDriver::MYSQL) {
$query = "SELECT * FROM users WHERE name = :name AND md5(concat(pass, :ip)) = :sess";
} else {
$query = "SELECT * FROM users WHERE name = :name AND md5(pass || :ip) = :sess";
}
$row = $database->get_row($query, ["name"=>$name, "ip"=>get_session_ip($config), "sess"=>$session]);
$cache->set("user-session:$name-$session", $row, 600);
}
return is_null($row) ? null : new User($row);
}
public static function by_id(int $id): ?User
{
global $cache, $database;
if ($id === 1) {
$cached = $cache->get('user-id:'.$id);
if ($cached) {
return new User($cached);
}
}
$row = $database->get_row("SELECT * FROM users WHERE id = :id", ["id"=>$id]);
if ($id === 1) {
$cache->set('user-id:'.$id, $row, 600);
}
return is_null($row) ? null : new User($row);
}
public static function by_name(string $name): ?User
{
global $database;
$row = $database->get_row("SELECT * FROM users WHERE LOWER(name) = LOWER(:name)", ["name"=>$name]);
return is_null($row) ? null : new User($row);
}
public static function name_to_id(string $name): int
{
$u = User::by_name($name);
if (is_null($u)) {
throw new ScoreException("Can't find any user named $name");
} else {
return $u->id;
}
}
public static function by_name_and_pass(string $name, string $pass): ?User
{
$my_user = User::by_name($name);
// If user tried to log in as "foo bar" and failed, try "foo_bar"
if (!$my_user && strpos($name, " ") !== false) {
$my_user = User::by_name(str_replace(" ", "_", $name));
}
if ($my_user) {
if ($my_user->passhash == md5(strtolower($name) . $pass)) {
log_info("core-user", "Migrating from md5 to bcrypt for $name");
$my_user->set_password($pass);
}
if (password_verify($pass, $my_user->passhash)) {
log_info("core-user", "Logged in as $name ({$my_user->class->name})");
return $my_user;
} else {
log_warning("core-user", "Failed to log in as $name (Invalid password)");
}
} else {
log_warning("core-user", "Failed to log in as $name (Invalid username)");
}
return null;
}
/* useful user object functions start here */
public function can(string $ability): bool
{
return $this->class->can($ability);
}
public function is_anonymous(): bool
{
global $config;
return ($this->id === $config->get_int('anon_id'));
}
public function is_logged_in(): bool
{
global $config;
return ($this->id !== $config->get_int('anon_id'));
}
public function set_class(string $class): void
{
global $database;
$database->Execute("UPDATE users SET class=:class WHERE id=:id", ["class"=>$class, "id"=>$this->id]);
log_info("core-user", 'Set class for '.$this->name.' to '.$class);
}
public function set_name(string $name): void
{
global $database;
if (User::by_name($name)) {
throw new ScoreException("Desired username is already in use");
}
$old_name = $this->name;
$this->name = $name;
$database->Execute("UPDATE users SET name=:name WHERE id=:id", ["name"=>$this->name, "id"=>$this->id]);
log_info("core-user", "Changed username for {$old_name} to {$this->name}");
}
public function set_password(string $password): void
{
global $database;
$hash = password_hash($password, PASSWORD_BCRYPT);
if (is_string($hash)) {
$this->passhash = $hash;
$database->Execute("UPDATE users SET pass=:hash WHERE id=:id", ["hash"=>$this->passhash, "id"=>$this->id]);
log_info("core-user", 'Set password for '.$this->name);
} else {
throw new SCoreException("Failed to hash password");
}
}
public function set_email(string $address): void
{
global $database;
$database->Execute("UPDATE users SET email=:email WHERE id=:id", ["email"=>$address, "id"=>$this->id]);
log_info("core-user", 'Set email for '.$this->name);
}
/**
* Get a snippet of HTML which will render the user's avatar, be that
* a local file, a remote file, a gravatar, a something else, etc.
*/
public function get_avatar_html(): string
{
// FIXME: configurable
global $config;
if ($config->get_string("avatar_host") === "gravatar") {
if (!empty($this->email)) {
$hash = md5(strtolower($this->email));
$s = $config->get_string("avatar_gravatar_size");
$d = urlencode($config->get_string("avatar_gravatar_default"));
$r = $config->get_string("avatar_gravatar_rating");
$cb = date("Y-m-d");
return "<img alt='avatar' class=\"avatar gravatar\" src=\"https://www.gravatar.com/avatar/$hash.jpg?s=$s&d=$d&r=$r&cacheBreak=$cb\">";
}
}
return "";
}
/**
* Get an auth token to be used in POST forms
*
* password = secret, avoid storing directly
* passhash = bcrypt(password), so someone who gets to the database can't get passwords
* sesskey = md5(passhash . IP), so if it gets sniffed it can't be used from another IP,
* and it can't be used to get the passhash to generate new sesskeys
* authtok = md5(sesskey, salt), presented to the user in web forms, to make sure that
* the form was generated within the session. Salted and re-hashed so that
* reading a web page from the user's cache doesn't give access to the session key
*/
public function get_auth_token(): string
{
global $config;
$salt = DATABASE_DSN;
$addr = get_session_ip($config);
return md5(md5($this->passhash . $addr) . "salty-csrf-" . $salt);
}
public function get_auth_html(): string
{
$at = $this->get_auth_token();
return '<input type="hidden" name="auth_token" value="'.$at.'">';
}
public function check_auth_token(): bool
{
return (isset($_POST["auth_token"]) && $_POST["auth_token"] == $this->get_auth_token());
}
public function ensure_authed(): void
{
if (!$this->check_auth_token()) {
die("Invalid auth token");
}
}
}

View file

@ -1,200 +0,0 @@
<?php
/**
* @global UserClass[] $_shm_user_classes
*/
global $_shm_user_classes;
$_shm_user_classes = array();
/**
* Class UserClass
*/
class UserClass {
/**
* @var null|string
*/
public $name = null;
/**
* @var \UserClass|null
*/
public $parent = null;
/**
* @var array
*/
public $abilities = array();
/**
* @param string $name
* @param null|string $parent
* @param array $abilities
*/
public function __construct($name, $parent=null, $abilities=array()) {
global $_shm_user_classes;
$this->name = $name;
$this->abilities = $abilities;
if(!is_null($parent)) {
$this->parent = $_shm_user_classes[$parent];
}
$_shm_user_classes[$name] = $this;
}
/**
* Determine if this class of user can perform an action or has ability.
*
* @param string $ability
* @return bool
* @throws SCoreException
*/
public function can(/*string*/ $ability) {
if(array_key_exists($ability, $this->abilities)) {
$val = $this->abilities[$ability];
return $val;
}
else if(!is_null($this->parent)) {
return $this->parent->can($ability);
}
else {
global $_shm_user_classes;
$min_dist = 9999;
$min_ability = null;
foreach($_shm_user_classes['base']->abilities as $a => $cando) {
$v = levenshtein($ability, $a);
if($v < $min_dist) {
$min_dist = $v;
$min_ability = $a;
}
}
throw new SCoreException("Unknown ability '".html_escape($ability)."'. Did the developer mean '".html_escape($min_ability)."'?");
}
}
}
// action_object_attribute
// action = create / view / edit / delete
// object = image / user / tag / setting
new UserClass("base", null, array(
"change_setting" => False, # modify web-level settings, eg the config table
"override_config" => False, # modify sys-level settings, eg shimmie.conf.php
"big_search" => False, # search for more than 3 tags at once (speed mode only)
"manage_extension_list" => False,
"manage_alias_list" => False,
"mass_tag_edit" => False,
"view_ip" => False, # view IP addresses associated with things
"ban_ip" => False,
"edit_user_name" => False,
"edit_user_password" => False,
"edit_user_info" => False, # email address, etc
"edit_user_class" => False,
"delete_user" => False,
"create_comment" => False,
"delete_comment" => False,
"bypass_comment_checks" => False, # spam etc
"replace_image" => False,
"create_image" => False,
"edit_image_tag" => False,
"edit_image_source" => False,
"edit_image_owner" => False,
"edit_image_lock" => False,
"bulk_edit_image_tag" => False,
"bulk_edit_image_source" => False,
"delete_image" => False,
"ban_image" => False,
"view_eventlog" => False,
"ignore_downtime" => False,
"create_image_report" => False,
"view_image_report" => False, # deal with reported images
"edit_wiki_page" => False,
"delete_wiki_page" => False,
"manage_blocks" => False,
"manage_admintools" => False,
"view_other_pms" => False,
"edit_feature" => False,
"bulk_edit_vote" => False,
"edit_other_vote" => False,
"view_sysinfo" => False,
"hellbanned" => False,
"view_hellbanned" => False,
"protected" => False, # only admins can modify protected users (stops a moderator changing an admin's password)
));
new UserClass("anonymous", "base", array(
));
new UserClass("user", "base", array(
"big_search" => True,
"create_image" => True,
"create_comment" => True,
"edit_image_tag" => True,
"edit_image_source" => True,
"create_image_report" => True,
));
new UserClass("admin", "base", array(
"change_setting" => True,
"override_config" => True,
"big_search" => True,
"edit_image_lock" => True,
"view_ip" => True,
"ban_ip" => True,
"edit_user_name" => True,
"edit_user_password" => True,
"edit_user_info" => True,
"edit_user_class" => True,
"delete_user" => True,
"create_image" => True,
"delete_image" => True,
"ban_image" => True,
"create_comment" => True,
"delete_comment" => True,
"bypass_comment_checks" => True,
"replace_image" => True,
"manage_extension_list" => True,
"manage_alias_list" => True,
"edit_image_tag" => True,
"edit_image_source" => True,
"edit_image_owner" => True,
"bulk_edit_image_tag" => True,
"bulk_edit_image_source" => True,
"mass_tag_edit" => True,
"create_image_report" => True,
"view_image_report" => True,
"edit_wiki_page" => True,
"delete_wiki_page" => True,
"view_eventlog" => True,
"manage_blocks" => True,
"manage_admintools" => True,
"ignore_downtime" => True,
"view_other_pms" => True,
"edit_feature" => True,
"bulk_edit_vote" => True,
"edit_other_vote" => True,
"view_sysinfo" => True,
"view_hellbanned" => True,
"protected" => True,
));
new UserClass("hellbanned", "user", array(
"hellbanned" => True,
));
@include_once "data/config/user-classes.conf.php";

263
core/userclass.php Normal file
View file

@ -0,0 +1,263 @@
<?php declare(strict_types=1);
/**
* @global UserClass[] $_shm_user_classes
*/
global $_shm_user_classes;
$_shm_user_classes = [];
/**
* Class UserClass
*/
class UserClass
{
/**
* @var ?string
*/
public $name = null;
/**
* @var ?UserClass
*/
public $parent = null;
/**
* @var array
*/
public $abilities = [];
public function __construct(string $name, string $parent=null, array $abilities=[])
{
global $_shm_user_classes;
$this->name = $name;
$this->abilities = $abilities;
if (!is_null($parent)) {
$this->parent = $_shm_user_classes[$parent];
}
$_shm_user_classes[$name] = $this;
}
/**
* Determine if this class of user can perform an action or has ability.
*
* @throws SCoreException
*/
public function can(string $ability): bool
{
if (array_key_exists($ability, $this->abilities)) {
return $this->abilities[$ability];
} elseif (!is_null($this->parent)) {
return $this->parent->can($ability);
} else {
global $_shm_user_classes;
$min_dist = 9999;
$min_ability = null;
foreach ($_shm_user_classes['base']->abilities as $a => $cando) {
$v = levenshtein($ability, $a);
if ($v < $min_dist) {
$min_dist = $v;
$min_ability = $a;
}
}
throw new SCoreException("Unknown ability '$ability'. Did the developer mean '$min_ability'?");
}
}
}
// action_object_attribute
// action = create / view / edit / delete
// object = image / user / tag / setting
new UserClass("base", null, [
Permissions::CHANGE_SETTING => false, # modify web-level settings, eg the config table
Permissions::OVERRIDE_CONFIG => false, # modify sys-level settings, eg shimmie.conf.php
Permissions::BIG_SEARCH => false, # search for more than 3 tags at once (speed mode only)
Permissions::MANAGE_EXTENSION_LIST => false,
Permissions::MANAGE_ALIAS_LIST => false,
Permissions::MANAGE_AUTO_TAG => false,
Permissions::MASS_TAG_EDIT => false,
Permissions::VIEW_IP => false, # view IP addresses associated with things
Permissions::BAN_IP => false,
Permissions::CREATE_USER => false,
Permissions::EDIT_USER_NAME => false,
Permissions::EDIT_USER_PASSWORD => false,
Permissions::EDIT_USER_INFO => false, # email address, etc
Permissions::EDIT_USER_CLASS => false,
Permissions::DELETE_USER => false,
Permissions::CREATE_COMMENT => false,
Permissions::DELETE_COMMENT => false,
Permissions::BYPASS_COMMENT_CHECKS => false, # spam etc
Permissions::REPLACE_IMAGE => false,
Permissions::CREATE_IMAGE => false,
Permissions::EDIT_IMAGE_TAG => false,
Permissions::EDIT_IMAGE_SOURCE => false,
Permissions::EDIT_IMAGE_OWNER => false,
Permissions::EDIT_IMAGE_LOCK => false,
Permissions::EDIT_IMAGE_TITLE => false,
Permissions::EDIT_IMAGE_RELATIONSHIPS => false,
Permissions::EDIT_IMAGE_ARTIST => false,
Permissions::BULK_EDIT_IMAGE_TAG => false,
Permissions::BULK_EDIT_IMAGE_SOURCE => false,
Permissions::DELETE_IMAGE => false,
Permissions::BAN_IMAGE => false,
Permissions::VIEW_EVENTLOG => false,
Permissions::IGNORE_DOWNTIME => false,
Permissions::CREATE_IMAGE_REPORT => false,
Permissions::VIEW_IMAGE_REPORT => false, # deal with reported images
Permissions::WIKI_ADMIN => false,
Permissions::EDIT_WIKI_PAGE => false,
Permissions::DELETE_WIKI_PAGE => false,
Permissions::MANAGE_BLOCKS => false,
Permissions::MANAGE_ADMINTOOLS => false,
Permissions::SEND_PM => false,
Permissions::READ_PM => false,
Permissions::VIEW_OTHER_PMS => false,
Permissions::EDIT_FEATURE => false,
Permissions::BULK_EDIT_VOTE => false,
Permissions::EDIT_OTHER_VOTE => false,
Permissions::VIEW_SYSINTO => false,
Permissions::HELLBANNED => false,
Permissions::VIEW_HELLBANNED => false,
Permissions::PROTECTED => false, # only admins can modify protected users (stops a moderator changing an admin's password)
Permissions::EDIT_IMAGE_RATING => false,
Permissions::BULK_EDIT_IMAGE_RATING => false,
Permissions::VIEW_TRASH => false,
Permissions::PERFORM_BULK_ACTIONS => false,
Permissions::BULK_ADD => false,
Permissions::EDIT_FILES => false,
Permissions::EDIT_TAG_CATEGORIES => false,
Permissions::RESCAN_MEDIA => false,
Permissions::SEE_IMAGE_VIEW_COUNTS => false,
Permissions::EDIT_FAVOURITES => false,
Permissions::ARTISTS_ADMIN => false,
Permissions::BLOTTER_ADMIN => false,
Permissions::FORUM_ADMIN => false,
Permissions::NOTES_ADMIN => false,
Permissions::POOLS_ADMIN => false,
Permissions::TIPS_ADMIN => false,
Permissions::CRON_ADMIN => false,
Permissions::APPROVE_IMAGE => false,
Permissions::APPROVE_COMMENT => false,
]);
// Ghost users can't do anything
new UserClass("ghost", "base", [
]);
// Anonymous users can't do anything by default, but
// the admin might grant them some permissions
new UserClass("anonymous", "base", [
Permissions::CREATE_USER => true,
]);
new UserClass("user", "base", [
Permissions::BIG_SEARCH => true,
Permissions::CREATE_IMAGE => true,
Permissions::CREATE_COMMENT => true,
Permissions::EDIT_IMAGE_TAG => true,
Permissions::EDIT_IMAGE_SOURCE => true,
Permissions::EDIT_IMAGE_TITLE => true,
Permissions::EDIT_IMAGE_RELATIONSHIPS => true,
Permissions::EDIT_IMAGE_ARTIST => true,
Permissions::CREATE_IMAGE_REPORT => true,
Permissions::EDIT_IMAGE_RATING => true,
Permissions::EDIT_FAVOURITES => true,
Permissions::SEND_PM => true,
Permissions::READ_PM => true,
]);
new UserClass("admin", "base", [
Permissions::CHANGE_SETTING => true,
Permissions::OVERRIDE_CONFIG => true,
Permissions::BIG_SEARCH => true,
Permissions::EDIT_IMAGE_LOCK => true,
Permissions::VIEW_IP => true,
Permissions::BAN_IP => true,
Permissions::EDIT_USER_NAME => true,
Permissions::EDIT_USER_PASSWORD => true,
Permissions::EDIT_USER_INFO => true,
Permissions::EDIT_USER_CLASS => true,
Permissions::DELETE_USER => true,
Permissions::CREATE_IMAGE => true,
Permissions::DELETE_IMAGE => true,
Permissions::BAN_IMAGE => true,
Permissions::CREATE_COMMENT => true,
Permissions::DELETE_COMMENT => true,
Permissions::BYPASS_COMMENT_CHECKS => true,
Permissions::REPLACE_IMAGE => true,
Permissions::MANAGE_EXTENSION_LIST => true,
Permissions::MANAGE_ALIAS_LIST => true,
Permissions::MANAGE_AUTO_TAG => true,
Permissions::EDIT_IMAGE_TAG => true,
Permissions::EDIT_IMAGE_SOURCE => true,
Permissions::EDIT_IMAGE_OWNER => true,
Permissions::EDIT_IMAGE_TITLE => true,
Permissions::BULK_EDIT_IMAGE_TAG => true,
Permissions::BULK_EDIT_IMAGE_SOURCE => true,
Permissions::MASS_TAG_EDIT => true,
Permissions::CREATE_IMAGE_REPORT => true,
Permissions::VIEW_IMAGE_REPORT => true,
Permissions::WIKI_ADMIN => true,
Permissions::EDIT_WIKI_PAGE => true,
Permissions::DELETE_WIKI_PAGE => true,
Permissions::VIEW_EVENTLOG => true,
Permissions::MANAGE_BLOCKS => true,
Permissions::MANAGE_ADMINTOOLS => true,
Permissions::IGNORE_DOWNTIME => true,
Permissions::SEND_PM => true,
Permissions::READ_PM => true,
Permissions::VIEW_OTHER_PMS => true,
Permissions::EDIT_FEATURE => true,
Permissions::BULK_EDIT_VOTE => true,
Permissions::EDIT_OTHER_VOTE => true,
Permissions::VIEW_SYSINTO => true,
Permissions::VIEW_HELLBANNED => true,
Permissions::PROTECTED => true,
Permissions::EDIT_IMAGE_RATING => true,
Permissions::BULK_EDIT_IMAGE_RATING => true,
Permissions::VIEW_TRASH => true,
Permissions::PERFORM_BULK_ACTIONS => true,
Permissions::BULK_ADD => true,
Permissions::EDIT_FILES => true,
Permissions::EDIT_TAG_CATEGORIES => true,
Permissions::RESCAN_MEDIA => true,
Permissions::SEE_IMAGE_VIEW_COUNTS => true,
Permissions::ARTISTS_ADMIN => true,
Permissions::BLOTTER_ADMIN => true,
Permissions::FORUM_ADMIN => true,
Permissions::NOTES_ADMIN => true,
Permissions::POOLS_ADMIN => true,
Permissions::TIPS_ADMIN => true,
Permissions::CRON_ADMIN => true,
Permissions::APPROVE_IMAGE => true,
Permissions::APPROVE_COMMENT => true,
]);
new UserClass("hellbanned", "user", [
Permissions::HELLBANNED => true,
]);
@include_once "data/config/user-classes.conf.php";

File diff suppressed because it is too large Load diff

745
core/util.php Normal file
View file

@ -0,0 +1,745 @@
<?php declare(strict_types=1);
use function MicroHTML\emptyHTML;
use function MicroHTML\rawHTML;
use function MicroHTML\FORM;
use function MicroHTML\INPUT;
use function MicroHTML\DIV;
use function MicroHTML\PRE;
use function MicroHTML\P;
use function MicroHTML\TABLE;
use function MicroHTML\THEAD;
use function MicroHTML\TFOOT;
use function MicroHTML\TR;
use function MicroHTML\TH;
use function MicroHTML\TD;
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\
* Misc *
\* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
const DATA_DIR = "data";
function mtimefile(string $file): string
{
$data_href = get_base_href();
$mtime = filemtime($file);
return "$data_href/$file?$mtime";
}
function get_theme(): string
{
global $config;
$theme = $config->get_string(SetupConfig::THEME, "default");
if (!file_exists("themes/$theme")) {
$theme = "default";
}
return $theme;
}
function contact_link(): ?string
{
global $config;
$text = $config->get_string('contact_link');
if (is_null($text)) {
return null;
}
if (
startsWith($text, "http:") ||
startsWith($text, "https:") ||
startsWith($text, "mailto:")
) {
return $text;
}
if (strpos($text, "@")) {
return "mailto:$text";
}
if (strpos($text, "/")) {
return "http://$text";
}
return $text;
}
/**
* Check if HTTPS is enabled for the server.
*/
function is_https_enabled(): bool
{
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
}
/**
* Compare two Block objects, used to sort them before being displayed
*/
function blockcmp(Block $a, Block $b): int
{
if ($a->position == $b->position) {
return 0;
} else {
return ($a->position > $b->position) ? 1 : -1;
}
}
/**
* Figure out PHP's internal memory limit
*/
function get_memory_limit(): int
{
global $config;
// thumbnail generation requires lots of memory
$default_limit = 8*1024*1024; // 8 MB of memory is PHP's default.
$shimmie_limit = $config->get_int(MediaConfig::MEM_LIMIT);
if ($shimmie_limit < 3*1024*1024) {
// we aren't going to fit, override
$shimmie_limit = $default_limit;
}
/*
Get PHP's configured memory limit.
Note that this is set to -1 for NO memory limit.
http://ca2.php.net/manual/en/ini.core.php#ini.memory-limit
*/
$memory = parse_shorthand_int(ini_get("memory_limit"));
if ($memory == -1) {
// No memory limit.
// Return the larger of the set limits.
return max($shimmie_limit, $default_limit);
} else {
// PHP has a memory limit set.
if ($shimmie_limit > $memory) {
// Shimmie wants more memory than what PHP is currently set for.
// Attempt to set PHP's memory limit.
if (ini_set("memory_limit", "$shimmie_limit") === false) {
/* We can't change PHP's limit, oh well, return whatever its currently set to */
return $memory;
}
$memory = parse_shorthand_int(ini_get("memory_limit"));
}
// PHP's memory limit is more than Shimmie needs.
return $memory; // return the current setting
}
}
/**
* Check if PHP has the GD library installed
*/
function check_gd_version(): int
{
$gdversion = 0;
if (function_exists('gd_info')) {
$gd_info = gd_info();
if (substr_count($gd_info['GD Version'], '2.')) {
$gdversion = 2;
} elseif (substr_count($gd_info['GD Version'], '1.')) {
$gdversion = 1;
}
}
return $gdversion;
}
/**
* Check whether ImageMagick's `convert` command
* is installed and working
*/
function check_im_version(): int
{
$convert_check = exec("convert");
return (empty($convert_check) ? 0 : 1);
}
/**
* Get the currently active IP, masked to make it not change when the last
* octet or two change, for use in session cookies and such
*/
function get_session_ip(Config $config): string
{
$mask = $config->get_string("session_hash_mask", "255.255.0.0");
$addr = $_SERVER['REMOTE_ADDR'];
$addr = inet_ntop(inet_pton($addr) & inet_pton($mask));
return $addr;
}
/**
* A shorthand way to send a TextFormattingEvent and get the results.
*/
function format_text(string $string): string
{
$tfe = send_event(new TextFormattingEvent($string));
return $tfe->formatted;
}
/**
* Generates the path to a file under the data folder based on the file's hash.
* This process creates subfolders based on octet pairs from the file's hash.
* The calculated folder follows this pattern data/$base/octet_pairs/$hash
* @param string $base
* @param string $hash
* @param bool $create
* @param int $splits The number of octet pairs to split the hash into. Caps out at strlen($hash)/2.
* @return string
*/
function warehouse_path(string $base, string $hash, bool $create=true, int $splits = WH_SPLITS): string
{
$dirs =[DATA_DIR, $base];
$splits = min($splits, strlen($hash) / 2);
for ($i = 0; $i < $splits; $i++) {
$dirs[] = substr($hash, $i * 2, 2);
}
$dirs[] = $hash;
$pa = join_path(...$dirs);
if ($create && !file_exists(dirname($pa))) {
mkdir(dirname($pa), 0755, true);
}
return $pa;
}
/**
* Determines the path to the specified file in the data folder.
*/
function data_path(string $filename, bool $create = true): string
{
$filename = join_path("data", $filename);
if ($create&&!file_exists(dirname($filename))) {
mkdir(dirname($filename), 0755, true);
}
return $filename;
}
function load_balance_url(string $tmpl, string $hash, int $n=0): string
{
static $flexihashes = [];
$matches = [];
if (preg_match("/(.*){(.*)}(.*)/", $tmpl, $matches)) {
$pre = $matches[1];
$opts = $matches[2];
$post = $matches[3];
if (isset($flexihashes[$opts])) {
$flexihash = $flexihashes[$opts];
} else {
$flexihash = new Flexihash\Flexihash();
foreach (explode(",", $opts) as $opt) {
$parts = explode("=", $opt);
$parts_count = count($parts);
$opt_val = "";
$opt_weight = 0;
if ($parts_count === 2) {
$opt_val = $parts[0];
$opt_weight = $parts[1];
} elseif ($parts_count === 1) {
$opt_val = $parts[0];
$opt_weight = 1;
}
$flexihash->addTarget($opt_val, $opt_weight);
}
$flexihashes[$opts] = $flexihash;
}
// $choice = $flexihash->lookup($pre.$post);
$choices = $flexihash->lookupList($hash, $n + 1); // hash doesn't change
$choice = $choices[$n];
$tmpl = $pre . $choice . $post;
}
return $tmpl;
}
function transload(string $url, string $mfile): ?array
{
global $config;
if ($config->get_string("transload_engine") === "curl" && function_exists("curl_init")) {
$ch = curl_init($url);
$fp = fopen($mfile, "w");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_VERBOSE, 1);
curl_setopt($ch, CURLOPT_HEADER, 1);
curl_setopt($ch, CURLOPT_REFERER, $url);
curl_setopt($ch, CURLOPT_USERAGENT, "Shimmie-".VERSION);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
$response = curl_exec($ch);
if ($response === false) {
log_warning("core-util", "Failed to transload $url");
throw new SCoreException("Failed to fetch $url");
}
$header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$headers = http_parse_headers(implode("\n", preg_split('/\R/', rtrim(substr($response, 0, $header_size)))));
$body = substr($response, $header_size);
curl_close($ch);
fwrite($fp, $body);
fclose($fp);
return $headers;
}
if ($config->get_string("transload_engine") === "wget") {
$s_url = escapeshellarg($url);
$s_mfile = escapeshellarg($mfile);
system("wget --no-check-certificate $s_url --output-document=$s_mfile");
return file_exists($mfile) ? ["ok"=>"true"] : null;
}
if ($config->get_string("transload_engine") === "fopen") {
$fp_in = @fopen($url, "r");
$fp_out = fopen($mfile, "w");
if (!$fp_in || !$fp_out) {
return null;
}
$length = 0;
while (!feof($fp_in) && $length <= $config->get_int('upload_size')) {
$data = fread($fp_in, 8192);
$length += strlen($data);
fwrite($fp_out, $data);
}
fclose($fp_in);
fclose($fp_out);
$headers = http_parse_headers(implode("\n", $http_response_header));
return $headers;
}
return null;
}
function path_to_tags(string $path): string
{
$matches = [];
$tags = [];
if (preg_match("/\d+ - (.+)\.([a-zA-Z0-9]+)/", basename($path), $matches)) {
$tags = explode(" ", $matches[1]);
}
$path = dirname($path);
$path = str_replace(";", ":", $path);
$path = str_replace("__", " ", $path);
$category = "";
foreach (explode("/", $path) as $dir) {
$category_to_inherit = "";
foreach (explode(" ", $dir) as $tag) {
$tag = trim($tag);
if ($tag=="") {
continue;
}
if (substr_compare($tag, ":", -1) === 0) {
// This indicates a tag that ends in a colon,
// which is for inheriting to tags on the subfolder
$category_to_inherit = $tag;
} else {
if ($category!=""&&strpos($tag, ":") === false) {
// This indicates that category inheritance is active,
// and we've encountered a tag that does not specify a category.
// So we attach the inherited category to the tag.
$tag = $category.$tag;
}
$tags[] = $tag;
}
}
// Category inheritance only works on the immediate subfolder,
// so we hold a category until the next iteration, and then set
// it back to an empty string after that iteration
$category = $category_to_inherit;
}
return implode(" ", $tags);
}
function join_url(string $base, string ...$paths)
{
$output = $base;
foreach ($paths as $path) {
$output = rtrim($output, "/");
$path = ltrim($path, "/");
$output .= "/".$path;
}
return $output;
}
function get_dir_contents(string $dir): array
{
assert(!empty($dir));
if (!is_dir($dir)) {
return [];
}
return array_diff(
scandir(
$dir
),
['..', '.']
);
}
/**
* Returns amount of files & total size of dir.
*/
function scan_dir(string $path): array
{
$bytestotal = 0;
$nbfiles = 0;
$ite = new RecursiveDirectoryIterator(
$path,
FilesystemIterator::KEY_AS_PATHNAME |
FilesystemIterator::CURRENT_AS_FILEINFO |
FilesystemIterator::SKIP_DOTS
);
foreach (new RecursiveIteratorIterator($ite) as $filename => $cur) {
try {
$filesize = $cur->getSize();
$bytestotal += $filesize;
$nbfiles++;
} catch (RuntimeException $e) {
// This usually just means that the file got eaten by the import
continue;
}
}
$size_mb = $bytestotal / 1048576; // to mb
$size_mb = number_format($size_mb, 2, '.', '');
return ['path' => $path, 'total_files' => $nbfiles, 'total_mb' => $size_mb];
}
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\
* Debugging functions *
\* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
// SHIT by default this returns the time as a string. And it's not even a
// string representation of a number, it's two numbers separated by a space.
// What the fuck were the PHP developers smoking.
$_shm_load_start = microtime(true);
/**
* Collects some debug information (execution time, memory usage, queries, etc)
* and formats it to stick in the footer of the page.
*/
function get_debug_info(): string
{
global $cache, $config, $_shm_event_count, $database, $_shm_load_start;
$i_mem = sprintf("%5.2f", ((memory_get_peak_usage(true)+512)/1024)/1024);
if ($config->get_string("commit_hash", "unknown") == "unknown") {
$commit = "";
} else {
$commit = " (".$config->get_string("commit_hash").")";
}
$time = sprintf("%.2f", microtime(true) - $_shm_load_start);
$dbtime = sprintf("%.2f", $database->dbtime);
$i_files = count(get_included_files());
$hits = $cache->get_hits();
$miss = $cache->get_misses();
$debug = "<br>Took $time seconds (db:$dbtime) and {$i_mem}MB of RAM";
$debug .= "; Used $i_files files and {$database->query_count} queries";
$debug .= "; Sent $_shm_event_count events";
$debug .= "; $hits cache hits and $miss misses";
$debug .= "; Shimmie version ". VERSION . $commit;
return $debug;
}
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\
* Request initialisation stuff *
\* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/** @privatesection
* @noinspection PhpIncludeInspection
*/
function require_all(array $files): void
{
foreach ($files as $filename) {
require_once $filename;
}
}
function _load_core_files()
{
require_all(array_merge(
zglob("core/*.php"),
zglob("core/imageboard/*.php"),
zglob("ext/*/info.php")
));
}
function _load_theme_files()
{
require_all(_get_themelet_files(get_theme()));
}
function _sanitise_environment(): void
{
global $tracer_enabled;
$min_php = "7.3";
if (version_compare(phpversion(), $min_php, ">=") === false) {
print "
Shimmie does not support versions of PHP lower than $min_php
(PHP reports that it is version ".phpversion().").
If your web host is running an older version, they are dangerously out of
date and you should plan on moving elsewhere.
";
exit;
}
if (file_exists("images") && !file_exists("data/images")) {
die("As of Shimmie 2.7 images and thumbs should be moved to data/images and data/thumbs");
}
if (TIMEZONE) {
date_default_timezone_set(TIMEZONE);
}
# ini_set('zend.assertions', '1'); // generate assertions
ini_set('assert.exception', '1'); // throw exceptions when failed
if (DEBUG) {
error_reporting(E_ALL);
}
// The trace system has a certain amount of memory consumption every time it is used,
// so to prevent running out of memory during complex operations code that uses it should
// check if tracer output is enabled before making use of it.
$tracer_enabled = constant('TRACE_FILE')!==null;
ob_start();
if (PHP_SAPI === 'cli' || PHP_SAPI == 'phpdbg') {
if (isset($_SERVER['REMOTE_ADDR'])) {
die("CLI with remote addr? Confused, not taking the risk.");
}
$_SERVER['REMOTE_ADDR'] = "0.0.0.0";
$_SERVER['HTTP_HOST'] = "<cli command>";
}
}
function _get_themelet_files(string $_theme): array
{
$base_themelets = [];
$base_themelets[] = 'themes/'.$_theme.'/page.class.php';
$base_themelets[] = 'themes/'.$_theme.'/themelet.class.php';
$ext_themelets = zglob("ext/{".Extension::get_enabled_extensions_as_string()."}/theme.php");
$custom_themelets = zglob('themes/'.$_theme.'/{'.Extension::get_enabled_extensions_as_string().'}.theme.php');
return array_merge($base_themelets, $ext_themelets, $custom_themelets);
}
/**
* Used to display fatal errors to the web user.
* @noinspection PhpPossiblePolymorphicInvocationInspection
*/
function _fatal_error(Exception $e): void
{
$version = VERSION;
$message = $e->getMessage();
$phpver = phpversion();
$query = is_subclass_of($e, "SCoreException") ? $e->query : null;
//$hash = exec("git rev-parse HEAD");
//$h_hash = $hash ? "<p><b>Hash:</b> $hash" : "";
//'.$h_hash.'
if (PHP_SAPI === 'cli' || PHP_SAPI == 'phpdbg') {
print("Trace: ");
$t = array_reverse($e->getTrace());
foreach ($t as $n => $f) {
$c = $f['class'] ?? '';
$t = $f['type'] ?? '';
$a = implode(", ", array_map("stringer", $f['args']));
print("$n: {$f['file']}({$f['line']}): {$c}{$t}{$f['function']}({$a})\n");
}
print("Message: $message\n");
if ($query) {
print("Query: {$query}\n");
}
print("Version: $version (on $phpver)\n");
} else {
$q = $query ? "" : "<p><b>Query:</b> " . html_escape($query);
header("HTTP/1.0 500 Internal Error");
echo '
<!doctype html>
<html lang="en">
<head>
<title>Internal error - SCore-'.$version.'</title>
</head>
<body>
<h1>Internal Error</h1>
<p><b>Message:</b> '.html_escape($message).'
'.$q.'
<p><b>Version:</b> '.$version.' (on '.$phpver.')
</body>
</html>
';
}
}
function _get_user(): User
{
global $config, $page;
$my_user = null;
if ($page->get_cookie("user") && $page->get_cookie("session")) {
$tmp_user = User::by_session($page->get_cookie("user"), $page->get_cookie("session"));
if (!is_null($tmp_user)) {
$my_user = $tmp_user;
}
}
if (is_null($my_user)) {
$my_user = User::by_id($config->get_int("anon_id", 0));
}
assert(!is_null($my_user));
return $my_user;
}
function _get_query(): string
{
return (@$_POST["q"]?:@$_GET["q"])?:"/";
}
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\
* HTML Generation *
\* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/**
* Give a HTML string which shows an IP (if the user is allowed to see IPs),
* and a link to ban that IP (if the user is allowed to ban IPs)
*
* FIXME: also check that IP ban ext is installed
*/
function show_ip(string $ip, string $ban_reason): string
{
global $user;
$u_reason = url_escape($ban_reason);
$u_end = url_escape("+1 week");
$ban = $user->can(Permissions::BAN_IP) ? ", <a href='".make_link("ip_ban/list", "c_ip=$ip&c_reason=$u_reason&c_expires=$u_end#create")."'>Ban</a>" : "";
$ip = $user->can(Permissions::VIEW_IP) ? $ip.$ban : "";
return $ip;
}
/**
* Make a form tag with relevant auth token and stuff
*/
function make_form(string $target, string $method="POST", bool $multipart=false, string $form_id="", string $onsubmit=""): string
{
global $user;
if ($method == "GET") {
$link = html_escape($target);
$target = make_link($target);
$extra_inputs = "<input type='hidden' name='q' value='$link'>";
} else {
$extra_inputs = $user->get_auth_html();
}
$extra = empty($form_id) ? '' : 'id="'. $form_id .'"';
if ($multipart) {
$extra .= " enctype='multipart/form-data'";
}
if ($onsubmit) {
$extra .= ' onsubmit="'.$onsubmit.'"';
}
return '<form action="'.$target.'" method="'.$method.'" '.$extra.'>'.$extra_inputs;
}
function SHM_FORM(string $target, string $method="POST", bool $multipart=false, string $form_id="", string $onsubmit="")
{
global $user;
$attrs = [
"action"=>$target,
"method"=>$method
];
if ($form_id) {
$attrs["id"] = $form_id;
}
if ($multipart) {
$attrs["enctype"] = 'multipart/form-data';
}
if ($onsubmit) {
$attrs["onsubmit"] = $onsubmit;
}
return FORM(
$attrs,
INPUT(["type"=>"hidden", "name"=>"q", "value"=>$target]),
$method == "GET" ? "" : rawHTML($user->get_auth_html())
);
}
function SHM_SIMPLE_FORM($target, ...$children)
{
$form = SHM_FORM($target);
$form->appendChild(emptyHTML(...$children));
return $form;
}
function SHM_SUBMIT(string $text)
{
return INPUT(["type"=>"submit", "value"=>$text]);
}
function SHM_COMMAND_EXAMPLE(string $ex, string $desc)
{
return DIV(
["class"=>"command_example"],
PRE($ex),
P($desc)
);
}
function SHM_USER_FORM(User $duser, string $target, string $title, $body, $foot)
{
if (is_string($foot)) {
$foot = TFOOT(TR(TD(["colspan"=>"2"], INPUT(["type"=>"submit", "value"=>$foot]))));
}
return SHM_SIMPLE_FORM(
$target,
P(
INPUT(["type"=>'hidden', "name"=>'id', "value"=>$duser->id]),
TABLE(
["class"=>"form"],
THEAD(TR(TH(["colspan"=>"2"], $title))),
$body,
$foot
)
)
);
}
const BYTE_DENOMINATIONS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
function human_filesize(int $bytes, $decimals = 2)
{
$factor = floor((strlen(strval($bytes)) - 1) / 3);
return sprintf("%.{$decimals}f", $bytes / pow(1024, $factor)) . @BYTE_DENOMINATIONS[$factor];
}

23
ext/admin/info.php Normal file
View file

@ -0,0 +1,23 @@
<?php declare(strict_types=1);
class AdminPageInfo extends ExtensionInfo
{
public const KEY = "admin";
public $key = self::KEY;
public $name = "Admin Controls";
public $url = self::SHIMMIE_URL;
public $authors = self::SHISH_AUTHOR;
public $license = self::LICENSE_GPLV2;
public $description = "Various things to make admins' lives easier";
public $documentation =
"Various moderate-level tools for admins; for advanced, obscure, and possibly dangerous tools see the shimmie2-utils script set
<p>Lowercase all tags:
<br>Set all tags to lowercase for consistency
<p>Recount tag use:
<br>If the counts of images per tag get messed up somehow, this will reset them, and remove any unused tags
<p>Database dump:
<br>Download the contents of the database in plain text format, useful for backups.
<p>Image dump:
<br>Download all the images as a .zip file (Requires ZipArchive)";
}

View file

@ -1,271 +1,194 @@
<?php <?php /** @noinspection PhpUnusedPrivateMethodInspection */
/** declare(strict_types=1);
* Name: Admin Controls
* Author: Shish <webmaster@shishnet.org>
* Link: http://code.shishnet.org/shimmie2/
* License: GPLv2
* Description: Various things to make admins' lives easier
* Documentation:
* Various moderate-level tools for admins; for advanced, obscure, and
* possibly dangerous tools see the shimmie2-utils script set
* <p>Lowercase all tags:
* <br>Set all tags to lowercase for consistency
* <p>Recount tag use:
* <br>If the counts of images per tag get messed up somehow, this will
* reset them, and remove any unused tags
* <p>Database dump:
* <br>Download the contents of the database in plain text format, useful
* for backups.
* <p>Image dump:
* <br>Download all the images as a .zip file (Requires ZipArchive)
*/
/** /**
* Sent when the admin page is ready to be added to * Sent when the admin page is ready to be added to
*/ */
class AdminBuildingEvent extends Event { class AdminBuildingEvent extends Event
/** @var \Page */ {
public $page; /** @var Page */
public $page;
/** public function __construct(Page $page)
* @param Page $page {
*/ parent::__construct();
public function __construct(Page $page) { $this->page = $page;
$this->page = $page; }
}
} }
class AdminActionEvent extends Event { class AdminActionEvent extends Event
/** @var string */ {
public $action; /** @var string */
/** @var bool */ public $action;
public $redirect = true; /** @var bool */
public $redirect = true;
/** public function __construct(string $action)
* @param string $action {
*/ parent::__construct();
public function __construct(/*string*/ $action) { $this->action = $action;
$this->action = $action; }
}
} }
class AdminPage extends Extension { class AdminPage extends Extension
public function onPageRequest(PageRequestEvent $event) { {
global $page, $user; /** @var AdminPageTheme */
protected $theme;
if($event->page_matches("admin")) { public function onPageRequest(PageRequestEvent $event)
if(!$user->can("manage_admintools")) { {
$this->theme->display_permission_denied(); global $page, $user;
}
else {
if($event->count_args() == 0) {
send_event(new AdminBuildingEvent($page));
}
else {
$action = $event->get_arg(0);
$aae = new AdminActionEvent($action);
if($user->check_auth_token()) { if ($event->page_matches("admin")) {
log_info("admin", "Util: $action"); if (!$user->can(Permissions::MANAGE_ADMINTOOLS)) {
set_time_limit(0); $this->theme->display_permission_denied();
send_event($aae); } else {
} if ($event->count_args() == 0) {
send_event(new AdminBuildingEvent($page));
} else {
$action = $event->get_arg(0);
$aae = new AdminActionEvent($action);
if($aae->redirect) { if ($user->check_auth_token()) {
$page->set_mode("redirect"); log_info("admin", "Util: $action");
$page->set_redirect(make_link("admin")); set_time_limit(0);
} send_event($aae);
} }
}
}
}
public function onCommand(CommandEvent $event) { if ($aae->redirect) {
if($event->cmd == "help") { $page->set_mode(PageMode::REDIRECT);
print " get-page [query string]\n"; $page->set_redirect(make_link("admin"));
print " eg 'get-page post/list'\n\n"; }
} }
if($event->cmd == "get-page") { }
global $page; }
send_event(new PageRequestEvent($event->args[0])); }
$page->display();
}
}
public function onAdminBuilding(AdminBuildingEvent $event) { public function onCommand(CommandEvent $event)
$this->theme->display_page(); {
$this->theme->display_form(); if ($event->cmd == "help") {
} print "\tget-page <query string>\n";
print "\t\teg 'get-page post/list'\n\n";
print "\tpost-page <query string> <urlencoded params>\n";
print "\t\teg 'post-page ip_ban/delete id=1'\n\n";
print "\tget-token\n";
print "\t\tget a CSRF auth token\n\n";
print "\tregen-thumb <id / hash>\n";
print "\t\tregenerate a thumbnail\n\n";
print "\tcache [get|set|del] [key] <value>\n";
print "\t\teg 'cache get config'\n\n";
}
if ($event->cmd == "get-page") {
global $page;
if (isset($event->args[1])) {
parse_str($event->args[1], $_GET);
}
send_event(new PageRequestEvent($event->args[0]));
$page->display();
}
if ($event->cmd == "post-page") {
global $page;
$_SERVER['REQUEST_METHOD'] = "POST";
if (isset($event->args[1])) {
parse_str($event->args[1], $_POST);
}
send_event(new PageRequestEvent($event->args[0]));
$page->display();
}
if ($event->cmd == "get-token") {
global $user;
print($user->get_auth_token());
}
if ($event->cmd == "regen-thumb") {
$uid = $event->args[0];
$image = Image::by_id_or_hash($uid);
if ($image) {
send_event(new ThumbnailGenerationEvent($image->hash, $image->ext, true));
} else {
print("No post with ID '$uid'\n");
}
}
if ($event->cmd == "cache") {
global $cache;
$cmd = $event->args[0];
$key = $event->args[1];
switch ($cmd) {
case "get":
var_dump($cache->get($key));
break;
case "set":
$cache->set($key, $event->args[2], 60);
break;
case "del":
$cache->delete($key);
break;
}
}
}
public function onUserBlockBuilding(UserBlockBuildingEvent $event) { public function onAdminBuilding(AdminBuildingEvent $event)
global $user; {
if($user->can("manage_admintools")) { $this->theme->display_page();
$event->add_link("Board Admin", make_link("admin")); $this->theme->display_form();
} }
}
public function onAdminAction(AdminActionEvent $event) { public function onPageSubNavBuilding(PageSubNavBuildingEvent $event)
$action = $event->action; {
if(method_exists($this, $action)) { global $user;
$event->redirect = $this->$action(); if ($event->parent==="system") {
} if ($user->can(Permissions::MANAGE_ADMINTOOLS)) {
} $event->add_nav_link("admin", new Link('admin'), "Board Admin");
}
}
}
public function onPostListBuilding(PostListBuildingEvent $event) { public function onUserBlockBuilding(UserBlockBuildingEvent $event)
global $user; {
if($user->can("manage_admintools") && !empty($event->search_terms)) { global $user;
$event->add_control($this->theme->dbq_html(implode(" ", $event->search_terms))); if ($user->can(Permissions::MANAGE_ADMINTOOLS)) {
} $event->add_link("Board Admin", make_link("admin"));
} }
}
private function delete_by_query() { public function onAdminAction(AdminActionEvent $event)
global $page; {
$query = $_POST['query']; $action = $event->action;
$reason = @$_POST['reason']; if (method_exists($this, $action)) {
assert(strlen($query) > 1); $event->redirect = $this->$action();
}
}
log_warning("admin", "Mass deleting: $query"); private function set_tag_case()
$count = 0; {
foreach(Image::find_images(0, 1000000, Tag::explode($query)) as $image) { global $database;
if($reason && class_exists("ImageBan")) { $database->execute(
send_event(new AddImageHashBanEvent($image->hash, $reason)); "UPDATE tags SET tag=:tag1 WHERE LOWER(tag) = LOWER(:tag2)",
} ["tag1" => $_POST['tag'], "tag2" => $_POST['tag']]
send_event(new ImageDeletionEvent($image)); );
$count++; log_info("admin", "Fixed the case of {$_POST['tag']}", "Fixed case");
} return true;
log_debug("admin", "Deleted $count images", true); }
$page->set_mode("redirect"); private function lowercase_all_tags()
$page->set_redirect(make_link("post/list")); {
return false; global $database;
} $database->execute("UPDATE tags SET tag=lower(tag)");
log_warning("admin", "Set all tags to lowercase", "Set all tags to lowercase");
return true;
}
private function set_tag_case() { private function recount_tag_use()
global $database; {
$database->execute($database->scoreql_to_sql( global $database;
"UPDATE tags SET tag=:tag1 WHERE SCORE_STRNORM(tag) = SCORE_STRNORM(:tag2)" $database->Execute("
), array("tag1" => $_POST['tag'], "tag2" => $_POST['tag']));
log_info("admin", "Fixed the case of ".html_escape($_POST['tag']), true);
return true;
}
private function lowercase_all_tags() {
global $database;
$database->execute("UPDATE tags SET tag=lower(tag)");
log_warning("admin", "Set all tags to lowercase", true);
return true;
}
private function recount_tag_use() {
global $database;
$database->Execute("
UPDATE tags UPDATE tags
SET count = COALESCE( SET count = COALESCE(
(SELECT COUNT(image_id) FROM image_tags WHERE tag_id=tags.id GROUP BY tag_id), (SELECT COUNT(image_id) FROM image_tags WHERE tag_id=tags.id GROUP BY tag_id),
0 0
) )
"); ");
$database->Execute("DELETE FROM tags WHERE count=0"); $database->Execute("DELETE FROM tags WHERE count=0");
log_warning("admin", "Re-counted tags", true); log_warning("admin", "Re-counted tags", "Re-counted tags");
return true;
}
private function database_dump() {
global $page;
$matches = array();
preg_match("#^(?P<proto>\w+)\:(?:user=(?P<user>\w+)(?:;|$)|password=(?P<password>\w*)(?:;|$)|host=(?P<host>[\w\.\-]+)(?:;|$)|dbname=(?P<dbname>[\w_]+)(?:;|$))+#", DATABASE_DSN, $matches);
$software = $matches['proto'];
$username = $matches['user'];
$password = $matches['password'];
$hostname = $matches['host'];
$database = $matches['dbname'];
switch($software) {
case 'mysql':
$cmd = "mysqldump -h$hostname -u$username -p$password $database";
break;
case 'pgsql':
putenv("PGPASSWORD=$password");
$cmd = "pg_dump -h $hostname -U $username $database";
break;
case 'sqlite':
$cmd = "sqlite3 $database .dump";
break;
default:
$cmd = false;
}
//FIXME: .SQL dump is empty if cmd doesn't exist
if($cmd) {
$page->set_mode("data");
$page->set_type("application/x-unknown");
$page->set_filename('shimmie-'.date('Ymd').'.sql');
$page->set_data(shell_exec($cmd));
}
return false;
}
private function download_all_images() {
global $database, $page;
$images = $database->get_all("SELECT hash, ext FROM images");
$filename = data_path('imgdump-'.date('Ymd').'.zip');
$zip = new ZipArchive;
if($zip->open($filename, ZIPARCHIVE::CREATE | ZIPARCHIVE::OVERWRITE) === TRUE){
foreach($images as $img){
$img_loc = warehouse_path("images", $img["hash"], FALSE);
$zip->addFile($img_loc, $img["hash"].".".$img["ext"]);
}
$zip->close();
}
$page->set_mode("redirect");
$page->set_redirect(make_link($filename)); //TODO: Delete file after downloaded?
return false; // we do want a redirect, but a manual one
}
private function reset_image_ids() {
global $database;
//TODO: Make work with PostgreSQL + SQLite
//TODO: Update score_log (Having an optional ID column for score_log would be nice..)
preg_match("#^(?P<proto>\w+)\:(?:user=(?P<user>\w+)(?:;|$)|password=(?P<password>\w*)(?:;|$)|host=(?P<host>[\w\.\-]+)(?:;|$)|dbname=(?P<dbname>[\w_]+)(?:;|$))+#", DATABASE_DSN, $matches);
if($matches['proto'] == "mysql"){
$tables = $database->get_col("SELECT TABLE_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = :db
AND REFERENCED_COLUMN_NAME = 'id'
AND REFERENCED_TABLE_NAME = 'images'", array("db" => $matches['dbname']));
$i = 1;
$ids = $database->get_col("SELECT id FROM images ORDER BY images.id ASC");
foreach($ids as $id){
$sql = "SET FOREIGN_KEY_CHECKS=0;
UPDATE images SET id={$i} WHERE image_id={$id};";
foreach($tables as $table){
$sql .= "UPDATE {$table} SET image_id={$i} WHERE image_id={$id};";
}
$sql .= " SET FOREIGN_KEY_CHECKS=1;";
$database->execute($sql);
$i++;
}
$database->execute("ALTER TABLE images AUTO_INCREMENT=".(count($ids) + 1));
}elseif($matches['proto'] == "pgsql"){
//TODO: Make this work with PostgreSQL
}elseif($matches['proto'] == "sqlite"){
//TODO: Make this work with SQLite
}
return true; return true;
} }
} }

View file

@ -1,84 +1,89 @@
<?php <?php declare(strict_types=1);
class AdminPageTest extends ShimmiePHPUnitTestCase { class AdminPageTest extends ShimmiePHPUnitTestCase
public function testAuth() { {
$this->get_page('admin'); public function testAuth()
$this->assert_response(403); {
$this->assert_title("Permission Denied"); send_event(new UserLoginEvent(User::by_name(self::$anon_name)));
$page = $this->get_page('admin');
$this->assertEquals(403, $page->code);
$this->assertEquals("Permission Denied", $page->title);
$this->log_in_as_user(); send_event(new UserLoginEvent(User::by_name(self::$user_name)));
$this->get_page('admin'); $page = $this->get_page('admin');
$this->assert_response(403); $this->assertEquals(403, $page->code);
$this->assert_title("Permission Denied"); $this->assertEquals("Permission Denied", $page->title);
$this->log_in_as_admin(); send_event(new UserLoginEvent(User::by_name(self::$admin_name)));
$this->get_page('admin'); $page = $this->get_page('admin');
$this->assert_response(200); $this->assertEquals(200, $page->code);
$this->assert_title("Admin Tools"); $this->assertEquals("Admin Tools", $page->title);
} }
public function testLowercase() { public function testLowercaseAndSetCase()
$ts = time(); // we need a tag that hasn't been used before {
// Create a problem
$ts = time(); // we need a tag that hasn't been used before
send_event(new UserLoginEvent(User::by_name(self::$admin_name)));
$image_id_1 = $this->post_image("tests/pbx_screenshot.jpg", "TeStCase$ts");
$this->log_in_as_admin(); // Validate problem
$image_id_1 = $this->post_image("tests/pbx_screenshot.jpg", "TeStCase$ts"); $page = $this->get_page("post/view/$image_id_1");
$this->assertEquals("Image $image_id_1: TeStCase$ts", $page->title);
$this->get_page("post/view/$image_id_1"); // Fix
$this->assert_title("Image $image_id_1: TeStCase$ts"); send_event(new AdminActionEvent('lowercase_all_tags'));
$this->get_page('admin'); // Validate fix
$this->assert_title("Admin Tools"); $this->get_page("post/view/$image_id_1");
//$this->click("All tags to lowercase"); $this->assert_title("Image $image_id_1: testcase$ts");
send_event(new AdminActionEvent('lowercase_all_tags'));
$this->get_page("post/view/$image_id_1"); // Change
$this->assert_title("Image $image_id_1: testcase$ts"); $_POST["tag"] = "TestCase$ts";
send_event(new AdminActionEvent('set_tag_case'));
$this->delete_image($image_id_1); // Validate change
} $this->get_page("post/view/$image_id_1");
$this->assert_title("Image $image_id_1: TestCase$ts");
}
# FIXME: make sure the admin tools actually work # FIXME: make sure the admin tools actually work
public function testRecount() { public function testRecount()
$this->log_in_as_admin(); {
$this->get_page('admin'); global $database;
$this->assert_title("Admin Tools");
//$this->click("Recount tag use"); // Create a problem
send_event(new AdminActionEvent('recount_tag_use')); $ts = time(); // we need a tag that hasn't been used before
} send_event(new UserLoginEvent(User::by_name(self::$admin_name)));
$database->execute(
"INSERT INTO tags(tag, count) VALUES(:tag, :count)",
["tag"=>"tes$ts", "count"=>42]
);
public function testDump() { // Fix
$this->log_in_as_admin(); send_event(new AdminActionEvent('recount_tag_use'));
$this->get_page('admin');
$this->assert_title("Admin Tools");
// this calls mysqldump which jams up travis prompting for a password // Validate fix
//$this->click("Download database contents"); $this->assertEquals(
//send_event(new AdminActionEvent('database_dump')); 0,
//$this->assert_response(200); $database->get_one(
} "SELECT count FROM tags WHERE tag = :tag",
["tag"=>"tes$ts"]
)
);
}
public function testDBQ() { public function testCommands()
$this->log_in_as_user(); {
$image_id_1 = $this->post_image("tests/pbx_screenshot.jpg", "test"); send_event(new UserLoginEvent(User::by_name(self::$admin_name)));
$image_id_2 = $this->post_image("tests/bedroom_workshop.jpg", "test2"); ob_start();
$image_id_3 = $this->post_image("tests/favicon.png", "test"); send_event(new CommandEvent(["index.php", "help"]));
send_event(new CommandEvent(["index.php", "get-page", "post/list"]));
send_event(new CommandEvent(["index.php", "post-page", "post/list", "foo=bar"]));
send_event(new CommandEvent(["index.php", "get-token"]));
send_event(new CommandEvent(["index.php", "regen-thumb", "42"]));
ob_end_clean();
$this->get_page("post/list/test/1"); // don't crash
//$this->click("Delete All These Images"); $this->assertTrue(true);
$_POST['query'] = 'test'; }
//$_POST['reason'] = 'reason'; // non-null-reason = add a hash ban
send_event(new AdminActionEvent('delete_by_query'));
$this->get_page("post/view/$image_id_1");
$this->assert_response(404);
$this->get_page("post/view/$image_id_2");
$this->assert_response(200);
$this->get_page("post/view/$image_id_3");
$this->assert_response(404);
$this->delete_image($image_id_1);
$this->delete_image($image_id_2);
$this->delete_image($image_id_3);
}
} }

View file

@ -1,77 +1,54 @@
<?php <?php declare(strict_types=1);
use function MicroHTML\INPUT;
class AdminPageTheme extends Themelet { class AdminPageTheme extends Themelet
/* {
* Show the basics of a page, for other extensions to add to /*
*/ * Show the basics of a page, for other extensions to add to
public function display_page() { */
global $page; public function display_page()
{
global $page;
$page->set_title("Admin Tools"); $page->set_title("Admin Tools");
$page->set_heading("Admin Tools"); $page->set_heading("Admin Tools");
$page->add_block(new NavBlock()); $page->add_block(new NavBlock());
} }
/** protected function button(string $name, string $action, bool $protected=false): string
* @param string $name {
* @param string $action $c_protected = $protected ? " protected" : "";
* @param bool $protected $html = make_form(make_link("admin/$action"), "POST", false, "admin$c_protected");
* @return string if ($protected) {
*/ $html .= "<input type='submit' id='$action' value='$name' disabled='disabled'>";
protected function button(/*string*/ $name, /*string*/ $action, /*boolean*/ $protected=false) { $html .= "<input type='checkbox' onclick='$(\"#$action\").attr(\"disabled\", !$(this).is(\":checked\"))'>";
$c_protected = $protected ? " protected" : ""; } else {
$html = make_form(make_link("admin/$action"), "POST", false, "admin$c_protected"); $html .= "<input type='submit' id='$action' value='$name'>";
if($protected) { }
$html .= "<input type='submit' id='$action' value='$name' disabled='disabled'>"; $html .= "</form>\n";
$html .= "<input type='checkbox' onclick='$(\"#$action\").attr(\"disabled\", !$(this).is(\":checked\"))'>"; return $html;
} }
else {
$html .= "<input type='submit' id='$action' value='$name'>";
}
$html .= "</form>\n";
return $html;
}
/* /*
* Show a form which links to admin_utils with POST[action] set to one of: * Show a form which links to admin_utils with POST[action] set to one of:
* 'lowercase all tags' * 'lowercase all tags'
* 'recount tag use' * 'recount tag use'
* etc * etc
*/ */
public function display_form() { public function display_form()
global $page, $database; {
global $page;
$html = ""; $html = "";
$html .= $this->button("All tags to lowercase", "lowercase_all_tags", true); $html .= $this->button("All tags to lowercase", "lowercase_all_tags", true);
$html .= $this->button("Recount tag use", "recount_tag_use", false); $html .= $this->button("Recount tag use", "recount_tag_use", false);
if(class_exists('ZipArchive')) $page->add_block(new Block("Misc Admin Tools", $html));
$html .= $this->button("Download all images", "download_all_images", false);
$html .= $this->button("Download database contents", "database_dump", false);
if($database->get_driver_name() == "mysql")
$html .= $this->button("Reset image IDs", "reset_image_ids", true);
$page->add_block(new Block("Misc Admin Tools", $html));
$html = make_form(make_link("admin/set_tag_case"), "POST"); $html = (string)SHM_SIMPLE_FORM(
$html .= "<input type='text' name='tag' placeholder='Enter tag with correct case' class='autocomplete_tags' autocomplete='off'>"; "admin/set_tag_case",
$html .= "<input type='submit' value='Set Tag Case'>"; INPUT(["type"=>'text', "name"=>'tag', "placeholder"=>'Enter tag with correct case', "class"=>'autocomplete_tags', "autocomplete"=>'off']),
$html .= "</form>\n"; SHM_SUBMIT('Set Tag Case'),
$page->add_block(new Block("Set Tag Case", $html)); );
} $page->add_block(new Block("Set Tag Case", $html));
}
public function dbq_html($terms) {
$h_terms = html_escape($terms);
$h_reason = "";
if(class_exists("ImageBan")) {
$h_reason = "<input type='text' name='reason' placeholder='Ban reason (leave blank to not ban)'>";
}
$html = make_form(make_link("admin/delete_by_query"), "POST") . "
<input type='button' class='shm-unlocker' data-unlock-sel='#dbqsubmit' value='Unlock'>
<input type='hidden' name='query' value='$h_terms'>
$h_reason
<input type='submit' id='dbqsubmit' disabled='true' value='Delete All These Images'>
</form>
";
return $html;
}
} }

15
ext/alias_editor/info.php Normal file
View file

@ -0,0 +1,15 @@
<?php declare(strict_types=1);
class AliasEditorInfo extends ExtensionInfo
{
public const KEY = "alias_editor";
public $key = self::KEY;
public $name = "Alias Editor";
public $url = self::SHIMMIE_URL;
public $authors = self::SHISH_AUTHOR;
public $license = self::LICENSE_GPLV2;
public $description = "Edit the alias list";
public $documentation = 'The list is visible at <a href="$site/alias/list">/alias/list</a>; only site admins can edit it, other people can view and download it';
public $core = true;
}

View file

@ -1,178 +1,209 @@
<?php <?php declare(strict_types=1);
/**
* Name: Alias Editor
* Author: Shish <webmaster@shishnet.org>
* Link: http://code.shishnet.org/shimmie2/
* License: GPLv2
* Description: Edit the alias list
* Documentation:
* The list is visible at <a href="$site/alias/list">/alias/list</a>; only
* site admins can edit it, other people can view and download it
*/
class AddAliasEvent extends Event { use MicroCRUD\ActionColumn;
/** @var string */ use MicroCRUD\TextColumn;
public $oldtag; use MicroCRUD\Table;
/** @var string */
public $newtag;
/** class AliasTable extends Table
* @param string $oldtag {
* @param string $newtag public function __construct(\FFSPHP\PDO $db)
*/ {
public function __construct($oldtag, $newtag) { parent::__construct($db);
$this->oldtag = trim($oldtag); $this->table = "aliases";
$this->newtag = trim($newtag); $this->base_query = "SELECT * FROM aliases";
} $this->primary_key = "oldtag";
$this->size = 100;
$this->limit = 1000000;
$this->set_columns([
new TextColumn("oldtag", "Old Tag"),
new TextColumn("newtag", "New Tag"),
new ActionColumn("oldtag"),
]);
$this->order_by = ["oldtag"];
$this->table_attrs = ["class" => "zebra"];
}
} }
class AddAliasException extends SCoreException {} class AddAliasEvent extends Event
{
/** @var string */
public $oldtag;
/** @var string */
public $newtag;
class AliasEditor extends Extension { public function __construct(string $oldtag, string $newtag)
public function onPageRequest(PageRequestEvent $event) { {
global $config, $database, $page, $user; parent::__construct();
$this->oldtag = trim($oldtag);
if($event->page_matches("alias")) { $this->newtag = trim($newtag);
if($event->get_arg(0) == "add") { }
if($user->can("manage_alias_list")) {
if(isset($_POST['oldtag']) && isset($_POST['newtag'])) {
try {
$aae = new AddAliasEvent($_POST['oldtag'], $_POST['newtag']);
send_event($aae);
$page->set_mode("redirect");
$page->set_redirect(make_link("alias/list"));
}
catch(AddAliasException $ex) {
$this->theme->display_error(500, "Error adding alias", $ex->getMessage());
}
}
}
}
else if($event->get_arg(0) == "remove") {
if($user->can("manage_alias_list")) {
if(isset($_POST['oldtag'])) {
$database->execute("DELETE FROM aliases WHERE oldtag=:oldtag", array("oldtag" => $_POST['oldtag']));
log_info("alias_editor", "Deleted alias for ".$_POST['oldtag'], true);
$page->set_mode("redirect");
$page->set_redirect(make_link("alias/list"));
}
}
}
else if($event->get_arg(0) == "list") {
$page_number = $event->get_arg(1);
if(is_null($page_number) || !is_numeric($page_number)) {
$page_number = 0;
}
else if ($page_number <= 0) {
$page_number = 0;
}
else {
$page_number--;
}
$alias_per_page = $config->get_int('alias_items_per_page', 30);
$query = "SELECT oldtag, newtag FROM aliases ORDER BY newtag ASC LIMIT :limit OFFSET :offset";
$alias = $database->get_pairs($query,
array("limit"=>$alias_per_page, "offset"=>$page_number * $alias_per_page)
);
$total_pages = ceil($database->get_one("SELECT COUNT(*) FROM aliases") / $alias_per_page);
$this->theme->display_aliases($alias, $page_number + 1, $total_pages);
}
else if($event->get_arg(0) == "export") {
$page->set_mode("data");
$page->set_type("text/csv");
$page->set_filename("aliases.csv");
$page->set_data($this->get_alias_csv($database));
}
else if($event->get_arg(0) == "import") {
if($user->can("manage_alias_list")) {
if(count($_FILES) > 0) {
$tmp = $_FILES['alias_file']['tmp_name'];
$contents = file_get_contents($tmp);
$this->add_alias_csv($database, $contents);
log_info("alias_editor", "Imported aliases from file", true); # FIXME: how many?
$page->set_mode("redirect");
$page->set_redirect(make_link("alias/list"));
}
else {
$this->theme->display_error(400, "No File Specified", "You have to upload a file");
}
}
else {
$this->theme->display_error(401, "Admins Only", "Only admins can edit the alias list");
}
}
}
}
public function onAddAlias(AddAliasEvent $event) {
global $database;
$pair = array("oldtag" => $event->oldtag, "newtag" => $event->newtag);
if($database->get_row("SELECT * FROM aliases WHERE oldtag=:oldtag AND lower(newtag)=lower(:newtag)", $pair)) {
throw new AddAliasException("That alias already exists");
}
else if($database->get_row("SELECT * FROM aliases WHERE oldtag=:newtag", array("newtag" => $event->newtag))) {
throw new AddAliasException("{$event->newtag} is itself an alias");
}
else {
$database->execute("INSERT INTO aliases(oldtag, newtag) VALUES(:oldtag, :newtag)", $pair);
log_info("alias_editor", "Added alias for {$event->oldtag} -> {$event->newtag}", true);
}
}
public function onUserBlockBuilding(UserBlockBuildingEvent $event) {
global $user;
if($user->can("manage_alias_list")) {
$event->add_link("Alias Editor", make_link("alias/list"));
}
}
/**
* @param Database $database
* @return string
*/
private function get_alias_csv(Database $database) {
$csv = "";
$aliases = $database->get_pairs("SELECT oldtag, newtag FROM aliases ORDER BY newtag");
foreach($aliases as $old => $new) {
$csv .= "\"$old\",\"$new\"\n";
}
return $csv;
}
/**
* @param Database $database
* @param string $csv
*/
private function add_alias_csv(Database $database, /*string*/ $csv) {
$csv = str_replace("\r", "\n", $csv);
foreach(explode("\n", $csv) as $line) {
$parts = str_getcsv($line);
if(count($parts) == 2) {
try {
$aae = new AddAliasEvent($parts[0], $parts[1]);
send_event($aae);
}
catch(AddAliasException $ex) {
$this->theme->display_error(500, "Error adding alias", $ex->getMessage());
}
}
}
}
/**
* Get the priority for this extension.
*
* Add alias *after* mass tag editing, else the MTE will
* search for the images and be redirected to the alias,
* missing out the images tagged with the old tag.
*
* @return int
*/
public function get_priority() {return 60;}
} }
class DeleteAliasEvent extends Event
{
public $oldtag;
public function __construct(string $oldtag)
{
parent::__construct();
$this->oldtag = $oldtag;
}
}
class AddAliasException extends SCoreException
{
}
class AliasEditor extends Extension
{
/** @var AliasEditorTheme */
protected $theme;
public function onPageRequest(PageRequestEvent $event)
{
global $config, $database, $page, $user;
if ($event->page_matches("alias")) {
if ($event->get_arg(0) == "add") {
if ($user->can(Permissions::MANAGE_ALIAS_LIST)) {
$user->ensure_authed();
$input = validate_input(["c_oldtag"=>"string", "c_newtag"=>"string"]);
try {
send_event(new AddAliasEvent($input['c_oldtag'], $input['c_newtag']));
$page->set_mode(PageMode::REDIRECT);
$page->set_redirect(make_link("alias/list"));
} catch (AddAliasException $ex) {
$this->theme->display_error(500, "Error adding alias", $ex->getMessage());
}
}
} elseif ($event->get_arg(0) == "remove") {
if ($user->can(Permissions::MANAGE_ALIAS_LIST)) {
$user->ensure_authed();
$input = validate_input(["d_oldtag"=>"string"]);
send_event(new DeleteAliasEvent($input['d_oldtag']));
$page->set_mode(PageMode::REDIRECT);
$page->set_redirect(make_link("alias/list"));
}
} elseif ($event->get_arg(0) == "list") {
$t = new AliasTable($database->raw_db());
$t->token = $user->get_auth_token();
$t->inputs = $_GET;
$t->size = $config->get_int('alias_items_per_page', 30);
if ($user->can(Permissions::MANAGE_ALIAS_LIST)) {
$t->create_url = make_link("alias/add");
$t->delete_url = make_link("alias/remove");
}
$this->theme->display_aliases($t->table($t->query()), $t->paginator());
} elseif ($event->get_arg(0) == "export") {
$page->set_mode(PageMode::DATA);
$page->set_type("text/csv");
$page->set_filename("aliases.csv");
$page->set_data($this->get_alias_csv($database));
} elseif ($event->get_arg(0) == "import") {
if ($user->can(Permissions::MANAGE_ALIAS_LIST)) {
if (count($_FILES) > 0) {
$tmp = $_FILES['alias_file']['tmp_name'];
$contents = file_get_contents($tmp);
$this->add_alias_csv($database, $contents);
log_info("alias_editor", "Imported aliases from file", "Imported aliases"); # FIXME: how many?
$page->set_mode(PageMode::REDIRECT);
$page->set_redirect(make_link("alias/list"));
} else {
$this->theme->display_error(400, "No File Specified", "You have to upload a file");
}
} else {
$this->theme->display_error(401, "Admins Only", "Only admins can edit the alias list");
}
}
}
}
public function onAddAlias(AddAliasEvent $event)
{
global $database;
$row = $database->get_row(
"SELECT * FROM aliases WHERE lower(oldtag)=lower(:oldtag)",
["oldtag"=>$event->oldtag]
);
if ($row) {
throw new AddAliasException("{$row['oldtag']} is already an alias for {$row['newtag']}");
}
$row = $database->get_row(
"SELECT * FROM aliases WHERE lower(oldtag)=lower(:newtag)",
["newtag" => $event->newtag]
);
if ($row) {
throw new AddAliasException("{$row['oldtag']} is itself an alias for {$row['newtag']}");
}
$database->execute(
"INSERT INTO aliases(oldtag, newtag) VALUES(:oldtag, :newtag)",
["oldtag" => $event->oldtag, "newtag" => $event->newtag]
);
log_info("alias_editor", "Added alias for {$event->oldtag} -> {$event->newtag}", "Added alias");
}
public function onDeleteAlias(DeleteAliasEvent $event)
{
global $database;
$database->execute("DELETE FROM aliases WHERE oldtag=:oldtag", ["oldtag" => $event->oldtag]);
log_info("alias_editor", "Deleted alias for {$event->oldtag}", "Deleted alias");
}
public function onPageSubNavBuilding(PageSubNavBuildingEvent $event)
{
if ($event->parent=="tags") {
$event->add_nav_link("aliases", new Link('alias/list'), "Aliases", NavLink::is_active(["alias"]));
}
}
public function onUserBlockBuilding(UserBlockBuildingEvent $event)
{
global $user;
if ($user->can(Permissions::MANAGE_ALIAS_LIST)) {
$event->add_link("Alias Editor", make_link("alias/list"));
}
}
private function get_alias_csv(Database $database): string
{
$csv = "";
$aliases = $database->get_pairs("SELECT oldtag, newtag FROM aliases ORDER BY newtag");
foreach ($aliases as $old => $new) {
$csv .= "\"$old\",\"$new\"\n";
}
return $csv;
}
private function add_alias_csv(Database $database, string $csv): int
{
$csv = str_replace("\r", "\n", $csv);
$i = 0;
foreach (explode("\n", $csv) as $line) {
$parts = str_getcsv($line);
if (count($parts) == 2) {
try {
send_event(new AddAliasEvent($parts[0], $parts[1]));
$i++;
} catch (AddAliasException $ex) {
$this->theme->display_error(500, "Error adding alias", $ex->getMessage());
}
}
}
return $i;
}
/**
* Get the priority for this extension.
*
* Add alias *after* mass tag editing, else the MTE will
* search for the images and be redirected to the alias,
* missing out the images tagged with the old tag.
*/
public function get_priority(): int
{
return 60;
}
}

View file

@ -1,104 +1,85 @@
<?php <?php declare(strict_types=1);
class AliasEditorTest extends ShimmiePHPUnitTestCase { class AliasEditorTest extends ShimmiePHPUnitTestCase
public function testAliasList() { {
$this->get_page('alias/list'); public function testAliasList()
$this->assert_response(200); {
$this->assert_title("Alias List"); $this->get_page('alias/list');
} $this->assert_response(200);
$this->assert_title("Alias List");
}
public function testAliasListReadOnly() { public function testAliasListReadOnly()
// Check that normal users can't add aliases. {
$this->log_in_as_user(); $this->log_in_as_user();
$this->get_page('alias/list'); $this->get_page('alias/list');
$this->assert_title("Alias List"); $this->assert_title("Alias List");
$this->assert_no_text("Add"); $this->assert_no_text("Add");
}
public function testAliasEditor() { $this->log_out();
/* $this->get_page('alias/list');
********************************************************************** $this->assert_title("Alias List");
* FIXME: TODO: $this->assert_no_text("Add");
* For some reason the alias tests always fail when they are running }
* inside the TravisCI VM environment. I have tried to determine
* the exact cause of this, but have been unable to pin it down.
*
* For now, I am commenting them out until I have more time to
* dig into this and determine exactly what is happening.
*
*********************************************************************
*/
$this->markTestIncomplete();
$this->log_in_as_admin(); public function testAliasOneToOne()
{
$this->log_in_as_admin();
# test one to one $this->get_page("alias/export/aliases.csv");
$this->get_page('alias/list'); $this->assert_no_text("test1");
$this->assert_title("Alias List");
$this->set_field('oldtag', "test1");
$this->set_field('newtag', "test2");
$this->clickSubmit('Add');
$this->assert_no_text("Error adding alias");
$this->get_page('alias/list'); send_event(new AddAliasEvent("test1", "test2"));
$this->assert_text("test1"); $this->get_page('alias/list');
$this->assert_text("test1");
$this->get_page("alias/export/aliases.csv");
$this->assert_text('"test1","test2"');
$this->get_page("alias/export/aliases.csv"); $image_id = $this->post_image("tests/pbx_screenshot.jpg", "test1");
$this->assert_text("test1,test2"); $this->get_page("post/view/$image_id"); # check that the tag has been replaced
$this->assert_title("Image $image_id: test2");
$this->get_page("post/list/test1/1"); # searching for an alias should find the master tag
$this->assert_response(302);
$this->get_page("post/list/test2/1"); # check that searching for the main tag still works
$this->assert_response(302);
$this->delete_image($image_id);
$image_id = $this->post_image("tests/pbx_screenshot.jpg", "test1"); send_event(new DeleteAliasEvent("test1"));
$this->get_page("post/view/$image_id"); # check that the tag has been replaced $this->get_page('alias/list');
$this->assert_title("Image $image_id: test2"); $this->assert_title("Alias List");
$this->get_page("post/list/test1/1"); # searching for an alias should find the master tag $this->assert_no_text("test1");
$this->assert_title("Image $image_id: test2"); }
$this->get_page("post/list/test2/1"); # check that searching for the main tag still works
$this->assert_title("Image $image_id: test2");
$this->delete_image($image_id);
$this->get_page('alias/list'); public function testAliasOneToMany()
$this->click("Remove"); {
$this->get_page('alias/list'); $this->log_in_as_admin();
$this->assert_title("Alias List");
$this->assert_no_text("test1");
# test one to many $this->get_page("alias/export/aliases.csv");
$this->get_page('alias/list'); $this->assert_no_text("multi");
$this->assert_title("Alias List");
$this->set_field('oldtag', "onetag");
$this->set_field('newtag', "multi tag");
$this->click("Add");
$this->get_page('alias/list');
$this->assert_text("multi");
$this->assert_text("tag");
$this->get_page("alias/export/aliases.csv"); send_event(new AddAliasEvent("onetag", "multi tag"));
$this->assert_text("onetag,multi tag"); $this->get_page('alias/list');
$this->assert_text("multi");
$this->assert_text("tag");
$this->get_page("alias/export/aliases.csv");
$this->assert_text('"onetag","multi tag"');
$image_id_1 = $this->post_image("tests/pbx_screenshot.jpg", "onetag"); $image_id_1 = $this->post_image("tests/pbx_screenshot.jpg", "onetag");
$image_id_2 = $this->post_image("tests/bedroom_workshop.jpg", "onetag"); $image_id_2 = $this->post_image("tests/bedroom_workshop.jpg", "onetag");
// FIXME: known broken $this->get_page("post/list/onetag/1"); # searching for an aliased tag should find its aliases
//$this->get_page("post/list/onetag/1"); # searching for an aliased tag should find its aliases $this->assert_title("multi tag");
//$this->assert_title("onetag"); $this->assert_no_text("No Images Found");
//$this->assert_no_text("No Images Found"); $this->get_page("post/list/multi/1");
$this->get_page("post/list/multi/1"); $this->assert_title("multi");
$this->assert_title("multi"); $this->assert_no_text("No Images Found");
$this->assert_no_text("No Images Found"); $this->get_page("post/list/multi tag/1");
$this->get_page("post/list/multi%20tag/1"); $this->assert_title("multi tag");
$this->assert_title("multi tag"); $this->assert_no_text("No Images Found");
$this->assert_no_text("No Images Found"); $this->delete_image($image_id_1);
$this->delete_image($image_id_1); $this->delete_image($image_id_2);
$this->delete_image($image_id_2);
$this->get_page('alias/list'); send_event(new DeleteAliasEvent("onetag"));
$this->click("Remove"); $this->get_page('alias/list');
$this->get_page('alias/list'); $this->assert_title("Alias List");
$this->assert_title("Alias List"); $this->assert_no_text("test1");
$this->assert_no_text("test1"); }
$this->log_out();
$this->get_page('alias/list');
$this->assert_title("Alias List");
$this->assert_no_text("Add");
}
} }

View file

@ -1,79 +1,36 @@
<?php <?php declare(strict_types=1);
class AliasEditorTheme extends Themelet { class AliasEditorTheme extends Themelet
/** {
* Show a page of aliases. /**
* * Show a page of aliases.
* Note: $can_manage = whether things like "add new alias" should be shown *
* * Note: $can_manage = whether things like "add new alias" should be shown
* @param array $aliases An array of ($old_tag => $new_tag) */
* @param int $pageNumber public function display_aliases($table, $paginator): void
* @param int $totalPages {
*/ global $page, $user;
public function display_aliases($aliases, $pageNumber, $totalPages) {
global $page, $user;
$can_manage = $user->can("manage_alias_list"); $can_manage = $user->can(Permissions::MANAGE_ALIAS_LIST);
if($can_manage) { $html = "
$h_action = "<th width='10%'>Action</th>"; $table
$h_add = " $paginator
<tr>
".make_form(make_link("alias/add"))."
<td><input type='text' name='oldtag' class='autocomplete_tags' autocomplete='off'></td>
<td><input type='text' name='newtag' class='autocomplete_tags' autocomplete='off'></td>
<td><input type='submit' value='Add'></td>
</form>
</tr>
";
}
else {
$h_action = "";
$h_add = "";
}
$h_aliases = "";
foreach($aliases as $old => $new) {
$h_old = html_escape($old);
$h_new = "<a href='".make_link("post/list/".url_escape($new)."/1")."'>".html_escape($new)."</a>";
$h_aliases .= "<tr><td>$h_old</td><td>$h_new</td>";
if($can_manage) {
$h_aliases .= "
<td>
".make_form(make_link("alias/remove"))."
<input type='hidden' name='oldtag' value='$h_old'>
<input type='submit' value='Remove'>
</form>
</td>
";
}
$h_aliases .= "</tr>";
}
$html = "
<table id='aliases' class='sortable zebra'>
<thead><tr><th>From</th><th>To</th>$h_action</tr></thead>
<tbody>$h_aliases</tbody>
<tfoot>$h_add</tfoot>
</table>
<p><a href='".make_link("alias/export/aliases.csv")."' download='aliases.csv'>Download as CSV</a></p> <p><a href='".make_link("alias/export/aliases.csv")."' download='aliases.csv'>Download as CSV</a></p>
"; ";
$bulk_html = " $bulk_html = "
".make_form(make_link("alias/import"), 'post', true)." ".make_form(make_link("alias/import"), 'post', true)."
<input type='file' name='alias_file'> <input type='file' name='alias_file'>
<input type='submit' value='Upload List'> <input type='submit' value='Upload List'>
</form> </form>
"; ";
$page->set_title("Alias List"); $page->set_title("Alias List");
$page->set_heading("Alias List"); $page->set_heading("Alias List");
$page->add_block(new NavBlock()); $page->add_block(new NavBlock());
$page->add_block(new Block("Aliases", $html)); $page->add_block(new Block("Aliases", $html));
if($can_manage) { if ($can_manage) {
$page->add_block(new Block("Bulk Upload", $bulk_html, "main", 51)); $page->add_block(new Block("Bulk Upload", $bulk_html, "main", 51));
} }
}
$this->display_paginator($page, "alias/list", null, $pageNumber, $totalPages);
}
} }

File diff suppressed because it is too large Load diff

View file

@ -1,75 +0,0 @@
<?php
/*
* Name: Amazon S3 Mirror
* Author: Shish <webmaster@shishnet.org>
* License: GPLv2
* Description: Copy uploaded files to S3
* Documentation:
*/
require_once "ext/amazon_s3/lib/S3.php";
class UploadS3 extends Extension {
public function onInitExt(InitExtEvent $event) {
global $config;
$config->set_default_string("amazon_s3_access", "");
$config->set_default_string("amazon_s3_secret", "");
$config->set_default_string("amazon_s3_bucket", "");
}
public function onSetupBuilding(SetupBuildingEvent $event) {
$sb = new SetupBlock("Amazon S3");
$sb->add_text_option("amazon_s3_access", "Access key: ");
$sb->add_text_option("amazon_s3_secret", "<br>Secret key: ");
$sb->add_text_option("amazon_s3_bucket", "<br>Bucket: ");
$event->panel->add_block($sb);
}
public function onImageAddition(ImageAdditionEvent $event) {
global $config;
$access = $config->get_string("amazon_s3_access");
$secret = $config->get_string("amazon_s3_secret");
$bucket = $config->get_string("amazon_s3_bucket");
if(!empty($bucket)) {
log_debug("amazon_s3", "Mirroring Image #".$event->image->id." to S3 #$bucket");
$s3 = new S3($access, $secret);
$s3->putBucket($bucket, S3::ACL_PUBLIC_READ);
$s3->putObjectFile(
warehouse_path("thumbs", $event->image->hash),
$bucket,
'thumbs/'.$event->image->hash,
S3::ACL_PUBLIC_READ,
array(),
array(
"Content-Type" => "image/jpeg",
"Content-Disposition" => "inline; filename=image-" . $event->image->id . ".jpg",
)
);
$s3->putObjectFile(
warehouse_path("images", $event->image->hash),
$bucket,
'images/'.$event->image->hash,
S3::ACL_PUBLIC_READ,
array(),
array(
"Content-Type" => $event->image->get_mime_type(),
"Content-Disposition" => "inline; filename=image-" . $event->image->id . "." . $event->image->ext,
)
);
}
}
public function onImageDeletion(ImageDeletionEvent $event) {
global $config;
$access = $config->get_string("amazon_s3_access");
$secret = $config->get_string("amazon_s3_secret");
$bucket = $config->get_string("amazon_s3_bucket");
if(!empty($bucket)) {
log_debug("amazon_s3", "Deleting Image #".$event->image->id." from S3");
$s3 = new S3($access, $secret);
$s3->deleteObject($bucket, "images/" . $event->image->hash);
$s3->deleteObject($bucket, "thumbs/" . $event->image->hash);
}
}
}

12
ext/approval/info.php Normal file
View file

@ -0,0 +1,12 @@
<?php declare(strict_types=1);
class ApprovalInfo extends ExtensionInfo
{
public const KEY = "approval";
public $key = self::KEY;
public $name = "Approval";
public $authors = ["Matthew Barbour"=>"matthew@darkholme.net"];
public $license = self::LICENSE_WTFPL;
public $description = "Adds an approval step to the upload/import process.";
}

255
ext/approval/main.php Normal file
View file

@ -0,0 +1,255 @@
<?php declare(strict_types=1);
abstract class ApprovalConfig
{
const VERSION = "ext_approval_version";
const IMAGES = "approve_images";
const COMMENTS = "approve_comments";
}
class Approval extends Extension
{
/** @var ApprovalTheme */
protected $theme;
public function onInitExt(InitExtEvent $event)
{
global $config;
$config->set_default_bool(ApprovalConfig::IMAGES, false);
$config->set_default_bool(ApprovalConfig::COMMENTS, false);
Image::$bool_props[] = "approved";
}
public function onPageRequest(PageRequestEvent $event)
{
global $page, $user;
if ($event->page_matches("approve_image") && $user->can(Permissions::APPROVE_IMAGE)) {
// Try to get the image ID
$image_id = int_escape($event->get_arg(0));
if (empty($image_id)) {
$image_id = isset($_POST['image_id']) ? $_POST['image_id'] : null;
}
if (empty($image_id)) {
throw new SCoreException("Can not approve image: No valid Image ID given.");
}
self::approve_image($image_id);
$page->set_mode(PageMode::REDIRECT);
$page->set_redirect(make_link("post/view/" . $image_id));
}
if ($event->page_matches("disapprove_image") && $user->can(Permissions::APPROVE_IMAGE)) {
// Try to get the image ID
$image_id = int_escape($event->get_arg(0));
if (empty($image_id)) {
$image_id = isset($_POST['image_id']) ? $_POST['image_id'] : null;
}
if (empty($image_id)) {
throw new SCoreException("Can not disapprove image: No valid Image ID given.");
}
self::disapprove_image($image_id);
$page->set_mode(PageMode::REDIRECT);
$page->set_redirect(make_link("post/view/".$image_id));
}
}
public function onSetupBuilding(SetupBuildingEvent $event)
{
$this->theme->display_admin_block($event);
}
public function onAdminBuilding(AdminBuildingEvent $event)
{
$this->theme->display_admin_form();
}
public function onAdminAction(AdminActionEvent $event)
{
global $database, $user;
$action = $event->action;
$event->redirect = true;
if ($action==="approval") {
$approval_action = $_POST["approval_action"];
switch ($approval_action) {
case "approve_all":
$database->set_timeout(300000); // These updates can take a little bit
$database->execute(
"UPDATE images SET approved = :true, approved_by_id = :approved_by_id WHERE approved = :false",
["approved_by_id"=>$user->id, "true"=>true, "false"=>false]
);
break;
case "disapprove_all":
$database->set_timeout(300000); // These updates can take a little bit
$database->execute(
"UPDATE images SET approved = :false, approved_by_id = NULL WHERE approved = :true",
["true"=>true, "false"=>false]
);
break;
default:
break;
}
}
}
public function onDisplayingImage(DisplayingImageEvent $event)
{
global $user, $page, $config;
if ($config->get_bool(ApprovalConfig::IMAGES) && $event->image->approved===false && !$user->can(Permissions::APPROVE_IMAGE)) {
$page->set_mode(PageMode::REDIRECT);
$page->set_redirect(make_link("post/list"));
}
}
public function onPageSubNavBuilding(PageSubNavBuildingEvent $event)
{
global $user;
if ($event->parent=="posts") {
if ($user->can(Permissions::APPROVE_IMAGE)) {
$event->add_nav_link("posts_unapproved", new Link('/post/list/approved%3Ano/1'), "Pending Approval", null, 60);
}
}
}
const SEARCH_REGEXP = "/^approved:(yes|no)/";
public function onSearchTermParse(SearchTermParseEvent $event)
{
global $user, $database, $config;
if ($config->get_bool(ApprovalConfig::IMAGES)) {
$matches = [];
if (is_null($event->term) && $this->no_approval_query($event->context)) {
$event->add_querylet(new Querylet($database->scoreql_to_sql("approved = SCORE_BOOL_Y ")));
}
if (is_null($event->term)) {
return;
}
if (preg_match(self::SEARCH_REGEXP, strtolower($event->term), $matches)) {
if ($user->can(Permissions::APPROVE_IMAGE) && $matches[1] == "no") {
$event->add_querylet(new Querylet($database->scoreql_to_sql("approved = SCORE_BOOL_N ")));
} else {
$event->add_querylet(new Querylet($database->scoreql_to_sql("approved = SCORE_BOOL_Y ")));
}
}
}
}
public function onHelpPageBuilding(HelpPageBuildingEvent $event)
{
global $user, $config;
if ($event->key===HelpPages::SEARCH) {
if ($user->can(Permissions::APPROVE_IMAGE) && $config->get_bool(ApprovalConfig::IMAGES)) {
$block = new Block();
$block->header = "Approval";
$block->body = $this->theme->get_help_html();
$event->add_block($block);
}
}
}
private function no_approval_query(array $context): bool
{
foreach ($context as $term) {
if (preg_match(self::SEARCH_REGEXP, $term)) {
return false;
}
}
return true;
}
public static function approve_image($image_id)
{
global $database, $user;
$database->execute(
"UPDATE images SET approved = :true, approved_by_id = :approved_by_id WHERE id = :id AND approved = :false",
["approved_by_id"=>$user->id, "id"=>$image_id, "true"=>true, "false"=>false]
);
}
public static function disapprove_image($image_id)
{
global $database;
$database->execute(
"UPDATE images SET approved = :false, approved_by_id = NULL WHERE id = :id AND approved = :true",
["id"=>$image_id, "true"=>true, "false"=>false]
);
}
public function onImageAdminBlockBuilding(ImageAdminBlockBuildingEvent $event)
{
global $user, $config;
if ($user->can(Permissions::APPROVE_IMAGE) && $config->get_bool(ApprovalConfig::IMAGES)) {
$event->add_part($this->theme->get_image_admin_html($event->image));
}
}
public function onBulkActionBlockBuilding(BulkActionBlockBuildingEvent $event)
{
global $user, $config;
if ($user->can(Permissions::APPROVE_IMAGE)&& $config->get_bool(ApprovalConfig::IMAGES)) {
if (in_array("approved:no", $event->search_terms)) {
$event->add_action("bulk_approve_image", "Approve", "a");
} else {
$event->add_action("bulk_disapprove_image", "Disapprove");
}
}
}
public function onBulkAction(BulkActionEvent $event)
{
global $page, $user;
switch ($event->action) {
case "bulk_approve_image":
if ($user->can(Permissions::APPROVE_IMAGE)) {
$total = 0;
foreach ($event->items as $image) {
self::approve_image($image->id);
$total++;
}
$page->flash("Approved $total items");
}
break;
case "bulk_disapprove_image":
if ($user->can(Permissions::APPROVE_IMAGE)) {
$total = 0;
foreach ($event->items as $image) {
self::disapprove_image($image->id);
$total++;
}
$page->flash("Disapproved $total items");
}
break;
}
}
public function onDatabaseUpgrade(DatabaseUpgradeEvent $event)
{
global $database;
if ($this->get_version(ApprovalConfig::VERSION) < 1) {
$database->execute($database->scoreql_to_sql(
"ALTER TABLE images ADD COLUMN approved SCORE_BOOL NOT NULL DEFAULT SCORE_BOOL_N"
));
$database->execute(
"ALTER TABLE images ADD COLUMN approved_by_id INTEGER NULL"
);
$database->execute("CREATE INDEX images_approved_idx ON images(approved)");
$this->set_version(ApprovalConfig::VERSION, 1);
}
}
}

61
ext/approval/theme.php Normal file
View file

@ -0,0 +1,61 @@
<?php declare(strict_types=1);
use function MicroHTML\BR;
use function MicroHTML\BUTTON;
use function MicroHTML\INPUT;
class ApprovalTheme extends Themelet
{
public function get_image_admin_html(Image $image)
{
if ($image->approved===true) {
$html = SHM_SIMPLE_FORM(
'disapprove_image/'.$image->id,
INPUT(["type"=>'hidden', "name"=>'image_id', "value"=>$image->id]),
SHM_SUBMIT("Disapprove")
);
} else {
$html = SHM_SIMPLE_FORM(
'approve_image/'.$image->id,
INPUT(["type"=>'hidden', "name"=>'image_id', "value"=>$image->id]),
SHM_SUBMIT("Approve")
);
}
return (string)$html;
}
public function get_help_html()
{
return '<p>Search for images that are approved/not approved.</p>
<div class="command_example">
<pre>approved:yes</pre>
<p>Returns images that have been approved.</p>
</div>
<div class="command_example">
<pre>approved:no</pre>
<p>Returns images that have not been approved.</p>
</div>
';
}
public function display_admin_block(SetupBuildingEvent $event)
{
$sb = new SetupBlock("Approval");
$sb->add_bool_option(ApprovalConfig::IMAGES, "Images: ");
$event->panel->add_block($sb);
}
public function display_admin_form()
{
global $page;
$html = (string)SHM_SIMPLE_FORM(
"admin/approval",
BUTTON(["name"=>'approval_action', "value"=>'approve_all'], "Approve All Images"),
BR(),
BUTTON(["name"=>'approval_action', "value"=>'disapprove_all'], "Disapprove All Images"),
);
$page->add_block(new Block("Approval", $html));
}
}

View file

@ -1,101 +0,0 @@
<?php
/**
* Name: Arrow Key Navigation
* Author: Drudex Software <support@drudexsoftware.com>
* Link: http://www.drudexsoftware.com/
* License: GPLv2
* Description: Allows viewers no navigate between images using the left & right arrow keys.
* Documentation:
* Simply enable this extention in the extention manager to enable arrow key navigation.
*/
class ArrowkeyNavigation extends Extension {
/**
* Adds functionality for post/view on images.
*
* @param DisplayingImageEvent $event
*/
public function onDisplayingImage(DisplayingImageEvent $event) {
$prev_url = make_http(make_link("post/prev/".$event->image->id));
$next_url = make_http(make_link("post/next/".$event->image->id));
$this->add_arrowkeys_code($prev_url, $next_url);
}
/**
* Adds functionality for post/list.
*
* @param PageRequestEvent $event
*/
public function onPageRequest(PageRequestEvent $event) {
if($event->page_matches("post/list")) {
$pageinfo = $this->get_list_pageinfo($event);
$prev_url = make_http(make_link("post/list/".$pageinfo["prev"]));
$next_url = make_http(make_link("post/list/".$pageinfo["next"]));
$this->add_arrowkeys_code($prev_url, $next_url);
}
}
/**
* Adds the javascript to the page with the given urls.
*
* @param string $prev_url
* @param string $next_url
*/
private function add_arrowkeys_code($prev_url, $next_url) {
global $page;
$page->add_html_header("<script type=\"text/javascript\">
(function($){
$(document).keyup(function(e) {
if($(e.target).is('input', 'textarea')){ return; }
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) { return; }
if (e.keyCode == 37) { window.location.href = '{$prev_url}'; }
else if (e.keyCode == 39) { window.location.href = '{$next_url}'; }
});
})(jQuery);
</script>", 60);
}
/**
* Returns info about the current page number.
*
* @param PageRequestEvent $event
* @return array
*/
private function get_list_pageinfo(PageRequestEvent $event) {
global $config, $database;
// get the amount of images per page
$images_per_page = $config->get_int('index_images');
// if there are no tags, use default
if (is_null($event->get_arg(1))){
$prefix = "";
$page_number = int_escape($event->get_arg(0));
$total_pages = ceil($database->get_one(
"SELECT COUNT(*) FROM images") / $images_per_page);
}
else { // if there are tags, use pages with tags
$prefix = url_escape($event->get_arg(0)) . "/";
$page_number = int_escape($event->get_arg(1));
$total_pages = ceil($database->get_one(
"SELECT count FROM tags WHERE tag=:tag",
array("tag"=>$event->get_arg(0))) / $images_per_page);
}
// creates previous & next values
// When previous first page, go to last page
if ($page_number <= 1) $prev = $total_pages;
else $prev = $page_number-1;
if ($page_number >= $total_pages) $next = 1;
else $next = $page_number+1;
// Create return array
$pageinfo = array(
"prev" => $prefix.$prev,
"next" => $prefix.$next,
);
return $pageinfo;
}
}

14
ext/artists/info.php Normal file
View file

@ -0,0 +1,14 @@
<?php declare(strict_types=1);
class ArtistsInfo extends ExtensionInfo
{
public const KEY = "artists";
public $key = self::KEY;
public $name = "Artists System";
public $url = self::SHIMMIE_URL;
public $authors = ["Sein Kraft"=>"mail@seinkraft.info","Alpha"=>"alpha@furries.com.ar"];
public $license = self::LICENSE_GPLV2;
public $description = "Simple artists extension";
public $beta = true;
}

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,15 @@
<?php <?php declare(strict_types=1);
class ArtistTest extends ShimmiePHPUnitTestCase { class ArtistsTest extends ShimmiePHPUnitTestCase
public function testSearch() { {
# FIXME: check that the results are there public function testSearch()
$this->get_page("post/list/author=bob/1"); {
#$this->assert_response(200); global $user;
} $this->log_in_as_user();
} $image_id = $this->post_image("tests/pbx_screenshot.jpg", "pbx computer screenshot");
$image = Image::by_id($image_id);
send_event(new AuthorSetEvent($image, $user, "bob"));
$this->assert_search_results(["author=bob"], [$image_id]);
}
}

View file

@ -1,13 +1,10 @@
<?php <?php declare(strict_types=1);
class ArtistsTheme extends Themelet { class ArtistsTheme extends Themelet
{
/** public function get_author_editor_html(string $author): string
* @param string $author {
* @return string $h_author = html_escape($author);
*/ return "
public function get_author_editor_html(/*string*/ $author) {
$h_author = html_escape($author);
return "
<tr> <tr>
<th>Author</th> <th>Author</th>
<td> <td>
@ -16,105 +13,104 @@ class ArtistsTheme extends Themelet {
</td> </td>
</tr> </tr>
"; ";
} }
/** public function sidebar_options(string $mode, ?int $artistID=null, $is_admin=false): void
* @param string $mode {
* @param null|int $artistID global $page, $user;
* @param bool $is_admin
*/
public function sidebar_options(/*string*/ $mode, $artistID=NULL, $is_admin=FALSE) {
global $page, $user;
$html = ""; $html = "";
if($mode == "neutral"){ if ($mode == "neutral") {
$html = "<form method='post' action='".make_link("artist/new_artist")."'> $html = "<form method='post' action='".make_link("artist/new_artist")."'>
".$user->get_auth_html()." ".$user->get_auth_html()."
<input type='submit' name='edit' id='edit' value='New Artist'/> <input type='submit' name='edit' id='edit' value='New Artist'/>
</form>"; </form>";
} }
if($mode == "editor"){ if ($mode == "editor") {
$html = "<form method='post' action='".make_link("artist/new_artist")."'> $html = "<form method='post' action='".make_link("artist/new_artist")."'>
".$user->get_auth_html()." ".$user->get_auth_html()."
<input type='submit' name='edit' id='edit' value='New Artist'/> <input type='submit' name='edit' value='New Artist'/>
</form> </form>
<form method='post' action='".make_link("artist/edit_artist")."'> <form method='post' action='".make_link("artist/edit_artist")."'>
".$user->get_auth_html()." ".$user->get_auth_html()."
<input type='submit' name='edit' id='edit' value='Edit Artist'/> <input type='submit' name='edit' value='Edit Artist'/>
<input type='hidden' name='artist_id' value='".$artistID."'> <input type='hidden' name='artist_id' value='".$artistID."'>
</form>"; </form>";
if($is_admin){ if ($is_admin) {
$html .= "<form method='post' action='".make_link("artist/nuke_artist")."'> $html .= "<form method='post' action='".make_link("artist/nuke_artist")."'>
".$user->get_auth_html()." ".$user->get_auth_html()."
<input type='submit' name='edit' id='edit' value='Delete Artist'/> <input type='submit' name='edit' value='Delete Artist'/>
<input type='hidden' name='artist_id' value='".$artistID."'> <input type='hidden' name='artist_id' value='".$artistID."'>
</form>"; </form>";
} }
$html .= "<form method='post' action='".make_link("artist/add_alias")."'> $html .= "<form method='post' action='".make_link("artist/add_alias")."'>
".$user->get_auth_html()." ".$user->get_auth_html()."
<input type='submit' name='edit' id='edit' value='Add Alias'/> <input type='submit' name='edit' value='Add Alias'/>
<input type='hidden' name='artist_id' value='".$artistID."'> <input type='hidden' name='artist_id' value='".$artistID."'>
</form> </form>
<form method='post' action='".make_link("artist/add_member")."'> <form method='post' action='".make_link("artist/add_member")."'>
".$user->get_auth_html()." ".$user->get_auth_html()."
<input type='submit' name='edit' id='edit' value='Add Member'/> <input type='submit' name='edit' value='Add Member'/>
<input type='hidden' name='artist_id' value='".$artistID."'> <input type='hidden' name='artist_id' value='".$artistID."'>
</form> </form>
<form method='post' action='".make_link("artist/add_url")."'> <form method='post' action='".make_link("artist/add_url")."'>
".$user->get_auth_html()." ".$user->get_auth_html()."
<input type='submit' name='edit' id='edit' value='Add Url'/> <input type='submit' name='edit' value='Add Url'/>
<input type='hidden' name='artist_id' value='".$artistID."'> <input type='hidden' name='artist_id' value='".$artistID."'>
</form>"; </form>";
} }
if($html) $page->add_block(new Block("Manage Artists", $html, "left", 10)); if ($html) {
} $page->add_block(new Block("Manage Artists", $html, "left", 10));
}
}
public function show_artist_editor($artist, $aliases, $members, $urls) { public function show_artist_editor($artist, $aliases, $members, $urls)
global $user; {
global $user;
$artistName = $artist['name']; $artistName = $artist['name'];
$artistNotes = $artist['notes']; $artistNotes = $artist['notes'];
$artistID = $artist['id']; $artistID = $artist['id'];
// aliases // aliases
$aliasesString = ""; $aliasesString = "";
$aliasesIDsString = ""; $aliasesIDsString = "";
foreach ($aliases as $alias) { foreach ($aliases as $alias) {
$aliasesString .= $alias["alias_name"]." "; $aliasesString .= $alias["alias_name"]." ";
$aliasesIDsString .= $alias["alias_id"]." "; $aliasesIDsString .= $alias["alias_id"]." ";
} }
$aliasesString = rtrim($aliasesString); $aliasesString = rtrim($aliasesString);
$aliasesIDsString = rtrim($aliasesIDsString); $aliasesIDsString = rtrim($aliasesIDsString);
// members // members
$membersString = ""; $membersString = "";
$membersIDsString = ""; $membersIDsString = "";
foreach ($members as $member) { foreach ($members as $member) {
$membersString .= $member["name"]." "; $membersString .= $member["name"]." ";
$membersIDsString .= $member["id"]." "; $membersIDsString .= $member["id"]." ";
} }
$membersString = rtrim($membersString); $membersString = rtrim($membersString);
$membersIDsString = rtrim($membersIDsString); $membersIDsString = rtrim($membersIDsString);
// urls // urls
$urlsString = ""; $urlsString = "";
$urlsIDsString = ""; $urlsIDsString = "";
foreach ($urls as $url) { foreach ($urls as $url) {
$urlsString .= $url["url"]."\n"; $urlsString .= $url["url"]."\n";
$urlsIDsString .= $url["id"]." "; $urlsIDsString .= $url["id"]." ";
} }
$urlsString = substr($urlsString, 0, strlen($urlsString) -1); $urlsString = substr($urlsString, 0, strlen($urlsString) -1);
$urlsIDsString = rtrim($urlsIDsString); $urlsIDsString = rtrim($urlsIDsString);
$html = ' $html = '
<form method="POST" action="'.make_link("artist/edited/".$artist['id']).'"> <form method="POST" action="'.make_link("artist/edited/".$artist['id']).'">
'.$user->get_auth_html().' '.$user->get_auth_html().'
<table> <table>
@ -132,14 +128,15 @@ class ArtistsTheme extends Themelet {
</form> </form>
'; ';
global $page; global $page;
$page->add_block(new Block("Edit artist", $html, "main", 10)); $page->add_block(new Block("Edit artist", $html, "main", 10));
} }
public function new_artist_composer() { public function new_artist_composer()
global $page, $user; {
global $page, $user;
$html = "<form action=".make_link("artist/create")." method='POST'> $html = "<form action=".make_link("artist/create")." method='POST'>
".$user->get_auth_html()." ".$user->get_auth_html()."
<table> <table>
<tr><td>Name:</td><td><input type='text' name='name' /></td></tr> <tr><td>Name:</td><td><input type='text' name='name' /></td></tr>
@ -151,86 +148,95 @@ class ArtistsTheme extends Themelet {
</table> </table>
"; ";
$page->set_title("Artists"); $page->set_title("Artists");
$page->set_heading("Artists"); $page->set_heading("Artists");
$page->add_block(new Block("Artists", $html, "main", 10)); $page->add_block(new Block("Artists", $html, "main", 10));
} }
public function list_artists($artists, $pageNumber, $totalPages) { public function list_artists($artists, $pageNumber, $totalPages)
global $user, $page; {
global $user, $page;
$html = "<table id='poolsList' class='zebra'>". $html = "<table id='poolsList' class='zebra'>".
"<thead><tr>". "<thead><tr>".
"<th>Name</th>". "<th>Name</th>".
"<th>Type</th>". "<th>Type</th>".
"<th>Last updater</th>". "<th>Last updater</th>".
"<th>Posts</th>"; "<th>Posts</th>";
if(!$user->is_anonymous()) $html .= "<th colspan='2'>Action</th>"; // space for edit link if (!$user->is_anonymous()) {
$html .= "<th colspan='2'>Action</th>";
$html .= "</tr></thead>"; } // space for edit link
$html .= "</tr></thead>";
$deletionLinkActionArray = array( $deletionLinkActionArray = [
'artist' => 'artist/nuke/', 'artist' => 'artist/nuke/',
'alias' => 'artist/alias/delete/', 'alias' => 'artist/alias/delete/',
'member' => 'artist/member/delete/', 'member' => 'artist/member/delete/',
); ];
$editionLinkActionArray = array( $editionLinkActionArray = [
'artist' => 'artist/edit/', 'artist' => 'artist/edit/',
'alias' => 'artist/alias/edit/', 'alias' => 'artist/alias/edit/',
'member' => 'artist/member/edit/', 'member' => 'artist/member/edit/',
); ];
$typeTextArray = array( $typeTextArray = [
'artist' => 'Artist', 'artist' => 'Artist',
'alias' => 'Alias', 'alias' => 'Alias',
'member' => 'Member', 'member' => 'Member',
); ];
foreach ($artists as $artist) { foreach ($artists as $artist) {
if ($artist['type'] != 'artist') if ($artist['type'] != 'artist') {
$artist['name'] = str_replace("_", " ", $artist['name']); $artist['name'] = str_replace("_", " ", $artist['name']);
}
$elementLink = "<a href='".make_link("artist/view/".$artist['artist_id'])."'>".str_replace("_", " ", $artist['name'])."</a>"; $elementLink = "<a href='".make_link("artist/view/".$artist['artist_id'])."'>".str_replace("_", " ", $artist['name'])."</a>";
//$artist_link = "<a href='".make_link("artist/view/".$artist['artist_id'])."'>".str_replace("_", " ", $artist['artist_name'])."</a>"; //$artist_link = "<a href='".make_link("artist/view/".$artist['artist_id'])."'>".str_replace("_", " ", $artist['artist_name'])."</a>";
$user_link = "<a href='".make_link("user/".$artist['user_name'])."'>".$artist['user_name']."</a>"; $user_link = "<a href='".make_link("user/".$artist['user_name'])."'>".$artist['user_name']."</a>";
$edit_link = "<a href='".make_link($editionLinkActionArray[$artist['type']].$artist['id'])."'>Edit</a>"; $edit_link = "<a href='".make_link($editionLinkActionArray[$artist['type']].$artist['id'])."'>Edit</a>";
$del_link = "<a href='".make_link($deletionLinkActionArray[$artist['type']].$artist['id'])."'>Delete</a>"; $del_link = "<a href='".make_link($deletionLinkActionArray[$artist['type']].$artist['id'])."'>Delete</a>";
$html .= "<tr>". $html .= "<tr>".
"<td class='left'>".$elementLink; "<td class='left'>".$elementLink;
//if ($artist['type'] == 'member') //if ($artist['type'] == 'member')
// $html .= " (member of ".$artist_link.")"; // $html .= " (member of ".$artist_link.")";
//if ($artist['type'] == 'alias') //if ($artist['type'] == 'alias')
// $html .= " (alias for ".$artist_link.")"; // $html .= " (alias for ".$artist_link.")";
$html .= "</td>". $html .= "</td>".
"<td>".$typeTextArray[$artist['type']]."</td>". "<td>".$typeTextArray[$artist['type']]."</td>".
"<td>".$user_link."</td>". "<td>".$user_link."</td>".
"<td>".$artist['posts']."</td>"; "<td>".$artist['posts']."</td>";
if(!$user->is_anonymous()) $html .= "<td>".$edit_link."</td>"; if (!$user->is_anonymous()) {
if($user->is_admin()) $html .= "<td>".$del_link."</td>"; $html .= "<td>".$edit_link."</td>";
}
if ($user->can(Permissions::ARTISTS_ADMIN)) {
$html .= "<td>".$del_link."</td>";
}
$html .= "</tr>"; $html .= "</tr>";
} }
$html .= "</tbody></table>"; $html .= "</tbody></table>";
$page->set_title("Artists"); $page->set_title("Artists");
$page->set_heading("Artists"); $page->set_heading("Artists");
$page->add_block(new Block("Artists", $html, "main", 10)); $page->add_block(new Block("Artists", $html, "main", 10));
$this->display_paginator($page, "artist/list", null, $pageNumber, $totalPages); $this->display_paginator($page, "artist/list", null, $pageNumber, $totalPages);
} }
public function show_new_alias_composer($artistID) { public function show_new_alias_composer($artistID)
global $user; {
global $user;
$html = ' $html = '
<form method="POST" action='.make_link("artist/alias/add").'> <form method="POST" action='.make_link("artist/alias/add").'>
'.$user->get_auth_html().' '.$user->get_auth_html().'
<table> <table>
@ -241,14 +247,15 @@ class ArtistsTheme extends Themelet {
</form> </form>
'; ';
global $page; global $page;
$page->add_block(new Block("Artist Aliases", $html, "main", 20)); $page->add_block(new Block("Artist Aliases", $html, "main", 20));
} }
public function show_new_member_composer($artistID) { public function show_new_member_composer($artistID)
global $user; {
global $user;
$html = ' $html = '
<form method="POST" action='.make_link("artist/member/add").'> <form method="POST" action='.make_link("artist/member/add").'>
'.$user->get_auth_html().' '.$user->get_auth_html().'
<table> <table>
@ -259,14 +266,15 @@ class ArtistsTheme extends Themelet {
</form> </form>
'; ';
global $page; global $page;
$page->add_block(new Block("Artist members", $html, "main", 30)); $page->add_block(new Block("Artist members", $html, "main", 30));
} }
public function show_new_url_composer($artistID) { public function show_new_url_composer($artistID)
global $user; {
global $user;
$html = ' $html = '
<form method="POST" action='.make_link("artist/url/add").'> <form method="POST" action='.make_link("artist/url/add").'>
'.$user->get_auth_html().' '.$user->get_auth_html().'
<table> <table>
@ -277,253 +285,274 @@ class ArtistsTheme extends Themelet {
</form> </form>
'; ';
global $page; global $page;
$page->add_block(new Block("Artist URLs", $html, "main", 40)); $page->add_block(new Block("Artist URLs", $html, "main", 40));
} }
public function show_alias_editor($alias) { public function show_alias_editor($alias)
global $user; {
global $user;
$html = ' $html = '
<form method="POST" action="'.make_link("artist/alias/edited/".$alias['id']).'"> <form method="POST" action="'.make_link("artist/alias/edited/".$alias['id']).'">
'.$user->get_auth_html().' '.$user->get_auth_html().'
<label for="alias">Alias:</label> <label for="alias">Alias:</label>
<input type="text" name="alias" value="'.$alias['alias'].'" /> <input type="text" name="alias" id="alias" value="'.$alias['alias'].'" />
<input type="hidden" name="aliasID" value="'.$alias['id'].'" /> <input type="hidden" name="aliasID" value="'.$alias['id'].'" />
<input type="submit" value="Submit" /> <input type="submit" value="Submit" />
</form> </form>
'; ';
global $page; global $page;
$page->add_block(new Block("Edit Alias", $html, "main", 10)); $page->add_block(new Block("Edit Alias", $html, "main", 10));
} }
public function show_url_editor($url) { public function show_url_editor($url)
global $user; {
global $user;
$html = ' $html = '
<form method="POST" action="'.make_link("artist/url/edited/".$url['id']).'"> <form method="POST" action="'.make_link("artist/url/edited/".$url['id']).'">
'.$user->get_auth_html().' '.$user->get_auth_html().'
<label for="url">URL:</label> <label for="url">URL:</label>
<input type="text" name="url" value="'.$url['url'].'" /> <input type="text" name="url" id="url" value="'.$url['url'].'" />
<input type="hidden" name="urlID" value="'.$url['id'].'" /> <input type="hidden" name="urlID" value="'.$url['id'].'" />
<input type="submit" value="Submit" /> <input type="submit" value="Submit" />
</form> </form>
'; ';
global $page; global $page;
$page->add_block(new Block("Edit URL", $html, "main", 10)); $page->add_block(new Block("Edit URL", $html, "main", 10));
} }
public function show_member_editor($member) { public function show_member_editor($member)
global $user; {
global $user;
$html = ' $html = '
<form method="POST" action="'.make_link("artist/member/edited/".$member['id']).'"> <form method="POST" action="'.make_link("artist/member/edited/".$member['id']).'">
'.$user->get_auth_html().' '.$user->get_auth_html().'
<label for="member">Member name:</label> <label for="name">Member name:</label>
<input type="text" name="name" value="'.$member['name'].'" /> <input type="text" name="name" id="name" value="'.$member['name'].'" />
<input type="hidden" name="memberID" value="'.$member['id'].'" /> <input type="hidden" name="memberID" value="'.$member['id'].'" />
<input type="submit" value="Submit" /> <input type="submit" value="Submit" />
</form> </form>
'; ';
global $page; global $page;
$page->add_block(new Block("Edit Member", $html, "main", 10)); $page->add_block(new Block("Edit Member", $html, "main", 10));
} }
public function show_artist($artist, $aliases, $members, $urls, $images, $userIsLogged, $userIsAdmin) { public function show_artist($artist, $aliases, $members, $urls, $images, $userIsLogged, $userIsAdmin)
global $page; {
global $page;
$artist_link = "<a href='".make_link("post/list/".$artist['name']."/1")."'>".str_replace("_", " ", $artist['name'])."</a>"; $artist_link = "<a href='".make_link("post/list/".$artist['name']."/1")."'>".str_replace("_", " ", $artist['name'])."</a>";
$html = "<table id='poolsList' class='zebra'> $html = "<table id='poolsList' class='zebra'>
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th></th>"; <th></th>";
if ($userIsLogged) $html .= "<th></th>"; if ($userIsLogged) {
if ($userIsAdmin) $html .= "<th></th>"; $html .= "<th></th>";
}
if ($userIsAdmin) {
$html .= "<th></th>";
}
$html .= " <tr> $html .= " <tr>
</thead> </thead>
<tr> <tr>
<td class='left'>Name:</td> <td class='left'>Name:</td>
<td class='left'>".$artist_link."</td>"; <td class='left'>".$artist_link."</td>";
if ($userIsLogged) $html .= "<td></td>"; if ($userIsLogged) {
if ($userIsAdmin) $html .= "<td></td>"; $html .= "<td></td>";
$html .= "</tr>"; }
if ($userIsAdmin) {
$html .= "<td></td>";
}
$html .= "</tr>";
$html .= $this->render_aliases($aliases, $userIsLogged, $userIsAdmin); $html .= $this->render_aliases($aliases, $userIsLogged, $userIsAdmin);
$html .= $this->render_members($members, $userIsLogged, $userIsAdmin); $html .= $this->render_members($members, $userIsLogged, $userIsAdmin);
$html .= $this->render_urls($urls, $userIsLogged, $userIsAdmin); $html .= $this->render_urls($urls, $userIsLogged, $userIsAdmin);
$html .= "<tr> $html .= "<tr>
<td class='left'>Notes:</td> <td class='left'>Notes:</td>
<td class='left'>".$artist["notes"]."</td>"; <td class='left'>".$artist["notes"]."</td>";
if ($userIsLogged) $html .= "<td></td>"; if ($userIsLogged) {
if ($userIsAdmin) $html .= "<td></td>"; $html .= "<td></td>";
//TODO how will notes be edited? On edit artist? (should there be an editartist?) or on a editnotes? }
//same question for deletion if ($userIsAdmin) {
$html .= "</tr> $html .= "<td></td>";
}
//TODO how will notes be edited? On edit artist? (should there be an editartist?) or on a editnotes?
//same question for deletion
$html .= "</tr>
</table>"; </table>";
$page->set_title("Artist"); $page->set_title("Artist");
$page->set_heading("Artist"); $page->set_heading("Artist");
$page->add_block(new Block("Artist", $html, "main", 10)); $page->add_block(new Block("Artist", $html, "main", 10));
//we show the images for the artist //we show the images for the artist
$artist_images = ""; $artist_images = "";
foreach($images as $image) { foreach ($images as $image) {
$thumb_html = $this->build_thumb_html($image); $thumb_html = $this->build_thumb_html($image);
$artist_images .= '<span class="thumb">'. $artist_images .= '<span class="thumb">'.
'<a href="$image_link">'.$thumb_html.'</a>'. '<a href="$image_link">'.$thumb_html.'</a>'.
'</span>'; '</span>';
} }
$page->add_block(new Block("Artist Images", $artist_images, "main", 20)); $page->add_block(new Block("Artist Images", $artist_images, "main", 20));
} }
/** private function render_aliases(array $aliases, bool $userIsLogged, bool $userIsAdmin): string
* @param $aliases {
* @param $userIsLogged $html = "";
* @param $userIsAdmin if (count($aliases) > 0) {
* @return string $aliasViewLink = str_replace("_", " ", $aliases[0]['alias_name']); // no link anymore
*/ $aliasEditLink = "<a href='" . make_link("artist/alias/edit/" . $aliases[0]['alias_id']) . "'>Edit</a>";
private function render_aliases($aliases, $userIsLogged, $userIsAdmin) { $aliasDeleteLink = "<a href='" . make_link("artist/alias/delete/" . $aliases[0]['alias_id']) . "'>Delete</a>";
$html = "";
if(count($aliases) > 0) {
$aliasViewLink = str_replace("_", " ", $aliases[0]['alias_name']); // no link anymore
$aliasEditLink = "<a href='" . make_link("artist/alias/edit/" . $aliases[0]['alias_id']) . "'>Edit</a>";
$aliasDeleteLink = "<a href='" . make_link("artist/alias/delete/" . $aliases[0]['alias_id']) . "'>Delete</a>";
$html .= "<tr> $html .= "<tr>
<td class='left'>Aliases:</td> <td class='left'>Aliases:</td>
<td class='left'>" . $aliasViewLink . "</td>"; <td class='left'>" . $aliasViewLink . "</td>";
if ($userIsLogged) if ($userIsLogged) {
$html .= "<td class='left'>" . $aliasEditLink . "</td>"; $html .= "<td class='left'>" . $aliasEditLink . "</td>";
}
if ($userIsAdmin) if ($userIsAdmin) {
$html .= "<td class='left'>" . $aliasDeleteLink . "</td>"; $html .= "<td class='left'>" . $aliasDeleteLink . "</td>";
}
$html .= "</tr>"; $html .= "</tr>";
if (count($aliases) > 1) { if (count($aliases) > 1) {
for ($i = 1; $i < count($aliases); $i++) { for ($i = 1; $i < count($aliases); $i++) {
$aliasViewLink = str_replace("_", " ", $aliases[$i]['alias_name']); // no link anymore $aliasViewLink = str_replace("_", " ", $aliases[$i]['alias_name']); // no link anymore
$aliasEditLink = "<a href='" . make_link("artist/alias/edit/" . $aliases[$i]['alias_id']) . "'>Edit</a>"; $aliasEditLink = "<a href='" . make_link("artist/alias/edit/" . $aliases[$i]['alias_id']) . "'>Edit</a>";
$aliasDeleteLink = "<a href='" . make_link("artist/alias/delete/" . $aliases[$i]['alias_id']) . "'>Delete</a>"; $aliasDeleteLink = "<a href='" . make_link("artist/alias/delete/" . $aliases[$i]['alias_id']) . "'>Delete</a>";
$html .= "<tr> $html .= "<tr>
<td class='left'>&nbsp;</td> <td class='left'>&nbsp;</td>
<td class='left'>" . $aliasViewLink . "</td>"; <td class='left'>" . $aliasViewLink . "</td>";
if ($userIsLogged) if ($userIsLogged) {
$html .= "<td class='left'>" . $aliasEditLink . "</td>"; $html .= "<td class='left'>" . $aliasEditLink . "</td>";
if ($userIsAdmin) }
$html .= "<td class='left'>" . $aliasDeleteLink . "</td>"; if ($userIsAdmin) {
$html .= "<td class='left'>" . $aliasDeleteLink . "</td>";
}
$html .= "</tr>"; $html .= "</tr>";
} }
} }
} }
return $html; return $html;
} }
/** private function render_members(array $members, bool $userIsLogged, bool $userIsAdmin): string
* @param $members {
* @param $userIsLogged $html = "";
* @param $userIsAdmin if (count($members) > 0) {
* @return string $memberViewLink = str_replace("_", " ", $members[0]['name']); // no link anymore
*/ $memberEditLink = "<a href='" . make_link("artist/member/edit/" . $members[0]['id']) . "'>Edit</a>";
private function render_members($members, $userIsLogged, $userIsAdmin) { $memberDeleteLink = "<a href='" . make_link("artist/member/delete/" . $members[0]['id']) . "'>Delete</a>";
$html = "";
if(count($members) > 0) {
$memberViewLink = str_replace("_", " ", $members[0]['name']); // no link anymore
$memberEditLink = "<a href='" . make_link("artist/member/edit/" . $members[0]['id']) . "'>Edit</a>";
$memberDeleteLink = "<a href='" . make_link("artist/member/delete/" . $members[0]['id']) . "'>Delete</a>";
$html .= "<tr> $html .= "<tr>
<td class='left'>Members:</td> <td class='left'>Members:</td>
<td class='left'>" . $memberViewLink . "</td>"; <td class='left'>" . $memberViewLink . "</td>";
if ($userIsLogged) if ($userIsLogged) {
$html .= "<td class='left'>" . $memberEditLink . "</td>"; $html .= "<td class='left'>" . $memberEditLink . "</td>";
if ($userIsAdmin) }
$html .= "<td class='left'>" . $memberDeleteLink . "</td>"; if ($userIsAdmin) {
$html .= "<td class='left'>" . $memberDeleteLink . "</td>";
}
$html .= "</tr>"; $html .= "</tr>";
if (count($members) > 1) { if (count($members) > 1) {
for ($i = 1; $i < count($members); $i++) { for ($i = 1; $i < count($members); $i++) {
$memberViewLink = str_replace("_", " ", $members[$i]['name']); // no link anymore $memberViewLink = str_replace("_", " ", $members[$i]['name']); // no link anymore
$memberEditLink = "<a href='" . make_link("artist/member/edit/" . $members[$i]['id']) . "'>Edit</a>"; $memberEditLink = "<a href='" . make_link("artist/member/edit/" . $members[$i]['id']) . "'>Edit</a>";
$memberDeleteLink = "<a href='" . make_link("artist/member/delete/" . $members[$i]['id']) . "'>Delete</a>"; $memberDeleteLink = "<a href='" . make_link("artist/member/delete/" . $members[$i]['id']) . "'>Delete</a>";
$html .= "<tr> $html .= "<tr>
<td class='left'>&nbsp;</td> <td class='left'>&nbsp;</td>
<td class='left'>" . $memberViewLink . "</td>"; <td class='left'>" . $memberViewLink . "</td>";
if ($userIsLogged) if ($userIsLogged) {
$html .= "<td class='left'>" . $memberEditLink . "</td>"; $html .= "<td class='left'>" . $memberEditLink . "</td>";
if ($userIsAdmin) }
$html .= "<td class='left'>" . $memberDeleteLink . "</td>"; if ($userIsAdmin) {
$html .= "<td class='left'>" . $memberDeleteLink . "</td>";
}
$html .= "</tr>"; $html .= "</tr>";
} }
} }
} }
return $html; return $html;
} }
/** private function render_urls(array $urls, bool $userIsLogged, bool $userIsAdmin): string
* @param $urls {
* @param $userIsLogged $html = "";
* @param $userIsAdmin if (count($urls) > 0) {
* @return string $urlViewLink = "<a href='" . str_replace("_", " ", $urls[0]['url']) . "' target='_blank'>" . str_replace("_", " ", $urls[0]['url']) . "</a>";
*/ $urlEditLink = "<a href='" . make_link("artist/url/edit/" . $urls[0]['id']) . "'>Edit</a>";
private function render_urls($urls, $userIsLogged, $userIsAdmin) { $urlDeleteLink = "<a href='" . make_link("artist/url/delete/" . $urls[0]['id']) . "'>Delete</a>";
$html = "";
if(count($urls) > 0) {
$urlViewLink = "<a href='" . str_replace("_", " ", $urls[0]['url']) . "' target='_blank'>" . str_replace("_", " ", $urls[0]['url']) . "</a>";
$urlEditLink = "<a href='" . make_link("artist/url/edit/" . $urls[0]['id']) . "'>Edit</a>";
$urlDeleteLink = "<a href='" . make_link("artist/url/delete/" . $urls[0]['id']) . "'>Delete</a>";
$html .= "<tr> $html .= "<tr>
<td class='left'>URLs:</td> <td class='left'>URLs:</td>
<td class='left'>" . $urlViewLink . "</td>"; <td class='left'>" . $urlViewLink . "</td>";
if ($userIsLogged) if ($userIsLogged) {
$html .= "<td class='left'>" . $urlEditLink . "</td>"; $html .= "<td class='left'>" . $urlEditLink . "</td>";
}
if ($userIsAdmin) if ($userIsAdmin) {
$html .= "<td class='left'>" . $urlDeleteLink . "</td>"; $html .= "<td class='left'>" . $urlDeleteLink . "</td>";
}
$html .= "</tr>"; $html .= "</tr>";
if (count($urls) > 1) { if (count($urls) > 1) {
for ($i = 1; $i < count($urls); $i++) { for ($i = 1; $i < count($urls); $i++) {
$urlViewLink = "<a href='" . str_replace("_", " ", $urls[$i]['url']) . "' target='_blank'>" . str_replace("_", " ", $urls[$i]['url']) . "</a>"; $urlViewLink = "<a href='" . str_replace("_", " ", $urls[$i]['url']) . "' target='_blank'>" . str_replace("_", " ", $urls[$i]['url']) . "</a>";
$urlEditLink = "<a href='" . make_link("artist/url/edit/" . $urls[$i]['id']) . "'>Edit</a>"; $urlEditLink = "<a href='" . make_link("artist/url/edit/" . $urls[$i]['id']) . "'>Edit</a>";
$urlDeleteLink = "<a href='" . make_link("artist/url/delete/" . $urls[$i]['id']) . "'>Delete</a>"; $urlDeleteLink = "<a href='" . make_link("artist/url/delete/" . $urls[$i]['id']) . "'>Delete</a>";
$html .= "<tr> $html .= "<tr>
<td class='left'>&nbsp;</td> <td class='left'>&nbsp;</td>
<td class='left'>" . $urlViewLink . "</td>"; <td class='left'>" . $urlViewLink . "</td>";
if ($userIsLogged) if ($userIsLogged) {
$html .= "<td class='left'>" . $urlEditLink . "</td>"; $html .= "<td class='left'>" . $urlEditLink . "</td>";
}
if ($userIsAdmin) if ($userIsAdmin) {
$html .= "<td class='left'>" . $urlDeleteLink . "</td>"; $html .= "<td class='left'>" . $urlDeleteLink . "</td>";
}
$html .= "</tr>"; $html .= "</tr>";
} }
return $html; return $html;
} }
} }
return $html; return $html;
} }
public function get_help_html()
{
return '<p>Search for images with a particular artist.</p>
<div class="command_example">
<pre>artist=leonardo</pre>
<p>Returns images with the artist "leonardo".</p>
</div>
';
}
} }

View file

@ -0,0 +1,7 @@
<?php declare(strict_types=1);
abstract class AutoTaggerConfig
{
public const VERSION = "ext_auto_tagger_ver";
public const ITEMS_PER_PAGE = "auto_tagger_items_per_page";
}

12
ext/auto_tagger/info.php Normal file
View file

@ -0,0 +1,12 @@
<?php declare(strict_types=1);
class AutoTaggerInfo extends ExtensionInfo
{
public const KEY = "auto_tagger";
public $key = self::KEY;
public $name = "Auto-Tagger";
public $authors = ["Matthew Barbour"=>"matthew@darkholme.net"];
public $license = self::LICENSE_WTFPL;
public $description = "Provides several automatic tagging functions";
}

331
ext/auto_tagger/main.php Normal file
View file

@ -0,0 +1,331 @@
<?php declare(strict_types=1);
require_once 'config.php';
use MicroCRUD\ActionColumn;
use MicroCRUD\TextColumn;
use MicroCRUD\Table;
class AutoTaggerTable extends Table
{
public function __construct(\FFSPHP\PDO $db)
{
parent::__construct($db);
$this->table = "auto_tagger";
$this->base_query = "SELECT * FROM auto_tag";
$this->primary_key = "tag";
$this->size = 100;
$this->limit = 1000000;
$this->set_columns([
new TextColumn("tag", "Tag"),
new TextColumn("additional_tags", "Additional Tags"),
new ActionColumn("tag"),
]);
$this->order_by = ["tag"];
$this->table_attrs = ["class" => "zebra"];
}
}
class AddAutoTagEvent extends Event
{
/** @var string */
public $tag;
/** @var string */
public $additional_tags;
public function __construct(string $tag, string $additional_tags)
{
parent::__construct();
$this->tag = trim($tag);
$this->additional_tags = trim($additional_tags);
}
}
class DeleteAutoTagEvent extends Event
{
public $tag;
public function __construct(string $tag)
{
parent::__construct();
$this->tag = $tag;
}
}
class AddAutoTagException extends SCoreException
{
}
class AutoTagger extends Extension
{
/** @var AutoTaggerTheme */
protected $theme;
public function onPageRequest(PageRequestEvent $event)
{
global $config, $database, $page, $user;
if ($event->page_matches("auto_tag")) {
if ($event->get_arg(0) == "add") {
if ($user->can(Permissions::MANAGE_AUTO_TAG)) {
$user->ensure_authed();
$input = validate_input(["c_tag"=>"string", "c_additional_tags"=>"string"]);
try {
send_event(new AddAutoTagEvent($input['c_tag'], $input['c_additional_tags']));
$page->set_mode(PageMode::REDIRECT);
$page->set_redirect(make_link("auto_tag/list"));
} catch (AddAutoTagException $ex) {
$this->theme->display_error(500, "Error adding auto-tag", $ex->getMessage());
}
}
} elseif ($event->get_arg(0) == "remove") {
if ($user->can(Permissions::MANAGE_AUTO_TAG)) {
$user->ensure_authed();
$input = validate_input(["d_tag"=>"string"]);
send_event(new DeleteAutoTagEvent($input['d_tag']));
$page->set_mode(PageMode::REDIRECT);
$page->set_redirect(make_link("auto_tag/list"));
}
} elseif ($event->get_arg(0) == "list") {
$t = new AutoTaggerTable($database->raw_db());
$t->token = $user->get_auth_token();
$t->inputs = $_GET;
$t->size = $config->get_int(AutoTaggerConfig::ITEMS_PER_PAGE, 30);
if ($user->can(Permissions::MANAGE_AUTO_TAG)) {
$t->create_url = make_link("auto_tag/add");
$t->delete_url = make_link("auto_tag/remove");
}
$this->theme->display_auto_tagtable($t->table($t->query()), $t->paginator());
} elseif ($event->get_arg(0) == "export") {
$page->set_mode(PageMode::DATA);
$page->set_type("text/csv");
$page->set_filename("auto_tag.csv");
$page->set_data($this->get_auto_tag_csv($database));
} elseif ($event->get_arg(0) == "import") {
if ($user->can(Permissions::MANAGE_AUTO_TAG)) {
if (count($_FILES) > 0) {
$tmp = $_FILES['auto_tag_file']['tmp_name'];
$contents = file_get_contents($tmp);
$count = $this->add_auto_tag_csv($database, $contents);
log_info(AutoTaggerInfo::KEY, "Imported $count auto-tag definitions from file from file", "Imported $count auto-tag definitions");
$page->set_mode(PageMode::REDIRECT);
$page->set_redirect(make_link("auto_tag/list"));
} else {
$this->theme->display_error(400, "No File Specified", "You have to upload a file");
}
} else {
$this->theme->display_error(401, "Admins Only", "Only admins can edit the auto-tag list");
}
}
}
}
public function onPageSubNavBuilding(PageSubNavBuildingEvent $event)
{
if ($event->parent=="tags") {
$event->add_nav_link("auto_tag", new Link('auto_tag/list'), "Auto-Tag", NavLink::is_active(["auto_tag"]));
}
}
public function onDatabaseUpgrade(DatabaseUpgradeEvent $event)
{
global $database;
// Create the database tables
if ($this->get_version(AutoTaggerConfig::VERSION) < 1) {
$database->create_table("auto_tag", "
tag VARCHAR(128) NOT NULL PRIMARY KEY,
additional_tags VARCHAR(2000) NOT NULL
");
if ($database->get_driver_name() == DatabaseDriver::PGSQL) {
$database->execute('CREATE INDEX auto_tag_lower_tag_idx ON auto_tag ((lower(tag)))');
}
$this->set_version(AutoTaggerConfig::VERSION, 1);
log_info(AutoTaggerInfo::KEY, "extension installed");
}
}
public function onTagSet(TagSetEvent $event)
{
$results = $this->apply_auto_tags($event->tags);
if (!empty($results)) {
$event->tags = $results;
}
}
public function onAddAutoTag(AddAutoTagEvent $event)
{
global $page;
$this->add_auto_tag($event->tag, $event->additional_tags);
$page->flash("Added Auto-Tag");
}
public function onDeleteAutoTag(DeleteAutoTagEvent $event)
{
$this->remove_auto_tag($event->tag);
}
public function onUserBlockBuilding(UserBlockBuildingEvent $event)
{
global $user;
if ($user->can(Permissions::MANAGE_AUTO_TAG)) {
$event->add_link("Auto-Tag Editor", make_link("auto_tag/list"));
}
}
private function get_auto_tag_csv(Database $database): string
{
$csv = "";
$pairs = $database->get_pairs("SELECT tag, additional_tags FROM auto_tag ORDER BY tag");
foreach ($pairs as $old => $new) {
$csv .= "\"$old\",\"$new\"\n";
}
return $csv;
}
private function add_auto_tag_csv(Database $database, string $csv): int
{
$csv = str_replace("\r", "\n", $csv);
$i = 0;
foreach (explode("\n", $csv) as $line) {
$parts = str_getcsv($line);
if (count($parts) == 2) {
try {
send_event(new AddAutoTagEvent($parts[0], $parts[1]));
$i++;
} catch (AddAutoTagException $ex) {
$this->theme->display_error(500, "Error adding auto-tags", $ex->getMessage());
}
}
}
return $i;
}
private function add_auto_tag(string $tag, string $additional_tags)
{
global $database;
if ($database->exists("SELECT * FROM auto_tag WHERE LOWER(tag)=LOWER(:tag)", ["tag"=>$tag])) {
throw new AutoTaggerException("Auto-Tag is already set for that tag");
} else {
$tag = Tag::sanitize($tag);
$additional_tags = Tag::explode($additional_tags);
$database->execute(
"INSERT INTO auto_tag(tag, additional_tags) VALUES(:tag, :additional_tags)",
["tag"=>$tag, "additional_tags"=>Tag::implode($additional_tags)]
);
log_info(
AutoTaggerInfo::KEY,
"Added auto-tag for {$tag} -> {".implode(" ", $additional_tags)."}"
);
// Now we apply it to existing items
$this->apply_new_auto_tag($tag);
}
}
private function update_auto_tag(string $tag, string $additional_tags): bool
{
global $database;
$result = $database->get_row("SELECT * FROM auto_tag WHERE LOWER(tag)=LOWER(:tag)", ["tag"=>$tag]);
if ($result===null) {
throw new AutoTaggerException("Auto-tag not set for $tag, can't update");
} else {
$additional_tags = Tag::explode($additional_tags);
$current_additional_tags = Tag::explode($result["additional_tags"]);
if (!Tag::compare($additional_tags, $current_additional_tags)) {
$database->execute(
"UPDATE auto_tag SET additional_tags = :additional_tags WHERE LOWER(tag)=LOWER(:tag)",
["tag"=>$tag, "additional_tags"=>Tag::implode($additional_tags)]
);
log_info(
AutoTaggerInfo::KEY,
"Updated auto-tag for {$tag} -> {".implode(" ", $additional_tags)."}",
"Updated Auto-Tag"
);
// Now we apply it to existing items
$this->apply_new_auto_tag($tag);
return true;
}
}
return false;
}
private function apply_new_auto_tag(string $tag)
{
global $database;
$tag_id = $database->get_one("SELECT id FROM tags WHERE LOWER(tag) = LOWER(:tag)", ["tag"=>$tag]);
if (!empty($tag_id)) {
$image_ids = $database->get_col_iterable("SELECT image_id FROM image_tags WHERE tag_id = :tag_id", ["tag_id"=>$tag_id]);
foreach ($image_ids as $image_id) {
$image = Image::by_id($image_id);
$event = new TagSetEvent($image, $image->get_tag_array());
send_event($event);
}
}
}
private function remove_auto_tag(String $tag)
{
global $database;
$database->execute("DELETE FROM auto_tag WHERE LOWER(tag)=LOWER(:tag)", ["tag" => $tag]);
}
/**
* #param string[] $tags_mixed
*/
private function apply_auto_tags(array $tags_mixed): ?array
{
global $database;
while (true) {
$new_tags = [];
foreach ($tags_mixed as $tag) {
$additional_tags = $database->get_one(
"SELECT additional_tags FROM auto_tag WHERE LOWER(tag) = LOWER(:input)",
["input" => $tag]
);
if (!empty($additional_tags)) {
$additional_tags = explode(" ", $additional_tags);
$new_tags = array_merge(
$new_tags,
array_udiff($additional_tags, $tags_mixed, 'strcasecmp')
);
}
}
if (empty($new_tags)) {
break;
}
$tags_mixed = array_merge($tags_mixed, $new_tags);
}
$results = array_intersect_key(
$tags_mixed,
array_unique(array_map('strtolower', $tags_mixed))
);
return $results;
}
/**
* Get the priority for this extension.
*
*/
public function get_priority(): int
{
return 30;
}
}

58
ext/auto_tagger/test.php Normal file
View file

@ -0,0 +1,58 @@
<?php declare(strict_types=1);
class AutoTaggerTest extends ShimmiePHPUnitTestCase
{
public function testAutoTaggerList()
{
$this->get_page('auto_tag/list');
$this->assert_response(200);
$this->assert_title("Auto-Tag");
}
public function testAutoTaggerListReadOnly()
{
$this->log_in_as_user();
$this->get_page('auto_tag/list');
$this->assert_title("Auto-Tag");
$this->assert_no_text("value=\"Add\"");
$this->log_out();
$this->get_page('auto_tag/list');
$this->assert_title("Auto-Tag");
$this->assert_no_text("value=\"Add\"");
}
public function testAutoTagger()
{
$this->log_in_as_admin();
$this->get_page("auto_tag/export/auto_tag.csv");
$this->assert_no_text("test1");
send_event(new AddAutoTagEvent("test1", "test2"));
$this->get_page('auto_tag/list');
$this->assert_text("test1");
$this->assert_text("test2");
$this->get_page("auto_tag/export/auto_tag.csv");
$this->assert_text('"test1","test2"');
$image_id = $this->post_image("tests/pbx_screenshot.jpg", "test1");
$this->get_page("post/view/$image_id"); # check that the tag has been replaced
$this->assert_title("Image $image_id: test1 test2");
$this->delete_image($image_id);
send_event(new AddAutoTagEvent("test2", "test3"));
$image_id = $this->post_image("tests/pbx_screenshot.jpg", "test1");
$this->get_page("post/view/$image_id"); # check that the tag has been replaced
$this->assert_title("Image $image_id: test1 test2 test3");
$this->delete_image($image_id);
send_event(new DeleteAutoTagEvent("test1"));
send_event(new DeleteAutoTagEvent("test2"));
$this->get_page('auto_tag/list');
$this->assert_title("Auto-Tag");
$this->assert_no_text("test1");
$this->assert_no_text("test2");
$this->assert_no_text("test3");
}
}

36
ext/auto_tagger/theme.php Normal file
View file

@ -0,0 +1,36 @@
<?php declare(strict_types=1);
class AutoTaggerTheme extends Themelet
{
/**
* Show a page of auto-tag definitions.
*
* Note: $can_manage = whether things like "add new alias" should be shown
*/
public function display_auto_tagtable($table, $paginator): void
{
global $page, $user;
$can_manage = $user->can(Permissions::MANAGE_AUTO_TAG);
$html = "
$table
$paginator
<p><a href='".make_link("auto_tag/export/auto_tag.csv")."' download='auto_tag.csv'>Download as CSV</a></p>
";
$bulk_html = "
".make_form(make_link("auto_tag/import"), 'post', true)."
<input type='file' name='auto_tag_file'>
<input type='submit' value='Upload List'>
</form>
";
$page->set_title("Auto-Tag List");
$page->set_heading("Auto-Tag List");
$page->add_block(new NavBlock());
$page->add_block(new Block("Auto-Tag", $html));
if ($can_manage) {
$page->add_block(new Block("Bulk Upload", $bulk_html, "main", 51));
}
}
}

11
ext/autocomplete/info.php Normal file
View file

@ -0,0 +1,11 @@
<?php declare(strict_types=1);
class AutoCompleteInfo extends ExtensionInfo
{
public const KEY = "autocomplete";
public $key = self::KEY;
public $name = "Autocomplete";
public $authors = ["Daku"=>"admin@codeanimu.net"];
public $description = "Adds autocomplete to search & tagging.";
}

View file

@ -14,8 +14,7 @@ ul.tagit li.tagit-choice {
-webkit-border-radius: 6px; -webkit-border-radius: 6px;
border: 1px solid #CAD8F3; border: 1px solid #CAD8F3;
background: none; background: #DEE7F8 none;
background-color: #DEE7F8;
font-weight: normal; font-weight: normal;
} }

View file

@ -1,47 +1,69 @@
<?php <?php declare(strict_types=1);
/*
* Name: Autocomplete
* Author: Daku <admin@codeanimu.net>
* Description: Adds autocomplete to search & tagging.
*/
class AutoComplete extends Extension { class AutoComplete extends Extension
public function get_priority() {return 30;} // before Home {
/** @var AutoCompleteTheme */
protected $theme;
public function onPageRequest(PageRequestEvent $event) { public function get_priority(): int
global $page, $database; {
return 30;
} // before Home
if($event->page_matches("api/internal/autocomplete")) { public function onPageRequest(PageRequestEvent $event)
if(!isset($_GET["s"])) return; {
global $cache, $page, $database;
//$limit = 0; if ($event->page_matches("api/internal/autocomplete")) {
$cache_key = "autocomplete-" . strtolower($_GET["s"]); if (!isset($_GET["s"])) {
$limitSQL = ""; return;
$SQLarr = array("search"=>$_GET["s"]."%"); }
if(isset($_GET["limit"]) && $_GET["limit"] !== 0){
$limitSQL = "LIMIT :limit";
$SQLarr['limit'] = $_GET["limit"];
$cache_key .= "-" . $_GET["limit"];
}
$res = $database->cache->get($cache_key); $page->set_mode(PageMode::DATA);
if(!$res) { $page->set_type("application/json");
$res = $database->get_pairs($database->scoreql_to_sql("
$s = strtolower($_GET["s"]);
if (
$s == '' ||
$s[0] == '_' ||
$s[0] == '%' ||
strlen($s) > 32
) {
$page->set_data("{}");
return;
}
//$limit = 0;
$cache_key = "autocomplete-$s";
$limitSQL = "";
$s = str_replace('_', '\_', $s);
$s = str_replace('%', '\%', $s);
$SQLarr = ["search"=>"$s%"]; #, "cat_search"=>"%:$s%"];
if (isset($_GET["limit"]) && $_GET["limit"] !== 0) {
$limitSQL = "LIMIT :limit";
$SQLarr['limit'] = $_GET["limit"];
$cache_key .= "-" . $_GET["limit"];
}
$res = $cache->get($cache_key);
if (!$res) {
$res = $database->get_pairs(
"
SELECT tag, count SELECT tag, count
FROM tags FROM tags
WHERE SCORE_STRNORM(tag) LIKE SCORE_STRNORM(:search) WHERE LOWER(tag) LIKE LOWER(:search)
-- OR LOWER(tag) LIKE LOWER(:cat_search)
AND count > 0 AND count > 0
ORDER BY count DESC ORDER BY count DESC
$limitSQL"), $SQLarr $limitSQL",
); $SQLarr
$database->cache->set($cache_key, $res, 600); );
} $cache->set($cache_key, $res, 600);
}
$page->set_mode("data"); $page->set_data(json_encode($res));
$page->set_type("application/json"); }
$page->set_data(json_encode($res));
}
$this->theme->build_autocomplete($page); $this->theme->build_autocomplete($page);
} }
} }

View file

@ -1,7 +1,7 @@
$(function(){ document.addEventListener('DOMContentLoaded', () => {
var metatags = ['order:id', 'order:width', 'order:height', 'order:filesize', 'order:filename']; var metatags = ['order:id', 'order:width', 'order:height', 'order:filesize', 'order:filename'];
$('[name=search]').tagit({ $('[name="search"]').tagit({
singleFieldDelimiter: ' ', singleFieldDelimiter: ' ',
beforeTagAdded: function(event, ui) { beforeTagAdded: function(event, ui) {
if(metatags.indexOf(ui.tagLabel) !== -1) { if(metatags.indexOf(ui.tagLabel) !== -1) {
@ -51,7 +51,7 @@ $(function(){
); );
}, },
error : function (request, status, error) { error : function (request, status, error) {
alert(error); console.log(error);
} }
}); });
}, },
@ -66,7 +66,7 @@ $(function(){
if(keyCode == 32) { if(keyCode == 32) {
e.preventDefault(); e.preventDefault();
$('[name=search]').tagit('createTag', $(this).val()); $('.autocomplete_tags').tagit('createTag', $(this).val());
$(this).autocomplete('close'); $(this).autocomplete('close');
} else if (keyCode == 9) { } else if (keyCode == 9) {
e.preventDefault(); e.preventDefault();

14
ext/autocomplete/test.php Normal file
View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
class AutoCompleteTest extends ShimmiePHPUnitTestCase
{
public function testAuth()
{
send_event(new UserLoginEvent(User::by_name(self::$anon_name)));
$page = $this->get_page('api/internal/autocomplete', ["s"=>"not-a-tag"]);
$this->assertEquals(200, $page->code);
$this->assertEquals(PageMode::DATA, $page->mode);
$this->assertEquals("[]", $page->data);
}
}

View file

@ -1,13 +1,15 @@
<?php <?php declare(strict_types=1);
class AutoCompleteTheme extends Themelet { class AutoCompleteTheme extends Themelet
public function build_autocomplete(Page $page) { {
$base_href = get_base_href(); public function build_autocomplete(Page $page)
// TODO: AJAX test and fallback. {
$base_href = get_base_href();
// TODO: AJAX test and fallback.
$page->add_html_header("<script src='$base_href/ext/autocomplete/lib/jquery-ui.min.js' type='text/javascript'></script>"); $page->add_html_header("<script defer src='$base_href/ext/autocomplete/lib/jquery-ui.min.js' type='text/javascript'></script>");
$page->add_html_header("<script src='$base_href/ext/autocomplete/lib/tag-it.min.js' type='text/javascript'></script>"); $page->add_html_header("<script defer src='$base_href/ext/autocomplete/lib/tag-it.min.js' type='text/javascript'></script>");
$page->add_html_header('<link rel="stylesheet" type="text/css" href="//ajax.googleapis.com/ajax/libs/jqueryui/1/themes/flick/jquery-ui.css">'); $page->add_html_header('<link rel="stylesheet" type="text/css" href="//ajax.googleapis.com/ajax/libs/jqueryui/1/themes/flick/jquery-ui.css">');
$page->add_html_header("<link rel='stylesheet' type='text/css' href='$base_href/ext/autocomplete/lib/jquery.tagit.css' />"); $page->add_html_header("<link rel='stylesheet' type='text/css' href='$base_href/ext/autocomplete/lib/jquery.tagit.css' />");
} }
} }

26
ext/ban_words/info.php Normal file
View file

@ -0,0 +1,26 @@
<?php declare(strict_types=1);
class BanWordsInfo extends ExtensionInfo
{
public const KEY = "ban_words";
public $key = self::KEY;
public $name = "Comment Word Ban";
public $url = self::SHIMMIE_URL;
public $authors = self::SHISH_AUTHOR;
public $license = self::LICENSE_GPLV2;
public $description = "For stopping spam and other comment abuse";
public $documentation =
"Allows an administrator to ban certain words
from comments. This can be a very simple but effective way
of stopping spam; just add \"viagra\", \"porn\", etc to the
banned words list.
<p>Regex bans are also supported, allowing more complicated
bans like <code>/http:.*\.cn\//</code> to block links to
chinese websites, or <code>/.*?http.*?http.*?http.*?http.*?/</code>
to block comments with four (or more) links in.
<p>Note that for non-regex matches, only whole words are
matched, eg banning \"sex\" would block the comment \"get free
sex call this number\", but allow \"This is a photo of Bob
from Essex\"";
}

View file

@ -1,29 +1,11 @@
<?php <?php declare(strict_types=1);
/*
* Name: Comment Word Ban
* Author: Shish <webmaster@shishnet.org>
* Link: http://code.shishnet.org/shimmie2/
* License: GPLv2
* Description: For stopping spam and other comment abuse
* Documentation:
* Allows an administrator to ban certain words
* from comments. This can be a very simple but effective way
* of stopping spam; just add "viagra", "porn", etc to the
* banned words list.
* <p>Regex bans are also supported, allowing more complicated
* bans like <code>/http:.*\.cn\//</code> to block links to
* chinese websites, or <code>/.*?http.*?http.*?http.*?http.*?/</code>
* to block comments with four (or more) links in.
* <p>Note that for non-regex matches, only whole words are
* matched, eg banning "sex" would block the comment "get free
* sex call this number", but allow "This is a photo of Bob
* from Essex"
*/
class BanWords extends Extension { class BanWords extends Extension
public function onInitExt(InitExtEvent $event) { {
global $config; public function onInitExt(InitExtEvent $event)
$config->set_default_string('banned_words', " {
global $config;
$config->set_default_string('banned_words', "
a href= a href=
anal anal
blowjob blowjob
@ -51,86 +33,87 @@ very nice site
viagra viagra
xanax xanax
"); ");
} }
public function onCommentPosting(CommentPostingEvent $event) { public function onCommentPosting(CommentPostingEvent $event)
global $user; {
if(!$user->can("bypass_comment_checks")) { global $user;
$this->test_text($event->comment, new CommentPostingException("Comment contains banned terms")); if (!$user->can(Permissions::BYPASS_COMMENT_CHECKS)) {
} $this->test_text($event->comment, new CommentPostingException("Comment contains banned terms"));
} }
}
public function onSourceSet(SourceSetEvent $event) { public function onSourceSet(SourceSetEvent $event)
$this->test_text($event->source, new SCoreException("Source contains banned terms")); {
} $this->test_text($event->source, new SCoreException("Source contains banned terms"));
}
public function onTagSet(TagSetEvent $event) { public function onTagSet(TagSetEvent $event)
$this->test_text(Tag::implode($event->tags), new SCoreException("Tags contain banned terms")); {
} $this->test_text(Tag::implode($event->tags), new SCoreException("Tags contain banned terms"));
}
public function onSetupBuilding(SetupBuildingEvent $event) { public function onSetupBuilding(SetupBuildingEvent $event)
$sb = new SetupBlock("Banned Phrases"); {
$sb->add_label("One per line, lines that start with slashes are treated as regex<br/>"); $sb = new SetupBlock("Banned Phrases");
$sb->add_longtext_option("banned_words"); $sb->add_label("One per line, lines that start with slashes are treated as regex<br/>");
$failed = array(); $sb->add_longtext_option("banned_words");
foreach($this->get_words() as $word) { $failed = [];
if($word[0] == '/') { foreach ($this->get_words() as $word) {
if(preg_match($word, "") === false) { if ($word[0] == '/') {
$failed[] = $word; if (preg_match($word, "") === false) {
} $failed[] = $word;
} }
} }
if($failed) { }
$sb->add_label("Failed regexes: ".join(", ", $failed)); if ($failed) {
} $sb->add_label("Failed regexes: ".join(", ", $failed));
$event->panel->add_block($sb); }
} $event->panel->add_block($sb);
}
/** /**
* Throws if the comment contains banned words. * Throws if the comment contains banned words.
* @param string $comment */
* @param CommentPostingException|SCoreException $ex private function test_text(string $comment, SCoreException $ex): void
* @throws CommentPostingException|SCoreException if the comment contains banned words. {
*/ $comment = strtolower($comment);
private function test_text($comment, $ex) {
$comment = strtolower($comment);
foreach($this->get_words() as $word) { foreach ($this->get_words() as $word) {
if($word[0] == '/') { if ($word[0] == '/') {
// lines that start with slash are regex // lines that start with slash are regex
if(preg_match($word, $comment) === 1) { if (preg_match($word, $comment) === 1) {
throw $ex; throw $ex;
} }
} } else {
else { // other words are literal
// other words are literal if (strpos($comment, $word) !== false) {
if(strpos($comment, $word) !== false) { throw $ex;
throw $ex; }
} }
} }
} }
}
/** private function get_words(): array
* @return string[] {
*/ global $config;
private function get_words() { $words = [];
global $config;
$words = array();
$banned = $config->get_string("banned_words"); $banned = $config->get_string("banned_words");
foreach(explode("\n", $banned) as $word) { foreach (explode("\n", $banned) as $word) {
$word = trim(strtolower($word)); $word = trim(strtolower($word));
if(strlen($word) == 0) { if (strlen($word) == 0) {
// line is blank // line is blank
continue; continue;
} }
$words[] = $word; $words[] = $word;
} }
return $words; return $words;
} }
public function get_priority() {return 30;} public function get_priority(): int
{
return 30;
}
} }

View file

@ -1,33 +1,34 @@
<?php <?php declare(strict_types=1);
class BanWordsTest extends ShimmiePHPUnitTestCase { class BanWordsTest extends ShimmiePHPUnitTestCase
public function check_blocked($image_id, $words) { {
global $user; public function check_blocked($image_id, $words)
try { {
send_event(new CommentPostingEvent($image_id, $user, $words)); global $user;
$this->fail("Exception not thrown"); try {
} send_event(new CommentPostingEvent($image_id, $user, $words));
catch(CommentPostingException $e) { $this->fail("Exception not thrown");
$this->assertEquals($e->getMessage(), "Comment contains banned terms"); } catch (CommentPostingException $e) {
} $this->assertEquals($e->getMessage(), "Comment contains banned terms");
} }
}
public function testWordBan() { public function testWordBan()
global $config; {
$config->set_string("banned_words", "viagra\nporn\n\n/http:.*\.cn\//"); global $config;
$config->set_string("banned_words", "viagra\nporn\n\n/http:.*\.cn\//");
$this->log_in_as_user(); $this->log_in_as_user();
$image_id = $this->post_image("tests/pbx_screenshot.jpg", "pbx computer screenshot"); $image_id = $this->post_image("tests/pbx_screenshot.jpg", "pbx computer screenshot");
$this->check_blocked($image_id, "kittens and viagra"); $this->check_blocked($image_id, "kittens and viagra");
$this->check_blocked($image_id, "kittens and ViagrA"); $this->check_blocked($image_id, "kittens and ViagrA");
$this->check_blocked($image_id, "kittens and viagra!"); $this->check_blocked($image_id, "kittens and viagra!");
$this->check_blocked($image_id, "some link to http://something.cn/"); $this->check_blocked($image_id, "some link to http://something.cn/");
$this->get_page('comment/list'); $this->get_page('comment/list');
$this->assert_title('Comments'); $this->assert_title('Comments');
$this->assert_no_text('viagra'); $this->assert_no_text('viagra');
$this->assert_no_text('ViagrA'); $this->assert_no_text('ViagrA');
$this->assert_no_text('http://something.cn/'); $this->assert_no_text('http://something.cn/');
} }
} }

32
ext/bbcode/info.php Normal file
View file

@ -0,0 +1,32 @@
<?php declare(strict_types=1);
class BBCodeInfo extends ExtensionInfo
{
public const KEY = "bbcode";
public $key = self::KEY;
public $name = "BBCode";
public $url = self::SHIMMIE_URL;
public $authors = self::SHISH_AUTHOR;
public $license = self::LICENSE_GPLV2;
public $core = true;
public $description = "Turns BBCode into HTML";
public $documentation =
" Supported tags:
<ul>
<li>[img]url[/img]
<li>[url]<a href=\"{self::SHIMMIE_URL}\">http://code.shishnet.org/</a>[/url]
<li>[email]<a href=\"mailto:{self::SHISH_EMAIL}\">webmaster@shishnet.org</a>[/email]
<li>[b]<b>bold</b>[/b]
<li>[i]<i>italic</i>[/i]
<li>[u]<u>underline</u>[/u]
<li>[s]<s>strikethrough</s>[/s]
<li>[sup]<sup>superscript</sup>[/sup]
<li>[sub]<sub>subscript</sub>[/sub]
<li>[[wiki article]]
<li>[[wiki article|with some text]]
<li>[quote]text[/quote]
<li>[quote=Username]text[/quote]
<li>&gt;&gt;123 (link to image #123)
</ul>";
}

View file

@ -1,184 +1,162 @@
<?php <?php declare(strict_types=1);
/**
* Name: BBCode
* Author: Shish <webmaster@shishnet.org>
* Link: http://code.shishnet.org/shimmie2/
* License: GPLv2
* Description: Turns BBCode into HTML
* Documentation:
* Supported tags:
* <ul>
* <li>[img]url[/img]
* <li>[url]<a href="http://code.shishnet.org/shimmie2/">http://code.shishnet.org/</a>[/url]
* <li>[email]<a href="mailto:webmaster@shishnet.org">webmaster@shishnet.org</a>[/email]
* <li>[b]<b>bold</b>[/b]
* <li>[i]<i>italic</i>[/i]
* <li>[u]<u>underline</u>[/u]
* <li>[s]<s>strikethrough</s>[/s]
* <li>[sup]<sup>superscript</sup>[/sup]
* <li>[sub]<sub>subscript</sub>[/sub]
* <li>[[wiki article]]
* <li>[[wiki article|with some text]]
* <li>[quote]text[/quote]
* <li>[quote=Username]text[/quote]
* <li>&gt;&gt;123 (link to image #123)
* </ul>
*/
class BBCode extends FormatterExtension {
/**
* @param string $text
* @return string
*/
public function format(/*string*/ $text) {
$text = $this->extract_code($text);
foreach(array(
"b", "i", "u", "s", "sup", "sub", "h1", "h2", "h3", "h4",
) as $el) {
$text = preg_replace("!\[$el\](.*?)\[/$el\]!s", "<$el>$1</$el>", $text);
}
$text = preg_replace('!^&gt;&gt;([^\d].+)!', '<blockquote><small>$1</small></blockquote>', $text);
$text = preg_replace('!&gt;&gt;(\d+)(#c?\d+)?!s', '<a class="shm-clink" data-clink-sel="$2" href="'.make_link('post/view/$1$2').'">&gt;&gt;$1$2</a>', $text);
$text = preg_replace('!\[anchor=(.*?)\](.*?)\[/anchor\]!s', '<span class="anchor">$2 <a class="alink" href="#bb-$1" name="bb-$1" title="link to this anchor"> ¶ </a></span>', $text); // add "bb-" to avoid clashing with eg #top
$text = preg_replace('!\[url=site://(.*?)(#c\d+)?\](.*?)\[/url\]!s', '<a class="shm-clink" data-clink-sel="$2" href="'.make_link('$1$2').'">$3</a>', $text);
$text = preg_replace('!\[url\]site://(.*?)(#c\d+)?\[/url\]!s', '<a class="shm-clink" data-clink-sel="$2" href="'.make_link('$1$2').'">$1$2</a>', $text);
$text = preg_replace('!\[url=((?:https?|ftp|irc|mailto)://.*?)\](.*?)\[/url\]!s', '<a href="$1">$2</a>', $text);
$text = preg_replace('!\[url\]((?:https?|ftp|irc|mailto)://.*?)\[/url\]!s', '<a href="$1">$1</a>', $text);
$text = preg_replace('!\[email\](.*?)\[/email\]!s', '<a href="mailto:$1">$1</a>', $text);
$text = preg_replace('!\[img\](https?:\/\/.*?)\[/img\]!s', '<img src="$1">', $text);
$text = preg_replace('!\[\[([^\|\]]+)\|([^\]]+)\]\]!s', '<a href="'.make_link('wiki/$1').'">$2</a>', $text);
$text = preg_replace('!\[\[([^\]]+)\]\]!s', '<a href="'.make_link('wiki/$1').'">$1</a>', $text);
$text = preg_replace("!\n\s*\n!", "\n\n", $text);
$text = str_replace("\n", "\n<br>", $text);
$text = preg_replace("/\[quote\](.*?)\[\/quote\]/s", "<blockquote><small>\\1</small></blockquote>", $text);
$text = preg_replace("/\[quote=(.*?)\](.*?)\[\/quote\]/s", "<blockquote><em>\\1 said:</em><br><small>\\2</small></blockquote>", $text);
while(preg_match("/\[list\](.*?)\[\/list\]/s", $text))
$text = preg_replace("/\[list\](.*?)\[\/list\]/s", "<ul>\\1</ul>", $text);
while(preg_match("/\[ul\](.*?)\[\/ul\]/s", $text))
$text = preg_replace("/\[ul\](.*?)\[\/ul\]/s", "<ul>\\1</ul>", $text);
while(preg_match("/\[ol\](.*?)\[\/ol\]/s", $text))
$text = preg_replace("/\[ol\](.*?)\[\/ol\]/s", "<ol>\\1</ol>", $text);
$text = preg_replace("/\[li\](.*?)\[\/li\]/s", "<li>\\1</li>", $text);
$text = preg_replace("#\[\*\]#s", "<li>", $text);
$text = preg_replace("#<br><(li|ul|ol|/ul|/ol)>#s", "<\\1>", $text);
$text = preg_replace("#\[align=(left|center|right)\](.*?)\[\/align\]#s", "<div style='text-align:\\1;'>\\2</div>", $text);
$text = $this->filter_spoiler($text);
$text = $this->insert_code($text);
return $text;
}
/** class BBCode extends FormatterExtension
* @param string $text {
* @return string public function format(string $text): string
*/ {
public function strip(/*string*/ $text) { $text = $this->extract_code($text);
foreach(array( foreach ([
"b", "i", "u", "s", "sup", "sub", "h1", "h2", "h3", "h4", "b", "i", "u", "s", "sup", "sub", "h1", "h2", "h3", "h4",
"code", "url", "email", "li", ] as $el) {
) as $el) { $text = preg_replace("!\[$el\](.*?)\[/$el\]!s", "<$el>$1</$el>", $text);
$text = preg_replace("!\[$el\](.*?)\[/$el\]!s", '$1', $text); }
} $text = preg_replace('!^&gt;&gt;([^\d].+)!', '<blockquote><small>$1</small></blockquote>', $text);
$text = preg_replace("!\[anchor=(.*?)\](.*?)\[/anchor\]!s", '$2', $text); $text = preg_replace('!&gt;&gt;(\d+)(#c?\d+)?!s', '<a class="shm-clink" data-clink-sel="$2" href="'.make_link('post/view/$1$2').'">&gt;&gt;$1$2</a>', $text);
$text = preg_replace("!\[url=(.*?)\](.*?)\[/url\]!s", '$2', $text); $text = preg_replace('!\[anchor=(.*?)\](.*?)\[/anchor\]!s', '<span class="anchor">$2 <a class="alink" href="#bb-$1" name="bb-$1" title="link to this anchor"> ¶ </a></span>', $text); // add "bb-" to avoid clashing with eg #top
$text = preg_replace("!\[img\](.*?)\[/img\]!s", "", $text); $text = preg_replace('!\[url=site://(.*?)(#c\d+)?\](.*?)\[/url\]!s', '<a class="shm-clink" data-clink-sel="$2" href="'.make_link('$1$2').'">$3</a>', $text);
$text = preg_replace("!\[\[([^\|\]]+)\|([^\]]+)\]\]!s", '$2', $text); $text = preg_replace('!\[url\]site://(.*?)(#c\d+)?\[/url\]!s', '<a class="shm-clink" data-clink-sel="$2" href="'.make_link('$1$2').'">$1$2</a>', $text);
$text = preg_replace("!\[\[([^\]]+)\]\]!s", '$1', $text); $text = preg_replace('!\[url=((?:https?|ftp|irc|mailto)://.*?)\](.*?)\[/url\]!s', '<a href="$1">$2</a>', $text);
$text = preg_replace("!\[quote\](.*?)\[/quote\]!s", "", $text); $text = preg_replace('!\[url\]((?:https?|ftp|irc|mailto)://.*?)\[/url\]!s', '<a href="$1">$1</a>', $text);
$text = preg_replace("!\[quote=(.*?)\](.*?)\[/quote\]!s", "", $text); $text = preg_replace('!\[email\](.*?)\[/email\]!s', '<a href="mailto:$1">$1</a>', $text);
$text = preg_replace("!\[/?(list|ul|ol)\]!", "", $text); $text = preg_replace('!\[img\](https?:\/\/.*?)\[/img\]!s', '<img alt="user image" src="$1">', $text);
$text = preg_replace("!\[\*\](.*?)!s", '$1', $text); $text = preg_replace('!\[\[([^\|\]]+)\|([^\]]+)\]\]!s', '<a href="'.make_link('wiki/$1').'">$2</a>', $text);
$text = $this->strip_spoiler($text); $text = preg_replace('!\[\[([^\]]+)\]\]!s', '<a href="'.make_link('wiki/$1').'">$1</a>', $text);
return $text; $text = preg_replace("!\n\s*\n!", "\n\n", $text);
} $text = str_replace("\n", "\n<br>", $text);
$text = preg_replace("/\[quote\](.*?)\[\/quote\]/s", "<blockquote><small>\\1</small></blockquote>", $text);
$text = preg_replace("/\[quote=(.*?)\](.*?)\[\/quote\]/s", "<blockquote><em>\\1 said:</em><br><small>\\2</small></blockquote>", $text);
while (preg_match("/\[list\](.*?)\[\/list\]/s", $text)) {
$text = preg_replace("/\[list\](.*?)\[\/list\]/s", "<ul>\\1</ul>", $text);
}
while (preg_match("/\[ul\](.*?)\[\/ul\]/s", $text)) {
$text = preg_replace("/\[ul\](.*?)\[\/ul\]/s", "<ul>\\1</ul>", $text);
}
while (preg_match("/\[ol\](.*?)\[\/ol\]/s", $text)) {
$text = preg_replace("/\[ol\](.*?)\[\/ol\]/s", "<ol>\\1</ol>", $text);
}
$text = preg_replace("/\[li\](.*?)\[\/li\]/s", "<li>\\1</li>", $text);
$text = preg_replace("#\[\*\]#s", "<li>", $text);
$text = preg_replace("#<br><(li|ul|ol|/ul|/ol)>#s", "<\\1>", $text);
$text = preg_replace("#\[align=(left|center|right)\](.*?)\[\/align\]#s", "<div style='text-align:\\1;'>\\2</div>", $text);
$text = $this->filter_spoiler($text);
$text = $this->insert_code($text);
return $text;
}
/** public function strip(string $text): string
* @param string $text {
* @return string foreach ([
*/ "b", "i", "u", "s", "sup", "sub", "h1", "h2", "h3", "h4",
private function filter_spoiler(/*string*/ $text) { "code", "url", "email", "li",
return str_replace( ] as $el) {
array("[spoiler]","[/spoiler]"), $text = preg_replace("!\[$el\](.*?)\[/$el\]!s", '$1', $text);
array("<span style=\"background-color:#000; color:#000;\">","</span>"), }
$text); $text = preg_replace("!\[anchor=(.*?)\](.*?)\[/anchor\]!s", '$2', $text);
} $text = preg_replace("!\[url=(.*?)\](.*?)\[/url\]!s", '$2', $text);
$text = preg_replace("!\[img\](.*?)\[/img\]!s", "", $text);
$text = preg_replace("!\[\[([^\|\]]+)\|([^\]]+)\]\]!s", '$2', $text);
$text = preg_replace("!\[\[([^\]]+)\]\]!s", '$1', $text);
$text = preg_replace("!\[quote\](.*?)\[/quote\]!s", "", $text);
$text = preg_replace("!\[quote=(.*?)\](.*?)\[/quote\]!s", "", $text);
$text = preg_replace("!\[/?(list|ul|ol)\]!", "", $text);
$text = preg_replace("!\[\*\](.*?)!s", '$1', $text);
$text = $this->strip_spoiler($text);
return $text;
}
/** private function filter_spoiler(string $text): string
* @param string $text {
* @return string return str_replace(
*/ ["[spoiler]","[/spoiler]"],
private function strip_spoiler(/*string*/ $text) { ["<span style=\"background-color:#000; color:#000;\">","</span>"],
$l1 = strlen("[spoiler]"); $text
$l2 = strlen("[/spoiler]"); );
while(true) { }
$start = strpos($text, "[spoiler]");
if($start === false) break;
$end = strpos($text, "[/spoiler]"); private function strip_spoiler(string $text): string
if($end === false) break; {
$l1 = strlen("[spoiler]");
$l2 = strlen("[/spoiler]");
while (true) {
$start = strpos($text, "[spoiler]");
if ($start === false) {
break;
}
if($end < $start) break; $end = strpos($text, "[/spoiler]");
if ($end === false) {
break;
}
$beginning = substr($text, 0, $start); if ($end < $start) {
$middle = str_rot13(substr($text, $start+$l1, ($end-$start-$l1))); break;
$ending = substr($text, $end + $l2, (strlen($text)-$end+$l2)); }
$text = $beginning . $middle . $ending; $beginning = substr($text, 0, $start);
} $middle = str_rot13(substr($text, $start+$l1, ($end-$start-$l1)));
return $text; $ending = substr($text, $end + $l2, (strlen($text)-$end+$l2));
}
/** $text = $beginning . $middle . $ending;
* @param string $text }
* @return string return $text;
*/ }
private function extract_code(/*string*/ $text) {
# at the end of this function, the only code! blocks should be
# the ones we've added -- others may contain malicious content,
# which would only appear after decoding
$text = str_replace("[code!]", "[code]", $text);
$text = str_replace("[/code!]", "[/code]", $text);
$l1 = strlen("[code]"); private function extract_code(string $text): string
$l2 = strlen("[/code]"); {
while(true) { # at the end of this function, the only code! blocks should be
$start = strpos($text, "[code]"); # the ones we've added -- others may contain malicious content,
if($start === false) break; # which would only appear after decoding
$text = str_replace("[code!]", "[code]", $text);
$text = str_replace("[/code!]", "[/code]", $text);
$end = strpos($text, "[/code]", $start); $l1 = strlen("[code]");
if($end === false) break; $l2 = strlen("[/code]");
while (true) {
$start = strpos($text, "[code]");
if ($start === false) {
break;
}
if($end < $start) break; $end = strpos($text, "[/code]", $start);
if ($end === false) {
break;
}
$beginning = substr($text, 0, $start); if ($end < $start) {
$middle = base64_encode(substr($text, $start+$l1, ($end-$start-$l1))); break;
$ending = substr($text, $end + $l2, (strlen($text)-$end+$l2)); }
$text = $beginning . "[code!]" . $middle . "[/code!]" . $ending; $beginning = substr($text, 0, $start);
} $middle = base64_encode(substr($text, $start+$l1, ($end-$start-$l1)));
return $text; $ending = substr($text, $end + $l2, (strlen($text)-$end+$l2));
}
/** $text = $beginning . "[code!]" . $middle . "[/code!]" . $ending;
* @param string $text }
* @return string return $text;
*/ }
private function insert_code(/*string*/ $text) {
$l1 = strlen("[code!]");
$l2 = strlen("[/code!]");
while(true) {
$start = strpos($text, "[code!]");
if($start === false) break;
$end = strpos($text, "[/code!]"); private function insert_code(string $text): string
if($end === false) break; {
$l1 = strlen("[code!]");
$l2 = strlen("[/code!]");
while (true) {
$start = strpos($text, "[code!]");
if ($start === false) {
break;
}
$beginning = substr($text, 0, $start); $end = strpos($text, "[/code!]");
$middle = base64_decode(substr($text, $start+$l1, ($end-$start-$l1))); if ($end === false) {
$ending = substr($text, $end + $l2, (strlen($text)-$end+$l2)); break;
}
$text = $beginning . "<pre>" . $middle . "</pre>" . $ending; $beginning = substr($text, 0, $start);
} $middle = base64_decode(substr($text, $start+$l1, ($end-$start-$l1)));
return $text; $ending = substr($text, $end + $l2, (strlen($text)-$end+$l2));
}
$text = $beginning . "<pre>" . $middle . "</pre>" . $ending;
}
return $text;
}
} }

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