Merge tag 'v2.11.5'

This commit is contained in:
2025-10-04 21:53:37 -05:00
346 changed files with 9718 additions and 7546 deletions

View 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
View 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
View 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
View 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
View 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)

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -1,12 +1,7 @@
backup
data
images
thumbs
*.phar
*.sqlite
*.cache
.devcontainer
trace.json
.docker/entrypoint.d/config.json
.sl
#Composer
composer.phar

View File

@@ -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>

View File

@@ -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"]

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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)));
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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)))";
}
}

View File

@@ -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];
}
}

View File

@@ -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;
}
}

View File

@@ -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
{
}

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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)
);
}

View File

@@ -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";

View File

@@ -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);

View File

@@ -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>

View File

@@ -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

View File

@@ -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
View 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);
}
}

View 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));
}
}

View 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);
}
}

View File

@@ -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
}
}

View File

@@ -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()

View File

@@ -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
);
}
}

View File

@@ -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

View File

@@ -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");
}
}
}

View File

@@ -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";

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -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"?
}
}

View File

@@ -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;

View File

@@ -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");

View File

@@ -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.";
}

View File

@@ -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
View 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();
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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"));

View File

@@ -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'].'" />

View File

@@ -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";
}

View File

@@ -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()));
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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: ' ',

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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]

View File

@@ -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()));
}
}
}

View File

@@ -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")
);

View File

@@ -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"));
}
}
}

View File

@@ -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);
}
}

View File

@@ -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()

View File

@@ -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>";
}

View File

@@ -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";
}

View File

@@ -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, [], []]));
}
}

View File

@@ -35,4 +35,5 @@ class BulkActionsInfo extends ExtensionInfo
<br>Sets the source of all selected posts.
</p>
</p>";
public ExtensionCategory $category = ExtensionCategory::MODERATION;
}

View File

@@ -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()));
}
}
}

View File

@@ -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"] . "' />" .

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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];
}

View File

@@ -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));
}

View File

@@ -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];
}

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -7,9 +7,6 @@ namespace Shimmie2;
class BulkParentChildConfig
{
}
class BulkParentChildException extends BulkActionException
{
}
class BulkParentChild extends Extension
{

View File

@@ -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;

View File

@@ -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'))));
}
}