Revert "Merge tag 'v2.10.6'"

This reverts commit 122ea4ab9e, reversing
changes made to c54a11e250.
This commit is contained in:
2024-02-16 23:06:09 -06:00
parent 122ea4ab9e
commit 6c08ee9675
521 changed files with 12363 additions and 14503 deletions

View File

@ -1,56 +0,0 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile
{
"name": "Shimmie",
"build": {
"context": "..",
"dockerfile": "../Dockerfile",
"target": "devcontainer"
},
"workspaceMount": "source=${localWorkspaceFolder},target=/app,type=bind",
"workspaceFolder": "/app",
"forwardPorts": [8000],
"portsAttributes": {
"8000": {
"label": "Application",
"onAutoForward": "openPreview"
}
},
"otherPortsAttributes": {
"onAutoForward": "silent"
},
"updateContentCommand": "composer install",
"postCreateCommand": "./.docker/entrypoint.sh unitd --no-daemon --control unix:/var/run/control.unit.sock",
"containerEnv": {
"UID": "2000",
"GID": "2000",
"UPLOAD_MAX_FILESIZE": "50M",
"INSTALL_DSN": "sqlite:data/shimmie.dev.sqlite"
},
"customizations": {
"vscode": {
"extensions": [
"recca0120.vscode-phpunit",
"ryanluker.vscode-coverage-gutters",
"xdebug.php-debug",
"DEVSENSE.phptools-vscode",
"ms-azuretools.vscode-docker"
],
"settings": {
"phpunit.args": [
"--configuration", "${workspaceFolder}/tests/phpunit.xml",
"--coverage-clover", "data/coverage.clover"
],
"coverage-gutters.coverageFileNames": [
"data/coverage.clover"
]
}
}
}
// Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "devcontainer"
}

View File

@ -1,81 +0,0 @@
{
"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": {
"memory_limit": "256M",
"upload_max_filesize": "$UPLOAD_MAX_FILESIZE",
"post_max_size": "$UPLOAD_MAX_FILESIZE"
}
},
"processes": {
"max": 8,
"spare": 2,
"idle_timeout": 60
}
}
},
"settings": {
"http": {
"max_body_size": 1048576000,
"static": {
"mime_types": {
"application/sourcemap": [".map"]
}
}
}
}
}

View File

