Merge tag 'v2.11.5'
This commit is contained in:
5
.devcontainer/bash_history
Normal file
5
.devcontainer/bash_history
Normal file
@@ -0,0 +1,5 @@
|
||||
./vendor/bin/php-cs-fixer fix && ./vendor/bin/phpunit --config tests/phpunit.xml && ./vendor/bin/phpstan analyse --memory-limit 1G -c tests/phpstan.neon
|
||||
./vendor/bin/php-cs-fixer fix
|
||||
./vendor/bin/phpunit --config tests/phpunit.xml
|
||||
./vendor/bin/phpstan analyse --memory-limit 1G -c tests/phpstan.neon
|
||||
php ./.docker/run.php
|
||||
7
.devcontainer/manual.sh
Executable file
7
.devcontainer/manual.sh
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
# build in verbose mode to populate the cache
|
||||
docker build .
|
||||
# build in quiet mode to get just the ID, should
|
||||
# be very fast because we cached
|
||||
docker run --rm -v $(pwd):/app -p 8000:8000 -t $(docker build -q .)
|
||||
113
.docker/run.php
Executable file
113
.docker/run.php
Executable file
@@ -0,0 +1,113 @@
|
||||
#!/bin/env php
|
||||
<?php
|
||||
// Check install is valid and dirs exist
|
||||
if (!is_dir('/app/data')) {
|
||||
mkdir('/app/data', 0755, true);
|
||||
}
|
||||
chown('/app/data', 'shimmie');
|
||||
chgrp('/app/data', 'shimmie');
|
||||
|
||||
// Get php.ini settings from PHP_INI_XXX environment variables
|
||||
$php_ini = [];
|
||||
foreach(getenv() as $key => $value) {
|
||||
if (strpos($key, 'PHP_INI_') === 0) {
|
||||
$php_ini_key = strtolower(substr($key, 8));
|
||||
$php_ini[$php_ini_key] = $value;
|
||||
}
|
||||
}
|
||||
// deprecated one-off special configs
|
||||
$php_ini['max_file_uploads'] ??= getenv('MAX_FILE_UPLOADS') ?: "100";
|
||||
$php_ini['upload_max_filesize'] ??= getenv('UPLOAD_MAX_FILESIZE') ?: '100M';
|
||||
// this one needs to be calculated for the web server itself
|
||||
$php_ini['post_max_size'] ??= (string)(
|
||||
ini_parse_quantity($php_ini['upload_max_filesize']) *
|
||||
intval($php_ini['max_file_uploads'])
|
||||
);
|
||||
|
||||
// Generate a config file for whatever web server we are using today
|
||||
$config = [
|
||||
"listeners" => [
|
||||
"*:8000" => [
|
||||
"pass" => "routes",
|
||||
"forwarded" => [
|
||||
"client_ip" => "X-Forwarded-For",
|
||||
"recursive" => false,
|
||||
"source" => [
|
||||
"172.17.0.0/16"
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
"routes" => [
|
||||
[
|
||||
"match" => [
|
||||
"uri" => "~/_(thumbs|images)/.*"
|
||||
],
|
||||
"action" => [
|
||||
"share" => [
|
||||
'`/app/data/${uri.replace(/_(thumbs|images)\\/(..)(..)(.*?)\\/.*/, "$1/$2/$3/$2$3$4")}`',
|
||||
'`/app/data/${uri.replace(/_(thumbs|images)\\/(..)(.*?)\\/.*/, "$1/$2/$2$3")}`'
|
||||
],
|
||||
"response_headers" => [
|
||||
"Cache-Control" => "public, max-age=31556926"
|
||||
]
|
||||
]
|
||||
],
|
||||
[
|
||||
"action" => [
|
||||
"share" => '/app/$uri',
|
||||
"types" => [
|
||||
"image/*",
|
||||
"application/javascript",
|
||||
"text/css",
|
||||
"application/sourcemap",
|
||||
"!"
|
||||
],
|
||||
"response_headers" => [
|
||||
"Cache-Control" => "public, max-age=31556926"
|
||||
],
|
||||
"fallback" => [
|
||||
"pass" => "applications/shimmie"
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
"applications" => [
|
||||
"shimmie" => [
|
||||
"type" => "php",
|
||||
"user" => "shimmie",
|
||||
"root" => "/app/",
|
||||
"script" => "index.php",
|
||||
"working_directory" => "/app/",
|
||||
"options" => [
|
||||
"admin" => $php_ini
|
||||
],
|
||||
"processes" => [
|
||||
"max" => 8,
|
||||
"spare" => 2,
|
||||
"idle_timeout" => 60
|
||||
]
|
||||
]
|
||||
],
|
||||
"settings" => [
|
||||
"http" => [
|
||||
"max_body_size" => ini_parse_quantity($php_ini['post_max_size']),
|
||||
"static" => [
|
||||
"mime_types" => [
|
||||
"application/sourcemap" => [".map"]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
file_put_contents(
|
||||
'/var/lib/unit/conf.json',
|
||||
json_encode($config, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
|
||||
// Start the web server
|
||||
pcntl_exec('/usr/sbin/unitd', [
|
||||
'--no-daemon',
|
||||
'--control', 'unix:/var/run/control.unit.sock',
|
||||
'--log', '/dev/stderr'
|
||||
]);
|
||||
38
.github/CONTRIBUTING.md
vendored
Normal file
38
.github/CONTRIBUTING.md
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
Vibes:
|
||||
======
|
||||
Generally-useful extensions are great, custom extensions and themes just for one specific DIY site
|
||||
are welcome too. I (Shish) will probably only actively maintain and add features to the extensions
|
||||
which I personally use, but if you submit some code of your own I will try to keep it updated and
|
||||
compatible with any API changes that come along. If your code comes with unit tests, this type of
|
||||
maintenance is much more likely to be successful :)
|
||||
|
||||
Testing:
|
||||
========
|
||||
Github Actions will be running three sets of automated tests, all of which you can run for yourself:
|
||||
|
||||
- `composer format` - keeping a single style for the whole project
|
||||
- `composer test` - unit testing
|
||||
- `composer stan` - type checking
|
||||
|
||||
The `main` branch is locked down so it can't accept pull requests that don't pass these
|
||||
|
||||
Testing FAQs:
|
||||
=============
|
||||
|
||||
## What the heck is "Method XX::YY() return type has no value type specified in iterable type array."?
|
||||
|
||||
PHP arrays are very loosely defined - they can be lists or maps, with integer or string
|
||||
(or non-continuous integer) keys, with any type of object (or multiple types of object).
|
||||
This isn't great for type safety, so PHPStan is a bit stricter, and requires you to
|
||||
specify what type of array it is and what it contains. You can do this with PHPdoc comments,
|
||||
like:
|
||||
|
||||
```php
|
||||
/**
|
||||
* @param array<string, Cake> $cakes -- a mapping like ["sponge" => new Cake()]
|
||||
* @return array<Ingredient> -- a list like [new Ingredient("flour"), new Ingredient("egg")]
|
||||
*/
|
||||
function get_ingredients(array $cakes, string $cake_name): array {
|
||||
return $cakes[$cake_name]->ingredients;
|
||||
}
|
||||
```
|
||||
22
.github/get-tags.py
vendored
Executable file
22
.github/get-tags.py
vendored
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from subprocess import check_output
|
||||
import sys
|
||||
|
||||
branch = check_output(["git", "branch", "--show-current"], text=True).strip()
|
||||
describe = check_output(["git", "describe", "--tags"], text=True).strip()
|
||||
tag = describe.split("-")[0][1:]
|
||||
a, b, c = tag.split(".")
|
||||
docker_username = sys.argv[1]
|
||||
docker_image = sys.argv[2] if len(sys.argv) > 2 else "shimmie2"
|
||||
image_name = f"{docker_username}/{docker_image}"
|
||||
|
||||
if branch == "main":
|
||||
print(f"tags={image_name}:latest")
|
||||
elif "-" not in describe:
|
||||
print(f"tags={image_name}:{a},{image_name}:{a}.{b},{image_name}:{a}.{b}.{c}")
|
||||
elif branch.startswith("branch-2."):
|
||||
print(f"tags={image_name}:{a},{image_name}:{a}.{b}")
|
||||
else:
|
||||
print(f"Only run from main, branch-2.X, or a tag (branch={branch}, describe={describe})")
|
||||
sys.exit(1)
|
||||
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set Git config
|
||||
run: |
|
||||
git config --local user.email "actions@github.com"
|
||||
|
||||
28
.github/workflows/publish.yml
vendored
28
.github/workflows/publish.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: Tests
|
||||
branches: main
|
||||
types: completed
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' }}
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- name: Publish to Registry
|
||||
uses: elgohr/Publish-Docker-Github-Action@main
|
||||
with:
|
||||
name: shish2k/shimmie2
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
cache: ${{ github.event_name != 'schedule' }}
|
||||
buildoptions: "--build-arg RUN_TESTS=false"
|
||||
tag_semver: true
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@master
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get version from tag
|
||||
id: get_version
|
||||
|
||||
144
.github/workflows/tests.yml
vendored
144
.github/workflows/tests.yml
vendored
@@ -2,6 +2,12 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- branch-2.*
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 2 * * 0' # Weekly on Sundays at 02:00
|
||||
@@ -9,12 +15,12 @@ on:
|
||||
jobs:
|
||||
format:
|
||||
name: Format
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Set Up Cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
vendor
|
||||
@@ -23,23 +29,19 @@ jobs:
|
||||
run: composer validate
|
||||
- name: Install PHP dependencies
|
||||
run: composer install --prefer-dist --no-progress
|
||||
- name: Set up PHP
|
||||
uses: shivammathur/setup-php@master
|
||||
with:
|
||||
php-version: 8.1
|
||||
- name: Format
|
||||
run: ./vendor/bin/php-cs-fixer fix && git diff --exit-code
|
||||
run: composer format && git diff --exit-code
|
||||
|
||||
static:
|
||||
name: Static Analysis
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Set Up Cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
vendor
|
||||
@@ -47,10 +49,57 @@ jobs:
|
||||
- name: Install PHP dependencies
|
||||
run: composer install --prefer-dist --no-progress
|
||||
- name: PHPStan
|
||||
uses: php-actions/phpstan@v3
|
||||
run: composer stan
|
||||
|
||||
upgrade:
|
||||
name: Upgrade from 2.9 ${{ matrix.database }}
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.3']
|
||||
database: ['pgsql', 'mysql', 'sqlite']
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout current
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
configuration: tests/phpstan.neon
|
||||
memory_limit: 1G
|
||||
fetch-depth: 0
|
||||
- name: Travel to past
|
||||
# is there a way to programatically get "the most recent
|
||||
# tagged minor version of the previous major version"?
|
||||
run: git checkout branch-2.9
|
||||
- name: Set Up Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
vendor
|
||||
key: vendor-${{ matrix.php }}-${{ hashFiles('composer.lock') }}
|
||||
- name: Set up PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
- name: Set up database
|
||||
run: ./tests/setup-db.sh "${{ matrix.database }}"
|
||||
- name: Install PHP dependencies
|
||||
run: composer install --no-progress
|
||||
- name: Install old version
|
||||
run: |
|
||||
php index.php
|
||||
cat data/config/shimmie.conf.php
|
||||
- name: Check old version works
|
||||
run: |
|
||||
php index.php get-page / > old.out
|
||||
grep -q 'Welcome to Shimmie 2.9' old.out || cat old.out
|
||||
rm -f old.out
|
||||
- name: Upgrade
|
||||
run: |
|
||||
git checkout ${{ github.sha }}
|
||||
composer install --no-progress
|
||||
php index.php db-upgrade
|
||||
- name: Check new version works
|
||||
run: |
|
||||
php index.php page:get / > new.out
|
||||
grep -q 'Welcome to Shimmie 2.10' new.out || cat new.out
|
||||
rm -f new.out
|
||||
|
||||
test:
|
||||
name: PHP ${{ matrix.php }} / DB ${{ matrix.database }}
|
||||
@@ -59,28 +108,27 @@ jobs:
|
||||
matrix:
|
||||
php: ['8.1', '8.2']
|
||||
database: ['pgsql', 'mysql', 'sqlite']
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Set Up Cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
vendor
|
||||
key: vendor-${{ matrix.php }}-${{ hashFiles('composer.lock') }}
|
||||
|
||||
- name: Set up PHP
|
||||
uses: shivammathur/setup-php@master
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: pcov
|
||||
extensions: mbstring
|
||||
|
||||
ini-file: development
|
||||
- name: Set up database
|
||||
run: |
|
||||
mkdir -p data/config
|
||||
@@ -112,19 +160,47 @@ jobs:
|
||||
run: composer update && composer install --prefer-dist --no-progress
|
||||
|
||||
- name: Run test suite
|
||||
run: |
|
||||
if [[ "${{ matrix.database }}" == "pgsql" ]]; then
|
||||
export TEST_DSN="pgsql:user=shimmie;password=shimmie;host=127.0.0.1;dbname=shimmie"
|
||||
fi
|
||||
if [[ "${{ matrix.database }}" == "mysql" ]]; then
|
||||
export TEST_DSN="mysql:user=root;password=root;host=127.0.0.1;dbname=shimmie"
|
||||
fi
|
||||
if [[ "${{ matrix.database }}" == "sqlite" ]]; then
|
||||
export TEST_DSN="sqlite:data/shimmie.sqlite"
|
||||
fi
|
||||
vendor/bin/phpunit --configuration tests/phpunit.xml --coverage-clover=data/coverage.clover
|
||||
run: composer test
|
||||
|
||||
- name: Upload coverage
|
||||
if: matrix.php == '8.1'
|
||||
publish:
|
||||
name: Publish
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- format
|
||||
- static
|
||||
- upgrade
|
||||
- test
|
||||
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/branch-2') || startsWith(github.ref, 'refs/tags/v2')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
- name: Set build vars
|
||||
id: get-vars
|
||||
run: |
|
||||
vendor/bin/ocular code-coverage:upload --format=php-clover data/coverage.clover
|
||||
echo "BUILD_TIME=$(date +'%Y-%m-%dT%H:%M:%S')" >> $GITHUB_ENV
|
||||
echo "BUILD_HASH=$GITHUB_SHA" >> $GITHUB_ENV
|
||||
./.github/get-tags.py ${{ vars.DOCKER_USERNAME }} ${{ secrets.DOCKER_IMAGE }} | tee -a $GITHUB_OUTPUT
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push to registry
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
push: true
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
build-args: |
|
||||
BUILD_TIME=${{ env.BUILD_TIME }}
|
||||
BUILD_HASH=${{ env.BUILD_HASH }}
|
||||
no-cache: ${{ github.event_name == 'schedule' }}
|
||||
tags: "${{ steps.get-vars.outputs.tags }}"
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,12 +1,7 @@
|
||||
backup
|
||||
data
|
||||
images
|
||||
thumbs
|
||||
*.phar
|
||||
*.sqlite
|
||||
*.cache
|
||||
.devcontainer
|
||||
trace.json
|
||||
.docker/entrypoint.d/config.json
|
||||
.sl
|
||||
|
||||
#Composer
|
||||
composer.phar
|
||||
|
||||
18
.htaccess
18
.htaccess
@@ -1,5 +1,13 @@
|
||||
<IfModule mod_php.c>
|
||||
php_value upload_max_filesize 100M
|
||||
php_value post_max_size 100M
|
||||
php_value max_execution_time 30
|
||||
php_value max_input_time 30
|
||||
php_value max_file_uploads 25
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_dir.c>
|
||||
DirectoryIndex index.php5 index.php
|
||||
DirectoryIndex index.php
|
||||
</IfModule>
|
||||
|
||||
<FilesMatch "\.(sqlite|sdb|s3db|db)$">
|
||||
@@ -26,12 +34,18 @@
|
||||
|
||||
<IfModule mod_expires.c>
|
||||
ExpiresActive On
|
||||
<FilesMatch "([0-9a-f]{32}|\.(gif|jpe?g|png|webp|css|js))$">
|
||||
<FilesMatch "([0-9a-f]{32}|\.(gif|jpe?g|png|webp))$">
|
||||
<IfModule mod_headers.c>
|
||||
Header set Cache-Control "public, max-age=2629743"
|
||||
</IfModule>
|
||||
ExpiresDefault "access plus 1 month"
|
||||
</FilesMatch>
|
||||
<FilesMatch "\.(css|js)$">
|
||||
<IfModule mod_headers.c>
|
||||
Header set Cache-Control "public, max-age=86400"
|
||||
</IfModule>
|
||||
ExpiresDefault "access plus 1 day"
|
||||
</FilesMatch>
|
||||
#ExpiresByType text/html "now"
|
||||
#ExpiresByType text/plain "now"
|
||||
</IfModule>
|
||||
|
||||
18
Dockerfile
18
Dockerfile
@@ -5,7 +5,8 @@ FROM debian:bookworm AS base
|
||||
RUN apt update && apt upgrade -y && apt install -y \
|
||||
php${PHP_VERSION}-cli php${PHP_VERSION}-gd php${PHP_VERSION}-zip php${PHP_VERSION}-xml php${PHP_VERSION}-mbstring \
|
||||
php${PHP_VERSION}-pgsql php${PHP_VERSION}-mysql php${PHP_VERSION}-sqlite3 \
|
||||
gosu curl imagemagick ffmpeg zip unzip && \
|
||||
php${PHP_VERSION}-memcached \
|
||||
curl imagemagick zip unzip unit unit-php && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Composer has 100MB of dependencies, and we only need that during build and test
|
||||
@@ -39,11 +40,12 @@ RUN [ $RUN_TESTS = false ] || (\
|
||||
# Actually run shimmie
|
||||
FROM base
|
||||
EXPOSE 8000
|
||||
HEALTHCHECK --interval=1m --timeout=3s CMD curl --fail http://127.0.0.1:8000/ || exit 1
|
||||
ENV UID=1000 \
|
||||
GID=1000 \
|
||||
UPLOAD_MAX_FILESIZE=50M
|
||||
COPY --from=app /app /app
|
||||
|
||||
# HEALTHCHECK --interval=1m --timeout=3s CMD curl --fail http://127.0.0.1:8000/ || exit 1
|
||||
ARG BUILD_TIME=unknown BUILD_HASH=unknown
|
||||
ENV UID=1000 GID=1000
|
||||
COPY --from=build /app /app
|
||||
WORKDIR /app
|
||||
CMD ["/bin/sh", "/app/tests/docker-init.sh"]
|
||||
RUN echo "_d('BUILD_TIME', '$BUILD_TIME');" >> core/sys_config.php && \
|
||||
echo "_d('BUILD_HASH', '$BUILD_HASH');" >> core/sys_config.php
|
||||
ENTRYPOINT ["/app/.docker/entrypoint.sh"]
|
||||
CMD ["php", "/app/.docker/run.php"]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "A tag-based image gallery",
|
||||
"type" : "project",
|
||||
"license" : "GPL-2.0-or-later",
|
||||
"minimum-stability" : "dev",
|
||||
"minimum-stability" : "stable",
|
||||
|
||||
"config": {
|
||||
"platform": {
|
||||
@@ -35,30 +35,32 @@
|
||||
"ext-pdo": "*",
|
||||
"ext-json": "*",
|
||||
"ext-fileinfo": "*",
|
||||
|
||||
"flexihash/flexihash" : "^2.0",
|
||||
"ifixit/php-akismet" : "^1.0",
|
||||
"google/recaptcha" : "^1.1",
|
||||
"shish/eventtracer-php" : "^2.0",
|
||||
"shish/ffsphp" : "^1.0",
|
||||
"shish/microcrud" : "^2.0",
|
||||
"shish/microhtml" : "^2.0",
|
||||
"shish/gqla" : "dev-main",
|
||||
"enshrined/svg-sanitize" : "^0.16",
|
||||
|
||||
"bower-asset/jquery" : "^1.12",
|
||||
"bower-asset/jquery-timeago" : "^1.5",
|
||||
"bower-asset/js-cookie" : "^2.1",
|
||||
"psr/simple-cache" : "^1.0",
|
||||
"sabre/cache" : "^2.0.1",
|
||||
"naroga/redis-cache": "dev-master"
|
||||
"flexihash/flexihash": "^2.0",
|
||||
"ifixit/php-akismet": "^1.0",
|
||||
"google/recaptcha": "^1.1",
|
||||
"shish/eventtracer-php": "^2.0",
|
||||
"shish/ffsphp": "^1.3",
|
||||
"shish/microbundler": "^1.0",
|
||||
"shish/microcrud": "^2.0",
|
||||
"shish/microhtml": "^2.2",
|
||||
"shish/gqla": "^1.0",
|
||||
"enshrined/svg-sanitize": "^0.16",
|
||||
"bower-asset/jquery": "^1.12",
|
||||
"bower-asset/jquery-timeago": "^1.5",
|
||||
"bower-asset/js-cookie": "^2.1",
|
||||
"psr/simple-cache": "^1.0",
|
||||
"sabre/cache": "^2.0.1",
|
||||
"naroga/redis-cache": "dev-master",
|
||||
"aws/aws-sdk-php": "^3.294",
|
||||
"symfony/console": "6.4.x-dev",
|
||||
"thecodingmachine/safe": "2.5.0"
|
||||
},
|
||||
|
||||
"require-dev" : {
|
||||
"phpunit/phpunit" : "^9.0",
|
||||
"friendsofphp/php-cs-fixer" : "^3.12",
|
||||
"scrutinizer/ocular": "dev-master",
|
||||
"phpstan/phpstan": "1.10.x-dev"
|
||||
"phpunit/phpunit" : "10.5.3",
|
||||
"friendsofphp/php-cs-fixer" : "3.41.1",
|
||||
"phpstan/phpstan": "1.10.50",
|
||||
"thecodingmachine/phpstan-safe-rule": "^1.2"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-memcache": "memcache caching",
|
||||
@@ -73,5 +75,15 @@
|
||||
"ext-zlib": "anti-spam",
|
||||
"ext-xml": "some extensions",
|
||||
"ext-gd": "GD-based thumbnailing"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"check": [
|
||||
"@format",
|
||||
"@stan",
|
||||
"@test"
|
||||
],
|
||||
"format": "PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix",
|
||||
"stan": "phpstan analyse --memory-limit 1G -c tests/phpstan.neon",
|
||||
"test": "phpunit --config tests/phpunit.xml"
|
||||
}
|
||||
}
|
||||
|
||||
3796
composer.lock
generated
3796
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -83,7 +83,7 @@ class BasePage
|
||||
public function set_filename(string $filename, string $disposition = "attachment"): void
|
||||
{
|
||||
$max_len = 250;
|
||||
if(strlen($filename) > $max_len) {
|
||||
if (strlen($filename) > $max_len) {
|
||||
// remove extension, truncate filename, apply extension
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION);
|
||||
$filename = substr($filename, 0, $max_len - strlen($ext) - 1) . '.' . $ext;
|
||||
@@ -284,7 +284,7 @@ class BasePage
|
||||
assert($this->file, "file should not be null with PageMode::FILE");
|
||||
|
||||
// https://gist.github.com/codler/3906826
|
||||
$size = filesize($this->file); // File size
|
||||
$size = \Safe\filesize($this->file); // File size
|
||||
$length = $size; // Content length
|
||||
$start = 0; // Start byte
|
||||
$end = $size - 1; // End byte
|
||||
@@ -383,19 +383,42 @@ class BasePage
|
||||
$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";
|
||||
$mcss = new \MicroBundler\MicroBundler();
|
||||
foreach ($css_files as $css) {
|
||||
$mcss->addSource($css);
|
||||
}
|
||||
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 ***/
|
||||
return $css_cache_file;
|
||||
}
|
||||
|
||||
private function get_initjs_cache_file(string $theme_name, int $config_latest): string
|
||||
{
|
||||
$js_latest = $config_latest;
|
||||
$js_files = array_merge(
|
||||
zglob("ext/{" . Extension::get_enabled_extensions_as_string() . "}/init.js"),
|
||||
zglob("themes/$theme_name/init.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/initscript/{$theme_name}.{$js_latest}.{$js_md5}.js");
|
||||
if (!file_exists($js_cache_file)) {
|
||||
$mcss = new \MicroBundler\MicroBundler();
|
||||
foreach ($js_files as $js) {
|
||||
$mcss->addSource($js);
|
||||
}
|
||||
$mcss->save($js_cache_file);
|
||||
}
|
||||
|
||||
return $js_cache_file;
|
||||
}
|
||||
|
||||
private function get_js_cache_file(string $theme_name, int $config_latest): string
|
||||
{
|
||||
$js_latest = $config_latest;
|
||||
$js_files = array_merge(
|
||||
[
|
||||
@@ -413,9 +436,9 @@ class BasePage
|
||||
$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";
|
||||
$mcss = new \MicroBundler\MicroBundler();
|
||||
foreach ($js_files as $js) {
|
||||
$mcss->addSource($js);
|
||||
}
|
||||
file_put_contents($js_cache_file, $js_data);
|
||||
}
|
||||
@@ -494,13 +517,23 @@ class BasePage
|
||||
$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;
|
||||
$head = $this->head_html();
|
||||
$body = $this->body_html();
|
||||
|
||||
$body_attrs = [
|
||||
"data-userclass" => $user->class->name,
|
||||
"data-base-href" => get_base_href(),
|
||||
"data-base-link" => make_link(""),
|
||||
];
|
||||
|
||||
print emptyHTML(
|
||||
rawHTML("<!doctype html>"),
|
||||
HTML(
|
||||
["lang" => "en"],
|
||||
HEAD(rawHTML($head)),
|
||||
BODY($body_attrs, rawHTML($body))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected function head_html(): string
|
||||
|
||||
@@ -37,15 +37,6 @@ class BaseThemelet
|
||||
$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...
|
||||
@@ -79,6 +70,19 @@ class BaseThemelet
|
||||
}
|
||||
}
|
||||
|
||||
$attrs = [
|
||||
"href" => $view_link,
|
||||
"class" => "thumb shm-thumb shm-thumb-link $custom_classes",
|
||||
"data-tags" => $tags,
|
||||
"data-height" => $image->height,
|
||||
"data-width" => $image->width,
|
||||
"data-mime" => $image->get_mime(),
|
||||
"data-post-id" => $id,
|
||||
];
|
||||
if (Extension::is_enabled(RatingsInfo::KEY)) {
|
||||
$attrs["data-rating"] = $image['rating'];
|
||||
}
|
||||
|
||||
return A(
|
||||
[
|
||||
"href"=>$view_link,
|
||||
@@ -191,4 +195,13 @@ class BaseThemelet
|
||||
' >>'
|
||||
);
|
||||
}
|
||||
|
||||
public function display_crud(string $title, HTMLElement $table, HTMLElement $paginator): void
|
||||
{
|
||||
global $page;
|
||||
$page->set_title($title);
|
||||
$page->set_heading($title);
|
||||
$page->add_block(new NavBlock());
|
||||
$page->add_block(new Block("$title Table", emptyHTML($table, $paginator)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,25 +104,28 @@ function loadCache(?string $dsn): CacheInterface
|
||||
{
|
||||
$matches = [];
|
||||
$c = null;
|
||||
if ($dsn && preg_match("#(.*)://(.*)#", $dsn, $matches) && !isset($_GET['DISABLE_CACHE'])) {
|
||||
if ($matches[1] == "memcached" || $matches[1] == "memcache") {
|
||||
$hp = explode(":", $matches[2]);
|
||||
$memcache = new \Memcached();
|
||||
$memcache->addServer($hp[0], (int)$hp[1]);
|
||||
$c = new \Sabre\Cache\Memcached($memcache);
|
||||
} elseif ($matches[1] == "apc") {
|
||||
$c = new \Sabre\Cache\Apcu();
|
||||
} elseif ($matches[1] == "redis") {
|
||||
$hp = explode(":", $matches[2]);
|
||||
$redis = new \Predis\Client([
|
||||
'scheme' => 'tcp',
|
||||
'host' => $hp[0],
|
||||
'port' => (int)$hp[1]
|
||||
], ['prefix' => 'shm:']);
|
||||
$c = new \Naroga\RedisCache\Redis($redis);
|
||||
if ($dsn && !isset($_GET['DISABLE_CACHE'])) {
|
||||
$url = parse_url($dsn);
|
||||
if ($url) {
|
||||
if ($url['scheme'] == "memcached" || $url['scheme'] == "memcache") {
|
||||
$memcache = new \Memcached();
|
||||
$memcache->addServer($url['host'], $url['port']);
|
||||
$c = new \Sabre\Cache\Memcached($memcache);
|
||||
} elseif ($url['scheme'] == "apc") {
|
||||
$c = new \Sabre\Cache\Apcu();
|
||||
} elseif ($url['scheme'] == "redis") {
|
||||
$redis = new \Predis\Client([
|
||||
'scheme' => 'tcp',
|
||||
'host' => $url['host'] ?? "127.0.0.1",
|
||||
'port' => $url['port'] ?? 6379,
|
||||
'username' => $url['user'] ?? null,
|
||||
'password' => $url['pass'] ?? null,
|
||||
], ['prefix' => 'shm:']);
|
||||
$c = new \Naroga\RedisCache\Redis($redis);
|
||||
}
|
||||
}
|
||||
}
|
||||
if(is_null($c)) {
|
||||
if (is_null($c)) {
|
||||
$c = new \Sabre\Cache\Memory();
|
||||
}
|
||||
global $_tracer;
|
||||
|
||||
@@ -61,8 +61,8 @@ class CommandBuilder
|
||||
|
||||
log_debug('command_builder', "Command `$cmd` returned $ret and outputted $output");
|
||||
|
||||
if ($fail_on_non_zero_return&&(int)$ret!==(int)0) {
|
||||
throw new SCoreException("Command `$cmd` failed, returning $ret and outputting $output");
|
||||
if ($fail_on_non_zero_return && (int)$ret !== (int)0) {
|
||||
throw new ServerError("Command `$cmd` failed, returning $ret and outputting $output");
|
||||
}
|
||||
return $ret;
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ abstract class BaseConfig implements Config
|
||||
{
|
||||
$val = $this->get($name, $default);
|
||||
if (!is_string($val) && !is_null($val)) {
|
||||
throw new SCoreException("$name is not a string: $val");
|
||||
throw new ServerError("$name is not a string: $val");
|
||||
}
|
||||
return $val;
|
||||
}
|
||||
@@ -231,7 +231,14 @@ abstract class BaseConfig implements Config
|
||||
|
||||
public function get_array(string $name, ?array $default=[]): ?array
|
||||
{
|
||||
return explode(",", $this->get($name, ""));
|
||||
$val = $this->get($name);
|
||||
if (is_null($val)) {
|
||||
return $default;
|
||||
}
|
||||
if (empty($val)) {
|
||||
return [];
|
||||
}
|
||||
return explode(",", $val);
|
||||
}
|
||||
|
||||
private function get(string $name, $default=null)
|
||||
|
||||
@@ -45,7 +45,7 @@ class Database
|
||||
|
||||
private function get_db(): PDO
|
||||
{
|
||||
if(is_null($this->db)) {
|
||||
if (is_null($this->db)) {
|
||||
$this->db = new PDO($this->dsn);
|
||||
$this->connect_engine();
|
||||
$this->get_engine()->init($this->db);
|
||||
@@ -59,7 +59,7 @@ class Database
|
||||
if (preg_match("/^([^:]*)/", $this->dsn, $matches)) {
|
||||
$db_proto=$matches[1];
|
||||
} else {
|
||||
throw new SCoreException("Can't figure out database engine");
|
||||
throw new ServerError("Can't figure out database engine");
|
||||
}
|
||||
|
||||
if ($db_proto === DatabaseDriverID::MYSQL->value) {
|
||||
@@ -93,7 +93,7 @@ class Database
|
||||
if ($this->is_transaction_open()) {
|
||||
return $this->get_db()->commit();
|
||||
} else {
|
||||
throw new SCoreException("Unable to call commit() as there is no transaction currently open.");
|
||||
throw new ServerError("Unable to call commit() as there is no transaction currently open.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ class Database
|
||||
if ($this->is_transaction_open()) {
|
||||
return $this->get_db()->rollback();
|
||||
} else {
|
||||
throw new SCoreException("Unable to call rollback() as there is no transaction currently open.");
|
||||
throw new ServerError("Unable to call rollback() as there is no transaction currently open.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,8 +319,6 @@ class Database
|
||||
|
||||
/**
|
||||
* Returns the number of tables present in the current database.
|
||||
*
|
||||
* @throws SCoreException
|
||||
*/
|
||||
public function count_tables(): int
|
||||
{
|
||||
@@ -337,7 +335,8 @@ class Database
|
||||
$this->get_all("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
);
|
||||
} else {
|
||||
throw new SCoreException("Can't count tables for database type {$this->get_engine()->id}");
|
||||
$did = (string)$this->get_engine()->id;
|
||||
throw new ServerError("Can't count tables for database type {$did}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,4 +369,19 @@ class Database
|
||||
$this->execute("ALTER TABLE $table RENAME COLUMN {$column}_b TO $column");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a deterministic pseudorandom order based on a seed and a column ID
|
||||
*/
|
||||
public function seeded_random(int $seed, string $id_column): string
|
||||
{
|
||||
$d = $this->get_driver_id();
|
||||
if ($d == DatabaseDriverID::MYSQL) {
|
||||
// MySQL supports RAND(n), where n is a random seed. Use that.
|
||||
return "RAND($seed)";
|
||||
}
|
||||
|
||||
// As fallback, use MD5 as a DRBG.
|
||||
return "MD5(CONCAT($seed, CONCAT('+', $id_column)))";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ class MySQL extends DBEngine
|
||||
|
||||
public function get_version(PDO $db): string
|
||||
{
|
||||
return $db->query('select version()')->fetch()[0];
|
||||
return $db->execute('select version()')->fetch()[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,11 +82,8 @@ class PostgreSQL extends DBEngine
|
||||
|
||||
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]';");
|
||||
}
|
||||
$addr = array_key_exists('REMOTE_ADDR', $_SERVER) ? get_real_ip() : 'local';
|
||||
$db->exec("SET application_name TO 'shimmie [$addr]';");
|
||||
if (defined("DATABASE_TIMEOUT")) {
|
||||
$this->set_timeout($db, DATABASE_TIMEOUT);
|
||||
}
|
||||
@@ -124,14 +121,14 @@ class PostgreSQL extends DBEngine
|
||||
|
||||
public function get_version(PDO $db): string
|
||||
{
|
||||
return $db->query('select version()')->fetch()[0];
|
||||
return $db->execute('select version()')->fetch()[0];
|
||||
}
|
||||
}
|
||||
|
||||
// shimmie functions for export to sqlite
|
||||
function _unix_timestamp($date): int
|
||||
{
|
||||
return strtotime($date);
|
||||
return \Safe\strtotime($date);
|
||||
}
|
||||
function _now(): string
|
||||
{
|
||||
@@ -231,6 +228,6 @@ class SQLite extends DBEngine
|
||||
|
||||
public function get_version(PDO $db): string
|
||||
{
|
||||
return $db->query('select sqlite_version()')->fetch()[0];
|
||||
return $db->execute('select sqlite_version()')->fetch()[0];
|
||||
}
|
||||
}
|
||||
|
||||
268
core/event.php
268
core/event.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shimmie2;
|
||||
|
||||
use MicroHTML\HTMLElement;
|
||||
|
||||
/**
|
||||
* Generic parent class for all events.
|
||||
*
|
||||
@@ -40,21 +42,38 @@ 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"
|
||||
* "42"); when an event handler asks $event->page_matches("view/{id}"), it returns
|
||||
* true and sets $event->get_arg('id') = "42"
|
||||
*/
|
||||
class PageRequestEvent extends Event
|
||||
{
|
||||
private string $method;
|
||||
public string $path;
|
||||
/** @var array<string, string|string[]> */
|
||||
public array $GET;
|
||||
/** @var array<string, string|string[]> */
|
||||
public array $POST;
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
public $args;
|
||||
public int $arg_count;
|
||||
public int $part_count;
|
||||
public array $args;
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private array $named_args = [];
|
||||
public int $page_num;
|
||||
private bool $is_authed;
|
||||
|
||||
public function __construct(string $path)
|
||||
/**
|
||||
* @param string $method The HTTP method used to make the request
|
||||
* @param string $path The path of the request
|
||||
* @param array<string, string|string[]> $get The GET parameters
|
||||
* @param array<string, string|string[]> $post The POST parameters
|
||||
*/
|
||||
public function __construct(string $method, string $path, array $get, array $post)
|
||||
{
|
||||
global $user;
|
||||
parent::__construct();
|
||||
global $config;
|
||||
|
||||
@@ -65,12 +84,90 @@ class PageRequestEvent extends Event
|
||||
if (empty($path)) { /* empty is faster than strlen */
|
||||
$path = $config->get_string(SetupConfig::FRONT_PAGE);
|
||||
}
|
||||
$this->path = $path;
|
||||
$this->GET = $get;
|
||||
$this->POST = $post;
|
||||
$this->is_authed = (
|
||||
defined("UNITTEST")
|
||||
|| (isset($_POST["auth_token"]) && $_POST["auth_token"] == $user->get_auth_token())
|
||||
);
|
||||
|
||||
// break the path into parts
|
||||
$args = explode('/', $path);
|
||||
$this->args = explode('/', $path);
|
||||
}
|
||||
|
||||
$this->args = $args;
|
||||
$this->arg_count = count($args);
|
||||
public function get_GET(string $key): ?string
|
||||
{
|
||||
if (array_key_exists($key, $this->GET)) {
|
||||
if (is_array($this->GET[$key])) {
|
||||
throw new UserError("GET parameter {$key} is an array, expected single value");
|
||||
}
|
||||
return $this->GET[$key];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function req_GET(string $key): string
|
||||
{
|
||||
$value = $this->get_GET($key);
|
||||
if ($value === null) {
|
||||
throw new UserError("Missing GET parameter {$key}");
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function get_POST(string $key): ?string
|
||||
{
|
||||
if (array_key_exists($key, $this->POST)) {
|
||||
if (is_array($this->POST[$key])) {
|
||||
throw new UserError("POST parameter {$key} is an array, expected single value");
|
||||
}
|
||||
return $this->POST[$key];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function req_POST(string $key): string
|
||||
{
|
||||
$value = $this->get_POST($key);
|
||||
if ($value === null) {
|
||||
throw new UserError("Missing POST parameter {$key}");
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]|null
|
||||
*/
|
||||
public function get_POST_array(string $key): ?array
|
||||
{
|
||||
if (array_key_exists($key, $this->POST)) {
|
||||
if (!is_array($this->POST[$key])) {
|
||||
throw new UserError("POST parameter {$key} is a single value, expected array");
|
||||
}
|
||||
return $this->POST[$key];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function req_POST_array(string $key): array
|
||||
{
|
||||
$value = $this->get_POST_array($key);
|
||||
if ($value === null) {
|
||||
throw new UserError("Missing POST parameter {$key}");
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function page_starts_with(string $name): bool
|
||||
{
|
||||
return str_starts_with($this->path, $name);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,96 +175,86 @@ class PageRequestEvent extends Event
|
||||
*
|
||||
* 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);
|
||||
public function page_matches(
|
||||
string $name,
|
||||
?string $method = null,
|
||||
?bool $authed = null,
|
||||
?string $permission = null,
|
||||
bool $paged = false,
|
||||
): bool {
|
||||
global $user;
|
||||
|
||||
if ($this->part_count > $this->arg_count) {
|
||||
if ($paged) {
|
||||
if ($this->page_matches("$name/{page_num}", $method, $authed, $permission, false)) {
|
||||
$pn = $this->get_arg("page_num");
|
||||
if (is_numberish($pn)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert($method === null || in_array($method, ["GET", "POST", "OPTIONS"]));
|
||||
$authed = $authed ?? $method == "POST";
|
||||
|
||||
// method check is fast so do that first
|
||||
if ($method !== null && $this->method !== $method) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for ($i=0; $i<$this->part_count; $i++) {
|
||||
if ($parts[$i] != $this->args[$i]) {
|
||||
// check if the path matches
|
||||
$parts = explode("/", $name);
|
||||
$part_count = count($parts);
|
||||
if ($part_count != count($this->args)) {
|
||||
return false;
|
||||
}
|
||||
$this->named_args = [];
|
||||
for ($i = 0; $i < $part_count; $i++) {
|
||||
if (str_starts_with($parts[$i], "{")) {
|
||||
$this->named_args[substr($parts[$i], 1, -1)] = $this->args[$i];
|
||||
} elseif ($parts[$i] != $this->args[$i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// if we matched the method and the path, but the page requires
|
||||
// authentication and the user is not authenticated, then complain
|
||||
if ($authed && $this->is_authed === false) {
|
||||
throw new PermissionDenied("Permission Denied: Missing CSRF Token");
|
||||
}
|
||||
if ($permission !== null && !$user->can($permission)) {
|
||||
throw new PermissionDenied("Permission Denied: {$user->name} lacks permission {$permission}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the n th argument of the page request (if it exists.)
|
||||
*/
|
||||
public function get_arg(int $n): string
|
||||
public function get_arg(string $n, ?string $default = null): string
|
||||
{
|
||||
$offset = $this->part_count + $n;
|
||||
if ($offset >= 0 && $offset < $this->arg_count) {
|
||||
return $this->args[$offset];
|
||||
if (array_key_exists($n, $this->named_args)) {
|
||||
return rawurldecode($this->named_args[$n]);
|
||||
} elseif ($default !== null) {
|
||||
return $default;
|
||||
} else {
|
||||
$nm1 = $this->arg_count - 1;
|
||||
throw new UserErrorException("Requested an invalid page argument {$offset} / {$nm1}");
|
||||
throw new UserError("Page argument {$n} is missing");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If page arg $n is set, then treat that as a 1-indexed page number
|
||||
* and return a 0-indexed page number less than $max; else return 0
|
||||
*/
|
||||
public function try_page_num(int $n, ?int $max=null): int
|
||||
public function get_iarg(string $n, ?int $default = null): int
|
||||
{
|
||||
if ($this->count_args() > $n) {
|
||||
$i = $this->get_arg($n);
|
||||
if (is_numeric($i) && int_escape($i) > 0) {
|
||||
return page_number($i, $max);
|
||||
} else {
|
||||
return 0;
|
||||
if (array_key_exists($n, $this->named_args)) {
|
||||
if (is_numberish($this->named_args[$n]) === false) {
|
||||
throw new UserError("Page argument {$n} exists but is not numeric");
|
||||
}
|
||||
return int_escape($this->named_args[$n]);
|
||||
} elseif ($default !== null) {
|
||||
return $default;
|
||||
} else {
|
||||
return 0;
|
||||
throw new UserError("Page argument {$n} is missing");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -322,3 +409,32 @@ class LogEvent extends Event
|
||||
class DatabaseUpgradeEvent extends Event
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
*/
|
||||
abstract class PartListBuildingEvent extends Event
|
||||
{
|
||||
/** @var T[] */
|
||||
private array $parts = [];
|
||||
|
||||
/**
|
||||
* @param T $html
|
||||
*/
|
||||
public function add_part(mixed $html, int $position = 50): void
|
||||
{
|
||||
while (isset($this->parts[$position])) {
|
||||
$position++;
|
||||
}
|
||||
$this->parts[$position] = $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<T>
|
||||
*/
|
||||
public function get_parts(): array
|
||||
{
|
||||
ksort($this->parts);
|
||||
return $this->parts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,12 +36,12 @@ class InstallerException extends \RuntimeException
|
||||
}
|
||||
}
|
||||
|
||||
class UserErrorException extends SCoreException
|
||||
class UserError extends SCoreException
|
||||
{
|
||||
public int $http_code = 400;
|
||||
}
|
||||
|
||||
class ServerErrorException extends SCoreException
|
||||
class ServerError extends SCoreException
|
||||
{
|
||||
public int $http_code = 500;
|
||||
}
|
||||
@@ -49,45 +49,28 @@ class ServerErrorException extends SCoreException
|
||||
/**
|
||||
* A fairly common, generic exception.
|
||||
*/
|
||||
class PermissionDeniedException extends UserErrorException
|
||||
class PermissionDenied extends UserError
|
||||
{
|
||||
public int $http_code = 403;
|
||||
}
|
||||
|
||||
/**
|
||||
* This exception is used when an Image cannot be found by ID.
|
||||
*/
|
||||
class ImageDoesNotExist extends UserErrorException
|
||||
class ObjectNotFound extends UserError
|
||||
{
|
||||
public int $http_code = 404;
|
||||
}
|
||||
|
||||
/**
|
||||
* This exception is used when a User cannot be found by some criteria.
|
||||
*/
|
||||
class UserDoesNotExist extends UserErrorException
|
||||
class ImageNotFound extends ObjectNotFound
|
||||
{
|
||||
}
|
||||
|
||||
class UserNotFound extends ObjectNotFound
|
||||
{
|
||||
public int $http_code = 404;
|
||||
}
|
||||
|
||||
/*
|
||||
* For validate_input()
|
||||
*/
|
||||
class InvalidInput extends UserErrorException
|
||||
class InvalidInput extends UserError
|
||||
{
|
||||
public int $http_code = 402;
|
||||
}
|
||||
|
||||
/*
|
||||
* This is used by the image resizing code when there is not enough memory to perform a resize.
|
||||
*/
|
||||
class InsufficientMemoryException extends ServerErrorException
|
||||
{
|
||||
}
|
||||
|
||||
/*
|
||||
* This is used by the image resizing code when there is an error while resizing
|
||||
*/
|
||||
class ImageResizeException extends ServerErrorException
|
||||
{
|
||||
}
|
||||
|
||||
@@ -117,6 +117,18 @@ enum ExtensionVisibility
|
||||
case HIDDEN;
|
||||
}
|
||||
|
||||
enum ExtensionCategory: string
|
||||
{
|
||||
case GENERAL = "General";
|
||||
case ADMIN = "Admin";
|
||||
case MODERATION = "Moderation";
|
||||
case FILE_HANDLING = "File Handling";
|
||||
case OBSERVABILITY = "Observability";
|
||||
case INTEGRATION = "Integration";
|
||||
case FEATURE = "Feature";
|
||||
case METADATA = "Metadata";
|
||||
}
|
||||
|
||||
abstract class ExtensionInfo
|
||||
{
|
||||
// Every credit you get costs us RAM. It stops now.
|
||||
@@ -141,8 +153,8 @@ abstract class ExtensionInfo
|
||||
public array $dependencies = [];
|
||||
public array $conflicts = [];
|
||||
public ExtensionVisibility $visibility = ExtensionVisibility::DEFAULT;
|
||||
public ExtensionCategory $category = ExtensionCategory::GENERAL;
|
||||
public ?string $link = null;
|
||||
public ?string $version = null;
|
||||
public ?string $documentation = null;
|
||||
|
||||
/** @var DatabaseDriverID[] which DBs this ext supports (blank for 'all') */
|
||||
@@ -244,7 +256,7 @@ abstract class ExtensionInfo
|
||||
foreach (get_subclasses_of("Shimmie2\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");
|
||||
throw new ServerError("Extension Info $class with key $extension_info->key has already been loaded");
|
||||
}
|
||||
|
||||
self::$all_info_by_key[$extension_info->key] = $extension_info;
|
||||
@@ -285,13 +297,62 @@ abstract class DataHandlerExtension extends Extension
|
||||
|
||||
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']}"
|
||||
);
|
||||
global $config;
|
||||
|
||||
if ($this->supported_mime($event->mime)) {
|
||||
if (!$this->check_contents($event->tmpname)) {
|
||||
// We DO support this extension - but the file looks corrupt
|
||||
throw new UploadException("Invalid or corrupted file");
|
||||
}
|
||||
|
||||
$existing = Image::by_hash(\Safe\md5_file($event->tmpname));
|
||||
if (!is_null($existing)) {
|
||||
if ($config->get_string(ImageConfig::UPLOAD_COLLISION_HANDLER) == ImageConfig::COLLISION_MERGE) {
|
||||
// Right now tags are the only thing that get merged, so
|
||||
// we can just send a TagSetEvent - in the future we might
|
||||
// want a dedicated MergeEvent?
|
||||
if (!empty($event->metadata['tags'])) {
|
||||
$tags = Tag::explode($existing->get_tag_list() . " " . $event->metadata['tags']);
|
||||
send_event(new TagSetEvent($existing, $tags));
|
||||
}
|
||||
$event->images[] = $existing;
|
||||
return;
|
||||
} else {
|
||||
throw new UploadException(">>{$existing->id} already has hash {$existing->hash}");
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new Image object
|
||||
$filename = $event->tmpname;
|
||||
assert(is_readable($filename));
|
||||
$image = new Image();
|
||||
$image->tmp_file = $filename;
|
||||
$image->filesize = \Safe\filesize($filename);
|
||||
$image->hash = \Safe\md5_file($filename);
|
||||
// DB limits to 255 char filenames
|
||||
$image->filename = substr($event->filename, -250);
|
||||
$image->set_mime($event->mime);
|
||||
try {
|
||||
send_event(new MediaCheckPropertiesEvent($image));
|
||||
} catch (MediaException $e) {
|
||||
throw new UploadException("Unable to scan media properties $filename / $image->filename / $image->hash: ".$e->getMessage());
|
||||
}
|
||||
$image->save_to_db(); // Ensure the image has a DB-assigned ID
|
||||
|
||||
$iae = send_event(new ImageAdditionEvent($image));
|
||||
send_event(new ImageInfoSetEvent($image, $event->slot, $event->metadata));
|
||||
|
||||
// If everything is OK, then move the file to the archive
|
||||
$filename = warehouse_path(Image::IMAGE_DIR, $event->hash);
|
||||
if (!@copy($event->tmpname, $filename)) {
|
||||
$errors = error_get_last();
|
||||
throw new UploadException(
|
||||
"Failed to copy file from uploads ({$event->tmpname}) to archive ($filename): ".
|
||||
"{$errors['type']} / {$errors['message']}"
|
||||
);
|
||||
}
|
||||
|
||||
$event->images[] = $iae->image;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ class ImageAdditionEvent extends Event
|
||||
public bool $merged = false;
|
||||
|
||||
/**
|
||||
* Inserts a new image into the database with its associated
|
||||
* information. Also calls TagSetEvent to set the tags for
|
||||
* this new image.
|
||||
* A new image is being added to the database - just the image,
|
||||
* metadata will come later with ImageInfoSetEvent (and if that
|
||||
* fails, then the image addition transaction will be rolled back)
|
||||
*/
|
||||
public function __construct(
|
||||
public Image $image,
|
||||
|
||||
@@ -69,25 +69,71 @@ class Image
|
||||
foreach ($row as $name => $value) {
|
||||
if (is_numeric($name)) {
|
||||
continue;
|
||||
}
|
||||
} elseif (property_exists($this, $name)) {
|
||||
$t = (new \ReflectionProperty($this, $name))->getType();
|
||||
assert(!is_null($t));
|
||||
if (is_a($t, \ReflectionNamedType::class)) {
|
||||
if (is_null($value)) {
|
||||
$this->$name = null;
|
||||
} else {
|
||||
$this->$name = match($t->getName()) {
|
||||
"int" => int_escape((string)$value),
|
||||
"bool" => bool_escape((string)$value),
|
||||
"string" => (string)$value,
|
||||
default => $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);
|
||||
}
|
||||
} elseif (array_key_exists($name, static::$prop_types)) {
|
||||
if (is_null($value)) {
|
||||
$value = null;
|
||||
} else {
|
||||
$value = match(static::$prop_types[$name]) {
|
||||
ImagePropType::BOOL => bool_escape((string)$value),
|
||||
ImagePropType::INT => int_escape((string)$value),
|
||||
ImagePropType::STRING => (string)$value,
|
||||
};
|
||||
}
|
||||
$this->dynamic_props[$name] = $value;
|
||||
} else {
|
||||
$this->$name = $value;
|
||||
// Database table has a column we don't know about,
|
||||
// it isn't static and it isn't a known prop_type -
|
||||
// maybe from an old extension that has since been
|
||||
// disabled? Just ignore it.
|
||||
if (defined('UNITTEST')) {
|
||||
throw new \Exception("Unknown column $name in images table");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function offsetExists(mixed $offset): bool
|
||||
{
|
||||
assert(is_string($offset));
|
||||
return array_key_exists($offset, static::$prop_types);
|
||||
}
|
||||
public function offsetGet(mixed $offset): mixed
|
||||
{
|
||||
assert(is_string($offset));
|
||||
if (!$this->offsetExists($offset)) {
|
||||
$known = implode(", ", array_keys(static::$prop_types));
|
||||
throw new \OutOfBoundsException("Undefined dynamic property: $offset (Known: $known)");
|
||||
}
|
||||
return $this->dynamic_props[$offset] ?? null;
|
||||
}
|
||||
public function offsetSet(mixed $offset, mixed $value): void
|
||||
{
|
||||
assert(is_string($offset));
|
||||
$this->dynamic_props[$offset] = $value;
|
||||
}
|
||||
public function offsetUnset(mixed $offset): void
|
||||
{
|
||||
assert(is_string($offset));
|
||||
unset($this->dynamic_props[$offset]);
|
||||
}
|
||||
|
||||
#[Field(name: "post_id")]
|
||||
public function graphql_oid(): int
|
||||
{
|
||||
@@ -111,6 +157,15 @@ class Image
|
||||
return ($row ? new Image($row) : null);
|
||||
}
|
||||
|
||||
public static function by_id_ex(int $post_id): Image
|
||||
{
|
||||
$maybe_post = static::by_id($post_id);
|
||||
if (!is_null($maybe_post)) {
|
||||
return $maybe_post;
|
||||
}
|
||||
throw new ImageNotFound("Image $post_id not found");
|
||||
}
|
||||
|
||||
public static function by_hash(string $hash): ?Image
|
||||
{
|
||||
global $database;
|
||||
@@ -460,7 +515,7 @@ class Image
|
||||
#[Field(name: "image_link")]
|
||||
public function get_image_link(): string
|
||||
{
|
||||
return $this->get_link(ImageConfig::ILINK, '_images/$hash/$id%20-%20$tags.$ext', 'image/$id.$ext');
|
||||
return $this->get_link(ImageConfig::ILINK, '_images/$hash/$id%20-%20$tags.$ext', 'image/$id/$id%20-%20$tags.$ext');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -481,7 +536,7 @@ class Image
|
||||
global $config;
|
||||
$mime = $config->get_string(ImageConfig::THUMB_MIME);
|
||||
$ext = FileExtension::get_for_mime($mime);
|
||||
return $this->get_link(ImageConfig::TLINK, '_thumbs/$hash/thumb.'.$ext, 'thumb/$id.'.$ext);
|
||||
return $this->get_link(ImageConfig::TLINK, '_thumbs/$hash/thumb.'.$ext, 'thumb/$id/thumb.'.$ext);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -498,7 +553,7 @@ class Image
|
||||
$image_link = make_link($image_link);
|
||||
}
|
||||
$chosen = $image_link;
|
||||
} elseif ($config->get_bool('nice_urls', false)) {
|
||||
} elseif ($config->get_bool(SetupConfig::NICE_URLS, false)) {
|
||||
$chosen = make_link($nice);
|
||||
} else {
|
||||
$chosen = make_link($plain);
|
||||
@@ -534,6 +589,9 @@ class Image
|
||||
*/
|
||||
public function get_image_filename(): string
|
||||
{
|
||||
if (!is_null($this->tmp_file)) {
|
||||
return $this->tmp_file;
|
||||
}
|
||||
return warehouse_path(self::IMAGE_DIR, $this->hash);
|
||||
}
|
||||
|
||||
@@ -545,15 +603,6 @@ class Image
|
||||
return warehouse_path(self::THUMBNAIL_DIR, $this->hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original filename.
|
||||
*/
|
||||
#[Field(name: "filename")]
|
||||
public function get_filename(): string
|
||||
{
|
||||
return $this->filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the image's extension.
|
||||
*/
|
||||
@@ -726,9 +775,17 @@ class Image
|
||||
*/
|
||||
public function remove_image_only(): void
|
||||
{
|
||||
log_info("core_image", 'Removed Post File ('.$this->hash.')');
|
||||
@unlink($this->get_image_filename());
|
||||
@unlink($this->get_thumb_filename());
|
||||
$img_del = @unlink($this->get_image_filename());
|
||||
$thumb_del = @unlink($this->get_thumb_filename());
|
||||
if ($img_del && $thumb_del) {
|
||||
if (!$quiet) {
|
||||
log_info("core_image", "Deleted files for Post #{$this->id} ({$this->hash})");
|
||||
}
|
||||
} else {
|
||||
$img = $img_del ? '' : ' image';
|
||||
$thumb = $thumb_del ? '' : ' thumbnail';
|
||||
log_error('core_image', "Failed to delete files for Post #{$this->id}{$img}{$thumb}");
|
||||
}
|
||||
}
|
||||
|
||||
public function parse_link_template(string $tmpl, int $n=0): string
|
||||
|
||||
@@ -25,8 +25,18 @@ function add_dir(string $base): array
|
||||
$tags = path_to_tags($short_path);
|
||||
$result = "$short_path (".str_replace(" ", ", ", $tags).")... ";
|
||||
try {
|
||||
add_image($full_path, $filename, $tags);
|
||||
$result .= "ok";
|
||||
$more_results = $database->with_savepoint(function () use ($full_path, $filename, $tags) {
|
||||
$dae = send_event(new DataUploadEvent($full_path, basename($full_path), 0, [
|
||||
'filename' => pathinfo($filename, PATHINFO_BASENAME),
|
||||
'tags' => Tag::implode($tags),
|
||||
]));
|
||||
$results = [];
|
||||
foreach ($dae->images as $image) {
|
||||
$results[] = new UploadSuccess($filename, $image->id);
|
||||
}
|
||||
return $results;
|
||||
});
|
||||
$results = array_merge($results, $more_results);
|
||||
} catch (UploadException $ex) {
|
||||
$result .= "failed: ".$ex->getMessage();
|
||||
}
|
||||
@@ -182,13 +192,13 @@ function create_scaled_image(string $inname, string $outname, array $tsize, stri
|
||||
));
|
||||
}
|
||||
|
||||
function redirect_to_next_image(Image $image): void
|
||||
function redirect_to_next_image(Image $image, ?string $search = null): void
|
||||
{
|
||||
global $page;
|
||||
|
||||
if (isset($_GET['search'])) {
|
||||
$search_terms = Tag::explode(Tag::decaret($_GET['search']));
|
||||
$query = "search=" . url_escape($_GET['search']);
|
||||
if (!is_null($search)) {
|
||||
$search_terms = Tag::explode($search);
|
||||
$query = "search=" . url_escape($search);
|
||||
} else {
|
||||
$search_terms = [];
|
||||
$query = null;
|
||||
|
||||
@@ -4,6 +4,23 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shimmie2;
|
||||
|
||||
use GQLA\Query;
|
||||
|
||||
/**
|
||||
* A small chunk of SQL code + parameters, to be used in a larger query
|
||||
*
|
||||
* eg
|
||||
*
|
||||
* $q = new Querylet("SELECT * FROM images");
|
||||
* $q->append(new Querylet(" WHERE id = :id", ["id" => 123]));
|
||||
* $q->append(new Querylet(" AND rating = :rating", ["rating" => "safe"]));
|
||||
* $q->append(new Querylet(" ORDER BY id DESC"));
|
||||
*
|
||||
* becomes
|
||||
*
|
||||
* SELECT * FROM images WHERE id = :id AND rating = :rating ORDER BY id DESC
|
||||
* ["id" => 123, "rating" => "safe"]
|
||||
*/
|
||||
class Querylet
|
||||
{
|
||||
public function __construct(
|
||||
@@ -29,6 +46,9 @@ class Querylet
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When somebody has searched for a tag, "cat", "cute", "-angry", etc
|
||||
*/
|
||||
class TagCondition
|
||||
{
|
||||
public function __construct(
|
||||
@@ -38,6 +58,10 @@ class TagCondition
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When somebody has searched for a specific image property, like "rating:safe",
|
||||
* "id:123", "width:100", etc
|
||||
*/
|
||||
class ImgCondition
|
||||
{
|
||||
public function __construct(
|
||||
@@ -46,3 +70,402 @@ class ImgCondition
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
class Search
|
||||
{
|
||||
/**
|
||||
* The search code is dark and full of horrors, and it's not always clear
|
||||
* what's going on. This is a list of the steps that the search code took
|
||||
* to find the images that it returned.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public static array $_search_path = [];
|
||||
|
||||
/**
|
||||
* Build a search query for a given set of tags and return
|
||||
* the results as a PDOStatement (raw SQL rows)
|
||||
*
|
||||
* @param list<string> $tags
|
||||
*/
|
||||
private static function find_images_internal(int $start = 0, ?int $limit = null, array $tags = []): \FFSPHP\PDOStatement
|
||||
{
|
||||
global $database, $user;
|
||||
|
||||
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 PermissionDenied("Anonymous users may only search for up to 3 tags at a time");
|
||||
}
|
||||
}
|
||||
|
||||
[$tag_conditions, $img_conditions, $order] = self::terms_to_conditions($tags);
|
||||
$querylet = self::build_search_querylet($tag_conditions, $img_conditions, $order, $limit, $start);
|
||||
return $database->get_all_iterable($querylet->sql, $querylet->variables);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for an array of images
|
||||
*
|
||||
* @param list<string> $tags
|
||||
* @return Image[]
|
||||
*/
|
||||
#[Query(name: "posts", type: "[Post!]!", args: ["tags" => "[string!]"])]
|
||||
public static function find_images(int $offset = 0, ?int $limit = null, array $tags = []): array
|
||||
{
|
||||
$result = self::find_images_internal($offset, $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
|
||||
*
|
||||
* @param list<string> $tags
|
||||
* @return \Generator<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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific set of images, in the order that the set specifies,
|
||||
* with all the search stuff (rating filters etc) taken into account
|
||||
*
|
||||
* @param int[] $ids
|
||||
* @return Image[]
|
||||
*/
|
||||
public static function get_images(array $ids): array
|
||||
{
|
||||
$visible_images = [];
|
||||
foreach (Search::find_images(tags: ["id=" . implode(",", $ids)]) as $image) {
|
||||
$visible_images[$image->id] = $image;
|
||||
}
|
||||
$visible_ids = array_keys($visible_images);
|
||||
|
||||
$visible_popular_ids = array_filter($ids, fn ($id) => in_array($id, $visible_ids));
|
||||
$images = array_map(fn ($id) => $visible_images[$id], $visible_popular_ids);
|
||||
return $images;
|
||||
}
|
||||
|
||||
/*
|
||||
* Image-related utility functions
|
||||
*/
|
||||
|
||||
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]
|
||||
);
|
||||
}
|
||||
|
||||
private static function count_total_images(): int
|
||||
{
|
||||
global $database;
|
||||
return cache_get_or_set("image-count", fn () => (int)$database->get_one("SELECT COUNT(*) FROM images"), 600);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of image results for a given search
|
||||
*
|
||||
* @param list<string> $tags
|
||||
*/
|
||||
public static function count_images(array $tags = []): int
|
||||
{
|
||||
global $cache, $database;
|
||||
$tag_count = count($tags);
|
||||
|
||||
// SPEED_HAX ignores the fact that extensions can add img_conditions
|
||||
// even when there are no tags being searched for
|
||||
if (SPEED_HAX && $tag_count === 0) {
|
||||
// total number of images in the DB
|
||||
$total = self::count_total_images();
|
||||
} elseif (SPEED_HAX && $tag_count === 1 && !preg_match("/[:=><\*\?]/", $tags[0])) {
|
||||
if (!str_starts_with($tags[0], "-")) {
|
||||
// one positive 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, so use the hash of tags as the key
|
||||
$cache_key = "image-count:" . md5(Tag::implode($tags));
|
||||
$total = $cache->get($cache_key);
|
||||
if (is_null($total)) {
|
||||
[$tag_conditions, $img_conditions, $order] = self::terms_to_conditions($tags);
|
||||
$querylet = self::build_search_querylet($tag_conditions, $img_conditions, null);
|
||||
$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);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
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_id() === DatabaseDriverID::SQLITE) {
|
||||
$sq .= "ESCAPE '\\'";
|
||||
}
|
||||
return $database->get_col($sq, ["tag" => Tag::sqlify($tag)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a human input string into a an abstract search query
|
||||
*
|
||||
* (This is only public for testing purposes, nobody should be calling this
|
||||
* directly from outside this class)
|
||||
*
|
||||
* @param string[] $terms
|
||||
* @return array{0: TagCondition[], 1: ImgCondition[], 2: string}
|
||||
*/
|
||||
public static function terms_to_conditions(array $terms): array
|
||||
{
|
||||
global $config;
|
||||
|
||||
$tag_conditions = [];
|
||||
$img_conditions = [];
|
||||
$order = null;
|
||||
|
||||
/*
|
||||
* Turn a bunch of strings into a bunch of TagCondition
|
||||
* and ImgCondition objects
|
||||
*/
|
||||
$stpen = 0; // search term parse event number
|
||||
foreach (array_merge([null], $terms) as $term) {
|
||||
$stpe = send_event(new SearchTermParseEvent($stpen++, $term, $terms));
|
||||
$order ??= $stpe->order;
|
||||
$img_conditions = array_merge($img_conditions, $stpe->img_conditions);
|
||||
$tag_conditions = array_merge($tag_conditions, $stpe->tag_conditions);
|
||||
}
|
||||
|
||||
$order = ($order ?: "images.".$config->get_string(IndexConfig::ORDER));
|
||||
|
||||
return [$tag_conditions, $img_conditions, $order];
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn an abstract search query into an SQL Querylet
|
||||
*
|
||||
* (This is only public for testing purposes, nobody should be calling this
|
||||
* directly from outside this class)
|
||||
*
|
||||
* @param TagCondition[] $tag_conditions
|
||||
* @param ImgCondition[] $img_conditions
|
||||
*/
|
||||
public static function build_search_querylet(
|
||||
array $tag_conditions,
|
||||
array $img_conditions,
|
||||
?string $order = null,
|
||||
?int $limit = null,
|
||||
?int $offset = null
|
||||
): Querylet {
|
||||
// no tags, do a simple search
|
||||
if (count($tag_conditions) === 0) {
|
||||
static::$_search_path[] = "no_tags";
|
||||
$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 (
|
||||
count($tag_conditions) === 1
|
||||
&& $tag_conditions[0]->positive
|
||||
// We can only do this if img_conditions is empty, because
|
||||
// we're going to apply the offset / limit to the image_tags
|
||||
// subquery, and applying extra conditions to the top-level
|
||||
// query might reduce the total results below the target limit
|
||||
&& empty($img_conditions)
|
||||
// We can only do this if we're sorting by ID, because
|
||||
// we're going to be using the image_tags table, which
|
||||
// only has image_id and tag_id, not any other columns
|
||||
&& ($order == "id DESC" || $order == "images.id DESC")
|
||||
// This is only an optimisation if we are applying limit
|
||||
// and offset
|
||||
&& !is_null($limit)
|
||||
&& !is_null($offset)
|
||||
) {
|
||||
static::$_search_path[] = "fast";
|
||||
$tc = $tag_conditions[0];
|
||||
// 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($tc->tag);
|
||||
if (count($tag_array) == 0) {
|
||||
// if wildcard expanded to nothing, take a shortcut
|
||||
static::$_search_path[] = "invalid_tag";
|
||||
$query = new Querylet("SELECT images.* FROM images WHERE 1=0");
|
||||
} else {
|
||||
$set = implode(', ', $tag_array);
|
||||
$query = new Querylet("
|
||||
SELECT images.*
|
||||
FROM images INNER JOIN (
|
||||
SELECT DISTINCT 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
|
||||
WHERE 1=1
|
||||
", ["limit" => $limit, "offset" => $offset]);
|
||||
// don't offset at the image level because
|
||||
// we already offset at the image_tags level
|
||||
$limit = null;
|
||||
$offset = null;
|
||||
}
|
||||
}
|
||||
|
||||
// more than one tag, or more than zero other conditions, or a non-default sort order
|
||||
else {
|
||||
static::$_search_path[] = "general";
|
||||
$positive_tag_id_array = [];
|
||||
$positive_wildcard_id_array = [];
|
||||
$negative_tag_id_array = [];
|
||||
$all_nonexistent_negatives = true;
|
||||
|
||||
foreach ($tag_conditions as $tq) {
|
||||
$tag_ids = self::tag_or_wildcard_to_ids($tq->tag);
|
||||
$tag_count = count($tag_ids);
|
||||
|
||||
if ($tq->positive) {
|
||||
$all_nonexistent_negatives = false;
|
||||
if ($tag_count == 0) {
|
||||
# one of the positive tags had zero results, therefor there
|
||||
# can be no results; "where 1=0" should shortcut things
|
||||
static::$_search_path[] = "invalid_tag";
|
||||
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 {
|
||||
if ($tag_count > 0) {
|
||||
$all_nonexistent_negatives = false;
|
||||
// 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 || $all_nonexistent_negatives, _get_query());
|
||||
|
||||
if ($all_nonexistent_negatives) {
|
||||
static::$_search_path[] = "all_nonexistent_negatives";
|
||||
$query = new Querylet("SELECT images.* FROM images WHERE 1=1");
|
||||
} elseif (!empty($positive_tag_id_array) || !empty($positive_wildcard_id_array)) {
|
||||
static::$_search_path[] = "some_positives";
|
||||
$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 DISTINCT 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)) {
|
||||
static::$_search_path[] = "only_negative_tags";
|
||||
$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 InvalidInput("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(new Querylet(" 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,9 +208,6 @@ class 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;
|
||||
}
|
||||
|
||||
@@ -248,7 +245,7 @@ class Tag
|
||||
foreach ($tags as $tag) {
|
||||
try {
|
||||
$tag = Tag::sanitize($tag);
|
||||
} catch (\Exception $e) {
|
||||
} catch (UserError $e) {
|
||||
$page->flash($e->getMessage());
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shimmie2;
|
||||
|
||||
require_once "core/urls.php";
|
||||
|
||||
/**
|
||||
* Shimmie Installer
|
||||
*
|
||||
@@ -121,13 +123,15 @@ function ask_questions()
|
||||
$warn_msg = $warnings ? "<h3>Warnings</h3>".implode("\n<p>", $warnings) : "";
|
||||
$err_msg = $errors ? "<h3>Errors</h3>".implode("\n<p>", $errors) : "";
|
||||
|
||||
$data_href = get_base_href();
|
||||
|
||||
die_nicely(
|
||||
"Install Options",
|
||||
<<<EOD
|
||||
$warn_msg
|
||||
$err_msg
|
||||
|
||||
<form action="index.php" method="POST">
|
||||
<form action="$data_href/index.php" method="POST">
|
||||
<table class='form' style="margin: 1em auto;">
|
||||
<tr>
|
||||
<th>Type:</th>
|
||||
|
||||
@@ -22,13 +22,13 @@ use function MicroHTML\TR;
|
||||
use function MicroHTML\TH;
|
||||
use function MicroHTML\TD;
|
||||
|
||||
function SHM_FORM(string $target, string $method="POST", bool $multipart=false, string $form_id="", string $onsubmit="", string $name=""): HTMLElement
|
||||
function SHM_FORM(string $target, bool $multipart = false, string $form_id = "", string $onsubmit = "", string $name = ""): HTMLElement
|
||||
{
|
||||
global $user;
|
||||
|
||||
$attrs = [
|
||||
"action"=>make_link($target),
|
||||
"method"=>$method
|
||||
"action" => make_link($target),
|
||||
"method" => 'POST'
|
||||
];
|
||||
|
||||
if ($form_id) {
|
||||
@@ -43,10 +43,10 @@ function SHM_FORM(string $target, string $method="POST", bool $multipart=false,
|
||||
if ($name) {
|
||||
$attrs["name"] = $name;
|
||||
}
|
||||
|
||||
return FORM(
|
||||
$attrs,
|
||||
INPUT(["type"=>"hidden", "name"=>"q", "value"=>$target]),
|
||||
$method == "GET" ? "" : $user->get_auth_microhtml()
|
||||
INPUT(["type" => "hidden", "name" => "auth_token", "value" => $user->get_auth_token()])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,5 +149,30 @@ function SHM_OPTION(string $value, string $text, bool $selected=false): HTMLElem
|
||||
return OPTION(["value"=>$value, "selected"=>""], $text);
|
||||
}
|
||||
|
||||
return OPTION(["value"=>$value], $text);
|
||||
return OPTION(["value" => $value], $text);
|
||||
}
|
||||
|
||||
function SHM_POST_INFO(
|
||||
string $title,
|
||||
HTMLElement|string|null $view = null,
|
||||
HTMLElement|string|null $edit = null,
|
||||
string|null $link = null,
|
||||
): HTMLElement {
|
||||
if (!is_null($view) && !is_null($edit)) {
|
||||
$show = emptyHTML(
|
||||
SPAN(["class" => "view"], $view),
|
||||
SPAN(["class" => "edit"], $edit),
|
||||
);
|
||||
} elseif (!is_null($edit)) {
|
||||
$show = $edit;
|
||||
} elseif (!is_null($view)) {
|
||||
$show = $view;
|
||||
} else {
|
||||
$show = "???";
|
||||
}
|
||||
return TR(
|
||||
["data-row" => $title],
|
||||
TH(["width" => "50px"], $link ? A(["href" => $link], $title) : $title),
|
||||
TD($show)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ abstract class Permissions
|
||||
public const BULK_EDIT_VOTE = "bulk_edit_vote";
|
||||
public const EDIT_OTHER_VOTE = "edit_other_vote";
|
||||
|
||||
public const VIEW_SYSINTO = "view_sysinfo";
|
||||
public const VIEW_SYSINFO = "view_sysinfo";
|
||||
|
||||
public const HELLBANNED = "hellbanned";
|
||||
public const VIEW_HELLBANNED = "view_hellbanned";
|
||||
@@ -113,15 +113,24 @@ abstract class Permissions
|
||||
|
||||
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";
|
||||
public const BYPASS_IMAGE_APPROVAL = "bypass_image_approval";
|
||||
|
||||
public const FORUM_ADMIN = "forum_admin";
|
||||
public const FORUM_CREATE = "forum_create";
|
||||
|
||||
public const NOTES_ADMIN = "notes_admin";
|
||||
public const NOTES_CREATE = "notes_create";
|
||||
public const NOTES_EDIT = "notes_edit";
|
||||
public const NOTES_REQUEST = "notes_request";
|
||||
|
||||
public const POOLS_ADMIN = "pools_admin";
|
||||
public const POOLS_CREATE = "pools_create";
|
||||
public const POOLS_UPDATE = "pools_update";
|
||||
|
||||
public const SET_PRIVATE_IMAGE = "set_private_image";
|
||||
public const SET_OTHERS_PRIVATE_IMAGES = "set_others_private_images";
|
||||
|
||||
|
||||
@@ -36,7 +36,11 @@ function array_iunique(array $array): array
|
||||
*/
|
||||
function ip_in_range(string $IP, string $CIDR): bool
|
||||
{
|
||||
list($net, $mask) = explode("/", $CIDR);
|
||||
$parts = explode("/", $CIDR);
|
||||
if (count($parts) == 1) {
|
||||
$parts[1] = "32";
|
||||
}
|
||||
list($net, $mask) = $parts;
|
||||
|
||||
$ip_net = ip2long($net);
|
||||
$ip_mask = ~((1 << (32 - (int)$mask)) - 1);
|
||||
@@ -129,8 +133,8 @@ function list_files(string $base, string $_sub_dir=""): array
|
||||
|
||||
$files = [];
|
||||
$dir = opendir("$base/$_sub_dir");
|
||||
if ($dir===false) {
|
||||
throw new SCoreException("Unable to open directory $base/$_sub_dir");
|
||||
if ($dir === false) {
|
||||
throw new UserError("Unable to open directory $base/$_sub_dir");
|
||||
}
|
||||
try {
|
||||
while ($f = readdir($dir)) {
|
||||
@@ -172,6 +176,9 @@ function flush_output(): void
|
||||
function stream_file(string $file, int $start, int $end): void
|
||||
{
|
||||
$fp = fopen($file, 'r');
|
||||
if (!$fp) {
|
||||
throw new \Exception("Failed to open $file");
|
||||
}
|
||||
try {
|
||||
fseek($fp, $start);
|
||||
$buffer = 1024 * 1024;
|
||||
@@ -244,23 +251,9 @@ function find_header(array $headers, string $name): ?string
|
||||
return $header;
|
||||
}
|
||||
|
||||
if (!function_exists('mb_strlen')) {
|
||||
// TODO: we should warn the admin that they are missing multibyte support
|
||||
/** @noinspection PhpUnusedParameterInspection */
|
||||
function mb_strlen($str, $encoding): int
|
||||
{
|
||||
return strlen($str);
|
||||
}
|
||||
function mb_internal_encoding($encoding): void
|
||||
{
|
||||
}
|
||||
function mb_strtolower($str): string
|
||||
{
|
||||
return strtolower($str);
|
||||
}
|
||||
}
|
||||
|
||||
/** @noinspection PhpUnhandledExceptionInspection */
|
||||
/**
|
||||
* @return class-string[]
|
||||
*/
|
||||
function get_subclasses_of(string $parent): array
|
||||
{
|
||||
$result = [];
|
||||
@@ -467,25 +460,26 @@ function clamp(int $val, ?int $min=null, ?int $max=null): int
|
||||
return $val;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
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) {
|
||||
$e = "UTF-8";
|
||||
$strlen = mb_strlen($string, $e);
|
||||
$padlen = mb_strlen($pad, $e);
|
||||
assert($limit > $padlen, "Can't truncate to a length less than the padding length");
|
||||
|
||||
// if string is shorter or equal to limit, leave it alone
|
||||
if ($strlen <= $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;
|
||||
}
|
||||
// if there is a break point between 0 and $limit, truncate to that
|
||||
$breakpoint = mb_strrpos($string, $break, -($strlen - $limit + $padlen), $e);
|
||||
if ($breakpoint !== false) {
|
||||
return mb_substr($string, 0, $breakpoint, $e) . $pad;
|
||||
}
|
||||
|
||||
return $string;
|
||||
// if there is no break point, cut mid-word
|
||||
return mb_substr($string, 0, $limit - $padlen, $e) . $pad;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -524,7 +518,7 @@ function parse_shorthand_int(string $limit): int
|
||||
/**
|
||||
* Turn an integer into a human readable filesize, eg 1024 -> 1KB
|
||||
*/
|
||||
function to_shorthand_int(int $int): string
|
||||
function to_shorthand_int(int|float $int): string
|
||||
{
|
||||
assert($int >= 0);
|
||||
|
||||
@@ -615,8 +609,8 @@ function parse_to_milliseconds(string $input): int
|
||||
*/
|
||||
function autodate(string $date, bool $html=true): string
|
||||
{
|
||||
$cpu = date('c', strtotime($date));
|
||||
$hum = date('F j, Y; H:i', strtotime($date));
|
||||
$cpu = date('c', \Safe\strtotime($date));
|
||||
$hum = date('F j, Y; H:i', \Safe\strtotime($date));
|
||||
return ($html ? "<time datetime='$cpu'>$hum</time>" : $hum);
|
||||
}
|
||||
|
||||
@@ -709,7 +703,7 @@ function validate_input(array $inputs): array
|
||||
} 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)));
|
||||
$outputs[$key] = date("Y-m-d H:i:s", \Safe\strtotime(trim($value)));
|
||||
} elseif (in_array('string', $flags)) {
|
||||
if (in_array('trim', $flags)) {
|
||||
$value = trim($value);
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shimmie2;
|
||||
|
||||
require_once "core/urls.php";
|
||||
|
||||
/*
|
||||
* A small number of PHP-sanity things (eg don't silently ignore errors) to
|
||||
* be included right at the very start of index.php and tests/bootstrap.php
|
||||
@@ -11,18 +13,20 @@ namespace Shimmie2;
|
||||
|
||||
function die_nicely($title, $body, $code=0)
|
||||
{
|
||||
$data_href = get_base_href();
|
||||
print("<!DOCTYPE html>
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<title>Shimmie</title>
|
||||
<link rel=\"shortcut icon\" href=\"ext/static_files/static/favicon.ico\">
|
||||
<link rel=\"stylesheet\" href=\"ext/static_files/style.css\" type=\"text/css\">
|
||||
<link rel='shortcut icon' href='$data_href/ext/static_files/static/favicon.ico'>
|
||||
<link rel='stylesheet' href='$data_href/ext/static_files/style.css' type='text/css'>
|
||||
<link rel='stylesheet' href='$data_href/ext/static_files/installer.css' type='text/css'>
|
||||
</head>
|
||||
<body>
|
||||
<div id=\"installer\">
|
||||
<div id='installer'>
|
||||
<h1>Shimmie</h1>
|
||||
<h3>$title</h3>
|
||||
<div class=\"container\">
|
||||
<div class='container'>
|
||||
$body
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,9 @@ $_shm_event_listeners = [];
|
||||
|
||||
function _load_event_listeners(): void
|
||||
{
|
||||
global $_shm_event_listeners;
|
||||
global $_shm_event_listeners, $_tracer;
|
||||
|
||||
$_tracer->begin("Load Event Listeners");
|
||||
|
||||
$cache_path = data_path("cache/shm_event_listeners.php");
|
||||
if (SPEED_HAX && file_exists($cache_path)) {
|
||||
@@ -31,6 +33,8 @@ function _load_event_listeners(): void
|
||||
_dump_event_listeners($_shm_event_listeners, $cache_path);
|
||||
}
|
||||
}
|
||||
|
||||
$_tracer->end();
|
||||
}
|
||||
|
||||
function _clear_cached_event_listeners(): void
|
||||
|
||||
@@ -31,7 +31,7 @@ _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.10.0-alpha$_g"); // string shimmie version
|
||||
_d("VERSION", "2.11.5"); // 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)
|
||||
|
||||
18
core/tests/ImageTest.php
Normal file
18
core/tests/ImageTest.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shimmie2;
|
||||
|
||||
require_once "core/imageboard/image.php";
|
||||
|
||||
class ImageTest extends ShimmiePHPUnitTestCase
|
||||
{
|
||||
public function testLoadData(): void
|
||||
{
|
||||
$this->log_in_as_user();
|
||||
$image_id_1 = $this->post_image("tests/pbx_screenshot.jpg", "question? colon:thing exclamation!");
|
||||
$image = Image::by_id_ex($image_id_1);
|
||||
$this->assertNull($image->source);
|
||||
}
|
||||
}
|
||||
54
core/tests/PageRequestEventTest.php
Normal file
54
core/tests/PageRequestEventTest.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shimmie2;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class PageRequestEventTest extends TestCase
|
||||
{
|
||||
public function testPageMatches(): void
|
||||
{
|
||||
$e = new PageRequestEvent("GET", "foo/bar", [], []);
|
||||
|
||||
$this->assertFalse($e->page_matches("foo"));
|
||||
$this->assertFalse($e->page_matches("foo/qux"));
|
||||
$this->assertTrue($e->page_matches("foo/bar"));
|
||||
$this->assertFalse($e->page_matches("foo/bar/baz"));
|
||||
|
||||
$this->assertFalse($e->page_matches("{thing}"));
|
||||
|
||||
$this->assertTrue($e->page_matches("foo/{thing}"));
|
||||
$this->assertEquals("bar", $e->get_arg('thing'));
|
||||
|
||||
$this->assertTrue($e->page_matches("{thing}/bar"));
|
||||
$this->assertEquals("foo", $e->get_arg('thing'));
|
||||
$this->assertFalse($e->page_matches("qux/{thing}"));
|
||||
$this->assertFalse($e->page_matches("foo/{thing}/long"));
|
||||
}
|
||||
|
||||
public function testPageMatchesPaged(): void
|
||||
{
|
||||
$e = new PageRequestEvent("GET", "foo/bar/4", [], []);
|
||||
|
||||
$this->assertFalse($e->page_matches("foo", paged: true));
|
||||
$this->assertEquals(1, $e->get_iarg('page_num', 1));
|
||||
$this->assertFalse($e->page_matches("foo/qux", paged: true));
|
||||
$this->assertTrue($e->page_matches("foo/bar", paged: true));
|
||||
$this->assertEquals(4, $e->get_iarg('page_num', 1));
|
||||
$this->assertFalse($e->page_matches("foo/bar/baz", paged: true));
|
||||
|
||||
$this->assertFalse($e->page_matches("{thing}", paged: true));
|
||||
|
||||
$this->assertTrue($e->page_matches("foo/{thing}", paged: true));
|
||||
$this->assertEquals("bar", $e->get_arg('thing'));
|
||||
$this->assertEquals(4, $e->get_iarg('page_num', 1));
|
||||
|
||||
$this->assertTrue($e->page_matches("{thing}/bar", paged: true));
|
||||
$this->assertEquals("foo", $e->get_arg('thing'));
|
||||
$this->assertEquals(4, $e->get_iarg('page_num', 1));
|
||||
$this->assertFalse($e->page_matches("qux/{thing}", paged: true));
|
||||
$this->assertFalse($e->page_matches("foo/{thing}/long", paged: true));
|
||||
}
|
||||
}
|
||||
38
core/tests/UserClassTest.php
Normal file
38
core/tests/UserClassTest.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shimmie2;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
require_once "core/userclass.php";
|
||||
|
||||
class UserClassTest extends ShimmiePHPUnitTestCase
|
||||
{
|
||||
public function test_new_class(): void
|
||||
{
|
||||
$cls = new UserClass("user2", "user", [
|
||||
Permissions::CREATE_COMMENT => true,
|
||||
Permissions::BIG_SEARCH => false,
|
||||
]);
|
||||
$this->assertEquals("user2", $cls->name);
|
||||
$this->assertTrue($cls->can(Permissions::CREATE_COMMENT));
|
||||
$this->assertFalse($cls->can(Permissions::BIG_SEARCH));
|
||||
}
|
||||
|
||||
public function test_not_found(): void
|
||||
{
|
||||
$cls = UserClass::$known_classes['user'];
|
||||
$this->assertException(ServerError::class, function () use ($cls) {
|
||||
$cls->can("not_found");
|
||||
});
|
||||
}
|
||||
|
||||
public function test_permissions(): void
|
||||
{
|
||||
$cls = UserClass::$known_classes['user'];
|
||||
$ps = $cls->permissions();
|
||||
$this->assertContains(Permissions::CREATE_COMMENT, $ps);
|
||||
}
|
||||
}
|
||||
@@ -52,4 +52,12 @@ class BasePageTest extends TestCase
|
||||
ob_end_clean();
|
||||
$this->assertTrue(true); // doesn't crash
|
||||
}
|
||||
|
||||
public function test_subNav(): void
|
||||
{
|
||||
// the default theme doesn't send this, so let's have
|
||||
// a random test manually
|
||||
send_event(new PageSubNavBuildingEvent("system"));
|
||||
$this->assertTrue(true); // doesn't crash
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,10 +85,11 @@ class PolyfillsTest extends TestCase
|
||||
|
||||
public function test_truncate()
|
||||
{
|
||||
$this->assertEquals("test words", truncate("test words", 10));
|
||||
$this->assertEquals("test...", truncate("test...", 9));
|
||||
$this->assertEquals("test...", truncate("test...", 6));
|
||||
$this->assertEquals("te...", truncate("te...", 2));
|
||||
$this->assertEquals("test words", truncate("test words", 10), "No truncation if string is short enough");
|
||||
$this->assertEquals("test...", truncate("test words", 9), "Truncate when string is too long");
|
||||
$this->assertEquals("test...", truncate("test words", 7), "Truncate to the same breakpoint");
|
||||
$this->assertEquals("te...", truncate("test words", 5), "Breakpoints past the limit don't matter");
|
||||
$this->assertEquals("o...", truncate("oneVeryLongWord", 4), "Hard-break if there are no breakpoints");
|
||||
}
|
||||
|
||||
public function test_to_shorthand_int()
|
||||
|
||||
@@ -162,4 +162,35 @@ class UtilTest extends TestCase
|
||||
path_to_tags("/category:/tag/baz.jpg")
|
||||
);
|
||||
}
|
||||
|
||||
public function test_contact_link(): void
|
||||
{
|
||||
$this->assertEquals(
|
||||
"mailto:asdf@example.com",
|
||||
contact_link("asdf@example.com")
|
||||
);
|
||||
$this->assertEquals(
|
||||
"http://example.com",
|
||||
contact_link("http://example.com")
|
||||
);
|
||||
$this->assertEquals(
|
||||
"https://foo.com/bar",
|
||||
contact_link("foo.com/bar")
|
||||
);
|
||||
$this->assertEquals(
|
||||
"john",
|
||||
contact_link("john")
|
||||
);
|
||||
}
|
||||
|
||||
public function test_get_user(): void
|
||||
{
|
||||
// TODO: HTTP_AUTHORIZATION
|
||||
// TODO: cookie user + session
|
||||
// fallback to anonymous
|
||||
$this->assertEquals(
|
||||
"Anonymous",
|
||||
_get_user()->name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
123
core/urls.php
123
core/urls.php
@@ -23,6 +23,22 @@ class Link
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a link to a search page for given terms,
|
||||
* with all the appropriate escaping
|
||||
*
|
||||
* @param string[] $terms
|
||||
*/
|
||||
function search_link(array $terms = [], int $page = 1): string
|
||||
{
|
||||
if ($terms) {
|
||||
$q = url_escape(Tag::implode($terms));
|
||||
return make_link("post/list/$q/$page");
|
||||
} else {
|
||||
return make_link("post/list/$page");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Figure out the correct way to link to a page, taking into account
|
||||
* things like the nice URLs setting.
|
||||
@@ -40,7 +56,7 @@ function make_link(?string $page=null, ?string $query=null, ?string $fragment=nu
|
||||
|
||||
$parts = [];
|
||||
$install_dir = get_base_href();
|
||||
if (SPEED_HAX || $config->get_bool('nice_urls', false)) {
|
||||
if (SPEED_HAX || $config->get_bool(SetupConfig::NICE_URLS, false)) {
|
||||
$parts['path'] = "$install_dir/$page";
|
||||
} else {
|
||||
$parts['path'] = "$install_dir/index.php";
|
||||
@@ -52,6 +68,111 @@ function make_link(?string $page=null, ?string $query=null, ?string $fragment=nu
|
||||
return unparse_url($parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Figure out the current page from a link that make_link() generated
|
||||
*
|
||||
* SHIT: notes for the future, because the web stack is a pile of hacks
|
||||
*
|
||||
* - According to some specs, "/" is for URL dividers with heiracial
|
||||
* significance and %2F is for slashes that are just slashes. This
|
||||
* is what shimmie currently does - eg if you search for "AC/DC",
|
||||
* the shimmie URL will be /post/list/AC%2FDC/1
|
||||
* - According to some other specs "/" and "%2F" are identical...
|
||||
* - PHP's $_GET[] automatically urldecodes the inputs so we can't
|
||||
* tell the difference between q=foo/bar and q=foo%2Fbar
|
||||
* - REQUEST_URI contains the exact URI that was given to us, so we
|
||||
* can parse it for ourselves
|
||||
* - <input type='hidden' name='q' value='post/list'> generates
|
||||
* q=post%2Flist
|
||||
*
|
||||
* This function should always return strings with no leading slashes
|
||||
*/
|
||||
function _get_query(?string $uri = null): string
|
||||
{
|
||||
$parsed_url = parse_url($uri ?? $_SERVER['REQUEST_URI'] ?? "");
|
||||
|
||||
// if we're looking at http://site.com/$INSTALL_DIR/index.php,
|
||||
// then get the query from the "q" parameter
|
||||
if (($parsed_url["path"] ?? "") == (get_base_href() . "/index.php")) {
|
||||
// $q = $_GET["q"] ?? "";
|
||||
// default to looking at the root
|
||||
$q = "";
|
||||
// (we need to manually parse the query string because PHP's $_GET
|
||||
// does an extra round of URL decoding, which we don't want)
|
||||
foreach (explode('&', $parsed_url['query'] ?? "") as $z) {
|
||||
$qps = explode('=', $z, 2);
|
||||
if (count($qps) == 2 && $qps[0] == "q") {
|
||||
$q = $qps[1];
|
||||
}
|
||||
}
|
||||
// if we have no slashes, but do have an encoded
|
||||
// slash, then we _probably_ encoded too much
|
||||
if (!str_contains($q, "/") && str_contains($q, "%2F")) {
|
||||
$q = rawurldecode($q);
|
||||
}
|
||||
}
|
||||
|
||||
// if we're looking at http://site.com/$INSTALL_DIR/$PAGE,
|
||||
// then get the query from the path
|
||||
else {
|
||||
$q = substr($parsed_url["path"] ?? "", strlen(get_base_href() . "/"));
|
||||
}
|
||||
|
||||
assert(!str_starts_with($q, "/"));
|
||||
return $q;
|
||||
}
|
||||
|
||||
/**
|
||||
* Figure out the path to the shimmie install directory.
|
||||
*
|
||||
* eg if shimmie is visible at https://foo.com/gallery, this
|
||||
* function should return /gallery
|
||||
*
|
||||
* PHP really, really sucks.
|
||||
*
|
||||
* This function should always return strings with no trailing
|
||||
* slashes, so that it can be used like `get_base_href() . "/data/asset.abc"`
|
||||
*
|
||||
* @param array<string, string>|null $server_settings
|
||||
*/
|
||||
function get_base_href(?array $server_settings = null): string
|
||||
{
|
||||
if (defined("BASE_HREF") && !empty(BASE_HREF)) {
|
||||
return BASE_HREF;
|
||||
}
|
||||
$server_settings = $server_settings ?? $_SERVER;
|
||||
if (str_ends_with($server_settings['PHP_SELF'], 'index.php')) {
|
||||
$self = $server_settings['PHP_SELF'];
|
||||
} elseif (isset($server_settings['SCRIPT_FILENAME']) && isset($server_settings['DOCUMENT_ROOT'])) {
|
||||
$self = substr($server_settings['SCRIPT_FILENAME'], strlen(rtrim($server_settings['DOCUMENT_ROOT'], "/")));
|
||||
} else {
|
||||
die("PHP_SELF or SCRIPT_FILENAME need to be set");
|
||||
}
|
||||
$dir = dirname($self);
|
||||
$dir = str_replace("\\", "/", $dir);
|
||||
$dir = rtrim($dir, "/");
|
||||
return $dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* The opposite of the standard library's parse_url
|
||||
*
|
||||
* @param array<string, string|int> $parsed_url
|
||||
*/
|
||||
function unparse_url(array $parsed_url): string
|
||||
{
|
||||
$scheme = isset($parsed_url['scheme']) ? $parsed_url['scheme'] . '://' : '';
|
||||
$host = $parsed_url['host'] ?? '';
|
||||
$port = isset($parsed_url['port']) ? ':' . $parsed_url['port'] : '';
|
||||
$user = $parsed_url['user'] ?? '';
|
||||
$pass = isset($parsed_url['pass']) ? ':' . $parsed_url['pass'] : '';
|
||||
$pass = ($user || $pass) ? "$pass@" : '';
|
||||
$path = $parsed_url['path'] ?? '';
|
||||
$query = !empty($parsed_url['query']) ? '?' . $parsed_url['query'] : '';
|
||||
$fragment = !empty($parsed_url['fragment']) ? '#' . $parsed_url['fragment'] : '';
|
||||
return "$scheme$user$pass$host$port$path$query$fragment";
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Take the current URL and modify some parameters
|
||||
|
||||
@@ -50,7 +50,7 @@ class User
|
||||
* One will very rarely construct a user directly, more common
|
||||
* would be to use User::by_id, User::by_session, etc.
|
||||
*
|
||||
* @throws SCoreException
|
||||
* @param array<string|int, mixed> $row
|
||||
*/
|
||||
public function __construct(array $row)
|
||||
{
|
||||
@@ -65,7 +65,7 @@ class User
|
||||
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"]}'");
|
||||
throw new ServerError("User '{$this->name}' has invalid class '{$row["class"]}'");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ class User
|
||||
{
|
||||
$u = User::by_name($name);
|
||||
if (is_null($u)) {
|
||||
throw new UserDoesNotExist("Can't find any user named $name");
|
||||
throw new UserNotFound("Can't find any user named $name");
|
||||
} else {
|
||||
return $u->id;
|
||||
}
|
||||
@@ -196,7 +196,7 @@ class User
|
||||
{
|
||||
global $database;
|
||||
if (User::by_name($name)) {
|
||||
throw new SCoreException("Desired username is already in use");
|
||||
throw new InvalidInput("Desired username is already in use");
|
||||
}
|
||||
$old_name = $this->name;
|
||||
$this->name = $name;
|
||||
@@ -273,32 +273,4 @@ class User
|
||||
$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.'">';
|
||||
}
|
||||
|
||||
// Temporary? This should eventually become get_auth_html (probably with a different name?).
|
||||
public function get_auth_microhtml(): HTMLElement
|
||||
{
|
||||
$at = $this->get_auth_token();
|
||||
return INPUT(["type"=>"hidden", "name"=>"auth_token", "value"=>$at]);
|
||||
}
|
||||
|
||||
public function check_auth_token(): bool
|
||||
{
|
||||
if (defined("UNITTEST")) {
|
||||
return true;
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ class UserClass
|
||||
#[Field(type: "[Permission!]!")]
|
||||
public function permissions(): array
|
||||
{
|
||||
global $_all_false;
|
||||
$perms = [];
|
||||
foreach ((new \ReflectionClass('\Shimmie2\Permissions'))->getConstants() as $k => $v) {
|
||||
if ($this->can($v)) {
|
||||
@@ -54,8 +53,6 @@ class UserClass
|
||||
|
||||
/**
|
||||
* Determine if this class of user can perform an action or has ability.
|
||||
*
|
||||
* @throws SCoreException
|
||||
*/
|
||||
public function can(string $ability): bool
|
||||
{
|
||||
@@ -74,16 +71,24 @@ class UserClass
|
||||
$min_ability = $a;
|
||||
}
|
||||
}
|
||||
throw new SCoreException("Unknown ability '$ability'. Did the developer mean '$min_ability'?");
|
||||
throw new ServerError("Unknown ability '$ability'. Did the developer mean '$min_ability'?");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$_all_false = [];
|
||||
foreach ((new \ReflectionClass('\Shimmie2\Permissions'))->getConstants() as $k => $v) {
|
||||
$_all_true = [];
|
||||
foreach ((new \ReflectionClass(Permissions::class))->getConstants() as $k => $v) {
|
||||
assert(is_string($v));
|
||||
$_all_false[$v] = false;
|
||||
$_all_true[$v] = true;
|
||||
}
|
||||
// hellbanned is a snowflake, it isn't really a "permission" so much as
|
||||
// "a special behaviour which applies to one particular user class"
|
||||
$_all_true[Permissions::HELLBANNED] = false;
|
||||
new UserClass("base", null, $_all_false);
|
||||
new UserClass("admin", null, $_all_true);
|
||||
unset($_all_true);
|
||||
unset($_all_false);
|
||||
|
||||
// Ghost users can't do anything
|
||||
@@ -114,120 +119,17 @@ new UserClass("user", "base", [
|
||||
Permissions::SET_PRIVATE_IMAGE => true,
|
||||
Permissions::PERFORM_BULK_ACTIONS => true,
|
||||
Permissions::BULK_DOWNLOAD => true,
|
||||
Permissions::CHANGE_USER_SETTING => true
|
||||
Permissions::CHANGE_USER_SETTING => true,
|
||||
Permissions::FORUM_CREATE => true,
|
||||
Permissions::NOTES_CREATE => true,
|
||||
Permissions::NOTES_EDIT => true,
|
||||
Permissions::NOTES_REQUEST => true,
|
||||
Permissions::POOLS_CREATE => true,
|
||||
Permissions::POOLS_UPDATE => true,
|
||||
]);
|
||||
|
||||
new UserClass("hellbanned", "user", [
|
||||
Permissions::HELLBANNED => true,
|
||||
]);
|
||||
|
||||
new UserClass("admin", "base", [
|
||||
Permissions::CHANGE_SETTING => true,
|
||||
Permissions::CHANGE_USER_SETTING => true,
|
||||
Permissions::CHANGE_OTHER_USER_SETTING => true,
|
||||
Permissions::OVERRIDE_CONFIG => true,
|
||||
Permissions::BIG_SEARCH => true,
|
||||
|
||||
Permissions::MANAGE_EXTENSION_LIST => true,
|
||||
Permissions::MANAGE_ALIAS_LIST => true,
|
||||
Permissions::MANAGE_AUTO_TAG => true,
|
||||
Permissions::MASS_TAG_EDIT => true,
|
||||
|
||||
Permissions::VIEW_IP => true,
|
||||
Permissions::BAN_IP => true,
|
||||
|
||||
Permissions::CREATE_USER => true,
|
||||
Permissions::CREATE_OTHER_USER => 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_COMMENT => true,
|
||||
Permissions::DELETE_COMMENT => true,
|
||||
Permissions::BYPASS_COMMENT_CHECKS => true,
|
||||
|
||||
Permissions::REPLACE_IMAGE => true,
|
||||
Permissions::CREATE_IMAGE => true,
|
||||
Permissions::EDIT_IMAGE_TAG => true,
|
||||
Permissions::EDIT_IMAGE_SOURCE => true,
|
||||
Permissions::EDIT_IMAGE_OWNER => true,
|
||||
Permissions::EDIT_IMAGE_LOCK => true,
|
||||
Permissions::EDIT_IMAGE_TITLE => true,
|
||||
Permissions::EDIT_IMAGE_RELATIONSHIPS => true,
|
||||
Permissions::EDIT_IMAGE_ARTIST => true,
|
||||
Permissions::BULK_EDIT_IMAGE_TAG => true,
|
||||
Permissions::BULK_EDIT_IMAGE_SOURCE => true,
|
||||
Permissions::DELETE_IMAGE => true,
|
||||
|
||||
Permissions::BAN_IMAGE => true,
|
||||
|
||||
Permissions::VIEW_EVENTLOG => true,
|
||||
Permissions::IGNORE_DOWNTIME => true,
|
||||
Permissions::VIEW_REGISTRATIONS => 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::MANAGE_BLOCKS => true,
|
||||
|
||||
Permissions::MANAGE_ADMINTOOLS => true,
|
||||
|
||||
Permissions::SEND_PM => true,
|
||||
Permissions::READ_PM => true,
|
||||
Permissions::VIEW_OTHER_PMS => true, # hm
|
||||
Permissions::EDIT_FEATURE => true,
|
||||
Permissions::BULK_EDIT_VOTE => true,
|
||||
Permissions::EDIT_OTHER_VOTE => true,
|
||||
Permissions::CREATE_VOTE => true,
|
||||
Permissions::VIEW_SYSINTO => true,
|
||||
|
||||
Permissions::HELLBANNED => false,
|
||||
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::EDIT_FAVOURITES => 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,
|
||||
Permissions::BYPASS_IMAGE_APPROVAL => true,
|
||||
|
||||
Permissions::CRON_RUN =>true,
|
||||
|
||||
Permissions::BULK_IMPORT =>true,
|
||||
Permissions::BULK_EXPORT =>true,
|
||||
Permissions::BULK_DOWNLOAD => true,
|
||||
Permissions::BULK_PARENT_CHILD => true,
|
||||
|
||||
Permissions::SET_PRIVATE_IMAGE => true,
|
||||
Permissions::SET_OTHERS_PRIVATE_IMAGES => true,
|
||||
]);
|
||||
|
||||
@include_once "data/config/user-classes.conf.php";
|
||||
|
||||
166
core/util.php
166
core/util.php
@@ -21,10 +21,10 @@ function get_theme(): string
|
||||
return $theme;
|
||||
}
|
||||
|
||||
function contact_link(): ?string
|
||||
function contact_link(?string $contact = null): ?string
|
||||
{
|
||||
global $config;
|
||||
$text = $config->get_string('contact_link');
|
||||
$text = $contact ?? $config->get_string('contact_link');
|
||||
if (is_null($text)) {
|
||||
return null;
|
||||
}
|
||||
@@ -42,7 +42,7 @@ function contact_link(): ?string
|
||||
}
|
||||
|
||||
if (str_contains($text, "/")) {
|
||||
return "http://$text";
|
||||
return "https://$text";
|
||||
}
|
||||
|
||||
return $text;
|
||||
@@ -148,25 +148,61 @@ function check_im_version(): int
|
||||
return (empty($convert_check) ? 0 : 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get request IP
|
||||
*/
|
||||
|
||||
function get_remote_addr()
|
||||
function is_trusted_proxy(): bool
|
||||
{
|
||||
return $_SERVER['REMOTE_ADDR'];
|
||||
$ra = $_SERVER['REMOTE_ADDR'] ?? "0.0.0.0";
|
||||
if (!defined("TRUSTED_PROXIES")) {
|
||||
return false;
|
||||
}
|
||||
// @phpstan-ignore-next-line - TRUSTED_PROXIES is defined in config
|
||||
foreach (TRUSTED_PROXIES as $proxy) {
|
||||
if ($ra === $proxy) { // check for "unix:" before checking IPs
|
||||
return true;
|
||||
}
|
||||
if (ip_in_range($ra, $proxy)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function is_bot(): bool
|
||||
{
|
||||
$ua = $_SERVER["HTTP_USER_AGENT"] ?? "No UA";
|
||||
return (
|
||||
str_contains($ua, "Googlebot")
|
||||
|| str_contains($ua, "YandexBot")
|
||||
|| str_contains($ua, "bingbot")
|
||||
|| str_contains($ua, "msnbot")
|
||||
|| str_contains($ua, "PetalBot")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real IP if behind a reverse proxy
|
||||
*/
|
||||
|
||||
function get_real_ip()
|
||||
{
|
||||
$ip = get_remote_addr();
|
||||
if (REVERSE_PROXY_X_HEADERS && isset($_SERVER['HTTP_X_REAL_IP'])) {
|
||||
$ip = $_SERVER['HTTP_X_REAL_IP'];
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||
$ip = "0.0.0.0";
|
||||
$ip = $_SERVER['REMOTE_ADDR'];
|
||||
|
||||
if ($ip == "unix:") {
|
||||
$ip = "0.0.0.0";
|
||||
}
|
||||
|
||||
if (is_trusted_proxy()) {
|
||||
if (isset($_SERVER['HTTP_X_REAL_IP'])) {
|
||||
if (filter_var_ex($ip, FILTER_VALIDATE_IP)) {
|
||||
$ip = $_SERVER['HTTP_X_REAL_IP'];
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||
$last_ip = $ips[count($ips) - 1];
|
||||
if (filter_var_ex($last_ip, FILTER_VALIDATE_IP)) {
|
||||
$ip = $last_ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +217,7 @@ function get_session_ip(Config $config): string
|
||||
{
|
||||
$mask = $config->get_string("session_hash_mask", "255.255.0.0");
|
||||
$addr = get_real_ip();
|
||||
$addr = inet_ntop(inet_pton($addr) & inet_pton($mask));
|
||||
$addr = \Safe\inet_ntop(inet_pton_ex($addr) & inet_pton_ex($mask));
|
||||
return $addr;
|
||||
}
|
||||
|
||||
@@ -195,6 +231,22 @@ function format_text(string $string): string
|
||||
return $event->formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a map of string to string-or-array, return only the string-to-string subset
|
||||
*
|
||||
* @param array<string, string|string[]> $map
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function only_strings(array $map): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($map as $k => $v) {
|
||||
if (is_string($v)) {
|
||||
$out[$k] = $v;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
/**
|
||||
* 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.
|
||||
@@ -278,10 +330,11 @@ function fetch_url(string $url, string $mfile): ?array
|
||||
|
||||
if ($config->get_string(UploadConfig::TRANSLOAD_ENGINE) === "curl" && function_exists("curl_init")) {
|
||||
$ch = curl_init($url);
|
||||
$fp = fopen($mfile, "w");
|
||||
assert($ch !== false);
|
||||
$fp = \Safe\fopen($mfile, "w");
|
||||
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||||
curl_setopt($ch, CURLOPT_VERBOSE, 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);
|
||||
@@ -293,7 +346,8 @@ function fetch_url(string $url, string $mfile): ?array
|
||||
}
|
||||
|
||||
$header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
$headers = http_parse_headers(implode("\n", preg_split('/\R/', rtrim(substr($response, 0, $header_size)))));
|
||||
$header_text = trim(substr($response, 0, $header_size));
|
||||
$headers = http_parse_headers(implode("\n", \Safe\preg_split('/\R/', $header_text)));
|
||||
$body = substr($response, $header_size);
|
||||
|
||||
curl_close($ch);
|
||||
@@ -307,11 +361,11 @@ function fetch_url(string $url, string $mfile): ?array
|
||||
$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(UploadConfig::TRANSLOAD_ENGINE) === "fopen") {
|
||||
if (!file_exists($mfile)) {
|
||||
throw new FetchException("wget failed");
|
||||
}
|
||||
$headers = [];
|
||||
} elseif ($config->get_string(UploadConfig::TRANSLOAD_ENGINE) === "fopen") {
|
||||
$fp_in = @fopen($url, "r");
|
||||
$fp_out = fopen($mfile, "w");
|
||||
if (!$fp_in || !$fp_out) {
|
||||
@@ -319,7 +373,7 @@ function fetch_url(string $url, string $mfile): ?array
|
||||
}
|
||||
$length = 0;
|
||||
while (!feof($fp_in) && $length <= $config->get_int(UploadConfig::SIZE)) {
|
||||
$data = fread($fp_in, 8192);
|
||||
$data = \Safe\fread($fp_in, 8192);
|
||||
$length += strlen($data);
|
||||
fwrite($fp_out, $data);
|
||||
}
|
||||
@@ -389,9 +443,7 @@ function get_dir_contents(string $dir): array
|
||||
return [];
|
||||
}
|
||||
return array_diff(
|
||||
scandir(
|
||||
$dir
|
||||
),
|
||||
\Safe\scandir($dir),
|
||||
['..', '.']
|
||||
);
|
||||
}
|
||||
@@ -501,8 +553,6 @@ function ftime(): float
|
||||
* Debugging functions *
|
||||
\* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||
|
||||
$_shm_load_start = ftime();
|
||||
|
||||
/**
|
||||
* Collects some debug information (execution time, memory usage, queries, etc)
|
||||
* and formats it to stick in the footer of the page.
|
||||
@@ -558,18 +608,36 @@ function require_all(array $files): void
|
||||
|
||||
function _load_core_files()
|
||||
{
|
||||
global $_tracer;
|
||||
$_tracer->begin("Load Core Files");
|
||||
require_all(array_merge(
|
||||
zglob("core/*.php"),
|
||||
zglob("core/imageboard/*.php"),
|
||||
zglob("ext/*/info.php")
|
||||
));
|
||||
$_tracer->end();
|
||||
}
|
||||
|
||||
function _load_theme_files()
|
||||
function _load_extension_files(): void
|
||||
{
|
||||
global $_tracer;
|
||||
$_tracer->begin("Load Ext Files");
|
||||
ExtensionInfo::load_all_extension_info();
|
||||
Extension::determine_enabled_extensions();
|
||||
require_all(zglob("ext/{".Extension::get_enabled_extensions_as_string()."}/main.php"));
|
||||
$_tracer->end();
|
||||
}
|
||||
|
||||
function _load_theme_files(): void
|
||||
{
|
||||
global $_tracer;
|
||||
$_tracer->begin("Load Theme Files");
|
||||
$theme = get_theme();
|
||||
$files = _get_themelet_files($theme);
|
||||
require_all($files);
|
||||
require_once('themes/'.$theme.'/page.class.php');
|
||||
require_once('themes/'.$theme.'/themelet.class.php');
|
||||
require_all(zglob("ext/{".Extension::get_enabled_extensions_as_string()."}/theme.php"));
|
||||
require_all(zglob('themes/'.$theme.'/{'.Extension::get_enabled_extensions_as_string().'}.theme.php'));
|
||||
$_tracer->end();
|
||||
}
|
||||
|
||||
function _set_up_shimmie_environment(): void
|
||||
@@ -641,7 +709,14 @@ function _fatal_error(\Exception $e): void
|
||||
|
||||
print("Version: $version (on $phpver)\n");
|
||||
} else {
|
||||
$q = $query ? "" : "<p><b>Query:</b> " . html_escape($query);
|
||||
$query = is_a($e, DatabaseException::class) ? $e->query : null;
|
||||
$code = is_a($e, SCoreException::class) ? $e->http_code : 500;
|
||||
|
||||
$q = "";
|
||||
if (is_a($e, DatabaseException::class)) {
|
||||
$q .= "<p><b>Query:</b> " . html_escape($query);
|
||||
$q .= "<p><b>Args:</b> " . html_escape(var_export($e->args, true));
|
||||
}
|
||||
if ($code >= 500) {
|
||||
error_log("Shimmie Error: $message (Query: $query)\n{$e->getTraceAsString()}");
|
||||
}
|
||||
@@ -717,16 +792,10 @@ function show_ip(string $ip, string $ban_reason): string
|
||||
/**
|
||||
* 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
|
||||
function make_form(string $target, bool $multipart = false, string $form_id = "", string $onsubmit = "", string $name = ""): 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();
|
||||
}
|
||||
$at = $user->get_auth_token();
|
||||
|
||||
$extra = empty($form_id) ? '' : 'id="'. $form_id .'"';
|
||||
if ($multipart) {
|
||||
@@ -735,7 +804,11 @@ function make_form(string $target, string $method="POST", bool $multipart=false,
|
||||
if ($onsubmit) {
|
||||
$extra .= ' onsubmit="'.$onsubmit.'"';
|
||||
}
|
||||
return '<form action="'.$target.'" method="'.$method.'" '.$extra.'>'.$extra_inputs;
|
||||
if ($name) {
|
||||
$extra .= ' name="'.$name.'"';
|
||||
}
|
||||
return '<form action="'.$target.'" method="POST" '.$extra.'>'.
|
||||
'<input type="hidden" name="auth_token" value="'.$at.'">';
|
||||
}
|
||||
|
||||
const BYTE_DENOMINATIONS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
@@ -759,3 +832,12 @@ function generate_key(int $length = 20): string
|
||||
|
||||
return $randomString;
|
||||
}
|
||||
|
||||
function shm_tempnam(string $prefix = ""): string
|
||||
{
|
||||
if (!is_dir("data/temp")) {
|
||||
mkdir("data/temp");
|
||||
}
|
||||
$temp = \Safe\realpath("data/temp");
|
||||
return \Safe\tempnam($temp, $prefix);
|
||||
}
|
||||
|
||||
@@ -16,4 +16,5 @@ class AdminPageInfo extends ExtensionInfo
|
||||
public string $description = "Provides a base for various small admin functions";
|
||||
public bool $core = true;
|
||||
public ExtensionVisibility $visibility = ExtensionVisibility::HIDDEN;
|
||||
public ExtensionCategory $category = ExtensionCategory::ADMIN;
|
||||
}
|
||||
|
||||
@@ -22,11 +22,17 @@ class AdminActionEvent extends Event
|
||||
{
|
||||
public string $action;
|
||||
public bool $redirect = true;
|
||||
/** @var array<string, mixed> */
|
||||
public array $params;
|
||||
|
||||
public function __construct(string $action)
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
public function __construct(string $action, array $params)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->action = $action;
|
||||
$this->params = $params;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,97 +45,108 @@ class AdminPage extends Extension
|
||||
{
|
||||
global $database, $page, $user;
|
||||
|
||||
if ($event->page_matches("admin")) {
|
||||
if (!$user->can(Permissions::MANAGE_ADMINTOOLS)) {
|
||||
$this->theme->display_permission_denied();
|
||||
} else {
|
||||
if ($event->count_args() == 0) {
|
||||
send_event(new AdminBuildingEvent($page));
|
||||
} else {
|
||||
$action = $event->get_arg(0);
|
||||
$aae = new AdminActionEvent($action);
|
||||
if ($event->page_matches("admin", method: "GET", permission: Permissions::MANAGE_ADMINTOOLS)) {
|
||||
send_event(new AdminBuildingEvent($page));
|
||||
}
|
||||
if ($event->page_matches("admin/{action}", method: "POST", permission: Permissions::MANAGE_ADMINTOOLS)) {
|
||||
$action = $event->get_arg('action');
|
||||
$aae = new AdminActionEvent($action, $event->POST);
|
||||
|
||||
if ($user->check_auth_token()) {
|
||||
log_info("admin", "Util: $action");
|
||||
shm_set_timeout(null);
|
||||
$database->set_timeout(null);
|
||||
send_event($aae);
|
||||
}
|
||||
log_info("admin", "Util: $action");
|
||||
shm_set_timeout(null);
|
||||
$database->set_timeout(null);
|
||||
send_event($aae);
|
||||
|
||||
if ($aae->redirect) {
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("admin"));
|
||||
}
|
||||
}
|
||||
if ($aae->redirect) {
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("admin"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function onCommand(CommandEvent $event)
|
||||
{
|
||||
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;
|
||||
$_SERVER['REQUEST_URI'] = $event->args[0];
|
||||
if (isset($event->args[1])) {
|
||||
parse_str($event->args[1], $_GET);
|
||||
$_SERVER['REQUEST_URI'] .= "?" . $event->args[1];
|
||||
}
|
||||
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->get_mime(), 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_export($cache->get($key));
|
||||
break;
|
||||
case "set":
|
||||
$cache->set($key, $event->args[2], 60);
|
||||
break;
|
||||
case "del":
|
||||
$cache->delete($key);
|
||||
break;
|
||||
}
|
||||
$event->app->register('page:get')
|
||||
->addArgument('query', InputArgument::REQUIRED)
|
||||
->addArgument('args', InputArgument::OPTIONAL)
|
||||
->setDescription('Get a page, eg /post/list')
|
||||
->setCode(function (InputInterface $input, OutputInterface $output): int {
|
||||
global $page;
|
||||
$query = $input->getArgument('query');
|
||||
$args = $input->getArgument('args');
|
||||
$_SERVER['REQUEST_URI'] = make_link($query);
|
||||
if (!is_null($args)) {
|
||||
parse_str($args, $_GET);
|
||||
$_SERVER['REQUEST_URI'] .= "?" . $args;
|
||||
}
|
||||
send_event(new PageRequestEvent("GET", $query, $_GET, []));
|
||||
$page->display();
|
||||
return Command::SUCCESS;
|
||||
});
|
||||
$event->app->register('page:post')
|
||||
->addArgument('query', InputArgument::REQUIRED)
|
||||
->addArgument('args', InputArgument::OPTIONAL)
|
||||
->setDescription('Post a page, eg ip_ban/delete id=1')
|
||||
->setCode(function (InputInterface $input, OutputInterface $output): int {
|
||||
global $page;
|
||||
$query = $input->getArgument('query');
|
||||
$args = $input->getArgument('args');
|
||||
global $page;
|
||||
if (!is_null($args)) {
|
||||
parse_str($args, $_POST);
|
||||
}
|
||||
send_event(new PageRequestEvent("POST", $query, [], $_POST));
|
||||
$page->display();
|
||||
return Command::SUCCESS;
|
||||
});
|
||||
$event->app->register('get-token')
|
||||
->setDescription('Get a CSRF token')
|
||||
->setCode(function (InputInterface $input, OutputInterface $output): int {
|
||||
global $user;
|
||||
$output->writeln($user->get_auth_token());
|
||||
return Command::SUCCESS;
|
||||
});
|
||||
$event->app->register('cache:get')
|
||||
->addArgument('key', InputArgument::REQUIRED)
|
||||
->setDescription("Get a cache value")
|
||||
->setCode(function (InputInterface $input, OutputInterface $output): int {
|
||||
global $cache;
|
||||
$key = $input->getArgument('key');
|
||||
$output->writeln(var_export($cache->get($key), true));
|
||||
return Command::SUCCESS;
|
||||
});
|
||||
$event->app->register('cache:set')
|
||||
->addArgument('key', InputArgument::REQUIRED)
|
||||
->addArgument('value', InputArgument::REQUIRED)
|
||||
->setDescription("Set a cache value")
|
||||
->setCode(function (InputInterface $input, OutputInterface $output): int {
|
||||
global $cache;
|
||||
$key = $input->getArgument('key');
|
||||
$value = $input->getArgument('value');
|
||||
$cache->set($key, $value, 60);
|
||||
return Command::SUCCESS;
|
||||
});
|
||||
$event->app->register('cache:del')
|
||||
->addArgument('key', InputArgument::REQUIRED)
|
||||
->setDescription("Delete a cache value")
|
||||
->setCode(function (InputInterface $input, OutputInterface $output): int {
|
||||
global $cache;
|
||||
$key = $input->getArgument('key');
|
||||
$cache->delete($key);
|
||||
return Command::SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
public function onAdminAction(AdminActionEvent $event): void
|
||||
{
|
||||
global $page;
|
||||
if ($event->action === "test") {
|
||||
$page->set_mode(PageMode::DATA);
|
||||
$page->set_data("test");
|
||||
}
|
||||
}
|
||||
|
||||
public function onAdminBuilding(AdminBuildingEvent $event)
|
||||
public function onAdminBuilding(AdminBuildingEvent $event): void
|
||||
{
|
||||
$this->theme->display_page();
|
||||
}
|
||||
|
||||
@@ -8,34 +8,34 @@ class AdminPageTest extends ShimmiePHPUnitTestCase
|
||||
{
|
||||
public function testAuth()
|
||||
{
|
||||
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_out();
|
||||
$this->assertException(PermissionDenied::class, function () {
|
||||
$this->get_page('admin');
|
||||
});
|
||||
|
||||
send_event(new UserLoginEvent(User::by_name(self::$user_name)));
|
||||
$page = $this->get_page('admin');
|
||||
$this->assertEquals(403, $page->code);
|
||||
$this->assertEquals("Permission Denied", $page->title);
|
||||
$this->log_in_as_user();
|
||||
$this->assertException(PermissionDenied::class, function () {
|
||||
$this->get_page('admin');
|
||||
});
|
||||
|
||||
send_event(new UserLoginEvent(User::by_name(self::$admin_name)));
|
||||
$this->log_in_as_admin();
|
||||
$page = $this->get_page('admin');
|
||||
$this->assertEquals(200, $page->code);
|
||||
$this->assertEquals("Admin Tools", $page->title);
|
||||
}
|
||||
|
||||
public function testCommands()
|
||||
public function testAct(): void
|
||||
{
|
||||
send_event(new UserLoginEvent(User::by_name(self::$admin_name)));
|
||||
ob_start();
|
||||
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->log_in_as_admin();
|
||||
$page = $this->post_page('admin/test');
|
||||
$this->assertEquals("test", $page->data);
|
||||
}
|
||||
|
||||
// don't crash
|
||||
$this->assertTrue(true);
|
||||
// does this belong here??
|
||||
public function testCliGen(): void
|
||||
{
|
||||
$app = new CliApp();
|
||||
send_event(new CliGenEvent($app));
|
||||
$this->assertTrue(true); // TODO: check for more than "no crash"?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ class DeleteAliasEvent extends Event
|
||||
}
|
||||
}
|
||||
|
||||
class AddAliasException extends SCoreException
|
||||
class AddAliasException extends UserError
|
||||
{
|
||||
}
|
||||
|
||||
@@ -65,57 +65,45 @@ class AliasEditor extends Extension
|
||||
{
|
||||
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_mime(MimeType::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($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");
|
||||
}
|
||||
if ($event->page_matches("alias/add", method: "POST", permission: Permissions::MANAGE_ALIAS_LIST)) {
|
||||
$input = validate_input(["c_oldtag" => "string", "c_newtag" => "string"]);
|
||||
send_event(new AddAliasEvent($input['c_oldtag'], $input['c_newtag']));
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("alias/list"));
|
||||
}
|
||||
if ($event->page_matches("alias/remove", method: "POST", permission: Permissions::MANAGE_ALIAS_LIST)) {
|
||||
$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"));
|
||||
}
|
||||
if ($event->page_matches("alias/list")) {
|
||||
$t = new AliasTable($database->raw_db());
|
||||
$t->token = $user->get_auth_token();
|
||||
$t->inputs = $event->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());
|
||||
}
|
||||
if ($event->page_matches("alias/export/aliases.csv")) {
|
||||
$page->set_mode(PageMode::DATA);
|
||||
$page->set_mime(MimeType::CSV);
|
||||
$page->set_filename("aliases.csv");
|
||||
$page->set_data($this->get_alias_csv($database));
|
||||
}
|
||||
if ($event->page_matches("alias/import", method: "POST", permission: Permissions::MANAGE_ALIAS_LIST)) {
|
||||
if (count($_FILES) > 0) {
|
||||
$tmp = $_FILES['alias_file']['tmp_name'];
|
||||
$contents = \Safe\file_get_contents($tmp);
|
||||
$this->add_alias_csv($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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,12 +174,8 @@ class AliasEditor extends Extension
|
||||
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());
|
||||
}
|
||||
send_event(new AddAliasEvent($parts[0], $parts[1]));
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
return $i;
|
||||
|
||||
@@ -33,7 +33,7 @@ class AliasEditorTest extends ShimmiePHPUnitTestCase
|
||||
$this->get_page("alias/export/aliases.csv");
|
||||
$this->assert_no_text("test1");
|
||||
|
||||
send_event(new AddAliasEvent("test1", "test2"));
|
||||
$this->post_page('alias/add', ['c_oldtag' => 'test1', 'c_newtag' => 'test2']);
|
||||
$this->get_page('alias/list');
|
||||
$this->assert_text("test1");
|
||||
$this->get_page("alias/export/aliases.csv");
|
||||
@@ -48,7 +48,7 @@ class AliasEditorTest extends ShimmiePHPUnitTestCase
|
||||
$this->assert_response(302);
|
||||
$this->delete_image($image_id);
|
||||
|
||||
send_event(new DeleteAliasEvent("test1"));
|
||||
$this->post_page('alias/remove', ['d_oldtag' => 'test1']);
|
||||
$this->get_page('alias/list');
|
||||
$this->assert_title("Alias List");
|
||||
$this->assert_no_text("test1");
|
||||
|
||||
@@ -12,5 +12,6 @@ class ApprovalInfo extends ExtensionInfo
|
||||
public string $name = "Approval";
|
||||
public array $authors = ["Matthew Barbour"=>"matthew@darkholme.net"];
|
||||
public string $license = self::LICENSE_WTFPL;
|
||||
public ExtensionCategory $category = ExtensionCategory::MODERATION;
|
||||
public string $description = "Adds an approval step to the upload/import process.";
|
||||
}
|
||||
|
||||
@@ -39,31 +39,15 @@ class Approval extends Extension
|
||||
{
|
||||
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 post: No valid Post ID given.");
|
||||
}
|
||||
|
||||
if ($event->page_matches("approve_image/{image_id}", method: "POST", permission: Permissions::APPROVE_IMAGE)) {
|
||||
$image_id = int_escape($event->get_arg('image_id'));
|
||||
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 Post ID given.");
|
||||
}
|
||||
|
||||
if ($event->page_matches("disapprove_image/{image_id}", method: "POST", permission: Permissions::APPROVE_IMAGE)) {
|
||||
$image_id = int_escape($event->get_arg('image_id'));
|
||||
self::disapprove_image($image_id);
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("post/view/".$image_id));
|
||||
@@ -72,7 +56,8 @@ class Approval extends Extension
|
||||
|
||||
public function onSetupBuilding(SetupBuildingEvent $event)
|
||||
{
|
||||
$this->theme->display_admin_block($event);
|
||||
$sb = $event->panel->create_new_block("Approval");
|
||||
$sb->add_bool_option(ApprovalConfig::IMAGES, "Posts: ");
|
||||
}
|
||||
|
||||
public function onAdminBuilding(AdminBuildingEvent $event)
|
||||
@@ -86,8 +71,8 @@ class Approval extends Extension
|
||||
|
||||
$action = $event->action;
|
||||
$event->redirect = true;
|
||||
if ($action==="approval") {
|
||||
$approval_action = $_POST["approval_action"];
|
||||
if ($action === "approval") {
|
||||
$approval_action = $event->params["approval_action"];
|
||||
switch ($approval_action) {
|
||||
case "approve_all":
|
||||
$database->set_timeout(null); // These updates can take a little bit
|
||||
@@ -104,7 +89,6 @@ class Approval extends Extension
|
||||
);
|
||||
break;
|
||||
default:
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -114,7 +98,7 @@ class Approval extends Extension
|
||||
{
|
||||
global $page;
|
||||
|
||||
if (!$this->check_permissions(($event->image))) {
|
||||
if (!$this->check_permissions($event->image)) {
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("post/list"));
|
||||
}
|
||||
@@ -220,7 +204,7 @@ class Approval extends Extension
|
||||
* Deny images upon insufficient permissions.
|
||||
**/
|
||||
if (!$this->check_permissions($event->image)) {
|
||||
throw new SCoreException("Access denied");
|
||||
throw new PermissionDenied("Access denied");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +212,12 @@ class Approval extends Extension
|
||||
{
|
||||
global $user, $config;
|
||||
if ($user->can(Permissions::APPROVE_IMAGE) && $config->get_bool(ApprovalConfig::IMAGES)) {
|
||||
$event->add_part((string)$this->theme->get_image_admin_html($event->image));
|
||||
if ($event->image['approved'] === true) {
|
||||
$event->add_button("Disapprove", "disapprove_image/".$event->image->id);
|
||||
} else {
|
||||
$event->add_button("Approve", "approve_image/".$event->image->id);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
43
ext/approval/test.php
Normal file
43
ext/approval/test.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shimmie2;
|
||||
|
||||
class ApprovalTest extends ShimmiePHPUnitTestCase
|
||||
{
|
||||
public function testNoApprovalNeeded(): void
|
||||
{
|
||||
$this->log_in_as_user();
|
||||
$image_id = $this->post_image("tests/pbx_screenshot.jpg", "some_tag");
|
||||
$this->assert_search_results(["some_tag"], [$image_id]);
|
||||
}
|
||||
|
||||
public function testApprovalNeeded(): void
|
||||
{
|
||||
global $config;
|
||||
$config->set_bool(ApprovalConfig::IMAGES, true);
|
||||
|
||||
// use can post but not see what they posted
|
||||
$this->log_in_as_user();
|
||||
$image_id = $this->post_image("tests/pbx_screenshot.jpg", "some_tag");
|
||||
$this->assert_search_results(["some_tag"], []);
|
||||
|
||||
// admin can approve
|
||||
$this->log_in_as_admin();
|
||||
$this->assert_search_results(["some_tag"], []);
|
||||
$this->post_page("approve_image/$image_id");
|
||||
$this->assert_search_results(["some_tag"], [$image_id]);
|
||||
|
||||
// use then sees the image
|
||||
$this->log_in_as_user();
|
||||
$this->assert_search_results(["some_tag"], [$image_id]);
|
||||
}
|
||||
|
||||
public function tearDown(): void
|
||||
{
|
||||
global $config;
|
||||
$config->set_bool(ApprovalConfig::IMAGES, false);
|
||||
parent::tearDown();
|
||||
}
|
||||
}
|
||||
@@ -7,30 +7,10 @@ namespace Shimmie2;
|
||||
use MicroHTML\HTMLElement;
|
||||
|
||||
use function MicroHTML\emptyHTML;
|
||||
|
||||
use function MicroHTML\{BUTTON,INPUT,P};
|
||||
use function MicroHTML\{BUTTON,P};
|
||||
|
||||
class ApprovalTheme extends Themelet
|
||||
{
|
||||
public function get_image_admin_html(Image $image): HTMLElement
|
||||
{
|
||||
if ($image->approved===true) {
|
||||
$form = SHM_SIMPLE_FORM(
|
||||
'disapprove_image/'.$image->id,
|
||||
INPUT(["type"=>'hidden', "name"=>'image_id', "value"=>$image->id]),
|
||||
SHM_SUBMIT("Disapprove")
|
||||
);
|
||||
} else {
|
||||
$form = SHM_SIMPLE_FORM(
|
||||
'approve_image/'.$image->id,
|
||||
INPUT(["type"=>'hidden', "name"=>'image_id', "value"=>$image->id]),
|
||||
SHM_SUBMIT("Approve")
|
||||
);
|
||||
}
|
||||
|
||||
return $form;
|
||||
}
|
||||
|
||||
public function get_help_html(): HTMLElement
|
||||
{
|
||||
return emptyHTML(
|
||||
@@ -40,13 +20,7 @@ class ApprovalTheme extends Themelet
|
||||
);
|
||||
}
|
||||
|
||||
public function display_admin_block(SetupBuildingEvent $event)
|
||||
{
|
||||
$sb = $event->panel->create_new_block("Approval");
|
||||
$sb->add_bool_option(ApprovalConfig::IMAGES, "Posts: ");
|
||||
}
|
||||
|
||||
public function display_admin_form()
|
||||
public function display_admin_form(): void
|
||||
{
|
||||
global $page;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ class ArtistsInfo extends ExtensionInfo
|
||||
public string $url = self::SHIMMIE_URL;
|
||||
public array $authors = ["Sein Kraft"=>"mail@seinkraft.info","Alpha"=>"alpha@furries.com.ar"];
|
||||
public string $license = self::LICENSE_GPLV2;
|
||||
public ExtensionCategory $category = ExtensionCategory::METADATA;
|
||||
public string $description = "Simple artists extension";
|
||||
public bool $beta = true;
|
||||
}
|
||||
|
||||
@@ -27,8 +27,9 @@ class Artists extends Extension
|
||||
public function onImageInfoSet(ImageInfoSetEvent $event)
|
||||
{
|
||||
global $user;
|
||||
if ($user->can(Permissions::EDIT_IMAGE_ARTIST) && isset($_POST["tag_edit__author"])) {
|
||||
send_event(new AuthorSetEvent($event->image, $user, $_POST["tag_edit__author"]));
|
||||
$author = $event->get_param("author");
|
||||
if ($user->can(Permissions::EDIT_IMAGE_ARTIST) && $author) {
|
||||
send_event(new AuthorSetEvent($event->image, $user, $author));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,262 +159,182 @@ class Artists extends Extension
|
||||
{
|
||||
global $page, $user;
|
||||
|
||||
if ($event->page_matches("artist")) {
|
||||
switch ($event->get_arg(0)) {
|
||||
//*************ARTIST SECTION**************
|
||||
case "list":
|
||||
{
|
||||
$this->get_listing($event);
|
||||
$this->theme->sidebar_options("neutral");
|
||||
break;
|
||||
}
|
||||
case "new":
|
||||
{
|
||||
if (!$user->is_anonymous()) {
|
||||
$this->theme->new_artist_composer();
|
||||
} else {
|
||||
$this->theme->display_error(401, "Error", "You must be registered and logged in to create a new artist.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "new_artist":
|
||||
{
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/new"));
|
||||
break;
|
||||
}
|
||||
case "create":
|
||||
{
|
||||
if (!$user->is_anonymous()) {
|
||||
$newArtistID = $this->add_artist();
|
||||
if ($newArtistID == -1) {
|
||||
$this->theme->display_error(400, "Error", "Error when entering artist data.");
|
||||
} else {
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/".$newArtistID));
|
||||
}
|
||||
} else {
|
||||
$this->theme->display_error(401, "Error", "You must be registered and logged in to create a new artist.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "view":
|
||||
{
|
||||
$artistID = int_escape($event->get_arg(1));
|
||||
$artist = $this->get_artist($artistID);
|
||||
$aliases = $this->get_alias($artist['id']);
|
||||
$members = $this->get_members($artist['id']);
|
||||
$urls = $this->get_urls($artist['id']);
|
||||
|
||||
$userIsLogged = !$user->is_anonymous();
|
||||
$userIsAdmin = $user->can(Permissions::ARTISTS_ADMIN);
|
||||
|
||||
$images = Image::find_images(limit: 4, tags: Tag::explode($artist['name']));
|
||||
|
||||
$this->theme->show_artist($artist, $aliases, $members, $urls, $images, $userIsLogged, $userIsAdmin);
|
||||
/*
|
||||
if ($userIsLogged) {
|
||||
$this->theme->show_new_alias_composer($artistID);
|
||||
$this->theme->show_new_member_composer($artistID);
|
||||
$this->theme->show_new_url_composer($artistID);
|
||||
}
|
||||
*/
|
||||
|
||||
$this->theme->sidebar_options("editor", $artistID, $userIsAdmin);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "edit":
|
||||
{
|
||||
$artistID = int_escape($event->get_arg(1));
|
||||
$artist = $this->get_artist($artistID);
|
||||
$aliases = $this->get_alias($artistID);
|
||||
$members = $this->get_members($artistID);
|
||||
$urls = $this->get_urls($artistID);
|
||||
|
||||
if (!$user->is_anonymous()) {
|
||||
$this->theme->show_artist_editor($artist, $aliases, $members, $urls);
|
||||
|
||||
$userIsAdmin = $user->can(Permissions::ARTISTS_ADMIN);
|
||||
$this->theme->sidebar_options("editor", $artistID, $userIsAdmin);
|
||||
} else {
|
||||
$this->theme->display_error(401, "Error", "You must be registered and logged in to edit an artist.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "edit_artist":
|
||||
{
|
||||
$artistID = $_POST['artist_id'];
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/edit/".$artistID));
|
||||
break;
|
||||
}
|
||||
case "edited":
|
||||
{
|
||||
$artistID = int_escape($_POST['id']);
|
||||
$this->update_artist();
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/".$artistID));
|
||||
break;
|
||||
}
|
||||
case "nuke_artist":
|
||||
{
|
||||
$artistID = $_POST['artist_id'];
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/nuke/".$artistID));
|
||||
break;
|
||||
}
|
||||
case "nuke":
|
||||
{
|
||||
$artistID = int_escape($event->get_arg(1));
|
||||
$this->delete_artist($artistID); // this will delete the artist, its alias, its urls and its members
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/list"));
|
||||
break;
|
||||
}
|
||||
case "add_alias":
|
||||
{
|
||||
$artistID = $_POST['artist_id'];
|
||||
$this->theme->show_new_alias_composer($artistID);
|
||||
break;
|
||||
}
|
||||
case "add_member":
|
||||
{
|
||||
$artistID = $_POST['artist_id'];
|
||||
$this->theme->show_new_member_composer($artistID);
|
||||
break;
|
||||
}
|
||||
case "add_url":
|
||||
{
|
||||
$artistID = $_POST['artist_id'];
|
||||
$this->theme->show_new_url_composer($artistID);
|
||||
break;
|
||||
}
|
||||
//***********ALIAS SECTION ***********************
|
||||
case "alias":
|
||||
{
|
||||
switch ($event->get_arg(1)) {
|
||||
case "add":
|
||||
{
|
||||
$artistID = $_POST['artistID'];
|
||||
$this->add_alias();
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/".$artistID));
|
||||
break;
|
||||
}
|
||||
case "delete":
|
||||
{
|
||||
$aliasID = int_escape($event->get_arg(2));
|
||||
$artistID = $this->get_artistID_by_aliasID($aliasID);
|
||||
$this->delete_alias($aliasID);
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/".$artistID));
|
||||
break;
|
||||
}
|
||||
case "edit":
|
||||
{
|
||||
$aliasID = int_escape($event->get_arg(2));
|
||||
$alias = $this->get_alias_by_id($aliasID);
|
||||
$this->theme->show_alias_editor($alias);
|
||||
break;
|
||||
}
|
||||
case "edited":
|
||||
{
|
||||
$this->update_alias();
|
||||
$aliasID = int_escape($_POST['aliasID']);
|
||||
$artistID = $this->get_artistID_by_aliasID($aliasID);
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/".$artistID));
|
||||
break;
|
||||
}
|
||||
}
|
||||
break; // case: alias
|
||||
}
|
||||
|
||||
//**************** URLS SECTION **********************
|
||||
case "url":
|
||||
{
|
||||
switch ($event->get_arg(1)) {
|
||||
case "add":
|
||||
{
|
||||
$artistID = $_POST['artistID'];
|
||||
$this->add_urls();
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/".$artistID));
|
||||
break;
|
||||
}
|
||||
case "delete":
|
||||
{
|
||||
$urlID = int_escape($event->get_arg(2));
|
||||
$artistID = $this->get_artistID_by_urlID($urlID);
|
||||
$this->delete_url($urlID);
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/".$artistID));
|
||||
break;
|
||||
}
|
||||
case "edit":
|
||||
{
|
||||
$urlID = int_escape($event->get_arg(2));
|
||||
$url = $this->get_url_by_id($urlID);
|
||||
$this->theme->show_url_editor($url);
|
||||
break;
|
||||
}
|
||||
case "edited":
|
||||
{
|
||||
$this->update_url();
|
||||
$urlID = int_escape($_POST['urlID']);
|
||||
$artistID = $this->get_artistID_by_urlID($urlID);
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/".$artistID));
|
||||
break;
|
||||
}
|
||||
}
|
||||
break; // case: urls
|
||||
}
|
||||
//******************* MEMBERS SECTION *********************
|
||||
case "member":
|
||||
{
|
||||
switch ($event->get_arg(1)) {
|
||||
case "add":
|
||||
{
|
||||
$artistID = $_POST['artistID'];
|
||||
$this->add_members();
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/".$artistID));
|
||||
break;
|
||||
}
|
||||
case "delete":
|
||||
{
|
||||
$memberID = int_escape($event->get_arg(2));
|
||||
$artistID = $this->get_artistID_by_memberID($memberID);
|
||||
$this->delete_member($memberID);
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/".$artistID));
|
||||
break;
|
||||
}
|
||||
case "edit":
|
||||
{
|
||||
$memberID = int_escape($event->get_arg(2));
|
||||
$member = $this->get_member_by_id($memberID);
|
||||
$this->theme->show_member_editor($member);
|
||||
break;
|
||||
}
|
||||
case "edited":
|
||||
{
|
||||
$this->update_member();
|
||||
$memberID = int_escape($_POST['memberID']);
|
||||
$artistID = $this->get_artistID_by_memberID($memberID);
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/".$artistID));
|
||||
break;
|
||||
}
|
||||
}
|
||||
break; //case: members
|
||||
}
|
||||
if ($event->page_matches("artist/list/{page}")) {
|
||||
$this->get_listing(page_number($event->get_arg('page')));
|
||||
$this->theme->sidebar_options("neutral");
|
||||
}
|
||||
if ($event->page_matches("artist/new")) {
|
||||
if (!$user->is_anonymous()) {
|
||||
$this->theme->new_artist_composer();
|
||||
} else {
|
||||
$this->theme->display_error(401, "Error", "You must be registered and logged in to create a new artist.");
|
||||
}
|
||||
}
|
||||
if ($event->page_matches("artist/new_artist")) {
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/new"));
|
||||
}
|
||||
if ($event->page_matches("artist/create")) {
|
||||
if (!$user->is_anonymous()) {
|
||||
$newArtistID = $this->add_artist();
|
||||
if ($newArtistID == -1) {
|
||||
$this->theme->display_error(400, "Error", "Error when entering artist data.");
|
||||
} else {
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/" . $newArtistID));
|
||||
}
|
||||
} else {
|
||||
$this->theme->display_error(401, "Error", "You must be registered and logged in to create a new artist.");
|
||||
}
|
||||
}
|
||||
if ($event->page_matches("artist/view/{artistID}")) {
|
||||
$artistID = $event->get_iarg('artistID');
|
||||
$artist = $this->get_artist($artistID);
|
||||
$aliases = $this->get_alias($artist['id']);
|
||||
$members = $this->get_members($artist['id']);
|
||||
$urls = $this->get_urls($artist['id']);
|
||||
|
||||
$userIsLogged = !$user->is_anonymous();
|
||||
$userIsAdmin = $user->can(Permissions::ARTISTS_ADMIN);
|
||||
|
||||
$images = Search::find_images(limit: 4, tags: Tag::explode($artist['name']));
|
||||
|
||||
$this->theme->show_artist($artist, $aliases, $members, $urls, $images, $userIsLogged, $userIsAdmin);
|
||||
/*
|
||||
if ($userIsLogged) {
|
||||
$this->theme->show_new_alias_composer($artistID);
|
||||
$this->theme->show_new_member_composer($artistID);
|
||||
$this->theme->show_new_url_composer($artistID);
|
||||
}
|
||||
*/
|
||||
|
||||
$this->theme->sidebar_options("editor", $artistID, $userIsAdmin);
|
||||
}
|
||||
if ($event->page_matches("artist/edit/{artistID}")) {
|
||||
$artistID = $event->get_iarg('artistID');
|
||||
$artist = $this->get_artist($artistID);
|
||||
$aliases = $this->get_alias($artistID);
|
||||
$members = $this->get_members($artistID);
|
||||
$urls = $this->get_urls($artistID);
|
||||
|
||||
if (!$user->is_anonymous()) {
|
||||
$this->theme->show_artist_editor($artist, $aliases, $members, $urls);
|
||||
|
||||
$userIsAdmin = $user->can(Permissions::ARTISTS_ADMIN);
|
||||
$this->theme->sidebar_options("editor", $artistID, $userIsAdmin);
|
||||
} else {
|
||||
$this->theme->display_error(401, "Error", "You must be registered and logged in to edit an artist.");
|
||||
}
|
||||
}
|
||||
if ($event->page_matches("artist/edit_artist")) {
|
||||
$artistID = int_escape($event->req_POST('artist_id'));
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/edit/" . $artistID));
|
||||
}
|
||||
if ($event->page_matches("artist/edited")) {
|
||||
$artistID = int_escape($event->get_POST('id'));
|
||||
$this->update_artist();
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/" . $artistID));
|
||||
}
|
||||
if ($event->page_matches("artist/nuke_artist")) {
|
||||
$artistID = int_escape($event->req_POST('artist_id'));
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/nuke/" . $artistID));
|
||||
}
|
||||
if ($event->page_matches("artist/nuke/{artistID}")) {
|
||||
$artistID = $event->get_iarg('artistID');
|
||||
$this->delete_artist($artistID); // this will delete the artist, its alias, its urls and its members
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/list"));
|
||||
}
|
||||
if ($event->page_matches("artist/add_alias")) {
|
||||
$artistID = int_escape($event->req_POST('artist_id'));
|
||||
$this->theme->show_new_alias_composer($artistID);
|
||||
}
|
||||
if ($event->page_matches("artist/add_member")) {
|
||||
$artistID = int_escape($event->req_POST('artist_id'));
|
||||
$this->theme->show_new_member_composer($artistID);
|
||||
}
|
||||
if ($event->page_matches("artist/add_url")) {
|
||||
$artistID = int_escape($event->req_POST('artist_id'));
|
||||
$this->theme->show_new_url_composer($artistID);
|
||||
}
|
||||
if ($event->page_matches("artist/alias/add")) {
|
||||
$artistID = int_escape($event->req_POST('artist_id'));
|
||||
$this->add_alias();
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/" . $artistID));
|
||||
}
|
||||
if ($event->page_matches("artist/alias/delete/{aliasID}")) {
|
||||
$aliasID = $event->get_iarg('aliasID');
|
||||
$artistID = $this->get_artistID_by_aliasID($aliasID);
|
||||
$this->delete_alias($aliasID);
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/" . $artistID));
|
||||
}
|
||||
if ($event->page_matches("artist/alias/edit/{aliasID}")) {
|
||||
$aliasID = $event->get_iarg('aliasID');
|
||||
$alias = $this->get_alias_by_id($aliasID);
|
||||
$this->theme->show_alias_editor($alias);
|
||||
}
|
||||
if ($event->page_matches("artist/alias/edited")) {
|
||||
$this->update_alias();
|
||||
$aliasID = int_escape($event->req_POST('aliasID'));
|
||||
$artistID = $this->get_artistID_by_aliasID($aliasID);
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/" . $artistID));
|
||||
}
|
||||
if ($event->page_matches("artist/url/add")) {
|
||||
$artistID = int_escape($event->req_POST('artist_id'));
|
||||
$this->add_urls();
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/" . $artistID));
|
||||
}
|
||||
if ($event->page_matches("artist/url/delete/{urlID}")) {
|
||||
$urlID = $event->get_iarg('urlID');
|
||||
$artistID = $this->get_artistID_by_urlID($urlID);
|
||||
$this->delete_url($urlID);
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/" . $artistID));
|
||||
}
|
||||
if ($event->page_matches("artist/url/edit/{urlID}")) {
|
||||
$urlID = $event->get_iarg('urlID');
|
||||
$url = $this->get_url_by_id($urlID);
|
||||
$this->theme->show_url_editor($url);
|
||||
}
|
||||
if ($event->page_matches("artist/url/edited")) {
|
||||
$this->update_url();
|
||||
$urlID = int_escape($event->req_POST('urlID'));
|
||||
$artistID = $this->get_artistID_by_urlID($urlID);
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/" . $artistID));
|
||||
}
|
||||
if ($event->page_matches("artist/member/add")) {
|
||||
$artistID = int_escape($event->req_POST('artist_id'));
|
||||
$this->add_members();
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/" . $artistID));
|
||||
}
|
||||
if ($event->page_matches("artist/member/delete/{memberID}")) {
|
||||
$memberID = $event->get_iarg('memberID');
|
||||
$artistID = $this->get_artistID_by_memberID($memberID);
|
||||
$this->delete_member($memberID);
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/" . $artistID));
|
||||
}
|
||||
if ($event->page_matches("artist/member/edit/{memberID}")) {
|
||||
$memberID = $event->get_iarg('memberID');
|
||||
$member = $this->get_member_by_id($memberID);
|
||||
$this->theme->show_member_editor($member);
|
||||
}
|
||||
if ($event->page_matches("artist/member/edited")) {
|
||||
$this->update_member();
|
||||
$memberID = int_escape($event->req_POST('memberID'));
|
||||
$artistID = $this->get_artistID_by_memberID($memberID);
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("artist/view/" . $artistID));
|
||||
}
|
||||
}
|
||||
|
||||
private function get_artistName_by_imageID(int $imageID): string
|
||||
@@ -457,37 +378,37 @@ class Artists extends Extension
|
||||
private function get_artistID_by_url(string $url): int
|
||||
{
|
||||
global $database;
|
||||
return (int)$database->get_one("SELECT artist_id FROM artist_urls WHERE url = :url", ['url'=>$url]);
|
||||
return (int) $database->get_one("SELECT artist_id FROM artist_urls WHERE url = :url", ['url' => $url]);
|
||||
}
|
||||
|
||||
private function get_artistID_by_memberName(string $member): int
|
||||
{
|
||||
global $database;
|
||||
return (int)$database->get_one("SELECT artist_id FROM artist_members WHERE name = :name", ['name'=>$member]);
|
||||
return (int) $database->get_one("SELECT artist_id FROM artist_members WHERE name = :name", ['name' => $member]);
|
||||
}
|
||||
|
||||
private function get_artistName_by_artistID(int $artistID): string
|
||||
{
|
||||
global $database;
|
||||
return (string)$database->get_one("SELECT name FROM artists WHERE id = :id", ['id'=>$artistID]);
|
||||
return (string) $database->get_one("SELECT name FROM artists WHERE id = :id", ['id' => $artistID]);
|
||||
}
|
||||
|
||||
private function get_artistID_by_aliasID(int $aliasID): int
|
||||
{
|
||||
global $database;
|
||||
return (int)$database->get_one("SELECT artist_id FROM artist_alias WHERE id = :id", ['id'=>$aliasID]);
|
||||
return (int) $database->get_one("SELECT artist_id FROM artist_alias WHERE id = :id", ['id' => $aliasID]);
|
||||
}
|
||||
|
||||
private function get_artistID_by_memberID(int $memberID): int
|
||||
{
|
||||
global $database;
|
||||
return (int)$database->get_one("SELECT artist_id FROM artist_members WHERE id = :id", ['id'=>$memberID]);
|
||||
return (int) $database->get_one("SELECT artist_id FROM artist_members WHERE id = :id", ['id' => $memberID]);
|
||||
}
|
||||
|
||||
private function get_artistID_by_urlID(int $urlID): int
|
||||
{
|
||||
global $database;
|
||||
return (int)$database->get_one("SELECT artist_id FROM artist_urls WHERE id = :id", ['id'=>$urlID]);
|
||||
return (int) $database->get_one("SELECT artist_id FROM artist_urls WHERE id = :id", ['id' => $urlID]);
|
||||
}
|
||||
|
||||
private function delete_alias(int $aliasID)
|
||||
@@ -790,7 +711,7 @@ class Artists extends Extension
|
||||
);
|
||||
|
||||
$num = count($result);
|
||||
for ($i = 0 ; $i < $num ; $i++) {
|
||||
for ($i = 0; $i < $num; $i++) {
|
||||
$result[$i]["name"] = stripslashes($result[$i]["name"]);
|
||||
}
|
||||
|
||||
@@ -806,7 +727,7 @@ class Artists extends Extension
|
||||
);
|
||||
|
||||
$num = count($result);
|
||||
for ($i = 0 ; $i < $num ; $i++) {
|
||||
for ($i = 0; $i < $num; $i++) {
|
||||
$result[$i]["url"] = stripslashes($result[$i]["url"]);
|
||||
}
|
||||
|
||||
@@ -816,7 +737,7 @@ class Artists extends Extension
|
||||
private function get_artist_id(string $name): int
|
||||
{
|
||||
global $database;
|
||||
return (int)$database->get_one(
|
||||
return (int) $database->get_one(
|
||||
"SELECT id FROM artists WHERE name = :name",
|
||||
['name'=>$name]
|
||||
);
|
||||
@@ -826,7 +747,7 @@ class Artists extends Extension
|
||||
{
|
||||
global $database;
|
||||
|
||||
return (int)$database->get_one(
|
||||
return (int) $database->get_one(
|
||||
"SELECT artist_id FROM artist_alias WHERE alias = :alias",
|
||||
['alias'=>$alias]
|
||||
);
|
||||
@@ -842,60 +763,59 @@ class Artists extends Extension
|
||||
}
|
||||
|
||||
/*
|
||||
* HERE WE GET THE LIST OF ALL ARTIST WITH PAGINATION
|
||||
*/
|
||||
private function get_listing(PageRequestEvent $event)
|
||||
* HERE WE GET THE LIST OF ALL ARTIST WITH PAGINATION
|
||||
*/
|
||||
private function get_listing(int $pageNumber): void
|
||||
{
|
||||
global $config, $database;
|
||||
|
||||
$pageNumber = clamp(int_escape($event->get_arg(1)), 1, null) - 1;
|
||||
$artistsPerPage = $config->get_int("artistsPerPage");
|
||||
|
||||
$listing = $database->get_all(
|
||||
"
|
||||
(
|
||||
SELECT a.id, a.user_id, a.name, u.name AS user_name, COALESCE(t.count, 0) AS posts
|
||||
, 'artist' as type, a.id AS artist_id, a.name AS artist_name, a.updated
|
||||
FROM artists AS a
|
||||
INNER JOIN users AS u
|
||||
ON a.user_id = u.id
|
||||
LEFT OUTER JOIN tags AS t
|
||||
ON a.name = t.tag
|
||||
GROUP BY a.id, a.user_id, a.name, u.name
|
||||
ORDER BY a.updated DESC
|
||||
)
|
||||
(
|
||||
SELECT a.id, a.user_id, a.name, u.name AS user_name, COALESCE(t.count, 0) AS posts
|
||||
, 'artist' as type, a.id AS artist_id, a.name AS artist_name, a.updated
|
||||
FROM artists AS a
|
||||
INNER JOIN users AS u
|
||||
ON a.user_id = u.id
|
||||
LEFT OUTER JOIN tags AS t
|
||||
ON a.name = t.tag
|
||||
GROUP BY a.id, a.user_id, a.name, u.name
|
||||
ORDER BY a.updated DESC
|
||||
)
|
||||
|
||||
UNION
|
||||
UNION
|
||||
|
||||
(
|
||||
SELECT aa.id, aa.user_id, aa.alias AS name, u.name AS user_name, COALESCE(t.count, 0) AS posts
|
||||
, 'alias' as type, a.id AS artist_id, a.name AS artist_name, aa.updated
|
||||
FROM artist_alias AS aa
|
||||
INNER JOIN users AS u
|
||||
ON aa.user_id = u.id
|
||||
INNER JOIN artists AS a
|
||||
ON aa.artist_id = a.id
|
||||
LEFT OUTER JOIN tags AS t
|
||||
ON aa.alias = t.tag
|
||||
GROUP BY aa.id, a.user_id, aa.alias, u.name, a.id, a.name
|
||||
ORDER BY aa.updated DESC
|
||||
)
|
||||
(
|
||||
SELECT aa.id, aa.user_id, aa.alias AS name, u.name AS user_name, COALESCE(t.count, 0) AS posts
|
||||
, 'alias' as type, a.id AS artist_id, a.name AS artist_name, aa.updated
|
||||
FROM artist_alias AS aa
|
||||
INNER JOIN users AS u
|
||||
ON aa.user_id = u.id
|
||||
INNER JOIN artists AS a
|
||||
ON aa.artist_id = a.id
|
||||
LEFT OUTER JOIN tags AS t
|
||||
ON aa.alias = t.tag
|
||||
GROUP BY aa.id, a.user_id, aa.alias, u.name, a.id, a.name
|
||||
ORDER BY aa.updated DESC
|
||||
)
|
||||
|
||||
UNION
|
||||
UNION
|
||||
|
||||
(
|
||||
SELECT m.id, m.user_id, m.name AS name, u.name AS user_name, COALESCE(t.count, 0) AS posts
|
||||
, 'member' AS type, a.id AS artist_id, a.name AS artist_name, m.updated
|
||||
FROM artist_members AS m
|
||||
INNER JOIN users AS u
|
||||
ON m.user_id = u.id
|
||||
INNER JOIN artists AS a
|
||||
ON m.artist_id = a.id
|
||||
LEFT OUTER JOIN tags AS t
|
||||
ON m.name = t.tag
|
||||
GROUP BY m.id, m.user_id, m.name, u.name, a.id, a.name
|
||||
ORDER BY m.updated DESC
|
||||
)
|
||||
(
|
||||
SELECT m.id, m.user_id, m.name AS name, u.name AS user_name, COALESCE(t.count, 0) AS posts
|
||||
, 'member' AS type, a.id AS artist_id, a.name AS artist_name, m.updated
|
||||
FROM artist_members AS m
|
||||
INNER JOIN users AS u
|
||||
ON m.user_id = u.id
|
||||
INNER JOIN artists AS a
|
||||
ON m.artist_id = a.id
|
||||
LEFT OUTER JOIN tags AS t
|
||||
ON m.name = t.tag
|
||||
GROUP BY m.id, m.user_id, m.name, u.name, a.id, a.name
|
||||
ORDER BY m.updated DESC
|
||||
)
|
||||
ORDER BY updated DESC
|
||||
LIMIT :offset, :limit
|
||||
",
|
||||
@@ -907,7 +827,7 @@ class Artists extends Extension
|
||||
|
||||
$number_of_listings = count($listing);
|
||||
|
||||
for ($i = 0 ; $i < $number_of_listings ; $i++) {
|
||||
for ($i = 0; $i < $number_of_listings; $i++) {
|
||||
$listing[$i]["name"] = stripslashes($listing[$i]["name"]);
|
||||
$listing[$i]["user_name"] = stripslashes($listing[$i]["user_name"]);
|
||||
$listing[$i]["artist_name"] = stripslashes($listing[$i]["artist_name"]);
|
||||
@@ -922,15 +842,15 @@ class Artists extends Extension
|
||||
ON a.id = aa.artist_id
|
||||
");
|
||||
|
||||
$totalPages = ceil($count / $artistsPerPage);
|
||||
$totalPages = (int) ceil($count / $artistsPerPage);
|
||||
|
||||
$this->theme->list_artists($listing, $pageNumber + 1, $totalPages);
|
||||
}
|
||||
|
||||
/*
|
||||
* HERE WE ADD AN ALIAS
|
||||
*/
|
||||
private function add_urls()
|
||||
* HERE WE ADD AN ALIAS
|
||||
*/
|
||||
private function add_urls(): void
|
||||
{
|
||||
global $user;
|
||||
$inputs = validate_input([
|
||||
@@ -1048,7 +968,7 @@ class Artists extends Extension
|
||||
", ['artist_id'=>$artistID]);
|
||||
|
||||
$rc = count($result);
|
||||
for ($i = 0 ; $i < $rc ; $i++) {
|
||||
for ($i = 0; $i < $rc; $i++) {
|
||||
$result[$i]["alias_name"] = stripslashes($result[$i]["alias_name"]);
|
||||
}
|
||||
return $result;
|
||||
|
||||
@@ -11,7 +11,7 @@ class ArtistsTest extends ShimmiePHPUnitTestCase
|
||||
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);
|
||||
$image = Image::by_id_ex($image_id);
|
||||
|
||||
send_event(new AuthorSetEvent($image, $user, "bob"));
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ class ArtistsTheme extends Themelet
|
||||
{
|
||||
public function get_author_editor_html(string $author): string
|
||||
{
|
||||
$h_author = html_escape($author);
|
||||
return (string)TR(TH("Author", TD(
|
||||
SPAN(["class"=>"view"], $h_author),
|
||||
INPUT(["class"=>"edit", "type"=>"text", "name"=>"tag_edit__author", "value"=>$h_author])
|
||||
)));
|
||||
return SHM_POST_INFO(
|
||||
"Author",
|
||||
$author,
|
||||
INPUT(["type" => "text", "name" => "author", "value" => $author])
|
||||
);
|
||||
}
|
||||
|
||||
public function sidebar_options(string $mode, ?int $artistID=null, $is_admin=false): void
|
||||
@@ -27,46 +27,39 @@ class ArtistsTheme extends Themelet
|
||||
$html = "";
|
||||
|
||||
if ($mode == "neutral") {
|
||||
$html = "<form method='post' action='".make_link("artist/new_artist")."'>
|
||||
".$user->get_auth_html()."
|
||||
$html = make_form(make_link("artist/new_artist"))."
|
||||
<input type='submit' name='edit' id='edit' value='New Artist'/>
|
||||
</form>";
|
||||
}
|
||||
|
||||
if ($mode == "editor") {
|
||||
$html = "<form method='post' action='".make_link("artist/new_artist")."'>
|
||||
".$user->get_auth_html()."
|
||||
$html = make_form(make_link("artist/new_artist"))."
|
||||
<input type='submit' name='edit' value='New Artist'/>
|
||||
</form>
|
||||
|
||||
<form method='post' action='".make_link("artist/edit_artist")."'>
|
||||
".$user->get_auth_html()."
|
||||
".make_form(make_link("artist/edit_artist"))."
|
||||
<input type='submit' name='edit' value='Edit Artist'/>
|
||||
<input type='hidden' name='artist_id' value='".$artistID."'>
|
||||
</form>";
|
||||
|
||||
if ($is_admin) {
|
||||
$html .= "<form method='post' action='".make_link("artist/nuke_artist")."'>
|
||||
".$user->get_auth_html()."
|
||||
$html .= make_form(make_link("artist/nuke_artist"))."
|
||||
<input type='submit' name='edit' value='Delete Artist'/>
|
||||
<input type='hidden' name='artist_id' value='".$artistID."'>
|
||||
</form>";
|
||||
}
|
||||
|
||||
$html .= "<form method='post' action='".make_link("artist/add_alias")."'>
|
||||
".$user->get_auth_html()."
|
||||
$html .= make_form(make_link("artist/add_alias"))."
|
||||
<input type='submit' name='edit' value='Add Alias'/>
|
||||
<input type='hidden' name='artist_id' value='".$artistID."'>
|
||||
</form>
|
||||
|
||||
<form method='post' action='".make_link("artist/add_member")."'>
|
||||
".$user->get_auth_html()."
|
||||
".make_form(make_link("artist/add_member"))."
|
||||
<input type='submit' name='edit' value='Add Member'/>
|
||||
<input type='hidden' name='artist_id' value='".$artistID."'>
|
||||
</form>
|
||||
|
||||
<form method='post' action='".make_link("artist/add_url")."'>
|
||||
".$user->get_auth_html()."
|
||||
".make_form(make_link("artist/add_url"))."
|
||||
<input type='submit' name='edit' value='Add Url'/>
|
||||
<input type='hidden' name='artist_id' value='".$artistID."'>
|
||||
</form>";
|
||||
@@ -115,9 +108,7 @@ class ArtistsTheme extends Themelet
|
||||
$urlsString = substr($urlsString, 0, strlen($urlsString) -1);
|
||||
$urlsIDsString = rtrim($urlsIDsString);
|
||||
|
||||
$html = '
|
||||
<form method="POST" action="'.make_link("artist/edited/".$artist['id']).'">
|
||||
'.$user->get_auth_html().'
|
||||
$html = make_form(make_link("artist/edited/".$artist['id'])).'
|
||||
<table>
|
||||
<tr><td>Name:</td><td><input type="text" name="name" value="'.$artistName.'" />
|
||||
<input type="hidden" name="id" value="'.$artistID.'" /></td></tr>
|
||||
@@ -141,8 +132,7 @@ class ArtistsTheme extends Themelet
|
||||
{
|
||||
global $page, $user;
|
||||
|
||||
$html = "<form action=".make_link("artist/create")." method='POST'>
|
||||
".$user->get_auth_html()."
|
||||
$html = make_form(make_link("artist/create"))."
|
||||
<table>
|
||||
<tr><td>Name:</td><td><input type='text' name='name' /></td></tr>
|
||||
<tr><td>Aliases:</td><td><input type='text' name='aliases' /></td></tr>
|
||||
@@ -241,9 +231,7 @@ class ArtistsTheme extends Themelet
|
||||
{
|
||||
global $user;
|
||||
|
||||
$html = '
|
||||
<form method="POST" action='.make_link("artist/alias/add").'>
|
||||
'.$user->get_auth_html().'
|
||||
$html = make_form(make_link("artist/alias/add")).'
|
||||
<table>
|
||||
<tr><td>Alias:</td><td><input type="text" name="aliases" />
|
||||
<input type="hidden" name="artistID" value='.$artistID.' /></td></tr>
|
||||
@@ -260,9 +248,7 @@ class ArtistsTheme extends Themelet
|
||||
{
|
||||
global $user;
|
||||
|
||||
$html = '
|
||||
<form method="POST" action='.make_link("artist/member/add").'>
|
||||
'.$user->get_auth_html().'
|
||||
$html = make_form(make_link("artist/member/add")).'
|
||||
<table>
|
||||
<tr><td>Members:</td><td><input type="text" name="members" />
|
||||
<input type="hidden" name="artistID" value='.$artistID.' /></td></tr>
|
||||
@@ -279,9 +265,7 @@ class ArtistsTheme extends Themelet
|
||||
{
|
||||
global $user;
|
||||
|
||||
$html = '
|
||||
<form method="POST" action='.make_link("artist/url/add").'>
|
||||
'.$user->get_auth_html().'
|
||||
$html = make_form(make_link("artist/url/add")).'
|
||||
<table>
|
||||
<tr><td>URL:</td><td><textarea name="urls"></textarea>
|
||||
<input type="hidden" name="artistID" value='.$artistID.' /></td></tr>
|
||||
@@ -298,9 +282,7 @@ class ArtistsTheme extends Themelet
|
||||
{
|
||||
global $user;
|
||||
|
||||
$html = '
|
||||
<form method="POST" action="'.make_link("artist/alias/edited/".$alias['id']).'">
|
||||
'.$user->get_auth_html().'
|
||||
$html = make_form(make_link("artist/alias/edited/".$alias['id'])).'
|
||||
<label for="alias">Alias:</label>
|
||||
<input type="text" name="alias" id="alias" value="'.$alias['alias'].'" />
|
||||
<input type="hidden" name="aliasID" value="'.$alias['id'].'" />
|
||||
@@ -316,9 +298,7 @@ class ArtistsTheme extends Themelet
|
||||
{
|
||||
global $user;
|
||||
|
||||
$html = '
|
||||
<form method="POST" action="'.make_link("artist/url/edited/".$url['id']).'">
|
||||
'.$user->get_auth_html().'
|
||||
$html = make_form(make_link("artist/url/edited/".$url['id'])).'
|
||||
<label for="url">URL:</label>
|
||||
<input type="text" name="url" id="url" value="'.$url['url'].'" />
|
||||
<input type="hidden" name="urlID" value="'.$url['id'].'" />
|
||||
@@ -334,9 +314,7 @@ class ArtistsTheme extends Themelet
|
||||
{
|
||||
global $user;
|
||||
|
||||
$html = '
|
||||
<form method="POST" action="'.make_link("artist/member/edited/".$member['id']).'">
|
||||
'.$user->get_auth_html().'
|
||||
$html = make_form(make_link("artist/member/edited/".$member['id'])).'
|
||||
<label for="name">Member name:</label>
|
||||
<input type="text" name="name" id="name" value="'.$member['name'].'" />
|
||||
<input type="hidden" name="memberID" value="'.$member['id'].'" />
|
||||
|
||||
@@ -12,5 +12,6 @@ class AutoTaggerInfo extends ExtensionInfo
|
||||
public string $name = "Auto-Tagger";
|
||||
public array $authors = ["Matthew Barbour"=>"matthew@darkholme.net"];
|
||||
public string $license = self::LICENSE_WTFPL;
|
||||
public ExtensionCategory $category = ExtensionCategory::METADATA;
|
||||
public string $description = "Provides several automatic tagging functions";
|
||||
}
|
||||
|
||||
@@ -71,57 +71,45 @@ class AutoTagger extends Extension
|
||||
{
|
||||
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_mime(MimeType::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($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");
|
||||
}
|
||||
if ($event->page_matches("auto_tag/add", method: "POST", permission: Permissions::MANAGE_AUTO_TAG)) {
|
||||
$input = validate_input(["c_tag" => "string", "c_additional_tags" => "string"]);
|
||||
send_event(new AddAutoTagEvent($input['c_tag'], $input['c_additional_tags']));
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("auto_tag/list"));
|
||||
}
|
||||
if ($event->page_matches("auto_tag/remove", method: "POST", permission: Permissions::MANAGE_AUTO_TAG)) {
|
||||
$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"));
|
||||
}
|
||||
if ($event->page_matches("auto_tag/list")) {
|
||||
$t = new AutoTaggerTable($database->raw_db());
|
||||
$t->token = $user->get_auth_token();
|
||||
$t->inputs = $event->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());
|
||||
}
|
||||
if ($event->page_matches("auto_tag/export/auto_tag.csv")) {
|
||||
$page->set_mode(PageMode::DATA);
|
||||
$page->set_mime(MimeType::CSV);
|
||||
$page->set_filename("auto_tag.csv");
|
||||
$page->set_data($this->get_auto_tag_csv($database));
|
||||
}
|
||||
if ($event->page_matches("auto_tag/import", method: "POST", permission: Permissions::MANAGE_AUTO_TAG)) {
|
||||
if (count($_FILES) > 0) {
|
||||
$tmp = $_FILES['auto_tag_file']['tmp_name'];
|
||||
$contents = \Safe\file_get_contents($tmp);
|
||||
$count = $this->add_auto_tag_csv($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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,12 +186,8 @@ class AutoTagger extends Extension
|
||||
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());
|
||||
}
|
||||
send_event(new AddAutoTagEvent($parts[0], $parts[1]));
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
return $i;
|
||||
@@ -258,7 +242,7 @@ class AutoTagger extends Extension
|
||||
$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_id = (int) $image_id;
|
||||
$image = Image::by_id($image_id);
|
||||
$image = Image::by_id_ex($image_id);
|
||||
send_event(new TagSetEvent($image, $image->get_tag_array()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ class AutoTaggerTheme extends Themelet
|
||||
";
|
||||
|
||||
$bulk_html = "
|
||||
".make_form(make_link("auto_tag/import"), 'post', true)."
|
||||
".make_form(make_link("auto_tag/import"), multipart: true)."
|
||||
<input type='file' name='auto_tag_file'>
|
||||
<input type='submit' value='Upload List'>
|
||||
</form>
|
||||
|
||||
@@ -19,19 +19,22 @@ class AutoComplete extends Extension
|
||||
global $page;
|
||||
|
||||
if ($event->page_matches("api/internal/autocomplete")) {
|
||||
$limit = (int)($_GET["limit"] ?? 0);
|
||||
$s = $_GET["s"] ?? "";
|
||||
$limit = (int)($event->get_GET("limit") ?? 1000);
|
||||
$s = $event->get_GET("s") ?? "";
|
||||
|
||||
$res = $this->complete($s, $limit);
|
||||
|
||||
$page->set_mode(PageMode::DATA);
|
||||
$page->set_mime(MimeType::JSON);
|
||||
$page->set_data(json_encode($res));
|
||||
$page->set_data(\Safe\json_encode($res));
|
||||
}
|
||||
|
||||
$this->theme->build_autocomplete($page);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{newtag:string|null,count:int}>
|
||||
*/
|
||||
private function complete(string $search, int $limit): array
|
||||
{
|
||||
global $cache, $database;
|
||||
@@ -62,23 +65,42 @@ class AutoComplete extends Extension
|
||||
$cache_key .= "-" . $limit;
|
||||
}
|
||||
|
||||
$res = $cache->get($cache_key);
|
||||
if (is_null($res)) {
|
||||
$res = $database->get_pairs(
|
||||
return cache_get_or_set($cache_key, function () use ($database, $limitSQL, $SQLarr) {
|
||||
$rows = $database->get_all(
|
||||
"
|
||||
SELECT tag, count
|
||||
FROM tags
|
||||
WHERE LOWER(tag) LIKE LOWER(:search)
|
||||
-- OR LOWER(tag) LIKE LOWER(:cat_search)
|
||||
AND count > 0
|
||||
ORDER BY count DESC
|
||||
$limitSQL
|
||||
-- (
|
||||
SELECT tag, NULL AS newtag, count
|
||||
FROM tags
|
||||
WHERE (
|
||||
LOWER(tag) LIKE LOWER(:search)
|
||||
OR LOWER(tag) LIKE LOWER(:cat_search)
|
||||
)
|
||||
AND count > 0
|
||||
-- )
|
||||
UNION
|
||||
-- (
|
||||
SELECT oldtag AS tag, newtag, count
|
||||
FROM aliases
|
||||
JOIN tags ON tag = newtag
|
||||
WHERE (
|
||||
(LOWER(oldtag) LIKE LOWER(:search) AND LOWER(newtag) NOT LIKE LOWER(:search))
|
||||
OR (LOWER(oldtag) LIKE LOWER(:cat_search) AND LOWER(newtag) NOT LIKE LOWER(:cat_search))
|
||||
)
|
||||
AND count > 0
|
||||
-- )
|
||||
ORDER BY count DESC, tag ASC
|
||||
$limitSQL
|
||||
",
|
||||
$SQLarr
|
||||
);
|
||||
$cache->set($cache_key, $res, 600);
|
||||
}
|
||||
|
||||
return $res;
|
||||
$ret = [];
|
||||
foreach ($rows as $row) {
|
||||
$ret[(string)$row['tag']] = [
|
||||
"newtag" => $row["newtag"],
|
||||
"count" => $row["count"],
|
||||
];
|
||||
}
|
||||
return $ret;
|
||||
}, 600);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,205 @@
|
||||
/**
|
||||
* Find the word currently being typed in the given element
|
||||
*
|
||||
* @param {HTMLInputElement} element
|
||||
* @returns {string}
|
||||
*/
|
||||
function getCurrentWord(element) {
|
||||
let text = element.value;
|
||||
let pos = element.selectionStart;
|
||||
var start = text.lastIndexOf(' ', pos-1);
|
||||
if(start === -1) {
|
||||
start = 0;
|
||||
}
|
||||
else {
|
||||
start++; // skip the space
|
||||
}
|
||||
return text.substring(start, pos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whenever input changes, look at what word is currently
|
||||
* being typed, and fetch completions for it.
|
||||
*
|
||||
* @param {HTMLInputElement} element
|
||||
*/
|
||||
function updateCompletions(element) {
|
||||
// Reset selction, but no need to validate and re-render
|
||||
// highlightCompletion(element, -1);
|
||||
element.selected_completion = -1;
|
||||
|
||||
// get the word before the cursor
|
||||
var word = getCurrentWord(element);
|
||||
|
||||
// search for completions
|
||||
if(element.completer_timeout !== null) {
|
||||
clearTimeout(element.completer_timeout);
|
||||
element.completer_timeout = null;
|
||||
}
|
||||
if(word === '' || word === '-') {
|
||||
element.completions = {};
|
||||
renderCompletions(element);
|
||||
}
|
||||
else {
|
||||
element.completer_timeout = setTimeout(() => {
|
||||
const wordWithoutMinus = word.replace(/^-/, '');
|
||||
fetch(shm_make_link('api/internal/autocomplete', {s: wordWithoutMinus})).then(
|
||||
(response) => response.json()
|
||||
).then((json) => {
|
||||
if(element.selected_completion !== -1) {
|
||||
return; // user has started to navigate the completions, so don't update them
|
||||
}
|
||||
element.completions = json;
|
||||
renderCompletions(element);
|
||||
});
|
||||
}, 250);
|
||||
renderCompletions(element);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight the nth completion
|
||||
*
|
||||
* @param {HTMLInputElement} element
|
||||
* @param {number} n
|
||||
*/
|
||||
function highlightCompletion(element, n) {
|
||||
if(!element.completions) return;
|
||||
element.selected_completion = Math.min(
|
||||
Math.max(n, -1),
|
||||
Object.keys(element.completions).length-1
|
||||
);
|
||||
renderCompletions(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the completion block
|
||||
*
|
||||
* @param {HTMLInputElement} element
|
||||
*/
|
||||
function renderCompletions(element) {
|
||||
let completions = element.completions;
|
||||
let selected_completion = element.selected_completion;
|
||||
|
||||
// remove any existing completion block
|
||||
hideCompletions();
|
||||
|
||||
// if there are no completions, don't render anything
|
||||
if(Object.keys(completions).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let completions_el = document.createElement('ul');
|
||||
completions_el.className = 'autocomplete_completions';
|
||||
completions_el.id = 'completions';
|
||||
|
||||
// add children for top completions, with the selected one highlighted
|
||||
let word = getCurrentWord(element);
|
||||
Object.keys(completions).filter(
|
||||
(key) => {
|
||||
let k = key.toLowerCase();
|
||||
let w = word.replace(/^-/, '').toLowerCase();
|
||||
return (k.startsWith(w) || k.split(':').some((k) => k.startsWith(w)))
|
||||
}
|
||||
).slice(0, 100).forEach((key, i) => {
|
||||
let li = document.createElement('li');
|
||||
li.innerText = completions[key].newtag ?
|
||||
`${key} → ${completions[key].newtag} (${completions[key].count})` :
|
||||
`${key} (${completions[key].count})` ;
|
||||
if(i === selected_completion) {
|
||||
li.className = 'selected';
|
||||
}
|
||||
// on hover, select the completion
|
||||
// (use mousemove rather than mouseover, because
|
||||
// if the mouse is stationary, then we want the
|
||||
// keyboard to be able to override it)
|
||||
li.addEventListener('mousemove', () => {
|
||||
// avoid re-rendering if the completion is already selected
|
||||
if(element.selected_completion !== i) {
|
||||
highlightCompletion(element, i);
|
||||
}
|
||||
});
|
||||
// on click, set the completion
|
||||
// (mousedown is used instead of click because click is
|
||||
// fired after blur, which causes the completion block to
|
||||
// be removed before the click event is handled)
|
||||
li.addEventListener('mousedown', (event) => {
|
||||
setCompletion(element, key);
|
||||
event.preventDefault();
|
||||
});
|
||||
li.addEventListener('touchstart', (event) => {
|
||||
setCompletion(element, key);
|
||||
event.preventDefault();
|
||||
});
|
||||
completions_el.appendChild(li);
|
||||
});
|
||||
|
||||
// insert the completion block after the element
|
||||
if(element.parentNode) {
|
||||
element.parentNode.insertBefore(completions_el, element.nextSibling);
|
||||
let br = element.getBoundingClientRect();
|
||||
completions_el.style.minWidth = br.width + 'px';
|
||||
completions_el.style.maxWidth = 'calc(100vw - 2rem - ' + br.left + 'px)';
|
||||
completions_el.style.left = window.scrollX + br.left + 'px';
|
||||
completions_el.style.top = window.scrollY + (br.top + br.height) + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* hide the completions block
|
||||
*/
|
||||
function hideCompletions() {
|
||||
document.querySelectorAll('.autocomplete_completions').forEach((element) => {
|
||||
element.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current word to the given completion
|
||||
*
|
||||
* @param {HTMLInputElement} element
|
||||
* @param {string} new_word
|
||||
*/
|
||||
function setCompletion(element, new_word) {
|
||||
let text = element.value;
|
||||
let pos = element.selectionStart;
|
||||
|
||||
// resolve alias before setting the word
|
||||
if(element.completions[new_word].newtag) {
|
||||
new_word = element.completions[new_word].newtag;
|
||||
}
|
||||
|
||||
// get the word before the cursor
|
||||
var start = text.lastIndexOf(' ', pos-1);
|
||||
if(start === -1) {
|
||||
start = 0;
|
||||
}
|
||||
else {
|
||||
start++; // skip the space
|
||||
}
|
||||
var end = text.indexOf(' ', pos);
|
||||
if(end === -1) {
|
||||
end = text.length;
|
||||
}
|
||||
|
||||
// replace the word with the completion
|
||||
if(text[start] === '-') {
|
||||
new_word = '-' + new_word;
|
||||
}
|
||||
new_word += ' ';
|
||||
element.value = text.substring(0, start) + new_word + text.substring(end);
|
||||
element.selectionStart = start + new_word.length;
|
||||
element.selectionEnd = start + new_word.length;
|
||||
|
||||
// reset metadata
|
||||
element.completions = {};
|
||||
element.selected_completion = -1;
|
||||
if(element.completer_timeout) {
|
||||
clearTimeout(element.completer_timeout);
|
||||
element.completer_timeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
var metatags = ['order:id', 'order:width', 'order:height', 'order:filesize', 'order:filename', 'order:favorites'];
|
||||
|
||||
@@ -31,33 +233,28 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
);
|
||||
|
||||
var isNegative = (request.term[0] === '-');
|
||||
$.ajax({
|
||||
url: base_href + '/api/internal/autocomplete',
|
||||
data: {'s': (isNegative ? request.term.substring(1) : request.term)},
|
||||
dataType : 'json',
|
||||
type : 'GET',
|
||||
success : function (data) {
|
||||
response(
|
||||
$.merge(ac_metatags,
|
||||
$.map(data, function (count, item) {
|
||||
item = (isNegative ? '-'+item : item);
|
||||
return {
|
||||
label : item + ' ('+count+')',
|
||||
value : item
|
||||
};
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
error : function (request, status, error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
minLength: 1
|
||||
})
|
||||
});
|
||||
element.addEventListener('keydown', (event) => {
|
||||
// up / down should select previous / next completion
|
||||
if(event.code === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
highlightCompletion(element, element.selected_completion-1);
|
||||
}
|
||||
else if(event.code === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
highlightCompletion(element, element.selected_completion+1);
|
||||
}
|
||||
// if enter or right are pressed while a completion is selected, add the selected completion
|
||||
else if((event.code === "Enter" || event.code == "ArrowRight") && element.selected_completion !== -1) {
|
||||
event.preventDefault();
|
||||
const key = Object.keys(element.completions)[element.selected_completion]
|
||||
setCompletion(element, key);
|
||||
}
|
||||
// if escape is pressed, hide the completion block
|
||||
else if(event.code === "Escape") {
|
||||
event.preventDefault();
|
||||
hideCompletions();
|
||||
}
|
||||
});
|
||||
|
||||
$('#tag_editor,[name="bulk_tags"]').tagit({
|
||||
singleFieldDelimiter: ' ',
|
||||
|
||||
@@ -8,10 +8,45 @@ class AutoCompleteTest extends ShimmiePHPUnitTestCase
|
||||
{
|
||||
public function testAuth()
|
||||
{
|
||||
$this->log_in_as_user();
|
||||
$image_id = $this->post_image("tests/pbx_screenshot.jpg", "link");
|
||||
send_event(new AddAliasEvent("prince_zelda", "link"));
|
||||
|
||||
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);
|
||||
|
||||
$page = $this->get_page('api/internal/autocomplete', ["s" => "li"]);
|
||||
$this->assertEquals(200, $page->code);
|
||||
$this->assertEquals(PageMode::DATA, $page->mode);
|
||||
$this->assertEquals('{"link":{"newtag":null,"count":1}}', $page->data);
|
||||
|
||||
$page = $this->get_page('api/internal/autocomplete', ["s" => "pr"]);
|
||||
$this->assertEquals(200, $page->code);
|
||||
$this->assertEquals(PageMode::DATA, $page->mode);
|
||||
$this->assertEquals('{"prince_zelda":{"newtag":"link","count":1}}', $page->data);
|
||||
}
|
||||
|
||||
public function testCategories(): void
|
||||
{
|
||||
$this->log_in_as_user();
|
||||
$image_id = $this->post_image("tests/pbx_screenshot.jpg", "artist:bob");
|
||||
|
||||
$page = $this->get_page('api/internal/autocomplete', ["s" => "bob"]);
|
||||
$this->assertEquals(200, $page->code);
|
||||
$this->assertEquals(PageMode::DATA, $page->mode);
|
||||
$this->assertEquals('{"artist:bob":{"newtag":null,"count":1}}', $page->data);
|
||||
|
||||
$page = $this->get_page('api/internal/autocomplete', ["s" => "art"]);
|
||||
$this->assertEquals(200, $page->code);
|
||||
$this->assertEquals(PageMode::DATA, $page->mode);
|
||||
$this->assertEquals('{"artist:bob":{"newtag":null,"count":1}}', $page->data);
|
||||
|
||||
$page = $this->get_page('api/internal/autocomplete', ["s" => "artist:"]);
|
||||
$this->assertEquals(200, $page->code);
|
||||
$this->assertEquals(PageMode::DATA, $page->mode);
|
||||
$this->assertEquals('{"artist:bob":{"newtag":null,"count":1}}', $page->data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class BanWordsInfo extends ExtensionInfo
|
||||
public string $license = self::LICENSE_GPLV2;
|
||||
public string $description = "For stopping spam and other comment abuse";
|
||||
public ?string $documentation =
|
||||
"Allows an administrator to ban certain words
|
||||
"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.
|
||||
@@ -27,4 +27,5 @@ to block comments with four (or more) links in.
|
||||
matched, eg banning \"sex\" would block the comment \"get free
|
||||
sex call this number\", but allow \"This is a photo of Bob
|
||||
from Essex\"";
|
||||
public ExtensionCategory $category = ExtensionCategory::MODERATION;
|
||||
}
|
||||
|
||||
@@ -49,12 +49,12 @@ xanax
|
||||
|
||||
public function onSourceSet(SourceSetEvent $event)
|
||||
{
|
||||
$this->test_text($event->source, new SCoreException("Source contains banned terms"));
|
||||
$this->test_text($event->source, new UserError("Source contains banned terms"));
|
||||
}
|
||||
|
||||
public function onTagSet(TagSetEvent $event)
|
||||
{
|
||||
$this->test_text(Tag::implode($event->tags), new SCoreException("Tags contain banned terms"));
|
||||
$this->test_text(Tag::implode($event->new_tags), new UserError("Tags contain banned terms"));
|
||||
}
|
||||
|
||||
public function onSetupBuilding(SetupBuildingEvent $event)
|
||||
|
||||
@@ -16,7 +16,7 @@ class BBCodeInfo extends ExtensionInfo
|
||||
public bool $core = true;
|
||||
public string $description = "Turns BBCode into HTML";
|
||||
public ?string $documentation =
|
||||
" Basic formatting tags:
|
||||
" Basic formatting tags:
|
||||
<ul>
|
||||
<li>[b]<b>bold</b>[/b]
|
||||
<li>[i]<i>italic</i>[/i]
|
||||
|
||||
@@ -26,13 +26,13 @@ class Biography extends Extension
|
||||
public function onPageRequest(PageRequestEvent $event)
|
||||
{
|
||||
global $page, $user, $user_config;
|
||||
if ($event->page_matches("biography")) {
|
||||
if ($user->check_auth_token()) {
|
||||
$user_config->set_string("biography", $_POST['biography']);
|
||||
$page->flash("Bio Updated");
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(referer_or(make_link()));
|
||||
}
|
||||
if ($event->page_matches("biography", method: "POST")) {
|
||||
$bio = $event->get_POST('biography');
|
||||
log_info("biography", "Set biography to $bio");
|
||||
$user_config->set_string("biography", $bio);
|
||||
$page->flash("Bio Updated");
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(referer_or(make_link()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ class BiographyTheme extends Themelet
|
||||
public function display_composer(Page $page, string $bio)
|
||||
{
|
||||
$html = SHM_SIMPLE_FORM(
|
||||
make_link("biography"),
|
||||
TEXTAREA(["style"=>"width: 100%", "rows"=>"6", "name"=>"biography"], $bio),
|
||||
"biography",
|
||||
TEXTAREA(["style" => "width: 100%", "rows" => "6", "name" => "biography"], $bio),
|
||||
SHM_SUBMIT("Save")
|
||||
);
|
||||
|
||||
|
||||
@@ -72,41 +72,36 @@ class Blocks extends Extension
|
||||
}
|
||||
}
|
||||
|
||||
if ($event->page_matches("blocks") && $user->can(Permissions::MANAGE_BLOCKS)) {
|
||||
if ($event->get_arg(0) == "add") {
|
||||
if ($user->check_auth_token()) {
|
||||
$database->execute("
|
||||
INSERT INTO blocks (pages, title, area, priority, content, userclass)
|
||||
VALUES (:pages, :title, :area, :priority, :content, :userclass)
|
||||
", ['pages'=>$_POST['pages'], 'title'=>$_POST['title'], 'area'=>$_POST['area'], 'priority'=>(int)$_POST['priority'], 'content'=>$_POST['content'], 'userclass'=>$_POST['userclass']]);
|
||||
log_info("blocks", "Added Block #".($database->get_last_insert_id('blocks_id_seq'))." (".$_POST['title'].")");
|
||||
$cache->delete("blocks");
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("blocks/list"));
|
||||
}
|
||||
}
|
||||
if ($event->get_arg(0) == "update") {
|
||||
if ($user->check_auth_token()) {
|
||||
if (!empty($_POST['delete'])) {
|
||||
$database->execute("
|
||||
DELETE FROM blocks
|
||||
WHERE id=:id
|
||||
", ['id'=>$_POST['id']]);
|
||||
log_info("blocks", "Deleted Block #".$_POST['id']);
|
||||
} else {
|
||||
$database->execute("
|
||||
UPDATE blocks SET pages=:pages, title=:title, area=:area, priority=:priority, content=:content, userclass=:userclass
|
||||
WHERE id=:id
|
||||
", ['pages'=>$_POST['pages'], 'title'=>$_POST['title'], 'area'=>$_POST['area'], 'priority'=>(int)$_POST['priority'], 'content'=>$_POST['content'], 'userclass'=>$_POST['userclass'], 'id'=>$_POST['id']]);
|
||||
log_info("blocks", "Updated Block #".$_POST['id']." (".$_POST['title'].")");
|
||||
}
|
||||
$cache->delete("blocks");
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("blocks/list"));
|
||||
}
|
||||
} elseif ($event->get_arg(0) == "list") {
|
||||
$this->theme->display_blocks($database->get_all("SELECT * FROM blocks ORDER BY area, priority"));
|
||||
if ($event->page_matches("blocks/add", method: "POST", permission: Permissions::MANAGE_BLOCKS)) {
|
||||
$database->execute("
|
||||
INSERT INTO blocks (pages, title, area, priority, content, userclass)
|
||||
VALUES (:pages, :title, :area, :priority, :content, :userclass)
|
||||
", ['pages' => $event->req_POST('pages'), 'title' => $event->req_POST('title'), 'area' => $event->req_POST('area'), 'priority' => (int)$event->req_POST('priority'), 'content' => $event->req_POST('content'), 'userclass' => $event->req_POST('userclass')]);
|
||||
log_info("blocks", "Added Block #".($database->get_last_insert_id('blocks_id_seq'))." (".$event->req_POST('title').")");
|
||||
$cache->delete("blocks");
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("blocks/list"));
|
||||
}
|
||||
if ($event->page_matches("blocks/update", method: "POST", permission: Permissions::MANAGE_BLOCKS)) {
|
||||
if (!is_null($event->get_POST('delete'))) {
|
||||
$database->execute("
|
||||
DELETE FROM blocks
|
||||
WHERE id=:id
|
||||
", ['id' => $event->req_POST('id')]);
|
||||
log_info("blocks", "Deleted Block #".$event->req_POST('id'));
|
||||
} else {
|
||||
$database->execute("
|
||||
UPDATE blocks SET pages=:pages, title=:title, area=:area, priority=:priority, content=:content, userclass=:userclass
|
||||
WHERE id=:id
|
||||
", ['pages' => $event->req_POST('pages'), 'title' => $event->req_POST('title'), 'area' => $event->req_POST('area'), 'priority' => (int)$event->req_POST('priority'), 'content' => $event->req_POST('content'), 'userclass' => $event->req_POST('userclass'), 'id' => $event->req_POST('id')]);
|
||||
log_info("blocks", "Updated Block #".$event->req_POST('id')." (".$event->req_POST('title').")");
|
||||
}
|
||||
$cache->delete("blocks");
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("blocks/list"));
|
||||
}
|
||||
if ($event->page_matches("blocks/list", permission: Permissions::MANAGE_BLOCKS)) {
|
||||
$this->theme->display_blocks($database->get_all("SELECT * FROM blocks ORDER BY area, priority"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,63 +72,32 @@ class Blotter extends Extension
|
||||
public function onPageRequest(PageRequestEvent $event)
|
||||
{
|
||||
global $page, $database, $user;
|
||||
if ($event->page_matches("blotter") && $event->count_args() > 0) {
|
||||
switch ($event->get_arg(0)) {
|
||||
case "editor":
|
||||
/**
|
||||
* Displays the blotter editor.
|
||||
*/
|
||||
if (!$user->can(Permissions::BLOTTER_ADMIN)) {
|
||||
$this->theme->display_permission_denied();
|
||||
} else {
|
||||
$entries = $database->get_all("SELECT * FROM blotter ORDER BY id DESC");
|
||||
$this->theme->display_editor($entries);
|
||||
}
|
||||
break;
|
||||
case "add":
|
||||
/**
|
||||
* Adds an entry
|
||||
*/
|
||||
if (!$user->can(Permissions::BLOTTER_ADMIN) || !$user->check_auth_token()) {
|
||||
$this->theme->display_permission_denied();
|
||||
} else {
|
||||
$entry_text = $_POST['entry_text'];
|
||||
if ($entry_text == "") {
|
||||
die("No entry message!");
|
||||
}
|
||||
$important = isset($_POST['important']);
|
||||
// Now insert into db:
|
||||
$database->execute(
|
||||
"INSERT INTO blotter (entry_date, entry_text, important) VALUES (now(), :text, :important)",
|
||||
["text"=>$entry_text, "important"=>$important]
|
||||
);
|
||||
log_info("blotter", "Added Message: $entry_text");
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("blotter/editor"));
|
||||
}
|
||||
break;
|
||||
case "remove":
|
||||
/**
|
||||
* Removes an entry
|
||||
*/
|
||||
if (!$user->can(Permissions::BLOTTER_ADMIN) || !$user->check_auth_token()) {
|
||||
$this->theme->display_permission_denied();
|
||||
} else {
|
||||
$id = int_escape($_POST['id']);
|
||||
$database->execute("DELETE FROM blotter WHERE id=:id", ["id"=>$id]);
|
||||
log_info("blotter", "Removed Entry #$id");
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("blotter/editor"));
|
||||
}
|
||||
break;
|
||||
case "list":
|
||||
/**
|
||||
* Displays all blotter entries
|
||||
*/
|
||||
$entries = $database->get_all("SELECT * FROM blotter ORDER BY id DESC");
|
||||
$this->theme->display_blotter_page($entries);
|
||||
break;
|
||||
}
|
||||
if ($event->page_matches("blotter/editor", method: "GET", permission: Permissions::BLOTTER_ADMIN)) {
|
||||
$entries = $database->get_all("SELECT * FROM blotter ORDER BY id DESC");
|
||||
$this->theme->display_editor($entries);
|
||||
}
|
||||
if ($event->page_matches("blotter/add", method: "POST", permission: Permissions::BLOTTER_ADMIN)) {
|
||||
$entry_text = $event->req_POST('entry_text');
|
||||
$important = !is_null($event->get_POST('important'));
|
||||
// Now insert into db:
|
||||
$database->execute(
|
||||
"INSERT INTO blotter (entry_date, entry_text, important) VALUES (now(), :text, :important)",
|
||||
["text" => $entry_text, "important" => $important]
|
||||
);
|
||||
log_info("blotter", "Added Message: $entry_text");
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("blotter/editor"));
|
||||
}
|
||||
if ($event->page_matches("blotter/remove", method: "POST", permission: Permissions::BLOTTER_ADMIN)) {
|
||||
$id = int_escape($event->req_POST('id'));
|
||||
$database->execute("DELETE FROM blotter WHERE id=:id", ["id" => $id]);
|
||||
log_info("blotter", "Removed Entry #$id");
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("blotter/editor"));
|
||||
}
|
||||
if ($event->page_matches("blotter/list", method: "GET")) {
|
||||
$entries = $database->get_all("SELECT * FROM blotter ORDER BY id DESC");
|
||||
$this->theme->display_blotter_page($entries);
|
||||
}
|
||||
/**
|
||||
* Finally, display the blotter on whatever page we're viewing.
|
||||
@@ -139,9 +108,10 @@ class Blotter extends Extension
|
||||
private function display_blotter()
|
||||
{
|
||||
global $database, $config;
|
||||
$limit = $config->get_int("blotter_recent", 5);
|
||||
$sql = 'SELECT * FROM blotter ORDER BY id DESC LIMIT '.intval($limit);
|
||||
$entries = $database->get_all($sql);
|
||||
$entries = $database->get_all(
|
||||
'SELECT * FROM blotter ORDER BY id DESC LIMIT :limit',
|
||||
["limit" => $config->get_int("blotter_recent", 5)]
|
||||
);
|
||||
$this->theme->display_blotter($entries);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,15 @@ class BlotterTest extends ShimmiePHPUnitTestCase
|
||||
{
|
||||
public function testDenial()
|
||||
{
|
||||
$this->get_page("blotter/editor");
|
||||
$this->assert_response(403);
|
||||
$this->get_page("blotter/add");
|
||||
$this->assert_response(403);
|
||||
$this->get_page("blotter/remove");
|
||||
$this->assert_response(403);
|
||||
$this->assertException(PermissionDenied::class, function () {
|
||||
$this->get_page("blotter/editor");
|
||||
});
|
||||
$this->assertException(PermissionDenied::class, function () {
|
||||
$this->post_page("blotter/add");
|
||||
});
|
||||
$this->assertException(PermissionDenied::class, function () {
|
||||
$this->post_page("blotter/remove");
|
||||
});
|
||||
}
|
||||
|
||||
public function testAddViewRemove()
|
||||
|
||||
@@ -81,8 +81,7 @@ class BlotterTheme extends Themelet
|
||||
<td>$entry_date</td>
|
||||
<td>$entry_text</td>
|
||||
<td>$important</td>
|
||||
<td><form name='remove$id' method='post' action='".make_link("blotter/remove")."'>
|
||||
".$user->get_auth_html()."
|
||||
<td>".make_form(make_link("blotter/remove"), name: "remove$id")."
|
||||
<input type='hidden' name='id' value='$id' />
|
||||
<input type='submit' style='width: 100%;' value='Remove' />
|
||||
</form>
|
||||
@@ -123,7 +122,7 @@ class BlotterTheme extends Themelet
|
||||
$i_close = "";
|
||||
//$id = $entries[$i]['id'];
|
||||
$messy_date = $entries[$i]['entry_date'];
|
||||
$clean_date = date("y/m/d", strtotime($messy_date));
|
||||
$clean_date = date("y/m/d", \Safe\strtotime($messy_date));
|
||||
$entry_text = $entries[$i]['entry_text'];
|
||||
if ($entries[$i]['important'] == 'Y') {
|
||||
$i_open = "<span style='color: #$i_color;'>";
|
||||
@@ -141,19 +140,18 @@ class BlotterTheme extends Themelet
|
||||
$i_color = $config->get_string("blotter_color", "#FF0000");
|
||||
$position = $config->get_string("blotter_position", "subheading");
|
||||
$entries_list = "";
|
||||
$num_entries = count($entries);
|
||||
for ($i = 0 ; $i < $num_entries ; $i++) {
|
||||
foreach ($entries as $entry) {
|
||||
/**
|
||||
* Blotter entries
|
||||
*/
|
||||
// Reset variables:
|
||||
$i_open = "";
|
||||
$i_close = "";
|
||||
//$id = $entries[$i]['id'];
|
||||
$messy_date = $entries[$i]['entry_date'];
|
||||
$clean_date = date("m/d/y", strtotime($messy_date));
|
||||
$entry_text = $entries[$i]['entry_text'];
|
||||
if ($entries[$i]['important'] == 'Y') {
|
||||
//$id = $entry['id'];
|
||||
$messy_date = $entry['entry_date'];
|
||||
$clean_date = date("m/d/y", \Safe\strtotime($messy_date));
|
||||
$entry_text = $entry['entry_text'];
|
||||
if ($entry['important'] == 'Y') {
|
||||
$i_open = "<span style='color: #$i_color'>";
|
||||
$i_close="</span>";
|
||||
}
|
||||
@@ -172,7 +170,7 @@ class BlotterTheme extends Themelet
|
||||
$out_text = "No blotter entries yet.";
|
||||
$in_text = "Empty.";
|
||||
} else {
|
||||
$clean_date = date("m/d/y", strtotime($entries[0]['entry_date']));
|
||||
$clean_date = date("m/d/y", \Safe\strtotime($entries[0]['entry_date']));
|
||||
$out_text = "Blotter updated: {$clean_date}";
|
||||
$in_text = "<ul>$entries_list</ul>";
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ class BrowserSearchInfo extends ExtensionInfo
|
||||
public string $url = "http://atravelinggeek.com/";
|
||||
public array $authors = ["ATravelingGeek"=>"atg@atravelinggeek.com"];
|
||||
public string $license = self::LICENSE_GPLV2;
|
||||
public ?string $version = "0.1c, October 26, 2007";
|
||||
public ExtensionCategory $category = ExtensionCategory::INTEGRATION;
|
||||
public string $description = "Allows the user to add a browser 'plugin' to search the site with real-time suggestions";
|
||||
public ?string $documentation =
|
||||
"Once installed, users with an opensearch compatible browser should see their search box light up with whatever \"click here to add a search engine\" notification they have
|
||||
"Once installed, users with an opensearch compatible browser should see their search box light up with whatever \"click here to add a search engine\" notification they have
|
||||
<br>
|
||||
<br>Some code (and lots of help) by Artanis (<a href='mailto:artanis.00@gmail.com'>Erik Youngren</a>) from the 'tagger' extension - Used with permission";
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class BrowserSearch extends Extension
|
||||
$search_title = $config->get_string(SetupConfig::TITLE);
|
||||
$search_form_url = make_link('post/list/{searchTerms}');
|
||||
$suggenton_url = make_link('browser_search/')."{searchTerms}";
|
||||
$icon_b64 = base64_encode(file_get_contents("ext/static_files/static/favicon.ico"));
|
||||
$icon_b64 = base64_encode(\Safe\file_get_contents("ext/static_files/static/favicon.ico"));
|
||||
|
||||
// Now for the XML
|
||||
$xml = "
|
||||
@@ -48,14 +48,14 @@ class BrowserSearch extends Extension
|
||||
$page->set_mode(PageMode::DATA);
|
||||
$page->set_mime(MimeType::XML);
|
||||
$page->set_data($xml);
|
||||
} elseif ($event->page_matches("browser_search")) {
|
||||
} elseif ($event->page_matches("browser_search/{tag_search}")) {
|
||||
$suggestions = $config->get_string("search_suggestions_results_order");
|
||||
if ($suggestions == "n") {
|
||||
return;
|
||||
}
|
||||
|
||||
// We have to build some json stuff
|
||||
$tag_search = $event->get_arg(0);
|
||||
$tag_search = $event->get_arg('tag_search');
|
||||
|
||||
// Now to get DB results
|
||||
if ($suggestions == "a") {
|
||||
@@ -71,7 +71,7 @@ class BrowserSearch extends Extension
|
||||
// And to do stuff with it. We want our output to look like:
|
||||
// ["shimmie",["shimmies","shimmy","shimmie","21 shimmies","hip shimmies","skea shimmies"],[],[]]
|
||||
$page->set_mode(PageMode::DATA);
|
||||
$page->set_data(json_encode([$tag_search, $tags, [], []]));
|
||||
$page->set_data(\Safe\json_encode([$tag_search, $tags, [], []]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,4 +35,5 @@ class BulkActionsInfo extends ExtensionInfo
|
||||
<br>Sets the source of all selected posts.
|
||||
</p>
|
||||
</p>";
|
||||
public ExtensionCategory $category = ExtensionCategory::MODERATION;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shimmie2;
|
||||
|
||||
class BulkActionException extends SCoreException
|
||||
{
|
||||
}
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\{InputInterface,InputArgument};
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class BulkActionBlockBuildingEvent extends Event
|
||||
{
|
||||
public array $actions = [];
|
||||
@@ -17,8 +18,8 @@ class BulkActionBlockBuildingEvent extends Event
|
||||
if (!empty($access_key)) {
|
||||
assert(strlen($access_key)==1);
|
||||
foreach ($this->actions as $existing) {
|
||||
if ($existing["access_key"]==$access_key) {
|
||||
throw new SCoreException("Access key $access_key is already in use");
|
||||
if ($existing["access_key"] == $access_key) {
|
||||
throw new UserError("Access key $access_key is already in use");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,13 +39,19 @@ class BulkActionEvent extends Event
|
||||
{
|
||||
public string $action;
|
||||
public \Generator $items;
|
||||
/** @var array<string, mixed> */
|
||||
public array $params;
|
||||
public bool $redirect = true;
|
||||
|
||||
public function __construct(String $action, \Generator $items)
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
public function __construct(string $action, \Generator $items, array $params)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->action = $action;
|
||||
$this->items = $items;
|
||||
$this->params = $params;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,20 +106,18 @@ class BulkActions extends Extension
|
||||
|
||||
public function onCommand(CommandEvent $event)
|
||||
{
|
||||
if ($event->cmd == "help") {
|
||||
print "\tbulk-action <action> <query>\n";
|
||||
print "\t\tperform an action on all query results\n\n";
|
||||
}
|
||||
if ($event->cmd == "bulk-action") {
|
||||
if (count($event->args) < 2) {
|
||||
return;
|
||||
}
|
||||
$action = $event->args[0];
|
||||
$query = $event->args[1];
|
||||
$items = $this->yield_search_results($query);
|
||||
log_info("bulk_actions", "Performing $action on {$event->args[1]}");
|
||||
send_event(new BulkActionEvent($event->args[0], $items));
|
||||
}
|
||||
$event->app->register('bulk-action')
|
||||
->addArgument('action', InputArgument::REQUIRED)
|
||||
->addArgument('query', InputArgument::REQUIRED)
|
||||
->setDescription('Perform a bulk action on a given query')
|
||||
->setCode(function (InputInterface $input, OutputInterface $output): int {
|
||||
$action = $input->getArgument('action');
|
||||
$query = $input->getArgument('query');
|
||||
$items = $this->yield_search_results($query);
|
||||
log_info("bulk_actions", "Performing $action on $query");
|
||||
send_event(new BulkActionEvent($action, $items, []));
|
||||
return Command::SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
public function onBulkAction(BulkActionEvent $event)
|
||||
@@ -127,13 +132,13 @@ class BulkActions extends Extension
|
||||
}
|
||||
break;
|
||||
case "bulk_tag":
|
||||
if (!isset($_POST['bulk_tags'])) {
|
||||
if (!isset($event->params['bulk_tags'])) {
|
||||
return;
|
||||
}
|
||||
if ($user->can(Permissions::BULK_EDIT_IMAGE_TAG)) {
|
||||
$tags = $_POST['bulk_tags'];
|
||||
$tags = $event->params['bulk_tags'];
|
||||
$replace = false;
|
||||
if (isset($_POST['bulk_tags_replace']) && $_POST['bulk_tags_replace'] == "true") {
|
||||
if (isset($event->params['bulk_tags_replace']) && $event->params['bulk_tags_replace'] == "true") {
|
||||
$replace = true;
|
||||
}
|
||||
|
||||
@@ -142,11 +147,11 @@ class BulkActions extends Extension
|
||||
}
|
||||
break;
|
||||
case "bulk_source":
|
||||
if (!isset($_POST['bulk_source'])) {
|
||||
if (!isset($event->params['bulk_source'])) {
|
||||
return;
|
||||
}
|
||||
if ($user->can(Permissions::BULK_EDIT_IMAGE_SOURCE)) {
|
||||
$source = $_POST['bulk_source'];
|
||||
$source = $event->params['bulk_source'];
|
||||
$i = $this->set_source($event->items, $source);
|
||||
$page->flash("Set source for $i items");
|
||||
}
|
||||
@@ -157,44 +162,28 @@ class BulkActions extends Extension
|
||||
public function onPageRequest(PageRequestEvent $event)
|
||||
{
|
||||
global $page, $user;
|
||||
if ($event->page_matches("bulk_action") && $user->can(Permissions::PERFORM_BULK_ACTIONS)) {
|
||||
if (!isset($_POST['bulk_action'])) {
|
||||
return;
|
||||
if ($event->page_matches("bulk_action", method: "POST", permission: Permissions::PERFORM_BULK_ACTIONS)) {
|
||||
$action = $event->req_POST('bulk_action');
|
||||
$items = null;
|
||||
if ($event->get_POST('bulk_selected_ids')) {
|
||||
$data = json_decode($event->req_POST('bulk_selected_ids'));
|
||||
if (!is_array($data) || empty($data)) {
|
||||
throw new InvalidInput("No ids specified in bulk_selected_ids");
|
||||
}
|
||||
$items = $this->yield_items($data);
|
||||
} elseif ($event->get_POST('bulk_query')) {
|
||||
$query = $event->req_POST('bulk_query');
|
||||
$items = $this->yield_search_results($query);
|
||||
} else {
|
||||
throw new InvalidInput("No ids selected and no query present, cannot perform bulk operation on entire collection");
|
||||
}
|
||||
|
||||
$action = $_POST['bulk_action'];
|
||||
shm_set_timeout(null);
|
||||
$bae = send_event(new BulkActionEvent($action, $items, $event->POST));
|
||||
|
||||
try {
|
||||
$items = null;
|
||||
if (isset($_POST['bulk_selected_ids']) && !empty($_POST['bulk_selected_ids'])) {
|
||||
$data = json_decode($_POST['bulk_selected_ids']);
|
||||
if (empty($data)) {
|
||||
throw new BulkActionException("No ids specified in bulk_selected_ids");
|
||||
}
|
||||
if (is_array($data)) {
|
||||
$items = $this->yield_items($data);
|
||||
}
|
||||
} elseif (isset($_POST['bulk_query']) && $_POST['bulk_query'] != "") {
|
||||
$query = $_POST['bulk_query'];
|
||||
if (!empty($query)) {
|
||||
$items = $this->yield_search_results($query);
|
||||
}
|
||||
} else {
|
||||
throw new BulkActionException("No ids selected and no query present, cannot perform bulk operation on entire collection");
|
||||
}
|
||||
|
||||
$bae = new BulkActionEvent($action, $items);
|
||||
|
||||
if (is_iterable($items)) {
|
||||
send_event($bae);
|
||||
}
|
||||
|
||||
if ($bae->redirect) {
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(referer_or(make_link()));
|
||||
}
|
||||
} catch (BulkActionException $e) {
|
||||
log_error(BulkActionsInfo::KEY, $e->getMessage(), $e->getMessage());
|
||||
if ($bae->redirect) {
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(referer_or(make_link()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class BulkActionsTheme extends Themelet
|
||||
}
|
||||
|
||||
foreach ($actions as $action) {
|
||||
$body .= "<div class='bulk_action'>" . make_form(make_link("bulk_action"), "POST", false, "", "return validate_selections(this,'" . html_escape($action["confirmation_message"]) . "');") .
|
||||
$body .= "<div class='bulk_action'>" . make_form(make_link("bulk_action"), onsubmit: "return validate_selections(this,'" . html_escape($action["confirmation_message"]) . "');") .
|
||||
"<input type='hidden' name='bulk_query' value='" . html_escape($query) . "'>" .
|
||||
"<input type='hidden' name='bulk_selected_ids' />" .
|
||||
"<input type='hidden' name='bulk_action' value='" . $action["action"] . "' />" .
|
||||
|
||||
@@ -15,7 +15,7 @@ class BulkAddInfo extends ExtensionInfo
|
||||
public string $license = self::LICENSE_GPLV2;
|
||||
public string $description = "Bulk add server-side images";
|
||||
public ?string $documentation =
|
||||
"Upload the images into a new directory via ftp or similar, go to
|
||||
"Upload the images into a new directory via ftp or similar, go to
|
||||
shimmie's admin page and put that directory in the bulk add box.
|
||||
If there are subdirectories, they get used as tags (eg if you
|
||||
upload into <code>/home/bob/uploads/holiday/2008/</code> and point
|
||||
@@ -23,4 +23,5 @@ class BulkAddInfo extends ExtensionInfo
|
||||
tagged \"holiday 2008\")
|
||||
<p><b>Note:</b> requires the \"admin\" extension to be enabled
|
||||
";
|
||||
public ExtensionCategory $category = ExtensionCategory::FILE_HANDLING;
|
||||
}
|
||||
|
||||
@@ -25,30 +25,31 @@ class BulkAdd extends Extension
|
||||
public function onPageRequest(PageRequestEvent $event)
|
||||
{
|
||||
global $page, $user;
|
||||
if ($event->page_matches("bulk_add")) {
|
||||
if ($user->can(Permissions::BULK_ADD) && $user->check_auth_token() && isset($_POST['dir'])) {
|
||||
shm_set_timeout(null);
|
||||
$bae = send_event(new BulkAddEvent($_POST['dir']));
|
||||
foreach ($bae->results as $result) {
|
||||
$this->theme->add_status("Adding files", $result);
|
||||
}
|
||||
$this->theme->display_upload_results($page);
|
||||
}
|
||||
if ($event->page_matches("bulk_add", method: "POST", permission: Permissions::BULK_ADD)) {
|
||||
$dir = $event->req_POST('dir');
|
||||
shm_set_timeout(null);
|
||||
$bae = send_event(new BulkAddEvent($dir));
|
||||
$this->theme->display_upload_results($page, $bae->results);
|
||||
}
|
||||
}
|
||||
|
||||
public function onCommand(CommandEvent $event)
|
||||
{
|
||||
if ($event->cmd == "help") {
|
||||
print "\tbulk-add [directory]\n";
|
||||
print "\t\tImport this directory\n\n";
|
||||
}
|
||||
if ($event->cmd == "bulk-add") {
|
||||
if (count($event->args) == 1) {
|
||||
$bae = send_event(new BulkAddEvent($event->args[0]));
|
||||
print(implode("\n", $bae->results));
|
||||
}
|
||||
}
|
||||
$event->app->register('bulk-add')
|
||||
->addArgument('directory', InputArgument::REQUIRED)
|
||||
->setDescription('Import a directory of images')
|
||||
->setCode(function (InputInterface $input, OutputInterface $output): int {
|
||||
$dir = $input->getArgument('directory');
|
||||
$bae = send_event(new BulkAddEvent($dir));
|
||||
foreach ($bae->results as $r) {
|
||||
if (is_a($r, UploadError::class)) {
|
||||
$output->writeln($r->name." failed: ".$r->error);
|
||||
} else {
|
||||
$output->writeln($r->name." ok");
|
||||
}
|
||||
}
|
||||
return Command::SUCCESS;
|
||||
});
|
||||
}
|
||||
|
||||
public function onAdminBuilding(AdminBuildingEvent $event)
|
||||
|
||||
@@ -15,14 +15,17 @@ class BulkAddCSVInfo extends ExtensionInfo
|
||||
public string $license = self::LICENSE_GPLV2;
|
||||
public string $description = "Bulk add server-side posts with metadata from CSV file";
|
||||
public ?string $documentation =
|
||||
"Modification of \"Bulk Add\" by Shish.<br><br>
|
||||
Adds posts from a CSV with the five following values: <br>
|
||||
\"/path/to/image.jpg\",\"spaced tags\",\"source\",\"rating s/q/e\",\"/path/thumbnail.jpg\" <br>
|
||||
<b>e.g.</b> \"/tmp/cat.png\",\"shish oekaki\",\"shimmie.shishnet.org\",\"s\",\"tmp/custom.jpg\" <br><br>
|
||||
Any value but the first may be omitted, but there must be five values per line.<br>
|
||||
<b>e.g.</b> \"/why/not/try/bulk_add.jpg\",\"\",\"\",\"\",\"\"<br><br>
|
||||
Post thumbnails will be displayed at the AR of the full post. Thumbnails that are
|
||||
normally static (e.g. SWF) will be displayed at the board's max thumbnail size<br><br>
|
||||
Useful for importing tagged posts without having to do database manipulation.<br>
|
||||
"Adds posts from a CSV with the five following values:
|
||||
<pre>\"/path/to/image.jpg\",\"spaced tags\",\"source\",\"rating s/q/e\",\"/path/thumbnail.jpg\"</pre>
|
||||
|
||||
<b>e.g.</b>
|
||||
<pre>\"/tmp/cat.png\",\"shish oekaki\",\"http://shimmie.shishnet.org\",\"s\",\"tmp/custom.jpg\"</pre>
|
||||
|
||||
Any value but the first may be omitted, but there must be five values per line.
|
||||
<b>e.g.</b> <pre>\"/why/not/try/bulk_add.jpg\",\"\",\"\",\"\",\"\"</pre>
|
||||
|
||||
Useful for importing tagged posts without having to do database manipulation.
|
||||
|
||||
<p><b>Note:</b> requires \"Admin Controls\" and optionally \"Post Ratings\" to be enabled<br><br>";
|
||||
public ExtensionCategory $category = ExtensionCategory::FILE_HANDLING;
|
||||
}
|
||||
|
||||
@@ -12,12 +12,11 @@ class BulkAddCSV extends Extension
|
||||
public function onPageRequest(PageRequestEvent $event)
|
||||
{
|
||||
global $page, $user;
|
||||
if ($event->page_matches("bulk_add_csv")) {
|
||||
if ($user->can(Permissions::BULK_ADD) && $user->check_auth_token() && isset($_POST['csv'])) {
|
||||
shm_set_timeout(null);
|
||||
$this->add_csv($_POST['csv']);
|
||||
$this->theme->display_upload_results($page);
|
||||
}
|
||||
if ($event->page_matches("bulk_add_csv", method: "POST", permission: Permissions::BULK_ADD)) {
|
||||
$csv = $event->req_POST('csv');
|
||||
shm_set_timeout(null);
|
||||
$this->add_csv($csv);
|
||||
$this->theme->display_upload_results($page);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,12 +49,20 @@ class BulkAddCSV extends Extension
|
||||
*/
|
||||
private function add_image(string $tmpname, string $filename, string $tags, string $source, string $rating, string $thumbfile)
|
||||
{
|
||||
$event = add_image($tmpname, $filename, $tags, $source);
|
||||
if ($event->image_id == -1) {
|
||||
throw new UploadException("File type not recognised");
|
||||
} else {
|
||||
if (class_exists("Shimmie2\RatingSetEvent") && in_array($rating, ["s", "q", "e"])) {
|
||||
send_event(new RatingSetEvent(Image::by_id($event->image_id), $rating));
|
||||
global $database;
|
||||
$database->with_savepoint(function () use ($tmpname, $filename, $tags, $source, $rating, $thumbfile) {
|
||||
$event = send_event(new DataUploadEvent($tmpname, basename($filename), 0, [
|
||||
'tags' => Tag::implode($tags),
|
||||
'source' => $source,
|
||||
'rating' => $rating,
|
||||
]));
|
||||
|
||||
if (count($event->images) == 0) {
|
||||
throw new UploadException("File type not recognised");
|
||||
} else {
|
||||
if (file_exists($thumbfile)) {
|
||||
copy($thumbfile, warehouse_path(Image::THUMBNAIL_DIR, $event->hash));
|
||||
}
|
||||
}
|
||||
if (file_exists($thumbfile)) {
|
||||
copy($thumbfile, warehouse_path(Image::THUMBNAIL_DIR, $event->hash));
|
||||
@@ -76,7 +83,7 @@ class BulkAddCSV extends Extension
|
||||
|
||||
$linenum = 1;
|
||||
$list = "";
|
||||
$csvhandle = fopen($csvfile, "r");
|
||||
$csvhandle = \Safe\fopen($csvfile, "r");
|
||||
|
||||
while (($csvdata = fgetcsv($csvhandle, 0, ",")) !== false) {
|
||||
if (count($csvdata) != 5) {
|
||||
|
||||
@@ -12,6 +12,7 @@ class BulkDownloadInfo extends ExtensionInfo
|
||||
public string $name = "Bulk Download";
|
||||
public array $authors = ["Matthew Barbour"=>"matthew@darkholme.net"];
|
||||
public string $license = self::LICENSE_WTFPL;
|
||||
public ExtensionCategory $category = ExtensionCategory::FILE_HANDLING;
|
||||
public string $description = "Allows bulk downloading images.";
|
||||
public array $dependencies = [BulkActionsInfo::KEY];
|
||||
}
|
||||
|
||||
@@ -8,10 +8,6 @@ class BulkDownloadConfig
|
||||
{
|
||||
public const SIZE_LIMIT = "bulk_download_size_limit";
|
||||
}
|
||||
class BulkDownloadException extends BulkActionException
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
class BulkDownload extends Extension
|
||||
{
|
||||
@@ -57,8 +53,8 @@ class BulkDownload extends Extension
|
||||
foreach ($event->items as $image) {
|
||||
$img_loc = warehouse_path(Image::IMAGE_DIR, $image->hash, false);
|
||||
$size_total += filesize($img_loc);
|
||||
if ($size_total>$max_size) {
|
||||
throw new BulkDownloadException("Bulk download limited to ".human_filesize($max_size));
|
||||
if ($size_total > $max_size) {
|
||||
throw new UserError("Bulk download limited to ".human_filesize($max_size));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class BulkImportExportInfo extends ExtensionInfo
|
||||
public string $name = "Bulk Import/Export";
|
||||
public array $authors = ["Matthew Barbour"=>"matthew@darkholme.net"];
|
||||
public string $license = self::LICENSE_WTFPL;
|
||||
public ExtensionCategory $category = ExtensionCategory::FILE_HANDLING;
|
||||
public string $description = "Allows bulk exporting/importing of images and associated data.";
|
||||
public array $dependencies = [BulkActionsInfo::KEY];
|
||||
}
|
||||
|
||||
@@ -46,17 +46,28 @@ class BulkImportExport extends DataHandlerExtension
|
||||
|
||||
$tmpfile = tempnam(sys_get_temp_dir(), "shimmie_bulk_import");
|
||||
$stream = $zip->getStream($item->hash);
|
||||
if ($zip === false) {
|
||||
throw new SCoreException("Could not import " . $item->hash . ": File not in zip");
|
||||
if ($stream === false) {
|
||||
throw new UserError("Could not import " . $item->hash . ": File not in zip");
|
||||
}
|
||||
|
||||
file_put_contents($tmpfile, $stream);
|
||||
|
||||
$id = add_image($tmpfile, $item->filename, Tag::implode($item->tags))->image_id;
|
||||
$database->with_savepoint(function () use ($item, $tmpfile, $event) {
|
||||
$images = send_event(new DataUploadEvent($tmpfile, basename($item->filename), 0, [
|
||||
'tags' => $item->new_tags,
|
||||
]))->images;
|
||||
|
||||
if ($id==-1) {
|
||||
throw new SCoreException("Unable to import file $item->hash");
|
||||
}
|
||||
if (count($images) == 0) {
|
||||
throw new UserError("Unable to import file $item->hash");
|
||||
}
|
||||
foreach ($images as $image) {
|
||||
$event->images[] = $image;
|
||||
if ($item->source != null) {
|
||||
$image->set_source($item->source);
|
||||
}
|
||||
send_event(new BulkImportEvent($image, $item));
|
||||
}
|
||||
});
|
||||
|
||||
$image = Image::by_id($id);
|
||||
|
||||
@@ -94,7 +105,7 @@ class BulkImportExport extends DataHandlerExtension
|
||||
"Imported $total items, skipped $skipped, $failed failed"
|
||||
);
|
||||
} else {
|
||||
throw new SCoreException("Could not open zip archive");
|
||||
throw new UserError("Could not open zip archive");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,7 +149,7 @@ class BulkImportExport extends DataHandlerExtension
|
||||
$zip->addFile($img_loc, $image->hash);
|
||||
}
|
||||
|
||||
$json_data = json_encode($json_data, JSON_PRETTY_PRINT);
|
||||
$json_data = \Safe\json_encode($json_data, JSON_PRETTY_PRINT);
|
||||
$zip->addFromString(self::EXPORT_INFO_FILE_NAME, $json_data);
|
||||
|
||||
$zip->close();
|
||||
@@ -171,7 +182,7 @@ class BulkImportExport extends DataHandlerExtension
|
||||
$info = $zip->getStream(self::EXPORT_INFO_FILE_NAME);
|
||||
if ($info !== false) {
|
||||
try {
|
||||
$json_string = stream_get_contents($info);
|
||||
$json_string = \Safe\stream_get_contents($info);
|
||||
$json_data = json_decode($json_string);
|
||||
return $json_data;
|
||||
} finally {
|
||||
|
||||
@@ -14,4 +14,5 @@ class BulkParentChildInfo extends ExtensionInfo
|
||||
public string $license = self::LICENSE_WTFPL;
|
||||
public string $description = "Allows bulk setting of parent-child relationships, in order of manual selection";
|
||||
public array $dependencies = [BulkActionsInfo::KEY];
|
||||
public ExtensionCategory $category = ExtensionCategory::METADATA;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,6 @@ namespace Shimmie2;
|
||||
class BulkParentChildConfig
|
||||
{
|
||||
}
|
||||
class BulkParentChildException extends BulkActionException
|
||||
{
|
||||
}
|
||||
|
||||
class BulkParentChild extends Extension
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ class CommentListInfo extends ExtensionInfo
|
||||
public string $url = self::SHIMMIE_URL;
|
||||
public array $authors = self::SHISH_AUTHOR;
|
||||
public string $license = self::LICENSE_GPLV2;
|
||||
public ExtensionCategory $category = ExtensionCategory::FEATURE;
|
||||
public string $description = "Allow users to make comments on images";
|
||||
public ?string $documentation = "Formatting is done with the standard formatting API (normally BBCode)";
|
||||
public bool $core = true;
|
||||
|
||||
@@ -42,7 +42,7 @@ class CommentDeletionEvent extends Event
|
||||
}
|
||||
}
|
||||
|
||||
class CommentPostingException extends SCoreException
|
||||
class CommentPostingException extends InvalidInput
|
||||
{
|
||||
}
|
||||
|
||||
@@ -195,76 +195,28 @@ class CommentList extends Extension
|
||||
|
||||
public function onPageRequest(PageRequestEvent $event)
|
||||
{
|
||||
if ($event->page_matches("comment")) {
|
||||
switch ($event->get_arg(0)) {
|
||||
case "add":
|
||||
$this->onPageRequest_add();
|
||||
break;
|
||||
case "delete":
|
||||
$this->onPageRequest_delete($event);
|
||||
break;
|
||||
case "bulk_delete":
|
||||
$this->onPageRequest_bulk_delete();
|
||||
break;
|
||||
case "list":
|
||||
$this->onPageRequest_list($event);
|
||||
break;
|
||||
case "beta-search":
|
||||
$this->onPageRequest_beta_search($event);
|
||||
break;
|
||||
}
|
||||
global $cache, $config, $database, $user, $page;
|
||||
if ($event->page_matches("comment/add", method: "POST", permission: Permissions::CREATE_COMMENT)) {
|
||||
$i_iid = int_escape($event->req_POST('image_id'));
|
||||
send_event(new CommentPostingEvent($i_iid, $user, $event->req_POST('comment')));
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("post/view/$i_iid", null, "comment_on_$i_iid"));
|
||||
}
|
||||
}
|
||||
|
||||
public function onRobotsBuilding(RobotsBuildingEvent $event)
|
||||
{
|
||||
// comment lists change all the time, crawlers should
|
||||
// index individual image's comments
|
||||
$event->add_disallow("comment");
|
||||
}
|
||||
|
||||
private function onPageRequest_add()
|
||||
{
|
||||
global $user, $page;
|
||||
if (isset($_POST['image_id']) && isset($_POST['comment'])) {
|
||||
try {
|
||||
$i_iid = int_escape($_POST['image_id']);
|
||||
send_event(new CommentPostingEvent(int_escape($_POST['image_id']), $user, $_POST['comment']));
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(make_link("post/view/$i_iid", null, "comment_on_$i_iid"));
|
||||
} catch (CommentPostingException $ex) {
|
||||
$this->theme->display_error(403, "Comment Blocked", $ex->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function onPageRequest_delete(PageRequestEvent $event)
|
||||
{
|
||||
global $user, $page;
|
||||
if ($user->can(Permissions::DELETE_COMMENT)) {
|
||||
if ($event->page_matches("comment/delete/{comment_id}/{image_id}", permission: Permissions::DELETE_COMMENT)) {
|
||||
// FIXME: post, not args
|
||||
if ($event->count_args() === 3) {
|
||||
send_event(new CommentDeletionEvent(int_escape($event->get_arg(1))));
|
||||
$page->flash("Deleted comment");
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(referer_or(make_link("post/view/" . $event->get_arg(2))));
|
||||
}
|
||||
} else {
|
||||
$this->theme->display_permission_denied();
|
||||
send_event(new CommentDeletionEvent($event->get_iarg('comment_id')));
|
||||
$page->flash("Deleted comment");
|
||||
$page->set_mode(PageMode::REDIRECT);
|
||||
$page->set_redirect(referer_or(make_link("post/view/" . $event->get_iarg('image_id'))));
|
||||
}
|
||||
}
|
||||
|
||||