@ -1,108 +0,0 @@
#!/bin/sh
set -e
# if user shimmie doesn't already exist, create it
if ! id -u shimmie >/dev/null 2>&1; then
groupadd -g $GID shimmie || true
useradd -ms /bin/bash -u $UID -g $GID shimmie || true
fi
mkdir -p /app/data
chown shimmie:shimmie /app/data
rm -rf /var/lib/unit/*
envsubst '$UPLOAD_MAX_FILESIZE' < /app/.docker/entrypoint.d/config.json.tmpl > /app/.docker/entrypoint.d/config.json
WAITLOOPS=5
SLEEPSEC=1
curl_put()
{
RET=$(/usr/bin/curl -s -w '%{http_code}' -X PUT --data-binary @$1 --unix-socket /var/run/control.unit.sock http://localhost/$2)
RET_BODY=$(echo $RET | /bin/sed '$ s/...$//')
RET_STATUS=$(echo $RET | /usr/bin/tail -c 4)
if [ "$RET_STATUS" -ne "200" ]; then
echo "$0: Error: HTTP response status code is '$RET_STATUS'"
echo "$RET_BODY"
return 1
else
echo "$0: OK: HTTP response status code is '$RET_STATUS'"
echo "$RET_BODY"
fi
return 0
}
if [ "$1" = "unitd" ] || [ "$1" = "unitd-debug" ]; then
if /usr/bin/find "/var/lib/unit/" -mindepth 1 -print -quit 2>/dev/null | /bin/grep -q .; then
echo "$0: /var/lib/unit/ is not empty, skipping initial configuration..."
else
echo "$0: Launching Unit daemon to perform initial configuration..."
/usr/sbin/$1 --control unix:/var/run/control.unit.sock
for i in $(/usr/bin/seq $WAITLOOPS); do
if [ ! -S /var/run/control.unit.sock ]; then
echo "$0: Waiting for control socket to be created..."
/bin/sleep $SLEEPSEC
else
break
fi
done
# even when the control socket exists, it does not mean unit has finished initialisation
# this curl call will get a reply once unit is fully launched
/usr/bin/curl -s -X GET --unix-socket /var/run/control.unit.sock http://localhost/
if /usr/bin/find "/app/.docker/entrypoint.d/" -mindepth 1 -print -quit 2>/dev/null | /bin/grep -q .; then
echo "$0: /app/.docker/entrypoint.d/ is not empty, applying initial configuration..."
echo "$0: Looking for certificate bundles in /app/.docker/entrypoint.d/..."
for f in $(/usr/bin/find /app/.docker/entrypoint.d/ -type f -name "*.pem"); do
echo "$0: Uploading certificates bundle: $f"
curl_put $f "certificates/$(basename $f .pem)"
done
echo "$0: Looking for JavaScript modules in /app/.docker/entrypoint.d/..."
for f in $(/usr/bin/find /app/.docker/entrypoint.d/ -type f -name "*.js"); do
echo "$0: Uploading JavaScript module: $f"
curl_put $f "js_modules/$(basename $f .js)"
done
echo "$0: Looking for configuration snippets in /app/.docker/entrypoint.d/..."
for f in $(/usr/bin/find /app/.docker/entrypoint.d/ -type f -name "*.json"); do
echo "$0: Applying configuration $f";
curl_put $f "config"
done
echo "$0: Looking for shell scripts in /app/.docker/entrypoint.d/..."
for f in $(/usr/bin/find /app/.docker/entrypoint.d/ -type f -name "*.sh"); do
echo "$0: Launching $f";
"$f"
done
# warn on filetypes we don't know what to do with
for f in $(/usr/bin/find /app/.docker/entrypoint.d/ -type f -not -name "*.sh" -not -name "*.json" -not -name "*.pem" -not -name "*.js"); do
echo "$0: Ignoring $f";
done
else
echo "$0: /app/.docker/entrypoint.d/ is empty, creating 'welcome' configuration..."
curl_put /usr/share/unit/welcome/welcome.json "config"
fi
echo "$0: Stopping Unit daemon after initial configuration..."
kill -TERM $(/bin/cat /var/run/unit.pid)
for i in $(/usr/bin/seq $WAITLOOPS); do
if [ -S /var/run/control.unit.sock ]; then
echo "$0: Waiting for control socket to be removed..."
/bin/sleep $SLEEPSEC
else
break
fi
done
if [ -S /var/run/control.unit.sock ]; then
kill -KILL $(/bin/cat /var/run/unit.pid)
rm -f /var/run/control.unit.sock
fi
echo
echo "$0: Unit initial configuration complete; ready for start up..."
echo
fi
fi
exec "$@"

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@v4
- uses: actions/checkout@v2
- name: Set Git config
run: |
git config --local user.email "actions@github.com"

View File

@ -3,9 +3,7 @@ name: Publish
on:
workflow_run:
workflows: Tests
branches:
- main
- master
branches: main
types: completed
workflow_dispatch:
push:
@ -18,18 +16,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' }}
steps:
- name: Checkout triggering commit
if: ${{ github.event_name == 'workflow_run' }}
uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
- name: Checkout main commit
if: ${{ github.event_name != 'workflow_run' }}
uses: actions/checkout@v4
- name: Set build vars
run: |
echo "BUILD_TIME=$(date +'%Y-%m-%dT%H:%M:%S')" >> $GITHUB_ENV
echo "BUILD_HASH=$GITHUB_SHA" >> $GITHUB_ENV
- uses: actions/checkout@master
- name: Publish to Registry
uses: elgohr/Publish-Docker-Github-Action@main
with:
@ -38,5 +25,4 @@ jobs:
password: ${{ secrets.DOCKER_PASSWORD }}
cache: ${{ github.event_name != 'schedule' }}
buildoptions: "--build-arg RUN_TESTS=false"
buildargs: BUILD_TIME,BUILD_HASH
tag_semver: true

View File

@ -11,31 +11,52 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@master
- name: Get version from tag
id: get_version
run: echo VERSION=${GITHUB_REF/refs\/tags\/v/} >> $GITHUB_OUTPUT
run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\/v/}
- name: Check and set sys_config
run: |
grep ${{ steps.get_version.outputs.VERSION }} core/sys_config.php
echo "_d('BUILD_TIME', '$(date +'%Y-%m-%dT%H:%M:%S')');" >> core/sys_config.php
echo "_d('BUILD_HASH', '$GITHUB_SHA');" >> core/sys_config.php
- name: Test version in sys_config
run: grep ${{ steps.get_version.outputs.VERSION }} core/sys_config.php
- name: Build
run: |
composer install --no-dev --no-progress
composer install --no-dev
cd ..
tar cvzf shimmie2-${{ steps.get_version.outputs.VERSION }}.tgz shimmie2
zip -r shimmie2-${{ steps.get_version.outputs.VERSION }}.zip shimmie2
- name: Create Release
uses: softprops/action-gh-release@v1
id: create_release
uses: actions/create-release@latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
token: ${{ secrets.GITHUB_TOKEN }}
name: Shimmie ${{ steps.get_version.outputs.VERSION }}
tag_name: ${{ github.ref }}
release_name: Shimmie ${{ steps.get_version.outputs.VERSION }}
body: Automated release from tags
files: |
../shimmie2-${{ steps.get_version.outputs.VERSION }}.zip
../shimmie2-${{ steps.get_version.outputs.VERSION }}.tgz
draft: false
prerelease: false
- name: Upload Zip
id: upload-release-asset-zip
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ../shimmie2-${{ steps.get_version.outputs.VERSION }}.zip
asset_name: shimmie2-${{ steps.get_version.outputs.VERSION }}.zip
asset_content_type: application/zip
- name: Upload Tar
id: upload-release-asset-tar
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ../shimmie2-${{ steps.get_version.outputs.VERSION }}.tgz
asset_name: shimmie2-${{ steps.get_version.outputs.VERSION }}.tgz
asset_content_type: application/gzip

View File

@ -2,9 +2,6 @@ name: Tests
on:
push:
branches:
- main
- master
pull_request:
schedule:
- cron: '0 2 * * 0' # Weekly on Sundays at 02:00
@ -15,9 +12,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Set Up Cache
uses: actions/cache@v4
uses: actions/cache@v3
with:
path: |
vendor
@ -29,7 +26,7 @@ jobs:
- name: Set up PHP
uses: shivammathur/setup-php@master
with:
php-version: 8.3
php-version: 8.1
- name: Format
run: ./vendor/bin/php-cs-fixer fix && git diff --exit-code
@ -38,11 +35,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
fetch-depth: 2
- name: Set Up Cache
uses: actions/cache@v4
uses: actions/cache@v3
with:
path: |
vendor
@ -55,97 +52,79 @@ jobs:
configuration: tests/phpstan.neon
memory_limit: 1G
upgrade:
name: Upgrade from 2.9 ${{ matrix.database }}
strategy:
matrix:
php: ['8.3']
database: ['pgsql', 'mysql', 'sqlite']
runs-on: ubuntu-latest
steps:
- name: Checkout current
uses: actions/checkout@v4
with:
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@master
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 }}
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3']
php: ['8.1', '8.2']
database: ['pgsql', 'mysql', 'sqlite']
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
fetch-depth: 2
- name: Set Up Cache
uses: actions/cache@v4
uses: actions/cache@v3
with:
path: |
vendor
key: vendor-${{ matrix.php }}-${{ hashFiles('composer.lock') }}
- name: Set up PHP
uses: shivammathur/setup-php@master
with:
php-version: ${{ matrix.php }}
coverage: pcov
extensions: mbstring
- name: Set up database
run: ./tests/setup-db.sh "${{ matrix.database }}"
run: |
mkdir -p data/config
if [[ "${{ matrix.database }}" == "pgsql" ]]; then
sudo systemctl start postgresql ;
psql --version ;
sudo -u postgres psql -c "SELECT set_config('log_statement', 'all', false);" -U postgres ;
sudo -u postgres psql -c "CREATE USER shimmie WITH PASSWORD 'shimmie';" -U postgres ;
sudo -u postgres psql -c "CREATE DATABASE shimmie WITH OWNER shimmie;" -U postgres ;
fi
if [[ "${{ matrix.database }}" == "mysql" ]]; then
sudo systemctl start mysql ;
mysql --version ;
mysql -e "SET GLOBAL general_log = 'ON';" -uroot -proot ;
mysql -e "CREATE DATABASE shimmie;" -uroot -proot ;
fi
if [[ "${{ matrix.database }}" == "sqlite" ]]; then
sudo apt update && sudo apt-get install -y sqlite3 ;
sqlite3 --version ;
fi
- name: Check versions
run: php -v && composer -V
- name: Validate composer.json and composer.lock
run: composer validate
- name: Install PHP dependencies
run: composer install --no-progress
run: composer update && composer install --prefer-dist --no-progress
- name: Run test suite
run: |
if [[ "${{ matrix.php }}" == "8.3" ]]; then
vendor/bin/phpunit --configuration tests/phpunit.xml --coverage-clover=data/coverage.clover
else
vendor/bin/phpunit --configuration tests/phpunit.xml
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
- name: Upload coverage
if: matrix.php == '8.3'
if: matrix.php == '8.1'
run: |
vendor/bin/ocular code-coverage:upload --format=php-clover data/coverage.clover

9
.gitignore vendored
View File

@ -1,9 +1,16 @@
backup
data
.docker/entrypoint.d/config.json
images
thumbs
*.phar
*.sqlite
*.cache
.devcontainer
trace.json
#Composer
composer.phar
composer.lock
/vendor/
# Created by http://www.gitignore.io

View File

@ -21,7 +21,7 @@
# any requests for files which don't physically exist should be handled by index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ index.php?q=$1&%{QUERY_STRING} "[L,B= ?,BNP]"
RewriteRule ^(.*)$ index.php?q=$1&%{QUERY_STRING} [L]
</IfModule>
<IfModule mod_expires.c>

View File

@ -14,5 +14,4 @@ return $_phpcs_config->setRules([
'array_syntax' => ['syntax' => 'short'],
])
->setFinder($_phpcs_finder)
->setCacheFile("data/php-cs-fixer.cache")
;
;

View File

@ -1,77 +1,49 @@
ARG PHP_VERSION=8.2
# Tree of layers:
# base
# ├── dev-tools
# │ ├── build
# │ │ └── tests
# │ └── devcontainer
# └── run (copies built artifacts out of build)
# Install base packages
# Things which all stages (build, test, run) need
# Install base packages which all stages (build, test, run) need
FROM debian:bookworm AS base
COPY --from=mwader/static-ffmpeg:6.1 /ffmpeg /ffprobe /usr/local/bin/
RUN apt update && \
apt upgrade -y && \
apt install -y curl && \
curl --output /usr/share/keyrings/nginx-keyring.gpg https://unit.nginx.org/keys/nginx-keyring.gpg && \
echo 'deb [signed-by=/usr/share/keyrings/nginx-keyring.gpg] https://packages.nginx.org/unit/debian/ bookworm unit' > /etc/apt/sources.list.d/unit.list && \
apt update && apt install -y --no-install-recommends \
php${PHP_VERSION}-cli \
php${PHP_VERSION}-gd php${PHP_VERSION}-zip php${PHP_VERSION}-xml php${PHP_VERSION}-mbstring php${PHP_VERSION}-curl \
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 \
php${PHP_VERSION}-memcached \
curl imagemagick zip unzip unit unit-php gettext && \
gosu curl imagemagick ffmpeg zip unzip && \
rm -rf /var/lib/apt/lists/*
RUN ln -sf /dev/stderr /var/log/unit.log
# Install dev packages
# Things which are only needed during development - Composer has 100MB of
# dependencies, so let's avoid including that in the final image
FROM base AS dev-tools
RUN apt update && apt upgrade -y && \
apt install -y composer php${PHP_VERSION}-xdebug git procps net-tools vim && \
rm -rf /var/lib/apt/lists/*
ENV XDEBUG_MODE=coverage
# Composer has 100MB of dependencies, and we only need that during build and test
FROM base AS composer
RUN apt update && apt upgrade -y && apt install -y composer php${PHP_VERSION}-xdebug && rm -rf /var/lib/apt/lists/*
# "Build" shimmie (composer install)
# Done in its own stage so that we don't meed to include all the
# composer fluff in the final image
FROM dev-tools AS build
# "Build" shimmie (composer install - done in its own stage so that we don't
# need to include all the composer fluff in the final image)
FROM composer AS app
COPY composer.json composer.lock /app/
WORKDIR /app
RUN composer install --no-dev --no-progress
RUN composer install --no-dev
COPY . /app/
# Tests in their own image.
# Re-run composer install to get dev dependencies
FROM build AS tests
RUN composer install --no-progress
# Tests in their own image. Really we should inherit from app and then
# `composer install` phpunit on top of that; but for some reason
# `composer install --no-dev && composer install` doesn't install dev
FROM composer AS tests
COPY composer.json composer.lock /app/
WORKDIR /app
RUN composer install
COPY . /app/
ARG RUN_TESTS=true
RUN [ $RUN_TESTS = false ] || (\
echo '=== Installing ===' && mkdir -p data/config && INSTALL_DSN="sqlite:data/shimmie.sqlite" php index.php && \
echo '=== Smoke Test ===' && php index.php get-page /post/list && \
echo '=== Unit Tests ===' && ./vendor/bin/phpunit --configuration tests/phpunit.xml && \
echo '=== Coverage ===' && ./vendor/bin/phpunit --configuration tests/phpunit.xml --coverage-text && \
echo '=== Coverage ===' && XDEBUG_MODE=coverage ./vendor/bin/phpunit --configuration tests/phpunit.xml --coverage-text && \
echo '=== Cleaning ===' && rm -rf data)
# Devcontainer target
# Contains all of the build and debug tools, but no code, since
# that's mounted from the host
FROM dev-tools AS devcontainer
EXPOSE 8000
# Actually run shimmie
FROM base AS run
FROM base
EXPOSE 8000
# 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 UPLOAD_MAX_FILESIZE=50M
COPY --from=build /app /app
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
WORKDIR /app
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 ["unitd", "--no-daemon", "--control", "unix:/var/run/control.unit.sock"]
CMD ["/bin/sh", "/app/tests/docker-init.sh"]

View File

@ -10,12 +10,10 @@
# Shimmie
[![Tests](https://github.com/shish/shimmie2/workflows/Tests/badge.svg?branch=main)](https://github.com/shish/shimmie2/actions)
[![Code Quality](https://scrutinizer-ci.com/g/shish/shimmie2/badges/quality-score.png?b=main)](https://scrutinizer-ci.com/g/shish/shimmie2/?branch=main)
[![Code Coverage](https://scrutinizer-ci.com/g/shish/shimmie2/badges/coverage.png?b=main)](https://scrutinizer-ci.com/g/shish/shimmie2/?branch=main)
[![Matrix](https://matrix.to/img/matrix-badge.svg)](https://matrix.to/#/#shimmie:matrix.org)
[![Test & Publish](https://github.com/shish/shimmie2/workflows/Test%20&%20Publish/badge.svg)](https://github.com/shish/shimmie2/actions)
[![Code Quality](https://scrutinizer-ci.com/g/shish/shimmie2/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/shish/shimmie2/?branch=master)
[![Code Coverage](https://scrutinizer-ci.com/g/shish/shimmie2/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/shish/shimmie2/?branch=master)
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/shish/shimmie2?quickstart=1)
# Documentation
@ -28,6 +26,13 @@
* [High-performance notes](https://github.com/shish/shimmie2/wiki/Performance)
# Contact
Email: webmaster at shishnet.org
Issue/Bug tracker: https://github.com/shish/shimmie2/issues
# Licence
All code is released under the [GNU GPL Version 2](https://www.gnu.org/licenses/gpl-2.0.html) unless mentioned otherwise.

View File

@ -35,31 +35,30 @@
"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.3",
"shish/microbundler": "^1.0",
"shish/microcrud": "^2.0",
"shish/microhtml": "^2.2",
"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",
"aws/aws-sdk-php": "^3.294",
"symfony/console": "6.4.x-dev"
"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"
},
"require-dev" : {
"phpunit/phpunit" : "10.5.3",
"friendsofphp/php-cs-fixer" : "3.41.1",
"scrutinizer/ocular": "1.9",
"phpstan/phpstan": "1.10.50"
"phpunit/phpunit" : "^9.0",
"friendsofphp/php-cs-fixer" : "^3.12",
"scrutinizer/ocular": "dev-master",
"phpstan/phpstan": "1.10.x-dev"
},
"suggest": {
"ext-memcache": "memcache caching",

3827
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -6,8 +6,6 @@ namespace Shimmie2;
use MicroHTML\HTMLElement;
use function MicroHTML\{emptyHTML,rawHTML,HTML,HEAD,BODY};
require_once "core/event.php";
enum PageMode: string
@ -19,22 +17,6 @@ enum PageMode: string
case MANUAL = 'manual';
}
class Cookie
{
public string $name;
public string $value;
public int $time;
public string $path;
public function __construct(string $name, string $value, int $time, string $path)
{
$this->name = $name;
$this->value = $value;
$this->time = $time;
$this->path = $path;
}
}
/**
* Class Page
*
@ -129,7 +111,6 @@ class BasePage
public string $title = "";
public string $heading = "";
public string $subheading = "";
public bool $left_enabled = true;
/** @var string[] */
public array $html_headers = [];
@ -137,7 +118,7 @@ class BasePage
/** @var string[] */
public array $http_headers = [];
/** @var Cookie[] */
/** @var string[][] */
public array $cookies = [];
/** @var Block[] */
@ -174,11 +155,6 @@ class BasePage
$this->flash[] = $message;
}
public function disable_left(): void
{
$this->left_enabled = false;
}
/**
* Add a line to the HTML head section.
*/
@ -209,7 +185,7 @@ class BasePage
public function add_cookie(string $name, string $value, int $time, string $path): void
{
$full_name = COOKIE_PREFIX . "_" . $name;
$this->cookies[] = new Cookie($full_name, $value, $time, $path);
$this->cookies[] = [$full_name, $value, $time, $path];
}
public function get_cookie(string $name): ?string
@ -270,7 +246,7 @@ class BasePage
header($head);
}
foreach ($this->cookies as $c) {
setcookie($c->name, $c->value, $c->time, $c->path);
setcookie($c[0], $c[1], $c[2], $c[3]);
}
} else {
print "Error: Headers have already been sent to the client.";
@ -282,7 +258,7 @@ class BasePage
*/
public function display(): void
{
if ($this->mode != PageMode::MANUAL) {
if ($this->mode!=PageMode::MANUAL) {
$this->send_headers();
}
@ -308,7 +284,7 @@ class BasePage
assert($this->file, "file should not be null with PageMode::FILE");
// https://gist.github.com/codler/3906826
$size = filesize_ex($this->file); // File size
$size = filesize($this->file); // File size
$length = $size; // Content length
$start = 0; // Start byte
$end = $size - 1; // End byte
@ -383,6 +359,8 @@ class BasePage
$data_href = get_base_href();
$theme_name = $config->get_string(SetupConfig::THEME, 'default');
$this->add_html_header("<script type='text/javascript'>base_href = '$data_href';</script>", 40);
# static handler will map these to themes/foo/static/bar.ico or ext/static_files/static/bar.ico
$this->add_html_header("<link rel='icon' type='image/x-icon' href='$data_href/favicon.ico'>", 41);
$this->add_html_header("<link rel='apple-touch-icon' href='$data_href/apple-touch-icon.png'>", 42);
@ -393,18 +371,7 @@ class BasePage
$config_latest = max($config_latest, filemtime($conf));
}
$css_cache_file = $this->get_css_cache_file($theme_name, $config_latest);
$this->add_html_header("<link rel='stylesheet' href='$data_href/$css_cache_file' type='text/css'>", 43);
$initjs_cache_file = $this->get_initjs_cache_file($theme_name, $config_latest);
$this->add_html_header("<script src='$data_href/$initjs_cache_file' type='text/javascript'></script>", 44);
$js_cache_file = $this->get_js_cache_file($theme_name, $config_latest);
$this->add_html_header("<script defer src='$data_href/$js_cache_file' type='text/javascript'></script>", 44);
}
private function get_css_cache_file(string $theme_name, int $config_latest): string
{
/*** Generate CSS cache files ***/
$css_latest = $config_latest;
$css_files = array_merge(
zglob("ext/{" . Extension::get_enabled_extensions_as_string() . "}/style.css"),
@ -416,47 +383,26 @@ 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)) {
$mcss = new \MicroBundler\MicroBundler();
foreach($css_files as $css) {
$mcss->addSource($css);
$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->save($css_cache_file);
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);
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
{
/*** Generate JS cache files ***/
$js_latest = $config_latest;
$js_files = array_merge(
[
"vendor/bower-asset/jquery/dist/jquery.min.js",
"vendor/bower-asset/jquery-timeago/jquery.timeago.js",
"vendor/bower-asset/js-cookie/src/js.cookie.js",
"ext/static_files/modernizr-3.3.1.custom.js",
],
zglob("ext/{" . Extension::get_enabled_extensions_as_string() . "}/script.js"),
zglob("themes/$theme_name/{" . implode(",", $this->get_theme_scripts()) . "}")
@ -467,18 +413,18 @@ 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)) {
$mcss = new \MicroBundler\MicroBundler();
foreach($js_files as $js) {
$mcss->addSource($js);
$js_data = "";
foreach ($js_files as $file) {
$js_data .= file_get_contents($file) . "\n";
}
$mcss->save($js_cache_file);
file_put_contents($js_cache_file, $js_data);
}
return $js_cache_file;
$this->add_html_header("<script defer src='$data_href/$js_cache_file' type='text/javascript'></script>", 44);
}
/**
* @return string[] A list of stylesheets relative to the theme root.
* @return array A list of stylesheets relative to the theme root.
*/
protected function get_theme_stylesheets(): array
{
@ -487,16 +433,13 @@ class BasePage
/**
* @return string[] A list of script files relative to the theme root.
* @return array A list of script files relative to the theme root.
*/
protected function get_theme_scripts(): array
{
return ["script.js"];
}
/**
* @return array{0: NavLink[], 1: NavLink[]}
*/
protected function get_nav_links(): array
{
$pnbe = send_event(new PageNavBuildingEvent());
@ -506,14 +449,14 @@ class BasePage
$active_link = null;
// To save on event calls, we check if one of the top-level links has already been marked as active
foreach ($nav_links as $link) {
if ($link->active === true) {
if ($link->active===true) {
$active_link = $link;
break;
}
}
$sub_links = null;
// If one is, we just query for sub-menu options under that one tab
if ($active_link !== null) {
if ($active_link!==null) {
$psnbe = send_event(new PageSubNavBuildingEvent($active_link->name));
$sub_links = $psnbe->links;
} else {
@ -523,23 +466,22 @@ class BasePage
// Now we check for a current link so we can identify the sub-links to show
foreach ($psnbe->links as $sub_link) {
if ($sub_link->active === true) {
if ($sub_link->active===true) {
$sub_links = $psnbe->links;
break;
}
}
// If the active link has been detected, we break out
if ($sub_links !== null) {
if ($sub_links!==null) {
$link->active = true;
break;
}
}
}
$sub_links = $sub_links ?? [];
usort($nav_links, fn (NavLink $a, NavLink $b) => $a->order - $b->order);
usort($sub_links, fn (NavLink $a, NavLink $b) => $a->order - $b->order);
$sub_links = $sub_links??[];
usort($nav_links, "Shimmie2\sort_nav_links");
usort($sub_links, "Shimmie2\sort_nav_links");
return [$nav_links, $sub_links];
}
@ -547,26 +489,18 @@ class BasePage
/**
* turns the Page into HTML
*/
public function render(): void
public function render()
{
global $config, $user;
$head_html = $this->head_html();
$body_html = $this->body_html();
$head = $this->head_html();
$body = $this->body_html();
$body_attrs = [
"data-userclass" => $user->class->name,
"data-base-href" => get_base_href(),
];
print emptyHTML(
rawHTML("<!doctype html>"),
HTML(
["lang" => "en"],
HEAD(rawHTML($head)),
BODY($body_attrs, rawHTML($body))
)
);
print <<<EOD
<!doctype html>
<html class="no-js" lang="en">
$head_html
$body_html
</html>
EOD;
}
protected function head_html(): string
@ -574,8 +508,10 @@ class BasePage
$html_header_html = $this->get_all_html_headers();
return "
<title>{$this->title}</title>
$html_header_html
<head>
<title>{$this->title}</title>
$html_header_html
</head>
";
}
@ -602,23 +538,30 @@ class BasePage
}
}
$wrapper = "";
if (strlen($this->heading) > 100) {
$wrapper = ' style="height: 3em; overflow: auto;"';
}
$footer_html = $this->footer_html();
$flash_html = $this->flash ? "<b id='flash'>".nl2br(html_escape(implode("\n", $this->flash)))."</b>" : "";
return "
<header>
<h1>{$this->heading}</h1>
$sub_block_html
</header>
<nav>
$left_block_html
</nav>
<article>
$flash_html
$main_block_html
</article>
<footer>
$footer_html
</footer>
<body>
<header>
<h1$wrapper>{$this->heading}</h1>
$sub_block_html
</header>
<nav>
$left_block_html
</nav>
<article>
$flash_html
$main_block_html
</article>
<footer>
$footer_html
</footer>
</body>
";
}
@ -633,7 +576,7 @@ class BasePage
<a href=\"https://code.shishnet.org/shimmie2/\">Shimmie</a> &copy;
<a href=\"https://www.shishnet.org/\">Shish</a> &amp;
<a href=\"https://github.com/shish/shimmie2/graphs/contributors\">The Team</a>
2007-2024,
2007-2023,
based on the Danbooru concept.
$debug
$contact
@ -643,10 +586,9 @@ class BasePage
class PageNavBuildingEvent extends Event
{
/** @var NavLink[] */
public array $links = [];
public function add_nav_link(string $name, Link $link, string $desc, ?bool $active = null, int $order = 50): void
public function add_nav_link(string $name, Link $link, string $desc, ?bool $active = null, int $order = 50)
{
$this->links[] = new NavLink($name, $link, $desc, $active, $order);
}
@ -656,16 +598,15 @@ class PageSubNavBuildingEvent extends Event
{
public string $parent;
/** @var NavLink[] */
public array $links = [];
public function __construct(string $parent)
{
parent::__construct();
$this->parent = $parent;
$this->parent= $parent;
}
public function add_nav_link(string $name, Link $link, string|HTMLElement $desc, ?bool $active = null, int $order = 50): void
public function add_nav_link(string $name, Link $link, string|HTMLElement $desc, ?bool $active = null, int $order = 50)
{
$this->links[] = new NavLink($name, $link, $desc, $active, $order);
}
@ -687,8 +628,8 @@ class NavLink
$this->link = $link;
$this->description = $description;
$this->order = $order;
if ($active == null) {
$query = _get_query();
if ($active==null) {
$query = ltrim(_get_query(), "/");
if ($query === "") {
// This indicates the front page, so we check what's set as the front page
$front_page = trim($config->get_string(SetupConfig::FRONT_PAGE), "/");
@ -698,7 +639,7 @@ class NavLink
} else {
$this->active = self::is_active([$link->page], $front_page);
}
} elseif ($query === $link->page) {
} elseif ($query===$link->page) {
$this->active = true;
} else {
$this->active = self::is_active([$link->page]);
@ -708,26 +649,23 @@ class NavLink
}
}
/**
* @param string[] $pages_matched
*/
public static function is_active(array $pages_matched, string $url = null): bool
{
/**
* Woo! We can actually SEE THE CURRENT PAGE!! (well... see it highlighted in the menu.)
*/
$url = $url ?? _get_query();
$url = $url??ltrim(_get_query(), "/");
$re1 = '.*?';
$re2 = '((?:[a-z][a-z_]+))';
$re1='.*?';
$re2='((?:[a-z][a-z_]+))';
if (preg_match_all("/".$re1.$re2."/is", $url, $matches)) {
$url = $matches[1][0];
$url=$matches[1][0];
}
$count_pages_matched = count($pages_matched);
for ($i = 0; $i < $count_pages_matched; $i++) {
for ($i=0; $i < $count_pages_matched; $i++) {
if ($url == $pages_matched[$i]) {
return true;
}
@ -736,3 +674,8 @@ class NavLink
return false;
}
}
function sort_nav_links(NavLink $a, NavLink $b): int
{
return $a->order - $b->order;
}

View File

@ -6,7 +6,7 @@ namespace Shimmie2;
use MicroHTML\HTMLElement;
use function MicroHTML\{A,B,BR,IMG,emptyHTML,joinHTML};
use function MicroHTML\{A,B,BR,IMG,OPTION,SELECT,emptyHTML};
/**
* Class BaseThemelet
@ -70,44 +70,39 @@ class BaseThemelet
}
$custom_classes = "";
if (Extension::is_enabled(RelationshipsInfo::KEY)) {
if ($image['parent_id'] !== null) {
if (class_exists("Shimmie2\Relationships")) {
if (property_exists($image, 'parent_id') && $image->parent_id !== null) {
$custom_classes .= "shm-thumb-has_parent ";
}
if ($image['has_children']) {
if (property_exists($image, 'has_children') && bool_escape($image->has_children)) {
$custom_classes .= "shm-thumb-has_child ";
}
}
$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(
$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,
],
IMG(
[
"id" => "thumb_$id",
"title" => $tip,
"alt" => $tip,
"height" => $tsize[1],
"width" => $tsize[0],
"src" => $thumb_link,
"id"=>"thumb_$id",
"title"=>$tip,
"alt"=>$tip,
"height"=>$tsize[1],
"width"=>$tsize[0],
"src"=>$thumb_link,
]
)
);
}
public function display_paginator(Page $page, string $base, ?string $query, int $page_number, int $total_pages, bool $show_random = false): void
public function display_paginator(Page $page, string $base, ?string $query, int $page_number, int $total_pages, bool $show_random = false)
{
if ($total_pages == 0) {
$total_pages = 1;
@ -117,18 +112,18 @@ class BaseThemelet
$page->add_html_header("<link rel='first' href='".make_http(make_link($base.'/1', $query))."'>");
if ($page_number < $total_pages) {
$page->add_html_header("<link rel='prefetch' href='".make_http(make_link($base.'/'.($page_number + 1), $query))."'>");
$page->add_html_header("<link rel='next' href='".make_http(make_link($base.'/'.($page_number + 1), $query))."'>");
$page->add_html_header("<link rel='prefetch' href='".make_http(make_link($base.'/'.($page_number+1), $query))."'>");
$page->add_html_header("<link rel='next' href='".make_http(make_link($base.'/'.($page_number+1), $query))."'>");
}
if ($page_number > 1) {
$page->add_html_header("<link rel='previous' href='".make_http(make_link($base.'/'.($page_number - 1), $query))."'>");
$page->add_html_header("<link rel='previous' href='".make_http(make_link($base.'/'.($page_number-1), $query))."'>");
}
$page->add_html_header("<link rel='last' href='".make_http(make_link($base.'/'.$total_pages, $query))."'>");
}
private function gen_page_link(string $base_url, ?string $query, int $page, string $name): HTMLElement
{
return A(["href" => make_link($base_url.'/'.$page, $query)], $name);
return A(["href"=>make_link($base_url.'/'.$page, $query)], $name);
}
private function gen_page_link_block(string $base_url, ?string $query, int $page, int $current_page, string $name): HTMLElement
@ -140,6 +135,19 @@ class BaseThemelet
return $paginator;
}
protected function implode(string|HTMLElement $glue, array $pieces): HTMLElement
{
$out = emptyHTML();
$n = 0;
foreach ($pieces as $piece) {
if ($n++ > 0) {
$out->appendChild($glue);
}
$out->appendChild($piece);
}
return $out;
}
private function build_paginator(int $current_page, int $total_pages, string $base_url, ?string $query, bool $show_random): HTMLElement
{
$next = $current_page + 1;
@ -167,10 +175,10 @@ class BaseThemelet
foreach (range($start, $end) as $i) {
$pages[] = $this->gen_page_link_block($base_url, $query, $i, $current_page, (string)$i);
}
$pages_html = joinHTML(" | ", $pages);
$pages_html = $this->implode(" | ", $pages);
return emptyHTML(
joinHTML(" | ", [
$this->implode(" | ", [
$first_html,
$prev_html,
$random_html,

View File

@ -45,7 +45,7 @@ class Block
*/
public bool $is_content = true;
public function __construct(string $header = null, string|\MicroHTML\HTMLElement $body = null, string $section = "main", int $position = 50, string $id = null)
public function __construct(string $header=null, string|\MicroHTML\HTMLElement $body=null, string $section="main", int $position=50, string $id=null)
{
$this->header = $header;
$this->body = (string)$body;
@ -63,7 +63,7 @@ class Block
/**
* Get the HTML for this block.
*/
public function get_html(bool $hidable = false): string
public function get_html(bool $hidable=false): string
{
$h = $this->header;
$b = $this->body;

View File

@ -10,8 +10,8 @@ class EventTracingCache implements CacheInterface
{
private CacheInterface $engine;
private \EventTracer $tracer;
private int $hits = 0;
private int $misses = 0;
private int $hits=0;
private int $misses=0;
public function __construct(CacheInterface $engine, \EventTracer $tracer)
{
@ -19,7 +19,7 @@ class EventTracingCache implements CacheInterface
$this->tracer = $tracer;
}
public function get($key, $default = null)
public function get($key, $default=null)
{
if ($key === "__etc_cache_hits") {
return $this->hits;
@ -29,7 +29,7 @@ class EventTracingCache implements CacheInterface
}
$sentinel = "__etc_sentinel";
$this->tracer->begin("Cache Get", ["key" => $key]);
$this->tracer->begin("Cache Get", ["key"=>$key]);
$val = $this->engine->get($key, $sentinel);
if ($val != $sentinel) {
$res = "hit";
@ -39,13 +39,13 @@ class EventTracingCache implements CacheInterface
$val = $default;
$this->misses++;
}
$this->tracer->end(null, ["result" => $res]);
$this->tracer->end(null, ["result"=>$res]);
return $val;
}
public function set($key, $value, $ttl = null)
{
$this->tracer->begin("Cache Set", ["key" => $key, "ttl" => $ttl]);
$this->tracer->begin("Cache Set", ["key"=>$key, "ttl"=>$ttl]);
$val = $this->engine->set($key, $value, $ttl);
$this->tracer->end();
return $val;
@ -53,7 +53,7 @@ class EventTracingCache implements CacheInterface
public function delete($key)
{
$this->tracer->begin("Cache Delete", ["key" => $key]);
$this->tracer->begin("Cache Delete", ["key"=>$key]);
$val = $this->engine->delete($key);
$this->tracer->end();
return $val;
@ -67,11 +67,6 @@ class EventTracingCache implements CacheInterface
return $val;
}
/**
* @param string[] $keys
* @param mixed $default
* @return iterable<mixed>
*/
public function getMultiple($keys, $default = null)
{
$this->tracer->begin("Cache Get Multiple", ["keys" => $keys]);
@ -80,9 +75,6 @@ class EventTracingCache implements CacheInterface
return $val;
}
/**
* @param array<string, mixed> $values
*/
public function setMultiple($values, $ttl = null)
{
$this->tracer->begin("Cache Set Multiple", ["keys" => array_keys($values)]);
@ -91,9 +83,6 @@ class EventTracingCache implements CacheInterface
return $val;
}
/**
* @param string[] $keys
*/
public function deleteMultiple($keys)
{
$this->tracer->begin("Cache Delete Multiple", ["keys" => $keys]);
@ -104,35 +93,33 @@ class EventTracingCache implements CacheInterface
public function has($key)
{
$this->tracer->begin("Cache Has", ["key" => $key]);
$this->tracer->begin("Cache Has", ["key"=>$key]);
$val = $this->engine->has($key);
$this->tracer->end(null, ["exists" => $val]);
$this->tracer->end(null, ["exists"=>$val]);
return $val;
}
}
function loadCache(?string $dsn): CacheInterface
{
$matches = [];
$c = null;
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 ($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(is_null($c)) {

View File

@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
use Symfony\Component\Console\Input\{ArgvInput,InputOption,InputDefinition,InputInterface};
use Symfony\Component\Console\Output\{OutputInterface,ConsoleOutput};
class CliApp extends \Symfony\Component\Console\Application
{
public function __construct()
{
parent::__construct('Shimmie', VERSION);
$this->setAutoExit(false);
}
protected function getDefaultInputDefinition(): InputDefinition
{
$definition = parent::getDefaultInputDefinition();
$definition->addOption(new InputOption(
'--user',
'-u',
InputOption::VALUE_REQUIRED,
'Log in as the given user'
));
return $definition;
}
public function run(InputInterface $input = null, OutputInterface $output = null): int
{
global $user;
$input ??= new ArgvInput();
$output ??= new ConsoleOutput();
if ($input->hasParameterOption(['--user', '-u'])) {
$name = $input->getParameterOption(['--user', '-u']);
$user = User::by_name($name);
if (is_null($user)) {
die("Unknown user '$name'\n");
} else {
send_event(new UserLoginEvent($user));
}
}
$log_level = SCORE_LOG_WARNING;
if (true === $input->hasParameterOption(['--quiet', '-q'], true)) {
$log_level = SCORE_LOG_ERROR;
} else {
if ($input->hasParameterOption('-vvv', true) || $input->hasParameterOption('--verbose=3', true) || 3 === $input->getParameterOption('--verbose', false, true)) {
$log_level = SCORE_LOG_DEBUG;
} elseif ($input->hasParameterOption('-vv', true) || $input->hasParameterOption('--verbose=2', true) || 2 === $input->getParameterOption('--verbose', false, true)) {
$log_level = SCORE_LOG_DEBUG;
} elseif ($input->hasParameterOption('-v', true) || $input->hasParameterOption('--verbose=1', true) || $input->hasParameterOption('--verbose', true) || $input->getParameterOption('--verbose', false, true)) {
$log_level = SCORE_LOG_INFO;
}
}
if (!defined("CLI_LOG_LEVEL")) {
define("CLI_LOG_LEVEL", $log_level);
}
return parent::run($input, $output);
}
}

View File

@ -10,12 +10,10 @@ namespace Shimmie2;
class CommandBuilder
{
private string $executable;
/** @var string[] */
private array $args = [];
/** @var string[] */
public array $output;
public function __construct(string $executable)
public function __construct(String $executable)
{
if (empty($executable)) {
throw new \InvalidArgumentException("executable cannot be empty");
@ -63,7 +61,7 @@ class CommandBuilder
log_debug('command_builder', "Command `$cmd` returned $ret and outputted $output");
if ($fail_on_non_zero_return && (int)$ret !== (int)0) {
if ($fail_on_non_zero_return&&(int)$ret!==(int)0) {
throw new SCoreException("Command `$cmd` failed, returning $ret and outputting $output");
}
return $ret;

View File

@ -16,7 +16,7 @@ interface Config
* so that the next time a page is loaded it will use the new
* configuration.
*/
public function save(string $name = null): void;
public function save(string $name=null): void;
//@{ /*--------------------------------- SET ------------------------------------------------------*/
/**
@ -41,8 +41,6 @@ interface Config
/**
* Set a configuration option to a new value, regardless of what the value is at the moment.
*
* @param mixed[] $value
*/
public function set_array(string $name, array $value): void;
//@} /*--------------------------------------------------------------------------------------------*/
@ -95,8 +93,6 @@ interface Config
* This has the advantage that the values will show up in the "advanced" setup
* page where they can be modified, while calling get_* with a "default"
* parameter won't show up.
*
* @param mixed[] $value
*/
public function set_default_array(string $name, array $value): void;
//@} /*--------------------------------------------------------------------------------------------*/
@ -105,30 +101,27 @@ interface Config
/**
* Pick a value out of the table by name, cast to the appropriate data type.
*/
public function get_int(string $name, ?int $default = null): ?int;
public function get_int(string $name, ?int $default=null): ?int;
/**
* Pick a value out of the table by name, cast to the appropriate data type.
*/
public function get_float(string $name, ?float $default = null): ?float;
public function get_float(string $name, ?float $default=null): ?float;
/**
* Pick a value out of the table by name, cast to the appropriate data type.
*/
public function get_string(string $name, ?string $default = null): ?string;
public function get_string(string $name, ?string $default=null): ?string;
/**
* Pick a value out of the table by name, cast to the appropriate data type.
*/
public function get_bool(string $name, ?bool $default = null): ?bool;
public function get_bool(string $name, ?bool $default=null): ?bool;
/**
* Pick a value out of the table by name, cast to the appropriate data type.
*
* @param mixed[] $default
* @return mixed[]
*/
public function get_array(string $name, ?array $default = []): ?array;
public function get_array(string $name, ?array $default=[]): ?array;
//@} /*--------------------------------------------------------------------------------------------*/
}
@ -141,7 +134,6 @@ interface Config
*/
abstract class BaseConfig implements Config
{
/** @var array<string, mixed> */
public array $values = [];
public function set_int(string $name, ?int $value): void
@ -170,7 +162,7 @@ abstract class BaseConfig implements Config
public function set_array(string $name, ?array $value): void
{
if ($value != null) {
if ($value!=null) {
$this->values[$name] = implode(",", $value);
} else {
$this->values[$name] = null;
@ -213,32 +205,17 @@ abstract class BaseConfig implements Config
}
}
/**
* @template T of int|null
* @param T $default
* @return T|int
*/
public function get_int(string $name, ?int $default = null): ?int
public function get_int(string $name, ?int $default=null): ?int
{
return (int)($this->get($name, $default));
}
/**
* @template T of float|null
* @param T $default
* @return T|float
*/
public function get_float(string $name, ?float $default = null): ?float
public function get_float(string $name, ?float $default=null): ?float
{
return (float)($this->get($name, $default));
}
/**
* @template T of string|null
* @param T $default
* @return T|string
*/
public function get_string(string $name, ?string $default = null): ?string
public function get_string(string $name, ?string $default=null): ?string
{
$val = $this->get($name, $default);
if (!is_string($val) && !is_null($val)) {
@ -247,34 +224,17 @@ abstract class BaseConfig implements Config
return $val;
}
/**
* @template T of bool|null
* @param T $default
* @return T|bool
*/
public function get_bool(string $name, ?bool $default = null): ?bool
public function get_bool(string $name, ?bool $default=null): ?bool
{
return bool_escape($this->get($name, $default));
}
/**
* @template T of array<string>|null
* @param T $default
* @return T|array<string>
*/
public function get_array(string $name, ?array $default = null): ?array
public function get_array(string $name, ?array $default=[]): ?array
{
$val = $this->get($name);
if(is_null($val)) {
return $default;
}
if(empty($val)) {
return [];
}
return explode(",", $val);
return explode(",", $this->get($name, ""));
}
private function get(string $name, mixed $default = null): mixed
private function get(string $name, $default=null)
{
if (isset($this->values[$name])) {
return $this->values[$name];
@ -318,30 +278,30 @@ class DatabaseConfig extends BaseConfig
$this->table_name = $table_name;
$this->sub_value = $sub_value;
$this->sub_column = $sub_column;
$this->cache_name = empty($sub_value) ? "config" : "config_{$sub_column}_{$sub_value}";
$this->values = cache_get_or_set($this->cache_name, fn () => $this->get_values());
$this->cache_name = empty($sub_value) ? "config" : "config_{$sub_value}";
$cached = $cache->get($this->cache_name);
if (!is_null($cached)) {
$this->values = $cached;
} else {
$this->values = [];
$query = "SELECT name, value FROM {$this->table_name}";
$args = [];
if (!empty($sub_column)&&!empty($sub_value)) {
$query .= " WHERE $sub_column = :sub_value";
$args["sub_value"] = $sub_value;
}
foreach ($this->database->get_all($query, $args) as $row) {
$this->values[$row["name"]] = $row["value"];
}
$cache->set($this->cache_name, $this->values);
}
}
private function get_values(): mixed
{
$values = [];
$query = "SELECT name, value FROM {$this->table_name}";
$args = [];
if (!empty($this->sub_column) && !empty($this->sub_value)) {
$query .= " WHERE {$this->sub_column} = :sub_value";
$args["sub_value"] = $this->sub_value;
}
foreach ($this->database->get_all($query, $args) as $row) {
$values[$row["name"]] = $row["value"];
}
return $values;
}
public function save(string $name = null): void
public function save(string $name=null): void
{
global $cache;
@ -352,10 +312,10 @@ class DatabaseConfig extends BaseConfig
}
} else {
$query = "DELETE FROM {$this->table_name} WHERE name = :name";
$args = ["name" => $name];
$args = ["name"=>$name];
$cols = ["name","value"];
$params = [":name",":value"];
if (!empty($this->sub_column) && !empty($this->sub_value)) {
if (!empty($this->sub_column)&&!empty($this->sub_value)) {
$query .= " AND $this->sub_column = :sub_value";
$args["sub_value"] = $this->sub_value;
$cols[] = $this->sub_column;
@ -364,7 +324,7 @@ class DatabaseConfig extends BaseConfig
$this->database->execute($query, $args);
$args["value"] = $this->values[$name];
$args["value"] =$this->values[$name];
$this->database->execute(
"INSERT INTO {$this->table_name} (".join(",", $cols).") VALUES (".join(",", $params).")",
$args

View File

@ -7,8 +7,6 @@ namespace Shimmie2;
use FFSPHP\PDO;
use FFSPHP\PDOStatement;
require_once __DIR__ . '/exceptions.php';
enum DatabaseDriverID: string
{
case MYSQL = "mysql";
@ -16,28 +14,8 @@ enum DatabaseDriverID: string
case SQLITE = "sqlite";
}
class DatabaseException extends SCoreException
{
public string $query;
/** @var array<string, mixed> */
public array $args;
/**
* @param array<string, mixed> $args
*/
public function __construct(string $msg, string $query, array $args)
{
parent::__construct($msg);
$this->error = $msg;
$this->query = $query;
$this->args = $args;
}
}
/**
* A class for controlled database access
*
* @phpstan-type QueryArgs array<string, string|int|bool|null>
*/
class Database
{
@ -58,7 +36,6 @@ class Database
* How many queries this DB object has run
*/
public int $query_count = 0;
/** @var string[] */
public array $queries = [];
public function __construct(string $dsn)
@ -80,7 +57,7 @@ class Database
private function connect_engine(): void
{
if (preg_match("/^([^:]*)/", $this->dsn, $matches)) {
$db_proto = $matches[1];
$db_proto=$matches[1];
} else {
throw new SCoreException("Can't figure out database engine");
}
@ -129,28 +106,6 @@ class Database
}
}
/**
* @template T
* @param callable():T $callback
* @return T
*/
public function with_savepoint(callable $callback, string $name = "sp"): mixed
{
global $_tracer;
try {
$_tracer->begin("Savepoint $name");
$this->execute("SAVEPOINT $name");
$ret = $callback();
$this->execute("RELEASE SAVEPOINT $name");
$_tracer->end();
return $ret;
} catch (\Exception $e) {
$this->execute("ROLLBACK TO SAVEPOINT $name");
$_tracer->end();
throw $e;
}
}
private function get_engine(): DBEngine
{
if (is_null($this->engine)) {
@ -174,18 +129,16 @@ class Database
return $this->get_engine()->get_version($this->get_db());
}
/**
* @param QueryArgs $args
*/
private function count_time(string $method, float $start, string $query, ?array $args): void
{
global $_tracer, $tracer_enabled;
$dur = ftime() - $start;
// trim whitespace
$query = preg_replace('/[\n\t ]+/m', ' ', $query);
$query = preg_replace('/[\n\t ]/m', ' ', $query);
$query = preg_replace('/ +/m', ' ', $query);
$query = trim($query);
if ($tracer_enabled) {
$_tracer->complete($start * 1000000, $dur * 1000000, "DB Query", ["query" => $query, "args" => $args, "method" => $method]);
$_tracer->complete($start * 1000000, $dur * 1000000, "DB Query", ["query"=>$query, "args"=>$args, "method"=>$method]);
}
$this->queries[] = $query;
$this->query_count++;
@ -197,32 +150,31 @@ class Database
$this->get_engine()->set_timeout($this->get_db(), $time);
}
public function notify(string $channel, ?string $data = null): void
public function notify(string $channel, ?string $data=null): void
{
$this->get_engine()->notify($this->get_db(), $channel, $data);
}
/**
* @param QueryArgs $args
*/
public function _execute(string $query, array $args = []): PDOStatement
{
try {
$uri = $_SERVER['REQUEST_URI'] ?? "unknown uri";
return $this->get_db()->execute(
"-- $uri\n" .
$ret = $this->get_db()->execute(
"-- " . str_replace("%2F", "/", urlencode($_GET['q'] ?? '')). "\n" .
$query,
$args
);
if ($ret === false) {
throw new SCoreException("Query failed", $query);
}
/** @noinspection PhpIncompatibleReturnTypeInspection */
return $ret;
} catch (\PDOException $pdoe) {
throw new DatabaseException($pdoe->getMessage(), $query, $args);
throw new SCoreException($pdoe->getMessage(), $query);
}
}
/**
* Execute an SQL query with no return
*
* @param QueryArgs $args
*/
public function execute(string $query, array $args = []): PDOStatement
{
@ -234,9 +186,6 @@ class Database
/**
* Execute an SQL query and return a 2D array.
*
* @param QueryArgs $args
* @return array<array<string, mixed>>
*/
public function get_all(string $query, array $args = []): array
{
@ -248,8 +197,6 @@ class Database
/**
* Execute an SQL query and return a iterable object for use with generators.
*
* @param QueryArgs $args
*/
public function get_all_iterable(string $query, array $args = []): PDOStatement
{
@ -261,9 +208,6 @@ class Database
/**
* Execute an SQL query and return a single row.
*
* @param QueryArgs $args
* @return array<string, mixed>
*/
public function get_row(string $query, array $args = []): ?array
{
@ -275,9 +219,6 @@ class Database
/**
* Execute an SQL query and return the first column of each row.
*
* @param QueryArgs $args
* @return array<mixed>
*/
public function get_col(string $query, array $args = []): array
{
@ -289,8 +230,6 @@ class Database
/**
* Execute an SQL query and return the first column of each row as a single iterable object.
*
* @param QueryArgs $args
*/
public function get_col_iterable(string $query, array $args = []): \Generator
{
@ -304,9 +243,6 @@ class Database
/**
* Execute an SQL query and return the the first column => the second column.
*
* @param QueryArgs $args
* @return array<string, mixed>
*/
public function get_pairs(string $query, array $args = []): array
{
@ -319,8 +255,6 @@ class Database
/**
* Execute an SQL query and return the the first column => the second column as an iterable object.
*
* @param QueryArgs $args
*/
public function get_pairs_iterable(string $query, array $args = []): \Generator
{
@ -334,10 +268,8 @@ class Database
/**
* Execute an SQL query and return a single value, or null.
*
* @param QueryArgs $args
*/
public function get_one(string $query, array $args = []): mixed
public function get_one(string $query, array $args = [])
{
$_start = ftime();
$row = $this->_execute($query, $args)->fetch();
@ -347,15 +279,13 @@ class Database
/**
* Execute an SQL query and returns a bool indicating if any data was returned
*
* @param QueryArgs $args
*/
public function exists(string $query, array $args = []): bool
{
$_start = ftime();
$row = $this->_execute($query, $args)->fetch();
$this->count_time("exists", $_start, $query, $args);
if ($row == null) {
if ($row==null) {
return false;
}
return true;
@ -407,8 +337,7 @@ class Database
$this->get_all("SELECT name FROM sqlite_master WHERE type = 'table'")
);
} else {
$did = (string)$this->get_engine()->id;
throw new SCoreException("Can't count tables for database type {$did}");
throw new SCoreException("Can't count tables for database type {$this->get_engine()->id}");
}
}
@ -417,7 +346,7 @@ class Database
return $this->get_db();
}
public function standardise_boolean(string $table, string $column, bool $include_postgres = false): void
public function standardise_boolean(string $table, string $column, bool $include_postgres=false): void
{
$d = $this->get_driver_id();
if ($d == DatabaseDriverID::MYSQL) {

View File

@ -16,7 +16,7 @@ abstract class DBEngine
{
public DatabaseDriverID $id;
public function init(PDO $db): void
public function init(PDO $db)
{
}
@ -30,18 +30,18 @@ abstract class DBEngine
return 'CREATE TABLE '.$name.' ('.$data.')';
}
abstract public function set_timeout(PDO $db, ?int $time): void;
abstract public function set_timeout(PDO $db, ?int $time);
abstract public function get_version(PDO $db): string;
abstract public function notify(PDO $db, string $channel, ?string $data = null): void;
abstract public function notify(PDO $db, string $channel, ?string $data=null): void;
}
class MySQL extends DBEngine
{
public DatabaseDriverID $id = DatabaseDriverID::MYSQL;
public function init(PDO $db): void
public function init(PDO $db)
{
$db->exec("SET NAMES utf8;");
}
@ -66,13 +66,13 @@ class MySQL extends DBEngine
// $db->exec("SET SESSION MAX_EXECUTION_TIME=".$time.";");
}
public function notify(PDO $db, string $channel, ?string $data = null): void
public function notify(PDO $db, string $channel, ?string $data=null): void
{
}
public function get_version(PDO $db): string
{
return false_throws($db->query('select version()'))->fetch()[0];
return $db->query('select version()')->fetch()[0];
}
}
@ -80,7 +80,7 @@ class PostgreSQL extends DBEngine
{
public DatabaseDriverID $id = DatabaseDriverID::PGSQL;
public function init(PDO $db): void
public function init(PDO $db)
{
if (array_key_exists('REMOTE_ADDR', $_SERVER)) {
$db->exec("SET application_name TO 'shimmie [{$_SERVER['REMOTE_ADDR']}]';");
@ -113,7 +113,7 @@ class PostgreSQL extends DBEngine
$db->exec("SET statement_timeout TO ".$time.";");
}
public function notify(PDO $db, string $channel, ?string $data = null): void
public function notify(PDO $db, string $channel, ?string $data=null): void
{
if ($data) {
$db->exec("NOTIFY $channel, '$data';");
@ -124,44 +124,44 @@ class PostgreSQL extends DBEngine
public function get_version(PDO $db): string
{
return false_throws($db->query('select version()'))->fetch()[0];
return $db->query('select version()')->fetch()[0];
}
}
// shimmie functions for export to sqlite
function _unix_timestamp(string $date): int
function _unix_timestamp($date): int
{
return strtotime_ex($date);
return strtotime($date);
}
function _now(): string
{
return date("Y-m-d H:i:s");
}
function _floor(float|int $a): float
function _floor($a): float
{
return floor($a);
}
function _log(float $a, ?float $b = null): float
function _log($a, $b=null): float
{
if (is_null($b)) {
return log($a);
} else {
return log($b, $a);
return log($a, $b);
}
}
function _isnull(mixed $a): bool
function _isnull($a): bool
{
return is_null($a);
}
function _md5(string $a): string
function _md5($a): string
{
return md5($a);
}
function _concat(string $a, string $b): string
function _concat($a, $b): string
{
return $a . $b;
}
function _lower(string $a): string
function _lower($a): string
{
return strtolower($a);
}
@ -169,7 +169,7 @@ function _rand(): int
{
return rand();
}
function _ln(float $n): float
function _ln($n): float
{
return log($n);
}
@ -178,7 +178,7 @@ class SQLite extends DBEngine
{
public DatabaseDriverID $id = DatabaseDriverID::SQLITE;
public function init(PDO $db): void
public function init(PDO $db)
{
ini_set('sqlite.assoc_case', '0');
$db->exec("PRAGMA foreign_keys = ON;");
@ -225,12 +225,12 @@ class SQLite extends DBEngine
// There doesn't seem to be such a thing for SQLite, so it does nothing
}
public function notify(PDO $db, string $channel, ?string $data = null): void
public function notify(PDO $db, string $channel, ?string $data=null): void
{
}
public function get_version(PDO $db): string
{
return false_throws($db->query('select sqlite_version()'))->fetch()[0];
return $db->query('select sqlite_version()')->fetch()[0];
}
}

View File

@ -46,28 +46,25 @@ class InitExtEvent extends Event
*/
class PageRequestEvent extends Event
{
public string $method;
public string $path;
/**
* @var string[]
*/
public array $args;
public $args;
public int $arg_count;
public int $part_count;
public function __construct(string $method, string $path)
public function __construct(string $path)
{
parent::__construct();
global $config;
$this->method = $method;
// trim starting slashes
$path = ltrim($path, "/");
// if we're looking at the root of the install,
// use the default front page
if ($path == "") {
// if path is not specified, use the default front page
if (empty($path)) { /* empty is faster than strlen */
$path = $config->get_string(SetupConfig::FRONT_PAGE);
}
$this->path = $path;
// break the path into parts
$args = explode('/', $path);
@ -90,7 +87,7 @@ class PageRequestEvent extends Event
return false;
}
for ($i = 0; $i < $this->part_count; $i++) {
for ($i=0; $i<$this->part_count; $i++) {
if ($parts[$i] != $this->args[$i]) {
return false;
}
@ -106,7 +103,7 @@ class PageRequestEvent extends Event
{
$offset = $this->part_count + $n;
if ($offset >= 0 && $offset < $this->arg_count) {
return rawurldecode($this->args[$offset]);
return $this->args[$offset];
} else {
$nm1 = $this->arg_count - 1;
throw new UserErrorException("Requested an invalid page argument {$offset} / {$nm1}");
@ -117,11 +114,11 @@ class PageRequestEvent extends Event
* 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 try_page_num(int $n, ?int $max=null): int
{
if ($this->count_args() > $n) {
$i = $this->get_arg($n);
if (is_numberish($i) && int_escape($i) > 0) {
if (is_numeric($i) && int_escape($i) > 0) {
return page_number($i, $max);
} else {
return 0;
@ -143,39 +140,11 @@ class PageRequestEvent extends Event
* Many things use these functions
*/
/**
* @return string[]
*/
public function get_search_terms(): array
{
$search_terms = [];
if ($this->count_args() === 2) {
$str = $this->get_arg(0);
// decode legacy caret-encoding just in case
// somebody has bookmarked such a URL
$from_caret = [
"^" => "^",
"s" => "/",
"b" => "\\",
"q" => "?",
"a" => "&",
"d" => ".",
];
$out = "";
$length = strlen($str);
for ($i = 0; $i < $length; $i++) {
if ($str[$i] == "^") {
$i++;
$out .= $from_caret[$str[$i]] ?? '';
} else {
$out .= $str[$i];
}
}
$str = $out;
// end legacy
$search_terms = Tag::explode($str);
$search_terms = Tag::explode(Tag::decaret($this->get_arg(0)));
}
return $search_terms;
}
@ -202,12 +171,73 @@ class PageRequestEvent extends Event
}
class CliGenEvent extends Event
/**
* Sent when index.php is called from the command line
*/
class CommandEvent extends Event
{
public function __construct(
public \Symfony\Component\Console\Application $app
) {
public string $cmd = "help";
/**
* @var string[]
*/
public array $args = [];
/**
* #param string[] $args
*/
public function __construct(array $args)
{
parent::__construct();
global $user;
$opts = [];
$log_level = SCORE_LOG_WARNING;
$arg_count = count($args);
for ($i=1; $i<$arg_count; $i++) {
switch ($args[$i]) {
case '-u':
$user = User::by_name($args[++$i]);
if (is_null($user)) {
die("Unknown user");
} else {
send_event(new UserLoginEvent($user));
}
break;
case '-q':
$log_level += 10;
break;
case '-v':
$log_level -= 10;
break;
default:
$opts[] = $args[$i];
break;
}
}
if (!defined("CLI_LOG_LEVEL")) {
define("CLI_LOG_LEVEL", $log_level);
}
if (count($opts) > 0) {
$this->cmd = $opts[0];
$this->args = array_slice($opts, 1);
} else {
print "\n";
print "Usage: php {$args[0]} [flags] [command]\n";
print "\n";
print "Flags:\n";
print "\t-u [username]\n";
print "\t\tLog in as the specified user\n";
print "\t-q / -v\n";
print "\t\tBe quieter / more verbose\n";
print "\t\tScale is debug - info - warning - error - critical\n";
print "\t\tDefault is to show warnings and above\n";
print "\n";
print "Currently known commands:\n";
}
}
}

View File

@ -9,13 +9,15 @@ namespace Shimmie2;
*/
class SCoreException extends \RuntimeException
{
public ?string $query;
public string $error;
public int $http_code = 500;
public function __construct(string $msg)
public function __construct(string $msg, ?string $query=null)
{
parent::__construct($msg);
$this->error = $msg;
$this->query = $query;
}
}

View File

@ -22,10 +22,9 @@ abstract class Extension
protected Themelet $theme;
public ExtensionInfo $info;
/** @var string[] */
private static array $enabled_extensions = [];
public function __construct(?string $class = null)
public function __construct($class = null)
{
$class = $class ?? get_called_class();
$this->theme = $this->get_theme_object($class);
@ -43,13 +42,9 @@ abstract class Extension
$normal = "Shimmie2\\{$base}Theme";
if (class_exists($custom)) {
$c = new $custom();
assert(is_a($c, Themelet::class));
return $c;
return new $custom();
} elseif (class_exists($normal)) {
$n = new $normal();
assert(is_a($n, Themelet::class));
return $n;
return new $normal();
} else {
return new Themelet();
}
@ -74,7 +69,7 @@ abstract class Extension
$extras
) as $key) {
$ext = ExtensionInfo::get_by_key($key);
if ($ext === null || !$ext->is_supported()) {
if ($ext===null || !$ext->is_supported()) {
continue;
}
// FIXME: error if one of our dependencies isn't supported
@ -87,14 +82,11 @@ abstract class Extension
}
}
public static function is_enabled(string $key): bool
public static function is_enabled(string $key): ?bool
{
return in_array($key, self::$enabled_extensions);
}
/**
* @return string[]
*/
public static function get_enabled_extensions(): array
{
return self::$enabled_extensions;
@ -110,7 +102,7 @@ abstract class Extension
return $config->get_int($name, 0);
}
protected function set_version(string $name, int $ver): void
protected function set_version(string $name, int $ver)
{
global $config;
$config->set_int($name, $ver);
@ -118,10 +110,6 @@ abstract class Extension
}
}
class ExtensionNotFound extends SCoreException
{
}
enum ExtensionVisibility
{
case DEFAULT;
@ -135,7 +123,7 @@ abstract class ExtensionInfo
public const SHISH_NAME = "Shish";
public const SHISH_EMAIL = "webmaster@shishnet.org";
public const SHIMMIE_URL = "https://code.shishnet.org/shimmie2/";
public const SHISH_AUTHOR = [self::SHISH_NAME => self::SHISH_EMAIL];
public const SHISH_AUTHOR = [self::SHISH_NAME=>self::SHISH_EMAIL];
public const LICENSE_GPLV2 = "GPLv2";
public const LICENSE_MIT = "MIT";
@ -149,11 +137,8 @@ abstract class ExtensionInfo
public string $name;
public string $license;
public string $description;
/** @var array<string, string|null> */
public array $authors = [];
/** @var string[] */
public array $dependencies = [];
/** @var string[] */
public array $conflicts = [];
public ExtensionVisibility $visibility = ExtensionVisibility::DEFAULT;
public ?string $link = null;
@ -167,7 +152,7 @@ abstract class ExtensionInfo
public function is_supported(): bool
{
if ($this->supported === null) {
if ($this->supported===null) {
$this->check_support();
}
return $this->supported;
@ -175,17 +160,14 @@ abstract class ExtensionInfo
public function get_support_info(): string
{
if ($this->supported === null) {
if ($this->supported===null) {
$this->check_support();
}
return $this->support_info;
}
/** @var array<string, ExtensionInfo> */
private static array $all_info_by_key = [];
/** @var array<string, ExtensionInfo> */
private static array $all_info_by_class = [];
/** @var string[] */
private static array $core_extensions = [];
protected function __construct()
@ -202,7 +184,7 @@ abstract class ExtensionInfo
return Extension::is_enabled($this->key);
}
private function check_support(): void
private function check_support()
{
global $database;
$this->support_info = "";
@ -221,25 +203,16 @@ abstract class ExtensionInfo
$this->supported = empty($this->support_info);
}
/**
* @return ExtensionInfo[]
*/
public static function get_all(): array
{
return array_values(self::$all_info_by_key);
}
/**
* @return string[]
*/
public static function get_all_keys(): array
{
return array_keys(self::$all_info_by_key);
}
/**
* @return string[]
*/
public static function get_core_extensions(): array
{
return self::$core_extensions;
@ -262,22 +235,21 @@ abstract class ExtensionInfo
return self::$all_info_by_class[$normal];
} else {
$infos = print_r(array_keys(self::$all_info_by_class), true);
throw new ExtensionNotFound("$normal not found in {$infos}");
throw new SCoreException("$normal not found in {$infos}");
}
}
public static function load_all_extension_info(): void
public static function load_all_extension_info()
{
foreach (get_subclasses_of(ExtensionInfo::class) as $class) {
foreach (get_subclasses_of("Shimmie2\ExtensionInfo") as $class) {
$extension_info = new $class();
assert(is_a($extension_info, ExtensionInfo::class));
if (array_key_exists($extension_info->key, self::$all_info_by_key)) {
throw new SCoreException("Extension Info $class with key $extension_info->key has already been loaded");
}
self::$all_info_by_key[$extension_info->key] = $extension_info;
self::$all_info_by_class[$class] = $extension_info;
if ($extension_info->core === true) {
if ($extension_info->core===true) {
self::$core_extensions[] = $extension_info->key;
}
}
@ -291,7 +263,7 @@ abstract class ExtensionInfo
*/
abstract class FormatterExtension extends Extension
{
public function onTextFormatting(TextFormattingEvent $event): void
public function onTextFormatting(TextFormattingEvent $event)
{
$event->formatted = $this->format($event->formatted);
$event->stripped = $this->strip($event->stripped);
@ -309,83 +281,86 @@ abstract class FormatterExtension extends Extension
*/
abstract class DataHandlerExtension extends Extension
{
/** @var string[] */
protected array $SUPPORTED_MIME = [];
public function onDataUpload(DataUploadEvent $event): void
protected function move_upload_to_archive(DataUploadEvent $event)
{
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(md5_file_ex($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'])) {
send_event(new TagSetEvent($existing, array_merge($existing->get_tag_array(), $event->metadata['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 = filesize_ex($filename);
$image->hash = md5_file_ex($filename);
$image->filename = (($pos = strpos($event->metadata['filename'], '?')) !== false) ? substr($event->metadata['filename'], 0, $pos) : $event->metadata['filename'];
$image->set_mime(MimeType::get_for_file($filename, get_file_ext($event->metadata["filename"]) ?? null));
if (empty($image->get_mime())) {
throw new UploadException("Unable to determine MIME for $filename");
}
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
// Let everybody else know, so that TagEdit can set tags, Ratings can set ratings, etc
$iae = send_event(new ImageAdditionEvent($image, $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;
$target = warehouse_path(Image::IMAGE_DIR, $event->hash);
if (!@copy($event->tmpname, $target)) {
$errors = error_get_last();
throw new UploadException(
"Failed to copy file from uploads ({$event->tmpname}) to archive ($target): ".
"{$errors['type']} / {$errors['message']}"
);
}
}
public function onThumbnailGeneration(ThumbnailGenerationEvent $event): void
public function onDataUpload(DataUploadEvent $event)
{
$supported_mime = $this->supported_mime($event->mime);
$check_contents = $this->check_contents($event->tmpname);
if ($supported_mime && $check_contents) {
$this->move_upload_to_archive($event);
send_event(new ThumbnailGenerationEvent($event->hash, $event->mime));
/* Check if we are replacing an image */
if (!is_null($event->replace_id)) {
/* hax: This seems like such a dirty way to do this.. */
/* Check to make sure the image exists. */
$existing = Image::by_id($event->replace_id);
if (is_null($existing)) {
throw new UploadException("Post to replace does not exist!");
}
if ($existing->hash === $event->hash) {
throw new UploadException("The uploaded post is the same as the one to replace.");
}
// even more hax..
$event->metadata['tags'] = $existing->get_tag_list();
$image = $this->create_image_from_data(warehouse_path(Image::IMAGE_DIR, $event->hash), $event->metadata);
send_event(new ImageReplaceEvent($event->replace_id, $image));
$_id = $event->replace_id;
assert(!is_null($_id));
$event->image_id = $_id;
} else {
$image = $this->create_image_from_data(warehouse_path(Image::IMAGE_DIR, $event->hash), $event->metadata);
$iae = send_event(new ImageAdditionEvent($image));
$event->image_id = $iae->image->id;
$event->merged = $iae->merged;
// Rating Stuff.
if (!empty($event->metadata['rating'])) {
$rating = $event->metadata['rating'];
send_event(new RatingSetEvent($image, $rating));
}
// Locked Stuff.
if (!empty($event->metadata['locked'])) {
$locked = $event->metadata['locked'];
send_event(new LockSetEvent($image, $locked));
}
}
} elseif ($supported_mime && !$check_contents) {
// We DO support this extension - but the file looks corrupt
throw new UploadException("Invalid or corrupted file");
}
}
public function onThumbnailGeneration(ThumbnailGenerationEvent $event)
{
$result = false;
if ($this->supported_mime($event->image->get_mime())) {
if ($this->supported_mime($event->mime)) {
if ($event->force) {
$result = $this->create_thumb($event->image);
$result = $this->create_thumb($event->hash, $event->mime);
} else {
$outname = $event->image->get_thumb_filename();
$outname = warehouse_path(Image::THUMBNAIL_DIR, $event->hash);
if (file_exists($outname)) {
return;
}
$result = $this->create_thumb($event->image);
$result = $this->create_thumb($event->hash, $event->mime);
}
}
if ($result) {
@ -393,48 +368,65 @@ abstract class DataHandlerExtension extends Extension
}
}
public function onDisplayingImage(DisplayingImageEvent $event): void
public function onDisplayingImage(DisplayingImageEvent $event)
{
global $config, $page;
global $page;
if ($this->supported_mime($event->image->get_mime())) {
// @phpstan-ignore-next-line
$this->theme->display_image($event->image);
if ($config->get_bool(ImageConfig::SHOW_META) && method_exists($this->theme, "display_metadata")) {
$this->theme->display_metadata($event->image);
}
$this->theme->display_image($page, $event->image);
}
}
public function onMediaCheckProperties(MediaCheckPropertiesEvent $event): void
public function onMediaCheckProperties(MediaCheckPropertiesEvent $event)
{
if ($this->supported_mime($event->image->get_mime())) {
$this->media_check_properties($event);
}
}
protected function create_image_from_data(string $filename, array $metadata): Image
{
$image = new Image();
assert(is_readable($filename));
$image->filesize = filesize($filename);
$image->hash = md5_file($filename);
$image->filename = (($pos = strpos($metadata['filename'], '?')) !== false) ? substr($metadata['filename'], 0, $pos) : $metadata['filename'];
$image->set_mime(MimeType::get_for_file($filename, get_file_ext($metadata["filename"]) ?? null));
$image->tag_array = is_array($metadata['tags']) ? $metadata['tags'] : Tag::explode($metadata['tags']);
$image->source = $metadata['source'];
if (empty($image->get_mime())) {
throw new UploadException("Unable to determine MIME for $filename");
}
try {
send_event(new MediaCheckPropertiesEvent($image));
} catch (MediaException $e) {
throw new UploadException("Unable to scan media properties $filename / $image->filename / $image->hash: ".$e->getMessage());
}
return $image;
}
abstract protected function media_check_properties(MediaCheckPropertiesEvent $event): void;
abstract protected function check_contents(string $tmpname): bool;
abstract protected function create_thumb(Image $image): bool;
abstract protected function create_thumb(string $hash, string $mime): bool;
protected function supported_mime(string $mime): bool
{
return MimeType::matches_array($mime, $this->SUPPORTED_MIME);
}
/**
* @return string[]
*/
public static function get_all_supported_mimes(): array
{
$arr = [];
foreach (get_subclasses_of(DataHandlerExtension::class) as $handler) {
foreach (get_subclasses_of("Shimmie2\DataHandlerExtension") as $handler) {
$handler = (new $handler());
assert(is_a($handler, DataHandlerExtension::class));
$arr = array_merge($arr, $handler->SUPPORTED_MIME);
}
// Not sure how to handle this otherwise, don't want to set up a whole other event for this one class
if (Extension::is_enabled(TranscodeImageInfo::KEY)) {
if (class_exists("Shimmie2\TranscodeImage")) {
$arr = array_merge($arr, TranscodeImage::get_enabled_mimes());
}
@ -442,9 +434,6 @@ abstract class DataHandlerExtension extends Extension
return $arr;
}
/**
* @return string[]
*/
public static function get_all_supported_exts(): array
{
$arr = [];

View File

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
use MicroCRUD\TextColumn;
use function MicroHTML\INPUT;
class AutoCompleteColumn extends TextColumn
{
public function read_input(array $inputs): \MicroHTML\HTMLElement
{
return INPUT([
"type" => "text",
"name" => "r_{$this->name}",
"class" => "autocomplete_tags",
"placeholder" => $this->title,
"value" => @$inputs["r_{$this->name}"]
]);
}
public function create_input(array $inputs): \MicroHTML\HTMLElement
{
return INPUT([
"type" => "text",
"name" => "c_{$this->name}",
"class" => "autocomplete_tags",
"placeholder" => $this->title,
"value" => @$inputs["c_{$this->name}"]
]);
}
}

View File

@ -9,20 +9,25 @@ namespace Shimmie2;
*/
class ImageAdditionEvent extends Event
{
public User $user;
public bool $merged = false;
/**
* Inserts a new image into the database with its associated
* information.
*
* @param mixed[] $metadata
* information. Also calls TagSetEvent to set the tags for
* this new image.
*/
public function __construct(
public Image $image,
public array $metadata,
) {
parent::__construct();
}
}
class ImageAdditionException extends SCoreException
{
}
/**
* An image is being deleted.
*/
@ -47,25 +52,18 @@ class ImageDeletionEvent extends Event
*/
class ImageReplaceEvent extends Event
{
public string $old_hash;
public string $new_hash;
/**
* Replaces an image file.
* Replaces an image.
*
* Updates an existing ID in the database to use a new image
* file, leaving the tags and such unchanged. Also removes
* the old image file and thumbnail from the disk.
*/
public function __construct(
public Image $image,
public string $tmp_filename,
public int $id,
public Image $image
) {
parent::__construct();
$this->old_hash = $image->hash;
$hash = md5_file($tmp_filename);
assert($hash !== false, "Failed to hash file $tmp_filename");
$this->new_hash = $hash;
}
}
@ -84,8 +82,9 @@ class ThumbnailGenerationEvent extends Event
* Request a thumbnail be made for an image object
*/
public function __construct(
public Image $image,
public bool $force = false
public string $hash,
public string $mime,
public bool $force=false
) {
parent::__construct();
$this->generated = false;

View File

@ -8,13 +8,6 @@ use GQLA\Type;
use GQLA\Field;
use GQLA\Query;
enum ImagePropType
{
case BOOL;
case INT;
case STRING;
}
/**
* Class Image
*
@ -23,18 +16,15 @@ enum ImagePropType
* As of 2.2, this no longer necessarily represents an
* image per se, but could be a video, sound file, or any
* other supported upload type.
*
* @implements \ArrayAccess<string, mixed>
*/
#[\AllowDynamicProperties]
#[Type(name: "Post")]
class Image implements \ArrayAccess
class Image
{
public const IMAGE_DIR = "images";
public const THUMBNAIL_DIR = "thumbs";
private bool $in_db = false;
public int $id;
public ?int $id = null;
#[Field]
public int $height = 0;
#[Field]
@ -54,9 +44,9 @@ class Image implements \ArrayAccess
public int $owner_id;
public string $owner_ip;
#[Field]
public string $posted;
public ?string $posted = null;
#[Field]
public ?string $source = null;
public ?string $source;
#[Field]
public bool $locked = false;
public ?bool $lossless = null;
@ -65,92 +55,39 @@ class Image implements \ArrayAccess
public ?bool $image = null;
public ?bool $audio = null;
public ?int $length = null;
public ?string $tmp_file = null;
/** @var array<string, ImagePropType> */
public static array $prop_types = [];
/** @var array<string, mixed> */
private array $dynamic_props = [];
public static array $bool_props = ["locked", "lossless", "video", "audio", "image"];
public static array $int_props = ["id", "owner_id", "height", "width", "filesize", "length"];
/**
* One will very rarely construct an image directly, more common
* would be to use Image::by_id, Image::by_hash, etc.
*
* @param array<string|int, mixed>|null $row
*/
public function __construct(?array $row = null)
public function __construct(?array $row=null)
{
if (!is_null($row)) {
foreach ($row as $name => $value) {
// some databases return both key=>value and numeric indices,
// we only want the key=>value ones
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,
};
}
}
}
} 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;
// some databases use table.name rather than name
$name = str_replace("images.", "", $name);
// hax, this is likely the cause of much scrutinizer-ci complaints.
if (is_null($value)) {
$this->$name = null;
} elseif (in_array($name, self::$bool_props)) {
$this->$name = bool_escape((string)$value);
} elseif (in_array($name, self::$int_props)) {
$this->$name = int_escape((string)$value);
} else {
// 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");
}
$this->$name = $value;
}
}
$this->in_db = true;
}
}
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)) {
throw new \OutOfBoundsException("Undefined dynamic property: $offset");
}
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
{
@ -166,11 +103,11 @@ class Image implements \ArrayAccess
public static function by_id(int $post_id): ?Image
{
global $database;
if ($post_id > 2 ** 32) {
if ($post_id > 2**32) {
// for some reason bots query huge numbers and pollute the DB error logs...
return null;
}
$row = $database->get_row("SELECT * FROM images WHERE images.id=:id", ["id" => $post_id]);
$row = $database->get_row("SELECT * FROM images WHERE images.id=:id", ["id"=>$post_id]);
return ($row ? new Image($row) : null);
}
@ -178,29 +115,26 @@ class Image implements \ArrayAccess
{
global $database;
$hash = strtolower($hash);
$row = $database->get_row("SELECT images.* FROM images WHERE hash=:hash", ["hash" => $hash]);
$row = $database->get_row("SELECT images.* FROM images WHERE hash=:hash", ["hash"=>$hash]);
return ($row ? new Image($row) : null);
}
public static function by_id_or_hash(string $id): ?Image
{
return (is_numberish($id) && strlen($id) != 32) ? Image::by_id((int)$id) : Image::by_hash($id);
return (is_numeric($id) && strlen($id) != 32) ? Image::by_id((int)$id) : Image::by_hash($id);
}
/**
* @param string[] $tags
*/
public static function by_random(array $tags = [], int $limit_range = 0): ?Image
public static function by_random(array $tags=[], int $limit_range=0): ?Image
{
$max = Search::count_images($tags);
$max = Image::count_images($tags);
if ($max < 1) {
return null;
} // From Issue #22 - opened by HungryFeline on May 30, 2011.
} // From Issue #22 - opened by HungryFeline on May 30, 2011.
if ($limit_range > 0 && $max > $limit_range) {
$max = $limit_range;
}
$rand = mt_rand(0, $max - 1);
$set = Search::find_images($rand, 1, $tags);
$rand = mt_rand(0, $max-1);
$set = Image::find_images($rand, 1, $tags);
if (count($set) > 0) {
return $set[0];
} else {
@ -208,6 +142,136 @@ class Image implements \ArrayAccess
}
}
private static function find_images_internal(int $start = 0, ?int $limit = null, array $tags=[]): iterable
{
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 PermissionDeniedException("Anonymous users may only search for up to 3 tags at a time");
}
}
$querylet = Image::build_search_querylet($tags, $limit, $start);
return $database->get_all_iterable($querylet->sql, $querylet->variables);
}
/**
* Search for an array of images
*
* @param 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
*/
public static function find_images_iterable(int $start = 0, ?int $limit = null, array $tags=[]): \Generator
{
$result = self::find_images_internal($start, $limit, $tags);
foreach ($result as $row) {
yield new Image($row);
}
}
/*
* Image-related utility functions
*/
public static function count_total_images(): int
{
global $cache, $database;
$total = $cache->get("image-count");
if (is_null($total)) {
$total = (int)$database->get_one("SELECT COUNT(*) FROM images");
$cache->set("image-count", $total, 600);
}
return $total;
}
public static function count_tag(string $tag): int
{
global $database;
return (int)$database->get_one(
"SELECT count FROM tags WHERE LOWER(tag) = LOWER(:tag)",
["tag"=>$tag]
);
}
/**
* Count the number of image results for a given search
*
* @param String[] $tags
*/
public static function count_images(array $tags=[]): int
{
global $cache, $database;
$tag_count = count($tags);
if (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 tag - we can look that up directly
$total = self::count_tag($tags[0]);
} else {
// one negative tag - subtract from the total
$total = self::count_total_images() - self::count_tag(substr($tags[0], 1));
}
} else {
// complex query
// implode(tags) can be too long for memcache...
$cache_key = "image-count:" . md5(Tag::implode($tags));
$total = $cache->get($cache_key);
if (is_null($total)) {
if (Extension::is_enabled(RatingsInfo::KEY)) {
$tags[] = "rating:*";
}
$querylet = Image::build_search_querylet($tags);
$total = (int)$database->get_one("SELECT COUNT(*) AS cnt FROM ($querylet->sql) AS tbl", $querylet->variables);
if (SPEED_HAX && $total > 5000) {
// when we have a ton of images, the count
// won't change dramatically very often
$cache->set($cache_key, $total, 3600);
}
}
}
if (is_null($total)) {
return 0;
}
return $total;
}
/**
* Count the number of pages for a given search
*
* @param String[] $tags
*/
public static function count_pages(array $tags=[]): int
{
global $config;
return (int)ceil(Image::count_images($tags) / $config->get_int(IndexConfig::IMAGES));
}
/*
* Accessors & mutators
*/
@ -218,9 +282,9 @@ class Image implements \ArrayAccess
* Rather than simply $this_id + 1, one must take into account
* deleted images and search queries
*
* @param string[] $tags
* @param String[] $tags
*/
public function get_next(array $tags = [], bool $next = true): ?Image
public function get_next(array $tags=[], bool $next=true): ?Image
{
global $database;
@ -232,18 +296,31 @@ class Image implements \ArrayAccess
$dir = "ASC";
}
$tags[] = 'id'. $gtlt . $this->id;
$tags[] = 'order:id_'. strtolower($dir);
$images = Search::find_images(0, 1, $tags);
return (count($images) > 0) ? $images[0] : null;
if (count($tags) === 0) {
$row = $database->get_row('
SELECT images.*
FROM images
WHERE images.id '.$gtlt.' '.$this->id.'
ORDER BY images.id '.$dir.'
LIMIT 1
');
} else {
$tags[] = 'id'. $gtlt . $this->id;
$tags[] = 'order:id_'. strtolower($dir);
$querylet = Image::build_search_querylet($tags);
$querylet->append_sql(' LIMIT 1');
$row = $database->get_row($querylet->sql, $querylet->variables);
}
return ($row ? new Image($row) : null);
}
/**
* The reverse of get_next
*
* @param string[] $tags
* @param String[] $tags
*/
public function get_prev(array $tags = []): ?Image
public function get_prev(array $tags=[]): ?Image
{
return $this->get_next($tags, false);
}
@ -254,9 +331,7 @@ class Image implements \ArrayAccess
#[Field(name: "owner")]
public function get_owner(): User
{
$user = User::by_id($this->owner_id);
assert(!is_null($user));
return $user;
return User::by_id($this->owner_id);
}
/**
@ -267,73 +342,92 @@ class Image implements \ArrayAccess
global $database;
if ($owner->id != $this->owner_id) {
$database->execute("
UPDATE images
SET owner_id=:owner_id
WHERE id=:id
", ["owner_id" => $owner->id, "id" => $this->id]);
UPDATE images
SET owner_id=:owner_id
WHERE id=:id
", ["owner_id"=>$owner->id, "id"=>$this->id]);
log_info("core_image", "Owner for Post #{$this->id} set to {$owner->name}");
}
}
public function save_to_db(): void
public function save_to_db()
{
global $database, $user;
$cut_name = substr($this->filename, 0, 255);
$props_to_save = [
"filename" => substr($this->filename, 0, 255),
"filesize" => $this->filesize,
"hash" => $this->hash,
"mime" => strtolower($this->mime),
"ext" => strtolower($this->ext),
"source" => $this->source,
"width" => $this->width,
"height" => $this->height,
"lossless" => $this->lossless,
"video" => $this->video,
"video_codec" => $this->video_codec,
"image" => $this->image,
"audio" => $this->audio,
"length" => $this->length
];
if (!$this->in_db) {
$props_to_save["owner_id"] = $user->id;
$props_to_save["owner_ip"] = get_real_ip();
$props_to_save["posted"] = date('Y-m-d H:i:s', time());
$props_sql = implode(", ", array_keys($props_to_save));
$vals_sql = implode(", ", array_map(fn ($prop) => ":$prop", array_keys($props_to_save)));
if (is_null($this->posted) || $this->posted == "") {
$this->posted = date('Y-m-d H:i:s', time());
}
if (is_null($this->id)) {
$database->execute(
"INSERT INTO images($props_sql) VALUES ($vals_sql)",
$props_to_save,
"INSERT INTO images(
owner_id, owner_ip,
filename, filesize,
hash, mime, ext,
width, height,
posted, source
)
VALUES (
:owner_id, :owner_ip,
:filename, :filesize,
:hash, :mime, :ext,
0, 0,
:posted, :source
)",
[
"owner_id" => $user->id, "owner_ip" => get_real_ip(),
"filename" => $cut_name, "filesize" => $this->filesize,
"hash" => $this->hash, "mime" => strtolower($this->mime),
"ext" => strtolower($this->ext),
"posted" => $this->posted, "source" => $this->source
]
);
$this->id = $database->get_last_insert_id('images_id_seq');
$this->in_db = true;
} else {
$props_sql = implode(", ", array_map(fn ($prop) => "$prop = :$prop", array_keys($props_to_save)));
$database->execute(
"UPDATE images SET $props_sql WHERE id = :id",
array_merge(
$props_to_save,
["id" => $this->id]
)
"UPDATE images SET ".
"filename = :filename, filesize = :filesize, hash = :hash, ".
"mime = :mime, ext = :ext, width = 0, height = 0, ".
"posted = :posted, source = :source ".
"WHERE id = :id",
[
"filename" => $cut_name,
"filesize" => $this->filesize,
"hash" => $this->hash,
"mime" => strtolower($this->mime),
"ext" => strtolower($this->ext),
"posted" => $this->posted,
"source" => $this->source,
"id" => $this->id,
]
);
}
// For the future: automatically save dynamic props instead of
// requiring each extension to do it manually.
/*
$props_sql = "UPDATE images SET ";
$props_sql .= implode(", ", array_map(fn ($prop) => "$prop = :$prop", array_keys($this->dynamic_props)));
$props_sql .= " WHERE id = :id";
$database->execute($props_sql, array_merge($this->dynamic_props, ["id" => $this->id]));
*/
$database->execute(
"UPDATE images SET ".
"lossless = :lossless, ".
"video = :video, video_codec = :video_codec, audio = :audio,image = :image, ".
"height = :height, width = :width, ".
"length = :length WHERE id = :id",
[
"id" => $this->id,
"width" => $this->width ?? 0,
"height" => $this->height ?? 0,
"lossless" => $this->lossless,
"video" => $this->video,
"video_codec" => $this->video_codec,
"image" => $this->image,
"audio" => $this->audio,
"length" => $this->length
]
);
}
/**
* Get this image's tags as an array.
*
* @return string[]
* @return String[]
*/
#[Field(name: "tags", type: "[string!]!")]
public function get_tag_array(): array
@ -341,12 +435,12 @@ class Image implements \ArrayAccess
global $database;
if (!isset($this->tag_array)) {
$this->tag_array = $database->get_col("
SELECT tag
FROM image_tags
JOIN tags ON image_tags.tag_id = tags.id
WHERE image_id=:id
ORDER BY tag
", ["id" => $this->id]);
SELECT tag
FROM image_tags
JOIN tags ON image_tags.tag_id = tags.id
WHERE image_id=:id
ORDER BY tag
", ["id"=>$this->id]);
sort($this->tag_array);
}
return $this->tag_array;
@ -440,9 +534,6 @@ class Image implements \ArrayAccess
*/
public function get_image_filename(): string
{
if(!is_null($this->tmp_file)) {
return $this->tmp_file;
}
return warehouse_path(self::IMAGE_DIR, $this->hash);
}
@ -476,18 +567,22 @@ class Image implements \ArrayAccess
* Get the image's mime type.
*/
#[Field(name: "mime")]
public function get_mime(): string
public function get_mime(): ?string
{
if ($this->mime === MimeType::WEBP && $this->lossless) {
if ($this->mime===MimeType::WEBP&&$this->lossless) {
return MimeType::WEBP_LOSSLESS;
}
return strtolower($this->mime);
$m = $this->mime;
if (is_null($m)) {
$m = MimeMap::get_for_extension($this->ext)[0];
}
return $m;
}
/**
* Set the image's mime type.
*/
public function set_mime(string $mime): void
public function set_mime($mime): void
{
$this->mime = $mime;
$ext = FileExtension::get_for_mime($this->get_mime());
@ -515,7 +610,7 @@ class Image implements \ArrayAccess
$new_source = null;
}
if ($new_source != $old_source) {
$database->execute("UPDATE images SET source=:source WHERE id=:id", ["source" => $new_source, "id" => $this->id]);
$database->execute("UPDATE images SET source=:source WHERE id=:id", ["source"=>$new_source, "id"=>$this->id]);
log_info("core_image", "Source for Post #{$this->id} set to: $new_source (was $old_source)");
}
}
@ -532,9 +627,8 @@ class Image implements \ArrayAccess
{
global $database;
if ($locked !== $this->locked) {
$database->execute("UPDATE images SET locked=:yn WHERE id=:id", ["yn" => $locked, "id" => $this->id]);
$s = $locked ? "locked" : "unlocked";
log_info("core_image", "Setting Post #{$this->id} to $s");
$database->execute("UPDATE images SET locked=:yn WHERE id=:id", ["yn"=>$locked, "id"=>$this->id]);
log_info("core_image", "Setting Post #{$this->id} lock to: $locked");
}
}
@ -554,38 +648,39 @@ class Image implements \ArrayAccess
FROM image_tags
WHERE image_id = :id
)
", ["id" => $this->id]);
", ["id"=>$this->id]);
$database->execute("
DELETE
FROM image_tags
WHERE image_id=:id
", ["id" => $this->id]);
DELETE
FROM image_tags
WHERE image_id=:id
", ["id"=>$this->id]);
}
/**
* Set the tags for this image.
*
* @param string[] $unfiltered_tags
*/
public function set_tags(array $unfiltered_tags): void
{
global $cache, $database, $page;
$tags = array_unique($unfiltered_tags);
$unfiltered_tags = array_unique($unfiltered_tags);
foreach ($tags as $tag) {
$tags = [];
foreach ($unfiltered_tags as $tag) {
if (mb_strlen($tag, 'UTF-8') > 255) {
throw new TagSetException("Can't set a tag longer than 255 characters");
$page->flash("Can't set a tag longer than 255 characters");
continue;
}
if (str_starts_with($tag, "-")) {
throw new TagSetException("Can't set a tag which starts with a minus");
}
if (str_contains($tag, "*")) {
throw new TagSetException("Can't set a tag which contains a wildcard (*)");
$page->flash("Can't set a tag which starts with a minus");
continue;
}
$tags[] = $tag;
}
if (count($tags) <= 0) {
throw new TagSetException('Tried to set zero tags');
throw new SCoreException('Tried to set zero tags');
}
if (strtolower(Tag::implode($tags)) != strtolower($this->get_tag_list())) {
@ -604,7 +699,7 @@ class Image implements \ArrayAccess
FROM image_tags
WHERE image_id = :id
)
", ["id" => $this->id]);
", ["id"=>$this->id]);
log_info("core_image", "Tags for Post #{$this->id} set to: ".Tag::implode($tags));
$cache->delete("image-{$this->id}-tags");
@ -618,34 +713,247 @@ class Image implements \ArrayAccess
{
global $database;
$this->delete_tags_from_image();
$database->execute("DELETE FROM images WHERE id=:id", ["id" => $this->id]);
$database->execute("DELETE FROM images WHERE id=:id", ["id"=>$this->id]);
log_info("core_image", 'Deleted Post #'.$this->id.' ('.$this->hash.')');
$this->remove_image_only(quiet: true);
unlink($this->get_image_filename());
unlink($this->get_thumb_filename());
}
/**
* This function removes an image (and thumbnail) from the DISK ONLY.
* It DOES NOT remove anything from the database.
*/
public function remove_image_only(bool $quiet = false): void
public function remove_image_only(): void
{
$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}");
}
log_info("core_image", 'Removed Post File ('.$this->hash.')');
@unlink($this->get_image_filename());
@unlink($this->get_thumb_filename());
}
public function parse_link_template(string $tmpl, int $n = 0): string
public function parse_link_template(string $tmpl, int $n=0): string
{
$plte = send_event(new ParseLinkTemplateEvent($tmpl, $this));
$tmpl = $plte->link;
return load_balance_url($tmpl, $this->hash, $n);
}
private static function tag_or_wildcard_to_ids(string $tag): array
{
global $database;
$sq = "SELECT id FROM tags WHERE LOWER(tag) LIKE LOWER(:tag)";
if ($database->get_driver_id() === DatabaseDriverID::SQLITE) {
$sq .= "ESCAPE '\\'";
}
return $database->get_col($sq, ["tag" => Tag::sqlify($tag)]);
}
/**
* @param String[] $terms
*/
private static function build_search_querylet(
array $terms,
?int $limit=null,
?int $offset=null
): Querylet {
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));
/*
* Turn a bunch of Querylet objects into a base query
*
* Must follow the format
*
* SELECT images.*
* FROM (...) AS images
* WHERE (...)
*
* ie, return a set of images.* columns, and end with a WHERE
*/
// no tags, do a simple search
if (count($tag_conditions) === 0) {
$query = new Querylet("SELECT images.* FROM images WHERE 1=1");
}
// one tag sorted by ID - we can fetch this from the image_tags table,
// and do the offset / limit there, which is 10x faster than fetching
// all the image_tags and doing the offset / limit on the result.
elseif (
count($tag_conditions) === 1
&& empty($img_conditions)
&& ($order == "id DESC" || $order == "images.id DESC")
&& !is_null($offset)
&& !is_null($limit)
) {
$tc = $tag_conditions[0];
$in = $tc->positive ? "IN" : "NOT IN";
// IN (SELECT id FROM tags) is 100x slower than doing a separate
// query and then a second query for IN(first_query_results)??
$tag_array = self::tag_or_wildcard_to_ids($tc->tag);
if (count($tag_array) == 0) {
// if wildcard expanded to nothing, take a shortcut
if ($tc->positive) {
$query = new Querylet("SELECT images.* FROM images WHERE 1=0");
} else {
$query = new Querylet("SELECT images.* FROM images WHERE 1=1");
}
} else {
$set = implode(', ', $tag_array);
$query = new Querylet("
SELECT images.*
FROM images INNER JOIN (
SELECT it.image_id
FROM image_tags it
WHERE it.tag_id $in ($set)
ORDER BY it.image_id DESC
LIMIT :limit OFFSET :offset
) a on a.image_id = images.id
ORDER BY images.id DESC
", ["limit"=>$limit, "offset"=>$offset]);
// don't offset at the image level because
// we already offset at the image_tags level
$order = null;
$limit = null;
$offset = null;
}
}
// more than one tag, or more than zero other conditions, or a non-default sort order
else {
$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
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['q']);
if ($all_nonexistent_negatives) {
$query = new Querylet("SELECT images.* FROM images WHERE 1=1");
} elseif (!empty($positive_tag_id_array) || !empty($positive_wildcard_id_array)) {
$inner_joins = [];
if (!empty($positive_tag_id_array)) {
foreach ($positive_tag_id_array as $tag) {
$inner_joins[] = "= $tag";
}
}
if (!empty($positive_wildcard_id_array)) {
foreach ($positive_wildcard_id_array as $tags) {
$positive_tag_id_list = join(', ', $tags);
$inner_joins[] = "IN ($positive_tag_id_list)";
}
}
$first = array_shift($inner_joins);
$sub_query = "SELECT it.image_id FROM image_tags it ";
$i = 0;
foreach ($inner_joins as $inner_join) {
$i++;
$sub_query .= " INNER JOIN image_tags it$i ON it$i.image_id = it.image_id AND it$i.tag_id $inner_join ";
}
if (!empty($negative_tag_id_array)) {
$negative_tag_id_list = join(', ', $negative_tag_id_array);
$sub_query .= " LEFT JOIN image_tags negative ON negative.image_id = it.image_id AND negative.tag_id IN ($negative_tag_id_list) ";
}
$sub_query .= "WHERE it.tag_id $first ";
if (!empty($negative_tag_id_array)) {
$sub_query .= " AND negative.image_id IS NULL";
}
$sub_query .= " GROUP BY it.image_id ";
$query = new Querylet("
SELECT images.*
FROM images
INNER JOIN ($sub_query) a on a.image_id = images.id
");
} elseif (!empty($negative_tag_id_array)) {
$negative_tag_id_list = join(', ', $negative_tag_id_array);
$query = new Querylet("
SELECT images.*
FROM images
LEFT JOIN image_tags negative ON negative.image_id = images.id AND negative.tag_id in ($negative_tag_id_list)
WHERE negative.image_id IS NULL
");
} else {
throw new SCoreException("No criteria specified");
}
}
/*
* Merge all the image metadata searches into one generic querylet
* and append to the base querylet with "AND blah"
*/
if (!empty($img_conditions)) {
$n = 0;
$img_sql = "";
$img_vars = [];
foreach ($img_conditions as $iq) {
if ($n++ > 0) {
$img_sql .= " AND";
}
if (!$iq->positive) {
$img_sql .= " NOT";
}
$img_sql .= " (" . $iq->qlet->sql . ")";
$img_vars = array_merge($img_vars, $iq->qlet->variables);
}
$query->append_sql(" AND ");
$query->append(new Querylet($img_sql, $img_vars));
}
if (!is_null($order)) {
$query->append(new Querylet(" ORDER BY ".$order));
}
if (!is_null($limit)) {
$query->append(new Querylet(" LIMIT :limit ", ["limit" => $limit]));
$query->append(new Querylet(" OFFSET :offset ", ["offset" => $offset]));
}
return $query;
}
}

View File

@ -12,41 +12,42 @@ namespace Shimmie2;
* Add a directory full of images
*
* @param string $base
* @param string[] $extra_tags
* @return UploadResult[]
* @return array
*/
function add_dir(string $base, array $extra_tags = []): array
function add_dir(string $base): array
{
global $database;
$results = [];
foreach (list_files($base) as $full_path) {
$short_path = str_replace($base, "", $full_path);
$filename = basename($full_path);
$tags = array_merge(path_to_tags($short_path), $extra_tags);
$tags = path_to_tags($short_path);
$result = "$short_path (".str_replace(" ", ", ", $tags).")... ";
try {
$more_results = $database->with_savepoint(function () use ($full_path, $filename, $tags) {
$dae = send_event(new DataUploadEvent($full_path, [
'filename' => pathinfo($filename, PATHINFO_BASENAME),
'tags' => $tags,
'source' => null,
]));
$results = [];
foreach($dae->images as $image) {
$results[] = new UploadSuccess($filename, $image->id);
}
return $results;
});
$results = array_merge($results, $more_results);
add_image($full_path, $filename, $tags);
$result .= "ok";
} catch (UploadException $ex) {
$results[] = new UploadError($filename, $ex->getMessage());
$result .= "failed: ".$ex->getMessage();
}
$results[] = $result;
}
return $results;
}
/**
* Sends a DataUploadEvent for a file.
*/
function add_image(string $tmpname, string $filename, string $tags, ?string $source=null): DataUploadEvent
{
return send_event(new DataUploadEvent($tmpname, [
'filename' => pathinfo($filename, PATHINFO_BASENAME),
'tags' => Tag::explode($tags),
'source' => $source,
]));
}
function get_file_ext(string $filename): ?string
{
return pathinfo($filename)['extension'] ?? null;
@ -60,7 +61,7 @@ function get_file_ext(string $filename): ?string
* @param int $orig_width
* @param int $orig_height
* @param bool $use_dpi_scaling Enables the High-DPI scaling.
* @return array{0: int, 1: int}
* @return array
*/
function get_thumbnail_size(int $orig_width, int $orig_height, bool $use_dpi_scaling = false): array
{
@ -108,60 +109,53 @@ function get_thumbnail_size(int $orig_width, int $orig_height, bool $use_dpi_sca
}
}
/**
* @return array{0: int, 1: int, 2: float}
*/
function get_scaled_by_aspect_ratio(int $original_width, int $original_height, int $max_width, int $max_height): array
{
$xscale = ($max_width / $original_width);
$yscale = ($max_height / $original_height);
$xscale = ($max_width/ $original_width);
$yscale = ($max_height/ $original_height);
$scale = ($yscale < $xscale) ? $yscale : $xscale ;
return [(int)($original_width * $scale), (int)($original_height * $scale), $scale];
return [(int)($original_width*$scale), (int)($original_height*$scale), $scale];
}
/**
* Fetches the thumbnails height and width settings and applies the High-DPI scaling setting before returning the dimensions.
*
* @return array{0: int, 1: int}
* @return array [width, height]
*/
function get_thumbnail_max_size_scaled(): array
{
global $config;
$scaling = $config->get_int(ImageConfig::THUMB_SCALING);
$max_width = $config->get_int(ImageConfig::THUMB_WIDTH) * ($scaling / 100);
$max_height = $config->get_int(ImageConfig::THUMB_HEIGHT) * ($scaling / 100);
$max_width = $config->get_int(ImageConfig::THUMB_WIDTH) * ($scaling/100);
$max_height = $config->get_int(ImageConfig::THUMB_HEIGHT) * ($scaling/100);
return [$max_width, $max_height];
}
function create_image_thumb(Image $image, string $engine = null): void
function create_image_thumb(string $hash, string $mime, string $engine = null)
{
global $config;
$inname = warehouse_path(Image::IMAGE_DIR, $hash);
$outname = warehouse_path(Image::THUMBNAIL_DIR, $hash);
$tsize = get_thumbnail_max_size_scaled();
create_scaled_image(
$image->get_image_filename(),
$image->get_thumb_filename(),
get_thumbnail_max_size_scaled(),
$image->get_mime(),
$inname,
$outname,
$tsize,
$mime,
$engine,
$config->get_string(ImageConfig::THUMB_FIT)
);
}
/**
* @param array{0: int, 1: int} $tsize
*/
function create_scaled_image(
string $inname,
string $outname,
array $tsize,
string $mime,
?string $engine = null,
?string $resize_type = null
): void {
function create_scaled_image(string $inname, string $outname, array $tsize, string $mime, ?string $engine = null, ?string $resize_type = null)
{
global $config;
if (empty($engine)) {
$engine = $config->get_string(ImageConfig::THUMB_ENGINE);
@ -193,7 +187,7 @@ function redirect_to_next_image(Image $image): void
global $page;
if (isset($_GET['search'])) {
$search_terms = Tag::explode($_GET['search']);
$search_terms = Tag::explode(Tag::decaret($_GET['search']));
$query = "search=" . url_escape($_GET['search']);
} else {
$search_terms = [];
@ -203,7 +197,7 @@ function redirect_to_next_image(Image $image): void
$target_image = $image->get_next($search_terms);
if ($target_image === null) {
$redirect_target = referer_or(search_link(), ['post/view']);
$redirect_target = referer_or(make_link("post/list"), ['post/view']);
} else {
$redirect_target = make_link("post/view/{$target_image->id}", null, $query);
}

View File

@ -4,17 +4,11 @@ declare(strict_types=1);
namespace Shimmie2;
use GQLA\Query;
class Querylet
{
/**
* @param string $sql
* @param array<string, mixed> $variables
*/
public function __construct(
public string $sql,
public array $variables = [],
public array $variables=[],
) {
}
@ -23,13 +17,23 @@ class Querylet
$this->sql .= $querylet->sql;
$this->variables = array_merge($this->variables, $querylet->variables);
}
public function append_sql(string $sql): void
{
$this->sql .= $sql;
}
public function add_variable($var): void
{
$this->variables[] = $var;
}
}
class TagCondition
{
public function __construct(
public string $tag,
public bool $positive = true,
public bool $positive,
) {
}
}
@ -38,391 +42,7 @@ class ImgCondition
{
public function __construct(
public Querylet $qlet,
public bool $positive = true,
public bool $positive,
) {
}
}
class Search
{
/** @var list<string> */
public static array $_search_path = [];
/**
* @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 PermissionDeniedException("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
*
* @param string[] $terms
* @return array{0: TagCondition[], 1: ImgCondition[], 2: string}
*/
private 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
*
* @param TagCondition[] $tag_conditions
* @param ImgCondition[] $img_conditions
*/
private 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['q']);
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 SCoreException("No criteria specified");
}
}
/*
* Merge all the image metadata searches into one generic querylet
* and append to the base querylet with "AND blah"
*/
if (!empty($img_conditions)) {
$n = 0;
$img_sql = "";
$img_vars = [];
foreach ($img_conditions as $iq) {
if ($n++ > 0) {
$img_sql .= " AND";
}
if (!$iq->positive) {
$img_sql .= " NOT";
}
$img_sql .= " (" . $iq->qlet->sql . ")";
$img_vars = array_merge($img_vars, $iq->qlet->variables);
}
$query->append(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

@ -26,10 +26,14 @@ class TagUsage
* @return TagUsage[]
*/
#[Query(name: "tags", type: '[TagUsage!]!')]
public static function tags(string $search, int $limit = 10): array
public static function tags(string $search, int $limit=10): array
{
global $cache, $database;
if (!$search) {
return [];
}
$search = strtolower($search);
if (
$search == '' ||
@ -44,16 +48,16 @@ class TagUsage
$limitSQL = "";
$search = str_replace('_', '\_', $search);
$search = str_replace('%', '\%', $search);
$SQLarr = ["search" => "$search%"]; #, "cat_search"=>"%:$search%"];
$SQLarr = ["search"=>"$search%"]; #, "cat_search"=>"%:$search%"];
if ($limit !== 0) {
$limitSQL = "LIMIT :limit";
$SQLarr['limit'] = $limit;
$cache_key .= "-" . $limit;
}
$res = cache_get_or_set(
$cache_key,
fn () => $database->get_pairs(
$res = $cache->get($cache_key);
if (is_null($res)) {
$res = $database->get_pairs(
"
SELECT tag, count
FROM tags
@ -64,9 +68,9 @@ class TagUsage
$limitSQL
",
$SQLarr
),
600
);
);
$cache->set($cache_key, $res, 600);
}
$counts = [];
foreach ($res as $k => $v) {
@ -86,8 +90,7 @@ class TagUsage
*/
class Tag
{
/** @var array<string, int> */
private static array $tag_id_cache = [];
private static $tag_id_cache = [];
public static function get_or_create_id(string $tag): int
{
@ -101,17 +104,17 @@ class Tag
$id = $database->get_one(
"SELECT id FROM tags WHERE LOWER(tag) = LOWER(:tag)",
["tag" => $tag]
["tag"=>$tag]
);
if (empty($id)) {
// a new tag
$database->execute(
"INSERT INTO tags(tag) VALUES (:tag)",
["tag" => $tag]
["tag"=>$tag]
);
$id = $database->get_one(
"SELECT id FROM tags WHERE LOWER(tag) = LOWER(:tag)",
["tag" => $tag]
["tag"=>$tag]
);
}
@ -119,19 +122,18 @@ class Tag
return $id;
}
/** @param string[] $tags */
public static function implode(array $tags): string
{
sort($tags, SORT_FLAG_CASE | SORT_STRING);
sort($tags, SORT_FLAG_CASE|SORT_STRING);
return implode(' ', $tags);
}
/**
* Turn a human-supplied string into a valid tag array.
*
* @return string[]
* #return string[]
*/
public static function explode(string $tags, bool $tagme = true): array
public static function explode(string $tags, bool $tagme=true): array
{
global $database;
@ -149,7 +151,7 @@ class Tag
$new = [];
$i = 0;
$tag_count = count($tag_array);
while ($i < $tag_count) {
while ($i<$tag_count) {
$tag = $tag_array[$i];
$negative = '';
if (!empty($tag) && ($tag[0] == '-')) {
@ -163,7 +165,7 @@ class Tag
FROM aliases
WHERE LOWER(oldtag)=LOWER(:tag)
",
["tag" => $tag]
["tag"=>$tag]
);
if (empty($newtags)) {
//tag has no alias, use old tag
@ -197,13 +199,9 @@ class Tag
public static function sanitize(string $tag): string
{
$tag = preg_replace("/\s/", "", $tag); # whitespace
assert($tag !== null);
$tag = preg_replace('/\x20[\x0e\x0f]/', '', $tag); # unicode RTL
assert($tag !== null);
$tag = preg_replace("/\.+/", ".", $tag); # strings of dots?
assert($tag !== null);
$tag = preg_replace("/^(\.+[\/\\\\])+/", "", $tag); # trailing slashes?
assert($tag !== null);
$tag = trim($tag, ", \t\n\r\0\x0B");
if ($tag == ".") {
@ -216,13 +214,9 @@ class Tag
return $tag;
}
/**
* @param string[] $tags1
* @param string[] $tags2
*/
public static function compare(array $tags1, array $tags2): bool
{
if (count($tags1) !== count($tags2)) {
if (count($tags1)!==count($tags2)) {
return false;
}
@ -234,11 +228,6 @@ class Tag
return $tags1 == $tags2;
}
/**
* @param string[] $source
* @param string[] $remove
* @return string[]
*/
public static function get_diff_tags(array $source, array $remove): array
{
$before = array_map('strtolower', $source);
@ -252,10 +241,6 @@ class Tag
return $after;
}
/**
* @param string[] $tags
* @return string[]
*/
public static function sanitize_array(array $tags): array
{
global $page;
@ -287,4 +272,53 @@ class Tag
// $term = str_replace("?", "_", $term);
return $term;
}
/**
* Kind of like urlencode, but using a custom scheme so that
* tags always fit neatly between slashes in a URL. Use this
* when you want to put an arbitrary tag into a URL.
*/
public static function caret(string $input): string
{
$to_caret = [
"^" => "^",
"/" => "s",
"\\" => "b",
"?" => "q",
"&" => "a",
"." => "d",
];
foreach ($to_caret as $from => $to) {
$input = str_replace($from, '^' . $to, $input);
}
return $input;
}
/**
* Use this when you want to get a tag out of a URL
*/
public static function decaret(string $str): string
{
$from_caret = [
"^" => "^",
"s" => "/",
"b" => "\\",
"q" => "?",
"a" => "&",
"d" => ".",
];
$out = "";
$length = strlen($str);
for ($i=0; $i<$length; $i++) {
if ($str[$i] == "^") {
$i++;
$out .= $from_caret[$str[$i]] ?? '';
} else {
$out .= $str[$i];
}
}
return $out;
}
}

View File

@ -20,7 +20,7 @@ namespace Shimmie2;
* and other such things that aren't ready yet
*/
function install(): void
function install()
{
date_default_timezone_set('UTC');
@ -46,16 +46,11 @@ function install(): void
if ($dsn) {
do_install($dsn);
} else {
if (PHP_SAPI == 'cli') {
print("INSTALL_DSN needs to be set for CLI installation\n");
exit(1);
} else {
ask_questions();
}
ask_questions();
}
}
function get_dsn(): ?string
function get_dsn()
{
if (getenv("INSTALL_DSN")) {
$dsn = getenv("INSTALL_DSN");
@ -71,7 +66,7 @@ function get_dsn(): ?string
return $dsn;
}
function do_install(string $dsn): void
function do_install($dsn)
{
try {
create_dirs();
@ -82,7 +77,7 @@ function do_install(string $dsn): void
}
}
function ask_questions(): void
function ask_questions()
{
$warnings = [];
$errors = [];
@ -119,9 +114,9 @@ function ask_questions(): void
";
}
$db_s = in_array(DatabaseDriverID::SQLITE->value, $drivers) ? '<option value="'. DatabaseDriverID::SQLITE->value .'">SQLite</option>' : "";
$db_m = in_array(DatabaseDriverID::MYSQL->value, $drivers) ? '<option value="'. DatabaseDriverID::MYSQL->value .'">MySQL</option>' : "";
$db_p = in_array(DatabaseDriverID::PGSQL->value, $drivers) ? '<option value="'. DatabaseDriverID::PGSQL->value .'">PostgreSQL</option>' : "";
$db_s = in_array(DatabaseDriverID::SQLITE->value, $drivers) ? '<option value="'. DatabaseDriverID::SQLITE->value .'">SQLite</option>' : "";
$warn_msg = $warnings ? "<h3>Warnings</h3>".implode("\n<p>", $warnings) : "";
$err_msg = $errors ? "<h3>Errors</h3>".implode("\n<p>", $errors) : "";
@ -137,9 +132,9 @@ function ask_questions(): void
<tr>
<th>Type:</th>
<td><select name="database_type" id="database_type" onchange="update_qs();">
$db_s
$db_m
$db_m
$db_p
$db_s
</select></td>
</tr>
<tr class="dbconf mysql pgsql">
@ -166,9 +161,13 @@ function ask_questions(): void
return document.querySelectorAll(n);
}
function update_qs() {
q('.dbconf').forEach(el => el.style.display = 'none');
Array.prototype.forEach.call(q('.dbconf'), function(el, i){
el.style.display = 'none';
});
let seldb = q("#database_type")[0].value || "none";
q('.'+seldb).forEach(el => el.style.display = null);
Array.prototype.forEach.call(q('.'+seldb), function(el, i){
el.style.display = null;
});
}
</script>
</form>
@ -179,9 +178,8 @@ function ask_questions(): void
The username provided must have access to create tables within the database.
</p>
<p class="dbconf sqlite">
SQLite with default settings is fine for tens of users with thousands
of images. For thousands of users or millions of images, postgres is
recommended.
For SQLite the database name will be a filename on disk, relative to
where shimmie was installed.
</p>
<p class="dbconf none">
Drivers can generally be downloaded with your OS package manager;
@ -192,7 +190,7 @@ EOD
}
function create_dirs(): void
function create_dirs()
{
$data_exists = file_exists("data") || mkdir("data");
$data_writable = $data_exists && (is_writable("data") || chmod("data", 0755));
@ -209,7 +207,7 @@ function create_dirs(): void
}
}
function create_tables(Database $db): void
function create_tables(Database $db)
{
try {
if ($db->count_tables() > 0) {
@ -294,8 +292,6 @@ function create_tables(Database $db): void
if ($db->is_transaction_open()) {
$db->commit();
}
// Ensure that we end this code in a transaction (for testing)
$db->begin_transaction();
} catch (\PDOException $e) {
throw new InstallerException(
"PDO Error:",
@ -308,7 +304,7 @@ function create_tables(Database $db): void
}
}
function write_config(string $dsn): void
function write_config($dsn)
{
$file_content = "<" . "?php\ndefine('DATABASE_DSN', '$dsn');\n";
@ -317,16 +313,11 @@ function write_config(string $dsn): void
}
if (file_put_contents("data/config/shimmie.conf.php", $file_content, LOCK_EX)) {
if (PHP_SAPI == 'cli') {
print("Installation Successful\n");
exit(0);
} else {
header("Location: index.php?flash=Installation%20complete");
die_nicely(
"Installation Successful",
"<p>If you aren't redirected, <a href=\"index.php\">click here to Continue</a>."
);
}
header("Location: index.php?flash=Installation%20complete");
die_nicely(
"Installation Successful",
"<p>If you aren't redirected, <a href=\"index.php\">click here to Continue</a>."
);
} else {
$h_file_content = htmlentities($file_content);
throw new InstallerException(

View File

@ -16,12 +16,12 @@ define("SCORE_LOG_DEBUG", 10);
define("SCORE_LOG_NOTSET", 0);
const LOGGING_LEVEL_NAMES = [
SCORE_LOG_NOTSET => "Not Set",
SCORE_LOG_DEBUG => "Debug",
SCORE_LOG_INFO => "Info",
SCORE_LOG_WARNING => "Warning",
SCORE_LOG_ERROR => "Error",
SCORE_LOG_CRITICAL => "Critical",
SCORE_LOG_NOTSET=>"Not Set",
SCORE_LOG_DEBUG=>"Debug",
SCORE_LOG_INFO=>"Info",
SCORE_LOG_WARNING=>"Warning",
SCORE_LOG_ERROR=>"Error",
SCORE_LOG_CRITICAL=>"Critical",
];
/**
@ -31,7 +31,7 @@ const LOGGING_LEVEL_NAMES = [
* When taking action, a log event should be stored by the server
* Quite often, both of these happen at once, hence log_*() having $flash
*/
function log_msg(string $section, int $priority, string $message, ?string $flash = null): void
function log_msg(string $section, int $priority, string $message, ?string $flash=null)
{
global $page;
send_event(new LogEvent($section, $priority, $message));
@ -47,23 +47,23 @@ function log_msg(string $section, int $priority, string $message, ?string $flash
}
// More shorthand ways of logging
function log_debug(string $section, string $message, ?string $flash = null): void
function log_debug(string $section, string $message, ?string $flash=null)
{
log_msg($section, SCORE_LOG_DEBUG, $message, $flash);
}
function log_info(string $section, string $message, ?string $flash = null): void
function log_info(string $section, string $message, ?string $flash=null)
{
log_msg($section, SCORE_LOG_INFO, $message, $flash);
}
function log_warning(string $section, string $message, ?string $flash = null): void
function log_warning(string $section, string $message, ?string $flash=null)
{
log_msg($section, SCORE_LOG_WARNING, $message, $flash);
}
function log_error(string $section, string $message, ?string $flash = null): void
function log_error(string $section, string $message, ?string $flash=null)
{
log_msg($section, SCORE_LOG_ERROR, $message, $flash);
}
function log_critical(string $section, string $message, ?string $flash = null): void
function log_critical(string $section, string $message, ?string $flash=null)
{
log_msg($section, SCORE_LOG_CRITICAL, $message, $flash);
}

View File

@ -6,7 +6,7 @@ namespace Shimmie2;
use MicroHTML\HTMLElement;
use function MicroHTML\{emptyHTML};
use function MicroHTML\emptyHTML;
use function MicroHTML\A;
use function MicroHTML\FORM;
use function MicroHTML\INPUT;
@ -15,16 +15,20 @@ use function MicroHTML\OPTION;
use function MicroHTML\PRE;
use function MicroHTML\P;
use function MicroHTML\SELECT;
use function MicroHTML\SPAN;
use function MicroHTML\{TABLE,THEAD,TFOOT,TR,TH,TD};
use function MicroHTML\TABLE;
use function MicroHTML\THEAD;
use function MicroHTML\TFOOT;
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, string $method="POST", 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"=>$method
];
if ($form_id) {
@ -41,35 +45,26 @@ function SHM_FORM(string $target, string $method = "POST", bool $multipart = fal
}
return FORM(
$attrs,
INPUT(["type" => "hidden", "name" => "q", "value" => $target]),
INPUT(["type"=>"hidden", "name"=>"q", "value"=>$target]),
$method == "GET" ? "" : $user->get_auth_microhtml()
);
}
/**
* @param array<string|HTMLElement|null> $children
*/
function SHM_SIMPLE_FORM(string $target, ...$children): HTMLElement
function SHM_SIMPLE_FORM($target, ...$children): HTMLElement
{
$form = SHM_FORM($target);
$form->appendChild(emptyHTML(...$children));
return $form;
}
/**
* @param array<string, mixed> $args
*/
function SHM_SUBMIT(string $text, array $args = []): HTMLElement
function SHM_SUBMIT(string $text, array $args=[]): HTMLElement
{
$args["type"] = "submit";
$args["value"] = $text;
return INPUT($args);
}
/**
* @param array<string, mixed> $args
*/
function SHM_A(string $href, string|HTMLElement $text, string $id = "", string $class = "", array $args = []): HTMLElement
function SHM_A(string $href, string|HTMLElement $text, string $id="", string $class="", array $args=[]): HTMLElement
{
$args["href"] = make_link($href);
@ -86,24 +81,24 @@ function SHM_A(string $href, string|HTMLElement $text, string $id = "", string $
function SHM_COMMAND_EXAMPLE(string $ex, string $desc): HTMLElement
{
return DIV(
["class" => "command_example"],
["class"=>"command_example"],
PRE($ex),
P($desc)
);
}
function SHM_USER_FORM(User $duser, string $target, string $title, HTMLElement $body, HTMLElement|string $foot): HTMLElement
function SHM_USER_FORM(User $duser, string $target, string $title, $body, $foot): HTMLElement
{
if (is_string($foot)) {
$foot = TFOOT(TR(TD(["colspan" => "2"], INPUT(["type" => "submit", "value" => $foot]))));
$foot = TFOOT(TR(TD(["colspan"=>"2"], INPUT(["type"=>"submit", "value"=>$foot]))));
}
return SHM_SIMPLE_FORM(
$target,
P(
INPUT(["type" => 'hidden', "name" => 'id', "value" => $duser->id]),
INPUT(["type"=>'hidden', "name"=>'id', "value"=>$duser->id]),
TABLE(
["class" => "form"],
THEAD(TR(TH(["colspan" => "2"], $title))),
["class"=>"form"],
THEAD(TR(TH(["colspan"=>"2"], $title))),
$body,
$foot
)
@ -115,14 +110,14 @@ function SHM_USER_FORM(User $duser, string $target, string $title, HTMLElement $
* Generates a <select> element and sets up the given options.
*
* @param string $name The name attribute of <select>.
* @param array<string|int, string> $options An array of pairs of parameters for <option> tags. First one is value, second one is text. Example: ('optionA', 'Choose Option A').
* @param array<string> $selected_options The values of options that should be pre-selected.
* @param array $options An array of pairs of parameters for <option> tags. First one is value, second one is text. Example: ('optionA', 'Choose Option A').
* @param array $selected_options The values of options that should be pre-selected.
* @param bool $required Wether the <select> element is required.
* @param bool $multiple Wether the <select> element is multiple-choice.
* @param bool $empty_option Whether the first option should be an empty one.
* @param array<string, mixed> $attrs Additional attributes dict for <select>. Example: ["id"=>"some_id", "class"=>"some_class"].
* @param array $attrs Additional attributes dict for <select>. Example: ["id"=>"some_id", "class"=>"some_class"].
*/
function SHM_SELECT(string $name, array $options, array $selected_options = [], bool $required = false, bool $multiple = false, bool $empty_option = false, array $attrs = []): HTMLElement
function SHM_SELECT(string $name, array $options, array $selected_options=[], bool $required=false, bool $multiple=false, bool $empty_option=false, array $attrs=[]): HTMLElement
{
if ($required) {
$attrs["required"] = "";
@ -148,36 +143,11 @@ function SHM_SELECT(string $name, array $options, array $selected_options = [],
return SELECT($attrs, ...$_options);
}
function SHM_OPTION(string $value, string $text, bool $selected = false): HTMLElement
function SHM_OPTION(string $value, string $text, bool $selected=false): HTMLElement
{
if ($selected) {
return OPTION(["value" => $value, "selected" => ""], $text);
return OPTION(["value"=>$value, "selected"=>""], $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)
);
return OPTION(["value"=>$value], $text);
}

View File

@ -10,9 +10,6 @@ namespace Shimmie2;
/**
* Return the unique elements of an array, case insensitively
*
* @param array<string> $array
* @return list<string>
*/
function array_iunique(array $array): array
{
@ -39,11 +36,7 @@ function array_iunique(array $array): array
*/
function ip_in_range(string $IP, string $CIDR): bool
{
$parts = explode("/", $CIDR);
if(count($parts) == 1) {
$parts[1] = "32";
}
list($net, $mask) = $parts;
list($net, $mask) = explode("/", $CIDR);
$ip_net = ip2long($net);
$ip_mask = ~((1 << (32 - (int)$mask)) - 1);
@ -57,16 +50,42 @@ function ip_in_range(string $IP, string $CIDR): bool
/**
* Delete an entire file heirachy
*
* from a patch by Christian Walde; only intended for use in the
* "extension manager" extension, but it seems to fit better here
*/
function deltree(string $dir): void
function deltree(string $f): void
{
$di = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::KEY_AS_PATHNAME);
$ri = new \RecursiveIteratorIterator($di, \RecursiveIteratorIterator::CHILD_FIRST);
/** @var \SplFileInfo $file */
foreach ($ri as $filename => $file) {
$file->isDir() ? rmdir($filename) : unlink($filename);
//Because Windows (I know, bad excuse)
if (PHP_OS === 'WINNT') {
$real = realpath($f);
$path = realpath('./').'\\'.str_replace('/', '\\', $f);
if ($path != $real) {
rmdir($path);
} else {
foreach (glob($f.'/*') as $sf) {
if (is_dir($sf) && !is_link($sf)) {
deltree($sf);
} else {
unlink($sf);
}
}
rmdir($f);
}
} else {
if (is_link($f)) {
unlink($f);
} elseif (is_dir($f)) {
foreach (glob($f.'/*') as $sf) {
if (is_dir($sf) && !is_link($sf)) {
deltree($sf);
} else {
unlink($sf);
}
}
rmdir($f);
}
}
rmdir($dir);
}
/**
@ -79,13 +98,9 @@ function full_copy(string $source, string $target): void
if (is_dir($source)) {
@mkdir($target);
$d = dir_ex($source);
$d = dir($source);
while (true) {
$entry = $d->read();
if ($entry === false) {
break;
}
while (false !== ($entry = $d->read())) {
if ($entry == '.' || $entry == '..') {
continue;
}
@ -105,10 +120,8 @@ function full_copy(string $source, string $target): void
/**
* Return a list of all the regular files in a directory and subdirectories
*
* @return string[]
*/
function list_files(string $base, string $_sub_dir = ""): array
function list_files(string $base, string $_sub_dir=""): array
{
assert(is_dir($base));
@ -116,7 +129,7 @@ function list_files(string $base, string $_sub_dir = ""): array
$files = [];
$dir = opendir("$base/$_sub_dir");
if ($dir === false) {
if ($dir===false) {
throw new SCoreException("Unable to open directory $base/$_sub_dir");
}
try {
@ -159,16 +172,12 @@ 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;
while (!feof($fp) && ($p = ftell($fp)) <= $end) {
if ($p + $buffer > $end) {
$buffer = $end - $p + 1;
assert($buffer >= 0);
}
echo fread($fp, $buffer);
flush_output();
@ -188,13 +197,13 @@ function stream_file(string $file, int $start, int $end): void
# http://www.php.net/manual/en/function.http-parse-headers.php#112917
if (!function_exists('http_parse_headers')) {
/**
* @return array<string, string|string[]>
* #return string[]
*/
function http_parse_headers(string $raw_headers): array
{
$headers = [];
$headers = []; // $headers = [];
foreach (explode("\n", $raw_headers) as $h) {
foreach (explode("\n", $raw_headers) as $i => $h) {
$h = explode(':', $h, 2);
if (isset($h[1])) {
@ -216,8 +225,6 @@ if (!function_exists('http_parse_headers')) {
/**
* HTTP Headers can sometimes be lowercase which will cause issues.
* In cases like these, we need to make sure to check for them if the camelcase version does not exist.
*
* @param array<string, mixed> $headers
*/
function find_header(array $headers, string $name): ?string
{
@ -240,22 +247,20 @@ function find_header(array $headers, string $name): ?string
if (!function_exists('mb_strlen')) {
// TODO: we should warn the admin that they are missing multibyte support
/** @noinspection PhpUnusedParameterInspection */
function mb_strlen(string $str, string $encoding): int
function mb_strlen($str, $encoding): int
{
return strlen($str);
}
function mb_internal_encoding(string $encoding): void
function mb_internal_encoding($encoding): void
{
}
function mb_strtolower(string $str): string
function mb_strtolower($str): string
{
return strtolower($str);
}
}
/**
* @return class-string[]
*/
/** @noinspection PhpUnhandledExceptionInspection */
function get_subclasses_of(string $parent): array
{
$result = [];
@ -270,8 +275,6 @@ function get_subclasses_of(string $parent): array
/**
* Like glob, with support for matching very long patterns with braces.
*
* @return string[]
*/
function zglob(string $pattern): array
{
@ -293,6 +296,52 @@ function zglob(string $pattern): array
}
}
/**
* 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.
*/
function get_base_href(): string
{
if (defined("BASE_HREF") && !empty(BASE_HREF)) {
return BASE_HREF;
}
$possible_vars = ['SCRIPT_NAME', 'PHP_SELF', 'PATH_INFO', 'ORIG_PATH_INFO'];
$ok_var = null;
foreach ($possible_vars as $var) {
if (isset($_SERVER[$var]) && substr($_SERVER[$var], -4) === '.php') {
$ok_var = $_SERVER[$var];
break;
}
}
assert(!empty($ok_var));
$dir = dirname($ok_var);
$dir = str_replace("\\", "/", $dir);
$dir = str_replace("//", "/", $dir);
$dir = rtrim($dir, "/");
return $dir;
}
/**
* The opposite of the standard library's parse_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";
}
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\
* Input / Output Sanitising *
@ -347,7 +396,7 @@ function url_escape(?string $input): string
/**
* Turn all manner of HTML / INI / JS / DB booleans into a PHP one
*/
function bool_escape(mixed $input): bool
function bool_escape($input): bool
{
/*
Sometimes, I don't like PHP -- this, is one of those times...
@ -390,7 +439,7 @@ function no_escape(string $input): string
* Given a 1-indexed numeric-ish thing, return a zero-indexed
* number between 0 and $max
*/
function page_number(string $input, ?int $max = null): int
function page_number(string $input, ?int $max=null): int
{
if (!is_numeric($input)) {
$pageNumber = 0;
@ -401,23 +450,10 @@ function page_number(string $input, ?int $max = null): int
} else {
$pageNumber = $input - 1;
}
return (int)$pageNumber;
return $pageNumber;
}
function is_numberish(string $s): bool
{
return is_numeric($s);
}
/**
* Because apparently phpstan thinks that if $i is an int, type(-$i) == int|float
*/
function negative_int(int $i): int
{
return -$i;
}
function clamp(int $val, ?int $min = null, ?int $max = null): int
function clamp(int $val, ?int $min=null, ?int $max=null): int
{
if (!is_null($min) && $val < $min) {
$val = $min;
@ -435,7 +471,7 @@ function clamp(int $val, ?int $min = null, ?int $max = null): int
* 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) {
@ -492,17 +528,17 @@ function to_shorthand_int(int $int): string
{
assert($int >= 0);
return match (true) {
$int >= pow(1024, 4) * 10 => sprintf("%.0fTB", $int / pow(1024, 4)),
$int >= pow(1024, 4) => sprintf("%.1fTB", $int / pow(1024, 4)),
$int >= pow(1024, 3) * 10 => sprintf("%.0fGB", $int / pow(1024, 3)),
$int >= pow(1024, 3) => sprintf("%.1fGB", $int / pow(1024, 3)),
$int >= pow(1024, 2) * 10 => sprintf("%.0fMB", $int / pow(1024, 2)),
$int >= pow(1024, 2) => sprintf("%.1fMB", $int / pow(1024, 2)),
$int >= pow(1024, 1) * 10 => sprintf("%.0fKB", $int / pow(1024, 1)),
$int >= pow(1024, 1) => sprintf("%.1fKB", $int / pow(1024, 1)),
default => (string)$int,
};
if ($int >= pow(1024, 4)) {
return sprintf("%.1fTB", $int / pow(1024, 4));
} elseif ($int >= pow(1024, 3)) {
return sprintf("%.1fGB", $int / pow(1024, 3));
} elseif ($int >= pow(1024, 2)) {
return sprintf("%.1fMB", $int / pow(1024, 2));
} elseif ($int >= 1024) {
return sprintf("%.1fKB", $int / 1024);
} else {
return (string)$int;
}
}
abstract class TIME_UNITS
{
@ -513,12 +549,12 @@ abstract class TIME_UNITS
public const DAYS = "d";
public const YEARS = "y";
public const CONVERSION = [
self::MILLISECONDS => 1000,
self::SECONDS => 60,
self::MINUTES => 60,
self::HOURS => 24,
self::DAYS => 365,
self::YEARS => PHP_INT_MAX
self::MILLISECONDS=>1000,
self::SECONDS=>60,
self::MINUTES=>60,
self::HOURS=>24,
self::DAYS=>365,
self::YEARS=>PHP_INT_MAX
];
}
function format_milliseconds(int $input, string $min_unit = TIME_UNITS::SECONDS): string
@ -529,17 +565,17 @@ function format_milliseconds(int $input, string $min_unit = TIME_UNITS::SECONDS)
$found = false;
foreach (TIME_UNITS::CONVERSION as $unit => $conversion) {
foreach (TIME_UNITS::CONVERSION as $unit=>$conversion) {
$count = $remainder % $conversion;
$remainder = floor($remainder / $conversion);
if ($found || $unit == $min_unit) {
if ($found||$unit==$min_unit) {
$found = true;
} else {
continue;
}
if ($count == 0 && $remainder < 1) {
if ($count==0&&$remainder<1) {
break;
}
$output = "$count".$unit." ".$output;
@ -560,7 +596,7 @@ function parse_to_milliseconds(string $input): int
$output += $length;
}
} else {
foreach (TIME_UNITS::CONVERSION as $unit => $conversion) {
foreach (TIME_UNITS::CONVERSION as $unit=>$conversion) {
if (preg_match('/([0-9]+)'.$unit.'/i', $input, $match)) {
$length = $match[1];
if (is_numeric($length)) {
@ -577,10 +613,10 @@ function parse_to_milliseconds(string $input): int
/**
* Turn a date into a time, a date, an "X minutes ago...", etc
*/
function autodate(string $date, bool $html = true): string
function autodate(string $date, bool $html=true): string
{
$cpu = date('c', strtotime_ex($date));
$hum = date('F j, Y; H:i', strtotime_ex($date));
$cpu = date('c', strtotime($date));
$hum = date('F j, Y; H:i', strtotime($date));
return ($html ? "<time datetime='$cpu'>$hum</time>" : $hum);
}
@ -613,10 +649,6 @@ function isValidDate(string $date): bool
return false;
}
/**
* @param array<string, string> $inputs
* @return array<string, mixed>
*/
function validate_input(array $inputs): array
{
$outputs = [];
@ -649,7 +681,6 @@ function validate_input(array $inputs): array
}
$outputs[$key] = $id;
} elseif (in_array('user_name', $flags)) {
// @phpstan-ignore-next-line - phpstan thinks $value can never be empty?
if (strlen($value) < 1) {
throw new InvalidInput("Username must be at least 1 character");
} elseif (!preg_match('/^[a-zA-Z0-9-_]+$/', $value)) {
@ -660,7 +691,8 @@ function validate_input(array $inputs): array
}
$outputs[$key] = $value;
} elseif (in_array('user_class', $flags)) {
if (!array_key_exists($value, UserClass::$known_classes)) {
global $_shm_user_classes;
if (!array_key_exists($value, $_shm_user_classes)) {
throw new InvalidInput("Invalid user class: ".html_escape($value));
}
$outputs[$key] = $value;
@ -677,7 +709,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_ex(trim($value)));
$outputs[$key] = date("Y-m-d H:i:s", strtotime(trim($value)));
} elseif (in_array('string', $flags)) {
if (in_array('trim', $flags)) {
$value = trim($value);
@ -736,12 +768,6 @@ function join_path(string ...$paths): string
/**
* Perform callback on each item returned by an iterator.
*
* @template T
* @template U
* @param callable(U):T $callback
* @param \iterator<U> $iter
* @return \Generator<T>
*/
function iterator_map(callable $callback, \iterator $iter): \Generator
{
@ -752,26 +778,20 @@ function iterator_map(callable $callback, \iterator $iter): \Generator
/**
* Perform callback on each item returned by an iterator and combine the result into an array.
*
* @template T
* @template U
* @param callable(U):T $callback
* @param \iterator<U> $iter
* @return array<T>
*/
function iterator_map_to_array(callable $callback, \iterator $iter): array
{
return iterator_to_array(iterator_map($callback, $iter));
}
function stringer(mixed $s): string
function stringer($s): string
{
if (is_array($s)) {
if (isset($s[0])) {
return "[" . implode(", ", array_map("Shimmie2\stringer", $s)) . "]";
} else {
$pairs = [];
foreach ($s as $k => $v) {
foreach ($s as $k=>$v) {
$pairs[] = "\"$k\"=>" . stringer($v);
}
return "[" . implode(", ", $pairs) . "]";
@ -794,24 +814,3 @@ function stringer(mixed $s): string
}
return "<Unstringable>";
}
/**
* If a value is in the cache, return it; otherwise, call the callback
* to generate it and store it in the cache.
*
* @template T
* @param string $key
* @param callable():T $callback
* @param int|null $ttl
* @return T
*/
function cache_get_or_set(string $key, callable $callback, ?int $ttl = null): mixed
{
global $cache;
$value = $cache->get($key);
if ($value === null) {
$value = $callback();
$cache->set($key, $value, $ttl);
}
return $value;
}

View File

@ -9,7 +9,7 @@ namespace Shimmie2;
* be included right at the very start of index.php and tests/bootstrap.php
*/
function die_nicely(string $title, string $body, int $code = 0): void
function die_nicely($title, $body, $code=0)
{
print("<!DOCTYPE html>
<html lang='en'>
@ -17,7 +17,6 @@ function die_nicely(string $title, string $body, int $code = 0): void
<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=\"stylesheet\" href=\"ext/static_files/installer.css\" type=\"text/css\">
</head>
<body>
<div id=\"installer\">

View File

@ -21,10 +21,7 @@ function _load_event_listeners(): void
{
global $_shm_event_listeners;
$ver = preg_replace("/[^a-zA-Z0-9\.]/", "_", VERSION);
$key = md5(Extension::get_enabled_extensions_as_string());
$cache_path = data_path("cache/event_listeners/el.$ver.$key.php");
$cache_path = data_path("cache/shm_event_listeners.php");
if (SPEED_HAX && file_exists($cache_path)) {
require_once($cache_path);
} else {
@ -48,7 +45,7 @@ function _set_event_listeners(): void
global $_shm_event_listeners;
$_shm_event_listeners = [];
foreach (get_subclasses_of(Extension::class) as $class) {
foreach (get_subclasses_of("Shimmie2\Extension") as $class) {
/** @var Extension $extension */
$extension = new $class();
@ -75,16 +72,11 @@ function _namespaced_class_name(string $class): string
return str_replace("Shimmie2\\", "", $class);
}
/**
* Dump the event listeners to a file for faster loading.
*
* @param array<string, array<int, Extension>> $event_listeners
*/
function _dump_event_listeners(array $event_listeners, string $path): void
{
$p = "<"."?php\nnamespace Shimmie2;\n";
foreach (get_subclasses_of(Extension::class) as $class) {
foreach (get_subclasses_of("Shimmie2\Extension") as $class) {
$scn = _namespaced_class_name($class);
$p .= "\$$scn = new $scn(); ";
}
@ -108,7 +100,7 @@ global $_shm_event_count;
$_shm_event_count = 0;
$_shm_timeout = null;
function shm_set_timeout(?int $timeout = null): void
function shm_set_timeout(?int $timeout=null): void
{
global $_shm_timeout;
if ($timeout) {
@ -163,7 +155,7 @@ function send_event(Event $event): Event
if ($tracer_enabled) {
$_tracer->end();
}
if ($event->stop_processing === true) {
if ($event->stop_processing===true) {
break;
}
}

View File

@ -1,96 +0,0 @@
<?php
/**
* @template T
* @param T|false $x
* @return T
*/
function false_throws(mixed $x, ?callable $errorgen = null): mixed
{
if($x === false) {
$msg = "Unexpected false";
if($errorgen) {
$msg = $errorgen();
}
throw new \Exception($msg);
}
return $x;
}
/**
* @template T
* @param T|null $x
* @return T
*/
function null_throws(mixed $x, ?callable $errorgen = null): mixed
{
if($x === null) {
$msg = "Unexpected null";
if($errorgen) {
$msg = $errorgen();
}
throw new \Exception($msg);
}
return $x;
}
/**
* @param int<1,max> $depth
*/
function json_encode_ex(mixed $value, int|null $flags = 0, int $depth = 512): string
{
return false_throws(json_encode($value, $flags, $depth), "json_last_error_msg");
}
function strtotime_ex(string $time, int|null $now = null): int
{
return false_throws(strtotime($time, $now));
}
function md5_file_ex(string $filename, bool|null $raw_output = false): string
{
return false_throws(md5_file($filename, $raw_output));
}
/**
* @return string[]
*/
function glob_ex(string $pattern, int|null $flags = 0): array
{
return false_throws(glob($pattern, $flags));
}
function file_get_contents_ex(string $filename): string
{
return false_throws(file_get_contents($filename));
}
function filesize_ex(string $filename): int
{
return false_throws(filesize($filename));
}
function inet_ntop_ex(string $in_addr): string
{
return false_throws(inet_ntop($in_addr));
}
function inet_pton_ex(string $ip_address): string
{
return false_throws(inet_pton($ip_address));
}
function dir_ex(string $directory): \Directory
{
return false_throws(dir($directory));
}
function exec_ex(string $command): string
{
return false_throws(exec($command));
}
function filter_var_ex(mixed $variable, int $filter = FILTER_DEFAULT, mixed $options = null): mixed
{
return false_throws(filter_var($variable, $filter, $options));
}

View File

@ -9,7 +9,7 @@ namespace Shimmie2;
* Shimmie will set the values to their defaults
*
* All of these can be over-ridden by placing a 'define' in
* data/config/shimmie.conf.php.
* data/config/shimmie.conf.php
*
* Do NOT change them in this file. These are the defaults only!
*
@ -17,13 +17,13 @@ namespace Shimmie2;
* define("SPEED_HAX", true);
*/
function _d(string $name, mixed $value): void
function _d(string $name, $value): void
{
if (!defined($name)) {
define($name, $value);
}
}
$_g = file_exists(".git") ? '+' : '';
_d("DATABASE_DSN", null); // string PDO database connection details
_d("DATABASE_TIMEOUT", 10000); // int Time to wait for each statement to complete
_d("CACHE_DSN", null); // string cache connection details
@ -31,10 +31,10 @@ _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", "v2.10.6"); // string shimmie version
_d("VERSION", "2.10.0-alpha$_g"); // string shimmie version
_d("TIMEZONE", null); // string timezone
_d("EXTRA_EXTS", ""); // string optional extra extensions
_d("BASE_HREF", null); // string force a specific base URL (default is auto-detect)
_d("TRACE_FILE", null); // string file to log performance data into
_d("TRACE_THRESHOLD", 0.0); // float log pages which take more time than this many seconds
_d("TRUSTED_PROXIES", []); // array trust "X-Real-IP" / "X-Forwarded-For" / "X-Forwarded-Proto" headers from these IP ranges
_d("REVERSE_PROXY_X_HEADERS", false); // boolean get request IPs from "X-Real-IP" and protocol from "X-Forwarded-Proto" HTTP headers

View File

@ -1,282 +0,0 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
if(class_exists("\\PHPUnit\\Framework\\TestCase")) {
abstract class ShimmiePHPUnitTestCase extends \PHPUnit\Framework\TestCase
{
protected static string $anon_name = "anonymous";
protected static string $admin_name = "demo";
protected static string $user_name = "test";
protected string $wipe_time = "test";
/**
* Start a DB transaction for each test class
*/
public static function setUpBeforeClass(): void
{
global $_tracer, $database;
$_tracer->begin(get_called_class());
$database->begin_transaction();
parent::setUpBeforeClass();
}
/**
* Start a savepoint for each test
*/
public function setUp(): void
{
global $database, $_tracer;
$_tracer->begin($this->name());
$_tracer->begin("setUp");
$class = str_replace("Test", "", get_class($this));
try {
if (!ExtensionInfo::get_for_extension_class($class)->is_supported()) {
$this->markTestSkipped("$class not supported with this database");
}
} catch (ExtensionNotFound $e) {
// ignore - this is a core test rather than an extension test
}
// Set up a clean environment for each test
$database->execute("SAVEPOINT test_start");
self::log_out();
foreach ($database->get_col("SELECT id FROM images") as $image_id) {
send_event(new ImageDeletionEvent(Image::by_id((int)$image_id), true));
}
$_tracer->end(); # setUp
$_tracer->begin("test");
}
public function tearDown(): void
{
global $_tracer, $database;
$database->execute("ROLLBACK TO test_start");
$_tracer->end(); # test
$_tracer->end(); # $this->getName()
}
public static function tearDownAfterClass(): void
{
parent::tearDownAfterClass();
global $_tracer, $database;
$database->rollback();
$_tracer->end(); # get_called_class()
$_tracer->clear();
$_tracer->flush("data/test-trace.json");
}
/**
* @param array<string, mixed> $args
* @return array<string, string|mixed[]>
*/
private static function check_args(array $args): array
{
if (!$args) {
return [];
}
foreach ($args as $k => $v) {
if (is_array($v)) {
$args[$k] = $v;
} else {
$args[$k] = (string)$v;
}
}
return $args;
}
/**
* @param array<string, mixed> $get_args
* @param array<string, mixed> $post_args
*/
protected static function request(
string $method,
string $page_name,
array $get_args = [],
array $post_args = []
): Page {
// use a fresh page
global $page;
$get_args = self::check_args($get_args);
$post_args = self::check_args($post_args);
if (str_contains($page_name, "?")) {
throw new \RuntimeException("Query string included in page name");
}
$_SERVER['REQUEST_URI'] = make_link($page_name, http_build_query($get_args));
$_GET = $get_args;
$_POST = $post_args;
$page = new Page();
send_event(new PageRequestEvent($method, $page_name));
if ($page->mode == PageMode::REDIRECT) {
$page->code = 302;
}
return $page;
}
/**
* @param array<string, mixed> $args
*/
protected static function get_page(string $page_name, array $args = []): Page
{
return self::request("GET", $page_name, $args, []);
}
/**
* @param array<string, mixed> $args
*/
protected static function post_page(string $page_name, array $args = []): Page
{
return self::request("POST", $page_name, [], $args);
}
// page things
protected function assert_title(string $title): void
{
global $page;
$this->assertStringContainsString($title, $page->title);
}
protected function assert_title_matches(string $title): void
{
global $page;
$this->assertStringMatchesFormat($title, $page->title);
}
protected function assert_no_title(string $title): void
{
global $page;
$this->assertStringNotContainsString($title, $page->title);
}
protected function assert_response(int $code): void
{
global $page;
$this->assertEquals($code, $page->code);
}
protected function page_to_text(string $section = null): string
{
global $page;
if ($page->mode == PageMode::PAGE) {
$text = $page->title . "\n";
foreach ($page->blocks as $block) {
if (is_null($section) || $section == $block->section) {
$text .= $block->header . "\n";
$text .= $block->body . "\n\n";
}
}
return $text;
} elseif ($page->mode == PageMode::DATA) {
return $page->data;
} else {
$this->fail("Page mode is {$page->mode->name} (only PAGE and DATA are supported)");
}
}
/**
* Assert that the page contains the given text somewhere in the blocks
*/
protected function assert_text(string $text, string $section = null): void
{
$this->assertStringContainsString($text, $this->page_to_text($section));
}
protected function assert_no_text(string $text, string $section = null): void
{
$this->assertStringNotContainsString($text, $this->page_to_text($section));
}
/**
* Assert that the page contains the given text somewhere in the binary data
*/
protected function assert_content(string $content): void
{
global $page;
$this->assertStringContainsString($content, $page->data);
}
protected function assert_no_content(string $content): void
{
global $page;
$this->assertStringNotContainsString($content, $page->data);
}
/**
* @param string[] $tags
* @param int[] $results
*/
protected function assert_search_results(array $tags, array $results): void
{
$images = Search::find_images(0, null, $tags);
$ids = [];
foreach ($images as $image) {
$ids[] = $image->id;
}
$this->assertEquals($results, $ids);
}
protected function assertException(string $type, callable $function): \Exception|null
{
$exception = null;
try {
call_user_func($function);
} catch (\Exception $e) {
$exception = $e;
}
self::assertThat(
$exception,
new \PHPUnit\Framework\Constraint\Exception($type),
"Expected exception of type $type, but got " . ($exception ? get_class($exception) : "none")
);
return $exception;
}
// user things
protected static function log_in_as_admin(): void
{
send_event(new UserLoginEvent(User::by_name(self::$admin_name)));
}
protected static function log_in_as_user(): void
{
send_event(new UserLoginEvent(User::by_name(self::$user_name)));
}
protected static function log_out(): void
{
global $config;
send_event(new UserLoginEvent(User::by_id($config->get_int("anon_id", 0))));
}
// post things
protected function post_image(string $filename, string $tags): int
{
$dae = send_event(new DataUploadEvent($filename, [
"filename" => $filename,
"tags" => Tag::explode($tags),
"source" => null,
]));
if(count($dae->images) == 0) {
throw new \Exception("Upload failed :(");
}
return $dae->images[0]->id;
}
protected function delete_image(int $image_id): void
{
$img = Image::by_id($image_id);
if ($img) {
send_event(new ImageDeletionEvent($img, true));
}
}
}
} else {
abstract class ShimmiePHPUnitTestCase
{
}
}

View File

@ -1,18 +0,0 @@
<?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($image_id_1);
$this->assertNull($image->source);
}
}

View File

@ -1,524 +0,0 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
use PHPUnit\Framework\Attributes\Depends;
use PHPUnit\Framework\Constraint\IsEqual;
require_once "core/imageboard/search.php";
class SearchTest extends ShimmiePHPUnitTestCase
{
public function testWeirdTags(): void
{
$this->log_in_as_user();
$image_id_1 = $this->post_image("tests/pbx_screenshot.jpg", "question? colon:thing exclamation!");
$image_id_2 = $this->post_image("tests/bedroom_workshop.jpg", "question. colon_thing exclamation%");
$this->assert_search_results(["question?"], [$image_id_1]);
$this->assert_search_results(["question."], [$image_id_2]);
$this->assert_search_results(["colon:thing"], [$image_id_1]);
$this->assert_search_results(["colon_thing"], [$image_id_2]);
$this->assert_search_results(["exclamation!"], [$image_id_1]);
$this->assert_search_results(["exclamation%"], [$image_id_2]);
}
/**
* @return int[]
*/
public function testUpload(): array
{
$this->log_in_as_user();
$image_id_1 = $this->post_image("tests/pbx_screenshot.jpg", "thing computer screenshot pbx phone");
$image_id_2 = $this->post_image("tests/bedroom_workshop.jpg", "thing computer computing bedroom workshop");
$this->log_out();
# make sure both uploads were ok
$this->assertTrue($image_id_1 > 0);
$this->assertTrue($image_id_2 > 0);
return [$image_id_1, $image_id_2];
}
/** ******************************************************
* Test turning a string into an abstract query
*
* @param string $tags
* @param TagCondition[] $expected_tag_conditions
* @param ImgCondition[] $expected_img_conditions
* @param string $expected_order
*/
private function assert_TTC(
string $tags,
array $expected_tag_conditions,
array $expected_img_conditions,
string $expected_order,
): void {
$class = new \ReflectionClass(Search::class);
$terms_to_conditions = $class->getMethod("terms_to_conditions");
$terms_to_conditions->setAccessible(true); // Use this if you are running PHP older than 8.1.0
$obj = new Search();
[$tag_conditions, $img_conditions, $order] = $terms_to_conditions->invokeArgs($obj, [Tag::explode($tags, false)]);
static::assertThat(
[
"tags" => $expected_tag_conditions,
"imgs" => $expected_img_conditions,
"order" => $expected_order,
],
new IsEqual([
"tags" => $tag_conditions,
"imgs" => $img_conditions,
"order" => $order,
])
);
}
public function testTTC_Empty(): void
{
$this->assert_TTC(
"",
[
],
[
new ImgCondition(new Querylet("trash != :true", ["true" => true])),
new ImgCondition(new Querylet("private != :true OR owner_id = :private_owner_id", [
"private_owner_id" => 1,
"true" => true])),
new ImgCondition(new Querylet("rating IN ('?', 's', 'q', 'e')", [])),
],
"images.id DESC"
);
}
public function testTTC_Hash(): void
{
$this->assert_TTC(
"hash=1234567890",
[
],
[
new ImgCondition(new Querylet("trash != :true", ["true" => true])),
new ImgCondition(new Querylet("private != :true OR owner_id = :private_owner_id", [
"private_owner_id" => 1,
"true" => true])),
new ImgCondition(new Querylet("rating IN ('?', 's', 'q', 'e')", [])),
new ImgCondition(new Querylet("images.hash = :hash", ["hash" => "1234567890"])),
],
"images.id DESC"
);
}
public function testTTC_Ratio(): void
{
$this->assert_TTC(
"ratio=42:12345",
[
],
[
new ImgCondition(new Querylet("trash != :true", ["true" => true])),
new ImgCondition(new Querylet("private != :true OR owner_id = :private_owner_id", [
"private_owner_id" => 1,
"true" => true])),
new ImgCondition(new Querylet("rating IN ('?', 's', 'q', 'e')", [])),
new ImgCondition(new Querylet("width / :width1 = height / :height1", ['width1' => 42,
'height1' => 12345])),
],
"images.id DESC"
);
}
public function testTTC_Order(): void
{
$this->assert_TTC(
"order=score",
[
],
[
new ImgCondition(new Querylet("trash != :true", ["true" => true])),
new ImgCondition(new Querylet("private != :true OR owner_id = :private_owner_id", [
"private_owner_id" => 1,
"true" => true])),
new ImgCondition(new Querylet("rating IN ('?', 's', 'q', 'e')", [])),
],
"images.numeric_score DESC"
);
}
/** ******************************************************
* Test turning an abstract query into SQL + fetching the results
*
* @param string[] $tcs
* @param string[] $ics
* @param string $order
* @param int $limit
* @param int $start
* @param int[] $res
* @param string[] $path
*/
private function assert_BSQ(
array $tcs = [],
array $ics = [],
string $order = "id DESC",
int $limit = 9999,
int $start = 0,
array $res = [],
array $path = null,
): void {
global $database;
$tcs = array_map(
fn ($tag) => ($tag[0] == "-") ?
new TagCondition(substr($tag, 1), false) :
new TagCondition($tag),
$tcs
);
$ics = array_map(
fn ($ic) => send_event(new SearchTermParseEvent(0, $ic, []))->img_conditions,
$ics
);
$ics = array_merge(...$ics);
Search::$_search_path = [];
$class = new \ReflectionClass(Search::class);
$build_search_querylet = $class->getMethod("build_search_querylet");
$build_search_querylet->setAccessible(true); // Use this if you are running PHP older than 8.1.0
$obj = new Search();
$querylet = $build_search_querylet->invokeArgs($obj, [$tcs, $ics, $order, $limit, $start]);
$results = $database->get_all($querylet->sql, $querylet->variables);
static::assertThat(
[
"res" => array_map(fn ($row) => $row['id'], $results),
"path" => Search::$_search_path,
],
new IsEqual([
"res" => $res,
"path" => $path ?? Search::$_search_path,
])
);
}
/* * * * * * * * * * *
* No-tag search *
* * * * * * * * * * */
#[Depends('testUpload')]
public function testBSQ_NoTags(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
tcs: [],
res: [$image_ids[1], $image_ids[0]],
path: ["no_tags"],
);
}
/* * * * * * * * * * *
* Fast-path search *
* * * * * * * * * * */
#[Depends('testUpload')]
public function testBSQ_FastPath_NoResults(): void
{
$this->testUpload();
$this->assert_BSQ(
tcs: ["maumaumau"],
res: [],
path: ["fast", "invalid_tag"],
);
}
#[Depends('testUpload')]
public function testBSQ_FastPath_OneResult(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
tcs: ["pbx"],
res: [$image_ids[0]],
path: ["fast"],
);
}
#[Depends('testUpload')]
public function testBSQ_FastPath_ManyResults(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
tcs: ["computer"],
res: [$image_ids[1], $image_ids[0]],
path: ["fast"],
);
}
#[Depends('testUpload')]
public function testBSQ_FastPath_WildNoResults(): void
{
$this->testUpload();
$this->assert_BSQ(
tcs: ["asdfasdf*"],
res: [],
path: ["fast", "invalid_tag"],
);
}
/**
* Only the first image matches both the wildcard and the tag.
* This checks for a bug where searching for "a* b" would return
* an image tagged "a1 a2" because the number of matched tags
* was equal to the number of searched tags.
*
* https://github.com/shish/shimmie2/issues/547
*/
#[Depends('testUpload')]
public function testBSQ_FastPath_WildOneResult(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
tcs: ["screen*"],
res: [$image_ids[0]],
path: ["fast"],
);
}
/**
* Test that the fast path doesn't return duplicate results
* when a wildcard matches one image multiple times.
*/
#[Depends('testUpload')]
public function testBSQ_FastPath_WildManyResults(): void
{
$image_ids = $this->testUpload();
// two images match comp* - one matches it once, one matches it twice
$this->assert_BSQ(
tcs: ["comp*"],
res: [$image_ids[1], $image_ids[0]],
path: ["fast"],
);
}
/* * * * * * * * * * *
* General search *
* * * * * * * * * * */
#[Depends('testUpload')]
public function testBSQ_GeneralPath_NoResults(): void
{
$this->testUpload();
# multiple tags, one of which doesn't exist
# (test the "one tag doesn't exist = no hits" path)
$this->assert_BSQ(
tcs: ["computer", "not_a_tag"],
res: [],
path: ["general", "invalid_tag"],
);
}
#[Depends('testUpload')]
public function testBSQ_GeneralPath_OneResult(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
tcs: ["computer", "screenshot"],
res: [$image_ids[0]],
path: ["general", "some_positives"],
);
}
/**
* Only the first image matches both the wildcard and the tag.
* This checks for a bug where searching for "a* b" would return
* an image tagged "a1 a2" because the number of matched tags
* was equal to the number of searched tags.
*
* https://github.com/shish/shimmie2/issues/547
*/
#[Depends('testUpload')]
public function testBSQ_GeneralPath_WildOneResult(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
tcs: ["comp*", "screenshot"],
res: [$image_ids[0]],
path: ["general", "some_positives"],
);
}
#[Depends('testUpload')]
public function testBSQ_GeneralPath_ManyResults(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
tcs: ["computer", "thing"],
res: [$image_ids[1], $image_ids[0]],
path: ["general", "some_positives"],
);
}
#[Depends('testUpload')]
public function testBSQ_GeneralPath_WildManyResults(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
tcs: ["comp*", "-asdf"],
res: [$image_ids[1], $image_ids[0]],
path: ["general", "some_positives"],
);
}
#[Depends('testUpload')]
public function testBSQ_GeneralPath_SubtractValidFromResults(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
tcs: ["computer", "-pbx"],
res: [$image_ids[1]],
path: ["general", "some_positives"],
);
}
#[Depends('testUpload')]
public function testBSQ_GeneralPath_SubtractNotValidFromResults(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
tcs: ["computer", "-not_a_tag"],
res: [$image_ids[1], $image_ids[0]],
path: ["general", "some_positives"],
);
}
#[Depends('testUpload')]
public function testBSQ_GeneralPath_SubtractValidFromDefault(): void
{
$image_ids = $this->testUpload();
// negative tag alone, should remove the image with that tag
$this->assert_BSQ(
tcs: ["-pbx"],
res: [$image_ids[1]],
path: ["general", "only_negative_tags"],
);
}
#[Depends('testUpload')]
public function testBSQ_GeneralPath_SubtractNotValidFromDefault(): void
{
$image_ids = $this->testUpload();
// negative that doesn't exist, should return all results
$this->assert_BSQ(
tcs: ["-not_a_tag"],
res: [$image_ids[1], $image_ids[0]],
path: ["general", "all_nonexistent_negatives"],
);
}
#[Depends('testUpload')]
public function testBSQ_GeneralPath_SubtractMultipleNotValidFromDefault(): void
{
$image_ids = $this->testUpload();
// multiple negative tags that don't exist, should return all results
$this->assert_BSQ(
tcs: ["-not_a_tag", "-also_not_a_tag"],
res: [$image_ids[1], $image_ids[0]],
path: ["general", "all_nonexistent_negatives"],
);
}
/* * * * * * * * * * *
* Meta Search *
* * * * * * * * * * */
#[Depends('testUpload')]
public function testBSQ_ImgCond_NoResults(): void
{
$this->testUpload();
$this->assert_BSQ(
ics: ["hash=1234567890"],
res: [],
path: ["no_tags"],
);
$this->assert_BSQ(
ics: ["ratio=42:12345"],
res: [],
path: ["no_tags"],
);
}
#[Depends('testUpload')]
public function testBSQ_ImgCond_OneResult(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
ics: ["hash=feb01bab5698a11dd87416724c7a89e3"],
res: [$image_ids[0]],
path: ["no_tags"],
);
$this->assert_BSQ(
ics: ["id={$image_ids[1]}"],
res: [$image_ids[1]],
path: ["no_tags"],
);
$this->assert_BSQ(
ics: ["filename=screenshot"],
res: [$image_ids[0]],
path: ["no_tags"],
);
}
#[Depends('testUpload')]
public function testBSQ_ImgCond_ManyResults(): void
{
$image_ids = $this->testUpload();
$this->assert_BSQ(
ics: ["size=640x480"],
res: [$image_ids[1], $image_ids[0]],
path: ["no_tags"],
);
$this->assert_BSQ(
ics: ["tags=5"],
res: [$image_ids[1], $image_ids[0]],
path: ["no_tags"],
);
$this->assert_BSQ(
ics: ["ext=jpg"],
res: [$image_ids[1], $image_ids[0]],
path: ["no_tags"],
);
}
/* * * * * * * * * * *
* Mixed *
* * * * * * * * * * */
#[Depends('testUpload')]
public function testBSQ_TagCondWithImgCond(): void
{
$image_ids = $this->testUpload();
// multiple tags, many results
$this->assert_BSQ(
tcs: ["computer"],
ics: ["size=640x480"],
res: [$image_ids[1], $image_ids[0]],
path: ["general", "some_positives"],
);
}
/**
* get_images
*/
#[Depends('testUpload')]
public function test_get_images(): void
{
$image_ids = $this->testUpload();
$res = Search::get_images($image_ids);
$this->assertGreaterThan($res[0]->id, $res[1]->id);
$res = Search::get_images(array_reverse($image_ids));
$this->assertLessThan($res[0]->id, $res[1]->id);
}
}

View File

@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
class StdLibExTest extends ShimmiePHPUnitTestCase
{
public function testJsonEncodeOk(): void
{
$this->assertEquals(
'{"a":1,"b":2,"c":3,"d":4,"e":5}',
json_encode_ex(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5])
);
}
public function testJsonEncodeError(): void
{
$e = $this->assertException(\Exception::class, function () {
json_encode_ex("\xB1\x31");
});
$this->assertEquals(
"Malformed UTF-8 characters, possibly incorrectly encoded",
$e->getMessage()
);
}
}

View File

@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
use PHPUnit\Framework\TestCase;
require_once "core/imageboard/tag.php";
class TagTest extends TestCase
{
public function test_compare(): void
{
$this->assertFalse(Tag::compare(["foo"], ["bar"]));
$this->assertFalse(Tag::compare(["foo"], ["foo", "bar"]));
$this->assertTrue(Tag::compare([], []));
$this->assertTrue(Tag::compare(["foo"], ["FoO"]));
$this->assertTrue(Tag::compare(["foo", "bar"], ["bar", "FoO"]));
}
}

View File

@ -1,273 +0,0 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Depends;
require_once "core/urls.php";
class UrlsTest extends TestCase
{
/**
* An integration test for
* - search_link()
* - make_link()
* - _get_query()
* - get_search_terms()
*/
#[Depends("test_search_link")]
public function test_get_search_terms_from_search_link(): void
{
/**
* @param array<string> $vars
* @return array<string>
*/
$gst = function (array $terms): array {
$pre = new PageRequestEvent("GET", _get_query(search_link($terms)));
$pre->page_matches("post/list");
return $pre->get_search_terms();
};
global $config;
foreach([true, false] as $nice_urls) {
$config->set_bool('nice_urls', $nice_urls);
$this->assertEquals(
["bar", "foo"],
$gst(["foo", "bar"])
);
$this->assertEquals(
["AC/DC"],
$gst(["AC/DC"])
);
$this->assertEquals(
["cat*", "rating=?"],
$gst(["rating=?", "cat*"]),
);
}
}
#[Depends("test_get_base_href")]
public function test_make_link(): void
{
global $config;
foreach([true, false] as $nice_urls) {
$config->set_bool('nice_urls', $nice_urls);
// basic
$this->assertEquals(
$nice_urls ? "/test/foo" : "/test/index.php?q=foo",
make_link("foo")
);
// remove leading slash from path
$this->assertEquals(
$nice_urls ? "/test/foo" : "/test/index.php?q=foo",
make_link("/foo")
);
// query
$this->assertEquals(
$nice_urls ? "/test/foo?a=1&b=2" : "/test/index.php?q=foo&a=1&b=2",
make_link("foo", "a=1&b=2")
);
// hash
$this->assertEquals(
$nice_urls ? "/test/foo#cake" : "/test/index.php?q=foo#cake",
make_link("foo", null, "cake")
);
// query + hash
$this->assertEquals(
$nice_urls ? "/test/foo?a=1&b=2#cake" : "/test/index.php?q=foo&a=1&b=2#cake",
make_link("foo", "a=1&b=2", "cake")
);
}
}
#[Depends("test_make_link")]
public function test_search_link(): void
{
global $config;
foreach([true, false] as $nice_urls) {
$config->set_bool('nice_urls', $nice_urls);
$this->assertEquals(
$nice_urls ? "/test/post/list/bar%20foo/1" : "/test/index.php?q=post/list/bar%20foo/1",
search_link(["foo", "bar"])
);
$this->assertEquals(
$nice_urls ? "/test/post/list/AC%2FDC/1" : "/test/index.php?q=post/list/AC%2FDC/1",
search_link(["AC/DC"])
);
$this->assertEquals(
$nice_urls ? "/test/post/list/cat%2A%20rating%3D%3F/1" : "/test/index.php?q=post/list/cat%2A%20rating%3D%3F/1",
search_link(["rating=?", "cat*"])
);
}
}
#[Depends("test_get_base_href")]
public function test_get_query(): void
{
// just validating an assumption that this test relies upon
$this->assertEquals(get_base_href(), "/test");
$this->assertEquals(
"tasty/cake",
_get_query("/test/tasty/cake"),
'http://$SERVER/$INSTALL_DIR/$PATH should return $PATH'
);
$this->assertEquals(
"tasty/cake",
_get_query("/test/index.php?q=tasty/cake"),
'http://$SERVER/$INSTALL_DIR/index.php?q=$PATH should return $PATH'
);
$this->assertEquals(
"tasty/cake%20pie",
_get_query("/test/index.php?q=tasty/cake%20pie"),
'URL encoded paths should be left alone'
);
$this->assertEquals(
"tasty/cake%20pie",
_get_query("/test/tasty/cake%20pie"),
'URL encoded queries should be left alone'
);
$this->assertEquals(
"",
_get_query("/test/"),
'If just viewing install directory, should return /'
);
$this->assertEquals(
"",
_get_query("/test/index.php"),
'If just viewing index.php, should return /'
);
$this->assertEquals(
"post/list/tasty%2Fcake/1",
_get_query("/test/post/list/tasty%2Fcake/1"),
'URL encoded niceurls should be left alone, even encoded slashes'
);
$this->assertEquals(
"post/list/tasty%2Fcake/1",
_get_query("/test/index.php?q=post/list/tasty%2Fcake/1"),
'URL encoded uglyurls should be left alone, even encoded slashes'
);
}
public function test_is_https_enabled(): void
{
$this->assertFalse(is_https_enabled(), "HTTPS should be disabled by default");
$_SERVER['HTTPS'] = "on";
$this->assertTrue(is_https_enabled(), "HTTPS should be enabled when set to 'on'");
unset($_SERVER['HTTPS']);
}
public function test_get_base_href(): void
{
// PHP_SELF should point to "the currently executing script
// relative to the document root"
$this->assertEquals("", get_base_href(["PHP_SELF" => "/index.php"]));
$this->assertEquals("/mydir", get_base_href(["PHP_SELF" => "/mydir/index.php"]));
// SCRIPT_FILENAME should point to "the absolute pathname of
// the currently executing script" and DOCUMENT_ROOT should
// point to "the document root directory under which the
// current script is executing"
$this->assertEquals("", get_base_href([
"PHP_SELF" => "<invalid>",
"SCRIPT_FILENAME" => "/var/www/html/index.php",
"DOCUMENT_ROOT" => "/var/www/html",
]), "root directory");
$this->assertEquals("/mydir", get_base_href([
"PHP_SELF" => "<invalid>",
"SCRIPT_FILENAME" => "/var/www/html/mydir/index.php",
"DOCUMENT_ROOT" => "/var/www/html",
]), "subdirectory");
$this->assertEquals("", get_base_href([
"PHP_SELF" => "<invalid>",
"SCRIPT_FILENAME" => "/var/www/html/index.php",
"DOCUMENT_ROOT" => "/var/www/html/",
]), "trailing slash in DOCUMENT_ROOT root should be ignored");
$this->assertEquals("/mydir", get_base_href([
"PHP_SELF" => "<invalid>",
"SCRIPT_FILENAME" => "/var/www/html/mydir/index.php",
"DOCUMENT_ROOT" => "/var/www/html/",
]), "trailing slash in DOCUMENT_ROOT subdir should be ignored");
}
#[Depends("test_is_https_enabled")]
#[Depends("test_get_base_href")]
public function test_make_http(): void
{
$this->assertEquals(
"http://cli-command/test/foo",
make_http("foo"),
"relative to shimmie root"
);
$this->assertEquals(
"http://cli-command/foo",
make_http("/foo"),
"relative to web server"
);
$this->assertEquals(
"https://foo.com",
make_http("https://foo.com"),
"absolute URL should be left alone"
);
}
public function test_modify_url(): void
{
$this->assertEquals(
"/foo/bar?a=3&b=2",
modify_url("/foo/bar?a=1&b=2", ["a" => "3"])
);
$this->assertEquals(
"https://blah.com/foo/bar?b=2",
modify_url("https://blah.com/foo/bar?a=1&b=2", ["a" => null])
);
$this->assertEquals(
"/foo/bar",
modify_url("/foo/bar?a=1&b=2", ["a" => null, "b" => null])
);
}
public function test_referer_or(): void
{
unset($_SERVER['HTTP_REFERER']);
$this->assertEquals(
"foo",
referer_or("foo")
);
$_SERVER['HTTP_REFERER'] = "cake";
$this->assertEquals(
"cake",
referer_or("foo")
);
$_SERVER['HTTP_REFERER'] = "cake";
$this->assertEquals(
"foo",
referer_or("foo", ["cake"])
);
}
public function tearDown(): void
{
global $config;
$config->set_bool('nice_urls', true);
parent::tearDown();
}
}

View File

@ -10,7 +10,7 @@ require_once "core/basepage.php";
class BasePageTest extends TestCase
{
public function test_page(): void
public function test_page()
{
$page = new BasePage();
$page->set_mode(PageMode::PAGE);
@ -20,7 +20,7 @@ class BasePageTest extends TestCase
$this->assertTrue(true); // doesn't crash
}
public function test_file(): void
public function test_file()
{
$page = new BasePage();
$page->set_mode(PageMode::FILE);
@ -31,7 +31,7 @@ class BasePageTest extends TestCase
$this->assertTrue(true); // doesn't crash
}
public function test_data(): void
public function test_data()
{
$page = new BasePage();
$page->set_mode(PageMode::DATA);
@ -42,7 +42,7 @@ class BasePageTest extends TestCase
$this->assertTrue(true); // doesn't crash
}
public function test_redirect(): void
public function test_redirect()
{
$page = new BasePage();
$page->set_mode(PageMode::REDIRECT);

View File

@ -10,7 +10,7 @@ require_once "core/block.php";
class BlockTest extends TestCase
{
public function test_basic(): void
public function test_basic()
{
$b = new Block("head", "body");
$this->assertEquals(

View File

@ -6,15 +6,15 @@ namespace Shimmie2;
use PHPUnit\Framework\TestCase;
class InitTest extends TestCase
class TestInit extends TestCase
{
public function testInitExt(): void
public function testInitExt()
{
send_event(new InitExtEvent());
$this->assertTrue(true);
}
public function testDatabaseUpgrade(): void
public function testDatabaseUpgrade()
{
send_event(new DatabaseUpgradeEvent());
$this->assertTrue(true);

View File

@ -10,7 +10,7 @@ require_once "core/polyfills.php";
class PolyfillsTest extends TestCase
{
public function test_html_escape(): void
public function test_html_escape()
{
$this->assertEquals(
"Foo &amp; &lt;main&gt;",
@ -26,7 +26,7 @@ class PolyfillsTest extends TestCase
$this->assertEquals(html_escape(html_unescape($x)), $x);
}
public function test_int_escape(): void
public function test_int_escape()
{
$this->assertEquals(0, int_escape(""));
$this->assertEquals(1, int_escape("1"));
@ -35,13 +35,13 @@ class PolyfillsTest extends TestCase
$this->assertEquals(0, int_escape(null));
}
public function test_url_escape(): void
public function test_url_escape()
{
$this->assertEquals("%5E%5Co%2F%5E", url_escape("^\o/^"));
$this->assertEquals("", url_escape(null));
}
public function test_bool_escape(): void
public function test_bool_escape()
{
$this->assertTrue(bool_escape(true));
$this->assertFalse(bool_escape(false));
@ -71,7 +71,7 @@ class PolyfillsTest extends TestCase
$this->assertFalse(bool_escape("0"));
}
public function test_clamp(): void
public function test_clamp()
{
$this->assertEquals(5, clamp(0, 5, 10)); // too small
$this->assertEquals(5, clamp(5, 5, 10)); // lower limit
@ -83,7 +83,7 @@ class PolyfillsTest extends TestCase
$this->assertEquals(42, clamp(42, null, null)); // no limit
}
public function test_truncate(): void
public function test_truncate()
{
$this->assertEquals("test words", truncate("test words", 10));
$this->assertEquals("test...", truncate("test...", 9));
@ -91,16 +91,13 @@ class PolyfillsTest extends TestCase
$this->assertEquals("te...", truncate("te...", 2));
}
public function test_to_shorthand_int(): void
public function test_to_shorthand_int()
{
// 0-9 should have 1 decimal place, 10+ should have none
$this->assertEquals("1.1GB", to_shorthand_int(1231231231));
$this->assertEquals("10KB", to_shorthand_int(10240));
$this->assertEquals("9.2KB", to_shorthand_int(9440));
$this->assertEquals("2", to_shorthand_int(2));
}
public function test_parse_shorthand_int(): void
public function test_parse_shorthand_int()
{
$this->assertEquals(-1, parse_shorthand_int("foo"));
$this->assertEquals(33554432, parse_shorthand_int("32M"));
@ -108,21 +105,21 @@ class PolyfillsTest extends TestCase
$this->assertEquals(1231231231, parse_shorthand_int("1231231231"));
}
public function test_format_milliseconds(): void
public function test_format_milliseconds()
{
$this->assertEquals("", format_milliseconds(5));
$this->assertEquals("5s", format_milliseconds(5000));
$this->assertEquals("1y 213d 16h 53m 20s", format_milliseconds(50000000000));
}
public function test_parse_to_milliseconds(): void
public function test_parse_to_milliseconds()
{
$this->assertEquals(10, parse_to_milliseconds("10"));
$this->assertEquals(5000, parse_to_milliseconds("5s"));
$this->assertEquals(50000000000, parse_to_milliseconds("1y 213d 16h 53m 20s"));
}
public function test_autodate(): void
public function test_autodate()
{
$this->assertEquals(
"<time datetime='2012-06-23T16:14:22+00:00'>June 23, 2012; 16:14</time>",
@ -130,7 +127,7 @@ class PolyfillsTest extends TestCase
);
}
public function test_validate_input(): void
public function test_validate_input()
{
$_POST = [
"foo" => " bar ",
@ -138,20 +135,20 @@ class PolyfillsTest extends TestCase
"num" => "42",
];
$this->assertEquals(
["foo" => "bar"],
validate_input(["foo" => "string,trim,lower"])
["foo"=>"bar"],
validate_input(["foo"=>"string,trim,lower"])
);
//$this->assertEquals(
// ["to_null"=>null],
// validate_input(["to_null"=>"string,trim,nullify"])
//);
$this->assertEquals(
["num" => 42],
validate_input(["num" => "int"])
["num"=>42],
validate_input(["num"=>"int"])
);
}
public function test_sanitize_path(): void
public function test_sanitize_path()
{
$this->assertEquals(
"one",
@ -194,7 +191,7 @@ class PolyfillsTest extends TestCase
);
}
public function test_join_path(): void
public function test_join_path()
{
$this->assertEquals(
"one",
@ -222,36 +219,11 @@ class PolyfillsTest extends TestCase
);
}
public function test_stringer(): void
public function test_stringer()
{
$this->assertEquals(
'["foo"=>"bar", "baz"=>[1, 2, 3], "qux"=>["a"=>"b"]]',
stringer(["foo" => "bar", "baz" => [1,2,3], "qux" => ["a" => "b"]])
stringer(["foo"=>"bar", "baz"=>[1,2,3], "qux"=>["a"=>"b"]])
);
}
public function test_ip_in_range(): void
{
$this->assertTrue(ip_in_range("1.2.3.4", "1.2.0.0/16"));
$this->assertFalse(ip_in_range("4.3.2.1", "1.2.0.0/16"));
// A single IP should be interpreted as a /32
$this->assertTrue(ip_in_range("1.2.3.4", "1.2.3.4"));
}
public function test_deltree(): void
{
$tmp = sys_get_temp_dir();
$dir = "$tmp/test_deltree";
mkdir($dir);
file_put_contents("$dir/foo", "bar");
mkdir("$dir/baz");
file_put_contents("$dir/baz/.qux", "quux");
$this->assertTrue(file_exists($dir));
$this->assertTrue(file_exists("$dir/foo"));
$this->assertTrue(file_exists("$dir/baz"));
$this->assertTrue(file_exists("$dir/baz/.qux"));
deltree($dir);
$this->assertFalse(file_exists($dir));
}
}

35
core/tests/tag.test.php Normal file
View File

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

105
core/tests/urls.test.php Normal file
View File

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Shimmie2;
use PHPUnit\Framework\TestCase;
require_once "core/urls.php";
class UrlsTest extends TestCase
{
public function test_make_link()
{
// basic
$this->assertEquals(
"/test/foo",
make_link("foo")
);
// remove leading slash from path
$this->assertEquals(
"/test/foo",
make_link("/foo")
);
// query
$this->assertEquals(
"/test/foo?a=1&b=2",
make_link("foo", "a=1&b=2")
);
// hash
$this->assertEquals(
"/test/foo#cake",
make_link("foo", null, "cake")
);
// query + hash
$this->assertEquals(
"/test/foo?a=1&b=2#cake",
make_link("foo", "a=1&b=2", "cake")
);
}
public function test_make_http()
{
// relative to shimmie install
$this->assertEquals(
"http://cli-command/test/foo",
make_http("foo")
);
// relative to web server
$this->assertEquals(
"http://cli-command/foo",
make_http("/foo")
);
// absolute
$this->assertEquals(
"https://foo.com",
make_http("https://foo.com")
);
}
public function test_modify_url()
{
$this->assertEquals(
"/foo/bar?a=3&b=2",
modify_url("/foo/bar?a=1&b=2", ["a"=>"3"])
);
$this->assertEquals(
"https://blah.com/foo/bar?b=2",
modify_url("https://blah.com/foo/bar?a=1&b=2", ["a"=>null])
);
$this->assertEquals(
"/foo/bar",
modify_url("/foo/bar?a=1&b=2", ["a"=>null, "b"=>null])
);
}
public function test_referer_or()
{
unset($_SERVER['HTTP_REFERER']);
$this->assertEquals(
"foo",
referer_or("foo")
);
$_SERVER['HTTP_REFERER'] = "cake";
$this->assertEquals(
"cake",
referer_or("foo")
);
$_SERVER['HTTP_REFERER'] = "cake";
$this->assertEquals(
"foo",
referer_or("foo", ["cake"])
);
}
}

View File

@ -10,42 +10,42 @@ require_once "core/util.php";
class UtilTest extends TestCase
{
public function test_get_theme(): void
public function test_get_theme()
{
$this->assertEquals("default", get_theme());
}
public function test_get_memory_limit(): void
public function test_get_memory_limit()
{
get_memory_limit();
$this->assertTrue(true);
}
public function test_check_gd_version(): void
public function test_check_gd_version()
{
check_gd_version();
$this->assertTrue(true);
}
public function test_check_im_version(): void
public function test_check_im_version()
{
check_im_version();
$this->assertTrue(true);
}
public function test_human_filesize(): void
public function test_human_filesize()
{
$this->assertEquals("123.00B", human_filesize(123));
$this->assertEquals("123B", human_filesize(123, 0));
$this->assertEquals("120.56KB", human_filesize(123456));
}
public function test_generate_key(): void
public function test_generate_key()
{
$this->assertEquals(20, strlen(generate_key()));
}
public function test_warehouse_path(): void
public function test_warehouse_path()
{
$hash = "7ac19c10d6859415";
@ -105,7 +105,7 @@ class UtilTest extends TestCase
);
}
public function test_load_balance_url(): void
public function test_load_balance_url()
{
$hash = "7ac19c10d6859415";
$ext = "jpg";
@ -123,42 +123,42 @@ class UtilTest extends TestCase
);
}
public function test_path_to_tags(): void
public function test_path_to_tags()
{
$this->assertEquals(
[],
"",
path_to_tags("nope.jpg")
);
$this->assertEquals(
[],
"",
path_to_tags("\\")
);
$this->assertEquals(
[],
"",
path_to_tags("/")
);
$this->assertEquals(
[],
"",
path_to_tags("C:\\")
);
$this->assertEquals(
["test", "tag"],
"test tag",
path_to_tags("123 - test tag.jpg")
);
$this->assertEquals(
["foo", "bar"],
"foo bar",
path_to_tags("/foo/bar/baz.jpg")
);
$this->assertEquals(
["cake", "pie", "foo", "bar"],
"cake pie foo bar",
path_to_tags("/foo/bar/123 - cake pie.jpg")
);
$this->assertEquals(
["bacon", "lemon"],
"bacon lemon",
path_to_tags("\\bacon\\lemon\\baz.jpg")
);
$this->assertEquals(
["category:tag"],
"category:tag",
path_to_tags("/category:/tag/baz.jpg")
);
}

View File

@ -4,12 +4,14 @@ declare(strict_types=1);
namespace Shimmie2;
use PhpParser\Node\Expr\Cast\Double;
class Link
{
public ?string $page;
public ?string $query;
public function __construct(?string $page = null, ?string $query = null)
public function __construct(?string $page=null, ?string $query=null)
{
$this->page = $page;
$this->query = $query;
@ -21,30 +23,13 @@ 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.
*
* eg make_link("foo/bar") becomes either "/v2/foo/bar" (niceurls) or
* "/v2/index.php?q=foo/bar" (uglyurls)
* eg make_link("post/list") becomes "/v2/index.php?q=post/list"
*/
function make_link(?string $page = null, ?string $query = null, ?string $fragment = null): string
function make_link(?string $page=null, ?string $query=null, ?string $fragment=null): string
{
global $config;
@ -67,130 +52,17 @@ function make_link(?string $page = null, ?string $query = null, ?string $fragmen
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
*
* @param array<string, mixed> $changes
*/
function modify_current_url(array $changes): string
{
return modify_url($_SERVER['REQUEST_URI'], $changes);
}
/**
* Take a URL and modify some parameters
*
* @param array<string, mixed> $changes
*/
function modify_url(string $url, array $changes): string
{
/** @var array<string, mixed> */
$parts = parse_url($url);
$params = [];
@ -231,10 +103,8 @@ function make_http(string $link): string
/**
* If HTTP_REFERER is set, and not blacklisted, then return it
* Else return a default $dest
*
* @param string[]|null $blacklist
*/
function referer_or(string $dest, ?array $blacklist = null): string
function referer_or(string $dest, ?array $blacklist=null): string
{
if (empty($_SERVER['HTTP_REFERER'])) {
return $dest;

View File

@ -11,6 +11,12 @@ use MicroHTML\HTMLElement;
use function MicroHTML\INPUT;
function _new_user(array $row): User
{
return new User($row);
}
/**
* Class User
*
@ -44,19 +50,20 @@ class User
* One will very rarely construct a user directly, more common
* would be to use User::by_id, User::by_session, etc.
*
* @param array<string|int, mixed> $row
* @throws SCoreException
*/
public function __construct(array $row)
{
global $_shm_user_classes;
$this->id = int_escape((string)$row['id']);
$this->name = $row['name'];
$this->email = $row['email'];
$this->join_date = $row['joindate'];
$this->passhash = $row['pass'];
if (array_key_exists($row["class"], UserClass::$known_classes)) {
$this->class = UserClass::$known_classes[$row["class"]];
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"]}'");
}
@ -91,7 +98,7 @@ class User
} else {
$query = "SELECT * FROM users WHERE name = :name AND md5(pass || :ip) = :sess";
}
$row = $database->get_row($query, ["name" => $name, "ip" => get_session_ip($config), "sess" => $session]);
$row = $database->get_row($query, ["name"=>$name, "ip"=>get_session_ip($config), "sess"=>$session]);
$cache->set("user-session:$name-$session", $row, 600);
}
return is_null($row) ? null : new User($row);
@ -106,7 +113,7 @@ class User
return new User($cached);
}
}
$row = $database->get_row("SELECT * FROM users WHERE id = :id", ["id" => $id]);
$row = $database->get_row("SELECT * FROM users WHERE id = :id", ["id"=>$id]);
if ($id === 1) {
$cache->set('user-id:'.$id, $row, 600);
}
@ -117,7 +124,7 @@ class User
public static function by_name(string $name): ?User
{
global $database;
$row = $database->get_row("SELECT * FROM users WHERE LOWER(name) = LOWER(:name)", ["name" => $name]);
$row = $database->get_row("SELECT * FROM users WHERE LOWER(name) = LOWER(:name)", ["name"=>$name]);
return is_null($row) ? null : new User($row);
}
@ -181,7 +188,7 @@ class User
public function set_class(string $class): void
{
global $database;
$database->execute("UPDATE users SET class=:class WHERE id=:id", ["class" => $class, "id" => $this->id]);
$database->execute("UPDATE users SET class=:class WHERE id=:id", ["class"=>$class, "id"=>$this->id]);
log_info("core-user", 'Set class for '.$this->name.' to '.$class);
}
@ -193,7 +200,7 @@ class User
}
$old_name = $this->name;
$this->name = $name;
$database->execute("UPDATE users SET name=:name WHERE id=:id", ["name" => $this->name, "id" => $this->id]);
$database->execute("UPDATE users SET name=:name WHERE id=:id", ["name"=>$this->name, "id"=>$this->id]);
log_info("core-user", "Changed username for {$old_name} to {$this->name}");
}
@ -201,15 +208,19 @@ class User
{
global $database;
$hash = password_hash($password, PASSWORD_BCRYPT);
$this->passhash = $hash;
$database->execute("UPDATE users SET pass=:hash WHERE id=:id", ["hash" => $this->passhash, "id" => $this->id]);
log_info("core-user", 'Set password for '.$this->name);
if (is_string($hash)) {
$this->passhash = $hash;
$database->execute("UPDATE users SET pass=:hash WHERE id=:id", ["hash"=>$this->passhash, "id"=>$this->id]);
log_info("core-user", 'Set password for '.$this->name);
} else {
throw new SCoreException("Failed to hash password");
}
}
public function set_email(string $address): void
{
global $database;
$database->execute("UPDATE users SET email=:email WHERE id=:id", ["email" => $address, "id" => $this->id]);
$database->execute("UPDATE users SET email=:email WHERE id=:id", ["email"=>$address, "id"=>$this->id]);
log_info("core-user", 'Set email for '.$this->name);
}
@ -273,7 +284,7 @@ class User
public function get_auth_microhtml(): HTMLElement
{
$at = $this->get_auth_token();
return INPUT(["type" => "hidden", "name" => "auth_token", "value" => $at]);
return INPUT(["type"=>"hidden", "name"=>"auth_token", "value"=>$at]);
}
public function check_auth_token(): bool

View File

@ -6,6 +6,13 @@ namespace Shimmie2;
use GQLA\Type;
use GQLA\Field;
use GQLA\Query;
/**
* @global UserClass[] $_shm_user_classes
*/
global $_shm_user_classes;
$_shm_user_classes = [];
/**
* Class UserClass
@ -13,40 +20,31 @@ use GQLA\Field;
#[Type(name: "UserClass")]
class UserClass
{
/** @var array<string, UserClass> */
public static array $known_classes = [];
#[Field]
public ?string $name = null;
public ?UserClass $parent = null;
/** @var array<string, bool> */
public array $abilities = [];
/**
* @param array<string, bool> $abilities
*/
public function __construct(string $name, string $parent = null, array $abilities = [])
{
global $_shm_user_classes;
$this->name = $name;
$this->abilities = $abilities;
if (!is_null($parent)) {
$this->parent = static::$known_classes[$parent];
$this->parent = $_shm_user_classes[$parent];
}
static::$known_classes[$name] = $this;
$_shm_user_classes[$name] = $this;
}
/**
* @return string[]
*/
#[Field(type: "[Permission!]!")]
public function permissions(): array
{
global $_all_false;
$perms = [];
foreach ((new \ReflectionClass(Permissions::class))->getConstants() as $k => $v) {
foreach ((new \ReflectionClass('\Shimmie2\Permissions'))->getConstants() as $k => $v) {
if ($this->can($v)) {
$perms[] = $v;
}
@ -66,9 +64,10 @@ class UserClass
} elseif (!is_null($this->parent)) {
return $this->parent->can($ability);
} else {
global $_shm_user_classes;
$min_dist = 9999;
$min_ability = null;
foreach (UserClass::$known_classes['base']->abilities as $a => $cando) {
foreach ($_shm_user_classes['base']->abilities as $a => $cando) {
$v = levenshtein($ability, $a);
if ($v < $min_dist) {
$min_dist = $v;
@ -81,8 +80,7 @@ class UserClass
}
$_all_false = [];
foreach ((new \ReflectionClass(Permissions::class))->getConstants() as $k => $v) {
assert(is_string($v));
foreach ((new \ReflectionClass('\Shimmie2\Permissions'))->getConstants() as $k => $v) {
$_all_false[$v] = false;
}
new UserClass("base", null, $_all_false);
@ -90,7 +88,6 @@ unset($_all_false);
// Ghost users can't do anything
new UserClass("ghost", "base", [
Permissions::READ_PM => true,
]);
// Anonymous users can't do anything by default, but
@ -222,10 +219,10 @@ new UserClass("admin", "base", [
Permissions::APPROVE_COMMENT => true,
Permissions::BYPASS_IMAGE_APPROVAL => true,
Permissions::CRON_RUN => true,
Permissions::CRON_RUN =>true,
Permissions::BULK_IMPORT => true,
Permissions::BULK_EXPORT => true,
Permissions::BULK_IMPORT =>true,
Permissions::BULK_EXPORT =>true,
Permissions::BULK_DOWNLOAD => true,
Permissions::BULK_PARENT_CHILD => true,

View File

@ -54,8 +54,8 @@ function contact_link(): ?string
function is_https_enabled(): bool
{
// check forwarded protocol
if (is_trusted_proxy() && !empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https') {
$_SERVER['HTTPS'] = 'on';
if (REVERSE_PROXY_X_HEADERS && !empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https') {
$_SERVER['HTTPS']='on';
}
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
}
@ -80,10 +80,10 @@ function get_memory_limit(): int
global $config;
// thumbnail generation requires lots of memory
$default_limit = 8 * 1024 * 1024; // 8 MB of memory is PHP's default.
$default_limit = 8*1024*1024; // 8 MB of memory is PHP's default.
$shimmie_limit = $config->get_int(MediaConfig::MEM_LIMIT);
if ($shimmie_limit < 3 * 1024 * 1024) {
if ($shimmie_limit < 3*1024*1024) {
// we aren't going to fit, override
$shimmie_limit = $default_limit;
}
@ -143,43 +143,30 @@ function check_gd_version(): int
*/
function check_im_version(): int
{
$convert_check = exec("convert --version");
$convert_check = exec("convert");
return (empty($convert_check) ? 0 : 1);
}
function is_trusted_proxy(): bool
{
$ra = $_SERVER['REMOTE_ADDR'] ?? "0.0.0.0";
// @phpstan-ignore-next-line - TRUSTED_PROXIES is defined in config
foreach(TRUSTED_PROXIES as $proxy) {
if(ip_in_range($ra, $proxy)) {
return true;
}
}
return false;
}
/**
* Get request IP
*/
function get_remote_addr()
{
return $_SERVER['REMOTE_ADDR'];
}
/**
* Get real IP if behind a reverse proxy
*/
function get_real_ip(): string
function get_real_ip()
{
$ip = $_SERVER['REMOTE_ADDR'];
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;
}
$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";
}
}
@ -194,7 +181,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_ex(inet_pton_ex($addr) & inet_pton_ex($mask));
$addr = inet_ntop(inet_pton($addr) & inet_pton($mask));
return $addr;
}
@ -218,9 +205,9 @@ function format_text(string $string): string
* @param int $splits The number of octet pairs to split the hash into. Caps out at strlen($hash)/2.
* @return string
*/
function warehouse_path(string $base, string $hash, bool $create = true, int $splits = WH_SPLITS): string
function warehouse_path(string $base, string $hash, bool $create=true, int $splits = WH_SPLITS): string
{
$dirs = [DATA_DIR, $base];
$dirs =[DATA_DIR, $base];
$splits = min($splits, strlen($hash) / 2);
for ($i = 0; $i < $splits; $i++) {
$dirs[] = substr($hash, $i * 2, 2);
@ -241,13 +228,13 @@ function warehouse_path(string $base, string $hash, bool $create = true, int $sp
function data_path(string $filename, bool $create = true): string
{
$filename = join_path("data", $filename);
if ($create && !file_exists(dirname($filename))) {
if ($create&&!file_exists(dirname($filename))) {
mkdir(dirname($filename), 0755, true);
}
return $filename;
}
function load_balance_url(string $tmpl, string $hash, int $n = 0): string
function load_balance_url(string $tmpl, string $hash, int $n=0): string
{
static $flexihashes = [];
$matches = [];
@ -285,24 +272,16 @@ function load_balance_url(string $tmpl, string $hash, int $n = 0): string
return $tmpl;
}
class FetchException extends \Exception
{
}
/**
* @return array<string, string|string[]>
*/
function fetch_url(string $url, string $mfile): array
function fetch_url(string $url, string $mfile): ?array
{
global $config;
if ($config->get_string(UploadConfig::TRANSLOAD_ENGINE) === "curl" && function_exists("curl_init")) {
$ch = curl_init($url);
assert($ch !== false);
$fp = false_throws(fopen($mfile, "w"));
$fp = 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);
@ -310,37 +289,37 @@ function fetch_url(string $url, string $mfile): array
$response = curl_exec($ch);
if ($response === false) {
throw new FetchException("cURL failed: ".curl_error($ch));
}
if ($response === true) { // we use CURLOPT_RETURNTRANSFER, so this should never happen
throw new FetchException("cURL failed successfully??");
return null;
}
$header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$header_text = trim(substr($response, 0, $header_size));
$headers = http_parse_headers(implode("\n", false_throws(preg_split('/\R/', $header_text))));
$headers = http_parse_headers(implode("\n", preg_split('/\R/', rtrim(substr($response, 0, $header_size)))));
$body = substr($response, $header_size);
curl_close($ch);
fwrite($fp, $body);
fclose($fp);
} elseif ($config->get_string(UploadConfig::TRANSLOAD_ENGINE) === "wget") {
return $headers;
}
if ($config->get_string(UploadConfig::TRANSLOAD_ENGINE) === "wget") {
$s_url = escapeshellarg($url);
$s_mfile = escapeshellarg($mfile);
system("wget --no-check-certificate $s_url --output-document=$s_mfile");
if(!file_exists($mfile)) {
throw new FetchException("wget failed");
}
$headers = [];
} elseif ($config->get_string(UploadConfig::TRANSLOAD_ENGINE) === "fopen") {
return file_exists($mfile) ? ["ok"=>"true"] : null;
}
if ($config->get_string(UploadConfig::TRANSLOAD_ENGINE) === "fopen") {
$fp_in = @fopen($url, "r");
$fp_out = fopen($mfile, "w");
if (!$fp_in || !$fp_out) {
throw new FetchException("fopen failed");
return null;
}
$length = 0;
while (!feof($fp_in) && $length <= $config->get_int(UploadConfig::SIZE)) {
$data = false_throws(fread($fp_in, 8192));
$data = fread($fp_in, 8192);
$length += strlen($data);
fwrite($fp_out, $data);
}
@ -348,22 +327,14 @@ function fetch_url(string $url, string $mfile): array
fclose($fp_out);
$headers = http_parse_headers(implode("\n", $http_response_header));
} else {
throw new FetchException("No transload engine configured");
return $headers;
}
if (filesize($mfile) == 0) {
@unlink($mfile);
throw new FetchException("No data found in $url -- perhaps the site has hotlink protection?");
}
return $headers;
return null;
}
/**
* @return string[]
*/
function path_to_tags(string $path): array
function path_to_tags(string $path): string
{
$matches = [];
$tags = [];
@ -384,7 +355,7 @@ function path_to_tags(string $path): array
$category_to_inherit = "";
foreach (explode(" ", $dir) as $tag) {
$tag = trim($tag);
if ($tag == "") {
if ($tag=="") {
continue;
}
if (substr_compare($tag, ":", -1) === 0) {
@ -392,7 +363,7 @@ function path_to_tags(string $path): array
// which is for inheriting to tags on the subfolder
$category_to_inherit = $tag;
} else {
if ($category != "" && !str_contains($tag, ":")) {
if ($category!="" && !str_contains($tag, ":")) {
// This indicates that category inheritance is active,
// and we've encountered a tag that does not specify a category.
// So we attach the inherited category to the tag.
@ -407,12 +378,9 @@ function path_to_tags(string $path): array
$category = $category_to_inherit;
}
return $tags;
return implode(" ", $tags);
}
/**
* @return string[]
*/
function get_dir_contents(string $dir): array
{
assert(!empty($dir));
@ -421,17 +389,29 @@ function get_dir_contents(string $dir): array
return [];
}
return array_diff(
false_throws(scandir($dir)),
scandir(
$dir
),
['..', '.']
);
}
function remove_empty_dirs(string $dir): bool
{
assert(!empty($dir));
$result = true;
$items = get_dir_contents($dir);
;
if (!is_dir($dir)) {
return false;
}
$items = array_diff(
scandir(
$dir
),
['..', '.']
);
foreach ($items as $item) {
$path = join_path($dir, $item);
if (is_dir($path)) {
@ -440,21 +420,31 @@ function remove_empty_dirs(string $dir): bool
$result = false;
}
}
if ($result === true) {
if ($result===true) {
$result = rmdir($dir);
}
return $result;
}
/**
* @return string[]
*/
function get_files_recursively(string $dir): array
{
$things = get_dir_contents($dir);
assert(!empty($dir));
if (!is_dir($dir)) {
return [];
}
$things = array_diff(
scandir(
$dir
),
['..', '.']
);
$output = [];
foreach ($things as $thing) {
$path = join_path($dir, $thing);
if (is_file($path)) {
@ -469,8 +459,6 @@ function get_files_recursively(string $dir): array
/**
* Returns amount of files & total size of dir.
*
* @return array{"path": string, "total_files": int, "total_mb": string}
*/
function scan_dir(string $path): array
{
@ -532,11 +520,6 @@ function get_debug_info(): string
return $debug;
}
/**
* Collects some debug information (execution time, memory usage, queries, etc)
*
* @return array<string, mixed>
*/
function get_debug_info_arr(): array
{
global $cache, $config, $_shm_event_count, $database, $_shm_load_start;
@ -550,7 +533,7 @@ function get_debug_info_arr(): array
return [
"time" => round(ftime() - $_shm_load_start, 2),
"dbtime" => round($database->dbtime, 2),
"mem_mb" => round(((memory_get_peak_usage(true) + 512) / 1024) / 1024, 2),
"mem_mb" => round(((memory_get_peak_usage(true)+512)/1024)/1024, 2),
"files" => count(get_included_files()),
"query_count" => $database->query_count,
// "query_log" => $database->queries,
@ -566,9 +549,6 @@ function get_debug_info_arr(): array
* Request initialisation stuff *
\* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/**
* @param string[] $files
*/
function require_all(array $files): void
{
foreach ($files as $filename) {
@ -576,7 +556,7 @@ function require_all(array $files): void
}
}
function _load_core_files(): void
function _load_core_files()
{
require_all(array_merge(
zglob("core/*.php"),
@ -585,20 +565,11 @@ function _load_core_files(): void
));
}
function _load_extension_files(): void
{
ExtensionInfo::load_all_extension_info();
Extension::determine_enabled_extensions();
require_all(zglob("ext/{".Extension::get_enabled_extensions_as_string()."}/main.php"));
}
function _load_theme_files(): void
function _load_theme_files()
{
$theme = get_theme();
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'));
$files = _get_themelet_files($theme);
require_all($files);
}
function _set_up_shimmie_environment(): void
@ -620,8 +591,20 @@ function _set_up_shimmie_environment(): void
// The trace system has a certain amount of memory consumption every time it is used,
// so to prevent running out of memory during complex operations code that uses it should
// check if tracer output is enabled before making use of it.
// @phpstan-ignore-next-line - TRACE_FILE is defined in config
$tracer_enabled = !is_null('TRACE_FILE');
$tracer_enabled = constant('TRACE_FILE')!==null;
}
function _get_themelet_files(string $_theme): array
{
$base_themelets = [];
$base_themelets[] = 'themes/'.$_theme.'/page.class.php';
$base_themelets[] = 'themes/'.$_theme.'/themelet.class.php';
$ext_themelets = zglob("ext/{".Extension::get_enabled_extensions_as_string()."}/theme.php");
$custom_themelets = zglob('themes/'.$_theme.'/{'.Extension::get_enabled_extensions_as_string().'}.theme.php');
return array_merge($base_themelets, $ext_themelets, $custom_themelets);
}
@ -633,6 +616,8 @@ function _fatal_error(\Exception $e): void
$version = VERSION;
$message = $e->getMessage();
$phpver = phpversion();
$query = is_subclass_of($e, "Shimmie2\SCoreException") ? $e->query : null;
$code = is_subclass_of($e, "Shimmie2\SCoreException") ? $e->http_code : 500;
//$hash = exec("git rev-parse HEAD");
//$h_hash = $hash ? "<p><b>Hash:</b> $hash" : "";
@ -644,29 +629,19 @@ function _fatal_error(\Exception $e): void
foreach ($t as $n => $f) {
$c = $f['class'] ?? '';
$t = $f['type'] ?? '';
$i = $f['file'] ?? 'unknown file';
$l = $f['line'] ?? -1;
$a = implode(", ", array_map("Shimmie2\stringer", $f['args'] ?? []));
print("$n: {$i}({$l}): {$c}{$t}{$f['function']}({$a})\n");
print("$n: {$f['file']}({$f['line']}): {$c}{$t}{$f['function']}({$a})\n");
}
print("Message: $message\n");
if (is_a($e, DatabaseException::class)) {
print("Query: {$e->query}\n");
print("Args: ".var_export($e->args, true)."\n");
if ($query) {
print("Query: {$query}\n");
}
print("Version: $version (on $phpver)\n");
} else {
$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));
}
$q = $query ? "" : "<p><b>Query:</b> " . html_escape($query);
if ($code >= 500) {
error_log("Shimmie Error: $message (Query: $query)\n{$e->getTraceAsString()}");
}
@ -702,7 +677,7 @@ function _get_user(): User
}
}
}
if (is_null($my_user) && $page->get_cookie("user") && $page->get_cookie("session")) {
if ($page->get_cookie("user") && $page->get_cookie("session")) {
$my_user = User::by_session($page->get_cookie("user"), $page->get_cookie("session"));
}
if (is_null($my_user)) {
@ -713,6 +688,11 @@ function _get_user(): User
return $my_user;
}
function _get_query(): string
{
return (@$_POST["q"] ?: @$_GET["q"]) ?: "/";
}
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\
* HTML Generation *
@ -737,7 +717,7 @@ 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, string $method="POST", bool $multipart=false, string $form_id="", string $onsubmit=""): string
{
global $user;
if ($method == "GET") {
@ -759,7 +739,7 @@ function make_form(string $target, string $method = "POST", bool $multipart = fa
}
const BYTE_DENOMINATIONS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
function human_filesize(int $bytes, int $decimals = 2): string
function human_filesize(int $bytes, $decimals = 2): string
{
$factor = floor((strlen(strval($bytes)) - 1) / 3);
return sprintf("%.{$decimals}f", $bytes / pow(1024, $factor)) . @BYTE_DENOMINATIONS[$factor];
@ -779,12 +759,3 @@ function generate_key(int $length = 20): string
return $randomString;
}
function shm_tempnam(string $prefix = ""): string
{
if(!is_dir("data/temp")) {
mkdir("data/temp");
}
$temp = false_throws(realpath("data/temp"));
return false_throws(tempnam($temp, $prefix));
}

View File

@ -4,10 +4,6 @@ declare(strict_types=1);
namespace Shimmie2;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\{InputInterface,InputArgument};
use Symfony\Component\Console\Output\OutputInterface;
/**
* Sent when the admin page is ready to be added to
*/
@ -39,7 +35,7 @@ class AdminPage extends Extension
/** @var AdminPageTheme */
protected Themelet $theme;
public function onPageRequest(PageRequestEvent $event): void
public function onPageRequest(PageRequestEvent $event)
{
global $database, $page, $user;
@ -58,8 +54,6 @@ class AdminPage extends Extension
shm_set_timeout(null);
$database->set_timeout(null);
send_event($aae);
} else {
throw new SCoreException("Invalid CSRF token");
}
if ($aae->redirect) {
@ -71,108 +65,86 @@ class AdminPage extends Extension
}
}
public function onCliGen(CliGenEvent $event): void
public function onCommand(CommandEvent $event)
{
$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'] = $query;
if (!is_null($args)) {
parse_str($args, $_GET);
$_SERVER['REQUEST_URI'] .= "?" . $args;
}
send_event(new PageRequestEvent("GET", $query));
$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));
$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('regen-thumb')
->addArgument('id_or_hash', InputArgument::REQUIRED)
->setDescription("Regenerate a post's thumbnail")
->setCode(function (InputInterface $input, OutputInterface $output): int {
$uid = $input->getArgument('id_or_hash');
$image = Image::by_id_or_hash($uid);
if ($image) {
send_event(new ThumbnailGenerationEvent($image, true));
} else {
$output->writeln("No post with ID '$uid'\n");
}
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;
});
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;
}
}
}
public function onAdminBuilding(AdminBuildingEvent $event): void
public function onAdminBuilding(AdminBuildingEvent $event)
{
$this->theme->display_page();
}
public function onPageSubNavBuilding(PageSubNavBuildingEvent $event): void
public function onPageSubNavBuilding(PageSubNavBuildingEvent $event)
{
global $user;
if ($event->parent === "system") {
if ($event->parent==="system") {
if ($user->can(Permissions::MANAGE_ADMINTOOLS)) {
$event->add_nav_link("admin", new Link('admin'), "Board Admin");
}
}
}
public function onUserBlockBuilding(UserBlockBuildingEvent $event): void
public function onUserBlockBuilding(UserBlockBuildingEvent $event)
{
global $user;
if ($user->can(Permissions::MANAGE_ADMINTOOLS)) {

View File

@ -6,7 +6,7 @@ namespace Shimmie2;
class AdminPageTest extends ShimmiePHPUnitTestCase
{
public function testAuth(): void
public function testAuth()
{
send_event(new UserLoginEvent(User::by_name(self::$anon_name)));
$page = $this->get_page('admin');
@ -23,4 +23,19 @@ class AdminPageTest extends ShimmiePHPUnitTestCase
$this->assertEquals(200, $page->code);
$this->assertEquals("Admin Tools", $page->title);
}
public function testCommands()
{
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();
// don't crash
$this->assertTrue(true);
}
}

View File

@ -9,7 +9,7 @@ class AdminPageTheme extends Themelet
/*
* Show the basics of a page, for other extensions to add to
*/
public function display_page(): void
public function display_page()
{
global $page;

View File

@ -19,12 +19,12 @@ class AliasTable extends Table
$this->size = 100;
$this->limit = 1000000;
$this->set_columns([
new AutoCompleteColumn("oldtag", "Old Tag"),
new AutoCompleteColumn("newtag", "New Tag"),
new TextColumn("oldtag", "Old Tag"),
new TextColumn("newtag", "New Tag"),
new ActionColumn("oldtag"),
]);
$this->order_by = ["oldtag"];
$this->table_attrs = ["class" => "zebra form"];
$this->table_attrs = ["class" => "zebra"];
}
}
@ -61,7 +61,7 @@ class AliasEditor extends Extension
/** @var AliasEditorTheme */
protected Themelet $theme;
public function onPageRequest(PageRequestEvent $event): void
public function onPageRequest(PageRequestEvent $event)
{
global $config, $database, $page, $user;
@ -69,7 +69,7 @@ class AliasEditor extends Extension
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"]);
$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);
@ -81,7 +81,7 @@ class AliasEditor extends Extension
} elseif ($event->get_arg(0) == "remove") {
if ($user->can(Permissions::MANAGE_ALIAS_LIST)) {
$user->ensure_authed();
$input = validate_input(["d_oldtag" => "string"]);
$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"));
@ -105,7 +105,7 @@ class AliasEditor extends Extension
if ($user->can(Permissions::MANAGE_ALIAS_LIST)) {
if (count($_FILES) > 0) {
$tmp = $_FILES['alias_file']['tmp_name'];
$contents = file_get_contents_ex($tmp);
$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);
@ -120,13 +120,13 @@ class AliasEditor extends Extension
}
}
public function onAddAlias(AddAliasEvent $event): void
public function onAddAlias(AddAliasEvent $event)
{
global $database;
$row = $database->get_row(
"SELECT * FROM aliases WHERE lower(oldtag)=lower(:oldtag)",
["oldtag" => $event->oldtag]
["oldtag"=>$event->oldtag]
);
if ($row) {
throw new AddAliasException("{$row['oldtag']} is already an alias for {$row['newtag']}");
@ -147,21 +147,21 @@ class AliasEditor extends Extension
log_info("alias_editor", "Added alias for {$event->oldtag} -> {$event->newtag}", "Added alias");
}
public function onDeleteAlias(DeleteAliasEvent $event): void
public function onDeleteAlias(DeleteAliasEvent $event)
{
global $database;
$database->execute("DELETE FROM aliases WHERE oldtag=:oldtag", ["oldtag" => $event->oldtag]);
log_info("alias_editor", "Deleted alias for {$event->oldtag}", "Deleted alias");
}
public function onPageSubNavBuilding(PageSubNavBuildingEvent $event): void
public function onPageSubNavBuilding(PageSubNavBuildingEvent $event)
{
if ($event->parent == "tags") {
if ($event->parent=="tags") {
$event->add_nav_link("aliases", new Link('alias/list'), "Aliases", NavLink::is_active(["alias"]));
}
}
public function onUserBlockBuilding(UserBlockBuildingEvent $event): void
public function onUserBlockBuilding(UserBlockBuildingEvent $event)
{
global $user;
if ($user->can(Permissions::MANAGE_ALIAS_LIST)) {
@ -174,7 +174,6 @@ class AliasEditor extends Extension
$csv = "";
$aliases = $database->get_pairs("SELECT oldtag, newtag FROM aliases ORDER BY newtag");
foreach ($aliases as $old => $new) {
assert(is_string($new));
$csv .= "\"$old\",\"$new\"\n";
}
return $csv;

View File

@ -6,14 +6,14 @@ namespace Shimmie2;
class AliasEditorTest extends ShimmiePHPUnitTestCase
{
public function testAliasList(): void
public function testAliasList()
{
$this->get_page('alias/list');
$this->assert_response(200);
$this->assert_title("Alias List");
}
public function testAliasListReadOnly(): void
public function testAliasListReadOnly()
{
$this->log_in_as_user();
$this->get_page('alias/list');
@ -26,7 +26,7 @@ class AliasEditorTest extends ShimmiePHPUnitTestCase
$this->assert_no_text("Add");
}
public function testAliasOneToOne(): void
public function testAliasOneToOne()
{
$this->log_in_as_admin();
@ -54,7 +54,7 @@ class AliasEditorTest extends ShimmiePHPUnitTestCase
$this->assert_no_text("test1");
}
public function testAliasOneToMany(): void
public function testAliasOneToMany()
{
$this->log_in_as_admin();

View File

@ -18,11 +18,11 @@ class AliasEditorTheme extends Themelet
{
global $page, $user;
$html = emptyHTML($table, BR(), $paginator, BR(), SHM_A("alias/export/aliases.csv", "Download as CSV", args: ["download" => "aliases.csv"]));
$html = emptyHTML($table, BR(), $paginator, BR(), SHM_A("alias/export/aliases.csv", "Download as CSV", args: ["download"=>"aliases.csv"]));
$bulk_form = SHM_FORM("alias/import", multipart: true);
$bulk_form->appendChild(
INPUT(["type" => "file", "name" => "alias_file"]),
INPUT(["type"=>"file", "name"=>"alias_file"]),
SHM_SUBMIT("Upload List")
);
$bulk_html = emptyHTML($bulk_form);

View File

@ -10,7 +10,7 @@ class ApprovalInfo extends ExtensionInfo
public string $key = self::KEY;
public string $name = "Approval";
public array $authors = ["Matthew Barbour" => "matthew@darkholme.net"];
public array $authors = ["Matthew Barbour"=>"matthew@darkholme.net"];
public string $license = self::LICENSE_WTFPL;
public string $description = "Adds an approval step to the upload/import process.";
}

View File

@ -16,18 +16,17 @@ class Approval extends Extension
/** @var ApprovalTheme */
protected Themelet $theme;
public function onInitExt(InitExtEvent $event): void
public function onInitExt(InitExtEvent $event)
{
global $config;
$config->set_default_bool(ApprovalConfig::IMAGES, false);
$config->set_default_bool(ApprovalConfig::COMMENTS, false);
Image::$prop_types["approved"] = ImagePropType::BOOL;
Image::$prop_types["approved_by_id"] = ImagePropType::INT;
Image::$bool_props[] = "approved";
}
public function onImageAddition(ImageAdditionEvent $event): void
public function onImageAddition(ImageAdditionEvent $event)
{
global $user, $config;
@ -36,7 +35,7 @@ class Approval extends Extension
}
}
public function onPageRequest(PageRequestEvent $event): void
public function onPageRequest(PageRequestEvent $event)
{
global $page, $user;
@ -71,37 +70,37 @@ class Approval extends Extension
}
}
public function onSetupBuilding(SetupBuildingEvent $event): void
public function onSetupBuilding(SetupBuildingEvent $event)
{
$this->theme->display_admin_block($event);
}
public function onAdminBuilding(AdminBuildingEvent $event): void
public function onAdminBuilding(AdminBuildingEvent $event)
{
$this->theme->display_admin_form();
}
public function onAdminAction(AdminActionEvent $event): void
public function onAdminAction(AdminActionEvent $event)
{
global $database, $user;
$action = $event->action;
$event->redirect = true;
if ($action === "approval") {
if ($action==="approval") {
$approval_action = $_POST["approval_action"];
switch ($approval_action) {
case "approve_all":
$database->set_timeout(null); // These updates can take a little bit
$database->execute(
"UPDATE images SET approved = :true, approved_by_id = :approved_by_id WHERE approved = :false",
["approved_by_id" => $user->id, "true" => true, "false" => false]
["approved_by_id"=>$user->id, "true"=>true, "false"=>false]
);
break;
case "disapprove_all":
$database->set_timeout(null); // These updates can take a little bit
$database->execute(
"UPDATE images SET approved = :false, approved_by_id = NULL WHERE approved = :true",
["true" => true, "false" => false]
["true"=>true, "false"=>false]
);
break;
default:
@ -111,36 +110,36 @@ class Approval extends Extension
}
}
public function onDisplayingImage(DisplayingImageEvent $event): void
public function onDisplayingImage(DisplayingImageEvent $event)
{
global $page;
if (!$this->check_permissions(($event->image))) {
$page->set_mode(PageMode::REDIRECT);
$page->set_redirect(make_link());
$page->set_redirect(make_link("post/list"));
}
}
public function onPageSubNavBuilding(PageSubNavBuildingEvent $event): void
public function onPageSubNavBuilding(PageSubNavBuildingEvent $event)
{
global $user;
if ($event->parent == "posts") {
if ($event->parent=="posts") {
if ($user->can(Permissions::APPROVE_IMAGE)) {
$event->add_nav_link("posts_unapproved", new Link('/post/list/approved%3Ano/1'), "Pending Approval", null, 60);
}
}
}
public function onUserBlockBuilding(UserBlockBuildingEvent $event): void
public function onUserBlockBuilding(UserBlockBuildingEvent $event)
{
global $user;
if ($user->can(Permissions::APPROVE_IMAGE)) {
$event->add_link("Pending Approval", search_link(["approved:no"]), 60);
$event->add_link("Pending Approval", make_link("/post/list/approved%3Ano/1"), 60);
}
}
public const SEARCH_REGEXP = "/^approved:(yes|no)/";
public function onSearchTermParse(SearchTermParseEvent $event): void
public function onSearchTermParse(SearchTermParseEvent $event)
{
global $user, $config;
@ -148,7 +147,7 @@ class Approval extends Extension
$matches = [];
if (is_null($event->term) && $this->no_approval_query($event->context)) {
$event->add_querylet(new Querylet("approved = :true", ["true" => true]));
$event->add_querylet(new Querylet("approved = :true", ["true"=>true]));
}
if (is_null($event->term)) {
@ -156,27 +155,25 @@ class Approval extends Extension
}
if (preg_match(self::SEARCH_REGEXP, strtolower($event->term), $matches)) {
if ($user->can(Permissions::APPROVE_IMAGE) && $matches[1] == "no") {
$event->add_querylet(new Querylet("approved != :true", ["true" => true]));
$event->add_querylet(new Querylet("approved != :true", ["true"=>true]));
} else {
$event->add_querylet(new Querylet("approved = :true", ["true" => true]));
$event->add_querylet(new Querylet("approved = :true", ["true"=>true]));
}
}
}
}
public function onHelpPageBuilding(HelpPageBuildingEvent $event): void
public function onHelpPageBuilding(HelpPageBuildingEvent $event)
{
global $user, $config;
if ($event->key === HelpPages::SEARCH) {
if ($event->key===HelpPages::SEARCH) {
if ($user->can(Permissions::APPROVE_IMAGE) && $config->get_bool(ApprovalConfig::IMAGES)) {
$event->add_block(new Block("Approval", $this->theme->get_help_html()));
}
}
}
/**
* @param string[] $context
*/
private function no_approval_query(array $context): bool
{
foreach ($context as $term) {
@ -187,23 +184,23 @@ class Approval extends Extension
return true;
}
public static function approve_image(int $image_id): void
public static function approve_image($image_id)
{
global $database, $user;
$database->execute(
"UPDATE images SET approved = :true, approved_by_id = :approved_by_id WHERE id = :id AND approved = :false",
["approved_by_id" => $user->id, "id" => $image_id, "true" => true, "false" => false]
["approved_by_id"=>$user->id, "id"=>$image_id, "true"=>true, "false"=>false]
);
}
public static function disapprove_image(int $image_id): void
public static function disapprove_image($image_id)
{
global $database;
$database->execute(
"UPDATE images SET approved = :false, approved_by_id = NULL WHERE id = :id AND approved = :true",
["id" => $image_id, "true" => true, "false" => false]
["id"=>$image_id, "true"=>true, "false"=>false]
);
}
@ -211,13 +208,13 @@ class Approval extends Extension
{
global $user, $config;
if ($config->get_bool(ApprovalConfig::IMAGES) && $image['approved'] === false && !$user->can(Permissions::APPROVE_IMAGE) && $user->id !== $image->owner_id) {
if ($config->get_bool(ApprovalConfig::IMAGES) && $image->approved===false && !$user->can(Permissions::APPROVE_IMAGE) && $user->id!==$image->owner_id) {
return false;
}
return true;
}
public function onImageDownloading(ImageDownloadingEvent $event): void
public function onImageDownloading(ImageDownloadingEvent $event)
{
/**
* Deny images upon insufficient permissions.
@ -227,19 +224,19 @@ class Approval extends Extension
}
}
public function onImageAdminBlockBuilding(ImageAdminBlockBuildingEvent $event): void
public function onImageAdminBlockBuilding(ImageAdminBlockBuildingEvent $event)
{
global $user, $config;
if ($user->can(Permissions::APPROVE_IMAGE) && $config->get_bool(ApprovalConfig::IMAGES)) {
$event->add_part($this->theme->get_image_admin_html($event->image));
$event->add_part((string)$this->theme->get_image_admin_html($event->image));
}
}
public function onBulkActionBlockBuilding(BulkActionBlockBuildingEvent $event): void
public function onBulkActionBlockBuilding(BulkActionBlockBuildingEvent $event)
{
global $user, $config;
if ($user->can(Permissions::APPROVE_IMAGE) && $config->get_bool(ApprovalConfig::IMAGES)) {
if ($user->can(Permissions::APPROVE_IMAGE)&& $config->get_bool(ApprovalConfig::IMAGES)) {
if (in_array("approved:no", $event->search_terms)) {
$event->add_action("bulk_approve_image", "Approve", "a");
} else {
@ -248,7 +245,7 @@ class Approval extends Extension
}
}
public function onBulkAction(BulkActionEvent $event): void
public function onBulkAction(BulkActionEvent $event)
{
global $page, $user;
@ -276,7 +273,7 @@ class Approval extends Extension
}
}
public function onDatabaseUpgrade(DatabaseUpgradeEvent $event): void
public function onDatabaseUpgrade(DatabaseUpgradeEvent $event)
{
global $database;

View File

@ -14,16 +14,16 @@ class ApprovalTheme extends Themelet
{
public function get_image_admin_html(Image $image): HTMLElement
{
if ($image['approved'] === true) {
if ($image->approved===true) {
$form = SHM_SIMPLE_FORM(
'disapprove_image/'.$image->id,
INPUT(["type" => 'hidden', "name" => 'image_id', "value" => $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]),
INPUT(["type"=>'hidden', "name"=>'image_id', "value"=>$image->id]),
SHM_SUBMIT("Approve")
);
}
@ -40,21 +40,21 @@ class ApprovalTheme extends Themelet
);
}
public function display_admin_block(SetupBuildingEvent $event): void
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(): void
public function display_admin_form()
{
global $page;
$form = SHM_SIMPLE_FORM(
"admin/approval",
BUTTON(["name" => 'approval_action', "value" => 'approve_all'], "Approve All Posts"),
BUTTON(["name"=>'approval_action', "value"=>'approve_all'], "Approve All Posts"),
" ",
BUTTON(["name" => 'approval_action', "value" => 'disapprove_all'], "Disapprove All Posts"),
BUTTON(["name"=>'approval_action', "value"=>'disapprove_all'], "Disapprove All Posts"),
);
$page->add_block(new Block("Approval", $form));

View File

@ -11,7 +11,7 @@ class ArtistsInfo extends ExtensionInfo
public string $key = self::KEY;
public string $name = "Artists System";
public string $url = self::SHIMMIE_URL;
public array $authors = ["Sein Kraft" => "mail@seinkraft.info","Alpha" => "alpha@furries.com.ar"];
public array $authors = ["Sein Kraft"=>"mail@seinkraft.info","Alpha"=>"alpha@furries.com.ar"];
public string $license = self::LICENSE_GPLV2;
public string $description = "Simple artists extension";
public bool $beta = true;

View File

@ -19,23 +19,12 @@ class AuthorSetEvent extends Event
}
}
/**
* @phpstan-type ArtistArtist array{id:int,artist_id:int,user_name:string,name:string,notes:string,type:string,posts:int}
* @phpstan-type ArtistAlias array{id:int,alias_id:int,alias_name:string,alias:string}
* @phpstan-type ArtistMember array{id:int,name:string}
* @phpstan-type ArtistUrl array{id:int,url:string}
*/
class Artists extends Extension
{
/** @var ArtistsTheme */
protected Themelet $theme;
public function onInitExt(InitExtEvent $event): void
{
Image::$prop_types["author"] = ImagePropType::STRING;
}
public function onImageInfoSet(ImageInfoSetEvent $event): void
public function onImageInfoSet(ImageInfoSetEvent $event)
{
global $user;
if ($user->can(Permissions::EDIT_IMAGE_ARTIST) && isset($_POST["tag_edit__author"])) {
@ -43,7 +32,7 @@ class Artists extends Extension
}
}
public function onImageInfoBoxBuilding(ImageInfoBoxBuildingEvent $event): void
public function onImageInfoBoxBuilding(ImageInfoBoxBuildingEvent $event)
{
global $user;
$artistName = $this->get_artistName_by_imageID($event->image->id);
@ -52,7 +41,7 @@ class Artists extends Extension
}
}
public function onSearchTermParse(SearchTermParseEvent $event): void
public function onSearchTermParse(SearchTermParseEvent $event)
{
if (is_null($event->term)) {
return;
@ -61,18 +50,18 @@ class Artists extends Extension
$matches = [];
if (preg_match("/^(author|artist)[=|:](.*)$/i", $event->term, $matches)) {
$char = $matches[2];
$event->add_querylet(new Querylet("author = :author_char", ["author_char" => $char]));
$event->add_querylet(new Querylet("author = :author_char", ["author_char"=>$char]));
}
}
public function onHelpPageBuilding(HelpPageBuildingEvent $event): void
public function onHelpPageBuilding(HelpPageBuildingEvent $event)
{
if ($event->key === HelpPages::SEARCH) {
if ($event->key===HelpPages::SEARCH) {
$event->add_block(new Block("Artist", $this->theme->get_help_html()));
}
}
public function onDatabaseUpgrade(DatabaseUpgradeEvent $event): void
public function onDatabaseUpgrade(DatabaseUpgradeEvent $event)
{
global $config, $database;
@ -124,7 +113,7 @@ class Artists extends Extension
}
}
public function onAuthorSet(AuthorSetEvent $event): void
public function onAuthorSet(AuthorSetEvent $event)
{
global $database;
@ -161,11 +150,11 @@ class Artists extends Extension
$database->execute(
"UPDATE images SET author = :author WHERE id = :id",
['author' => $artistName, 'id' => $event->image->id]
['author'=>$artistName, 'id'=>$event->image->id]
);
}
public function onPageRequest(PageRequestEvent $event): void
public function onPageRequest(PageRequestEvent $event)
{
global $page, $user;
@ -220,7 +209,7 @@ class Artists extends Extension
$userIsLogged = !$user->is_anonymous();
$userIsAdmin = $user->can(Permissions::ARTISTS_ADMIN);
$images = Search::find_images(limit: 4, tags: Tag::explode($artist['name']));
$images = Image::find_images(limit: 4, tags: Tag::explode($artist['name']));
$this->theme->show_artist($artist, $aliases, $members, $urls, $images, $userIsLogged, $userIsAdmin);
/*
@ -430,28 +419,28 @@ class Artists extends Extension
private function get_artistName_by_imageID(int $imageID): string
{
global $database;
$result = $database->get_row("SELECT author FROM images WHERE id = :id", ['id' => $imageID]);
$result = $database->get_row("SELECT author FROM images WHERE id = :id", ['id'=>$imageID]);
return $result['author'] ?? "";
}
private function url_exists_by_url(string $url): bool
{
global $database;
$result = $database->get_one("SELECT COUNT(1) FROM artist_urls WHERE url = :url", ['url' => $url]);
$result = $database->get_one("SELECT COUNT(1) FROM artist_urls WHERE url = :url", ['url'=>$url]);
return ($result != 0);
}
private function member_exists_by_name(string $member): bool
{
global $database;
$result = $database->get_one("SELECT COUNT(1) FROM artist_members WHERE name = :name", ['name' => $member]);
$result = $database->get_one("SELECT COUNT(1) FROM artist_members WHERE name = :name", ['name'=>$member]);
return ($result != 0);
}
private function alias_exists_by_name(string $alias): bool
{
global $database;
$result = $database->get_one("SELECT COUNT(1) FROM artist_alias WHERE alias = :alias", ['alias' => $alias]);
$result = $database->get_one("SELECT COUNT(1) FROM artist_alias WHERE alias = :alias", ['alias'=>$alias]);
return ($result != 0);
}
@ -460,7 +449,7 @@ class Artists extends Extension
global $database;
$result = $database->get_one(
"SELECT COUNT(1) FROM artist_alias WHERE artist_id = :artist_id AND alias = :alias",
['artist_id' => $artistID, 'alias' => $alias]
['artist_id'=>$artistID, 'alias'=>$alias]
);
return ($result != 0);
}
@ -468,85 +457,76 @@ 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): void
private function delete_alias(int $aliasID)
{
global $database;
$database->execute("DELETE FROM artist_alias WHERE id = :id", ['id' => $aliasID]);
$database->execute("DELETE FROM artist_alias WHERE id = :id", ['id'=>$aliasID]);
}
private function delete_url(int $urlID): void
private function delete_url(int $urlID)
{
global $database;
$database->execute("DELETE FROM artist_urls WHERE id = :id", ['id' => $urlID]);
$database->execute("DELETE FROM artist_urls WHERE id = :id", ['id'=>$urlID]);
}
private function delete_member(int $memberID): void
private function delete_member(int $memberID)
{
global $database;
$database->execute("DELETE FROM artist_members WHERE id = :id", ['id' => $memberID]);
$database->execute("DELETE FROM artist_members WHERE id = :id", ['id'=>$memberID]);
}
/**
* @return ArtistAlias
*/
private function get_alias_by_id(int $aliasID): array
{
global $database;
return $database->get_row("SELECT * FROM artist_alias WHERE id = :id", ['id' => $aliasID]);
return $database->get_row("SELECT * FROM artist_alias WHERE id = :id", ['id'=>$aliasID]);
}
/**
* @return ArtistUrl
*/
private function get_url_by_id(int $urlID): array
{
global $database;
return $database->get_row("SELECT * FROM artist_urls WHERE id = :id", ['id' => $urlID]);
return $database->get_row("SELECT * FROM artist_urls WHERE id = :id", ['id'=>$urlID]);
}
/**
* @return ArtistMember
*/
private function get_member_by_id(int $memberID): array
{
global $database;
return $database->get_row("SELECT * FROM artist_members WHERE id = :id", ['id' => $memberID]);
return $database->get_row("SELECT * FROM artist_members WHERE id = :id", ['id'=>$memberID]);
}
private function update_artist(): void
private function update_artist()
{
global $user;
$inputs = validate_input([
@ -578,13 +558,13 @@ class Artists extends Extension
global $database;
$database->execute(
"UPDATE artists SET name = :name, notes = :notes, updated = now(), user_id = :user_id WHERE id = :id",
['name' => $name, 'notes' => $notes, 'user_id' => $userID, 'id' => $artistID]
['name'=>$name, 'notes'=>$notes, 'user_id'=>$userID, 'id'=>$artistID]
);
// ALIAS MATCHING SECTION
$i = 0;
$aliasesAsArray = is_null($aliasesAsString) ? [] : explode(" ", $aliasesAsString);
$aliasesIDsAsArray = is_null($aliasesIDsAsString) ? [] : array_map(fn ($n) => int_escape($n), explode(" ", $aliasesIDsAsString));
$aliasesIDsAsArray = is_null($aliasesIDsAsString) ? [] : explode(" ", $aliasesIDsAsString);
while ($i < count($aliasesAsArray)) {
// if an alias was updated
if ($i < count($aliasesIDsAsArray)) {
@ -604,7 +584,7 @@ class Artists extends Extension
// MEMBERS MATCHING SECTION
$i = 0;
$membersAsArray = is_null($membersAsString) ? [] : explode(" ", $membersAsString);
$membersIDsAsArray = is_null($membersIDsAsString) ? [] : array_map(fn ($n) => int_escape($n), explode(" ", $membersIDsAsString));
$membersIDsAsArray = is_null($membersIDsAsString) ? [] : explode(" ", $membersIDsAsString);
while ($i < count($membersAsArray)) {
// if a member was updated
if ($i < count($membersIDsAsArray)) {
@ -623,11 +603,10 @@ class Artists extends Extension
// URLS MATCHING SECTION
$i = 0;
assert(is_string($urlsAsString));
$urlsAsString = str_replace("\r\n", "\n", $urlsAsString);
$urlsAsString = str_replace("\n\r", "\n", $urlsAsString);
$urlsAsArray = empty($urlsAsString) ? [] : explode("\n", $urlsAsString);
$urlsIDsAsArray = is_null($urlsIDsAsString) ? [] : array_map(fn ($n) => int_escape($n), explode(" ", $urlsIDsAsString));
$urlsAsArray = is_null($urlsAsString) ? [] : explode("\n", $urlsAsString);
$urlsIDsAsArray = is_null($urlsIDsAsString) ? [] : explode(" ", $urlsIDsAsString);
while ($i < count($urlsAsArray)) {
// if an URL was updated
if ($i < count($urlsIDsAsArray)) {
@ -645,7 +624,7 @@ class Artists extends Extension
}
}
private function update_alias(): void
private function update_alias()
{
global $user;
$inputs = validate_input([
@ -655,16 +634,16 @@ class Artists extends Extension
$this->save_existing_alias($inputs['aliasID'], $inputs['alias'], $user->id);
}
private function save_existing_alias(int $aliasID, string $alias, int $userID): void
private function save_existing_alias(int $aliasID, string $alias, int $userID)
{
global $database;
$database->execute(
"UPDATE artist_alias SET alias = :alias, updated = now(), user_id = :user_id WHERE id = :id",
['alias' => $alias, 'user_id' => $userID, 'id' => $aliasID]
['alias'=>$alias, 'user_id'=>$userID, 'id'=>$aliasID]
);
}
private function update_url(): void
private function update_url()
{
global $user;
$inputs = validate_input([
@ -674,16 +653,16 @@ class Artists extends Extension
$this->save_existing_url($inputs['urlID'], $inputs['url'], $user->id);
}
private function save_existing_url(int $urlID, string $url, int $userID): void
private function save_existing_url(int $urlID, string $url, int $userID)
{
global $database;
$database->execute(
"UPDATE artist_urls SET url = :url, updated = now(), user_id = :user_id WHERE id = :id",
['url' => $url, 'user_id' => $userID, 'id' => $urlID]
['url'=>$url, 'user_id'=>$userID, 'id'=>$urlID]
);
}
private function update_member(): void
private function update_member()
{
global $user;
$inputs = validate_input([
@ -693,12 +672,12 @@ class Artists extends Extension
$this->save_existing_member($inputs['memberID'], $inputs['name'], $user->id);
}
private function save_existing_member(int $memberID, string $memberName, int $userID): void
private function save_existing_member(int $memberID, string $memberName, int $userID)
{
global $database;
$database->execute(
"UPDATE artist_members SET name = :name, updated = now(), user_id = :user_id WHERE id = :id",
['name' => $memberName, 'user_id' => $userID, 'id' => $memberID]
['name'=>$memberName, 'user_id'=>$userID, 'id'=>$memberID]
);
}
@ -754,7 +733,6 @@ class Artists extends Extension
}
if (!is_null($urls)) {
assert(is_string($urls));
//delete double "separators"
$urls = str_replace("\r\n", "\n", $urls);
$urls = str_replace("\n\r", "\n", $urls);
@ -775,7 +753,7 @@ class Artists extends Extension
$database->execute("
INSERT INTO artists (user_id, name, notes, created, updated)
VALUES (:user_id, :name, :notes, now(), now())
", ['user_id' => $user->id, 'name' => $name, 'notes' => $notes]);
", ['user_id'=>$user->id, 'name'=>$name, 'notes'=>$notes]);
return $database->get_last_insert_id('artists_id_seq');
}
@ -784,20 +762,17 @@ class Artists extends Extension
global $database;
$result = $database->get_one(
"SELECT COUNT(1) FROM artists WHERE name = :name",
['name' => $name]
['name'=>$name]
);
return ($result != 0);
}
/**
* @return ArtistArtist
*/
private function get_artist(int $artistID): array
{
global $database;
$result = $database->get_row(
"SELECT * FROM artists WHERE id = :id",
['id' => $artistID]
['id'=>$artistID]
);
$result["name"] = stripslashes($result["name"]);
@ -806,15 +781,12 @@ class Artists extends Extension
return $result;
}
/**
* @return ArtistMember[]
*/
private function get_members(int $artistID): array
{
global $database;
$result = $database->get_all(
"SELECT * FROM artist_members WHERE artist_id = :artist_id",
['artist_id' => $artistID]
['artist_id'=>$artistID]
);
$num = count($result);
@ -825,15 +797,12 @@ class Artists extends Extension
return $result;
}
/**
* @return ArtistUrl[]
*/
private function get_urls(int $artistID): array
{
global $database;
$result = $database->get_all(
"SELECT id, url FROM artist_urls WHERE artist_id = :artist_id",
['artist_id' => $artistID]
['artist_id'=>$artistID]
);
$num = count($result);
@ -849,7 +818,7 @@ class Artists extends Extension
global $database;
return (int)$database->get_one(
"SELECT id FROM artists WHERE name = :name",
['name' => $name]
['name'=>$name]
);
}
@ -859,23 +828,23 @@ class Artists extends Extension
return (int)$database->get_one(
"SELECT artist_id FROM artist_alias WHERE alias = :alias",
['alias' => $alias]
['alias'=>$alias]
);
}
private function delete_artist(int $artistID): void
private function delete_artist(int $artistID)
{
global $database;
$database->execute(
"DELETE FROM artists WHERE id = :id",
['id' => $artistID]
['id'=>$artistID]
);
}
/*
* HERE WE GET THE LIST OF ALL ARTIST WITH PAGINATION
*/
private function get_listing(PageRequestEvent $event): void
private function get_listing(PageRequestEvent $event)
{
global $config, $database;
@ -931,8 +900,8 @@ class Artists extends Extension
LIMIT :offset, :limit
",
[
"offset" => $pageNumber * $artistsPerPage,
"limit" => $artistsPerPage
"offset"=>$pageNumber * $artistsPerPage,
"limit"=>$artistsPerPage
]
);
@ -953,7 +922,7 @@ class Artists extends Extension
ON a.id = aa.artist_id
");
$totalPages = (int)ceil($count / $artistsPerPage);
$totalPages = ceil($count / $artistsPerPage);
$this->theme->list_artists($listing, $pageNumber + 1, $totalPages);
}
@ -961,7 +930,7 @@ class Artists extends Extension
/*
* HERE WE ADD AN ALIAS
*/
private function add_urls(): void
private function add_urls()
{
global $user;
$inputs = validate_input([
@ -978,17 +947,17 @@ class Artists extends Extension
}
}
private function save_new_url(int $artistID, string $url, int $userID): void
private function save_new_url(int $artistID, string $url, int $userID)
{
global $database;
$database->execute(
"INSERT INTO artist_urls (artist_id, created, updated, url, user_id) VALUES (:artist_id, now(), now(), :url, :user_id)",
['artist' => $artistID, 'url' => $url, 'user_id' => $userID]
['artist'=>$artistID, 'url'=>$url, 'user_id'=>$userID]
);
}
private function add_alias(): void
private function add_alias()
{
global $user;
$inputs = validate_input([
@ -1005,17 +974,17 @@ class Artists extends Extension
}
}
private function save_new_alias(int $artistID, string $alias, int $userID): void
private function save_new_alias(int $artistID, string $alias, int $userID)
{
global $database;
$database->execute(
"INSERT INTO artist_alias (artist_id, created, updated, alias, user_id) VALUES (:artist_id, now(), now(), :alias, :user_id)",
['artist_id' => $artistID, 'alias' => $alias, 'user_id' => $userID]
['artist_id'=>$artistID, 'alias'=>$alias, 'user_id'=>$userID]
);
}
private function add_members(): void
private function add_members()
{
global $user;
$inputs = validate_input([
@ -1032,13 +1001,13 @@ class Artists extends Extension
}
}
private function save_new_member(int $artistID, string $member, int $userID): void
private function save_new_member(int $artistID, string $member, int $userID)
{
global $database;
$database->execute(
"INSERT INTO artist_members (artist_id, name, created, updated, user_id) VALUES (:artist_id, :name, now(), now(), :user_id)",
['artist' => $artistID, 'name' => $member, 'user_id' => $userID]
['artist'=>$artistID, 'name'=>$member, 'user_id'=>$userID]
);
}
@ -1048,7 +1017,7 @@ class Artists extends Extension
$result = $database->get_one(
"SELECT COUNT(1) FROM artist_members WHERE artist_id = :artist_id AND name = :name",
['artist_id' => $artistID, 'name' => $member]
['artist_id'=>$artistID, 'name'=>$member]
);
return ($result != 0);
}
@ -1059,15 +1028,13 @@ class Artists extends Extension
$result = $database->get_one(
"SELECT COUNT(1) FROM artist_urls WHERE artist_id = :artist_id AND url = :url",
['artist_id' => $artistID, 'url' => $url]
['artist_id'=>$artistID, 'url'=>$url]
);
return ($result != 0);
}
/**
* HERE WE GET THE INFO OF THE ALIAS
*
* @return array<string, mixed>
*/
private function get_alias(int $artistID): array
{
@ -1078,7 +1045,7 @@ class Artists extends Extension
FROM artist_alias
WHERE artist_id = :artist_id
ORDER BY alias ASC
", ['artist_id' => $artistID]);
", ['artist_id'=>$artistID]);
$rc = count($result);
for ($i = 0 ; $i < $rc ; $i++) {

View File

@ -6,7 +6,7 @@ namespace Shimmie2;
class ArtistsTest extends ShimmiePHPUnitTestCase
{
public function testSearch(): void
public function testSearch()
{
global $user;
$this->log_in_as_user();

View File

@ -7,26 +7,20 @@ namespace Shimmie2;
use MicroHTML\HTMLElement;
use function MicroHTML\emptyHTML;
use function MicroHTML\{INPUT,P};
use function MicroHTML\{INPUT,P,SPAN,TD,TH,TR};
/**
* @phpstan-type ArtistArtist array{id:int,artist_id:int,user_name:string,name:string,notes:string,type:string,posts:int}
* @phpstan-type ArtistAlias array{id:int,alias_id:int,alias_name:string,alias:string}
* @phpstan-type ArtistMember array{id:int,name:string}
* @phpstan-type ArtistUrl array{id:int,url:string}
*/
class ArtistsTheme extends Themelet
{
public function get_author_editor_html(string $author): HTMLElement
public function get_author_editor_html(string $author): string
{
return SHM_POST_INFO(
"Author",
$author,
INPUT(["type" => "text", "name" => "tag_edit__author", "value" => $author])
);
$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])
)));
}
public function sidebar_options(string $mode, ?int $artistID = null, bool $is_admin = false): void
public function sidebar_options(string $mode, ?int $artistID=null, $is_admin=false): void
{
global $page, $user;
@ -83,13 +77,7 @@ class ArtistsTheme extends Themelet
}
}
/**
* @param ArtistArtist $artist
* @param ArtistAlias[] $aliases
* @param ArtistMember[] $members
* @param ArtistUrl[] $urls
*/
public function show_artist_editor(array $artist, array $aliases, array $members, array $urls): void
public function show_artist_editor($artist, $aliases, $members, $urls)
{
global $user;
@ -124,7 +112,7 @@ class ArtistsTheme extends Themelet
$urlsString .= $url["url"]."\n";
$urlsIDsString .= $url["id"]." ";
}
$urlsString = substr($urlsString, 0, strlen($urlsString) - 1);
$urlsString = substr($urlsString, 0, strlen($urlsString) -1);
$urlsIDsString = rtrim($urlsIDsString);
$html = '
@ -149,7 +137,7 @@ class ArtistsTheme extends Themelet
$page->add_block(new Block("Edit artist", $html, "main", 10));
}
public function new_artist_composer(): void
public function new_artist_composer()
{
global $page, $user;
@ -170,10 +158,7 @@ class ArtistsTheme extends Themelet
$page->add_block(new Block("Artists", $html, "main", 10));
}
/**
* @param ArtistArtist[] $artists
*/
public function list_artists(array $artists, int $pageNumber, int $totalPages): void
public function list_artists($artists, $pageNumber, $totalPages)
{
global $user, $page;
@ -252,7 +237,7 @@ class ArtistsTheme extends Themelet
$this->display_paginator($page, "artist/list", null, $pageNumber, $totalPages);
}
public function show_new_alias_composer(int $artistID): void
public function show_new_alias_composer($artistID)
{
global $user;
@ -271,7 +256,7 @@ class ArtistsTheme extends Themelet
$page->add_block(new Block("Artist Aliases", $html, "main", 20));
}
public function show_new_member_composer(int $artistID): void
public function show_new_member_composer($artistID)
{
global $user;
@ -290,7 +275,7 @@ class ArtistsTheme extends Themelet
$page->add_block(new Block("Artist members", $html, "main", 30));
}
public function show_new_url_composer(int $artistID): void
public function show_new_url_composer($artistID)
{
global $user;
@ -309,10 +294,7 @@ class ArtistsTheme extends Themelet
$page->add_block(new Block("Artist URLs", $html, "main", 40));
}
/**
* @param ArtistAlias $alias
*/
public function show_alias_editor(array $alias): void
public function show_alias_editor($alias)
{
global $user;
@ -330,10 +312,7 @@ class ArtistsTheme extends Themelet
$page->add_block(new Block("Edit Alias", $html, "main", 10));
}
/**
* @param ArtistUrl $url
*/
public function show_url_editor(array $url): void
public function show_url_editor($url)
{
global $user;
@ -351,10 +330,7 @@ class ArtistsTheme extends Themelet
$page->add_block(new Block("Edit URL", $html, "main", 10));
}
/**
* @param ArtistMember $member
*/
public function show_member_editor(array $member): void
public function show_member_editor($member)
{
global $user;
@ -372,18 +348,11 @@ class ArtistsTheme extends Themelet
$page->add_block(new Block("Edit Member", $html, "main", 10));
}
/**
* @param ArtistArtist $artist
* @param ArtistAlias[] $aliases
* @param ArtistMember[] $members
* @param ArtistUrl[] $urls
* @param Image[] $images
*/
public function show_artist(array $artist, array $aliases, array $members, array $urls, array $images, bool $userIsLogged, bool $userIsAdmin): void
public function show_artist($artist, $aliases, $members, $urls, $images, $userIsLogged, $userIsAdmin)
{
global $page;
$artist_link = "<a href='".search_link([$artist['name']])."'>".str_replace("_", " ", $artist['name'])."</a>";
$artist_link = "<a href='".make_link("post/list/".$artist['name']."/1")."'>".str_replace("_", " ", $artist['name'])."</a>";
$html = "<table id='poolsList' class='zebra'>
<thead>
@ -447,9 +416,6 @@ class ArtistsTheme extends Themelet
$page->add_block(new Block("Artist Posts", $artist_images, "main", 20));
}
/**
* @param ArtistAlias[] $aliases
*/
private function render_aliases(array $aliases, bool $userIsLogged, bool $userIsAdmin): string
{
$html = "";
@ -496,9 +462,6 @@ class ArtistsTheme extends Themelet
return $html;
}
/**
* @param ArtistMember[] $members
*/
private function render_members(array $members, bool $userIsLogged, bool $userIsAdmin): string
{
$html = "";
@ -543,9 +506,6 @@ class ArtistsTheme extends Themelet
return $html;
}
/**
* @param ArtistUrl[] $urls
*/
private function render_urls(array $urls, bool $userIsLogged, bool $userIsAdmin): string
{
$html = "";

View File

@ -10,7 +10,7 @@ class AutoTaggerInfo extends ExtensionInfo
public string $key = self::KEY;
public string $name = "Auto-Tagger";
public array $authors = ["Matthew Barbour" => "matthew@darkholme.net"];
public array $authors = ["Matthew Barbour"=>"matthew@darkholme.net"];
public string $license = self::LICENSE_WTFPL;
public string $description = "Provides several automatic tagging functions";
}

View File

@ -21,12 +21,12 @@ class AutoTaggerTable extends Table
$this->size = 100;
$this->limit = 1000000;
$this->set_columns([
new AutoCompleteColumn("tag", "Tag"),
new AutoCompleteColumn("additional_tags", "Additional Tags"),
new TextColumn("tag", "Tag"),
new TextColumn("additional_tags", "Additional Tags"),
new ActionColumn("tag"),
]);
$this->order_by = ["tag"];
$this->table_attrs = ["class" => "zebra form"];
$this->table_attrs = ["class" => "zebra"];
}
}
@ -67,7 +67,7 @@ class AutoTagger extends Extension
/** @var AutoTaggerTheme */
protected Themelet $theme;
public function onPageRequest(PageRequestEvent $event): void
public function onPageRequest(PageRequestEvent $event)
{
global $config, $database, $page, $user;
@ -75,7 +75,7 @@ class AutoTagger extends Extension
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"]);
$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);
@ -87,7 +87,7 @@ class AutoTagger extends Extension
} elseif ($event->get_arg(0) == "remove") {
if ($user->can(Permissions::MANAGE_AUTO_TAG)) {
$user->ensure_authed();
$input = validate_input(["d_tag" => "string"]);
$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"));
@ -111,7 +111,7 @@ class AutoTagger extends Extension
if ($user->can(Permissions::MANAGE_AUTO_TAG)) {
if (count($_FILES) > 0) {
$tmp = $_FILES['auto_tag_file']['tmp_name'];
$contents = file_get_contents_ex($tmp);
$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);
@ -126,14 +126,14 @@ class AutoTagger extends Extension
}
}
public function onPageSubNavBuilding(PageSubNavBuildingEvent $event): void
public function onPageSubNavBuilding(PageSubNavBuildingEvent $event)
{
if ($event->parent == "tags") {
if ($event->parent=="tags") {
$event->add_nav_link("auto_tag", new Link('auto_tag/list'), "Auto-Tag", NavLink::is_active(["auto_tag"]));
}
}
public function onDatabaseUpgrade(DatabaseUpgradeEvent $event): void
public function onDatabaseUpgrade(DatabaseUpgradeEvent $event)
{
global $database;
@ -153,27 +153,27 @@ class AutoTagger extends Extension
}
}
public function onTagSet(TagSetEvent $event): void
public function onTagSet(TagSetEvent $event)
{
$results = $this->apply_auto_tags($event->new_tags);
$results = $this->apply_auto_tags($event->tags);
if (!empty($results)) {
$event->new_tags = $results;
$event->tags = $results;
}
}
public function onAddAutoTag(AddAutoTagEvent $event): void
public function onAddAutoTag(AddAutoTagEvent $event)
{
global $page;
$this->add_auto_tag($event->tag, $event->additional_tags);
$page->flash("Added Auto-Tag");
}
public function onDeleteAutoTag(DeleteAutoTagEvent $event): void
public function onDeleteAutoTag(DeleteAutoTagEvent $event)
{
$this->remove_auto_tag($event->tag);
}
public function onUserBlockBuilding(UserBlockBuildingEvent $event): void
public function onUserBlockBuilding(UserBlockBuildingEvent $event)
{
global $user;
if ($user->can(Permissions::MANAGE_AUTO_TAG)) {
@ -186,7 +186,6 @@ class AutoTagger extends Extension
$csv = "";
$pairs = $database->get_pairs("SELECT tag, additional_tags FROM auto_tag ORDER BY tag");
foreach ($pairs as $old => $new) {
assert(is_string($new));
$csv .= "\"$old\",\"$new\"\n";
}
return $csv;
@ -210,10 +209,10 @@ class AutoTagger extends Extension
return $i;
}
private function add_auto_tag(string $tag, string $additional_tags): void
private function add_auto_tag(string $tag, string $additional_tags)
{
global $database;
$existing_tags = $database->get_one("SELECT additional_tags FROM auto_tag WHERE LOWER(tag)=LOWER(:tag)", ["tag" => $tag]);
$existing_tags = $database->get_one("SELECT additional_tags FROM auto_tag WHERE LOWER(tag)=LOWER(:tag)", ["tag"=>$tag]);
if (!is_null($existing_tags)) {
// Auto Tags already exist, so we will append new tags to the existing one
$tag = Tag::sanitize($tag);
@ -227,7 +226,7 @@ class AutoTagger extends Extension
$database->execute(
"UPDATE auto_tag set additional_tags=:existing_tags where tag=:tag",
["tag" => $tag, "existing_tags" => Tag::implode($existing_tags)]
["tag"=>$tag, "existing_tags"=>Tag::implode($existing_tags)]
);
log_info(
AutoTaggerInfo::KEY,
@ -239,7 +238,7 @@ class AutoTagger extends Extension
$database->execute(
"INSERT INTO auto_tag(tag, additional_tags) VALUES(:tag, :additional_tags)",
["tag" => $tag, "additional_tags" => Tag::implode($additional_tags)]
["tag"=>$tag, "additional_tags"=>Tag::implode($additional_tags)]
);
log_info(
@ -251,12 +250,12 @@ class AutoTagger extends Extension
$this->apply_new_auto_tag($tag);
}
private function apply_new_auto_tag(string $tag): void
private function apply_new_auto_tag(string $tag)
{
global $database;
$tag_id = $database->get_one("SELECT id FROM tags WHERE LOWER(tag) = LOWER(:tag)", ["tag" => $tag]);
$tag_id = $database->get_one("SELECT id FROM tags WHERE LOWER(tag) = LOWER(:tag)", ["tag"=>$tag]);
if (!empty($tag_id)) {
$image_ids = $database->get_col_iterable("SELECT image_id FROM image_tags WHERE tag_id = :tag_id", ["tag_id" => $tag_id]);
$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);
@ -265,7 +264,7 @@ class AutoTagger extends Extension
}
}
private function remove_auto_tag(string $tag): void
private function remove_auto_tag(String $tag)
{
global $database;
@ -273,10 +272,9 @@ class AutoTagger extends Extension
}
/**
* @param string[] $tags_mixed
* @return string[]
* #param string[] $tags_mixed
*/
private function apply_auto_tags(array $tags_mixed): array
private function apply_auto_tags(array $tags_mixed): ?array
{
global $database;

View File

@ -6,14 +6,14 @@ namespace Shimmie2;
class AutoTaggerTest extends ShimmiePHPUnitTestCase
{
public function testAutoTaggerList(): void
public function testAutoTaggerList()
{
$this->get_page('auto_tag/list');
$this->assert_response(200);
$this->assert_title("Auto-Tag");
}
public function testAutoTaggerListReadOnly(): void
public function testAutoTaggerListReadOnly()
{
$this->log_in_as_user();
$this->get_page('auto_tag/list');
@ -26,7 +26,7 @@ class AutoTaggerTest extends ShimmiePHPUnitTestCase
$this->assert_no_text("value=\"Add\"");
}
public function testAutoTagger(): void
public function testAutoTagger()
{
$this->log_in_as_admin();

View File

@ -4,8 +4,6 @@ declare(strict_types=1);
namespace Shimmie2;
use MicroHTML\HTMLElement;
class AutoTaggerTheme extends Themelet
{
/**
@ -13,7 +11,7 @@ class AutoTaggerTheme extends Themelet
*
* Note: $can_manage = whether things like "add new alias" should be shown
*/
public function display_auto_tagtable(HTMLElement $table, HTMLElement $paginator): void
public function display_auto_tagtable($table, $paginator): void
{
global $page, $user;

View File

@ -10,6 +10,6 @@ class AutoCompleteInfo extends ExtensionInfo
public string $key = self::KEY;
public string $name = "Autocomplete";
public array $authors = ["Daku" => "admin@codeanimu.net"];
public array $authors = ["Daku"=>"admin@codeanimu.net"];
public string $description = "Adds autocomplete to search & tagging.";
}

File diff suppressed because one or more lines are too long

13
ext/autocomplete/lib/jquery-ui.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,69 @@
ul.tagit {
padding: 1px 5px;
overflow: auto;
margin-left: inherit; /* usually we don't want the regular ul margins. */
margin-right: inherit;
}
ul.tagit li {
display: block;
float: left;
margin: 2px 5px 2px 0;
}
ul.tagit li.tagit-choice {
position: relative;
line-height: inherit;
}
input.tagit-hidden-field {
display: none;
}
ul.tagit li.tagit-choice-read-only {
padding: .2em .5em .2em .5em;
}
ul.tagit li.tagit-choice-editable {
padding: .2em 18px .2em .5em;
}
ul.tagit li.tagit-new {
padding: .25em 4px .25em 0;
}
ul.tagit li.tagit-choice a.tagit-label {
cursor: pointer;
text-decoration: none;
}
ul.tagit li.tagit-choice .tagit-close {
cursor: pointer;
position: absolute;
right: .1em;
top: 50%;
margin-top: -8px;
line-height: 17px;
}
/* used for some custom themes that don't need image icons */
ul.tagit li.tagit-choice .tagit-close .text-icon {
display: none;
}
ul.tagit li.tagit-choice input {
display: block;
float: left;
margin: 2px 5px 2px 0;
}
ul.tagit input[type="text"] {
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
-moz-box-shadow: none;
-webkit-box-shadow: none;
box-shadow: none;
border: none;
margin: 0;
padding: 0;
width: inherit;
background-color: inherit;
outline: none;
}

18
ext/autocomplete/lib/tag-it.min.js vendored Normal file
View File

@ -0,0 +1,18 @@
//Removed TAB keybind
;(function(b){b.widget("ui.tagit",{options:{allowDuplicates:!1,caseSensitive:!0,fieldName:"tags",placeholderText:null,readOnly:!1,removeConfirmation:!1,tagLimit:null,availableTags:[],autocomplete:{},showAutocompleteOnFocus:!1,allowSpaces:!1,singleField:!1,singleFieldDelimiter:",",singleFieldNode:null,animate:!0,tabIndex:null,beforeTagAdded:null,afterTagAdded:null,beforeTagRemoved:null,afterTagRemoved:null,onTagClicked:null,onTagLimitExceeded:null,onTagAdded:null,onTagRemoved:null,tagSource:null},_create:function(){var a=
this;this.element.is("input")?(this.tagList=b("<ul></ul>").insertAfter(this.element),this.options.singleField=!0,this.options.singleFieldNode=this.element,this.element.addClass("tagit-hidden-field")):this.tagList=this.element.find("ul, ol").andSelf().last();this.tagInput=b('<input type="text" />').addClass("ui-widget-content");this.options.readOnly&&this.tagInput.attr("disabled","disabled");this.options.tabIndex&&this.tagInput.attr("tabindex",this.options.tabIndex);this.options.placeholderText&&this.tagInput.attr("placeholder",
this.options.placeholderText);this.options.autocomplete.source||(this.options.autocomplete.source=function(a,e){var d=a.term.toLowerCase(),c=b.grep(this.options.availableTags,function(a){return 0===a.toLowerCase().indexOf(d)});this.options.allowDuplicates||(c=this._subtractArray(c,this.assignedTags()));e(c)});this.options.showAutocompleteOnFocus&&(this.tagInput.focus(function(b,d){a._showAutocomplete()}),"undefined"===typeof this.options.autocomplete.minLength&&(this.options.autocomplete.minLength=
0));b.isFunction(this.options.autocomplete.source)&&(this.options.autocomplete.source=b.proxy(this.options.autocomplete.source,this));b.isFunction(this.options.tagSource)&&(this.options.tagSource=b.proxy(this.options.tagSource,this));this.tagList.addClass("tagit").addClass("ui-widget ui-widget-content ui-corner-all").append(b('<li class="tagit-new"></li>').append(this.tagInput)).click(function(d){var c=b(d.target);c.hasClass("tagit-label")?(c=c.closest(".tagit-choice"),c.hasClass("removed")||a._trigger("onTagClicked",
d,{tag:c,tagLabel:a.tagLabel(c)})):a.tagInput.focus()});var c=!1;if(this.options.singleField)if(this.options.singleFieldNode){var d=b(this.options.singleFieldNode),f=d.val().split(this.options.singleFieldDelimiter);d.val("");b.each(f,function(b,d){a.createTag(d,null,!0);c=!0})}else this.options.singleFieldNode=b('<input type="hidden" style="display:none;" value="" name="'+this.options.fieldName+'" />'),this.tagList.after(this.options.singleFieldNode);c||this.tagList.children("li").each(function(){b(this).hasClass("tagit-new")||
(a.createTag(b(this).text(),b(this).attr("class"),!0),b(this).remove())});this.tagInput.keydown(function(c){if(c.which==b.ui.keyCode.BACKSPACE&&""===a.tagInput.val()){var d=a._lastTag();!a.options.removeConfirmation||d.hasClass("remove")?a.removeTag(d):a.options.removeConfirmation&&d.addClass("remove ui-state-highlight")}else a.options.removeConfirmation&&a._lastTag().removeClass("remove ui-state-highlight");if(c.which===b.ui.keyCode.COMMA&&!1===c.shiftKey||c.which===b.ui.keyCode.ENTER||c.which==
c.which==b.ui.keyCode.SPACE&&!0!==a.options.allowSpaces&&('"'!=b.trim(a.tagInput.val()).replace(/^s*/,"").charAt(0)||'"'==b.trim(a.tagInput.val()).charAt(0)&&'"'==b.trim(a.tagInput.val()).charAt(b.trim(a.tagInput.val()).length-1)&&0!==b.trim(a.tagInput.val()).length-1))c.which===b.ui.keyCode.ENTER&&""===a.tagInput.val()||c.preventDefault(),a.options.autocomplete.autoFocus&&a.tagInput.data("autocomplete-open")||(a.tagInput.autocomplete("close"),a.createTag(a._cleanedInput()))}).blur(function(b){a.tagInput.data("autocomplete-open")||
a.createTag(a._cleanedInput())});if(this.options.availableTags||this.options.tagSource||this.options.autocomplete.source)d={select:function(b,c){a.createTag(c.item.value);return!1}},b.extend(d,this.options.autocomplete),d.source=this.options.tagSource||d.source,this.tagInput.autocomplete(d).bind("autocompleteopen.tagit",function(b,c){a.tagInput.data("autocomplete-open",!0)}).bind("autocompleteclose.tagit",function(b,c){a.tagInput.data("autocomplete-open",!1)}),this.tagInput.autocomplete("widget").addClass("tagit-autocomplete")},
destroy:function(){b.Widget.prototype.destroy.call(this);this.element.unbind(".tagit");this.tagList.unbind(".tagit");this.tagInput.removeData("autocomplete-open");this.tagList.removeClass("tagit ui-widget ui-widget-content ui-corner-all tagit-hidden-field");this.element.is("input")?(this.element.removeClass("tagit-hidden-field"),this.tagList.remove()):(this.element.children("li").each(function(){b(this).hasClass("tagit-new")?b(this).remove():(b(this).removeClass("tagit-choice ui-widget-content ui-state-default ui-state-highlight ui-corner-all remove tagit-choice-editable tagit-choice-read-only"),
b(this).text(b(this).children(".tagit-label").text()))}),this.singleFieldNode&&this.singleFieldNode.remove());return this},_cleanedInput:function(){return b.trim(this.tagInput.val().replace(/^"(.*)"$/,"$1"))},_lastTag:function(){return this.tagList.find(".tagit-choice:last:not(.removed)")},_tags:function(){return this.tagList.find(".tagit-choice:not(.removed)")},assignedTags:function(){var a=this,c=[];this.options.singleField?(c=b(this.options.singleFieldNode).val().split(this.options.singleFieldDelimiter),
""===c[0]&&(c=[])):this._tags().each(function(){c.push(a.tagLabel(this))});return c},_updateSingleTagsField:function(a){b(this.options.singleFieldNode).val(a.join(this.options.singleFieldDelimiter)).trigger("change")},_subtractArray:function(a,c){for(var d=[],f=0;f<a.length;f++)-1==b.inArray(a[f],c)&&d.push(a[f]);return d},tagLabel:function(a){return this.options.singleField?b(a).find(".tagit-label:first").text():b(a).find("input:first").val()},_showAutocomplete:function(){this.tagInput.autocomplete("search",
"")},_findTagByLabel:function(a){var c=this,d=null;this._tags().each(function(f){if(c._formatStr(a)==c._formatStr(c.tagLabel(this)))return d=b(this),!1});return d},_isNew:function(a){return!this._findTagByLabel(a)},_formatStr:function(a){return this.options.caseSensitive?a:b.trim(a.toLowerCase())},_effectExists:function(a){return Boolean(b.effects&&(b.effects[a]||b.effects.effect&&b.effects.effect[a]))},createTag:function(a,c,d){var f=this;a=b.trim(a);this.options.preprocessTag&&(a=this.options.preprocessTag(a));
if(""===a)return!1;if(!this.options.allowDuplicates&&!this._isNew(a))return a=this._findTagByLabel(a),!1!==this._trigger("onTagExists",null,{existingTag:a,duringInitialization:d})&&this._effectExists("highlight")&&a.effect("highlight"),!1;if(this.options.tagLimit&&this._tags().length>=this.options.tagLimit)return this._trigger("onTagLimitExceeded",null,{duringInitialization:d}),!1;var g=b(this.options.onTagClicked?'<a class="tagit-label"></a>':'<span class="tagit-label"></span>').text(a),e=b("<li></li>").addClass("tagit-choice ui-widget-content ui-state-default ui-corner-all").addClass(c).append(g);
this.options.readOnly?e.addClass("tagit-choice-read-only"):(e.addClass("tagit-choice-editable"),c=b("<span></span>").addClass("ui-icon ui-icon-close"),c=b('<a><span class="text-icon">\u00d7</span></a>').addClass("tagit-close").append(c).click(function(a){f.removeTag(e)}),e.append(c));this.options.singleField||(g=g.html(),e.append('<input type="hidden" value="'+g+'" name="'+this.options.fieldName+'" class="tagit-hidden-field" />'));!1!==this._trigger("beforeTagAdded",null,{tag:e,tagLabel:this.tagLabel(e),
duringInitialization:d})&&(this.options.singleField&&(g=this.assignedTags(),g.push(a),this._updateSingleTagsField(g)),this._trigger("onTagAdded",null,e),this.tagInput.val(""),this.tagInput.parent().before(e),this._trigger("afterTagAdded",null,{tag:e,tagLabel:this.tagLabel(e),duringInitialization:d}),this.options.showAutocompleteOnFocus&&!d&&setTimeout(function(){f._showAutocomplete()},0))},removeTag:function(a,c){c="undefined"===typeof c?this.options.animate:c;a=b(a);this._trigger("onTagRemoved",
null,a);if(!1!==this._trigger("beforeTagRemoved",null,{tag:a,tagLabel:this.tagLabel(a)})){if(this.options.singleField){var d=this.assignedTags(),f=this.tagLabel(a),d=b.grep(d,function(a){return a!=f});this._updateSingleTagsField(d)}if(c){a.addClass("removed");var d=this._effectExists("blind")?["blind",{direction:"horizontal"},"fast"]:["fast"],g=this;d.push(function(){a.remove();g._trigger("afterTagRemoved",null,{tag:a,tagLabel:g.tagLabel(a)})});a.fadeOut("fast").hide.apply(a,d).dequeue()}else a.remove(),
this._trigger("afterTagRemoved",null,{tag:a,tagLabel:this.tagLabel(a)})}},removeTagByLabel:function(a,b){var d=this._findTagByLabel(a);if(!d)throw"No such tag exists with the name '"+a+"'";this.removeTag(d,b)},removeAll:function(){var a=this;this._tags().each(function(b,d){a.removeTag(d,!1)})}})})(jQuery);

View File

@ -0,0 +1,97 @@
/* Optional scoped theme for tag-it which mimics the zendesk widget. */
ul.tagit {
border-style: solid;
border-width: 1px;
border-color: #C6C6C6;
background: inherit;
}
ul.tagit li.tagit-choice {
-moz-border-radius: 6px;
border-radius: 6px;
-webkit-border-radius: 6px;
border: 1px solid #CAD8F3;
background: #DEE7F8 none;
font-weight: normal;
}
ul.tagit li.tagit-choice .tagit-label:not(a) {
color: #555;
}
ul.tagit li.tagit-choice a.tagit-close {
text-decoration: none;
}
ul.tagit li.tagit-choice .tagit-close {
right: .4em;
}
ul.tagit li.tagit-choice .ui-icon {
display: none;
}
ul.tagit li.tagit-choice .tagit-close .text-icon {
display: inline;
font-family: arial, sans-serif;
font-size: 16px;
line-height: 16px;
color: #777;
}
ul.tagit li.tagit-choice:hover, ul.tagit li.tagit-choice.remove {
background-color: #bbcef1;
border-color: #6d95e0;
}
ul.tagit li.tagit-choice a.tagLabel:hover,
ul.tagit li.tagit-choice a.tagit-close .text-icon:hover {
color: #222;
}
ul.tagit input[type="text"] {
color: #333333;
background: none;
}
.ui-widget {
font-size: 1.1em;
}
/* Forked from a jQuery UI theme, so that we don't require the jQuery UI CSS as a dependency. */
.tagit-autocomplete.ui-autocomplete { position: absolute; cursor: default; }
* html .tagit-autocomplete.ui-autocomplete { width:1px; } /* without this, the menu expands to 100% in IE6 */
.tagit-autocomplete.ui-menu {
list-style:none;
padding: 2px;
margin: 0;
display:block;
float: left;
}
.tagit-autocomplete.ui-menu .ui-menu {
margin-top: -3px;
}
.tagit-autocomplete.ui-menu .ui-menu-item {
margin:0;
padding: 0;
zoom: 1;
float: left;
clear: left;
width: 100%;
}
.tagit-autocomplete.ui-menu .ui-menu-item a {
text-decoration:none;
display:block;
padding:.2em .4em;
line-height:1.5;
zoom:1;
}
.tagit-autocomplete .ui-menu .ui-menu-item a.ui-state-hover,
.tagit-autocomplete .ui-menu .ui-menu-item a.ui-state-active {
font-weight: normal;
margin: -1px;
}
.tagit-autocomplete.ui-widget-content { border: 1px solid #aaaaaa; background: #ffffff 50% 50% repeat-x; color: #222222; }
.tagit-autocomplete.ui-corner-all, .tagit-autocomplete .ui-corner-all { -moz-border-radius: 4px; -webkit-border-radius: 4px; -khtml-border-radius: 4px; border-radius: 4px; }
.tagit-autocomplete .ui-state-hover, .tagit-autocomplete .ui-state-focus { border: 1px solid #999999; background: #dadada; font-weight: normal; color: #212121; }
.tagit-autocomplete .ui-state-active { border: 1px solid #aaaaaa; }
.tagit-autocomplete .ui-widget-content { border: 1px solid #aaaaaa; }
.tagit .ui-helper-hidden-accessible { position: absolute !important; clip: rect(1px,1px,1px,1px); }

View File

@ -6,34 +6,40 @@ namespace Shimmie2;
class AutoComplete extends Extension
{
/** @var AutoCompleteTheme */
protected Themelet $theme;
public function get_priority(): int
{
return 30;
} // before Home
public function onPageRequest(PageRequestEvent $event): void
public function onPageRequest(PageRequestEvent $event)
{
global $page;
if ($event->page_matches("api/internal/autocomplete")) {
$limit = (int)($_GET["limit"] ?? 1000);
$limit = (int)($_GET["limit"] ?? 0);
$s = $_GET["s"] ?? "";
$res = $this->complete($s, $limit);
$page->set_mode(PageMode::DATA);
$page->set_mime(MimeType::JSON);
$page->set_data(json_encode_ex($res));
$page->set_data(json_encode($res));
}
$this->theme->build_autocomplete($page);
}
/**
* @return array<string, int>
*/
private function complete(string $search, int $limit): array
{
global $cache, $database;
if (!$search) {
return [];
}
$search = strtolower($search);
if (
$search == '' ||
@ -45,32 +51,34 @@ class AutoComplete extends Extension
}
# memcache keys can't contain spaces
$cache_key = "autocomplete:$limit:" . md5($search);
$cache_key = "autocomplete:" . md5($search);
$limitSQL = "";
$search = str_replace('_', '\_', $search);
$search = str_replace('%', '\%', $search);
$SQLarr = [
"search" => "$search%",
"cat_search" => Extension::is_enabled(TagCategoriesInfo::KEY) ? "%:$search%" : "",
];
$SQLarr = ["search"=>"$search%"]; #, "cat_search"=>"%:$search%"];
if ($limit !== 0) {
$limitSQL = "LIMIT :limit";
$SQLarr['limit'] = $limit;
$cache_key .= "-" . $limit;
}
return cache_get_or_set($cache_key, fn () => $database->get_pairs(
"
$res = $cache->get($cache_key);
if (is_null($res)) {
$res = $database->get_pairs(
"
SELECT tag, count
FROM tags
WHERE (
LOWER(tag) LIKE LOWER(:search)
OR LOWER(tag) LIKE LOWER(:cat_search)
)
WHERE LOWER(tag) LIKE LOWER(:search)
-- OR LOWER(tag) LIKE LOWER(:cat_search)
AND count > 0
ORDER BY count DESC, tag ASC
ORDER BY count DESC
$limitSQL
",
$SQLarr
), 600);
",
$SQLarr
);
$cache->set($cache_key, $res, 600);
}
return $res;
}
}

View File

@ -1,256 +1,115 @@
/**
* 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((document.body.getAttribute("data-base-href") ?? "") + '/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 value = completions[key];
let li = document.createElement('li');
li.innerText = key + ' (' + value + ')';
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;
// 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', () => {
// Find all elements with class 'autocomplete_tags'
document.querySelectorAll('.autocomplete_tags').forEach((element) => {
// set metadata
element.completions = {};
element.selected_completion = -1;
element.completer_timeout = null;
var metatags = ['order:id', 'order:width', 'order:height', 'order:filesize', 'order:filename', 'order:favorites'];
// disable built-in autocomplete
element.setAttribute('autocomplete', 'off');
// safari treats spellcheck as a form of autocomplete
element.setAttribute('spellcheck', 'off');
// when element is focused, add completion block
element.addEventListener('focus', () => {
updateCompletions(element);
});
// when element is blurred, remove completion block
element.addEventListener('blur', () => {
hideCompletions();
});
// when cursor is moved, change current completion
document.addEventListener('selectionchange', () => {
// if element is focused
if(document.activeElement === element) {
updateCompletions(element);
$('.autocomplete_tags').tagit({
singleFieldDelimiter: ' ',
beforeTagAdded: function(event, ui) {
if(metatags.indexOf(ui.tagLabel) !== -1) {
ui.tag.addClass('tag-metatag');
} else {
console.log(ui.tagLabel);
// give special class to negative tags
if(ui.tagLabel[0] === '-') {
ui.tag.addClass('tag-negative');
}else{
ui.tag.addClass('tag-positive');
}
}
});
},
autocomplete : ({
source: function (request, response) {
var ac_metatags = $.map(
$.grep(metatags, function(s) {
// Only show metatags for strings longer than one character
return (request.term.length > 1 && s.indexOf(request.term) === 0);
}),
function(item) {
return {
label : item + ' [metatag]',
value : item
};
}
);
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();
setCompletion(element, Object.keys(element.completions)[element.selected_completion]);
}
// if escape is pressed, hide the completion block
else if(event.code === "Escape") {
event.preventDefault();
hideCompletions();
}
});
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
})
});
// on change, update completions
element.addEventListener('input', () => {
updateCompletions(element);
});
$('#tag_editor,[name="bulk_tags"]').tagit({
singleFieldDelimiter: ' ',
autocomplete : ({
source: function (request, response) {
$.ajax({
url: base_href + '/api/internal/autocomplete',
data: {'s': request.term},
dataType : 'json',
type : 'GET',
success : function (data) {
response(
$.map(data, function (count, item) {
return {
label : item + ' ('+count+')',
value : item
};
})
);
},
error : function (request, status, error) {
console.log(error);
}
});
},
minLength: 1
})
});
$('.ui-autocomplete-input').keydown(function(e) {
var keyCode = e.keyCode || e.which;
//Stop tags containing space.
if(keyCode === 32) {
e.preventDefault();
var el = $('.ui-widget-content:focus');
//Find the correct element in a page with multiple tagit input boxes.
$('.autocomplete_tags').each(function(_,n){
if (n.parentNode.contains(el[0])){
$(n.parentNode).find('.autocomplete_tags').tagit('createTag', el.val());
}
});
$(this).autocomplete('close');
} else if (keyCode === 9) {
e.preventDefault();
var tag = $('.tagit-autocomplete[style*=\"display: block\"] > li:focus, .tagit-autocomplete[style*=\"display: block\"] > li:first').first();
if(tag.length){
$(tag).click();
$('.ui-autocomplete-input').val(''); //If tag already exists, make sure to remove duplicate.
}
}
});
});

View File

@ -1,22 +1,9 @@
.autocomplete_completions {
position: absolute;
z-index: 1000;
border: 1px solid #ccc;
color: #000;
background-color: #fff;
padding: 5px;
list-style: none;
margin: 0;
padding: 0;
font-size: 1em;
white-space: nowrap;
overflow: hidden;
text-align: left;
}
.autocomplete_completions LI {
padding: 0.15em;
}
.autocomplete_completions .selected {
background-color: #ccc;
outline: none;
}
#Navigationleft .blockbody { overflow: visible; }
.tagit { background: white !important; border: 1px solid grey !important; cursor: text; }
.tagit-choice { cursor: initial; }
input[name=search] ~ input[type=submit] { display: inline-block !important; }
.tag-negative { background: #ff8080 !important; }
.tag-positive { background: #40bf40 !important; }
.tag-metatag { background: #eaa338 !important; }

View File

@ -6,41 +6,12 @@ namespace Shimmie2;
class AutoCompleteTest extends ShimmiePHPUnitTestCase
{
public function testAuth(): void
public function testAuth()
{
$this->log_in_as_user();
$image_id = $this->post_image("tests/pbx_screenshot.jpg", "some_tag");
send_event(new UserLoginEvent(User::by_name(self::$anon_name)));
$page = $this->get_page('api/internal/autocomplete', ["s" => "not-a-tag"]);
$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" => "so"]);
$this->assertEquals(200, $page->code);
$this->assertEquals(PageMode::DATA, $page->mode);
$this->assertEquals('{"some_tag":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":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":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":1}', $page->data);
}
}

View File

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

View File

@ -6,7 +6,7 @@ namespace Shimmie2;
class BanWords extends Extension
{
public function onInitExt(InitExtEvent $event): void
public function onInitExt(InitExtEvent $event)
{
global $config;
$config->set_default_string('banned_words', "
@ -39,7 +39,7 @@ xanax
");
}
public function onCommentPosting(CommentPostingEvent $event): void
public function onCommentPosting(CommentPostingEvent $event)
{
global $user;
if (!$user->can(Permissions::BYPASS_COMMENT_CHECKS)) {
@ -47,17 +47,17 @@ xanax
}
}
public function onSourceSet(SourceSetEvent $event): void
public function onSourceSet(SourceSetEvent $event)
{
$this->test_text($event->source, new SCoreException("Source contains banned terms"));
}
public function onTagSet(TagSetEvent $event): void
public function onTagSet(TagSetEvent $event)
{
$this->test_text(Tag::implode($event->new_tags), new SCoreException("Tags contain banned terms"));
$this->test_text(Tag::implode($event->tags), new SCoreException("Tags contain banned terms"));
}
public function onSetupBuilding(SetupBuildingEvent $event): void
public function onSetupBuilding(SetupBuildingEvent $event)
{
$sb = $event->panel->create_new_block("Banned Phrases");
$sb->add_label("One per line, lines that start with slashes are treated as regex<br/>");
@ -97,9 +97,6 @@ xanax
}
}
/**
* @return string[]
*/
private function get_words(): array
{
global $config;

View File

@ -6,7 +6,7 @@ namespace Shimmie2;
class BanWordsTest extends ShimmiePHPUnitTestCase
{
public function check_blocked(int $image_id, string $words): void
public function check_blocked($image_id, $words)
{
global $user;
try {
@ -17,7 +17,7 @@ class BanWordsTest extends ShimmiePHPUnitTestCase
}
}
public function testWordBan(): void
public function testWordBan()
{
global $config;
$config->set_string("banned_words", "viagra\nporn\n\n/http:.*\.cn\//");

View File

@ -7,12 +7,6 @@ namespace Shimmie2;
class BBCode extends FormatterExtension
{
public function format(string $text): string
{
$text = $this->_format($text);
return "<span class='bbcode'>$text</span>";
}
public function _format(string $text): string
{
$text = $this->extract_code($text);
foreach ([
@ -103,8 +97,8 @@ class BBCode extends FormatterExtension
}
$beginning = substr($text, 0, $start);
$middle = str_rot13(substr($text, $start + $l1, ($end - $start - $l1)));
$ending = substr($text, $end + $l2, (strlen($text) - $end + $l2));
$middle = str_rot13(substr($text, $start+$l1, ($end-$start-$l1)));
$ending = substr($text, $end + $l2, (strlen($text)-$end+$l2));
$text = $beginning . $middle . $ending;
}
@ -137,8 +131,8 @@ class BBCode extends FormatterExtension
}
$beginning = substr($text, 0, $start);
$middle = base64_encode(substr($text, $start + $l1, ($end - $start - $l1)));
$ending = substr($text, $end + $l2, (strlen($text) - $end + $l2));
$middle = base64_encode(substr($text, $start+$l1, ($end-$start-$l1)));
$ending = substr($text, $end + $l2, (strlen($text)-$end+$l2));
$text = $beginning . "[code!]" . $middle . "[/code!]" . $ending;
}
@ -161,10 +155,10 @@ class BBCode extends FormatterExtension
}
$beginning = substr($text, 0, $start);
$middle = base64_decode(substr($text, $start + $l1, ($end - $start - $l1)));
$ending = substr($text, $end + $l2, (strlen($text) - $end + $l2));
$middle = base64_decode(substr($text, $start+$l1, ($end-$start-$l1)));
$ending = substr($text, $end + $l2, (strlen($text)-$end+$l2));
$text = $beginning . "<pre class='code'>" . $middle . "</pre>" . $ending;
$text = $beginning . "<pre>" . $middle . "</pre>" . $ending;
}
return $text;
}

View File

@ -1,20 +1,18 @@
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll(".shm-clink").forEach(function(el) {
var target_id = el.getAttribute("data-clink-sel");
if(target_id && document.querySelectorAll(target_id).length > 0) {
$(".shm-clink").each(function(idx, elm) {
var target_id = $(elm).data("clink-sel");
if(target_id && $(target_id).length > 0) {
// if the target comment is already on this page, don't bother
// switching pages
el.setAttribute("href", target_id);
$(elm).attr("href", target_id);
// highlight it when clicked
el.addEventListener("click", function(e) {
$(elm).click(function(e) {
// This needs jQuery UI
$(target_id).highlight();
});
// vanilla target name should already be in the URL tag, but this
// will include the anon ID as displayed on screen
el.innerHTML = "@"+document.querySelector(target_id+" .username").innerHTML;
$(elm).html("@"+$(target_id+" .username").html());
}
});
});

View File

@ -1,15 +1,16 @@
.bbcode PRE.code {
background: #DEDEDE;
font-size: 0.9rem;
CODE {
background: #DEDEDE;
font-size: 0.8em;
}
.bbcode BLOCKQUOTE {
BLOCKQUOTE {
border: 1px solid black;
padding: 8px;
background: #DDD;
}
.bbcode .anchor A.alink {
.anchor A.alink {
visibility: hidden;
}
.bbcode .anchor:hover A.alink {
.anchor:hover A.alink {
visibility: visible;
}

View File

@ -6,7 +6,7 @@ namespace Shimmie2;
class BBCodeTest extends ShimmiePHPUnitTestCase
{
public function testBasics(): void
public function testBasics()
{
$this->assertEquals(
"<b>bold</b><i>italic</i>",
@ -14,7 +14,7 @@ class BBCodeTest extends ShimmiePHPUnitTestCase
);
}
public function testStacking(): void
public function testStacking()
{
$this->assertEquals(
"<b>B</b><i>I</i><b>B</b>",
@ -26,7 +26,7 @@ class BBCodeTest extends ShimmiePHPUnitTestCase
);
}
public function testFailure(): void
public function testFailure()
{
$this->assertEquals(
"[b]bold[i]italic",
@ -34,15 +34,15 @@ class BBCodeTest extends ShimmiePHPUnitTestCase
);
}
public function testCode(): void
public function testCode()
{
$this->assertEquals(
"<pre class='code'>[b]bold[/b]</pre>",
"<pre>[b]bold[/b]</pre>",
$this->filter("[code][b]bold[/b][/code]")
);
}
public function testNestedList(): void
public function testNestedList()
{
$this->assertEquals(
"<ul><li>a<ul><li>a<li>b</ul><li>b</ul>",
@ -54,7 +54,7 @@ class BBCodeTest extends ShimmiePHPUnitTestCase
);
}
public function testSpoiler(): void
public function testSpoiler()
{
$this->assertEquals(
"<span style=\"background-color:#000; color:#000;\">ShishNet</span>",
@ -69,7 +69,7 @@ class BBCodeTest extends ShimmiePHPUnitTestCase
# "[spoiler]ShishNet");
}
public function testURL(): void
public function testURL()
{
$this->assertEquals(
"<a href=\"https://shishnet.org\">https://shishnet.org</a>",
@ -85,7 +85,7 @@ class BBCodeTest extends ShimmiePHPUnitTestCase
);
}
public function testEmailURL(): void
public function testEmailURL()
{
$this->assertEquals(
"<a href=\"mailto:spam@shishnet.org\">spam@shishnet.org</a>",
@ -93,7 +93,7 @@ class BBCodeTest extends ShimmiePHPUnitTestCase
);
}
public function testAnchor(): void
public function testAnchor()
{
$this->assertEquals(
'<span class="anchor">Rules <a class="alink" href="#bb-rules" name="bb-rules" title="link to this anchor"> ¶ </a></span>',
@ -101,19 +101,19 @@ class BBCodeTest extends ShimmiePHPUnitTestCase
);
}
private function filter(string $in): string
private function filter($in): string
{
$bb = new BBCode();
return $bb->_format($in);
return $bb->format($in);
}
private function strip(string $in): string
private function strip($in): string
{
$bb = new BBCode();
return $bb->strip($in);
}
public function testSiteLinks(): void
public function testSiteLinks()
{
$this->assertEquals(
'<a class="shm-clink" data-clink-sel="" href="/test/post/view/123">&gt;&gt;123</a>',

View File

@ -9,7 +9,7 @@ class Biography extends Extension
/** @var BiographyTheme */
protected Themelet $theme;
public function onUserPageBuilding(UserPageBuildingEvent $event): void
public function onUserPageBuilding(UserPageBuildingEvent $event)
{
global $page, $user;
$duser = $event->display_user;
@ -23,7 +23,7 @@ class Biography extends Extension
}
}
public function onPageRequest(PageRequestEvent $event): void
public function onPageRequest(PageRequestEvent $event)
{
global $page, $user, $user_config;
if ($event->page_matches("biography")) {

View File

@ -6,10 +6,10 @@ namespace Shimmie2;
class BiographyTest extends ShimmiePHPUnitTestCase
{
public function testBio(): void
public function testBio()
{
$this->log_in_as_user();
$this->post_page("biography", ["biography" => "My bio goes here"]);
$this->post_page("biography", ["biography"=>"My bio goes here"]);
$this->get_page("user/" . self::$user_name);
$this->assert_text("My bio goes here");

View File

@ -8,16 +8,16 @@ use function MicroHTML\TEXTAREA;
class BiographyTheme extends Themelet
{
public function display_biography(Page $page, string $bio): void
public function display_biography(Page $page, string $bio)
{
$page->add_block(new Block("About Me", format_text($bio), "main", 30, "about-me"));
}
public function display_composer(Page $page, string $bio): void
public function display_composer(Page $page, string $bio)
{
$html = SHM_SIMPLE_FORM(
make_link("biography"),
TEXTAREA(["style" => "width: 100%", "rows" => "6", "name" => "biography"], $bio),
TEXTAREA(["style"=>"width: 100%", "rows"=>"6", "name"=>"biography"], $bio),
SHM_SUBMIT("Save")
);

View File

@ -9,7 +9,7 @@ class Blocks extends Extension
/** @var BlocksTheme */
protected Themelet $theme;
public function onDatabaseUpgrade(DatabaseUpgradeEvent $event): void
public function onDatabaseUpgrade(DatabaseUpgradeEvent $event)
{
global $database;
if ($this->get_version("ext_blocks_version") < 1) {
@ -31,17 +31,17 @@ class Blocks extends Extension
}
}
public function onPageSubNavBuilding(PageSubNavBuildingEvent $event): void
public function onPageSubNavBuilding(PageSubNavBuildingEvent $event)
{
global $user;
if ($event->parent === "system") {
if ($event->parent==="system") {
if ($user->can(Permissions::MANAGE_BLOCKS)) {
$event->add_nav_link("blocks", new Link('blocks/list'), "Blocks Editor");
}
}
}
public function onUserBlockBuilding(UserBlockBuildingEvent $event): void
public function onUserBlockBuilding(UserBlockBuildingEvent $event)
{
global $user;
if ($user->can(Permissions::MANAGE_BLOCKS)) {
@ -49,11 +49,15 @@ class Blocks extends Extension
}
}
public function onPageRequest(PageRequestEvent $event): void
public function onPageRequest(PageRequestEvent $event)
{
global $cache, $database, $page, $user;
$blocks = cache_get_or_set("blocks", fn () => $database->get_all("SELECT * FROM blocks"), 600);
$blocks = $cache->get("blocks");
if (is_null($blocks)) {
$blocks = $database->get_all("SELECT * FROM blocks");
$cache->set("blocks", $blocks, 600);
}
foreach ($blocks as $block) {
$path = implode("/", $event->args);
if (strlen($path) < 4000 && fnmatch($block['pages'], $path)) {
@ -74,7 +78,7 @@ class Blocks extends Extension
$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']]);
", ['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);
@ -87,13 +91,13 @@ class Blocks extends Extension
$database->execute("
DELETE FROM blocks
WHERE id=:id
", ['id' => $_POST['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']]);
", ['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");

View File

@ -6,7 +6,7 @@ namespace Shimmie2;
class BlocksTest extends ShimmiePHPUnitTestCase
{
public function testBlocks(): void
public function testBlocks()
{
$this->log_in_as_admin();
$this->get_page("blocks/list");

View File

@ -16,38 +16,35 @@ use function MicroHTML\OPTION;
class BlocksTheme extends Themelet
{
/**
* @param array<array{id:int,title:string,area:string,priority:int,userclass:string,pages:string,content:string}> $blocks
*/
public function display_blocks(array $blocks): void
public function display_blocks($blocks)
{
global $page;
$html = TABLE(["class" => "form", "style" => "width: 100%;"]);
$html = TABLE(["class"=>"form", "style"=>"width: 100%;"]);
foreach ($blocks as $block) {
$html->appendChild(SHM_SIMPLE_FORM(
"blocks/update",
TR(
INPUT(["type" => "hidden", "name" => "id", "value" => $block['id']]),
INPUT(["type"=>"hidden", "name"=>"id", "value"=>$block['id']]),
TH("Title"),
TD(INPUT(["type" => "text", "name" => "title", "value" => $block['title']])),
TD(INPUT(["type"=>"text", "name"=>"title", "value"=>$block['title']])),
TH("Area"),
TD(INPUT(["type" => "text", "name" => "area", "value" => $block['area']])),
TD(INPUT(["type"=>"text", "name"=>"area", "value"=>$block['area']])),
TH("Priority"),
TD(INPUT(["type" => "text", "name" => "priority", "value" => $block['priority']])),
TD(INPUT(["type"=>"text", "name"=>"priority", "value"=>$block['priority']])),
TH("User Class"),
TD(INPUT(["type" => "text", "name" => "userclass", "value" => $block['userclass']])),
TD(INPUT(["type"=>"text", "name"=>"userclass", "value"=>$block['userclass']])),
TH("Pages"),
TD(INPUT(["type" => "text", "name" => "pages", "value" => $block['pages']])),
TD(INPUT(["type"=>"text", "name"=>"pages", "value"=>$block['pages']])),
TH("Delete"),
TD(INPUT(["type" => "checkbox", "name" => "delete"])),
TD(INPUT(["type" => "submit", "value" => "Save"]))
TD(INPUT(["type"=>"checkbox", "name"=>"delete"])),
TD(INPUT(["type"=>"submit", "value"=>"Save"]))
),
TR(
TD(["colspan" => "13"], TEXTAREA(["rows" => "5", "name" => "content"], $block['content']))
TD(["colspan"=>"13"], TEXTAREA(["rows"=>"5", "name"=>"content"], $block['content']))
),
TR(
TD(["colspan" => "13"], rawHTML("&nbsp;"))
TD(["colspan"=>"13"], rawHTML("&nbsp;"))
),
));
}
@ -56,19 +53,19 @@ class BlocksTheme extends Themelet
"blocks/add",
TR(
TH("Title"),
TD(INPUT(["type" => "text", "name" => "title", "value" => ""])),
TD(INPUT(["type"=>"text", "name"=>"title", "value"=>""])),
TH("Area"),
TD(SELECT(["name" => "area"], OPTION("left"), OPTION("main"))),
TD(SELECT(["name"=>"area"], OPTION("left"), OPTION("main"))),
TH("Priority"),
TD(INPUT(["type" => "text", "name" => "priority", "value" => '50'])),
TD(INPUT(["type"=>"text", "name"=>"priority", "value"=>'50'])),
TH("User Class"),
TD(INPUT(["type" => "text", "name" => "userclass", "value" => ""])),
TD(INPUT(["type"=>"text", "name"=>"userclass", "value"=>""])),
TH("Pages"),
TD(INPUT(["type" => "text", "name" => "pages", "value" => 'post/list*'])),
TD(["colspan" => '3'], INPUT(["type" => "submit", "value" => "Add"]))
TD(INPUT(["type"=>"text", "name"=>"pages", "value"=>'post/list*'])),
TD(["colspan"=>'3'], INPUT(["type"=>"submit", "value"=>"Add"]))
),
TR(
TD(["colspan" => "13"], TEXTAREA(["rows" => "5", "name" => "content"]))
TD(["colspan"=>"13"], TEXTAREA(["rows"=>"5", "name"=>"content"]))
),
));

View File

@ -11,8 +11,10 @@ class BlotterInfo extends ExtensionInfo
public string $key = self::KEY;
public string $name = "Blotter";
public string $url = "http://seemslegit.com/";
public array $authors = ["Zach Hall" => "zach@sosguy.net"];
public array $authors = ["Zach Hall"=>"zach@sosguy.net"];
public string $license = self::LICENSE_GPLV2;
public string $description = "Displays brief updates about whatever you want on every page.";
public ?string $documentation = "Colors and positioning can be configured to match your site's design.<p>Development TODO at https://github.com/zshall/shimmie2/issues";
public string $description = "Displays brief updates about whatever you want on every page.
Colors and positioning can be configured to match your site's design.
Development TODO at https://github.com/zshall/shimmie2/issues";
}

View File

@ -9,7 +9,7 @@ class Blotter extends Extension
/** @var BlotterTheme */
protected Themelet $theme;
public function onInitExt(InitExtEvent $event): void
public function onInitExt(InitExtEvent $event)
{
global $config;
$config->set_default_int("blotter_recent", 5);
@ -17,7 +17,7 @@ class Blotter extends Extension
$config->set_default_string("blotter_position", "subheading");
}
public function onDatabaseUpgrade(DatabaseUpgradeEvent $event): void
public function onDatabaseUpgrade(DatabaseUpgradeEvent $event)
{
global $database;
@ -31,7 +31,7 @@ class Blotter extends Extension
// Insert sample data:
$database->execute(
"INSERT INTO blotter (entry_date, entry_text, important) VALUES (now(), :text, :important)",
["text" => "Installed the blotter extension!", "important" => true]
["text"=>"Installed the blotter extension!", "important"=>true]
);
log_info("blotter", "Installed tables for blotter extension.");
$this->set_version("blotter_version", 2);
@ -42,7 +42,7 @@ class Blotter extends Extension
}
}
public function onSetupBuilding(SetupBuildingEvent $event): void
public function onSetupBuilding(SetupBuildingEvent $event)
{
$sb = $event->panel->create_new_block("Blotter");
$sb->add_int_option("blotter_recent", "<br />Number of recent entries to display: ");
@ -50,10 +50,10 @@ class Blotter extends Extension
$sb->add_choice_option("blotter_position", ["Top of page" => "subheading", "In navigation bar" => "left"], "<br>Position: ");
}
public function onPageSubNavBuilding(PageSubNavBuildingEvent $event): void
public function onPageSubNavBuilding(PageSubNavBuildingEvent $event)
{
global $user;
if ($event->parent === "system") {
if ($event->parent==="system") {
if ($user->can(Permissions::BLOTTER_ADMIN)) {
$event->add_nav_link("blotter", new Link('blotter/editor'), "Blotter Editor");
}
@ -61,7 +61,7 @@ class Blotter extends Extension
}
public function onUserBlockBuilding(UserBlockBuildingEvent $event): void
public function onUserBlockBuilding(UserBlockBuildingEvent $event)
{
global $user;
if ($user->can(Permissions::BLOTTER_ADMIN)) {
@ -69,7 +69,7 @@ class Blotter extends Extension
}
}
public function onPageRequest(PageRequestEvent $event): void
public function onPageRequest(PageRequestEvent $event)
{
global $page, $database, $user;
if ($event->page_matches("blotter") && $event->count_args() > 0) {
@ -100,7 +100,7 @@ class Blotter extends Extension
// Now insert into db:
$database->execute(
"INSERT INTO blotter (entry_date, entry_text, important) VALUES (now(), :text, :important)",
["text" => $entry_text, "important" => $important]
["text"=>$entry_text, "important"=>$important]
);
log_info("blotter", "Added Message: $entry_text");
$page->set_mode(PageMode::REDIRECT);
@ -115,7 +115,7 @@ class Blotter extends Extension
$this->theme->display_permission_denied();
} else {
$id = int_escape($_POST['id']);
$database->execute("DELETE FROM blotter WHERE id=:id", ["id" => $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"));
@ -136,7 +136,7 @@ class Blotter extends Extension
$this->display_blotter();
}
private function display_blotter(): void
private function display_blotter()
{
global $database, $config;
$limit = $config->get_int("blotter_recent", 5);

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