Compare commits
3 Commits
main
...
claro-thro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0aa9d0548 | ||
|
|
3c7414cee1 | ||
|
|
4d265a4deb |
@@ -1,104 +0,0 @@
|
||||
ARG PROXY_REGISTRY
|
||||
|
||||
FROM ${PROXY_REGISTRY}alpine:3.22
|
||||
EXPOSE 9000/tcp
|
||||
|
||||
ARG ALPINE_MIRROR
|
||||
|
||||
ENV SCRIPT_ROOT=/opt/tt-rss
|
||||
ENV SRC_DIR=/src/tt-rss/
|
||||
|
||||
# Normally there's no need to change this, should point to a volume shared with nginx container
|
||||
ENV APP_INSTALL_BASE_DIR=/var/www/html
|
||||
|
||||
# Used to centralize the PHP version suffix for packages and paths
|
||||
ENV PHP_SUFFIX=84
|
||||
|
||||
RUN [ ! -z ${ALPINE_MIRROR} ] && \
|
||||
sed -i.bak "s#dl-cdn.alpinelinux.org#${ALPINE_MIRROR}#" /etc/apk/repositories ; \
|
||||
apk add --no-cache ca-certificates dcron git postgresql-client rsync sudo tzdata \
|
||||
php${PHP_SUFFIX} \
|
||||
$(for p in ctype curl dom exif fileinfo fpm gd iconv intl json mbstring opcache \
|
||||
openssl pcntl pdo pdo_pgsql pecl-apcu pecl-xdebug phar posix session simplexml sockets sodium tokenizer xml xmlwriter zip; do \
|
||||
php_pkgs="$php_pkgs php${PHP_SUFFIX}-$p"; \
|
||||
done; \
|
||||
echo $php_pkgs) && \
|
||||
sed -i 's/\(memory_limit =\) 128M/\1 256M/' /etc/php${PHP_SUFFIX}/php.ini && \
|
||||
sed -i -e 's/^listen = 127.0.0.1:9000/listen = 9000/' \
|
||||
-e 's/;\(clear_env\) = .*/\1 = no/i' \
|
||||
-e 's/;\(pm.status_path = \/status\)/\1/i' \
|
||||
-e 's/;\(pm.status_listen\) = .*/\1 = 9001/i' \
|
||||
-e 's/^\(user\|group\) = .*/\1 = app/i' \
|
||||
-e 's/;\(php_admin_value\[error_log\]\) = .*/\1 = \/tmp\/error.log/' \
|
||||
-e 's/;\(php_admin_flag\[log_errors\]\) = .*/\1 = on/' \
|
||||
/etc/php${PHP_SUFFIX}/php-fpm.d/www.conf && \
|
||||
mkdir -p /var/www ${SCRIPT_ROOT}/config.d ${SCRIPT_ROOT}/sql/post-init.d
|
||||
|
||||
ARG CI_COMMIT_BRANCH
|
||||
ENV CI_COMMIT_BRANCH=${CI_COMMIT_BRANCH}
|
||||
|
||||
ARG CI_COMMIT_SHORT_SHA
|
||||
ENV CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
|
||||
|
||||
ARG CI_COMMIT_TIMESTAMP
|
||||
ENV CI_COMMIT_TIMESTAMP=${CI_COMMIT_TIMESTAMP}
|
||||
|
||||
ARG CI_COMMIT_SHA
|
||||
ENV CI_COMMIT_SHA=${CI_COMMIT_SHA}
|
||||
|
||||
ADD .docker/app/startup.sh ${SCRIPT_ROOT}
|
||||
ADD .docker/app/update.sh ${SCRIPT_ROOT}
|
||||
ADD .docker/app/updater.sh ${SCRIPT_ROOT}
|
||||
ADD .docker/app/dcron.sh ${SCRIPT_ROOT}
|
||||
ADD .docker/app/backup.sh /etc/periodic/weekly/backup
|
||||
|
||||
RUN chmod 0755 ${SCRIPT_ROOT}/*.sh /etc/periodic/weekly/backup
|
||||
|
||||
ADD .docker/app/index.php ${SCRIPT_ROOT}
|
||||
ADD .docker/app/config.docker.php ${SCRIPT_ROOT}
|
||||
|
||||
COPY . ${SRC_DIR}
|
||||
|
||||
ARG ORIGIN_REPO_XACCEL=https://github.com/tt-rss/tt-rss-plugin-nginx-xaccel.git
|
||||
|
||||
RUN git clone --depth=1 ${ORIGIN_REPO_XACCEL} ${SRC_DIR}/plugins.local/nginx_xaccel
|
||||
|
||||
ENV OWNER_UID=1000
|
||||
ENV OWNER_GID=1000
|
||||
|
||||
ENV PHP_WORKER_MAX_CHILDREN=5
|
||||
ENV PHP_WORKER_MEMORY_LIMIT=256M
|
||||
|
||||
# these are applied on every startup, if set
|
||||
ENV ADMIN_USER_PASS=""
|
||||
# see classes/UserHelper.php ACCESS_LEVEL_*
|
||||
# setting this to -2 would effectively disable built-in admin user
|
||||
# unless single user mode is enabled
|
||||
ENV ADMIN_USER_ACCESS_LEVEL=""
|
||||
|
||||
# these are applied unless user already exists
|
||||
ENV AUTO_CREATE_USER=""
|
||||
ENV AUTO_CREATE_USER_PASS=""
|
||||
ENV AUTO_CREATE_USER_ACCESS_LEVEL="0"
|
||||
ENV AUTO_CREATE_USER_ENABLE_API=""
|
||||
|
||||
# TODO: remove prefix from container variables not used by tt-rss itself:
|
||||
#
|
||||
# - TTRSS_NO_STARTUP_PLUGIN_UPDATES -> NO_STARTUP_PLUGIN_UPDATES
|
||||
# - TTRSS_XDEBUG_... -> XDEBUG_...
|
||||
|
||||
# don't try to update local plugins on startup
|
||||
ENV TTRSS_NO_STARTUP_PLUGIN_UPDATES=""
|
||||
|
||||
# TTRSS_XDEBUG_HOST defaults to host IP if unset
|
||||
ENV TTRSS_XDEBUG_ENABLED=""
|
||||
ENV TTRSS_XDEBUG_HOST=""
|
||||
ENV TTRSS_XDEBUG_PORT="9000"
|
||||
|
||||
ENV TTRSS_DB_HOST="db"
|
||||
ENV TTRSS_DB_PORT="5432"
|
||||
|
||||
ENV TTRSS_PHP_EXECUTABLE="/usr/bin/php${PHP_SUFFIX}"
|
||||
ENV TTRSS_PLUGINS="auth_internal, note, nginx_xaccel"
|
||||
|
||||
CMD ${SCRIPT_ROOT}/startup.sh
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/bin/sh -e
|
||||
|
||||
DST_DIR=/backups
|
||||
KEEP_DAYS=28
|
||||
APP_ROOT=$APP_INSTALL_BASE_DIR/tt-rss
|
||||
|
||||
if pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER -p $TTRSS_DB_PORT; then
|
||||
DST_FILE=ttrss-backup-$(date +%Y%m%d).sql.gz
|
||||
|
||||
echo backing up tt-rss database to $DST_DIR/$DST_FILE...
|
||||
|
||||
export PGPASSWORD=$TTRSS_DB_PASS
|
||||
|
||||
pg_dump --clean -h $TTRSS_DB_HOST -U $TTRSS_DB_USER $TTRSS_DB_NAME | gzip > $DST_DIR/$DST_FILE
|
||||
|
||||
DST_FILE=ttrss-backup-$(date +%Y%m%d).tar.gz
|
||||
|
||||
echo backing up tt-rss local directories to $DST_DIR/$DST_FILE...
|
||||
|
||||
tar -cz -f $DST_DIR/$DST_FILE $APP_ROOT/*.local \
|
||||
$APP_ROOT/cache/feed-icons/ \
|
||||
$APP_ROOT/config.php
|
||||
|
||||
echo cleaning up...
|
||||
|
||||
find $DST_DIR -type f -name '*.gz' -mtime +$KEEP_DAYS -delete
|
||||
|
||||
echo done.
|
||||
else
|
||||
echo backup failed: database is not ready.
|
||||
fi
|
||||
@@ -1,8 +0,0 @@
|
||||
<?php
|
||||
|
||||
$snippets = glob(getenv("SCRIPT_ROOT")."/config.d/*.php");
|
||||
|
||||
foreach ($snippets as $snippet) {
|
||||
require_once $snippet;
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/sh
|
||||
# https://github.com/dubiousjim/dcron/issues/13
|
||||
set -e
|
||||
|
||||
/usr/sbin/crond "$@"
|
||||
@@ -1,3 +0,0 @@
|
||||
<?php
|
||||
header("Location: /tt-rss/");
|
||||
return;
|
||||
@@ -1,170 +0,0 @@
|
||||
#!/bin/sh -e
|
||||
#
|
||||
# this script initializes the working copy on a persistent volume and starts PHP FPM
|
||||
#
|
||||
|
||||
# TODO this should do a reasonable amount of attempts and terminate with an error
|
||||
while ! pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER -p $TTRSS_DB_PORT; do
|
||||
echo waiting until $TTRSS_DB_HOST is ready...
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# We don't need those here (HTTP_HOST would cause false SELF_URL_PATH check failures)
|
||||
unset HTTP_PORT
|
||||
unset HTTP_HOST
|
||||
|
||||
if ! id app >/dev/null 2>&1; then
|
||||
addgroup -g $OWNER_GID app
|
||||
adduser -D -h $APP_INSTALL_BASE_DIR -G app -u $OWNER_UID app
|
||||
fi
|
||||
|
||||
update-ca-certificates || true
|
||||
|
||||
DST_DIR=$APP_INSTALL_BASE_DIR/tt-rss
|
||||
|
||||
[ -e $DST_DIR ] && rm -f $DST_DIR/.app_is_ready
|
||||
|
||||
export PGPASSWORD=$TTRSS_DB_PASS
|
||||
|
||||
[ ! -e $APP_INSTALL_BASE_DIR/index.php ] && cp ${SCRIPT_ROOT}/index.php $APP_INSTALL_BASE_DIR
|
||||
|
||||
if [ -z $SKIP_RSYNC_ON_STARTUP ]; then
|
||||
if [ ! -d $DST_DIR ]; then
|
||||
mkdir -p $DST_DIR
|
||||
chown $OWNER_UID:$OWNER_GID $DST_DIR
|
||||
|
||||
sudo -u app rsync -a --no-owner \
|
||||
$SRC_DIR/ $DST_DIR/
|
||||
else
|
||||
chown -R $OWNER_UID:$OWNER_GID $DST_DIR
|
||||
|
||||
sudo -u app rsync -a --no-owner --delete \
|
||||
--exclude /cache \
|
||||
--exclude /lock \
|
||||
--exclude /feed-icons \
|
||||
--exclude /plugins/af_comics/filters.local \
|
||||
--exclude /plugins.local \
|
||||
--exclude /templates.local \
|
||||
--exclude /themes.local \
|
||||
$SRC_DIR/ $DST_DIR/
|
||||
|
||||
sudo -u app rsync -a --no-owner --delete \
|
||||
$SRC_DIR/plugins.local/nginx_xaccel \
|
||||
$DST_DIR/plugins.local/nginx_xaccel
|
||||
fi
|
||||
else
|
||||
echo "warning: working copy in $DST_DIR won't be updated, make sure you know what you're doing."
|
||||
fi
|
||||
|
||||
for d in cache lock feed-icons plugins.local themes.local templates.local cache/export cache/feeds cache/images cache/upload; do
|
||||
sudo -u app mkdir -p $DST_DIR/$d
|
||||
done
|
||||
|
||||
# this is some next level bullshit
|
||||
# - https://stackoverflow.com/questions/65622914/why-would-i-get-a-php-pdoexception-complaining-that-it-cant-make-a-postgres-con
|
||||
# - fatal error: could not open certificate file "/root/.postgresql/postgresql.crt": Permission denied
|
||||
chown -R app:app /root # /.postgresql
|
||||
|
||||
for d in cache lock feed-icons; do
|
||||
chown -R app:app $DST_DIR/$d
|
||||
chmod -R u=rwX,g=rX,o=rX $DST_DIR/$d
|
||||
done
|
||||
|
||||
sudo -u app cp ${SCRIPT_ROOT}/config.docker.php $DST_DIR/config.php
|
||||
chmod 644 $DST_DIR/config.php
|
||||
|
||||
chown -R $OWNER_UID:$OWNER_GID $DST_DIR \
|
||||
/var/log/php${PHP_SUFFIX}
|
||||
|
||||
if [ -z "$TTRSS_NO_STARTUP_PLUGIN_UPDATES" ]; then
|
||||
echo updating all local plugins...
|
||||
|
||||
find $DST_DIR/plugins.local -mindepth 1 -maxdepth 1 -type d | while read PLUGIN; do
|
||||
if [ -d $PLUGIN/.git ]; then
|
||||
echo updating $PLUGIN...
|
||||
|
||||
cd $PLUGIN && \
|
||||
sudo -u app git config core.filemode false && \
|
||||
sudo -u app git config pull.rebase false && \
|
||||
sudo -u app git pull origin main || sudo -u app git pull origin master || echo warning: attempt to update plugin $PLUGIN failed.
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo skipping local plugin updates, disabled.
|
||||
fi
|
||||
|
||||
PSQL="psql -q -h $TTRSS_DB_HOST -p $TTRSS_DB_PORT -U $TTRSS_DB_USER $TTRSS_DB_NAME"
|
||||
|
||||
$PSQL -c "create extension if not exists pg_trgm"
|
||||
|
||||
# this was previously generated
|
||||
rm -f $DST_DIR/config.php.bak
|
||||
|
||||
if [ ! -z "${TTRSS_XDEBUG_ENABLED}" ]; then
|
||||
if [ -z "${TTRSS_XDEBUG_HOST}" ]; then
|
||||
export TTRSS_XDEBUG_HOST=$(ip ro sh 0/0 | cut -d " " -f 3)
|
||||
fi
|
||||
echo enabling xdebug with the following parameters:
|
||||
env | grep TTRSS_XDEBUG
|
||||
cat > /etc/php${PHP_SUFFIX}/conf.d/50_xdebug.ini <<EOF
|
||||
zend_extension=xdebug.so
|
||||
xdebug.mode=debug
|
||||
xdebug.start_with_request = yes
|
||||
xdebug.client_port = ${TTRSS_XDEBUG_PORT}
|
||||
xdebug.client_host = ${TTRSS_XDEBUG_HOST}
|
||||
EOF
|
||||
fi
|
||||
|
||||
sed -i.bak "s/^\(memory_limit\) = \(.*\)/\1 = ${PHP_WORKER_MEMORY_LIMIT}/" \
|
||||
/etc/php${PHP_SUFFIX}/php.ini
|
||||
|
||||
sed -i.bak "s/^\(pm.max_children\) = \(.*\)/\1 = ${PHP_WORKER_MAX_CHILDREN}/" \
|
||||
/etc/php${PHP_SUFFIX}/php-fpm.d/www.conf
|
||||
|
||||
sudo -Eu app php${PHP_SUFFIX} $DST_DIR/update.php --update-schema=force-yes
|
||||
|
||||
if [ ! -z "$ADMIN_USER_PASS" ]; then
|
||||
sudo -Eu app php${PHP_SUFFIX} $DST_DIR/update.php --user-set-password "admin:$ADMIN_USER_PASS"
|
||||
else
|
||||
if sudo -Eu app php${PHP_SUFFIX} $DST_DIR/update.php --user-check-password "admin:password"; then
|
||||
RANDOM_PASS=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 16 ; echo '')
|
||||
|
||||
echo "*****************************************************************************"
|
||||
echo "* Setting initial built-in admin user password to '$RANDOM_PASS' *"
|
||||
echo "* If you want to set it manually, use ADMIN_USER_PASS environment variable. *"
|
||||
echo "*****************************************************************************"
|
||||
|
||||
sudo -Eu app php${PHP_SUFFIX} $DST_DIR/update.php --user-set-password "admin:$RANDOM_PASS"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -z "$ADMIN_USER_ACCESS_LEVEL" ]; then
|
||||
sudo -Eu app php${PHP_SUFFIX} $DST_DIR/update.php --user-set-access-level "admin:$ADMIN_USER_ACCESS_LEVEL"
|
||||
fi
|
||||
|
||||
if [ ! -z "$AUTO_CREATE_USER" ]; then
|
||||
sudo -Eu app /bin/sh -c "php${PHP_SUFFIX} $DST_DIR/update.php --user-exists $AUTO_CREATE_USER ||
|
||||
php${PHP_SUFFIX} $DST_DIR/update.php --force-yes --user-add \"$AUTO_CREATE_USER:$AUTO_CREATE_USER_PASS:$AUTO_CREATE_USER_ACCESS_LEVEL\""
|
||||
|
||||
if [ ! -z "$AUTO_CREATE_USER_ENABLE_API" ]; then
|
||||
# TODO: remove || true later
|
||||
sudo -Eu app /bin/sh -c "php${PHP_SUFFIX} $DST_DIR/update.php --user-enable-api \"$AUTO_CREATE_USER:$AUTO_CREATE_USER_ENABLE_API\"" || true
|
||||
fi
|
||||
|
||||
fi
|
||||
|
||||
rm -f /tmp/error.log && mkfifo /tmp/error.log && chown app:app /tmp/error.log
|
||||
|
||||
(tail -q -f /tmp/error.log >> /proc/1/fd/2) &
|
||||
|
||||
unset ADMIN_USER_PASS
|
||||
unset AUTO_CREATE_USER_PASS
|
||||
|
||||
find ${SCRIPT_ROOT}/sql/post-init.d/ -type f -name '*.sql' | while read F; do
|
||||
echo applying SQL patch file: $F
|
||||
$PSQL -f $F
|
||||
done
|
||||
|
||||
touch $DST_DIR/.app_is_ready
|
||||
|
||||
exec /usr/sbin/php-fpm${PHP_SUFFIX} --nodaemonize --force-stderr
|
||||
@@ -1,86 +0,0 @@
|
||||
#!/bin/sh -e
|
||||
#
|
||||
# this script kickstarts a minimal working environment and runs update.php, could be used as an entrypoint for a cronjob
|
||||
# which doesn't share a volume with FPM/updater
|
||||
#
|
||||
|
||||
# We don't need those here (HTTP_HOST would cause false SELF_URL_PATH check failures)
|
||||
unset HTTP_PORT
|
||||
unset HTTP_HOST
|
||||
|
||||
if ! id app >/dev/null 2>&1; then
|
||||
addgroup -g $OWNER_GID app
|
||||
adduser -D -h $APP_INSTALL_BASE_DIR -G app -u $OWNER_UID app
|
||||
fi
|
||||
|
||||
update-ca-certificates || true
|
||||
|
||||
DST_DIR=$APP_INSTALL_BASE_DIR/tt-rss
|
||||
|
||||
if [ -z $SKIP_RSYNC_ON_STARTUP ]; then
|
||||
if [ ! -d $DST_DIR ]; then
|
||||
mkdir -p $DST_DIR
|
||||
chown $OWNER_UID:$OWNER_GID $DST_DIR
|
||||
|
||||
sudo -u app rsync -a --no-owner \
|
||||
$SRC_DIR/ $DST_DIR/
|
||||
else
|
||||
chown -R $OWNER_UID:$OWNER_GID $DST_DIR
|
||||
|
||||
sudo -u app rsync -a --no-owner --delete \
|
||||
--exclude /cache \
|
||||
--exclude /lock \
|
||||
--exclude /feed-icons \
|
||||
--exclude /plugins/af_comics/filters.local \
|
||||
--exclude /plugins.local \
|
||||
--exclude /templates.local \
|
||||
--exclude /themes.local \
|
||||
$SRC_DIR/ $DST_DIR/
|
||||
|
||||
sudo -u app rsync -a --no-owner --delete \
|
||||
$SRC_DIR/plugins.local/nginx_xaccel \
|
||||
$DST_DIR/plugins.local/nginx_xaccel
|
||||
fi
|
||||
else
|
||||
echo "warning: working copy in $DST_DIR won't be updated, make sure you know what you're doing."
|
||||
fi
|
||||
|
||||
for d in cache lock feed-icons plugins.local themes.local templates.local cache/export cache/feeds cache/images cache/upload; do
|
||||
sudo -u app mkdir -p $DST_DIR/$d
|
||||
done
|
||||
|
||||
# this is some next level bullshit
|
||||
# - https://stackoverflow.com/questions/65622914/why-would-i-get-a-php-pdoexception-complaining-that-it-cant-make-a-postgres-con
|
||||
# - fatal error: could not open certificate file "/root/.postgresql/postgresql.crt": Permission denied
|
||||
chown -R app:app /root # /.postgresql
|
||||
|
||||
for d in cache lock feed-icons; do
|
||||
chown -R app:app $DST_DIR/$d
|
||||
chmod -R u=rwX,g=rX,o=rX $DST_DIR/$d
|
||||
done
|
||||
|
||||
sudo -u app cp ${SCRIPT_ROOT}/config.docker.php $DST_DIR/config.php
|
||||
chmod 644 $DST_DIR/config.php
|
||||
|
||||
if [ ! -z "${TTRSS_XDEBUG_ENABLED}" ]; then
|
||||
if [ -z "${TTRSS_XDEBUG_HOST}" ]; then
|
||||
export TTRSS_XDEBUG_HOST=$(ip ro sh 0/0 | cut -d " " -f 3)
|
||||
fi
|
||||
echo enabling xdebug with the following parameters:
|
||||
env | grep TTRSS_XDEBUG
|
||||
cat > /etc/php${PHP_SUFFIX}/conf.d/50_xdebug.ini <<EOF
|
||||
zend_extension=xdebug.so
|
||||
xdebug.mode=debug
|
||||
xdebug.start_with_request = yes
|
||||
xdebug.client_port = ${TTRSS_XDEBUG_PORT}
|
||||
xdebug.client_host = ${TTRSS_XDEBUG_HOST}
|
||||
EOF
|
||||
fi
|
||||
|
||||
sed -i.bak "s/^\(memory_limit\) = \(.*\)/\1 = ${PHP_WORKER_MEMORY_LIMIT}/" \
|
||||
/etc/php${PHP_SUFFIX}/php.ini
|
||||
|
||||
sed -i.bak "s/^\(pm.max_children\) = \(.*\)/\1 = ${PHP_WORKER_MAX_CHILDREN}/" \
|
||||
/etc/php${PHP_SUFFIX}/php-fpm.d/www.conf
|
||||
|
||||
sudo -Eu app php${PHP_SUFFIX} $DST_DIR/update.php "$@"
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/bin/sh -e
|
||||
#
|
||||
# this scripts waits for startup.sh to finish (implying a shared volume) and runs multiprocess daemon when working copy is available
|
||||
#
|
||||
|
||||
# We don't need those here (HTTP_HOST would cause false SELF_URL_PATH check failures)
|
||||
unset HTTP_PORT
|
||||
unset HTTP_HOST
|
||||
|
||||
unset ADMIN_USER_PASS
|
||||
unset AUTO_CREATE_USER_PASS
|
||||
|
||||
# wait for the app container to delete .app_is_ready and perform rsync, etc.
|
||||
sleep 30
|
||||
|
||||
if ! id app; then
|
||||
addgroup -g $OWNER_GID app
|
||||
adduser -D -h $APP_INSTALL_BASE_DIR -G app -u $OWNER_UID app
|
||||
fi
|
||||
|
||||
update-ca-certificates || true
|
||||
|
||||
# TODO this should do a reasonable amount of attempts and terminate with an error
|
||||
while ! pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER -p $TTRSS_DB_PORT; do
|
||||
echo waiting until $TTRSS_DB_HOST is ready...
|
||||
sleep 3
|
||||
done
|
||||
|
||||
sed -i.bak "s/^\(memory_limit\) = \(.*\)/\1 = ${PHP_WORKER_MEMORY_LIMIT}/" \
|
||||
/etc/php${PHP_SUFFIX}/php.ini
|
||||
|
||||
DST_DIR=$APP_INSTALL_BASE_DIR/tt-rss
|
||||
|
||||
while [ ! -s $DST_DIR/config.php -a -e $DST_DIR/.app_is_ready ]; do
|
||||
echo waiting for app container...
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# this is some next level bullshit
|
||||
# - https://stackoverflow.com/questions/65622914/why-would-i-get-a-php-pdoexception-complaining-that-it-cant-make-a-postgres-con
|
||||
# - fatal error: could not open certificate file "/root/.postgresql/postgresql.crt": Permission denied
|
||||
chown -R app:app /root # /.postgresql
|
||||
|
||||
sudo -E -u app "${TTRSS_PHP_EXECUTABLE}" $APP_INSTALL_BASE_DIR/tt-rss/update_daemon2.php "$@"
|
||||
@@ -1,5 +0,0 @@
|
||||
ARG PROXY_REGISTRY
|
||||
FROM ${PROXY_REGISTRY}nginxinc/nginx-unprivileged:1-alpine
|
||||
|
||||
COPY ./phpdoc /usr/share/nginx/html/ttrss-docs
|
||||
COPY .docker/phpdoc/redirects.conf /etc/nginx/conf.d/
|
||||
@@ -1,2 +0,0 @@
|
||||
port_in_redirect off;
|
||||
absolute_redirect off;
|
||||
@@ -1,30 +0,0 @@
|
||||
ARG PROXY_REGISTRY
|
||||
FROM ${PROXY_REGISTRY}nginx:alpine
|
||||
|
||||
HEALTHCHECK CMD curl --fail http://localhost${APP_BASE}/index.php || exit 1
|
||||
|
||||
COPY .docker/web-nginx/nginx.conf /etc/nginx/templates/nginx.conf.template
|
||||
|
||||
# By default, nginx will send the php requests to "app" server, but this server
|
||||
# name can be overridden at runtime by passing an APP_UPSTREAM env var
|
||||
ENV APP_UPSTREAM=${APP_UPSTREAM:-app}
|
||||
ENV APP_FASTCGI_PASS="${APP_FASTCGI_PASS:-\$backend}"
|
||||
|
||||
# Webroot (defaults to /var/www/html)
|
||||
ENV APP_WEB_ROOT=${APP_WEB_ROOT:-/var/www/html}
|
||||
|
||||
# Base location for tt-rss (defaults to /tt-rss)
|
||||
ENV APP_BASE=${APP_BASE:-/tt-rss}
|
||||
|
||||
# Resolver for nginx (kube-dns.kube-system.svc.cluster.local for k8s)
|
||||
ENV RESOLVER=${RESOLVER:-127.0.0.11}
|
||||
|
||||
# In order to make tt-rss appear on website root without /tt-rss/ set above as follows in .env:
|
||||
# APP_WEB_ROOT=/var/www/html/tt-rss
|
||||
# APP_BASE=
|
||||
|
||||
# It's necessary to set the following NGINX_ENVSUBST_OUTPUT_DIR env var to tell
|
||||
# nginx to replace the env vars of /etc/nginx/templates/nginx.conf.template
|
||||
# and put the result in /etc/nginx/nginx.conf (instead of /etc/nginx/conf.d/nginx.conf)
|
||||
# See https://github.com/docker-library/docs/tree/master/nginx#using-environment-variables-in-nginx-configuration-new-in-119
|
||||
ENV NGINX_ENVSUBST_OUTPUT_DIR=/etc/nginx
|
||||
@@ -1,78 +0,0 @@
|
||||
worker_processes auto;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
access_log /dev/stdout;
|
||||
error_log /dev/stderr warn;
|
||||
|
||||
sendfile on;
|
||||
|
||||
index index.php;
|
||||
|
||||
resolver ${RESOLVER} valid=5s;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
root ${APP_WEB_ROOT};
|
||||
|
||||
location ${APP_BASE}/cache {
|
||||
aio threads;
|
||||
internal;
|
||||
}
|
||||
|
||||
location ${APP_BASE}/backups {
|
||||
internal;
|
||||
}
|
||||
|
||||
rewrite ${APP_BASE}/healthz ${APP_BASE}/public.php?op=healthcheck;
|
||||
|
||||
# Regular PHP handling (without PATH_INFO)
|
||||
location ~ \.php$ {
|
||||
# regex to split $uri to $fastcgi_script_name and $fastcgi_path
|
||||
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
|
||||
|
||||
# Check that the PHP script exists before passing it
|
||||
try_files $fastcgi_script_name =404;
|
||||
|
||||
fastcgi_index index.php;
|
||||
include fastcgi.conf;
|
||||
|
||||
set $backend "${APP_UPSTREAM}:9000";
|
||||
|
||||
fastcgi_pass ${APP_FASTCGI_PASS};
|
||||
}
|
||||
|
||||
# Allow PATH_INFO for PHP files in plugins.local directories with an /api/ sub directory to allow plugins to leverage when desired
|
||||
location ~ /plugins\.local/.*/api/.*\.php(/|$) {
|
||||
# regex to split $uri to $fastcgi_script_name and $fastcgi_path
|
||||
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
|
||||
|
||||
# Check that the PHP script exists before passing it
|
||||
try_files $fastcgi_script_name =404;
|
||||
|
||||
# Bypass the fact that try_files resets $fastcgi_path_info
|
||||
# see: http://trac.nginx.org/nginx/ticket/321
|
||||
set $path_info $fastcgi_path_info;
|
||||
fastcgi_param PATH_INFO $path_info;
|
||||
|
||||
fastcgi_index index.php;
|
||||
include fastcgi.conf;
|
||||
|
||||
set $backend "${APP_UPSTREAM}:9000";
|
||||
|
||||
fastcgi_pass ${APP_FASTCGI_PASS};
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
.git/
|
||||
cache/
|
||||
plugins.local/
|
||||
templates.local/
|
||||
themes.local/
|
||||
@@ -4,6 +4,3 @@ insert_final_newline = true
|
||||
|
||||
[*.php]
|
||||
indent_style = tab
|
||||
|
||||
[*.js]
|
||||
indent_style = tab
|
||||
|
||||
47
.env-dist
47
.env-dist
@@ -1,47 +0,0 @@
|
||||
# Copy this file to .env before building the container. Put any local modifications here.
|
||||
|
||||
# Run FPM under this UID/GID.
|
||||
# OWNER_UID=1000
|
||||
# OWNER_GID=1000
|
||||
|
||||
# FPM settings.
|
||||
#PHP_WORKER_MAX_CHILDREN=5
|
||||
#PHP_WORKER_MEMORY_LIMIT=256M
|
||||
|
||||
# ADMIN_USER_* settings are applied on every startup.
|
||||
|
||||
# Set admin user password to this value. If not set, random password will be generated on startup, look for it in the 'app' container logs.
|
||||
#ADMIN_USER_PASS=
|
||||
|
||||
# Sets admin user access level to this value. Valid values:
|
||||
# -2 - forbidden to login
|
||||
# -1 - readonly
|
||||
# 0 - default user
|
||||
# 10 - admin
|
||||
#ADMIN_USER_ACCESS_LEVEL=
|
||||
|
||||
# Auto create another user (in addition to built-in admin) unless it already exists.
|
||||
#AUTO_CREATE_USER=
|
||||
#AUTO_CREATE_USER_PASS=
|
||||
#AUTO_CREATE_USER_ACCESS_LEVEL=0
|
||||
|
||||
# Default database credentials.
|
||||
TTRSS_DB_USER=postgres
|
||||
TTRSS_DB_NAME=postgres
|
||||
TTRSS_DB_PASS=password
|
||||
|
||||
# This is a fallback value for PHP CLI SAPI, it should be set to a fully-qualified tt-rss URL
|
||||
# TTRSS_SELF_URL_PATH=http://example.com/tt-rss
|
||||
|
||||
# You can customize other config.php defines by setting overrides here. See tt-rss/.docker/app/Dockerfile for complete list. Examples:
|
||||
|
||||
# TTRSS_PLUGINS=auth_remote
|
||||
# TTRSS_SINGLE_USER_MODE=true
|
||||
# TTRSS_SESSION_COOKIE_LIFETIME=2592000
|
||||
# TTRSS_FORCE_ARTICLE_PURGE=30
|
||||
# ...
|
||||
|
||||
# Bind exposed port to 127.0.0.1 to run behind reverse proxy on the same host. If you plan expose the container, remove "127.0.0.1:".
|
||||
HTTP_PORT=127.0.0.1:8280
|
||||
#HTTP_PORT=8280
|
||||
|
||||
300
.eslintrc.js
300
.eslintrc.js
@@ -1,300 +0,0 @@
|
||||
module.exports = {
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true,
|
||||
"jquery": false,
|
||||
"webextensions": false
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2020
|
||||
},
|
||||
"rules": {
|
||||
"accessor-pairs": "error",
|
||||
"array-bracket-newline": "off",
|
||||
"array-bracket-spacing": "off",
|
||||
"array-callback-return": "error",
|
||||
"array-element-newline": "off",
|
||||
"arrow-body-style": "error",
|
||||
"arrow-parens": "error",
|
||||
"arrow-spacing": "error",
|
||||
"block-scoped-var": "off",
|
||||
"block-spacing": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"brace-style": "off",
|
||||
"callback-return": "off",
|
||||
"camelcase": "off",
|
||||
"capitalized-comments": "off",
|
||||
"class-methods-use-this": "error",
|
||||
"comma-dangle": "off",
|
||||
"comma-spacing": "off",
|
||||
"comma-style": [
|
||||
"error",
|
||||
"last"
|
||||
],
|
||||
"complexity": "off",
|
||||
"computed-property-spacing": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"consistent-return": "off",
|
||||
"consistent-this": "off",
|
||||
"curly": "off",
|
||||
"default-case": "off",
|
||||
"dot-location": "off",
|
||||
"dot-notation": "off",
|
||||
"eol-last": "error",
|
||||
"eqeqeq": "off",
|
||||
"func-call-spacing": "error",
|
||||
"func-name-matching": "error",
|
||||
"func-names": "off",
|
||||
"func-style": "off",
|
||||
"function-paren-newline": "off",
|
||||
"generator-star-spacing": "error",
|
||||
"global-require": "error",
|
||||
"guard-for-in": "off",
|
||||
"handle-callback-err": "off",
|
||||
"id-blacklist": "error",
|
||||
"id-length": "off",
|
||||
"id-match": "error",
|
||||
"implicit-arrow-linebreak": "off",
|
||||
"indent": "off",
|
||||
"indent-legacy": "off",
|
||||
"init-declarations": "off",
|
||||
"jsx-quotes": "error",
|
||||
"key-spacing": "off",
|
||||
"keyword-spacing": [
|
||||
"error",
|
||||
{
|
||||
"after": true,
|
||||
"before": true
|
||||
}
|
||||
],
|
||||
"line-comment-position": "off",
|
||||
"linebreak-style": [
|
||||
"error",
|
||||
"unix"
|
||||
],
|
||||
"lines-around-comment": "off",
|
||||
"lines-around-directive": "error",
|
||||
"lines-between-class-members": "error",
|
||||
"max-classes-per-file": "off",
|
||||
"max-depth": "off",
|
||||
"max-len": "off",
|
||||
"max-lines": "off",
|
||||
"max-lines-per-function": "off",
|
||||
"max-nested-callbacks": "error",
|
||||
"max-params": "off",
|
||||
"max-statements": "off",
|
||||
"max-statements-per-line": [ "warn", { "max" : 2 } ],
|
||||
"multiline-comment-style": "off",
|
||||
"multiline-ternary": "off",
|
||||
"new-cap": "warn",
|
||||
"new-parens": "error",
|
||||
"newline-after-var": "off",
|
||||
"newline-before-return": "off",
|
||||
"newline-per-chained-call": "off",
|
||||
"no-alert": "off",
|
||||
"no-array-constructor": "error",
|
||||
"no-async-promise-executor": "off",
|
||||
"no-await-in-loop": "warn",
|
||||
"no-bitwise": "off",
|
||||
"no-buffer-constructor": "error",
|
||||
"no-caller": "error",
|
||||
"no-catch-shadow": "off",
|
||||
"no-confusing-arrow": "error",
|
||||
"no-continue": "off",
|
||||
"no-console": "off",
|
||||
"no-div-regex": "error",
|
||||
"no-duplicate-imports": "error",
|
||||
"no-else-return": "off",
|
||||
"no-empty": [
|
||||
"error",
|
||||
{
|
||||
"allowEmptyCatch": true
|
||||
}
|
||||
],
|
||||
"no-empty-function": "error",
|
||||
"no-eq-null": "off",
|
||||
"no-eval": "error",
|
||||
"no-extend-native": "off",
|
||||
"no-extra-bind": "error",
|
||||
"no-extra-label": "error",
|
||||
"no-extra-parens": "off",
|
||||
"no-floating-decimal": "error",
|
||||
"no-implicit-globals": "off",
|
||||
"no-implied-eval": "off",
|
||||
"no-inline-comments": "off",
|
||||
"no-inner-declarations": [
|
||||
"error",
|
||||
"functions"
|
||||
],
|
||||
"no-invalid-this": "error",
|
||||
"no-iterator": "error",
|
||||
"no-label-var": "error",
|
||||
"no-labels": "error",
|
||||
"no-lone-blocks": "error",
|
||||
"no-lonely-if": "error",
|
||||
"no-loop-func": "off",
|
||||
"no-magic-numbers": "off",
|
||||
"no-misleading-character-class": "off",
|
||||
"no-mixed-operators": "off",
|
||||
"no-mixed-requires": "error",
|
||||
"no-multi-assign": "error",
|
||||
"no-multi-spaces": "off",
|
||||
"no-multi-str": "error",
|
||||
"no-multiple-empty-lines": "error",
|
||||
"no-native-reassign": "error",
|
||||
"no-negated-condition": "off",
|
||||
"no-negated-in-lhs": "error",
|
||||
"no-nested-ternary": "error",
|
||||
"no-new": "warn",
|
||||
"no-new-func": "error",
|
||||
"no-new-object": "off",
|
||||
"no-new-require": "error",
|
||||
"no-new-wrappers": "error",
|
||||
"no-octal-escape": "error",
|
||||
"no-param-reassign": "off",
|
||||
"no-path-concat": "error",
|
||||
"no-plusplus": "off",
|
||||
"no-process-env": "error",
|
||||
"no-process-exit": "error",
|
||||
"no-proto": "error",
|
||||
"no-prototype-builtins": "warn",
|
||||
"no-restricted-globals": "error",
|
||||
"no-restricted-imports": "error",
|
||||
"no-restricted-modules": "error",
|
||||
"no-restricted-properties": "error",
|
||||
"no-restricted-syntax": "error",
|
||||
"no-return-assign": [
|
||||
"error",
|
||||
"except-parens"
|
||||
],
|
||||
"no-return-await": "error",
|
||||
"no-script-url": "error",
|
||||
"no-self-compare": "error",
|
||||
"no-sequences": "error",
|
||||
"no-shadow": "off",
|
||||
"no-shadow-restricted-names": "error",
|
||||
"no-spaced-func": "error",
|
||||
"no-sync": "error",
|
||||
"no-tabs": "off",
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-ternary": "off",
|
||||
"no-throw-literal": "error",
|
||||
"no-trailing-spaces": "error",
|
||||
"no-undef-init": "error",
|
||||
"no-undefined": "off",
|
||||
"no-undef": "warn",
|
||||
"no-underscore-dangle": "off",
|
||||
"no-unmodified-loop-condition": "error",
|
||||
"no-unneeded-ternary": [
|
||||
"error",
|
||||
{
|
||||
"defaultAssignment": true
|
||||
}
|
||||
],
|
||||
"no-unused-expressions": "off",
|
||||
"no-unused-vars": "warn",
|
||||
"no-use-before-define": "off",
|
||||
"no-useless-call": "error",
|
||||
"no-useless-computed-key": "error",
|
||||
"no-useless-concat": "error",
|
||||
"no-useless-constructor": "error",
|
||||
"no-useless-rename": "error",
|
||||
"no-useless-return": "off",
|
||||
"no-var": "warn",
|
||||
"no-void": "error",
|
||||
"no-warning-comments": "off",
|
||||
"no-whitespace-before-property": "error",
|
||||
"no-with": "error",
|
||||
"nonblock-statement-body-position": [
|
||||
"error",
|
||||
"any"
|
||||
],
|
||||
"object-curly-newline": "off",
|
||||
"object-curly-spacing": "off",
|
||||
"object-property-newline": "off",
|
||||
"object-shorthand": "off",
|
||||
"one-var": "off",
|
||||
"one-var-declaration-per-line": "error",
|
||||
"operator-assignment": "off",
|
||||
"operator-linebreak": [
|
||||
"error",
|
||||
"after"
|
||||
],
|
||||
"padded-blocks": "off",
|
||||
"padding-line-between-statements": "error",
|
||||
"prefer-arrow-callback": "off",
|
||||
"prefer-const": "error",
|
||||
"prefer-destructuring": "off",
|
||||
"prefer-numeric-literals": "error",
|
||||
"prefer-object-spread": "off",
|
||||
"prefer-promise-reject-errors": "error",
|
||||
"prefer-reflect": "off",
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error",
|
||||
"prefer-template": "off",
|
||||
"quote-props": "off",
|
||||
"quotes": "off",
|
||||
"radix": [
|
||||
"error",
|
||||
"as-needed"
|
||||
],
|
||||
"require-atomic-updates": "off",
|
||||
"require-await": "warn",
|
||||
"require-jsdoc": "off",
|
||||
"require-unicode-regexp": "off",
|
||||
"rest-spread-spacing": "error",
|
||||
"semi": "off",
|
||||
"semi-spacing": [
|
||||
"error",
|
||||
{
|
||||
"after": true,
|
||||
"before": false
|
||||
}
|
||||
],
|
||||
"semi-style": [
|
||||
"error",
|
||||
"last"
|
||||
],
|
||||
"sort-imports": "error",
|
||||
"sort-keys": "off",
|
||||
"sort-vars": "off",
|
||||
"space-before-blocks": "off",
|
||||
"space-before-function-paren": "off",
|
||||
"space-in-parens": "off",
|
||||
"space-infix-ops": "off",
|
||||
"space-unary-ops": [
|
||||
"error",
|
||||
{
|
||||
"nonwords": false,
|
||||
"words": false
|
||||
}
|
||||
],
|
||||
"spaced-comment": "off",
|
||||
"strict": [
|
||||
"off",
|
||||
"never"
|
||||
],
|
||||
"switch-colon-spacing": "error",
|
||||
"symbol-description": "error",
|
||||
"template-curly-spacing": "error",
|
||||
"template-tag-spacing": "error",
|
||||
"unicode-bom": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"valid-jsdoc": "error",
|
||||
"vars-on-top": "off",
|
||||
"wrap-iife": "error",
|
||||
"wrap-regex": "error",
|
||||
"yield-star-spacing": "error",
|
||||
"yoda": [
|
||||
"error",
|
||||
"never"
|
||||
]
|
||||
}
|
||||
};
|
||||
54
.github/ISSUE_TEMPLATE/bug.yml
vendored
54
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -1,54 +0,0 @@
|
||||
name: Bug Report
|
||||
description: Report a bug
|
||||
labels: [bug]
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What is the problem?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Reproduction Steps
|
||||
description: (If known) Minimal steps that can reproduce the issue.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: tt-rss Version
|
||||
description: Which version (commit) of tt-rss are you using?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: |
|
||||
How are you running tt-rss? Include all relevant details (e.g. Docker image, OS, PHP version, etc.).
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: other
|
||||
attributes:
|
||||
label: Other Information
|
||||
description: |
|
||||
Anything else you want to provide (e.g. related issues, suggestions on how to fix, links for context, etc.).
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: acknowledgments
|
||||
attributes:
|
||||
label: Acknowledge
|
||||
description: Please mark any checkbox that applies (otherwise leave unchecked).
|
||||
options:
|
||||
- label: I'm interested in implementing the fix
|
||||
required: false
|
||||
20
.github/ISSUE_TEMPLATE/documentation.yml
vendored
20
.github/ISSUE_TEMPLATE/documentation.yml
vendored
@@ -1,20 +0,0 @@
|
||||
name: Documentation Issue
|
||||
description: Report an issue with documentation
|
||||
labels: [documentation]
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
id: issue
|
||||
attributes:
|
||||
label: What is the issue?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: acknowledgments
|
||||
attributes:
|
||||
label: Acknowledge
|
||||
description: Please mark any checkbox that apply (otherwise leave unchecked).
|
||||
options:
|
||||
- label: I'm interested in implementing the fix
|
||||
required: false
|
||||
57
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
57
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
@@ -1,57 +0,0 @@
|
||||
name: Enhancement Request
|
||||
description: Request a new enhancement/feature
|
||||
labels: [enhancement]
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Short description of the enhancement/feature you are proposing.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: Use Case
|
||||
description: Why do you need this enhancement/feature?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: |
|
||||
(If known) How would you propose the enhancement/feature be implemented?
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: tt-rss Version
|
||||
description: Which version (commit) of tt-rss are you currently using?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: other
|
||||
attributes:
|
||||
label: Other Information
|
||||
description: |
|
||||
Anything else you want to provide (e.g. related issues, suggestions on how to implement, links for context, etc.).
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: acknowledgments
|
||||
attributes:
|
||||
label: Acknowledge
|
||||
description: Please mark any checkbox that apply (otherwise leave unchecked).
|
||||
options:
|
||||
- label: I'm interested in implementing the enhancement/feature
|
||||
required: false
|
||||
- label: This enhancement/feature might result in a breaking change
|
||||
required: false
|
||||
7
.github/dependabot.yml
vendored
7
.github/dependabot.yml
vendored
@@ -1,7 +0,0 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: composer
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
25
.github/pull_request_template.md
vendored
25
.github/pull_request_template.md
vendored
@@ -1,25 +0,0 @@
|
||||
## Description
|
||||
<!-- Describe your changes in detail -->
|
||||
|
||||
## Motivation and Context
|
||||
<!-- Why is this change required? What problem does it solve? -->
|
||||
<!-- If it fixes an open issue, please link to the issue here. -->
|
||||
|
||||
## How Has This Been Tested?
|
||||
<!-- Please describe in detail how you tested your changes. -->
|
||||
|
||||
## Screenshots (if appropriate)
|
||||
<!-- If screenshots will help in understanding the change, include them here. -->
|
||||
|
||||
## Types of Changes
|
||||
<!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply. -->
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Refactoring (non-breaking change)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
|
||||
|
||||
## Checklist
|
||||
<!-- Go over all the following points, and put an `x` in all the boxes that apply. -->
|
||||
<!-- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
|
||||
- [ ] My code follows the code style of this project.
|
||||
- [ ] My change requires a change to the documentation.
|
||||
67
.github/workflows/php-code-quality.yml
vendored
67
.github/workflows/php-code-quality.yml
vendored
@@ -1,67 +0,0 @@
|
||||
name: PHP Code Quality
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.php'
|
||||
- 'phpstan.neon'
|
||||
- 'phpunit.xml'
|
||||
# Allow manual triggering
|
||||
workflow_dispatch:
|
||||
# Allow other workflows (e.g. Publish) to invoke this one.
|
||||
workflow_call:
|
||||
|
||||
|
||||
env:
|
||||
fail-fast: true
|
||||
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
jobs:
|
||||
phpstan:
|
||||
name: PHPStan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.4'
|
||||
coverage: none
|
||||
tools: none
|
||||
|
||||
- name: Run PHPStan
|
||||
run: vendor/bin/phpstan analyze --no-progress
|
||||
|
||||
phpunit:
|
||||
name: PHPUnit
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: ${{ matrix.experimental }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php: ['8.2', '8.3', '8.4']
|
||||
experimental: [false]
|
||||
include:
|
||||
- php: '8.5'
|
||||
experimental: true
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up PHP ${{ matrix.php }}
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: none
|
||||
tools: none
|
||||
|
||||
- name: Run PHPUnit
|
||||
run: vendor/bin/phpunit --exclude integration --coverage-filter classes --coverage-filter include
|
||||
93
.github/workflows/publish.yml
vendored
93
.github/workflows/publish.yml
vendored
@@ -1,93 +0,0 @@
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '.**'
|
||||
- 'tests/**'
|
||||
- '*.*-dist'
|
||||
- '*.js'
|
||||
- '*.json'
|
||||
- '*.lock'
|
||||
- '*.md'
|
||||
- '*.neon'
|
||||
- '*.xml'
|
||||
# Allow manual triggering
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
jobs:
|
||||
test-php:
|
||||
uses: ./.github/workflows/php-code-quality.yml
|
||||
|
||||
publish-dockerhub:
|
||||
name: Publish ${{ matrix.image.name }} to Docker Hub
|
||||
needs:
|
||||
- test-php
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
image:
|
||||
- name: app
|
||||
dockerfile: ./.docker/app/Dockerfile
|
||||
repository: supahgreg/tt-rss
|
||||
- name: web-nginx
|
||||
dockerfile: ./.docker/web-nginx/Dockerfile
|
||||
repository: supahgreg/tt-rss-web-nginx
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Get commit timestamp
|
||||
run: echo "COMMIT_TIMESTAMP=$(git show -s --format=%ci HEAD)" >> $GITHUB_ENV
|
||||
|
||||
- name: Get commit short SHA
|
||||
run: echo "COMMIT_SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ matrix.image.repository }}
|
||||
tags: |
|
||||
# update 'latest'
|
||||
type=raw,value=latest
|
||||
# short SHA with a 'sha-' prefix (e.g. sha-abc123)
|
||||
type=sha
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push to Docker Hub
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.image.dockerfile }}
|
||||
platforms: linux/arm64,linux/amd64
|
||||
# TODO: clean up build arg and environment variable naming.
|
||||
build-args: |
|
||||
CI_COMMIT_BRANCH=${{ github.ref_name }}
|
||||
CI_COMMIT_SHA=${{ github.sha }}
|
||||
CI_COMMIT_SHORT_SHA=${{ env.COMMIT_SHORT_SHA }}
|
||||
CI_COMMIT_TIMESTAMP=${{ env.COMMIT_TIMESTAMP }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: true
|
||||
sbom: true
|
||||
push: true
|
||||
35
.gitignore
vendored
35
.gitignore
vendored
@@ -1,18 +1,21 @@
|
||||
Thumbs.db
|
||||
/.env
|
||||
/docker-compose.override.yml
|
||||
/.app_is_ready
|
||||
/deploy.exclude
|
||||
/deploy.sh
|
||||
/messages.mo
|
||||
/node_modules
|
||||
/locale/**/*.po~
|
||||
/plugins.local/*
|
||||
/themes.local/*
|
||||
/config.php
|
||||
/feed-icons/*
|
||||
/cache/*/*
|
||||
/lock/*
|
||||
/.vscode/settings.json
|
||||
/vendor/**/.git
|
||||
/.phpunit.result.cache
|
||||
/.phpstan-tmp
|
||||
/.tools/
|
||||
*~
|
||||
*.DS_Store
|
||||
#*
|
||||
.idea/*
|
||||
plugins.local/*
|
||||
themes.local/*
|
||||
config.php
|
||||
feed-icons/*
|
||||
cache/*/*
|
||||
lock/*
|
||||
tags
|
||||
cache/htmlpurifier/*/*ser
|
||||
lib/htmlpurifier/library/HTMLPurifier/DefinitionCache/Serializer/*/*ser
|
||||
web.config
|
||||
/.save.cson
|
||||
/.tags*
|
||||
/.gutentags
|
||||
|
||||
310
.gitlab-ci.yml
310
.gitlab-ci.yml
@@ -1,266 +1,44 @@
|
||||
stages:
|
||||
- lint
|
||||
- build
|
||||
- push
|
||||
- test
|
||||
- publish
|
||||
|
||||
variables:
|
||||
ESLINT_PATHS: js plugins
|
||||
REGISTRY_PROJECT: cthulhoo
|
||||
IMAGE_TAR_FPM: image-fpm.tar
|
||||
IMAGE_TAR_WEB: image-web.tar
|
||||
|
||||
include:
|
||||
- project: 'ci/ci-templates'
|
||||
ref: master
|
||||
file: .ci-build-docker-kaniko.yml
|
||||
- project: 'ci/ci-templates'
|
||||
ref: master
|
||||
file: .ci-registry-push.yml
|
||||
- project: 'ci/ci-templates'
|
||||
ref: master
|
||||
file: .ci-lint-common.yml
|
||||
- project: 'ci/ci-templates'
|
||||
ref: master
|
||||
file: .ci-update-helm-imagetag.yml
|
||||
|
||||
phpunit:
|
||||
extends: .phpunit
|
||||
variables:
|
||||
PHPUNIT_ARGS: --exclude integration --coverage-filter classes --coverage-filter include
|
||||
|
||||
eslint:
|
||||
extends: .eslint
|
||||
|
||||
phpstan:
|
||||
extends: .phpstan
|
||||
|
||||
ttrss-fpm-pgsql-static:build:
|
||||
extends: .build-docker-kaniko-no-push
|
||||
variables:
|
||||
DOCKERFILE: ${CI_PROJECT_DIR}/.docker/app/Dockerfile
|
||||
IMAGE_TAR: ${IMAGE_TAR_FPM}
|
||||
|
||||
ttrss-fpm-pgsql-static:push-commit-only-gitlab:
|
||||
extends: .crane-image-registry-push-commit-only-gitlab
|
||||
variables:
|
||||
IMAGE_TAR: ${IMAGE_TAR_FPM}
|
||||
needs:
|
||||
- job: ttrss-fpm-pgsql-static:build
|
||||
|
||||
ttrss-web-nginx:build:
|
||||
extends: .build-docker-kaniko-no-push
|
||||
variables:
|
||||
DOCKERFILE: ${CI_PROJECT_DIR}/.docker/web-nginx/Dockerfile
|
||||
IMAGE_TAR: ${IMAGE_TAR_WEB}
|
||||
|
||||
ttrss-web-nginx:push-commit-only-gitlab:
|
||||
extends: .crane-image-registry-push-commit-only-gitlab
|
||||
variables:
|
||||
IMAGE_TAR: ${IMAGE_TAR_WEB}
|
||||
needs:
|
||||
- job: ttrss-web-nginx:build
|
||||
|
||||
phpdoc:build:
|
||||
image: ${PHP_IMAGE}
|
||||
stage: publish
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
script:
|
||||
- php84 /phpDocumentor.phar -d classes -d include -t phpdoc --visibility=public
|
||||
artifacts:
|
||||
paths:
|
||||
- phpdoc
|
||||
|
||||
phpdoc:publish:
|
||||
extends: .build-docker-kaniko
|
||||
stage: publish
|
||||
needs:
|
||||
- job: phpdoc:build
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $REGISTRY_USER != null && $REGISTRY_PASSWORD != null
|
||||
variables:
|
||||
DOCKERFILE: ${CI_PROJECT_DIR}/.docker/phpdoc/Dockerfile
|
||||
NAME: ttrss-phpdoc
|
||||
VERSION: latest
|
||||
|
||||
phpunit-integration:
|
||||
image: ${PHP_IMAGE}
|
||||
variables:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
TTRSS_DB_HOST: db
|
||||
TTRSS_DB_USER: ${POSTGRES_USER}
|
||||
TTRSS_DB_NAME: ${POSTGRES_DB}
|
||||
TTRSS_DB_PASS: ${POSTGRES_PASSWORD}
|
||||
FF_NETWORK_PER_BUILD: "true"
|
||||
APP_WEB_ROOT: /builds/shared-root
|
||||
APP_INSTALL_BASE_DIR: ${APP_WEB_ROOT}
|
||||
APP_BASE: "/tt-rss"
|
||||
APP_FASTCGI_PASS: app:9000 # skip resolver
|
||||
AUTO_CREATE_USER: test
|
||||
AUTO_CREATE_USER_PASS: 'test'
|
||||
AUTO_CREATE_USER_ACCESS_LEVEL: '10'
|
||||
AUTO_CREATE_USER_ENABLE_API: 'true'
|
||||
APP_URL: http://web-nginx/tt-rss
|
||||
API_URL: ${APP_URL}/api/
|
||||
HEALTHCHECK_URL: ${APP_URL}/public.php?op=healthcheck
|
||||
__URLHELPER_ALLOW_LOOPBACK: 'true'
|
||||
services:
|
||||
- &svc_db
|
||||
name: registry.fakecake.org/docker.io/postgres:15-alpine
|
||||
alias: db
|
||||
- &svc_app
|
||||
name: ${CI_REGISTRY}/${CI_PROJECT_PATH}/ttrss-fpm-pgsql-static:${CI_COMMIT_SHORT_SHA}
|
||||
alias: app
|
||||
- &svc_web
|
||||
name: ${CI_REGISTRY}/${CI_PROJECT_PATH}/ttrss-web-nginx:${CI_COMMIT_SHORT_SHA}
|
||||
alias: web-nginx
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
needs:
|
||||
- job: ttrss-fpm-pgsql-static:push-commit-only-gitlab
|
||||
- job: ttrss-web-nginx:push-commit-only-gitlab
|
||||
before_script:
|
||||
# wait for everything to start
|
||||
- |
|
||||
for a in `seq 1 15`; do
|
||||
curl -fs ${HEALTHCHECK_URL} && break
|
||||
sleep 5
|
||||
done
|
||||
script:
|
||||
- cp tests/integration/feed.xml ${APP_WEB_ROOT}/${APP_BASE}/
|
||||
- php84 vendor/bin/phpunit --group integration --do-not-cache-result --log-junit phpunit-report.xml --coverage-cobertura phpunit-coverage.xml --coverage-text --colors=never
|
||||
artifacts:
|
||||
when: always
|
||||
reports:
|
||||
junit: phpunit-report.xml
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: phpunit-coverage.xml
|
||||
coverage: '/^\s*Lines:\s*\d+.\d+\%/'
|
||||
|
||||
phpunit-integration:root-location:
|
||||
variables:
|
||||
APP_WEB_ROOT: /builds/shared-root/tt-rss
|
||||
APP_INSTALL_BASE_DIR: /builds/shared-root
|
||||
APP_BASE: ""
|
||||
APP_URL: http://web-nginx
|
||||
extends: phpunit-integration
|
||||
|
||||
selenium:
|
||||
extends: phpunit-integration
|
||||
image: ${SELENIUM_IMAGE}
|
||||
variables:
|
||||
SELENIUM_GRID_ENDPOINT: http://selenium:4444/wd/hub
|
||||
services:
|
||||
- *svc_db
|
||||
- *svc_app
|
||||
- *svc_web
|
||||
- name: registry.fakecake.org/docker.io/selenium/standalone-chrome:4.32.0-20250515
|
||||
alias: selenium
|
||||
script:
|
||||
- |
|
||||
for i in `seq 1 10`; do
|
||||
echo attempt $i...
|
||||
python3 tests/integration/selenium_test.py && break
|
||||
sleep 10
|
||||
done
|
||||
artifacts:
|
||||
when: always
|
||||
reports:
|
||||
junit: selenium-report.xml
|
||||
|
||||
ttrss-fpm-pgsql-static:publish:
|
||||
stage: publish
|
||||
extends: .crane-image-registry-push-master
|
||||
variables:
|
||||
IMAGE_TAR: ${IMAGE_TAR_FPM}
|
||||
needs:
|
||||
- job: ttrss-fpm-pgsql-static:build
|
||||
- job: phpunit-integration
|
||||
- job: selenium
|
||||
|
||||
ttrss-fpm-pgsql-static:publish-docker-hub:
|
||||
stage: publish
|
||||
extends: .crane-image-registry-push-master-docker-hub
|
||||
variables:
|
||||
IMAGE_TAR: ${IMAGE_TAR_FPM}
|
||||
needs:
|
||||
- job: ttrss-fpm-pgsql-static:build
|
||||
- job: phpunit-integration
|
||||
- job: selenium
|
||||
|
||||
ttrss-fpm-pgsql-static:publish-gitlab:
|
||||
stage: publish
|
||||
extends: .crane-image-registry-push-master-gitlab
|
||||
variables:
|
||||
IMAGE_TAR: ${IMAGE_TAR_FPM}
|
||||
needs:
|
||||
- job: ttrss-fpm-pgsql-static:build
|
||||
- job: phpunit-integration
|
||||
- job: selenium
|
||||
|
||||
ttrss-web-nginx:publish:
|
||||
stage: publish
|
||||
extends: .crane-image-registry-push-master
|
||||
variables:
|
||||
IMAGE_TAR: ${IMAGE_TAR_WEB}
|
||||
needs:
|
||||
- job: ttrss-web-nginx:build
|
||||
- job: phpunit-integration
|
||||
- job: selenium
|
||||
|
||||
ttrss-web-nginx:publish-docker-hub:
|
||||
stage: publish
|
||||
extends: .crane-image-registry-push-master-docker-hub
|
||||
variables:
|
||||
IMAGE_TAR: ${IMAGE_TAR_WEB}
|
||||
needs:
|
||||
- job: ttrss-web-nginx:build
|
||||
- job: phpunit-integration
|
||||
- job: selenium
|
||||
|
||||
ttrss-web-nginx:publish-gitlab:
|
||||
stage: publish
|
||||
extends: .crane-image-registry-push-master-gitlab
|
||||
variables:
|
||||
IMAGE_TAR: ${IMAGE_TAR_WEB}
|
||||
needs:
|
||||
- job: ttrss-web-nginx:build
|
||||
- job: phpunit-integration
|
||||
- job: selenium
|
||||
|
||||
update-demo:
|
||||
stage: publish
|
||||
extends: .update-helm-imagetag
|
||||
variables:
|
||||
CHART_REPO: gitlab.fakecake.org/git/helm-charts/tt-rss.git
|
||||
CHART_VALUES: values-demo.yaml
|
||||
ACCESS_TOKEN: ${DEMO_HELM_TOKEN}
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $DEMO_HELM_TOKEN != null
|
||||
|
||||
update-prod:
|
||||
stage: publish
|
||||
extends: .update-helm-imagetag
|
||||
variables:
|
||||
CHART_REPO: gitlab.fakecake.org/git/helm-charts/tt-rss-prod.git
|
||||
CHART_VALUES: values-prod.yaml
|
||||
ACCESS_TOKEN: ${PROD_HELM_TOKEN}
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $PROD_HELM_TOKEN != null
|
||||
|
||||
# https://about.gitlab.com/blog/how-to-automatically-create-a-new-mr-on-gitlab-with-gitlab-ci/
|
||||
weblate-integration-auto-mr:
|
||||
image: ${INFRA_IMAGE}
|
||||
stage: publish
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == "weblate-integration" && $AUTO_MR_TOKEN != null
|
||||
script:
|
||||
- HOST=${CI_PROJECT_URL} CI_PROJECT_ID=${CI_PROJECT_ID}
|
||||
CI_COMMIT_REF_NAME=${CI_COMMIT_REF_NAME}
|
||||
GITLAB_USER_ID=${GITLAB_USER_ID}
|
||||
PRIVATE_TOKEN=${AUTO_MR_TOKEN} ./utils/autoMergeRequest.sh
|
||||
phpmd:
|
||||
image: php:5.6
|
||||
when: manual
|
||||
script:
|
||||
- sh utils/gitlab-ci/php-lint.sh
|
||||
- curl -o /usr/bin/phpmd -L http://static.phpmd.org/php/2.6.0/phpmd.phar
|
||||
- chmod +x /usr/bin/phpmd
|
||||
- sh utils/gitlab-ci/phpmd.sh
|
||||
|
||||
schema:
|
||||
image: fox/selenium-ci
|
||||
when: manual
|
||||
script:
|
||||
- /etc/init.d/postgresql start
|
||||
- /usr/local/sbin/init-database.sh
|
||||
- sh ./utils/gitlab-ci/check-schema.sh
|
||||
|
||||
phpunit_basic:
|
||||
image: fox/selenium-ci
|
||||
when: manual
|
||||
script:
|
||||
- /etc/init.d/postgresql start
|
||||
- /usr/local/sbin/init-database.sh
|
||||
- sh ./utils/gitlab-ci/check-schema.sh
|
||||
- cp utils/gitlab-ci/config-template.php config.php
|
||||
- su -s /bin/bash www-data -c "php ./update.php --debug-feed 1"
|
||||
- wget -O /usr/bin/phpunit https://phar.phpunit.de/phpunit-5.7.phar
|
||||
- chmod +x /usr/bin/phpunit
|
||||
- phpunit tests/*.php
|
||||
|
||||
phpunit_functional:
|
||||
image: fox/selenium-ci
|
||||
when: manual
|
||||
script:
|
||||
- /etc/init.d/postgresql start
|
||||
- /etc/init.d/nginx start
|
||||
- /etc/init.d/php5-fpm start
|
||||
- /usr/local/sbin/init-database.sh
|
||||
- sh ./utils/gitlab-ci/check-schema.sh
|
||||
- ln -s `pwd` ../../tt-rss
|
||||
- cp utils/gitlab-ci/config-template.php config.php
|
||||
- chmod -R 777 cache lock feed-icons
|
||||
- /usr/local/sbin/init-selenium.sh
|
||||
- phpunit tests/functional/*.php
|
||||
|
||||
24
.vscode/launch.json
vendored
24
.vscode/launch.json
vendored
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Listen for XDebug",
|
||||
"type": "php",
|
||||
"request": "launch",
|
||||
"pathMappings": {
|
||||
"/var/www/html/tt-rss": "${workspaceRoot}",
|
||||
},
|
||||
"port": 9000
|
||||
},
|
||||
{
|
||||
"name": "Launch Chrome",
|
||||
"request": "launch",
|
||||
"type": "chrome",
|
||||
"pathMapping": {
|
||||
"/tt-rss/": "${workspaceFolder}"
|
||||
},
|
||||
"urlFilter": "*/tt-rss/*",
|
||||
"runtimeExecutable": "chrome.exe",
|
||||
}
|
||||
]
|
||||
}
|
||||
46
.vscode/tasks.json
vendored
46
.vscode/tasks.json
vendored
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "shell",
|
||||
"label": "phpstan (watcher)",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"fileLocation": [
|
||||
"relative",
|
||||
"${workspaceRoot}"
|
||||
],
|
||||
"owner": "phpstan-watcher",
|
||||
"pattern": {
|
||||
"regexp": "^/app/(.*?):([0-9\\?]*):(.*)$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"message": 3
|
||||
},
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": "Using configuration file",
|
||||
"endsPattern": "All done"
|
||||
}
|
||||
},
|
||||
"command": "chmod +x ${workspaceRoot}/utils/phpstan-watcher.sh && ${workspaceRoot}/utils/phpstan-watcher.sh"
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"label": "phpunit",
|
||||
"command": "chmod +x ${workspaceRoot}/utils/phpunit.sh && ${workspaceRoot}/utils/phpunit.sh",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"type": "gulp",
|
||||
"task": "default",
|
||||
"problemMatcher": [],
|
||||
"label": "gulp: default",
|
||||
"options": {
|
||||
"env": {
|
||||
"PATH": "${workspaceRoot}/node_modules/.bin:$PATH"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,20 @@
|
||||
Contributions (code, translations, reporting issues, etc.) are welcome.
|
||||
## Contributing code the right way
|
||||
|
||||
> [!NOTE]
|
||||
> The original tt-rss project handled translations via Weblate.
|
||||
> It's yet to be determined how this project will handle things.
|
||||
New user accounts on Gogs are not allowed to fork repositories because of spam. To get
|
||||
initial fork access, do the following:
|
||||
|
||||
1. Register on the forums and on Gogs
|
||||
2. Create a thread describing your proposed changes in Development subforum while
|
||||
including your Gogs username
|
||||
3. If your changes make sense to me, I'll update your repo limit and you'll be able to
|
||||
fork things and file pull requests
|
||||
|
||||
If you already have a fully functional Gogs account it works pretty much like Github:
|
||||
|
||||
1. Fork the repository you're interested in
|
||||
2. Do the needful
|
||||
3. File a pull request with your changes against master branch
|
||||
|
||||
That's it. If you have any other questions, see this forum thread:
|
||||
|
||||
https://discourse.tt-rss.org/t/how-to-contribute-code-via-pull-requests-on-git-tt-rss-org/1850
|
||||
|
||||
39
README.md
39
README.md
@@ -1,36 +1,10 @@
|
||||
Tiny Tiny RSS (tt-rss)
|
||||
======================
|
||||
Tiny Tiny RSS
|
||||
=============
|
||||
|
||||
Tiny Tiny RSS (tt-rss) is a free, flexible, open-source, web-based news feed (RSS/Atom/other) reader and aggregator.
|
||||
Web-based news feed aggregator, designed to allow you to read news from
|
||||
any location, while feeling as close to a real desktop application as possible.
|
||||
|
||||
## Getting started
|
||||
|
||||
Please refer to [the wiki](https://github.com/tt-rss/tt-rss/wiki).
|
||||
|
||||
## Some notes about this project
|
||||
|
||||
* The original tt-rss project, hosted at https://tt-rss.org/ and its various subdomains, [will be gone after 2025-11-01](https://community.tt-rss.org/t/the-end-of-tt-rss-org/7164).
|
||||
* Massive thanks to fox for creating tt-rss, and maintaining it (and absolutely everything else that went along with it) for so many years.
|
||||
* This project (https://github.com/tt-rss/tt-rss) is a fork of tt-rss as of 2025-10-03, created by one of its long-time contributors (`wn_`/`wn_name` on `tt-rss.org`, `supahgreg` on `github.com`).
|
||||
* The goal is to continue tt-rss development, with an initial focus on replacing `tt-rss.org` references and integrations + getting things working.
|
||||
* Developer note: Due to use of `invalid@email.com` on `supahgreg`'s pre-2025-10-03 commits (which were done on `tt-rss.org`) GitHub incorrectly shows `ivanivanov884`
|
||||
(the GitHub user associated with that e-mail address) as the author instead of `wn_`/`supahgreg`. Apologies for any confusion. `¯\_(ツ)_/¯`
|
||||
* Plugins that were under https://gitlab.tt-rss.org/tt-rss/plugins have been mirrored to `https://github.com/tt-rss/tt-rss-plugin-*`.
|
||||
* Plugin repository names have changed to get a consistent `tt-rss-plugin-*` prefix.
|
||||
* Documentation from https://tt-rss.org has been recreated in https://github.com/tt-rss/tt-rss/wiki .
|
||||
* The repository that held the content for https://tt-rss.org was mirrored to https://github.com/tt-rss/tt-rss-web-static .
|
||||
Some content tweaks were made after mirroring (prior to the wiki being set up), and the repository is now archived.
|
||||
* Docker images (for `linux/amd64` and `linux/arm64`) are being built and published to Docker Hub [via GitHub Actions](https://github.com/tt-rss/tt-rss/actions/workflows/publish.yml).
|
||||
* See https://hub.docker.com/r/supahgreg/tt-rss/ and https://hub.docker.com/r/supahgreg/tt-rss-web-nginx/ , and
|
||||
[the installation guide](https://github.com/tt-rss/tt-rss/wiki/Installation-Guide) for how they can be used.
|
||||
|
||||
## Development and contributing
|
||||
|
||||
* Contributions (code, translations, reporting issues, etc.) are welcome.
|
||||
* Development and issue tracking primarily happens in https://github.com/tt-rss/tt-rss .
|
||||
* Help translate tt-rss into your own language using [Weblate](https://hosted.weblate.org/engage/tt-rss/).
|
||||
|
||||
## License
|
||||
http://tt-rss.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
@@ -46,3 +20,6 @@ You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Copyright (c) 2005 Andrew Dolgov (unless explicitly stated otherwise).
|
||||
|
||||
Uses Silk icons by Mark James: http://www.famfamfam.com/lab/icons/silk/
|
||||
|
||||
|
||||
@@ -1,68 +1,91 @@
|
||||
<?php
|
||||
error_reporting(E_ERROR | E_PARSE);
|
||||
|
||||
set_include_path(__DIR__ . PATH_SEPARATOR .
|
||||
dirname(__DIR__) . PATH_SEPARATOR .
|
||||
dirname(__DIR__) . "/include" . PATH_SEPARATOR .
|
||||
require_once "../config.php";
|
||||
|
||||
set_include_path(dirname(__FILE__) . PATH_SEPARATOR .
|
||||
dirname(dirname(__FILE__)) . PATH_SEPARATOR .
|
||||
dirname(dirname(__FILE__)) . "/include" . PATH_SEPARATOR .
|
||||
get_include_path());
|
||||
|
||||
chdir("..");
|
||||
|
||||
define('TTRSS_SESSION_NAME', 'ttrss_api_sid');
|
||||
define('NO_SESSION_AUTOSTART', true);
|
||||
|
||||
require_once "autoload.php";
|
||||
require_once "db.php";
|
||||
require_once "db-prefs.php";
|
||||
require_once "functions.php";
|
||||
require_once "sessions.php";
|
||||
|
||||
ini_set('session.use_cookies', "0");
|
||||
ini_set("session.gc_maxlifetime", "86400");
|
||||
ini_set('session.use_cookies', 0);
|
||||
ini_set("session.gc_maxlifetime", 86400);
|
||||
|
||||
ob_start();
|
||||
define('AUTH_DISABLE_OTP', true);
|
||||
|
||||
$_REQUEST = json_decode((string)file_get_contents("php://input"), true);
|
||||
if (defined('ENABLE_GZIP_OUTPUT') && ENABLE_GZIP_OUTPUT &&
|
||||
function_exists("ob_gzhandler")) {
|
||||
|
||||
if (!empty($_REQUEST["sid"])) {
|
||||
ob_start("ob_gzhandler");
|
||||
} else {
|
||||
ob_start();
|
||||
}
|
||||
|
||||
$input = file_get_contents("php://input");
|
||||
|
||||
if (defined('_API_DEBUG_HTTP_ENABLED') && _API_DEBUG_HTTP_ENABLED) {
|
||||
// Override $_REQUEST with JSON-encoded data if available
|
||||
// fallback on HTTP parameters
|
||||
if ($input) {
|
||||
$input = json_decode($input, true);
|
||||
if ($input) $_REQUEST = $input;
|
||||
}
|
||||
} else {
|
||||
// Accept JSON only
|
||||
$input = json_decode($input, true);
|
||||
$_REQUEST = $input;
|
||||
}
|
||||
|
||||
if ($_REQUEST["sid"]) {
|
||||
session_id($_REQUEST["sid"]);
|
||||
session_start();
|
||||
@session_start();
|
||||
} else if (defined('_API_DEBUG_HTTP_ENABLED')) {
|
||||
@session_start();
|
||||
}
|
||||
|
||||
startup_gettext();
|
||||
|
||||
if (!init_plugins()) return;
|
||||
|
||||
if (!empty($_SESSION["uid"])) {
|
||||
if (!Sessions::validate_session()) {
|
||||
header("Content-Type: application/json");
|
||||
if ($_SESSION["uid"]) {
|
||||
if (!validate_session()) {
|
||||
header("Content-Type: text/json");
|
||||
|
||||
print json_encode([
|
||||
"seq" => -1,
|
||||
"status" => API::STATUS_ERR,
|
||||
"content" => [ "error" => API::E_NOT_LOGGED_IN ]
|
||||
]);
|
||||
print json_encode(array("seq" => -1,
|
||||
"status" => 1,
|
||||
"content" => array("error" => "NOT_LOGGED_IN")));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
UserHelper::load_user_plugins($_SESSION["uid"]);
|
||||
load_user_plugins( $_SESSION["uid"]);
|
||||
}
|
||||
|
||||
$method = strtolower($_REQUEST["op"] ?? "");
|
||||
$method = strtolower($_REQUEST["op"]);
|
||||
|
||||
$handler = new API($_REQUEST);
|
||||
|
||||
if ($handler->before($method)) {
|
||||
if ($method && method_exists($handler, $method)) {
|
||||
$handler->$method();
|
||||
} else /* if (method_exists($handler, 'index')) */ {
|
||||
} else if (method_exists($handler, 'index')) {
|
||||
$handler->index($method);
|
||||
}
|
||||
// API isn't currently overriding Handler#after()
|
||||
// $handler->after();
|
||||
$handler->after();
|
||||
}
|
||||
|
||||
$content_length = ob_get_length();
|
||||
|
||||
header("Api-Content-Length: $content_length");
|
||||
header("Content-Length: $content_length");
|
||||
header("Api-Content-Length: " . ob_get_length());
|
||||
|
||||
ob_end_flush();
|
||||
|
||||
|
||||
51
atom-to-html.xsl
Normal file
51
atom-to-html.xsl
Normal file
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xsl:stylesheet
|
||||
xmlns:atom="http://www.w3.org/2005/Atom"
|
||||
xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">
|
||||
|
||||
<xsl:output method="html"/>
|
||||
|
||||
<xsl:template match="/atom:feed">
|
||||
<html>
|
||||
<head>
|
||||
<title><xsl:value-of select="atom:title"/></title>
|
||||
<link rel="stylesheet" type="text/css" href="css/default.css"/>
|
||||
<script language="javascript" src="lib/xsl_mop-up.js"></script>
|
||||
</head>
|
||||
|
||||
<body onload="go_decoding()" class="ttrss_utility">
|
||||
|
||||
<div id="cometestme" style="display:none;">
|
||||
<xsl:text disable-output-escaping="yes">&amp;</xsl:text>
|
||||
</div>
|
||||
|
||||
<div class="rss">
|
||||
|
||||
<h1><xsl:value-of select="atom:title"/></h1>
|
||||
|
||||
<p class="description">This feed has been exported from
|
||||
<a target="_new" class="extlink" href="http://tt-rss.org">Tiny Tiny RSS</a>.
|
||||
It contains the following items:</p>
|
||||
|
||||
<xsl:for-each select="atom:entry">
|
||||
<h2><a target="_new" href="{atom:link/@href}"><xsl:value-of select="atom:title"/></a></h2>
|
||||
|
||||
<div name="decodeme" class="content">
|
||||
<xsl:value-of select="atom:content" disable-output-escaping="yes"/>
|
||||
</div>
|
||||
|
||||
<xsl:if test="enclosure">
|
||||
<p><a href="{enclosure/@url}">Extra...</a></p>
|
||||
</xsl:if>
|
||||
|
||||
|
||||
</xsl:for-each>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</xsl:template>
|
||||
|
||||
</xsl:stylesheet>
|
||||
|
||||
146
backend.php
146
backend.php
@@ -1,11 +1,24 @@
|
||||
<?php
|
||||
set_include_path(__DIR__ ."/include" . PATH_SEPARATOR .
|
||||
set_include_path(dirname(__FILE__) ."/include" . PATH_SEPARATOR .
|
||||
get_include_path());
|
||||
|
||||
$op = $_REQUEST['op'] ?? '';
|
||||
$method = !empty($_REQUEST['subop']) ?
|
||||
$_REQUEST['subop'] :
|
||||
$_REQUEST["method"] ?? false;
|
||||
/* remove ill effects of magic quotes */
|
||||
|
||||
if (get_magic_quotes_gpc()) {
|
||||
function stripslashes_deep($value) {
|
||||
$value = is_array($value) ?
|
||||
array_map('stripslashes_deep', $value) : stripslashes($value);
|
||||
return $value;
|
||||
}
|
||||
|
||||
$_POST = array_map('stripslashes_deep', $_POST);
|
||||
$_GET = array_map('stripslashes_deep', $_GET);
|
||||
$_COOKIE = array_map('stripslashes_deep', $_COOKIE);
|
||||
$_REQUEST = array_map('stripslashes_deep', $_REQUEST);
|
||||
}
|
||||
|
||||
$op = $_REQUEST["op"];
|
||||
@$method = $_REQUEST['subop'] ? $_REQUEST['subop'] : $_REQUEST["method"];
|
||||
|
||||
if (!$method)
|
||||
$method = 'index';
|
||||
@@ -14,53 +27,52 @@
|
||||
|
||||
/* Public calls compatibility shim */
|
||||
|
||||
$public_calls = array("rss", "getUnread", "getProfiles", "share");
|
||||
$public_calls = array("globalUpdateFeeds", "rss", "getUnread", "getProfiles", "share",
|
||||
"fbexport", "logout", "pubsub");
|
||||
|
||||
if (array_search($op, $public_calls) !== false) {
|
||||
header("Location: public.php?" . $_SERVER['QUERY_STRING']);
|
||||
return;
|
||||
}
|
||||
|
||||
$csrf_token = $_POST['csrf_token'] ?? "";
|
||||
@$csrf_token = $_REQUEST['csrf_token'];
|
||||
|
||||
require_once "autoload.php";
|
||||
require_once "sessions.php";
|
||||
|
||||
$op = (string)clean($op);
|
||||
$method = (string)clean($method);
|
||||
require_once "functions.php";
|
||||
require_once "config.php";
|
||||
require_once "db.php";
|
||||
require_once "db-prefs.php";
|
||||
|
||||
startup_gettext();
|
||||
|
||||
if (!init_plugins()) {
|
||||
return;
|
||||
$script_started = microtime(true);
|
||||
|
||||
if (!init_plugins()) return;
|
||||
|
||||
header("Content-Type: text/json; charset=utf-8");
|
||||
|
||||
if (ENABLE_GZIP_OUTPUT && function_exists("ob_gzhandler")) {
|
||||
ob_start("ob_gzhandler");
|
||||
}
|
||||
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
|
||||
if (Config::get(Config::SINGLE_USER_MODE)) {
|
||||
UserHelper::authenticate("admin", null);
|
||||
if (SINGLE_USER_MODE) {
|
||||
authenticate_user( "admin", null);
|
||||
}
|
||||
|
||||
if (!empty($_SESSION["uid"])) {
|
||||
if (!Sessions::validate_session()) {
|
||||
header("Content-Type: application/json");
|
||||
print Errors::to_json(Errors::E_UNAUTHORIZED);
|
||||
|
||||
if ($_SESSION["uid"]) {
|
||||
if (!validate_session()) {
|
||||
header("Content-Type: text/json");
|
||||
print error_json(6);
|
||||
return;
|
||||
}
|
||||
UserHelper::load_user_plugins($_SESSION["uid"]);
|
||||
}
|
||||
|
||||
if (Config::is_migration_needed()) {
|
||||
print Errors::to_json(Errors::E_SCHEMA_MISMATCH);
|
||||
|
||||
return;
|
||||
load_user_plugins( $_SESSION["uid"]);
|
||||
}
|
||||
|
||||
$purge_intervals = array(
|
||||
0 => __("Use default"),
|
||||
-1 => __("Never purge"),
|
||||
7 => __("1 week old"),
|
||||
5 => __("1 week old"),
|
||||
14 => __("2 weeks old"),
|
||||
31 => __("1 month old"),
|
||||
60 => __("2 months old"),
|
||||
@@ -87,93 +99,49 @@
|
||||
1440 => __("Daily"),
|
||||
10080 => __("Weekly"));
|
||||
|
||||
$access_level_names = [
|
||||
UserHelper::ACCESS_LEVEL_DISABLED => __("Disabled"),
|
||||
UserHelper::ACCESS_LEVEL_READONLY => __("Read Only"),
|
||||
UserHelper::ACCESS_LEVEL_USER => __("User"),
|
||||
UserHelper::ACCESS_LEVEL_POWERUSER => __("Power User"),
|
||||
UserHelper::ACCESS_LEVEL_ADMIN => __("Administrator")
|
||||
];
|
||||
$access_level_names = array(
|
||||
0 => __("User"),
|
||||
5 => __("Power User"),
|
||||
10 => __("Administrator"));
|
||||
|
||||
// shortcut syntax for plugin methods (?op=plugin--pmethod&...params)
|
||||
/* if (str_contains($op, PluginHost::PUBLIC_METHOD_DELIMITER)) {
|
||||
list ($plugin, $pmethod) = explode(PluginHost::PUBLIC_METHOD_DELIMITER, $op, 2);
|
||||
|
||||
// TODO: better implementation that won't modify $_REQUEST
|
||||
$_REQUEST["plugin"] = $plugin;
|
||||
$method = $pmethod;
|
||||
$op = "pluginhandler";
|
||||
} */
|
||||
|
||||
// $op = str_replace(, "_", $op);
|
||||
$op = str_replace("-", "_", $op);
|
||||
|
||||
$override = PluginHost::getInstance()->lookup_handler($op, $method);
|
||||
|
||||
if (class_exists($op) || $override) {
|
||||
|
||||
if (str_starts_with($method, "_")) {
|
||||
user_error("Refusing to invoke method $method of handler $op which starts with underscore.", E_USER_WARNING);
|
||||
header("Content-Type: application/json");
|
||||
print Errors::to_json(Errors::E_UNAUTHORIZED);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($override) {
|
||||
$handler = $override;
|
||||
} else {
|
||||
$reflection = new ReflectionClass($op);
|
||||
$handler = $reflection->newInstanceWithoutConstructor();
|
||||
$handler = new $op($_REQUEST);
|
||||
}
|
||||
|
||||
if (implements_interface($handler, 'IHandler')) {
|
||||
|
||||
$handler->__construct($_REQUEST);
|
||||
|
||||
if ($handler && implements_interface($handler, 'IHandler')) {
|
||||
if (validate_csrf($csrf_token) || $handler->csrf_ignore($method)) {
|
||||
|
||||
$before = $handler->before($method);
|
||||
|
||||
if ($before) {
|
||||
if ($handler->before($method)) {
|
||||
if ($method && method_exists($handler, $method)) {
|
||||
$reflection = new ReflectionMethod($handler, $method);
|
||||
|
||||
if ($reflection->getNumberOfRequiredParameters() == 0) {
|
||||
$handler->$method();
|
||||
} else {
|
||||
user_error("Refusing to invoke method $method of handler $op which has required parameters.", E_USER_WARNING);
|
||||
header("Content-Type: application/json");
|
||||
|
||||
print Errors::to_json(Errors::E_UNAUTHORIZED);
|
||||
}
|
||||
$handler->$method();
|
||||
} else {
|
||||
if (method_exists($handler, "catchall")) {
|
||||
$handler->catchall($method);
|
||||
} else {
|
||||
header("Content-Type: application/json");
|
||||
|
||||
print Errors::to_json(Errors::E_UNKNOWN_METHOD, ["info" => get_class($handler) . "->$method"]);
|
||||
}
|
||||
}
|
||||
|
||||
$handler->after();
|
||||
return;
|
||||
} else {
|
||||
header("Content-Type: application/json");
|
||||
print Errors::to_json(Errors::E_UNAUTHORIZED);
|
||||
|
||||
header("Content-Type: text/json");
|
||||
print error_json(6);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
user_error("Refusing to invoke method $method of handler $op with invalid CSRF token.", E_USER_WARNING);
|
||||
header("Content-Type: application/json");
|
||||
print Errors::to_json(Errors::E_UNAUTHORIZED);
|
||||
|
||||
header("Content-Type: text/json");
|
||||
print error_json(6);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
header("Content-Type: application/json");
|
||||
print Errors::to_json(Errors::E_UNKNOWN_METHOD, [ "info" => (isset($handler) ? get_class($handler) : "UNKNOWN:".$op) . "->$method"]);
|
||||
header("Content-Type: text/json");
|
||||
print error_json(13);
|
||||
|
||||
?>
|
||||
|
||||
0
cache/export/.empty
vendored
Normal file → Executable file
0
cache/export/.empty
vendored
Normal file → Executable file
0
cache/images/.empty
vendored
Normal file → Executable file
0
cache/images/.empty
vendored
Normal file → Executable file
948
classes/API.php
948
classes/API.php
@@ -1,948 +0,0 @@
|
||||
<?php
|
||||
class API extends Handler {
|
||||
|
||||
const API_LEVEL = 23;
|
||||
|
||||
const STATUS_OK = 0;
|
||||
const STATUS_ERR = 1;
|
||||
|
||||
const E_API_DISABLED = "API_DISABLED";
|
||||
const E_NOT_LOGGED_IN = "NOT_LOGGED_IN";
|
||||
const E_LOGIN_ERROR = "LOGIN_ERROR";
|
||||
const E_INCORRECT_USAGE = "INCORRECT_USAGE";
|
||||
const E_UNKNOWN_METHOD = "UNKNOWN_METHOD";
|
||||
const E_OPERATION_FAILED = "E_OPERATION_FAILED";
|
||||
const E_NOT_FOUND = "E_NOT_FOUND";
|
||||
|
||||
private ?int $seq = null;
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $reply
|
||||
*/
|
||||
private function _wrap(int $status, array $reply): bool {
|
||||
print json_encode([
|
||||
"seq" => $this->seq,
|
||||
"status" => $status,
|
||||
"content" => $reply
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function before(string $method): bool {
|
||||
if (parent::before($method)) {
|
||||
header("Content-Type: application/json");
|
||||
|
||||
if (empty($_SESSION["uid"]) && $method != "login" && $method != "isloggedin") {
|
||||
$this->_wrap(self::STATUS_ERR, array("error" => self::E_NOT_LOGGED_IN));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!empty($_SESSION["uid"]) && $method != "logout" && !Prefs::get(Prefs::ENABLE_API_ACCESS, $_SESSION["uid"])) {
|
||||
$this->_wrap(self::STATUS_ERR, array("error" => self::E_API_DISABLED));
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->seq = (int) clean($_REQUEST['seq'] ?? 0);
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getVersion(): bool {
|
||||
$rv = array("version" => Config::get_version());
|
||||
return $this->_wrap(self::STATUS_OK, $rv);
|
||||
}
|
||||
|
||||
function getApiLevel(): bool {
|
||||
$rv = array("level" => self::API_LEVEL);
|
||||
return $this->_wrap(self::STATUS_OK, $rv);
|
||||
}
|
||||
|
||||
function login(): bool {
|
||||
|
||||
if (session_status() == PHP_SESSION_ACTIVE) {
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
session_start();
|
||||
|
||||
$login = clean($_REQUEST["user"]);
|
||||
$password = clean($_REQUEST["password"]);
|
||||
|
||||
if (Config::get(Config::SINGLE_USER_MODE)) $login = "admin";
|
||||
|
||||
if ($uid = UserHelper::find_user_by_login($login)) {
|
||||
if (Prefs::get(Prefs::ENABLE_API_ACCESS, $uid)) {
|
||||
if (UserHelper::authenticate($login, $password, false, Auth_Base::AUTH_SERVICE_API)) {
|
||||
|
||||
// needed for _get_config()
|
||||
UserHelper::load_user_plugins($_SESSION['uid']);
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, array("session_id" => session_id(),
|
||||
"config" => $this->_get_config(),
|
||||
"api_level" => self::API_LEVEL));
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR));
|
||||
}
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_API_DISABLED));
|
||||
}
|
||||
}
|
||||
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR));
|
||||
}
|
||||
|
||||
function logout(): bool {
|
||||
UserHelper::logout();
|
||||
return $this->_wrap(self::STATUS_OK, array("status" => "OK"));
|
||||
}
|
||||
|
||||
function isLoggedIn(): bool {
|
||||
return $this->_wrap(self::STATUS_OK, array("status" => (bool)($_SESSION["uid"] ?? '')));
|
||||
}
|
||||
|
||||
function getUnread(): bool {
|
||||
$feed_id = clean($_REQUEST["feed_id"] ?? "");
|
||||
$is_cat = self::_param_to_bool($_REQUEST["is_cat"] ?? false);
|
||||
|
||||
if ($feed_id) {
|
||||
return $this->_wrap(self::STATUS_OK, array("unread" => Feeds::_get_counters($feed_id, $is_cat, true)));
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_OK, array("unread" => Feeds::_get_global_unread()));
|
||||
}
|
||||
}
|
||||
|
||||
/* Method added for ttrss-reader for Android */
|
||||
function getCounters(): bool {
|
||||
return $this->_wrap(self::STATUS_OK, Counters::get_all());
|
||||
}
|
||||
|
||||
function getFeeds(): bool {
|
||||
$cat_id = (int) clean($_REQUEST["cat_id"]);
|
||||
$unread_only = self::_param_to_bool($_REQUEST["unread_only"] ?? false);
|
||||
$limit = (int) clean($_REQUEST["limit"] ?? 0);
|
||||
$offset = (int) clean($_REQUEST["offset"] ?? 0);
|
||||
$include_nested = self::_param_to_bool($_REQUEST["include_nested"] ?? false);
|
||||
|
||||
$feeds = $this->_api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested);
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, $feeds);
|
||||
}
|
||||
|
||||
function getCategories(): bool {
|
||||
$unread_only = self::_param_to_bool($_REQUEST["unread_only"] ?? false);
|
||||
$enable_nested = self::_param_to_bool($_REQUEST["enable_nested"] ?? false);
|
||||
$include_empty = self::_param_to_bool($_REQUEST["include_empty"] ?? false);
|
||||
|
||||
// TODO do not return empty categories, return Uncategorized and standard virtual cats
|
||||
|
||||
$categories = ORM::for_table('ttrss_feed_categories')
|
||||
->select_many('id', 'title', 'order_id')
|
||||
->select_many_expr([
|
||||
'num_feeds' => '(SELECT COUNT(id) FROM ttrss_feeds WHERE ttrss_feed_categories.id IS NOT NULL AND cat_id = ttrss_feed_categories.id)',
|
||||
'num_cats' => '(SELECT COUNT(id) FROM ttrss_feed_categories AS c2 WHERE c2.parent_cat = ttrss_feed_categories.id)',
|
||||
])
|
||||
->where('owner_uid', $_SESSION['uid']);
|
||||
|
||||
if ($enable_nested) {
|
||||
$categories->where_null('parent_cat');
|
||||
}
|
||||
|
||||
$cats = [];
|
||||
|
||||
foreach ($categories->find_many() as $category) {
|
||||
if ($include_empty || $category->num_feeds > 0 || $category->num_cats > 0) {
|
||||
$unread = Feeds::_get_counters($category->id, true, true);
|
||||
|
||||
if ($enable_nested)
|
||||
$unread += Feeds::_get_cat_children_unread($category->id);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
array_push($cats, [
|
||||
'id' => (int) $category->id,
|
||||
'title' => $category->title,
|
||||
'unread' => (int) $unread,
|
||||
'order_id' => (int) $category->order_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([Feeds::CATEGORY_LABELS, Feeds::CATEGORY_SPECIAL, Feeds::CATEGORY_UNCATEGORIZED] as $cat_id) {
|
||||
if ($include_empty || !$this->_is_cat_empty($cat_id)) {
|
||||
$unread = Feeds::_get_counters($cat_id, true, true);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
array_push($cats, [
|
||||
'id' => $cat_id,
|
||||
'title' => Feeds::_get_cat_title($cat_id, $_SESSION['uid']),
|
||||
'unread' => (int) $unread,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, $cats);
|
||||
}
|
||||
|
||||
function getHeadlines(): bool {
|
||||
$feed_id = clean($_REQUEST["feed_id"] ?? "");
|
||||
|
||||
if (!empty($feed_id) || is_numeric($feed_id)) { // is_numeric for feed_id "0"
|
||||
$limit = (int)clean($_REQUEST["limit"] ?? 0 );
|
||||
|
||||
if (!$limit || $limit >= 200) $limit = 200;
|
||||
|
||||
$offset = (int)clean($_REQUEST["skip"] ?? 0);
|
||||
$filter = clean($_REQUEST["filter"] ?? "");
|
||||
$is_cat = self::_param_to_bool($_REQUEST["is_cat"] ?? false);
|
||||
$show_excerpt = self::_param_to_bool($_REQUEST["show_excerpt"] ?? false);
|
||||
$show_content = self::_param_to_bool($_REQUEST["show_content"] ?? false);
|
||||
/* all_articles, unread, adaptive, marked, updated */
|
||||
$view_mode = clean($_REQUEST["view_mode"] ?? null);
|
||||
$include_attachments = self::_param_to_bool($_REQUEST["include_attachments"] ?? false);
|
||||
$since_id = (int)clean($_REQUEST["since_id"] ?? 0);
|
||||
$include_nested = self::_param_to_bool($_REQUEST["include_nested"] ?? false);
|
||||
$sanitize_content = self::_param_to_bool($_REQUEST["sanitize"] ?? true);
|
||||
$force_update = self::_param_to_bool($_REQUEST["force_update"] ?? false);
|
||||
$has_sandbox = self::_param_to_bool($_REQUEST["has_sandbox"] ?? false);
|
||||
$excerpt_length = (int)clean($_REQUEST["excerpt_length"] ?? 0);
|
||||
$check_first_id = (int)clean($_REQUEST["check_first_id"] ?? 0);
|
||||
$include_header = self::_param_to_bool($_REQUEST["include_header"] ?? false);
|
||||
|
||||
$_SESSION['hasSandbox'] = $has_sandbox;
|
||||
|
||||
list($override_order, $skip_first_id_check) = Feeds::_order_to_override_query(clean($_REQUEST["order_by"] ?? ""));
|
||||
|
||||
/* do not rely on params below */
|
||||
|
||||
$search = clean($_REQUEST["search"] ?? "");
|
||||
|
||||
list($headlines, $headlines_header) = $this->_api_get_headlines($feed_id, $limit, $offset,
|
||||
$filter, $is_cat, $show_excerpt, $show_content, $view_mode, $override_order,
|
||||
$include_attachments, $since_id, $search,
|
||||
$include_nested, $sanitize_content, $force_update, $excerpt_length, $check_first_id, $skip_first_id_check);
|
||||
|
||||
if ($include_header) {
|
||||
return $this->_wrap(self::STATUS_OK, array($headlines_header, $headlines));
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_OK, $headlines);
|
||||
}
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE));
|
||||
}
|
||||
}
|
||||
|
||||
function updateArticle(): bool {
|
||||
$article_ids = array_filter(explode(",", clean($_REQUEST["article_ids"] ?? "")));
|
||||
$mode = (int) clean($_REQUEST["mode"]);
|
||||
$data = clean($_REQUEST["data"] ?? "");
|
||||
$field_raw = (int)clean($_REQUEST["field"]);
|
||||
|
||||
$field = "";
|
||||
$additional_fields = "";
|
||||
|
||||
switch ($field_raw) {
|
||||
case 0:
|
||||
$field = "marked";
|
||||
$additional_fields = ",last_marked = NOW()";
|
||||
break;
|
||||
case 1:
|
||||
$field = "published";
|
||||
$additional_fields = ",last_published = NOW()";
|
||||
break;
|
||||
case 2:
|
||||
$field = "unread";
|
||||
$additional_fields = ",last_read = NOW()";
|
||||
break;
|
||||
case 3:
|
||||
$field = "note";
|
||||
break;
|
||||
case 4:
|
||||
$field = "score";
|
||||
break;
|
||||
};
|
||||
|
||||
$set_to = match ($mode) {
|
||||
0 => 'false',
|
||||
1 => 'true',
|
||||
2 => "NOT $field",
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($field == 'note')
|
||||
$set_to = $this->pdo->quote($data);
|
||||
elseif ($field == 'score')
|
||||
$set_to = (int) $data;
|
||||
|
||||
if ($field && $set_to && count($article_ids) > 0) {
|
||||
|
||||
$article_qmarks = arr_qmarks($article_ids);
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
$field = $set_to $additional_fields
|
||||
WHERE ref_id IN ($article_qmarks) AND owner_uid = ?");
|
||||
$sth->execute([...$article_ids, $_SESSION['uid']]);
|
||||
|
||||
if ($field == 'marked')
|
||||
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_MARK_TOGGLED, $article_ids);
|
||||
|
||||
if ($field == 'published')
|
||||
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, $article_ids);
|
||||
|
||||
$num_updated = $sth->rowCount();
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, array("status" => "OK",
|
||||
"updated" => $num_updated));
|
||||
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE));
|
||||
}
|
||||
}
|
||||
|
||||
function getArticle(): bool {
|
||||
$article_ids = array_filter(explode(',', clean($_REQUEST['article_id'] ?? '')));
|
||||
$sanitize_content = self::_param_to_bool($_REQUEST['sanitize'] ?? true);
|
||||
|
||||
if (count($article_ids)) {
|
||||
$entries = ORM::for_table('ttrss_entries')
|
||||
->table_alias('e')
|
||||
->select_many('e.id', 'e.guid', 'e.title', 'e.link', 'e.author', 'e.content', 'e.lang', 'e.comments',
|
||||
'ue.feed_id', 'ue.int_id', 'ue.marked', 'ue.unread', 'ue.published', 'ue.score', 'ue.note')
|
||||
->select_many_expr([
|
||||
'updated' => 'SUBSTRING_FOR_DATE(updated,1,16)',
|
||||
'feed_title' => '(SELECT title FROM ttrss_feeds WHERE id = ue.feed_id)',
|
||||
'site_url' => '(SELECT site_url FROM ttrss_feeds WHERE id = ue.feed_id)',
|
||||
'hide_images' => '(SELECT hide_images FROM ttrss_feeds WHERE id = feed_id)',
|
||||
])
|
||||
->join('ttrss_user_entries', [ 'ue.ref_id', '=', 'e.id'], 'ue')
|
||||
->where_in('e.id', array_map('intval', $article_ids))
|
||||
->where('ue.owner_uid', $_SESSION['uid'])
|
||||
->find_many();
|
||||
|
||||
$articles = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$article = [
|
||||
'id' => $entry->id,
|
||||
'guid' => $entry->guid,
|
||||
'title' => $entry->title,
|
||||
'link' => $entry->link,
|
||||
'labels' => Article::_get_labels($entry->id),
|
||||
'unread' => self::_param_to_bool($entry->unread),
|
||||
'marked' => self::_param_to_bool($entry->marked),
|
||||
'published' => self::_param_to_bool($entry->published),
|
||||
'comments' => $entry->comments,
|
||||
'author' => $entry->author,
|
||||
'updated' => (int) strtotime($entry->updated ?? ''),
|
||||
'feed_id' => $entry->feed_id,
|
||||
'attachments' => Article::_get_enclosures($entry->id),
|
||||
'score' => (int) $entry->score,
|
||||
'feed_title' => $entry->feed_title,
|
||||
'note' => $entry->note,
|
||||
'lang' => $entry->lang,
|
||||
];
|
||||
|
||||
if ($sanitize_content) {
|
||||
$article['content'] = Sanitizer::sanitize(
|
||||
$entry->content,
|
||||
self::_param_to_bool($entry->hide_images),
|
||||
null, $entry->site_url, null, $entry->id);
|
||||
} else {
|
||||
$article['content'] = $entry->content;
|
||||
}
|
||||
|
||||
$hook_object = ['article' => &$article];
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_API,
|
||||
function ($result) use (&$article) {
|
||||
$article = $result;
|
||||
},
|
||||
$hook_object);
|
||||
|
||||
$article['content'] = DiskCache::rewrite_urls($article['content']);
|
||||
|
||||
array_push($articles, $article);
|
||||
}
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, $articles);
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_ERR, ['error' => self::E_INCORRECT_USAGE]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @see RPC::_make_init_params()
|
||||
* @see RPC::_make_runtime_info()
|
||||
* @return array<string, array<string, string>|bool|int|string>
|
||||
*/
|
||||
private function _get_config(): array {
|
||||
return [
|
||||
'custom_sort_types' => $this->_get_custom_sort_types(),
|
||||
'daemon_is_running' => file_is_locked('update_daemon.lock'),
|
||||
'icons_url' => Config::get_self_url() . '/public.php',
|
||||
'num_feeds' => ORM::for_table('ttrss_feeds')
|
||||
->where('owner_uid', $_SESSION['uid'])
|
||||
->count(),
|
||||
];
|
||||
}
|
||||
|
||||
function getConfig(): bool {
|
||||
$config = $this->_get_config();
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, $config);
|
||||
}
|
||||
|
||||
function updateFeed(): bool {
|
||||
$feed_id = (int) clean($_REQUEST["feed_id"]);
|
||||
|
||||
if (!ini_get("open_basedir")) {
|
||||
RSSUtils::update_rss_feed($feed_id);
|
||||
}
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, array("status" => "OK"));
|
||||
}
|
||||
|
||||
function catchupFeed(): bool {
|
||||
$feed_id = clean($_REQUEST["feed_id"]);
|
||||
$is_cat = self::_param_to_bool($_REQUEST["is_cat"] ?? false);
|
||||
$mode = clean($_REQUEST["mode"] ?? "");
|
||||
$search_query = clean($_REQUEST["search_query"] ?? "");
|
||||
$search_lang = clean($_REQUEST["search_lang"] ?? "");
|
||||
|
||||
if (!in_array($mode, ["all", "1day", "1week", "2week"]))
|
||||
$mode = "all";
|
||||
|
||||
Feeds::_catchup($feed_id, $is_cat, $_SESSION["uid"], $mode, [$search_query, $search_lang]);
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, array("status" => "OK"));
|
||||
}
|
||||
|
||||
function getPref(): bool {
|
||||
$pref_name = clean($_REQUEST["pref_name"]);
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, array("value" => Prefs::get($pref_name, $_SESSION["uid"], $_SESSION["profile"] ?? null)));
|
||||
}
|
||||
|
||||
function getLabels(): bool {
|
||||
$article_id = (int)clean($_REQUEST['article_id'] ?? -1);
|
||||
|
||||
$rv = [];
|
||||
|
||||
$labels = ORM::for_table('ttrss_labels2')
|
||||
->where('owner_uid', $_SESSION['uid'])
|
||||
->order_by_asc('caption')
|
||||
->find_many();
|
||||
|
||||
if ($article_id)
|
||||
$article_labels = Article::_get_labels($article_id);
|
||||
else
|
||||
$article_labels = [];
|
||||
|
||||
foreach ($labels as $label) {
|
||||
$checked = false;
|
||||
foreach ($article_labels as $al) {
|
||||
if (Labels::feed_to_label_id($al[0]) == $label->id) {
|
||||
$checked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
array_push($rv, [
|
||||
'id' => (int) Labels::label_to_feed_id($label->id),
|
||||
'caption' => $label->caption,
|
||||
'fg_color' => $label->fg_color,
|
||||
'bg_color' => $label->bg_color,
|
||||
'checked' => $checked,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, $rv);
|
||||
}
|
||||
|
||||
function setArticleLabel(): bool {
|
||||
|
||||
$article_ids = explode(",", clean($_REQUEST["article_ids"]));
|
||||
$label_id = (int) clean($_REQUEST['label_id']);
|
||||
$assign = self::_param_to_bool(clean($_REQUEST['assign']));
|
||||
|
||||
$label = Labels::find_caption(Labels::feed_to_label_id($label_id), $_SESSION["uid"]);
|
||||
|
||||
$num_updated = 0;
|
||||
|
||||
if ($label) {
|
||||
|
||||
foreach ($article_ids as $id) {
|
||||
|
||||
if ($assign)
|
||||
Labels::add_article((int)$id, $label, $_SESSION["uid"]);
|
||||
else
|
||||
Labels::remove_article((int)$id, $label, $_SESSION["uid"]);
|
||||
|
||||
++$num_updated;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, array("status" => "OK",
|
||||
"updated" => $num_updated));
|
||||
|
||||
}
|
||||
|
||||
function index(string $method): bool {
|
||||
$plugin = PluginHost::getInstance()->get_api_method(strtolower($method));
|
||||
|
||||
if ($plugin && method_exists($plugin, $method)) {
|
||||
$reply = $plugin->$method();
|
||||
|
||||
return $this->_wrap($reply[0], $reply[1]);
|
||||
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_UNKNOWN_METHOD, "method" => $method));
|
||||
}
|
||||
}
|
||||
|
||||
function shareToPublished(): bool {
|
||||
$title = clean($_REQUEST["title"]);
|
||||
$url = clean($_REQUEST["url"]);
|
||||
$sanitize_content = self::_param_to_bool($_REQUEST["sanitize"] ?? true);
|
||||
|
||||
if ($sanitize_content)
|
||||
$content = clean($_REQUEST["content"]);
|
||||
else
|
||||
$content = $_REQUEST["content"];
|
||||
|
||||
if (Article::_create_published_article($title, $url, $content, "", $_SESSION["uid"])) {
|
||||
return $this->_wrap(self::STATUS_OK, array("status" => 'OK'));
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_OPERATION_FAILED));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{'id': int, 'title': string, 'unread': int, 'cat_id': int}>
|
||||
*/
|
||||
private static function _api_get_feeds(int $cat_id, bool $unread_only, int $limit, int $offset, bool $include_nested = false): array {
|
||||
$feeds = [];
|
||||
|
||||
/* Labels */
|
||||
|
||||
/* API only: -4 (Feeds::CATEGORY_ALL) All feeds, including virtual feeds */
|
||||
if ($cat_id == Feeds::CATEGORY_ALL || $cat_id == Feeds::CATEGORY_LABELS) {
|
||||
$counters = Counters::get_labels();
|
||||
|
||||
foreach (array_values($counters) as $cv) {
|
||||
$unread = $cv['counter'];
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
$row = [
|
||||
'id' => (int) $cv['id'],
|
||||
'title' => $cv['description'],
|
||||
'unread' => $cv['counter'],
|
||||
'cat_id' => Feeds::CATEGORY_LABELS,
|
||||
];
|
||||
|
||||
array_push($feeds, $row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Virtual feeds */
|
||||
|
||||
foreach (PluginHost::getInstance()->get_feeds(Feeds::CATEGORY_SPECIAL) as $feed) {
|
||||
if (!implements_interface($feed['sender'], 'IVirtualFeed'))
|
||||
continue;
|
||||
|
||||
/** @var IVirtualFeed $feed['sender'] */
|
||||
$unread = $feed['sender']->get_unread($feed['id']);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
$row = [
|
||||
'id' => PluginHost::pfeed_to_feed_id($feed['id']),
|
||||
'title' => $feed['title'],
|
||||
'unread' => $unread,
|
||||
'cat_id' => Feeds::CATEGORY_SPECIAL,
|
||||
];
|
||||
|
||||
array_push($feeds, $row);
|
||||
}
|
||||
}
|
||||
|
||||
if ($cat_id == Feeds::CATEGORY_ALL || $cat_id == Feeds::CATEGORY_SPECIAL) {
|
||||
foreach ([Feeds::FEED_STARRED, Feeds::FEED_PUBLISHED, Feeds::FEED_FRESH,
|
||||
Feeds::FEED_ALL, Feeds::FEED_RECENTLY_READ, Feeds::FEED_ARCHIVED] as $i) {
|
||||
$unread = Feeds::_get_counters($i, false, true);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
$title = Feeds::_get_title($i, $_SESSION['uid']);
|
||||
|
||||
$row = [
|
||||
'id' => $i,
|
||||
'title' => $title,
|
||||
'unread' => $unread,
|
||||
'cat_id' => Feeds::CATEGORY_SPECIAL,
|
||||
];
|
||||
|
||||
array_push($feeds, $row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Child cats */
|
||||
|
||||
if ($include_nested && $cat_id) {
|
||||
$categories = ORM::for_table('ttrss_feed_categories')
|
||||
->where(['parent_cat' => $cat_id, 'owner_uid' => $_SESSION['uid']])
|
||||
->order_by_asc('order_id')
|
||||
->order_by_asc('title')
|
||||
->find_many();
|
||||
|
||||
foreach ($categories as $category) {
|
||||
$unread = Feeds::_get_counters($category->id, true, true) +
|
||||
Feeds::_get_cat_children_unread($category->id);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
$row = [
|
||||
'id' => (int) $category->id,
|
||||
'title' => $category->title,
|
||||
'unread' => $unread,
|
||||
'is_cat' => true,
|
||||
'order_id' => (int) $category->order_id,
|
||||
];
|
||||
array_push($feeds, $row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Real feeds */
|
||||
|
||||
/* API only: -3 (Feeds::CATEGORY_ALL_EXCEPT_VIRTUAL) All feeds, excluding virtual feeds (e.g. Labels and such) */
|
||||
$feeds_obj = ORM::for_table('ttrss_feeds')
|
||||
->select_many('id', 'feed_url', 'cat_id', 'title', 'order_id', 'last_error', 'update_interval')
|
||||
->select_expr('SUBSTRING_FOR_DATE(last_updated,1,19)', 'last_updated')
|
||||
->where('owner_uid', $_SESSION['uid'])
|
||||
->order_by_asc('order_id')
|
||||
->order_by_asc('title');
|
||||
|
||||
if ($limit) $feeds_obj->limit($limit);
|
||||
if ($offset) $feeds_obj->offset($offset);
|
||||
|
||||
if ($cat_id != Feeds::CATEGORY_ALL_EXCEPT_VIRTUAL && $cat_id != Feeds::CATEGORY_ALL) {
|
||||
$feeds_obj->where_raw('(cat_id = ? OR (? = 0 AND cat_id IS NULL))', [$cat_id, $cat_id]);
|
||||
}
|
||||
|
||||
foreach ($feeds_obj->find_many() as $feed) {
|
||||
$unread = Feeds::_get_counters($feed->id, false, true);
|
||||
$has_icon = Feeds::_has_icon($feed->id);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
$row = [
|
||||
'feed_url' => $feed->feed_url,
|
||||
'title' => $feed->title,
|
||||
'id' => (int) $feed->id,
|
||||
'unread' => (int) $unread,
|
||||
'has_icon' => $has_icon,
|
||||
'cat_id' => (int) $feed->cat_id,
|
||||
'last_updated' => (int) strtotime($feed->last_updated ?? ''),
|
||||
'order_id' => (int) $feed->order_id,
|
||||
'last_error' => $feed->last_error,
|
||||
'update_interval' => (int) $feed->update_interval,
|
||||
];
|
||||
|
||||
array_push($feeds, $row);
|
||||
}
|
||||
}
|
||||
|
||||
return $feeds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: array<int, array<string, mixed>>, 1: array<string, mixed>} $headlines, $headlines_header
|
||||
*/
|
||||
private static function _api_get_headlines(int|string $feed_id, int $limit, int $offset,
|
||||
string $filter, bool $is_cat, bool $show_excerpt, bool $show_content, ?string $view_mode, string $order,
|
||||
bool $include_attachments, int $since_id, string $search = "", bool $include_nested = false,
|
||||
bool $sanitize_content = true, bool $force_update = false, int $excerpt_length = 100, ?int $check_first_id = null,
|
||||
bool $skip_first_id_check = false): array {
|
||||
|
||||
if ($force_update && is_numeric($feed_id) && $feed_id > 0) {
|
||||
// Update the feed if required with some basic flood control
|
||||
|
||||
$feed = ORM::for_table('ttrss_feeds')
|
||||
->select_many('id', 'cache_images')
|
||||
->select_expr('SUBSTRING_FOR_DATE(last_updated,1,19)', 'last_updated')
|
||||
->find_one($feed_id);
|
||||
|
||||
if ($feed) {
|
||||
$last_updated = strtotime($feed->last_updated ?? '');
|
||||
$cache_images = self::_param_to_bool($feed->cache_images);
|
||||
|
||||
if (!$cache_images && time() - $last_updated > 120) {
|
||||
RSSUtils::update_rss_feed($feed_id, true);
|
||||
} else {
|
||||
$feed->last_updated = '1970-01-01';
|
||||
$feed->last_update_started = '1970-01-01';
|
||||
$feed->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$qfh_ret = [];
|
||||
|
||||
if (!$is_cat && is_numeric($feed_id) && $feed_id < PLUGIN_FEED_BASE_INDEX && $feed_id > LABEL_BASE_INDEX) {
|
||||
$pfeed_id = PluginHost::feed_to_pfeed_id($feed_id);
|
||||
|
||||
$handler = PluginHost::getInstance()->get_feed_handler($pfeed_id);
|
||||
|
||||
if ($handler) {
|
||||
$params = array(
|
||||
"feed" => $feed_id,
|
||||
"limit" => $limit,
|
||||
"view_mode" => $view_mode,
|
||||
"cat_view" => $is_cat,
|
||||
"search" => $search,
|
||||
"override_order" => $order,
|
||||
"offset" => $offset,
|
||||
"since_id" => 0,
|
||||
"include_children" => $include_nested,
|
||||
"check_first_id" => $check_first_id,
|
||||
"skip_first_id_check" => $skip_first_id_check
|
||||
);
|
||||
|
||||
$qfh_ret = $handler->get_headlines($pfeed_id, $params);
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
$params = array(
|
||||
"feed" => $feed_id,
|
||||
"limit" => $limit,
|
||||
"view_mode" => $view_mode,
|
||||
"cat_view" => $is_cat,
|
||||
"search" => $search,
|
||||
"override_order" => $order,
|
||||
"offset" => $offset,
|
||||
"since_id" => $since_id,
|
||||
"include_children" => $include_nested,
|
||||
"check_first_id" => $check_first_id,
|
||||
"skip_first_id_check" => $skip_first_id_check
|
||||
);
|
||||
|
||||
$qfh_ret = Feeds::_get_headlines($params);
|
||||
}
|
||||
|
||||
$result = $qfh_ret[0];
|
||||
$feed_title = $qfh_ret[1];
|
||||
$first_id = $qfh_ret[6];
|
||||
|
||||
$headlines = array();
|
||||
|
||||
$headlines_header = array(
|
||||
'id' => $feed_id,
|
||||
'first_id' => $first_id,
|
||||
'is_cat' => $is_cat);
|
||||
|
||||
if (!is_numeric($result)) {
|
||||
while ($line = $result->fetch()) {
|
||||
$line["content_preview"] = truncate_string(strip_tags($line["content"]), $excerpt_length);
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES,
|
||||
function ($result) use (&$line) {
|
||||
$line = $result;
|
||||
},
|
||||
$line, $excerpt_length);
|
||||
|
||||
$is_updated = ($line["last_read"] == "" &&
|
||||
($line["unread"] != "t" && $line["unread"] != "1"));
|
||||
|
||||
$tags = explode(",", $line["tag_cache"]);
|
||||
|
||||
$label_cache = $line["label_cache"];
|
||||
$labels = false;
|
||||
|
||||
if ($label_cache) {
|
||||
$label_cache = json_decode($label_cache, true);
|
||||
|
||||
if ($label_cache) {
|
||||
if (($label_cache["no-labels"] ?? 0) == 1)
|
||||
$labels = [];
|
||||
else
|
||||
$labels = $label_cache;
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_array($labels)) $labels = Article::_get_labels($line["id"]);
|
||||
|
||||
$headline_row = array(
|
||||
"id" => (int)$line["id"],
|
||||
"guid" => $line["guid"],
|
||||
"unread" => self::_param_to_bool($line["unread"]),
|
||||
"marked" => self::_param_to_bool($line["marked"]),
|
||||
"published" => self::_param_to_bool($line["published"]),
|
||||
"updated" => (int)strtotime($line["updated"] ?? ''),
|
||||
"is_updated" => $is_updated,
|
||||
"title" => $line["title"],
|
||||
"link" => $line["link"],
|
||||
"feed_id" => $line["feed_id"] ? $line['feed_id'] : 0,
|
||||
"tags" => $tags,
|
||||
);
|
||||
|
||||
$enclosures = Article::_get_enclosures($line['id']);
|
||||
|
||||
if ($include_attachments)
|
||||
$headline_row['attachments'] = $enclosures;
|
||||
|
||||
if ($show_excerpt)
|
||||
$headline_row["excerpt"] = $line["content_preview"];
|
||||
|
||||
if ($show_content) {
|
||||
|
||||
if ($sanitize_content) {
|
||||
$headline_row["content"] = Sanitizer::sanitize(
|
||||
$line["content"],
|
||||
self::_param_to_bool($line['hide_images']),
|
||||
null, $line["site_url"], null, $line["id"]);
|
||||
} else {
|
||||
$headline_row["content"] = $line["content"];
|
||||
}
|
||||
}
|
||||
|
||||
// unify label output to ease parsing
|
||||
if (($labels["no-labels"] ?? 0) == 1) $labels = [];
|
||||
|
||||
$headline_row["labels"] = $labels;
|
||||
|
||||
$headline_row["feed_title"] = $line["feed_title"] ?? $feed_title;
|
||||
|
||||
$headline_row["comments_count"] = (int)$line["num_comments"];
|
||||
$headline_row["comments_link"] = $line["comments"];
|
||||
|
||||
$headline_row["always_display_attachments"] = self::_param_to_bool($line["always_display_enclosures"]);
|
||||
|
||||
$headline_row["author"] = $line["author"];
|
||||
|
||||
$headline_row["score"] = (int)$line["score"];
|
||||
$headline_row["note"] = $line["note"];
|
||||
$headline_row["lang"] = $line["lang"];
|
||||
|
||||
$headline_row["site_url"] = $line["site_url"];
|
||||
|
||||
if ($show_content) {
|
||||
$hook_object = ["headline" => &$headline_row];
|
||||
|
||||
list ($flavor_image, $flavor_stream, $flavor_kind) = Article::_get_image($enclosures,
|
||||
$line["content"], // unsanitized
|
||||
$line["site_url"] ?? "", // could be null if archived article
|
||||
$headline_row);
|
||||
|
||||
$headline_row["flavor_image"] = $flavor_image;
|
||||
$headline_row["flavor_stream"] = $flavor_stream;
|
||||
|
||||
/* optional */
|
||||
if ($flavor_kind)
|
||||
$headline_row["flavor_kind"] = $flavor_kind;
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_API,
|
||||
function ($result) use (&$headline_row) {
|
||||
$headline_row = $result;
|
||||
},
|
||||
$hook_object);
|
||||
|
||||
$headline_row["content"] = DiskCache::rewrite_urls($headline_row['content']);
|
||||
}
|
||||
|
||||
array_push($headlines, $headline_row);
|
||||
}
|
||||
} else if ($result == -1) {
|
||||
$headlines_header['first_id_changed'] = true;
|
||||
}
|
||||
|
||||
return array($headlines, $headlines_header);
|
||||
}
|
||||
|
||||
function unsubscribeFeed(): bool {
|
||||
$feed_id = (int) clean($_REQUEST["feed_id"]);
|
||||
|
||||
$feed_exists = ORM::for_table('ttrss_feeds')
|
||||
->where(['id' => $feed_id, 'owner_uid' => $_SESSION['uid']])
|
||||
->count();
|
||||
|
||||
if ($feed_exists) {
|
||||
Pref_Feeds::remove_feed($feed_id, $_SESSION['uid']);
|
||||
return $this->_wrap(self::STATUS_OK, ['status' => 'OK']);
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_ERR, ['error' => self::E_OPERATION_FAILED]);
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeToFeed(): bool {
|
||||
$feed_url = clean($_REQUEST["feed_url"]);
|
||||
$category_id = (int) clean($_REQUEST["category_id"]);
|
||||
$login = clean($_REQUEST["login"] ?? "");
|
||||
$password = clean($_REQUEST["password"] ?? "");
|
||||
|
||||
if ($feed_url) {
|
||||
$rc = Feeds::_subscribe($feed_url, $category_id, $login, $password);
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, array("status" => $rc));
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE));
|
||||
}
|
||||
}
|
||||
|
||||
function getFeedTree(): bool {
|
||||
$include_empty = self::_param_to_bool($_REQUEST['include_empty'] ?? false);
|
||||
|
||||
$pf = new Pref_Feeds($_REQUEST);
|
||||
|
||||
$_REQUEST['mode'] = 2;
|
||||
$_REQUEST['force_show_empty'] = $include_empty;
|
||||
|
||||
return $this->_wrap(self::STATUS_OK,
|
||||
array("categories" => $pf->_makefeedtree()));
|
||||
}
|
||||
|
||||
function getFeedIcon(): bool {
|
||||
$id = (int)$_REQUEST['id'];
|
||||
$cache = DiskCache::instance('feed-icons');
|
||||
|
||||
if ($cache->exists((string)$id)) {
|
||||
return $cache->send((string)$id) > 0;
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_NOT_FOUND));
|
||||
}
|
||||
}
|
||||
|
||||
// only works for labels or uncategorized for the time being
|
||||
private function _is_cat_empty(int $id): bool {
|
||||
if ($id == Feeds::CATEGORY_LABELS) {
|
||||
$label_count = ORM::for_table('ttrss_labels2')
|
||||
->where('owner_uid', $_SESSION['uid'])
|
||||
->count();
|
||||
|
||||
return $label_count == 0;
|
||||
} else if ($id == Feeds::CATEGORY_UNCATEGORIZED) {
|
||||
$uncategorized_count = ORM::for_table('ttrss_feeds')
|
||||
->where_null('cat_id')
|
||||
->where('owner_uid', $_SESSION['uid'])
|
||||
->count();
|
||||
|
||||
return $uncategorized_count == 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private function _get_custom_sort_types(): array {
|
||||
$ret = [];
|
||||
|
||||
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_HEADLINES_CUSTOM_SORT_MAP, function ($result) use (&$ret) {
|
||||
foreach ($result as $sort_value => $sort_title) {
|
||||
$ret[$sort_value] = $sort_title;
|
||||
}
|
||||
});
|
||||
|
||||
return $ret;
|
||||
}
|
||||
}
|
||||
@@ -1,706 +0,0 @@
|
||||
<?php
|
||||
class Article extends Handler_Protected {
|
||||
const ARTICLE_KIND_ALBUM = 1;
|
||||
const ARTICLE_KIND_VIDEO = 2;
|
||||
const ARTICLE_KIND_YOUTUBE = 3;
|
||||
|
||||
const CATCHUP_MODE_MARK_AS_READ = 0;
|
||||
const CATCHUP_MODE_MARK_AS_UNREAD = 1;
|
||||
const CATCHUP_MODE_TOGGLE = 2;
|
||||
|
||||
function redirect(): void {
|
||||
$article = ORM::for_table('ttrss_entries')
|
||||
->table_alias('e')
|
||||
->join('ttrss_user_entries', [ 'ref_id', '=', 'e.id'], 'ue')
|
||||
->where('ue.owner_uid', $_SESSION['uid'])
|
||||
->find_one((int)$_REQUEST['id']);
|
||||
|
||||
if ($article) {
|
||||
$article_url = UrlHelper::validate($article->link);
|
||||
|
||||
if ($article_url) {
|
||||
header("Location: $article_url");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
|
||||
print "Article not found or has an empty URL.";
|
||||
}
|
||||
|
||||
static function _create_published_article(string $title, string $url, string $content, string $labels_str, int $owner_uid): bool {
|
||||
|
||||
$guid = 'SHA1:' . sha1("ttshared:" . $url . $owner_uid); // include owner_uid to prevent global GUID clash
|
||||
|
||||
if (!$content) {
|
||||
$pluginhost = new PluginHost();
|
||||
$pluginhost->load_all(PluginHost::KIND_ALL, $owner_uid);
|
||||
//$pluginhost->load_data();
|
||||
|
||||
$pluginhost->run_hooks_callback(PluginHost::HOOK_GET_FULL_TEXT,
|
||||
function ($result) use (&$content) {
|
||||
if ($result) {
|
||||
$content = $result;
|
||||
return true;
|
||||
}
|
||||
},
|
||||
$url);
|
||||
}
|
||||
|
||||
$content_hash = sha1($content);
|
||||
|
||||
if ($labels_str != "") {
|
||||
$labels = explode(",", $labels_str);
|
||||
} else {
|
||||
$labels = array();
|
||||
}
|
||||
|
||||
$rc = false;
|
||||
|
||||
if (!$title) $title = $url;
|
||||
if (!$title && !$url) return false;
|
||||
|
||||
if (filter_var($url, FILTER_VALIDATE_URL) === false) return false;
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$pdo->beginTransaction();
|
||||
|
||||
// only check for our user data here, others might have shared this with different content etc
|
||||
$sth = $pdo->prepare("SELECT id FROM ttrss_entries, ttrss_user_entries WHERE
|
||||
guid = ? AND ref_id = id AND owner_uid = ? LIMIT 1");
|
||||
$sth->execute([$guid, $owner_uid]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$ref_id = $row['id'];
|
||||
|
||||
$sth = $pdo->prepare("SELECT int_id FROM ttrss_user_entries WHERE
|
||||
ref_id = ? AND owner_uid = ? LIMIT 1");
|
||||
$sth->execute([$ref_id, $owner_uid]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$int_id = $row['int_id'];
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_entries SET
|
||||
content = ?, content_hash = ? WHERE id = ?");
|
||||
$sth->execute([$content, $content_hash, $ref_id]);
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_entries
|
||||
SET tsvector_combined = to_tsvector( :ts_content)
|
||||
WHERE id = :id");
|
||||
$params = [
|
||||
":ts_content" => mb_substr(\Soundasleep\Html2Text::convert($content), 0, 900000),
|
||||
":id" => $ref_id];
|
||||
$sth->execute($params);
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET published = true,
|
||||
last_published = NOW() WHERE
|
||||
int_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$int_id, $owner_uid]);
|
||||
|
||||
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, [$ref_id]);
|
||||
|
||||
} else {
|
||||
|
||||
$sth = $pdo->prepare("INSERT INTO ttrss_user_entries
|
||||
(ref_id, uuid, feed_id, orig_feed_id, owner_uid, published, tag_cache, label_cache,
|
||||
last_read, note, unread, last_published)
|
||||
VALUES
|
||||
(?, '', NULL, NULL, ?, true, '', '', NOW(), '', false, NOW())");
|
||||
$sth->execute([$ref_id, $owner_uid]);
|
||||
|
||||
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, [$ref_id]);
|
||||
}
|
||||
|
||||
if (count($labels) != 0) {
|
||||
foreach ($labels as $label) {
|
||||
Labels::add_article($ref_id, trim($label), $owner_uid);
|
||||
}
|
||||
}
|
||||
|
||||
$rc = true;
|
||||
|
||||
} else {
|
||||
$sth = $pdo->prepare("INSERT INTO ttrss_entries
|
||||
(title, guid, link, updated, content, content_hash, date_entered, date_updated)
|
||||
VALUES
|
||||
(?, ?, ?, NOW(), ?, ?, NOW(), NOW()) RETURNING id");
|
||||
$sth->execute([$title, $guid, $url, $content, $content_hash]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$ref_id = $row["id"];
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_entries
|
||||
SET tsvector_combined = to_tsvector( :ts_content)
|
||||
WHERE id = :id");
|
||||
$params = [
|
||||
":ts_content" => mb_substr(\Soundasleep\Html2Text::convert($content), 0, 900000),
|
||||
":id" => $ref_id];
|
||||
$sth->execute($params);
|
||||
|
||||
$sth = $pdo->prepare("INSERT INTO ttrss_user_entries
|
||||
(ref_id, uuid, feed_id, orig_feed_id, owner_uid, published, tag_cache, label_cache,
|
||||
last_read, note, unread, last_published)
|
||||
VALUES
|
||||
(?, '', NULL, NULL, ?, true, '', '', NOW(), '', false, NOW())");
|
||||
$sth->execute([$ref_id, $owner_uid]);
|
||||
|
||||
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, [$ref_id]);
|
||||
|
||||
if (count($labels) != 0) {
|
||||
foreach ($labels as $label) {
|
||||
Labels::add_article($ref_id, trim($label), $owner_uid);
|
||||
}
|
||||
}
|
||||
|
||||
$rc = true;
|
||||
}
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
return $rc;
|
||||
}
|
||||
|
||||
function printArticleTags(): void {
|
||||
$id = (int) clean($_REQUEST['id'] ?? 0);
|
||||
|
||||
print json_encode(["id" => $id,
|
||||
"tags" => self::_get_tags($id)]);
|
||||
}
|
||||
|
||||
function setScore(): void {
|
||||
$ids = array_map("intval", clean($_REQUEST['ids'] ?? []));
|
||||
$score = (int)clean($_REQUEST['score']);
|
||||
|
||||
$ids_qmarks = arr_qmarks($ids);
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
score = ? WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
|
||||
$sth->execute([$score, ...$ids, $_SESSION['uid']]);
|
||||
|
||||
print json_encode(["id" => $ids, "score" => $score]);
|
||||
}
|
||||
|
||||
function setArticleTags(): void {
|
||||
|
||||
$id = clean($_REQUEST["id"]);
|
||||
|
||||
//$tags_str = clean($_REQUEST["tags_str"]);
|
||||
//$tags = array_unique(array_map('trim', explode(",", $tags_str)));
|
||||
|
||||
$tags = FeedItem_Common::normalize_categories(explode(",", clean($_REQUEST["tags_str"] ?? "")));
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT int_id FROM ttrss_user_entries WHERE
|
||||
ref_id = ? AND owner_uid = ? LIMIT 1");
|
||||
$sth->execute([$id, $_SESSION['uid']]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
|
||||
$tags_to_cache = array();
|
||||
|
||||
$int_id = $row['int_id'];
|
||||
|
||||
$dsth = $this->pdo->prepare("DELETE FROM ttrss_tags WHERE
|
||||
post_int_id = ? AND owner_uid = ?");
|
||||
$dsth->execute([$int_id, $_SESSION['uid']]);
|
||||
|
||||
$csth = $this->pdo->prepare("SELECT post_int_id FROM ttrss_tags
|
||||
WHERE post_int_id = ? AND owner_uid = ? AND tag_name = ?");
|
||||
|
||||
$usth = $this->pdo->prepare("INSERT INTO ttrss_tags
|
||||
(post_int_id, owner_uid, tag_name)
|
||||
VALUES (?, ?, ?)");
|
||||
|
||||
foreach ($tags as $tag) {
|
||||
$csth->execute([$int_id, $_SESSION['uid'], $tag]);
|
||||
|
||||
if (!$csth->fetch()) {
|
||||
$usth->execute([$int_id, $_SESSION['uid'], $tag]);
|
||||
}
|
||||
|
||||
array_push($tags_to_cache, $tag);
|
||||
}
|
||||
|
||||
/* update tag cache */
|
||||
|
||||
$tags_str = join(",", $tags_to_cache);
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries
|
||||
SET tag_cache = ? WHERE ref_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$tags_str, $id, $_SESSION['uid']]);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
|
||||
// get latest tags from the database, original $tags is sometimes JSON-encoded as a hash ({}) - ???
|
||||
print json_encode(["id" => (int)$id, "tags" => $this->_get_tags($id)]);
|
||||
}
|
||||
|
||||
function completeTags(): void {
|
||||
$search = clean($_REQUEST["search"]);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT DISTINCT tag_name FROM ttrss_tags
|
||||
WHERE owner_uid = ? AND
|
||||
tag_name LIKE ? ORDER BY tag_name
|
||||
LIMIT 10");
|
||||
|
||||
$sth->execute([$_SESSION['uid'], "$search%"]);
|
||||
|
||||
$results = [];
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
array_push($results, $line["tag_name"]);
|
||||
}
|
||||
|
||||
print json_encode($results);
|
||||
}
|
||||
|
||||
function assigntolabel(): void {
|
||||
$this->_label_ops(true);
|
||||
}
|
||||
|
||||
function removefromlabel(): void {
|
||||
$this->_label_ops(false);
|
||||
}
|
||||
|
||||
private function _label_ops(bool $assign): void {
|
||||
$reply = array();
|
||||
|
||||
$ids = array_map("intval", array_filter(explode(",", clean($_REQUEST["ids"] ?? "")), "strlen"));
|
||||
$label_id = clean($_REQUEST["lid"]);
|
||||
|
||||
$label = Labels::find_caption($label_id, $_SESSION["uid"]);
|
||||
|
||||
$reply["labels-for"] = [];
|
||||
|
||||
if ($label) {
|
||||
foreach ($ids as $id) {
|
||||
if ($assign)
|
||||
Labels::add_article($id, $label, $_SESSION["uid"]);
|
||||
else
|
||||
Labels::remove_article($id, $label, $_SESSION["uid"]);
|
||||
|
||||
array_push($reply["labels-for"],
|
||||
["id" => (int)$id, "labels" => $this->_get_labels($id)]);
|
||||
}
|
||||
}
|
||||
|
||||
$reply["message"] = "UPDATE_COUNTERS";
|
||||
|
||||
print json_encode($reply);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $id article id
|
||||
* @return array{'formatted': string, 'entries': array<int, array<string, mixed>>}
|
||||
*/
|
||||
static function _format_enclosures(int $id, bool $always_display_enclosures, string $article_content, bool $hide_images = false): array {
|
||||
$enclosures = self::_get_enclosures($id);
|
||||
$enclosures_formatted = "";
|
||||
|
||||
/*foreach ($enclosures as &$enc) {
|
||||
array_push($enclosures, [
|
||||
"type" => $enc["content_type"],
|
||||
"filename" => basename($enc["content_url"]),
|
||||
"url" => $enc["content_url"],
|
||||
"title" => $enc["title"],
|
||||
"width" => (int) $enc["width"],
|
||||
"height" => (int) $enc["height"]
|
||||
]);
|
||||
}*/
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_FORMAT_ENCLOSURES,
|
||||
function ($result) use (&$enclosures_formatted, &$enclosures) {
|
||||
if (is_array($result)) {
|
||||
$enclosures_formatted = $result[0];
|
||||
$enclosures = $result[1];
|
||||
} else {
|
||||
$enclosures_formatted = $result;
|
||||
}
|
||||
},
|
||||
$enclosures_formatted, $enclosures, $id, $always_display_enclosures, $article_content, $hide_images);
|
||||
|
||||
if (!empty($enclosures_formatted)) {
|
||||
return [
|
||||
'formatted' => $enclosures_formatted,
|
||||
'entries' => []
|
||||
];
|
||||
}
|
||||
|
||||
$rv = [
|
||||
'formatted' => '',
|
||||
'entries' => []
|
||||
];
|
||||
|
||||
$rv['can_inline'] = isset($_SESSION["uid"]) &&
|
||||
empty($_SESSION["bw_limit"]) &&
|
||||
!Prefs::get(Prefs::STRIP_IMAGES, $_SESSION["uid"], $_SESSION["profile"] ?? null) &&
|
||||
($always_display_enclosures || !preg_match("/<img/i", $article_content));
|
||||
|
||||
$rv['inline_text_only'] = $hide_images && $rv['can_inline'];
|
||||
|
||||
foreach ($enclosures as $enc) {
|
||||
|
||||
// this is highly approximate
|
||||
$enc["filename"] = basename($enc["content_url"]);
|
||||
|
||||
$rendered_enc = "";
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ENCLOSURE,
|
||||
function ($result) use (&$rendered_enc) {
|
||||
$rendered_enc = $result;
|
||||
},
|
||||
$enc, $id, $rv);
|
||||
|
||||
if ($rendered_enc) {
|
||||
$rv['formatted'] .= $rendered_enc;
|
||||
} else {
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ENCLOSURE_ENTRY,
|
||||
function ($result) use (&$enc) {
|
||||
$enc = $result;
|
||||
},
|
||||
$enc, $id, $rv);
|
||||
|
||||
array_push($rv['entries'], $enc);
|
||||
}
|
||||
}
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
static function _get_tags(int $id, int $owner_uid = 0, ?string $tag_cache = null): array {
|
||||
$a_id = $id;
|
||||
|
||||
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT DISTINCT tag_name,
|
||||
owner_uid as owner FROM ttrss_tags
|
||||
WHERE post_int_id = (SELECT int_id FROM ttrss_user_entries WHERE
|
||||
ref_id = ? AND owner_uid = ? LIMIT 1) ORDER BY tag_name");
|
||||
|
||||
$tags = array();
|
||||
|
||||
/* check cache first */
|
||||
|
||||
if (!$tag_cache) {
|
||||
$csth = $pdo->prepare("SELECT tag_cache FROM ttrss_user_entries
|
||||
WHERE ref_id = ? AND owner_uid = ?");
|
||||
$csth->execute([$id, $owner_uid]);
|
||||
|
||||
if ($row = $csth->fetch()) {
|
||||
$tag_cache = $row["tag_cache"];
|
||||
}
|
||||
}
|
||||
|
||||
if ($tag_cache) {
|
||||
$tags = explode(",", $tag_cache);
|
||||
} else {
|
||||
|
||||
/* do it the hard way */
|
||||
|
||||
$sth->execute([$a_id, $owner_uid]);
|
||||
|
||||
while ($tmp_line = $sth->fetch()) {
|
||||
array_push($tags, $tmp_line["tag_name"]);
|
||||
}
|
||||
|
||||
/* update the cache */
|
||||
|
||||
$tags_str = join(",", $tags);
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries
|
||||
SET tag_cache = ? WHERE ref_id = ?
|
||||
AND owner_uid = ?");
|
||||
$sth->execute([$tags_str, $id, $owner_uid]);
|
||||
}
|
||||
|
||||
return $tags;
|
||||
}
|
||||
|
||||
function getmetadatabyid(): void {
|
||||
$article = ORM::for_table('ttrss_entries')
|
||||
->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue')
|
||||
->where('ue.owner_uid', $_SESSION['uid'])
|
||||
->find_one((int)$_REQUEST['id']);
|
||||
|
||||
if ($article) {
|
||||
echo json_encode(["link" => $article->link, "title" => $article->title]);
|
||||
} else {
|
||||
echo json_encode([]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
static function _get_enclosures(int $id): array {
|
||||
$encs = ORM::for_table('ttrss_enclosures')
|
||||
->where('post_id', $id)
|
||||
->find_many();
|
||||
|
||||
$rv = [];
|
||||
|
||||
$cache = DiskCache::instance("images");
|
||||
|
||||
foreach ($encs as $enc) {
|
||||
$cache_key = sha1($enc->content_url);
|
||||
|
||||
if ($cache->exists($cache_key)) {
|
||||
$enc->content_url = $cache->get_url($cache_key);
|
||||
}
|
||||
|
||||
array_push($rv, $enc->as_array());
|
||||
}
|
||||
|
||||
return $rv;
|
||||
|
||||
}
|
||||
|
||||
static function _purge_orphans(): void {
|
||||
|
||||
$pdo = Db::pdo();
|
||||
$res = $pdo->query("DELETE FROM ttrss_entries WHERE
|
||||
NOT EXISTS (SELECT ref_id FROM ttrss_user_entries WHERE ref_id = id)");
|
||||
|
||||
if (Debug::enabled()) {
|
||||
$rows = $res->rowCount();
|
||||
Debug::log("Purged $rows orphaned posts.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $ids
|
||||
* @param int $cmode Article::CATCHUP_MODE_*
|
||||
*/
|
||||
static function _catchup_by_id($ids, int $cmode, ?int $owner_uid = null): void {
|
||||
|
||||
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$ids_qmarks = arr_qmarks($ids);
|
||||
|
||||
if ($cmode == Article::CATCHUP_MODE_MARK_AS_UNREAD) {
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
unread = true
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
} else if ($cmode == Article::CATCHUP_MODE_TOGGLE) {
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
unread = NOT unread,last_read = NOW()
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
} else {
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
unread = false,last_read = NOW()
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
}
|
||||
|
||||
$sth->execute([...$ids, $owner_uid]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<int, int|string>>
|
||||
*/
|
||||
static function _get_labels(int $id, ?int $owner_uid = null): array {
|
||||
$rv = array();
|
||||
|
||||
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT label_cache FROM
|
||||
ttrss_user_entries WHERE ref_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$id, $owner_uid]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$label_cache = $row["label_cache"];
|
||||
|
||||
if ($label_cache) {
|
||||
$tmp = json_decode($label_cache, true);
|
||||
|
||||
if (empty($tmp) || ($tmp["no-labels"] ?? 0) == 1)
|
||||
return $rv;
|
||||
else
|
||||
return $tmp;
|
||||
}
|
||||
}
|
||||
|
||||
$sth = $pdo->prepare("SELECT DISTINCT label_id,caption,fg_color,bg_color
|
||||
FROM ttrss_labels2, ttrss_user_labels2
|
||||
WHERE id = label_id
|
||||
AND article_id = ?
|
||||
AND owner_uid = ?
|
||||
ORDER BY caption");
|
||||
$sth->execute([$id, $owner_uid]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
$rk = array(Labels::label_to_feed_id($line["label_id"]),
|
||||
$line["caption"], $line["fg_color"],
|
||||
$line["bg_color"]);
|
||||
array_push($rv, $rk);
|
||||
}
|
||||
|
||||
if (count($rv) > 0)
|
||||
// PHPStan has issues with the shape of $rv for some reason (array vs non-empty-array).
|
||||
// @phpstan-ignore-next-line
|
||||
Labels::update_cache($owner_uid, $id, $rv);
|
||||
else
|
||||
Labels::update_cache($owner_uid, $id, array("no-labels" => 1));
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $enclosures
|
||||
* @param array<string, mixed> $headline
|
||||
*
|
||||
* @return array<int, Article::ARTICLE_KIND_*|string>
|
||||
*/
|
||||
static function _get_image(array $enclosures, string $content, string $site_url, array $headline): array {
|
||||
$article_image = "";
|
||||
$article_stream = "";
|
||||
$article_kind = 0;
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_IMAGE,
|
||||
function ($result, $plugin) use (&$article_image, &$article_stream, &$content) {
|
||||
list ($article_image, $article_stream, $content) = $result;
|
||||
|
||||
// run until first hard match
|
||||
return !empty($article_image);
|
||||
},
|
||||
$enclosures, $content, $site_url, $headline);
|
||||
|
||||
if (!$article_image && !$article_stream) {
|
||||
$tmpdoc = new DOMDocument();
|
||||
|
||||
if (@$tmpdoc->loadHTML('<?xml encoding="UTF-8">' . mb_substr($content, 0, 131070))) {
|
||||
$tmpxpath = new DOMXPath($tmpdoc);
|
||||
$elems = $tmpxpath->query('(//img[@src]|//video[@poster]|//iframe[contains(@src , "youtube.com/embed/")])');
|
||||
|
||||
/** @var DOMElement $e */
|
||||
foreach ($elems as $e) {
|
||||
if ($e->nodeName == "iframe") {
|
||||
$matches = [];
|
||||
if (preg_match("/\/embed\/([\w-]+)/", $e->getAttribute("src"), $matches)) {
|
||||
$article_image = "https://img.youtube.com/vi/" . $matches[1] . "/hqdefault.jpg";
|
||||
$article_stream = "https://youtu.be/" . $matches[1];
|
||||
$article_kind = Article::ARTICLE_KIND_YOUTUBE;
|
||||
break;
|
||||
}
|
||||
} else if ($e->nodeName == "video") {
|
||||
$article_image = $e->getAttribute("poster");
|
||||
|
||||
/** @var DOMElement|null $src */
|
||||
$src = $tmpxpath->query("//source[@src]", $e)->item(0);
|
||||
|
||||
if ($src) {
|
||||
$article_stream = $src->getAttribute("src");
|
||||
$article_kind = Article::ARTICLE_KIND_VIDEO;
|
||||
}
|
||||
|
||||
break;
|
||||
} else if ($e->nodeName == 'img') {
|
||||
if (mb_strpos($e->getAttribute("src"), "data:") !== 0) {
|
||||
$article_image = $e->getAttribute("src");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$article_image)
|
||||
foreach ($enclosures as $enc) {
|
||||
if (str_contains($enc["content_type"], "image/")) {
|
||||
$article_image = $enc["content_url"];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($article_image) {
|
||||
$article_image = UrlHelper::rewrite_relative($site_url, $article_image);
|
||||
|
||||
if (!$article_kind && (count($enclosures) > 1 || (isset($elems) && $elems->length > 1)))
|
||||
$article_kind = Article::ARTICLE_KIND_ALBUM;
|
||||
}
|
||||
|
||||
if ($article_stream)
|
||||
$article_stream = UrlHelper::rewrite_relative($site_url, $article_stream);
|
||||
}
|
||||
|
||||
$cache = DiskCache::instance("images");
|
||||
|
||||
if ($article_image && $cache->exists(sha1($article_image)))
|
||||
$article_image = $cache->get_url(sha1($article_image));
|
||||
|
||||
if ($article_stream && $cache->exists(sha1($article_stream)))
|
||||
$article_stream = $cache->get_url(sha1($article_stream));
|
||||
|
||||
return [$article_image, $article_stream, $article_kind];
|
||||
}
|
||||
|
||||
/**
|
||||
* only cached, returns label ids (not label feed ids)
|
||||
*
|
||||
* @param array<int, int> $article_ids
|
||||
* @return array<int, int>
|
||||
*/
|
||||
static function _labels_of(array $article_ids) {
|
||||
if (count($article_ids) == 0)
|
||||
return [];
|
||||
|
||||
$entries = ORM::for_table('ttrss_entries')
|
||||
->table_alias('e')
|
||||
->select('ue.label_cache')
|
||||
->join('ttrss_user_entries', ['ue.ref_id', '=', 'e.id'], 'ue')
|
||||
->where_in('e.id', $article_ids)
|
||||
->where('ue.owner_uid', $_SESSION['uid'])
|
||||
->find_many();
|
||||
|
||||
$rv = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$labels = json_decode($entry->label_cache);
|
||||
|
||||
if (isset($labels) && is_array($labels)) {
|
||||
foreach ($labels as $label) {
|
||||
if (empty($label["no-labels"]))
|
||||
array_push($rv, Labels::feed_to_label_id($label[0]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($rv);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $article_ids
|
||||
* @return array<int, int>
|
||||
*/
|
||||
static function _feeds_of(array $article_ids) {
|
||||
if (count($article_ids) == 0)
|
||||
return [];
|
||||
|
||||
$entries = ORM::for_table('ttrss_entries')
|
||||
->table_alias('e')
|
||||
->select('ue.feed_id')
|
||||
->join('ttrss_user_entries', ['ue.ref_id', '=', 'e.id'], 'ue')
|
||||
->where_in('e.id', $article_ids)
|
||||
->where('ue.owner_uid', $_SESSION['uid'])
|
||||
->find_many();
|
||||
|
||||
$rv = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
array_push($rv, $entry->feed_id);
|
||||
}
|
||||
|
||||
return array_unique($rv);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
abstract class Auth_Base extends Plugin implements IAuthModule {
|
||||
protected $pdo;
|
||||
|
||||
const AUTH_SERVICE_API = '_api';
|
||||
|
||||
function __construct() {
|
||||
$this->pdo = Db::pdo();
|
||||
}
|
||||
|
||||
function hook_auth_user($login, $password, $service = '') {
|
||||
return $this->authenticate($login, $password, $service);
|
||||
}
|
||||
|
||||
/** Auto-creates specified user if allowed by system configuration.
|
||||
* Can be used instead of find_user_by_login() by external auth modules
|
||||
* @param string $login
|
||||
* @param null|string|false $password
|
||||
* @throws Exception
|
||||
* @throws PDOException
|
||||
*/
|
||||
function auto_create_user(string $login, null|false|string $password = false): ?int {
|
||||
if ($login && Config::get(Config::AUTH_AUTO_CREATE)) {
|
||||
$user_id = UserHelper::find_user_by_login($login);
|
||||
|
||||
if (!$user_id) {
|
||||
|
||||
if (!$password) $password = make_password();
|
||||
|
||||
$user = ORM::for_table('ttrss_users')->create();
|
||||
|
||||
$user->salt = UserHelper::get_salt();
|
||||
$user->login = mb_strtolower($login);
|
||||
$user->pwd_hash = UserHelper::hash_password($password, $user->salt);
|
||||
$user->access_level = 0;
|
||||
$user->created = Db::NOW();
|
||||
$user->save();
|
||||
|
||||
return UserHelper::find_user_by_login($login);
|
||||
|
||||
} else {
|
||||
return $user_id;
|
||||
}
|
||||
}
|
||||
|
||||
return UserHelper::find_user_by_login($login);
|
||||
}
|
||||
|
||||
|
||||
/** replaced with UserHelper::find_user_by_login()
|
||||
* @deprecated
|
||||
*/
|
||||
function find_user_by_login(string $login): ?int {
|
||||
return UserHelper::find_user_by_login($login);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
interface Cache_Adapter {
|
||||
public function set_dir(string $dir) : void;
|
||||
public function get_dir(): string;
|
||||
public function make_dir(): bool;
|
||||
public function is_writable(?string $filename = null): bool;
|
||||
public function exists(string $filename): bool;
|
||||
/**
|
||||
* @return int|false -1 if the file doesn't exist, false if an error occurred, size in bytes otherwise
|
||||
*/
|
||||
public function get_size(string $filename);
|
||||
/**
|
||||
* @return int|false -1 if the file doesn't exist, false if an error occurred, timestamp otherwise
|
||||
*/
|
||||
public function get_mtime(string $filename);
|
||||
/**
|
||||
* @param mixed $data
|
||||
*
|
||||
* @return int|false Bytes written or false if an error occurred.
|
||||
*/
|
||||
public function put(string $filename, $data);
|
||||
public function get(string $filename): ?string;
|
||||
public function get_full_path(string $filename): string;
|
||||
public function remove(string $filename) : bool;
|
||||
/**
|
||||
* @return false|null|string false if detection failed, null if the file doesn't exist, string mime content type otherwise
|
||||
*/
|
||||
public function get_mime_type(string $filename);
|
||||
/**
|
||||
* @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent
|
||||
*/
|
||||
public function send(string $filename);
|
||||
|
||||
/** Catchall function to expire all subfolders/prefixes in the cache, invoked on the backend */
|
||||
public function expire_all(): void;
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
<?php
|
||||
class Cache_Local implements Cache_Adapter {
|
||||
private string $dir;
|
||||
|
||||
public function remove(string $filename): bool {
|
||||
return unlink($this->get_full_path($filename));
|
||||
}
|
||||
|
||||
public function get_mtime(string $filename) {
|
||||
return filemtime($this->get_full_path($filename));
|
||||
}
|
||||
|
||||
public function set_dir(string $dir) : void {
|
||||
$cache_dir = Config::get(Config::CACHE_DIR);
|
||||
|
||||
// use absolute path local to current dir if CACHE_DIR is relative
|
||||
// TODO: maybe add a special method to Config() for this?
|
||||
if ($cache_dir[0] != '/')
|
||||
$cache_dir = dirname(__DIR__) . "/$cache_dir";
|
||||
|
||||
$this->dir = $cache_dir . "/" . basename(clean($dir));
|
||||
|
||||
$this->make_dir();
|
||||
}
|
||||
|
||||
public function get_dir(): string {
|
||||
return $this->dir;
|
||||
}
|
||||
|
||||
public function make_dir(): bool {
|
||||
if (!is_dir($this->dir)) {
|
||||
return mkdir($this->dir);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function is_writable(?string $filename = null): bool {
|
||||
if ($filename) {
|
||||
if (file_exists($this->get_full_path($filename)))
|
||||
return is_writable($this->get_full_path($filename));
|
||||
else
|
||||
return is_writable($this->dir);
|
||||
} else {
|
||||
return is_writable($this->dir);
|
||||
}
|
||||
}
|
||||
|
||||
public function exists(string $filename): bool {
|
||||
return file_exists($this->get_full_path($filename));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|false -1 if the file doesn't exist, false if an error occurred, size in bytes otherwise
|
||||
*/
|
||||
public function get_size(string $filename) {
|
||||
if ($this->exists($filename))
|
||||
return filesize($this->get_full_path($filename));
|
||||
else
|
||||
return -1;
|
||||
}
|
||||
|
||||
public function get_full_path(string $filename): string {
|
||||
return $this->dir . "/" . basename(clean($filename));
|
||||
}
|
||||
|
||||
public function get(string $filename): ?string {
|
||||
if ($this->exists($filename))
|
||||
return file_get_contents($this->get_full_path($filename));
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
*
|
||||
* @return int|false Bytes written or false if an error occurred.
|
||||
*/
|
||||
public function put(string $filename, $data) {
|
||||
return file_put_contents($this->get_full_path($filename), $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false|null|string false if detection failed, null if the file doesn't exist, string mime content type otherwise
|
||||
*/
|
||||
public function get_mime_type(string $filename) {
|
||||
if ($this->exists($filename))
|
||||
return mime_content_type($this->get_full_path($filename));
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent
|
||||
*/
|
||||
public function send(string $filename) {
|
||||
return $this->send_local_file($this->get_full_path($filename));
|
||||
}
|
||||
|
||||
public function expire_all(): void {
|
||||
$dirs = array_filter(glob(Config::get(Config::CACHE_DIR) . "/*"), "is_dir");
|
||||
|
||||
foreach ($dirs as $cache_dir) {
|
||||
$num_deleted = 0;
|
||||
|
||||
if (is_writable($cache_dir) && !file_exists("$cache_dir/.no-auto-expiry")) {
|
||||
$files = glob("$cache_dir/*");
|
||||
|
||||
if ($files) {
|
||||
foreach ($files as $file) {
|
||||
if (time() - filemtime($file) > 86400 * Config::get(Config::CACHE_MAX_DAYS)) {
|
||||
unlink($file);
|
||||
|
||||
++$num_deleted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Debug::log("Expired $cache_dir: removed $num_deleted files.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* this is essentially a wrapper for readfile() which allows plugins to hook
|
||||
* output with httpd-specific "fast" implementation i.e. X-Sendfile or whatever else
|
||||
*
|
||||
* hook function should return true if request was handled (or at least attempted to)
|
||||
*
|
||||
* note that this can be called without user context so the plugin to handle this
|
||||
* should be loaded systemwide in config.php
|
||||
*
|
||||
* @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent
|
||||
*/
|
||||
private function send_local_file(string $filename) {
|
||||
if (file_exists($filename)) {
|
||||
|
||||
if (is_writable($filename) && !$this->exists('.no-auto-expiry')) {
|
||||
touch($filename);
|
||||
}
|
||||
|
||||
$tmppluginhost = new PluginHost();
|
||||
|
||||
$tmppluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_SYSTEM);
|
||||
//$tmppluginhost->load_data();
|
||||
|
||||
if ($tmppluginhost->run_hooks_until(PluginHost::HOOK_SEND_LOCAL_FILE, true, $filename))
|
||||
return true;
|
||||
|
||||
return readfile($filename);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,682 +0,0 @@
|
||||
<?php
|
||||
class Config {
|
||||
private const _ENVVAR_PREFIX = "TTRSS_";
|
||||
|
||||
const T_BOOL = 1;
|
||||
const T_STRING = 2;
|
||||
const T_INT = 3;
|
||||
|
||||
const SCHEMA_VERSION = 151;
|
||||
|
||||
/** override default values, defined below in _DEFAULTS[], prefixing with _ENVVAR_PREFIX:
|
||||
*
|
||||
* DB_TYPE becomes:
|
||||
*
|
||||
* .env (docker environment):
|
||||
*
|
||||
* TTRSS_DB_TYPE=pgsql
|
||||
*
|
||||
* or config.php:
|
||||
*
|
||||
* putenv('TTRSS_DB_HOST=my-patroni.example.com');
|
||||
*
|
||||
* note lack of quotes and spaces before and after "=".
|
||||
*
|
||||
*/
|
||||
|
||||
/** this is kept for backwards/plugin compatibility, the only supported database is PostgreSQL
|
||||
*
|
||||
* @deprecated usages of `Config::get(Config::DB_TYPE)` should be replaced with default (and only) value: `pgsql` or removed
|
||||
*/
|
||||
const DB_TYPE = "DB_TYPE";
|
||||
|
||||
/** database server hostname */
|
||||
const DB_HOST = "DB_HOST";
|
||||
|
||||
/** database user */
|
||||
const DB_USER = "DB_USER";
|
||||
|
||||
/** database name */
|
||||
const DB_NAME = "DB_NAME";
|
||||
|
||||
/** database password */
|
||||
const DB_PASS = "DB_PASS";
|
||||
|
||||
/** database server port */
|
||||
const DB_PORT = "DB_PORT";
|
||||
|
||||
/** PostgreSQL SSL mode (prefer, require, disabled) */
|
||||
const DB_SSLMODE = "DB_SSLMODE";
|
||||
|
||||
/** this is a fallback falue for the CLI SAPI, it should be set to a fully-qualified tt-rss URL */
|
||||
const SELF_URL_PATH = "SELF_URL_PATH";
|
||||
|
||||
/** operate in single user mode, disables all functionality related to
|
||||
* multiple users and authentication. enabling this assumes you have
|
||||
* your tt-rss directory protected by other means (e.g. http auth). */
|
||||
const SINGLE_USER_MODE = "SINGLE_USER_MODE";
|
||||
|
||||
/** use this PHP CLI executable to start various tasks */
|
||||
const PHP_EXECUTABLE = "PHP_EXECUTABLE";
|
||||
|
||||
/** base directory for lockfiles (must be writable) */
|
||||
const LOCK_DIRECTORY = "LOCK_DIRECTORY";
|
||||
|
||||
/** base directory for local cache (must be writable) */
|
||||
const CACHE_DIR = "CACHE_DIR";
|
||||
|
||||
/** auto create users authenticated via external modules */
|
||||
const AUTH_AUTO_CREATE = "AUTH_AUTO_CREATE";
|
||||
|
||||
/** auto log in users authenticated via external modules i.e. auth_remote */
|
||||
const AUTH_AUTO_LOGIN = "AUTH_AUTO_LOGIN";
|
||||
|
||||
/** unconditinally purge all articles older than this amount, in days
|
||||
* overrides user-controlled purge interval */
|
||||
const FORCE_ARTICLE_PURGE = "FORCE_ARTICLE_PURGE";
|
||||
|
||||
/** default lifetime of a session (e.g. login) cookie. In seconds,
|
||||
* 0 means cookie will be deleted when browser closes. */
|
||||
const SESSION_COOKIE_LIFETIME = "SESSION_COOKIE_LIFETIME";
|
||||
|
||||
/** send email using this name */
|
||||
const SMTP_FROM_NAME = "SMTP_FROM_NAME";
|
||||
|
||||
/** send email using this address */
|
||||
const SMTP_FROM_ADDRESS = "SMTP_FROM_ADDRESS";
|
||||
|
||||
/** default subject for email digest */
|
||||
const DIGEST_SUBJECT = "DIGEST_SUBJECT";
|
||||
|
||||
/** enable built-in update checker, both for core code and plugins (using git) */
|
||||
const CHECK_FOR_UPDATES = "CHECK_FOR_UPDATES";
|
||||
|
||||
/** system plugins enabled for all users, comma separated list, no quotes
|
||||
* keep at least one auth module in there (i.e. auth_internal) */
|
||||
const PLUGINS = "PLUGINS";
|
||||
|
||||
/** available options: sql (default, event log), syslog, stdout (for debugging) */
|
||||
const LOG_DESTINATION = "LOG_DESTINATION";
|
||||
|
||||
/** link this stylesheet on all pages (if it exists), should be placed in themes.local */
|
||||
const LOCAL_OVERRIDE_STYLESHEET = "LOCAL_OVERRIDE_STYLESHEET";
|
||||
|
||||
/** same but this javascript file (you can use that for polyfills), should be placed in themes.local */
|
||||
const LOCAL_OVERRIDE_JS = "LOCAL_OVERRIDE_JS";
|
||||
|
||||
/** in seconds, terminate update tasks that ran longer than this interval */
|
||||
const DAEMON_MAX_CHILD_RUNTIME = "DAEMON_MAX_CHILD_RUNTIME";
|
||||
|
||||
/** max concurrent update jobs forking update daemon starts */
|
||||
const DAEMON_MAX_JOBS = "DAEMON_MAX_JOBS";
|
||||
|
||||
/** log level for update daemon */
|
||||
const DAEMON_LOG_LEVEL = "DAEMON_LOG_LEVEL";
|
||||
|
||||
/** How long to wait for response when requesting feed from a site (seconds) */
|
||||
const FEED_FETCH_TIMEOUT = "FEED_FETCH_TIMEOUT";
|
||||
|
||||
/** How long to wait for response when requesting uncached feed from a site (seconds) */
|
||||
const FEED_FETCH_NO_CACHE_TIMEOUT = "FEED_FETCH_NO_CACHE_TIMEOUT";
|
||||
|
||||
/** Default timeout when fetching files from remote sites */
|
||||
const FILE_FETCH_TIMEOUT = "FILE_FETCH_TIMEOUT";
|
||||
|
||||
/** How long to wait for initial response from website when fetching remote files */
|
||||
const FILE_FETCH_CONNECT_TIMEOUT = "FILE_FETCH_CONNECT_TIMEOUT";
|
||||
|
||||
/** stop updating feeds if user haven't logged in for X days */
|
||||
const DAEMON_UPDATE_LOGIN_LIMIT = "DAEMON_UPDATE_LOGIN_LIMIT";
|
||||
|
||||
/** how many feeds to update in one batch */
|
||||
const DAEMON_FEED_LIMIT = "DAEMON_FEED_LIMIT";
|
||||
|
||||
/** default sleep interval between feed updates (sec) */
|
||||
const DAEMON_SLEEP_INTERVAL = "DAEMON_SLEEP_INTERVAL";
|
||||
|
||||
/** do not cache files larger than that (bytes) */
|
||||
const MAX_CACHE_FILE_SIZE = "MAX_CACHE_FILE_SIZE";
|
||||
|
||||
/** do not download files larger than that (bytes) */
|
||||
const MAX_DOWNLOAD_FILE_SIZE = "MAX_DOWNLOAD_FILE_SIZE";
|
||||
|
||||
/** max file size for downloaded favicons (bytes) */
|
||||
const MAX_FAVICON_FILE_SIZE = "MAX_FAVICON_FILE_SIZE";
|
||||
|
||||
/** max age in days for various automatically cached (temporary) files */
|
||||
const CACHE_MAX_DAYS = "CACHE_MAX_DAYS";
|
||||
|
||||
/** max interval between forced unconditional updates for servers
|
||||
* not complying with http if-modified-since (seconds) */
|
||||
const MAX_CONDITIONAL_INTERVAL = "MAX_CONDITIONAL_INTERVAL";
|
||||
|
||||
/** automatically disable updates for feeds which failed to
|
||||
* update for this amount of days; 0 disables */
|
||||
const DAEMON_UNSUCCESSFUL_DAYS_LIMIT = "DAEMON_UNSUCCESSFUL_DAYS_LIMIT";
|
||||
|
||||
/** log all sent emails in the event log */
|
||||
const LOG_SENT_MAIL = "LOG_SENT_MAIL";
|
||||
|
||||
/** use HTTP proxy for requests */
|
||||
const HTTP_PROXY = "HTTP_PROXY";
|
||||
|
||||
/** prevent users from changing passwords */
|
||||
const FORBID_PASSWORD_CHANGES = "FORBID_PASSWORD_CHANGES";
|
||||
|
||||
/** default session cookie name */
|
||||
const SESSION_NAME = "SESSION_NAME";
|
||||
|
||||
/** enable plugin update checker (using git) */
|
||||
const CHECK_FOR_PLUGIN_UPDATES = "CHECK_FOR_PLUGIN_UPDATES";
|
||||
|
||||
/** allow installing first party plugins using plugin installer in prefs */
|
||||
const ENABLE_PLUGIN_INSTALLER = "ENABLE_PLUGIN_INSTALLER";
|
||||
|
||||
/** minimum amount of seconds required between authentication attempts */
|
||||
const AUTH_MIN_INTERVAL = "AUTH_MIN_INTERVAL";
|
||||
|
||||
/** http user agent (changing this is not recommended) */
|
||||
const HTTP_USER_AGENT = "HTTP_USER_AGENT";
|
||||
|
||||
/** delay updates for this feed if received HTTP 429 (Too Many Requests) for this amount of seconds (base value, actual delay is base...base*2) */
|
||||
const HTTP_429_THROTTLE_INTERVAL = "HTTP_429_THROTTLE_INTERVAL";
|
||||
|
||||
/** disables login form controls except HOOK_LOGINFORM_ADDITIONAL_BUTTONS (for SSO providers), also prevents logging in through auth_internal */
|
||||
const DISABLE_LOGIN_FORM = "DISABLE_LOGIN_FORM";
|
||||
|
||||
/** optional key to transparently encrypt sensitive data (currently limited to sessions and feed passwords),
|
||||
* key is a 32 byte hex string which may be generated using `update.php --gen-encryption-key` */
|
||||
const ENCRYPTION_KEY = "ENCRYPTION_KEY";
|
||||
|
||||
/** scheduled task to purge orphaned articles, value should be valid cron expression
|
||||
* @see https://github.com/dragonmantank/cron-expression/blob/master/README.md#cron-expressions
|
||||
*/
|
||||
const SCHEDULE_PURGE_ORPHANS = "SCHEDULE_PURGE_ORPHANS";
|
||||
|
||||
/** scheduled task to expire disk cache, value should be valid cron expression */
|
||||
const SCHEDULE_DISK_CACHE_EXPIRE_ALL = "SCHEDULE_DISK_CACHE_EXPIRE_ALL";
|
||||
|
||||
/** scheduled task, value should be valid cron expression */
|
||||
const SCHEDULE_DISABLE_FAILED_FEEDS = "SCHEDULE_DISABLE_FAILED_FEEDS";
|
||||
|
||||
/** scheduled task to cleanup feed icons, value should be valid cron expression */
|
||||
const SCHEDULE_CLEANUP_FEED_ICONS = "SCHEDULE_CLEANUP_FEED_ICONS";
|
||||
|
||||
/** scheduled task to disable feed updates of inactive users, value should be valid cron expression */
|
||||
const SCHEDULE_LOG_DAEMON_UPDATE_LOGIN_LIMIT_USERS = "SCHEDULE_LOG_DAEMON_UPDATE_LOGIN_LIMIT_USERS";
|
||||
|
||||
/** scheduled task to cleanup error log, value should be valid cron expression */
|
||||
const SCHEDULE_EXPIRE_ERROR_LOG = "SCHEDULE_EXPIRE_ERROR_LOG";
|
||||
|
||||
/** scheduled task to cleanup update daemon lock files, value should be valid cron expression */
|
||||
const SCHEDULE_EXPIRE_LOCK_FILES = "SCHEDULE_EXPIRE_LOCK_FILES";
|
||||
|
||||
/** scheduled task to send digests, value should be valid cron expression */
|
||||
const SCHEDULE_SEND_HEADLINES_DIGESTS = "SCHEDULE_SEND_HEADLINES_DIGESTS";
|
||||
|
||||
/** default (fallback) light theme path */
|
||||
const DEFAULT_LIGHT_THEME = "DEFAULT_LIGHT_THEME";
|
||||
|
||||
/** default (fallback) dark (night) theme path */
|
||||
const DEFAULT_DARK_THEME = "DEFAULT_DARK_THEME";
|
||||
|
||||
/** default values for all global configuration options */
|
||||
private const _DEFAULTS = [
|
||||
Config::DB_TYPE => [ "pgsql", Config::T_STRING ],
|
||||
Config::DB_HOST => [ "db", Config::T_STRING ],
|
||||
Config::DB_USER => [ "", Config::T_STRING ],
|
||||
Config::DB_NAME => [ "", Config::T_STRING ],
|
||||
Config::DB_PASS => [ "", Config::T_STRING ],
|
||||
Config::DB_PORT => [ "5432", Config::T_STRING ],
|
||||
Config::DB_SSLMODE => [ "prefer", Config::T_STRING ],
|
||||
Config::SELF_URL_PATH => [ "https://example.com/tt-rss", Config::T_STRING ],
|
||||
Config::SINGLE_USER_MODE => [ "", Config::T_BOOL ],
|
||||
Config::PHP_EXECUTABLE => [ "/usr/bin/php", Config::T_STRING ],
|
||||
Config::LOCK_DIRECTORY => [ "lock", Config::T_STRING ],
|
||||
Config::CACHE_DIR => [ "cache", Config::T_STRING ],
|
||||
Config::AUTH_AUTO_CREATE => [ "true", Config::T_BOOL ],
|
||||
Config::AUTH_AUTO_LOGIN => [ "true", Config::T_BOOL ],
|
||||
Config::FORCE_ARTICLE_PURGE => [ 0, Config::T_INT ],
|
||||
Config::SESSION_COOKIE_LIFETIME => [ 86400, Config::T_INT ],
|
||||
Config::SMTP_FROM_NAME => [ "Tiny Tiny RSS", Config::T_STRING ],
|
||||
Config::SMTP_FROM_ADDRESS => [ "noreply@localhost", Config::T_STRING ],
|
||||
Config::DIGEST_SUBJECT => [ "[tt-rss] New headlines for last 24 hours",
|
||||
Config::T_STRING ],
|
||||
Config::CHECK_FOR_UPDATES => [ "true", Config::T_BOOL ],
|
||||
Config::PLUGINS => [ "auth_internal", Config::T_STRING ],
|
||||
Config::LOG_DESTINATION => [ Logger::LOG_DEST_SQL, Config::T_STRING ],
|
||||
Config::LOCAL_OVERRIDE_STYLESHEET => [ "local-overrides.css",
|
||||
Config::T_STRING ],
|
||||
Config::LOCAL_OVERRIDE_JS => [ "local-overrides.js",
|
||||
Config::T_STRING ],
|
||||
Config::DAEMON_MAX_CHILD_RUNTIME => [ 1800, Config::T_INT ],
|
||||
Config::DAEMON_MAX_JOBS => [ 2, Config::T_INT ],
|
||||
Config::DAEMON_LOG_LEVEL => [ Debug::LOG_NORMAL, Config::T_INT ],
|
||||
Config::FEED_FETCH_TIMEOUT => [ 45, Config::T_INT ],
|
||||
Config::FEED_FETCH_NO_CACHE_TIMEOUT => [ 15, Config::T_INT ],
|
||||
Config::FILE_FETCH_TIMEOUT => [ 45, Config::T_INT ],
|
||||
Config::FILE_FETCH_CONNECT_TIMEOUT => [ 15, Config::T_INT ],
|
||||
Config::DAEMON_UPDATE_LOGIN_LIMIT => [ 30, Config::T_INT ],
|
||||
Config::DAEMON_FEED_LIMIT => [ 50, Config::T_INT ],
|
||||
Config::DAEMON_SLEEP_INTERVAL => [ 120, Config::T_INT ],
|
||||
Config::MAX_CACHE_FILE_SIZE => [ 64*1024*1024, Config::T_INT ],
|
||||
Config::MAX_DOWNLOAD_FILE_SIZE => [ 16*1024*1024, Config::T_INT ],
|
||||
Config::MAX_FAVICON_FILE_SIZE => [ 1*1024*1024, Config::T_INT ],
|
||||
Config::CACHE_MAX_DAYS => [ 7, Config::T_INT ],
|
||||
Config::MAX_CONDITIONAL_INTERVAL => [ 3600*12, Config::T_INT ],
|
||||
Config::DAEMON_UNSUCCESSFUL_DAYS_LIMIT => [ 30, Config::T_INT ],
|
||||
Config::LOG_SENT_MAIL => [ "", Config::T_BOOL ],
|
||||
Config::HTTP_PROXY => [ "", Config::T_STRING ],
|
||||
Config::FORBID_PASSWORD_CHANGES => [ "", Config::T_BOOL ],
|
||||
Config::SESSION_NAME => [ "ttrss_sid", Config::T_STRING ],
|
||||
Config::CHECK_FOR_PLUGIN_UPDATES => [ "true", Config::T_BOOL ],
|
||||
Config::ENABLE_PLUGIN_INSTALLER => [ "true", Config::T_BOOL ],
|
||||
Config::AUTH_MIN_INTERVAL => [ 5, Config::T_INT ],
|
||||
Config::HTTP_USER_AGENT => [ 'Tiny Tiny RSS/%s (https://github.com/tt-rss/tt-rss)',
|
||||
Config::T_STRING ],
|
||||
Config::HTTP_429_THROTTLE_INTERVAL => [ 3600, Config::T_INT ],
|
||||
Config::DISABLE_LOGIN_FORM => [ "", Config::T_BOOL ],
|
||||
Config::ENCRYPTION_KEY => [ "", Config::T_STRING ],
|
||||
Config::SCHEDULE_PURGE_ORPHANS => ["@daily", Config::T_STRING],
|
||||
Config::SCHEDULE_DISK_CACHE_EXPIRE_ALL => ["@daily", Config::T_STRING],
|
||||
Config::SCHEDULE_DISABLE_FAILED_FEEDS => ["@daily", Config::T_STRING],
|
||||
Config::SCHEDULE_CLEANUP_FEED_ICONS => ["@daily", Config::T_STRING],
|
||||
Config::SCHEDULE_LOG_DAEMON_UPDATE_LOGIN_LIMIT_USERS =>
|
||||
["@daily", Config::T_STRING],
|
||||
Config::SCHEDULE_EXPIRE_ERROR_LOG => ["@hourly", Config::T_STRING],
|
||||
Config::SCHEDULE_EXPIRE_LOCK_FILES => ["@hourly", Config::T_STRING],
|
||||
Config::SCHEDULE_SEND_HEADLINES_DIGESTS => ["@hourly", Config::T_STRING],
|
||||
Config::DEFAULT_LIGHT_THEME => [ "light.css", Config::T_STRING],
|
||||
Config::DEFAULT_DARK_THEME => [ "night.css", Config::T_STRING],
|
||||
];
|
||||
|
||||
private static ?Config $instance = null;
|
||||
|
||||
/** @var array<string, array<bool|int|string>> */
|
||||
private array $params = [];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private array $version = [];
|
||||
|
||||
private Db_Migrations $migrations;
|
||||
|
||||
public static function get_instance() : Config {
|
||||
if (self::$instance == null)
|
||||
self::$instance = new self();
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
private function __clone() {
|
||||
//
|
||||
}
|
||||
|
||||
function __construct() {
|
||||
$ref = new ReflectionClass(get_class($this));
|
||||
|
||||
foreach ($ref->getConstants() as $const => $cvalue) {
|
||||
if (isset(self::_DEFAULTS[$const])) {
|
||||
$override = getenv(self::_ENVVAR_PREFIX . $const);
|
||||
|
||||
list ($defval, $deftype) = self::_DEFAULTS[$const];
|
||||
|
||||
$this->params[$cvalue] = [ self::cast_to($override !== false ? $override : $defval, $deftype), $deftype ];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** determine tt-rss version (using git)
|
||||
*
|
||||
* package maintainers who don't use git: if version_static.txt exists in tt-rss root
|
||||
* directory, its contents are displayed instead of git commit-based version, this could be generated
|
||||
* based on source git tree commit used when creating the package
|
||||
* @return array<string, mixed>|string
|
||||
*/
|
||||
static function get_version(bool $as_string = true): array|string {
|
||||
return self::get_instance()->_get_version($as_string);
|
||||
}
|
||||
|
||||
// returns version showing (if possible) full timestamp of commit id
|
||||
static function get_version_html() : string {
|
||||
$version = self::get_version(false);
|
||||
|
||||
return sprintf("<span title=\"%s\n%s\n%s\">%s</span>",
|
||||
date("Y-m-d H:i:s", ($version['timestamp'] ?? 0)),
|
||||
$version['commit'] ?? '',
|
||||
$version['branch'] ?? '',
|
||||
$version['version']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|string
|
||||
*/
|
||||
private function _get_version(bool $as_string = true): array|string {
|
||||
$root_dir = self::get_self_dir();
|
||||
|
||||
if (empty($this->version)) {
|
||||
$this->version["status"] = -1;
|
||||
|
||||
if (getenv("CI_COMMIT_SHORT_SHA") && getenv("CI_COMMIT_TIMESTAMP")) {
|
||||
|
||||
$this->version["branch"] = getenv("CI_COMMIT_BRANCH");
|
||||
$this->version["timestamp"] = strtotime(getenv("CI_COMMIT_TIMESTAMP"));
|
||||
$this->version["version"] = sprintf("%s-%s", date("y.m", $this->version["timestamp"]), getenv("CI_COMMIT_SHORT_SHA"));
|
||||
$this->version["commit"] = getenv("CI_COMMIT_SHORT_SHA");
|
||||
$this->version["status"] = 0;
|
||||
|
||||
} else if (PHP_OS === "Darwin") {
|
||||
$this->version["version"] = "UNKNOWN (Unsupported, Darwin)";
|
||||
} else if (file_exists("$root_dir/version_static.txt")) {
|
||||
$this->version["version"] = trim(file_get_contents("$root_dir/version_static.txt")) . " (Unsupported)";
|
||||
} else if (ini_get("open_basedir")) {
|
||||
$this->version["version"] = "UNKNOWN (Unsupported, open_basedir)";
|
||||
} else if (is_dir("$root_dir/.git")) {
|
||||
$this->version = self::get_version_from_git($root_dir);
|
||||
|
||||
if ($this->version["status"] != 0) {
|
||||
user_error("Unable to determine version: " . $this->version["version"], E_USER_WARNING);
|
||||
|
||||
$this->version["version"] = "UNKNOWN (Unsupported, Git error)";
|
||||
} else if (!getenv("SCRIPT_ROOT") || !file_exists("/.dockerenv")) {
|
||||
$this->version["version"] .= " (Unsupported)";
|
||||
}
|
||||
|
||||
} else {
|
||||
$this->version["version"] = "UNKNOWN (Unsupported)";
|
||||
}
|
||||
}
|
||||
|
||||
return $as_string ? $this->version["version"] : $this->version;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int|string>
|
||||
*/
|
||||
static function get_version_from_git(string $dir): array {
|
||||
$descriptorspec = [
|
||||
1 => ["pipe", "w"], // STDOUT
|
||||
2 => ["pipe", "w"], // STDERR
|
||||
];
|
||||
|
||||
$rv = [
|
||||
"status" => -1,
|
||||
"version" => "",
|
||||
"branch" => "",
|
||||
"commit" => "",
|
||||
"timestamp" => 0,
|
||||
];
|
||||
|
||||
$proc = proc_open("git --no-pager log --pretty=\"version-%ct-%h\" -n1 HEAD",
|
||||
$descriptorspec, $pipes, $dir);
|
||||
|
||||
if (is_resource($proc)) {
|
||||
$stdout = trim(stream_get_contents($pipes[1]));
|
||||
$stderr = trim(stream_get_contents($pipes[2]));
|
||||
$status = proc_close($proc);
|
||||
|
||||
$rv["status"] = $status;
|
||||
|
||||
list($check, $timestamp, $commit) = explode("-", $stdout);
|
||||
|
||||
if ($check == "version") {
|
||||
|
||||
$rv["version"] = sprintf("%s-%s", date("y.m", (int)$timestamp), $commit);
|
||||
$rv["commit"] = $commit;
|
||||
$rv["timestamp"] = $timestamp;
|
||||
|
||||
// proc_close() may return -1 even if command completed successfully
|
||||
// so if it looks like we got valid data, we ignore it
|
||||
|
||||
if ($rv["status"] == -1)
|
||||
$rv["status"] = 0;
|
||||
|
||||
} else {
|
||||
$rv["version"] = T_sprintf("Git error [RC=%d]: %s", $status, $stderr);
|
||||
}
|
||||
}
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
static function get_migrations() : Db_Migrations {
|
||||
return self::get_instance()->_get_migrations();
|
||||
}
|
||||
|
||||
private function _get_migrations() : Db_Migrations {
|
||||
if (empty($this->migrations)) {
|
||||
$this->migrations = new Db_Migrations();
|
||||
$this->migrations->initialize(self::get_self_dir() . "/sql", "ttrss_version", true, self::SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
return $this->migrations;
|
||||
}
|
||||
|
||||
static function is_migration_needed() : bool {
|
||||
return self::get_migrations()->is_migration_needed();
|
||||
}
|
||||
|
||||
static function get_schema_version() : int {
|
||||
return self::get_migrations()->get_version();
|
||||
}
|
||||
|
||||
static function cast_to(string $value, int $type_hint): bool|int|string {
|
||||
return match ($type_hint) {
|
||||
self::T_BOOL => sql_bool_to_bool($value),
|
||||
self::T_INT => (int) $value,
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
|
||||
private function _get(string $param): bool|int|string {
|
||||
list ($value, $type_hint) = $this->params[$param];
|
||||
|
||||
return $this->cast_to($value, $type_hint);
|
||||
}
|
||||
|
||||
private function _add(string $param, string $default, int $type_hint): void {
|
||||
$override = getenv(self::_ENVVAR_PREFIX . $param);
|
||||
|
||||
$this->params[$param] = [ self::cast_to($override !== false ? $override : $default, $type_hint), $type_hint ];
|
||||
}
|
||||
|
||||
static function add(string $param, string $default, int $type_hint = Config::T_STRING): void {
|
||||
$instance = self::get_instance();
|
||||
|
||||
$instance->_add($param, $default, $type_hint);
|
||||
}
|
||||
|
||||
static function get(string $param): bool|int|string {
|
||||
$instance = self::get_instance();
|
||||
|
||||
return $instance->_get($param);
|
||||
}
|
||||
|
||||
static function is_server_https() : bool {
|
||||
return (!empty($_SERVER['HTTPS']) && ($_SERVER['HTTPS'] != 'off')) ||
|
||||
(!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https');
|
||||
}
|
||||
|
||||
/** returns fully-qualified external URL to tt-rss (no trailing slash)
|
||||
* SELF_URL_PATH configuration variable is used as a fallback for the CLI SAPI
|
||||
* */
|
||||
static function get_self_url(bool $always_detect = false) : string {
|
||||
if (!$always_detect && php_sapi_name() == "cli") {
|
||||
return self::get(Config::SELF_URL_PATH);
|
||||
} else {
|
||||
$proto = self::is_server_https() ? 'https' : 'http';
|
||||
|
||||
$self_url_path = $proto . '://' . $_SERVER["HTTP_HOST"] . parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
|
||||
$self_url_path = preg_replace("/(\/api\/{1,})?(\w+\.php)?(\?.*$)?$/", "", $self_url_path);
|
||||
$self_url_path = preg_replace("/(\/plugins(.local)?)\/.{1,}$/", "", $self_url_path);
|
||||
|
||||
return rtrim($self_url_path, "/");
|
||||
}
|
||||
}
|
||||
/* sanity check stuff */
|
||||
|
||||
static function sanity_check(): void {
|
||||
|
||||
/*
|
||||
we don't actually need the DB object right now but some checks below might use ORM which won't be initialized
|
||||
because it is set up in the Db constructor, which is why it's a good idea to invoke it as early as possible
|
||||
|
||||
it is a bit of a hack, maybe ORM should be initialized somewhere else (functions.php?)
|
||||
*/
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$errors = [];
|
||||
|
||||
if (!str_contains(self::get(Config::PLUGINS), "auth_")) {
|
||||
array_push($errors, "Please enable at least one authentication module via PLUGINS");
|
||||
}
|
||||
|
||||
/* we assume our dependencies are sane under docker, so some sanity checks are skipped.
|
||||
this also allows tt-rss process to run under root if requested (I'm using this for development
|
||||
under podman because of uidmapping issues with rootless containers, don't use in production -fox) */
|
||||
if (!getenv("container")) {
|
||||
if (function_exists('posix_getuid') && posix_getuid() == 0) {
|
||||
array_push($errors, "Please don't run this script as root.");
|
||||
}
|
||||
|
||||
if (version_compare(PHP_VERSION, '8.2.0', '<')) {
|
||||
array_push($errors, "PHP version 8.2.0 or newer required. You're using " . PHP_VERSION . ".");
|
||||
}
|
||||
|
||||
if (!class_exists("UConverter")) {
|
||||
array_push($errors, "PHP UConverter class is missing, it's provided by the Internationalization (intl) module.");
|
||||
}
|
||||
|
||||
if (!function_exists("curl_init") && !ini_get("allow_url_fopen")) {
|
||||
array_push($errors, "PHP configuration option allow_url_fopen is disabled, and CURL functions are not present. Either enable allow_url_fopen or install PHP extension for CURL.");
|
||||
}
|
||||
|
||||
if (!function_exists("json_encode")) {
|
||||
array_push($errors, "PHP support for JSON is required, but was not found.");
|
||||
}
|
||||
|
||||
if (!function_exists("flock")) {
|
||||
array_push($errors, "PHP support for flock() function is required.");
|
||||
}
|
||||
|
||||
if (!class_exists("PDO")) {
|
||||
array_push($errors, "PHP support for PDO is required but was not found.");
|
||||
}
|
||||
|
||||
if (!function_exists("mb_strlen")) {
|
||||
array_push($errors, "PHP support for mbstring functions is required but was not found.");
|
||||
}
|
||||
|
||||
if (!function_exists("hash")) {
|
||||
array_push($errors, "PHP support for hash() function is required but was not found.");
|
||||
}
|
||||
|
||||
if (ini_get("safe_mode")) {
|
||||
array_push($errors, "PHP safe mode setting is obsolete and not supported by tt-rss.");
|
||||
}
|
||||
|
||||
if (!function_exists("mime_content_type")) {
|
||||
array_push($errors, "PHP function mime_content_type() is missing, try enabling fileinfo module.");
|
||||
}
|
||||
|
||||
if (!class_exists("DOMDocument")) {
|
||||
array_push($errors, "PHP support for DOMDocument is required, but was not found.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_writable(self::get(Config::CACHE_DIR) . "/images")) {
|
||||
array_push($errors, "Image cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/images)");
|
||||
}
|
||||
|
||||
if (!is_writable(self::get(Config::CACHE_DIR) . "/upload")) {
|
||||
array_push($errors, "Upload cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/upload)");
|
||||
}
|
||||
|
||||
if (!is_writable(self::get(Config::CACHE_DIR) . "/export")) {
|
||||
array_push($errors, "Data export cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/export)");
|
||||
}
|
||||
|
||||
if (!is_writable(self::get(Config::LOCK_DIRECTORY))) {
|
||||
array_push($errors, "LOCK_DIRECTORY is not writable (chmod -R 777 ".self::get(Config::LOCK_DIRECTORY).").\n");
|
||||
}
|
||||
|
||||
// ttrss_users won't be there on initial startup (before migrations are done)
|
||||
if (!Config::is_migration_needed() && self::get(Config::SINGLE_USER_MODE)) {
|
||||
if (UserHelper::get_login_by_id(1) != "admin") {
|
||||
array_push($errors, "SINGLE_USER_MODE is enabled but default admin account (ID: 1) is not found.");
|
||||
}
|
||||
}
|
||||
|
||||
// skip check for CLI scripts so that we could install database schema if it is missing.
|
||||
if (php_sapi_name() != "cli") {
|
||||
if (self::get_schema_version() < 0) {
|
||||
array_push($errors, "Base database schema is missing. Either load it manually or perform a migration (<code>update.php --update-schema</code>)");
|
||||
}
|
||||
}
|
||||
|
||||
if (count($errors) > 0 && php_sapi_name() != "cli") {
|
||||
http_response_code(503); ?>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Startup failed</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<link rel="stylesheet" type="text/css" href="themes/light.css">
|
||||
</head>
|
||||
<body class="sanity_failed flat ttrss_utility">
|
||||
<div class="content">
|
||||
<h1>Startup failed</h1>
|
||||
|
||||
<p>Please fix errors indicated by the following messages:</p>
|
||||
|
||||
<?php foreach ($errors as $error) { echo self::format_error($error); } ?>
|
||||
|
||||
<p>You might want to check the tt-rss <a target="_blank" href="https://github.com/tt-rss/tt-rss/wiki">wiki</a> or
|
||||
<a target="_blank" href="https://github.com/tt-rss/tt-rss/discussions">discussions</a> for more information.
|
||||
Please search before creating a new topic for your question.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<?php
|
||||
die;
|
||||
} else if (count($errors) > 0) {
|
||||
echo "Please fix errors indicated by the following messages:\n\n";
|
||||
|
||||
foreach ($errors as $error) {
|
||||
echo " * " . strip_tags($error)."\n";
|
||||
}
|
||||
|
||||
echo "\nYou might want to check the tt-rss wiki or forums for more information.\n";
|
||||
echo "Please search the forums before creating a new topic for your question.\n";
|
||||
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static function format_error(string $msg): string {
|
||||
return "<div class=\"alert alert-danger\">$msg</div>";
|
||||
}
|
||||
|
||||
static function get_override_links(): string {
|
||||
$rv = "";
|
||||
|
||||
$local_css = get_theme_path(self::get(self::LOCAL_OVERRIDE_STYLESHEET));
|
||||
if ($local_css) $rv .= stylesheet_tag($local_css);
|
||||
|
||||
$local_js = get_theme_path(self::get(self::LOCAL_OVERRIDE_JS));
|
||||
if ($local_js) $rv .= javascript_tag($local_js);
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
static function get_user_agent(): string {
|
||||
return sprintf(self::get(self::HTTP_USER_AGENT), self::get_version());
|
||||
}
|
||||
|
||||
static function get_self_dir() : string {
|
||||
return dirname(__DIR__); # we're in classes/Config.php
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
<?php
|
||||
class Counters {
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, int|string>>
|
||||
*/
|
||||
static function get_all(): array {
|
||||
return [
|
||||
...self::get_global(),
|
||||
...self::get_virt(),
|
||||
...self::get_labels(),
|
||||
...self::get_feeds(),
|
||||
...self::get_cats(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $feed_ids
|
||||
* @param array<int>|null $label_ids
|
||||
* @return array<int, array<string, int|string>>
|
||||
*/
|
||||
static function get_conditional(?array $feed_ids = null, ?array $label_ids = null): array {
|
||||
return [
|
||||
...self::get_global(),
|
||||
...self::get_virt(),
|
||||
...self::get_labels($label_ids),
|
||||
...self::get_feeds($feed_ids),
|
||||
...self::get_cats(is_array($feed_ids) ? Feeds::_cats_of($feed_ids, $_SESSION["uid"], true) : null)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
static private function get_cat_children(int $cat_id, int $owner_uid): array {
|
||||
$unread = 0;
|
||||
$marked = 0;
|
||||
$published = 0;
|
||||
|
||||
$cats = ORM::for_table('ttrss_feed_categories')
|
||||
->where('owner_uid', $owner_uid)
|
||||
->where('parent_cat', $cat_id)
|
||||
->find_many();
|
||||
|
||||
foreach ($cats as $cat) {
|
||||
list ($tmp_unread, $tmp_marked, $tmp_published) = self::get_cat_children($cat->id, $owner_uid);
|
||||
|
||||
$unread += $tmp_unread + Feeds::_get_cat_unread($cat->id, $owner_uid);
|
||||
$marked += $tmp_marked + Feeds::_get_cat_marked($cat->id, $owner_uid);
|
||||
$published += $tmp_published + Feeds::_get_cat_published($cat->id, $owner_uid);
|
||||
}
|
||||
|
||||
return [$unread, $marked, $published];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $cat_ids
|
||||
* @return array<int, array<string, int|string>>
|
||||
*/
|
||||
private static function get_cats(?array $cat_ids = null): array {
|
||||
$ret = [];
|
||||
|
||||
/* Labels category */
|
||||
|
||||
$cv = array("id" => Feeds::CATEGORY_LABELS, "kind" => "cat",
|
||||
"counter" => Feeds::_get_cat_unread(Feeds::CATEGORY_LABELS));
|
||||
|
||||
array_push($ret, $cv);
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
if (is_array($cat_ids)) {
|
||||
if (count($cat_ids) == 0)
|
||||
return [];
|
||||
|
||||
$cat_ids_qmarks = arr_qmarks($cat_ids);
|
||||
|
||||
$sth = $pdo->prepare("SELECT fc.id,
|
||||
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
|
||||
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
|
||||
SUM(CASE WHEN published THEN 1 ELSE 0 END) AS count_published,
|
||||
(SELECT COUNT(id) FROM ttrss_feed_categories fcc
|
||||
WHERE fcc.parent_cat = fc.id) AS num_children
|
||||
FROM ttrss_feed_categories fc
|
||||
LEFT JOIN ttrss_feeds f ON (f.cat_id = fc.id)
|
||||
LEFT JOIN ttrss_user_entries ue ON (ue.feed_id = f.id)
|
||||
WHERE fc.owner_uid = ? AND fc.id IN ($cat_ids_qmarks)
|
||||
GROUP BY fc.id
|
||||
UNION
|
||||
SELECT 0,
|
||||
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
|
||||
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
|
||||
SUM(CASE WHEN published THEN 1 ELSE 0 END) AS count_published,
|
||||
0
|
||||
FROM ttrss_feeds f, ttrss_user_entries ue
|
||||
WHERE f.cat_id IS NULL AND
|
||||
ue.feed_id = f.id AND
|
||||
ue.owner_uid = ?");
|
||||
|
||||
$sth->execute([$_SESSION['uid'], ...$cat_ids, $_SESSION['uid']]);
|
||||
|
||||
} else {
|
||||
$sth = $pdo->prepare("SELECT fc.id,
|
||||
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
|
||||
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
|
||||
SUM(CASE WHEN published THEN 1 ELSE 0 END) AS count_published,
|
||||
(SELECT COUNT(id) FROM ttrss_feed_categories fcc
|
||||
WHERE fcc.parent_cat = fc.id) AS num_children
|
||||
FROM ttrss_feed_categories fc
|
||||
LEFT JOIN ttrss_feeds f ON (f.cat_id = fc.id)
|
||||
LEFT JOIN ttrss_user_entries ue ON (ue.feed_id = f.id)
|
||||
WHERE fc.owner_uid = :uid
|
||||
GROUP BY fc.id
|
||||
UNION
|
||||
SELECT 0,
|
||||
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
|
||||
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
|
||||
SUM(CASE WHEN published THEN 1 ELSE 0 END) AS count_published,
|
||||
0
|
||||
FROM ttrss_feeds f, ttrss_user_entries ue
|
||||
WHERE f.cat_id IS NULL AND
|
||||
ue.feed_id = f.id AND
|
||||
ue.owner_uid = :uid");
|
||||
|
||||
$sth->execute(["uid" => $_SESSION['uid']]);
|
||||
}
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
if ($line["num_children"] > 0) {
|
||||
list ($child_counter, $child_marked_counter, $child_published_counter) = self::get_cat_children($line["id"], $_SESSION["uid"]);
|
||||
} else {
|
||||
$child_counter = 0;
|
||||
$child_marked_counter = 0;
|
||||
$child_published_counter = 0;
|
||||
}
|
||||
|
||||
$cv = [
|
||||
"id" => (int)$line["id"],
|
||||
"kind" => "cat",
|
||||
"markedcounter" => (int) $line["count_marked"] + $child_marked_counter,
|
||||
"publishedcounter" => (int) $line["count_published"] + $child_published_counter,
|
||||
"counter" => (int) $line["count"] + $child_counter
|
||||
];
|
||||
|
||||
array_push($ret, $cv);
|
||||
}
|
||||
|
||||
return $ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $feed_ids
|
||||
* @return array<int, array<string, int|string>>
|
||||
*/
|
||||
private static function get_feeds(?array $feed_ids = null): array {
|
||||
$ret = [];
|
||||
|
||||
if (is_array($feed_ids) && count($feed_ids) === 0)
|
||||
return $ret;
|
||||
|
||||
$feeds = ORM::for_table('ttrss_feeds')
|
||||
->table_alias('f')
|
||||
->select_many('f.id', 'f.title', 'f.last_error')
|
||||
->select_many_expr([
|
||||
'count' => 'SUM(CASE WHEN ue.unread THEN 1 ELSE 0 END)',
|
||||
'count_marked' => 'SUM(CASE WHEN ue.marked THEN 1 ELSE 0 END)',
|
||||
'count_published' => 'SUM(CASE WHEN ue.published THEN 1 ELSE 0 END)',
|
||||
'last_updated' => 'SUBSTRING_FOR_DATE(f.last_updated,1,19)',
|
||||
])
|
||||
->join('ttrss_user_entries', [ 'ue.feed_id', '=', 'f.id'], 'ue')
|
||||
->where('ue.owner_uid', $_SESSION['uid'])
|
||||
->group_by('f.id');
|
||||
|
||||
if (is_array($feed_ids))
|
||||
$feeds->where_in('f.id', $feed_ids);
|
||||
|
||||
foreach ($feeds->find_many() as $feed) {
|
||||
$ret[] = [
|
||||
'id' => $feed->id,
|
||||
'title' => truncate_string($feed->title, 30),
|
||||
'error' => $feed->last_error,
|
||||
'updated' => TimeHelper::make_local_datetime($feed->last_updated),
|
||||
'counter' => (int) $feed->count,
|
||||
'markedcounter' => (int) $feed->count_marked,
|
||||
'publishedcounter' => (int) $feed->count_published,
|
||||
'ts' => Feeds::_has_icon($feed->id) ? (int) filemtime(Feeds::_get_icon_file($feed->id)) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, int|string>>
|
||||
*/
|
||||
private static function get_global(): array {
|
||||
$ret = [
|
||||
[
|
||||
"id" => "global-unread",
|
||||
"counter" => (int) Feeds::_get_global_unread()
|
||||
]
|
||||
];
|
||||
|
||||
$subcribed_feeds = ORM::for_table('ttrss_feeds')
|
||||
->where('owner_uid', $_SESSION['uid'])
|
||||
->count();
|
||||
|
||||
array_push($ret, [
|
||||
"id" => "subscribed-feeds",
|
||||
"counter" => $subcribed_feeds
|
||||
]);
|
||||
|
||||
return $ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, int|string>>
|
||||
*/
|
||||
private static function get_virt(): array {
|
||||
$ret = [];
|
||||
|
||||
foreach ([Feeds::FEED_ARCHIVED, Feeds::FEED_STARRED, Feeds::FEED_PUBLISHED,
|
||||
Feeds::FEED_FRESH, Feeds::FEED_ALL] as $feed_id) {
|
||||
|
||||
$count = Feeds::_get_counters($feed_id, false, true);
|
||||
|
||||
if (in_array($feed_id, [Feeds::FEED_ARCHIVED, Feeds::FEED_STARRED, Feeds::FEED_PUBLISHED]))
|
||||
$auxctr = Feeds::_get_counters($feed_id, false);
|
||||
else
|
||||
$auxctr = 0;
|
||||
|
||||
$cv = [
|
||||
"id" => $feed_id,
|
||||
"counter" => (int) $count,
|
||||
"auxcounter" => (int) $auxctr
|
||||
];
|
||||
|
||||
if ($feed_id == Feeds::FEED_STARRED)
|
||||
$cv["markedcounter"] = $auxctr;
|
||||
|
||||
if ($feed_id == Feeds::FEED_PUBLISHED)
|
||||
$cv["publishedcounter"] = $auxctr;
|
||||
|
||||
array_push($ret, $cv);
|
||||
}
|
||||
|
||||
foreach (PluginHost::getInstance()->get_feeds(Feeds::CATEGORY_SPECIAL) as $feed) {
|
||||
if (!implements_interface($feed['sender'], 'IVirtualFeed'))
|
||||
continue;
|
||||
|
||||
/** @var Plugin&IVirtualFeed $feed['sender'] */
|
||||
|
||||
$cv = [
|
||||
"id" => PluginHost::pfeed_to_feed_id($feed['id']),
|
||||
"counter" => $feed['sender']->get_unread($feed['id'])
|
||||
];
|
||||
|
||||
if (method_exists($feed['sender'], 'get_total'))
|
||||
$cv["auxcounter"] = $feed['sender']->get_total($feed['id']);
|
||||
|
||||
array_push($ret, $cv);
|
||||
}
|
||||
|
||||
return $ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $label_ids
|
||||
* @return array<int, array<string, int|string>>
|
||||
*/
|
||||
static function get_labels(?array $label_ids = null): array {
|
||||
$ret = [];
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
if (is_array($label_ids)) {
|
||||
if (count($label_ids) == 0)
|
||||
return [];
|
||||
|
||||
$label_ids_qmarks = arr_qmarks($label_ids);
|
||||
|
||||
$sth = $pdo->prepare("SELECT id,
|
||||
caption,
|
||||
SUM(CASE WHEN u1.unread = true THEN 1 ELSE 0 END) AS count_unread,
|
||||
SUM(CASE WHEN u1.marked = true THEN 1 ELSE 0 END) AS count_marked,
|
||||
SUM(CASE WHEN u1.published = true THEN 1 ELSE 0 END) AS count_published,
|
||||
COUNT(u1.unread) AS total
|
||||
FROM ttrss_labels2 LEFT JOIN ttrss_user_labels2 ON
|
||||
(ttrss_labels2.id = label_id)
|
||||
LEFT JOIN ttrss_user_entries AS u1 ON u1.ref_id = article_id AND u1.owner_uid = ?
|
||||
WHERE ttrss_labels2.owner_uid = ? AND ttrss_labels2.id IN ($label_ids_qmarks)
|
||||
GROUP BY ttrss_labels2.id, ttrss_labels2.caption");
|
||||
$sth->execute([$_SESSION["uid"], $_SESSION["uid"], ...$label_ids]);
|
||||
} else {
|
||||
$sth = $pdo->prepare("SELECT id,
|
||||
caption,
|
||||
SUM(CASE WHEN u1.unread = true THEN 1 ELSE 0 END) AS count_unread,
|
||||
SUM(CASE WHEN u1.marked = true THEN 1 ELSE 0 END) AS count_marked,
|
||||
SUM(CASE WHEN u1.published = true THEN 1 ELSE 0 END) AS count_published,
|
||||
COUNT(u1.unread) AS total
|
||||
FROM ttrss_labels2 LEFT JOIN ttrss_user_labels2 ON
|
||||
(ttrss_labels2.id = label_id)
|
||||
LEFT JOIN ttrss_user_entries AS u1 ON u1.ref_id = article_id AND u1.owner_uid = :uid
|
||||
WHERE ttrss_labels2.owner_uid = :uid
|
||||
GROUP BY ttrss_labels2.id, ttrss_labels2.caption");
|
||||
$sth->execute([":uid" => $_SESSION['uid']]);
|
||||
}
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
|
||||
$id = Labels::label_to_feed_id($line["id"]);
|
||||
|
||||
$cv = [
|
||||
"id" => $id,
|
||||
"counter" => (int) $line["count_unread"],
|
||||
"auxcounter" => (int) $line["total"],
|
||||
"markedcounter" => (int) $line["count_marked"],
|
||||
"publishedcounter" => (int) $line["count_published"],
|
||||
"description" => $line["caption"]
|
||||
];
|
||||
|
||||
array_push($ret, $cv);
|
||||
}
|
||||
|
||||
return $ret;
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
class Crypt {
|
||||
|
||||
/** the only algo supported at the moment */
|
||||
private const ENCRYPT_ALGO = 'xchacha20poly1305_ietf';
|
||||
|
||||
/** currently only generates keys using sodium_crypto_aead_chacha20poly1305_keygen() i.e. one supported Crypt::ENCRYPT_ALGO
|
||||
* @return string random 256-bit (for ChaCha20-Poly1305) binary string
|
||||
*/
|
||||
static function generate_key() : string {
|
||||
return sodium_crypto_aead_chacha20poly1305_keygen();
|
||||
}
|
||||
|
||||
/** encrypts provided ciphertext using Config::ENCRYPTION_KEY into an encrypted object
|
||||
*
|
||||
* @return array{'algo': string, 'nonce': string, 'payload': string} encrypted data object containing algo, nonce, and encrypted data
|
||||
*/
|
||||
static function encrypt_string(string $ciphertext) : array {
|
||||
$key = Config::get(Config::ENCRYPTION_KEY);
|
||||
|
||||
if (!$key)
|
||||
throw new Exception("Crypt::encrypt_string() failed to encrypt - key is not available");
|
||||
|
||||
$nonce = \random_bytes(\SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
|
||||
|
||||
$payload = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($ciphertext, '', $nonce, hex2bin($key));
|
||||
|
||||
if ($payload) {
|
||||
$encrypted_data = [
|
||||
'algo' => self::ENCRYPT_ALGO,
|
||||
'nonce' => $nonce,
|
||||
'payload' => $payload,
|
||||
];
|
||||
|
||||
return $encrypted_data;
|
||||
}
|
||||
|
||||
throw new Exception("Crypt::encrypt_string() failed to encrypt ciphertext");
|
||||
}
|
||||
|
||||
/** decrypts payload of a valid encrypted object using Config::ENCRYPTION_KEY
|
||||
*
|
||||
* @param array{'algo': string, 'nonce': string, 'payload': string} $encrypted_data
|
||||
*
|
||||
* @return string decrypted string payload
|
||||
*/
|
||||
static function decrypt_string(array $encrypted_data) : string {
|
||||
$key = Config::get(Config::ENCRYPTION_KEY);
|
||||
|
||||
if (!$key)
|
||||
throw new Exception("Crypt::decrypt_string() failed to decrypt - key is not available");
|
||||
|
||||
// only one is supported for the time being
|
||||
switch ($encrypted_data['algo']) {
|
||||
case self::ENCRYPT_ALGO:
|
||||
return sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($encrypted_data['payload'], '', $encrypted_data['nonce'], hex2bin($key));
|
||||
}
|
||||
|
||||
throw new Exception('Crypt::decrypt_string() failed to decrypt passed encrypted data object, unsupported algo: ' . $encrypted_data['algo']);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
<?php
|
||||
class Db {
|
||||
private static ?Db $instance = null;
|
||||
|
||||
private ?PDO $pdo = null;
|
||||
|
||||
function __construct() {
|
||||
ORM::configure(self::get_dsn());
|
||||
ORM::configure('username', Config::get(Config::DB_USER));
|
||||
ORM::configure('password', Config::get(Config::DB_PASS));
|
||||
ORM::configure('return_result_sets', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $delta adjust generated timestamp by this value in seconds (either positive or negative)
|
||||
* @return string
|
||||
*/
|
||||
static function NOW(int $delta = 0): string {
|
||||
return date("Y-m-d H:i:s", time() + $delta);
|
||||
}
|
||||
|
||||
private function __clone() {
|
||||
//
|
||||
}
|
||||
|
||||
public static function get_dsn(): string {
|
||||
$db_port = Config::get(Config::DB_PORT) ? ';port=' . Config::get(Config::DB_PORT) : '';
|
||||
$db_host = Config::get(Config::DB_HOST) ? ';host=' . Config::get(Config::DB_HOST) : '';
|
||||
$db_sslmode = Config::get(Config::DB_SSLMODE);
|
||||
|
||||
return 'pgsql:dbname=' . Config::get(Config::DB_NAME) . $db_host . $db_port .
|
||||
";sslmode=$db_sslmode";
|
||||
}
|
||||
|
||||
// this really shouldn't be used unless a separate PDO connection is needed
|
||||
// normal usage is Db::pdo()->prepare(...) etc
|
||||
public function pdo_connect() : PDO {
|
||||
|
||||
try {
|
||||
$pdo = new PDO(self::get_dsn(),
|
||||
Config::get(Config::DB_USER),
|
||||
Config::get(Config::DB_PASS));
|
||||
} catch (Exception $e) {
|
||||
print "<pre>Exception while creating PDO object:" . $e->getMessage() . "</pre>";
|
||||
exit(101);
|
||||
}
|
||||
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
$pdo->query("set client_encoding = 'UTF-8'");
|
||||
$pdo->query("set datestyle = 'ISO, european'");
|
||||
$pdo->query("set TIME ZONE 0");
|
||||
$pdo->query("set cpu_tuple_cost = 0.5");
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
public static function instance() : Db {
|
||||
if (self::$instance == null)
|
||||
self::$instance = new self();
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public static function pdo() : PDO {
|
||||
if (self::$instance == null)
|
||||
self::$instance = new self();
|
||||
|
||||
if (empty(self::$instance->pdo)) {
|
||||
self::$instance->pdo = self::$instance->pdo_connect();
|
||||
}
|
||||
|
||||
return self::$instance->pdo;
|
||||
}
|
||||
|
||||
/** @deprecated usages should be replaced with `RANDOM()` */
|
||||
public static function sql_random_function(): string {
|
||||
return "RANDOM()";
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
<?php
|
||||
class Db_Migrations {
|
||||
|
||||
private string $base_filename = "schema.sql";
|
||||
private string $base_path;
|
||||
private string $migrations_path;
|
||||
private string $migrations_table;
|
||||
private bool $base_is_latest;
|
||||
private PDO $pdo;
|
||||
private int $cached_version = 0;
|
||||
private int $cached_max_version = 0;
|
||||
private int $max_version_override;
|
||||
|
||||
function __construct() {
|
||||
$this->pdo = Db::pdo();
|
||||
}
|
||||
|
||||
function initialize_for_plugin(Plugin $plugin, bool $base_is_latest = true, string $schema_suffix = "sql"): void {
|
||||
$plugin_dir = PluginHost::getInstance()->get_plugin_dir($plugin);
|
||||
$this->initialize("{$plugin_dir}/{$schema_suffix}",
|
||||
strtolower("ttrss_migrations_plugin_" . get_class($plugin)),
|
||||
$base_is_latest);
|
||||
}
|
||||
|
||||
function initialize(string $root_path, string $migrations_table, bool $base_is_latest = true, int $max_version_override = 0): void {
|
||||
$this->base_path = "$root_path/pgsql";
|
||||
$this->migrations_path = $this->base_path . "/migrations";
|
||||
$this->migrations_table = $migrations_table;
|
||||
$this->base_is_latest = $base_is_latest;
|
||||
$this->max_version_override = $max_version_override;
|
||||
}
|
||||
|
||||
private function set_version(int $version): void {
|
||||
Debug::log("Updating table {$this->migrations_table} with version {$version}...", Debug::LOG_EXTENDED);
|
||||
|
||||
$sth = $this->pdo->query("SELECT * FROM {$this->migrations_table}");
|
||||
|
||||
if ($sth->fetch()) {
|
||||
$sth = $this->pdo->prepare("UPDATE {$this->migrations_table} SET schema_version = ?");
|
||||
} else {
|
||||
$sth = $this->pdo->prepare("INSERT INTO {$this->migrations_table} (schema_version) VALUES (?)");
|
||||
}
|
||||
|
||||
$sth->execute([$version]);
|
||||
|
||||
$this->cached_version = $version;
|
||||
}
|
||||
|
||||
function get_version() : int {
|
||||
if ($this->cached_version)
|
||||
return $this->cached_version;
|
||||
|
||||
try {
|
||||
$sth = $this->pdo->query("SELECT * FROM {$this->migrations_table}");
|
||||
|
||||
if ($res = $sth->fetch()) {
|
||||
return (int) $res['schema_version'];
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
$this->create_migrations_table();
|
||||
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private function create_migrations_table(): void {
|
||||
$this->pdo->query("CREATE TABLE IF NOT EXISTS {$this->migrations_table} (schema_version integer not null)");
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws PDOException
|
||||
* @return bool false if the migration failed, otherwise true (or an exception)
|
||||
*/
|
||||
private function migrate_to(int $version): bool {
|
||||
try {
|
||||
if ($version <= $this->get_version()) {
|
||||
Debug::log("Refusing to apply version $version: current version is higher", Debug::LOG_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($version == 0)
|
||||
Debug::log("Loading base database schema...", Debug::LOG_VERBOSE);
|
||||
else
|
||||
Debug::log("Starting migration to $version...", Debug::LOG_VERBOSE);
|
||||
|
||||
$lines = $this->get_lines($version);
|
||||
|
||||
if (count($lines) > 0) {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
foreach ($lines as $line) {
|
||||
Debug::log($line, Debug::LOG_EXTENDED);
|
||||
try {
|
||||
$this->pdo->query($line);
|
||||
} catch (PDOException $e) {
|
||||
Debug::log("Failed on line: $line", Debug::LOG_VERBOSE);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
if ($version == 0 && $this->base_is_latest)
|
||||
$this->set_version($this->get_max_version());
|
||||
else
|
||||
$this->set_version($version);
|
||||
|
||||
$this->pdo->commit();
|
||||
|
||||
Debug::log("Migration finished, current version: " . $this->get_version(), Debug::LOG_VERBOSE);
|
||||
|
||||
Logger::log(E_USER_NOTICE, "Applied migration to version $version for {$this->migrations_table}");
|
||||
return true;
|
||||
} else {
|
||||
Debug::log("Migration failed: schema file is empty or missing.", Debug::LOG_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
|
||||
} catch (PDOException $e) {
|
||||
Debug::log("Migration failed: " . $e->getMessage(), Debug::LOG_VERBOSE);
|
||||
try {
|
||||
$this->pdo->rollback();
|
||||
} catch (PDOException $ie) {
|
||||
//
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
function get_max_version() : int {
|
||||
if ($this->max_version_override > 0)
|
||||
return $this->max_version_override;
|
||||
|
||||
if ($this->cached_max_version)
|
||||
return $this->cached_max_version;
|
||||
|
||||
$migrations = glob("{$this->migrations_path}/*.sql");
|
||||
|
||||
if (count($migrations) > 0) {
|
||||
natsort($migrations);
|
||||
|
||||
$this->cached_max_version = (int) basename(array_pop($migrations), ".sql");
|
||||
|
||||
} else {
|
||||
$this->cached_max_version = 0;
|
||||
}
|
||||
|
||||
return $this->cached_max_version;
|
||||
}
|
||||
|
||||
function is_migration_needed() : bool {
|
||||
return $this->get_version() != $this->get_max_version();
|
||||
}
|
||||
|
||||
function migrate() : bool {
|
||||
|
||||
if ($this->get_version() == -1) {
|
||||
try {
|
||||
$this->migrate_to(0);
|
||||
} catch (PDOException $e) {
|
||||
user_error("Failed to load base schema for {$this->migrations_table}: " . $e->getMessage(), E_USER_WARNING);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for ($i = $this->get_version() + 1; $i <= $this->get_max_version(); $i++) {
|
||||
try {
|
||||
$this->migrate_to($i);
|
||||
} catch (PDOException $e) {
|
||||
user_error("Failed to apply migration {$i} for {$this->migrations_table}: " . $e->getMessage(), E_USER_WARNING);
|
||||
return false;
|
||||
//throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
return !$this->is_migration_needed();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function get_lines(int $version) : array {
|
||||
if ($version > 0)
|
||||
$filename = "{$this->migrations_path}/{$version}.sql";
|
||||
else
|
||||
$filename = "{$this->base_path}/{$this->base_filename}";
|
||||
|
||||
if (file_exists($filename)) {
|
||||
$lines = array_filter(preg_split("/[\r\n]/", file_get_contents($filename)),
|
||||
fn($line) => strlen(trim($line)) > 0 && !str_starts_with($line, "--"));
|
||||
|
||||
return array_filter(explode(";", implode("", $lines)),
|
||||
fn($line) => strlen(trim($line)) > 0 && !in_array(strtolower($line), ["begin", "commit"]));
|
||||
|
||||
} else {
|
||||
user_error("Requested schema file {$filename} not found.", E_USER_ERROR);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php
|
||||
class Db_Prefs {
|
||||
// this class is a stub for the time being (to be removed)
|
||||
|
||||
function read(string $pref_name, ?int $user_id = null, bool $die_on_error = false): bool|int|null|string {
|
||||
return Prefs::get($pref_name, $user_id ?: $_SESSION['uid'], $_SESSION['profile'] ?? null);
|
||||
}
|
||||
|
||||
function write(string $pref_name, mixed $value, ?int $user_id = null, bool $strip_tags = true): bool {
|
||||
return Prefs::set($pref_name, $value, $user_id ?: $_SESSION['uid'], $_SESSION['profile'] ?? null, $strip_tags);
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
<?php
|
||||
class Debug {
|
||||
const LOG_DISABLED = -1;
|
||||
const LOG_NORMAL = 0;
|
||||
const LOG_VERBOSE = 1;
|
||||
const LOG_EXTENDED = 2;
|
||||
|
||||
const SEPARATOR = "<-{log-separator}->";
|
||||
|
||||
const ALL_LOG_LEVELS = [
|
||||
Debug::LOG_DISABLED,
|
||||
Debug::LOG_NORMAL,
|
||||
Debug::LOG_VERBOSE,
|
||||
Debug::LOG_EXTENDED,
|
||||
];
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
public static int $LOG_DISABLED = self::LOG_DISABLED;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
public static int $LOG_NORMAL = self::LOG_NORMAL;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
public static int $LOG_VERBOSE = self::LOG_VERBOSE;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
public static int $LOG_EXTENDED = self::LOG_EXTENDED;
|
||||
|
||||
private static bool $enabled = false;
|
||||
private static bool $quiet = false;
|
||||
private static ?string $logfile = null;
|
||||
private static bool $enable_html = false;
|
||||
|
||||
private static int $loglevel = self::LOG_NORMAL;
|
||||
|
||||
public static function set_logfile(string $logfile): void {
|
||||
self::$logfile = $logfile;
|
||||
}
|
||||
|
||||
public static function enabled(): bool {
|
||||
return self::$enabled;
|
||||
}
|
||||
|
||||
public static function set_enabled(bool $enable): void {
|
||||
self::$enabled = $enable;
|
||||
}
|
||||
|
||||
public static function set_quiet(bool $quiet): void {
|
||||
self::$quiet = $quiet;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Debug::LOG_* $level
|
||||
*/
|
||||
public static function set_loglevel(int $level): void {
|
||||
self::$loglevel = $level;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int Debug::LOG_*
|
||||
*/
|
||||
public static function get_loglevel(): int {
|
||||
return self::$loglevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $level integer loglevel value
|
||||
* @return Debug::LOG_* if valid, warn and return LOG_DISABLED otherwise
|
||||
*/
|
||||
public static function map_loglevel(int $level) : int {
|
||||
if (in_array($level, self::ALL_LOG_LEVELS)) {
|
||||
/** @phpstan-ignore return.type (yes it is a Debug::LOG_* value) */
|
||||
return $level;
|
||||
} else {
|
||||
user_error("Passed invalid debug log level: $level", E_USER_WARNING);
|
||||
return self::LOG_DISABLED;
|
||||
}
|
||||
}
|
||||
|
||||
public static function enable_html(bool $enable) : void {
|
||||
self::$enable_html = $enable;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Debug::LOG_* $level log level
|
||||
*/
|
||||
public static function log(string $message, int $level = Debug::LOG_NORMAL): bool {
|
||||
|
||||
if (!self::$enabled || self::$loglevel < $level) return false;
|
||||
|
||||
$ts = date("H:i:s", time());
|
||||
if (function_exists('posix_getpid')) {
|
||||
$ts = "$ts/" . posix_getpid();
|
||||
}
|
||||
|
||||
$orig_message = $message;
|
||||
|
||||
if ($message === self::SEPARATOR) {
|
||||
$message = self::$enable_html ? "<hr/>" :
|
||||
"=================================================================================================================================";
|
||||
}
|
||||
|
||||
if (self::$logfile) {
|
||||
$fp = fopen(self::$logfile, 'a+');
|
||||
|
||||
if ($fp) {
|
||||
$locked = false;
|
||||
|
||||
if (function_exists("flock")) {
|
||||
$tries = 0;
|
||||
|
||||
// try to lock logfile for writing
|
||||
while ($tries < 5 && !$locked = flock($fp, LOCK_EX | LOCK_NB)) {
|
||||
sleep(1);
|
||||
++$tries;
|
||||
}
|
||||
|
||||
if (!$locked) {
|
||||
fclose($fp);
|
||||
user_error("Unable to lock debugging log file: " . self::$logfile, E_USER_WARNING);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
fputs($fp, "[$ts] $message\n");
|
||||
|
||||
if (function_exists("flock")) {
|
||||
flock($fp, LOCK_UN);
|
||||
}
|
||||
|
||||
fclose($fp);
|
||||
|
||||
if (self::$quiet)
|
||||
return false;
|
||||
|
||||
} else {
|
||||
user_error("Unable to open debugging log file: " . self::$logfile, E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
|
||||
if (self::$enable_html) {
|
||||
if ($orig_message === self::SEPARATOR) {
|
||||
print "$message\n";
|
||||
} else {
|
||||
print "<span class='log-timestamp'>$ts</span> <span class='log-message'>$message</span>\n";
|
||||
}
|
||||
} else {
|
||||
print "[$ts] $message\n";
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
<?php
|
||||
class Digest
|
||||
{
|
||||
static function send_headlines_digests(): void {
|
||||
$user_limit = 15; // amount of users to process (e.g. emails to send out)
|
||||
$limit = 1000; // maximum amount of headlines to include
|
||||
|
||||
Debug::log("Sending digests, batch of max $user_limit users, headline limit = $limit");
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$res = $pdo->query("SELECT id, login, email FROM ttrss_users
|
||||
WHERE email != '' AND (last_digest_sent IS NULL OR last_digest_sent < NOW() - INTERVAL '1 day')");
|
||||
|
||||
while ($line = $res->fetch()) {
|
||||
|
||||
if (Prefs::get(Prefs::DIGEST_ENABLE, $line['id'])) {
|
||||
$preferred_ts = strtotime(Prefs::get(Prefs::DIGEST_PREFERRED_TIME, $line['id']) ?? '');
|
||||
|
||||
// try to send digests within 2 hours of preferred time
|
||||
if ($preferred_ts && time() >= $preferred_ts &&
|
||||
time() - $preferred_ts <= 7200
|
||||
) {
|
||||
|
||||
Debug::log("Sending digest for UID:" . $line['id'] . " - " . $line["email"]);
|
||||
|
||||
$do_catchup = Prefs::get(Prefs::DIGEST_CATCHUP, $line['id']);
|
||||
|
||||
global $tz_offset;
|
||||
|
||||
// reset tz_offset global to prevent tz cache clash between users
|
||||
$tz_offset = -1;
|
||||
|
||||
$tuple = Digest::prepare_headlines_digest($line["id"], 1, $limit);
|
||||
$digest = $tuple[0];
|
||||
$headlines_count = $tuple[1];
|
||||
$affected_ids = $tuple[2];
|
||||
$digest_text = $tuple[3];
|
||||
|
||||
if ($headlines_count > 0) {
|
||||
|
||||
$mailer = new Mailer();
|
||||
|
||||
//$rc = $mail->quickMail($line["email"], $line["login"], Config::get(Config::DIGEST_SUBJECT), $digest, $digest_text);
|
||||
|
||||
$rc = $mailer->mail(["to_name" => $line["login"],
|
||||
"to_address" => $line["email"],
|
||||
"subject" => Config::get(Config::DIGEST_SUBJECT),
|
||||
"message" => $digest_text,
|
||||
"message_html" => $digest]);
|
||||
|
||||
//if (!$rc && $debug) Debug::log("ERROR: " . $mailer->lastError());
|
||||
|
||||
Debug::log("RC=$rc");
|
||||
|
||||
if ($rc && $do_catchup) {
|
||||
Debug::log("Marking affected articles as read...");
|
||||
Article::_catchup_by_id($affected_ids, Article::CATCHUP_MODE_MARK_AS_READ, $line["id"]);
|
||||
}
|
||||
} else {
|
||||
Debug::log("No headlines");
|
||||
}
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_users SET last_digest_sent = NOW()
|
||||
WHERE id = ?");
|
||||
$sth->execute([$line["id"]]);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Debug::log("All done.");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: int, 2: array<int>, 3: string}
|
||||
*/
|
||||
static function prepare_headlines_digest(int $user_id, int $days = 1, int $limit = 1000) {
|
||||
|
||||
$tpl = new Templator();
|
||||
$tpl_t = new Templator();
|
||||
|
||||
$tpl->readTemplateFromFile("digest_template_html.txt");
|
||||
$tpl_t->readTemplateFromFile("digest_template.txt");
|
||||
|
||||
$user_tz_string = Prefs::get(Prefs::USER_TIMEZONE, $user_id);
|
||||
$min_score = Prefs::get(Prefs::DIGEST_MIN_SCORE, $user_id);
|
||||
|
||||
if ($user_tz_string == 'Automatic')
|
||||
$user_tz_string = 'GMT';
|
||||
|
||||
$local_ts = TimeHelper::convert_timestamp(time(), 'UTC', $user_tz_string);
|
||||
|
||||
$tpl->setVariable('CUR_DATE', date('Y/m/d', $local_ts));
|
||||
$tpl->setVariable('CUR_TIME', date('G:i', $local_ts));
|
||||
$tpl->setVariable('TTRSS_HOST', Config::get_self_url());
|
||||
|
||||
$tpl_t->setVariable('CUR_DATE', date('Y/m/d', $local_ts));
|
||||
$tpl_t->setVariable('CUR_TIME', date('G:i', $local_ts));
|
||||
$tpl_t->setVariable('TTRSS_HOST', Config::get_self_url());
|
||||
|
||||
$affected_ids = array();
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT ttrss_entries.title,
|
||||
ttrss_feeds.title AS feed_title,
|
||||
COALESCE(ttrss_feed_categories.title, '" . __('Uncategorized') . "') AS cat_title,
|
||||
date_updated,
|
||||
ttrss_user_entries.ref_id,
|
||||
link,
|
||||
score,
|
||||
content,
|
||||
SUBSTRING_FOR_DATE(last_updated,1,19) AS last_updated
|
||||
FROM
|
||||
ttrss_user_entries,ttrss_entries,ttrss_feeds
|
||||
LEFT JOIN
|
||||
ttrss_feed_categories ON (cat_id = ttrss_feed_categories.id)
|
||||
WHERE
|
||||
ref_id = ttrss_entries.id AND feed_id = ttrss_feeds.id
|
||||
AND include_in_digest = true
|
||||
AND ttrss_entries.date_updated > NOW() - INTERVAL '$days day'
|
||||
AND ttrss_user_entries.owner_uid = :user_id
|
||||
AND unread = true
|
||||
AND score >= :min_score
|
||||
ORDER BY ttrss_feed_categories.title, ttrss_feeds.title, score DESC, date_updated DESC
|
||||
LIMIT " . (int)$limit);
|
||||
$sth->execute([':user_id' => $user_id, ':min_score' => $min_score]);
|
||||
|
||||
$headlines_count = 0;
|
||||
$headlines = array();
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
array_push($headlines, $line);
|
||||
$headlines_count++;
|
||||
}
|
||||
|
||||
for ($i = 0; $i < sizeof($headlines); $i++) {
|
||||
|
||||
$line = $headlines[$i];
|
||||
|
||||
array_push($affected_ids, $line["ref_id"]);
|
||||
|
||||
$updated = TimeHelper::make_local_datetime($line['last_updated'], owner_uid: $user_id);
|
||||
|
||||
if (Prefs::get(Prefs::ENABLE_FEED_CATS, $user_id)) {
|
||||
$line['feed_title'] = $line['cat_title'] . " / " . $line['feed_title'];
|
||||
}
|
||||
|
||||
$article_labels = Article::_get_labels($line["ref_id"], $user_id);
|
||||
$article_labels_formatted = "";
|
||||
|
||||
if (count($article_labels) > 0) {
|
||||
$article_labels_formatted = implode(", ", array_map(fn($a) => $a[1], $article_labels));
|
||||
}
|
||||
|
||||
$tpl->setVariable('FEED_TITLE', $line["feed_title"]);
|
||||
$tpl->setVariable('ARTICLE_TITLE', $line["title"]);
|
||||
$tpl->setVariable('ARTICLE_LINK', $line["link"]);
|
||||
$tpl->setVariable('ARTICLE_UPDATED', $updated);
|
||||
$tpl->setVariable('ARTICLE_EXCERPT',
|
||||
truncate_string(strip_tags($line["content"]), 300));
|
||||
// $tpl->setVariable('ARTICLE_CONTENT',
|
||||
// strip_tags($article_content));
|
||||
$tpl->setVariable('ARTICLE_LABELS', $article_labels_formatted, true);
|
||||
|
||||
$tpl->addBlock('article');
|
||||
|
||||
$tpl_t->setVariable('FEED_TITLE', $line["feed_title"]);
|
||||
$tpl_t->setVariable('ARTICLE_TITLE', $line["title"]);
|
||||
$tpl_t->setVariable('ARTICLE_LINK', $line["link"]);
|
||||
$tpl_t->setVariable('ARTICLE_UPDATED', $updated);
|
||||
$tpl_t->setVariable('ARTICLE_LABELS', $article_labels_formatted, true);
|
||||
$tpl_t->setVariable('ARTICLE_EXCERPT',
|
||||
truncate_string(strip_tags($line["content"]), 300, "..."), true);
|
||||
|
||||
$tpl_t->addBlock('article');
|
||||
|
||||
if (!isset($headlines[$i + 1]) || $headlines[$i]['feed_title'] != $headlines[$i + 1]['feed_title']) {
|
||||
$tpl->addBlock('feed');
|
||||
$tpl_t->addBlock('feed');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$tpl->addBlock('digest');
|
||||
$tpl->generateOutputToString($tmp);
|
||||
|
||||
$tpl_t->addBlock('digest');
|
||||
$tpl_t->generateOutputToString($tmp_t);
|
||||
|
||||
return array($tmp, $headlines_count, $affected_ids, $tmp_t);
|
||||
}
|
||||
}
|
||||
@@ -1,458 +0,0 @@
|
||||
<?php
|
||||
class DiskCache implements Cache_Adapter {
|
||||
private Cache_Adapter $adapter;
|
||||
|
||||
/** @var array<string, DiskCache> $instances */
|
||||
private static array $instances = [];
|
||||
|
||||
/**
|
||||
* https://stackoverflow.com/a/53662733
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private array $mimeMap = [
|
||||
'video/3gpp2' => '3g2',
|
||||
'video/3gp' => '3gp',
|
||||
'video/3gpp' => '3gp',
|
||||
'application/x-compressed' => '7zip',
|
||||
'audio/x-acc' => 'aac',
|
||||
'audio/ac3' => 'ac3',
|
||||
'application/postscript' => 'ai',
|
||||
'audio/x-aiff' => 'aif',
|
||||
'audio/aiff' => 'aif',
|
||||
'audio/x-au' => 'au',
|
||||
'video/x-msvideo' => 'avi',
|
||||
'video/msvideo' => 'avi',
|
||||
'video/avi' => 'avi',
|
||||
'application/x-troff-msvideo' => 'avi',
|
||||
'application/macbinary' => 'bin',
|
||||
'application/mac-binary' => 'bin',
|
||||
'application/x-binary' => 'bin',
|
||||
'application/x-macbinary' => 'bin',
|
||||
'image/bmp' => 'bmp',
|
||||
'image/x-bmp' => 'bmp',
|
||||
'image/x-bitmap' => 'bmp',
|
||||
'image/x-xbitmap' => 'bmp',
|
||||
'image/x-win-bitmap' => 'bmp',
|
||||
'image/x-windows-bmp' => 'bmp',
|
||||
'image/ms-bmp' => 'bmp',
|
||||
'image/x-ms-bmp' => 'bmp',
|
||||
'application/bmp' => 'bmp',
|
||||
'application/x-bmp' => 'bmp',
|
||||
'application/x-win-bitmap' => 'bmp',
|
||||
'application/cdr' => 'cdr',
|
||||
'application/coreldraw' => 'cdr',
|
||||
'application/x-cdr' => 'cdr',
|
||||
'application/x-coreldraw' => 'cdr',
|
||||
'image/cdr' => 'cdr',
|
||||
'image/x-cdr' => 'cdr',
|
||||
'zz-application/zz-winassoc-cdr' => 'cdr',
|
||||
'application/mac-compactpro' => 'cpt',
|
||||
'application/pkix-crl' => 'crl',
|
||||
'application/pkcs-crl' => 'crl',
|
||||
'application/x-x509-ca-cert' => 'crt',
|
||||
'application/pkix-cert' => 'crt',
|
||||
'text/css' => 'css',
|
||||
'text/x-comma-separated-values' => 'csv',
|
||||
'text/comma-separated-values' => 'csv',
|
||||
'application/vnd.msexcel' => 'csv',
|
||||
'application/x-director' => 'dcr',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
||||
'application/x-dvi' => 'dvi',
|
||||
'message/rfc822' => 'eml',
|
||||
'application/x-msdownload' => 'exe',
|
||||
'video/x-f4v' => 'f4v',
|
||||
'audio/x-flac' => 'flac',
|
||||
'video/x-flv' => 'flv',
|
||||
'image/gif' => 'gif',
|
||||
'application/gpg-keys' => 'gpg',
|
||||
'application/x-gtar' => 'gtar',
|
||||
'application/x-gzip' => 'gzip',
|
||||
'application/mac-binhex40' => 'hqx',
|
||||
'application/mac-binhex' => 'hqx',
|
||||
'application/x-binhex40' => 'hqx',
|
||||
'application/x-mac-binhex40' => 'hqx',
|
||||
'text/html' => 'html',
|
||||
'image/x-icon' => 'ico',
|
||||
'image/x-ico' => 'ico',
|
||||
'image/vnd.microsoft.icon' => 'ico',
|
||||
'text/calendar' => 'ics',
|
||||
'application/java-archive' => 'jar',
|
||||
'application/x-java-application' => 'jar',
|
||||
'application/x-jar' => 'jar',
|
||||
'image/jp2' => 'jp2',
|
||||
'video/mj2' => 'jp2',
|
||||
'image/jpx' => 'jp2',
|
||||
'image/jpm' => 'jp2',
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/pjpeg' => 'jpg',
|
||||
'application/x-javascript' => 'js',
|
||||
'application/json' => 'json',
|
||||
'text/json' => 'json',
|
||||
'application/vnd.google-earth.kml+xml' => 'kml',
|
||||
'application/vnd.google-earth.kmz' => 'kmz',
|
||||
'text/x-log' => 'log',
|
||||
'audio/x-m4a' => 'm4a',
|
||||
'audio/mp4' => 'm4a',
|
||||
'application/vnd.mpegurl' => 'm4u',
|
||||
'audio/midi' => 'mid',
|
||||
'application/vnd.mif' => 'mif',
|
||||
'video/quicktime' => 'mov',
|
||||
'video/x-sgi-movie' => 'movie',
|
||||
'audio/mpeg' => 'mp3',
|
||||
'audio/mpg' => 'mp3',
|
||||
'audio/mpeg3' => 'mp3',
|
||||
'audio/mp3' => 'mp3',
|
||||
'video/mp4' => 'mp4',
|
||||
'video/mpeg' => 'mpeg',
|
||||
'application/oda' => 'oda',
|
||||
'audio/ogg' => 'ogg',
|
||||
'video/ogg' => 'ogg',
|
||||
'application/ogg' => 'ogg',
|
||||
'font/otf' => 'otf',
|
||||
'application/x-pkcs10' => 'p10',
|
||||
'application/pkcs10' => 'p10',
|
||||
'application/x-pkcs12' => 'p12',
|
||||
'application/x-pkcs7-signature' => 'p7a',
|
||||
'application/pkcs7-mime' => 'p7c',
|
||||
'application/x-pkcs7-mime' => 'p7c',
|
||||
'application/x-pkcs7-certreqresp' => 'p7r',
|
||||
'application/pkcs7-signature' => 'p7s',
|
||||
'application/pdf' => 'pdf',
|
||||
'application/octet-stream' => 'pdf',
|
||||
'application/x-x509-user-cert' => 'pem',
|
||||
'application/x-pem-file' => 'pem',
|
||||
'application/pgp' => 'pgp',
|
||||
'application/x-httpd-php' => 'php',
|
||||
'application/php' => 'php',
|
||||
'application/x-php' => 'php',
|
||||
'text/php' => 'php',
|
||||
'text/x-php' => 'php',
|
||||
'application/x-httpd-php-source' => 'php',
|
||||
'image/png' => 'png',
|
||||
'image/x-png' => 'png',
|
||||
'application/powerpoint' => 'ppt',
|
||||
'application/vnd.ms-powerpoint' => 'ppt',
|
||||
'application/vnd.ms-office' => 'ppt',
|
||||
'application/msword' => 'ppt',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx',
|
||||
'application/x-photoshop' => 'psd',
|
||||
'image/vnd.adobe.photoshop' => 'psd',
|
||||
'audio/x-realaudio' => 'ra',
|
||||
'audio/x-pn-realaudio' => 'ram',
|
||||
'application/x-rar' => 'rar',
|
||||
'application/rar' => 'rar',
|
||||
'application/x-rar-compressed' => 'rar',
|
||||
'audio/x-pn-realaudio-plugin' => 'rpm',
|
||||
'application/x-pkcs7' => 'rsa',
|
||||
'text/rtf' => 'rtf',
|
||||
'text/richtext' => 'rtx',
|
||||
'video/vnd.rn-realvideo' => 'rv',
|
||||
'application/x-stuffit' => 'sit',
|
||||
'application/smil' => 'smil',
|
||||
'text/srt' => 'srt',
|
||||
'image/svg+xml' => 'svg',
|
||||
'application/x-shockwave-flash' => 'swf',
|
||||
'application/x-tar' => 'tar',
|
||||
'application/x-gzip-compressed' => 'tgz',
|
||||
'image/tiff' => 'tiff',
|
||||
'font/ttf' => 'ttf',
|
||||
'text/plain' => 'txt',
|
||||
'text/x-vcard' => 'vcf',
|
||||
'application/videolan' => 'vlc',
|
||||
'text/vtt' => 'vtt',
|
||||
'audio/x-wav' => 'wav',
|
||||
'audio/wave' => 'wav',
|
||||
'audio/wav' => 'wav',
|
||||
'application/wbxml' => 'wbxml',
|
||||
'video/webm' => 'webm',
|
||||
'image/webp' => 'webp',
|
||||
'audio/x-ms-wma' => 'wma',
|
||||
'application/wmlc' => 'wmlc',
|
||||
'video/x-ms-wmv' => 'wmv',
|
||||
'video/x-ms-asf' => 'wmv',
|
||||
'font/woff' => 'woff',
|
||||
'font/woff2' => 'woff2',
|
||||
'application/xhtml+xml' => 'xhtml',
|
||||
'application/excel' => 'xl',
|
||||
'application/msexcel' => 'xls',
|
||||
'application/x-msexcel' => 'xls',
|
||||
'application/x-ms-excel' => 'xls',
|
||||
'application/x-excel' => 'xls',
|
||||
'application/x-dos_ms_excel' => 'xls',
|
||||
'application/xls' => 'xls',
|
||||
'application/x-xls' => 'xls',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
|
||||
'application/vnd.ms-excel' => 'xlsx',
|
||||
'application/xml' => 'xml',
|
||||
'text/xml' => 'xml',
|
||||
'text/xsl' => 'xsl',
|
||||
'application/xspf+xml' => 'xspf',
|
||||
'application/x-compress' => 'z',
|
||||
'application/x-zip' => 'zip',
|
||||
'application/zip' => 'zip',
|
||||
'application/x-zip-compressed' => 'zip',
|
||||
'application/s-compressed' => 'zip',
|
||||
'multipart/x-zip' => 'zip',
|
||||
'text/x-scriptzsh' => 'zsh'
|
||||
];
|
||||
|
||||
public static function instance(string $dir) : DiskCache {
|
||||
if ((self::$instances[$dir] ?? null) == null)
|
||||
self::$instances[$dir] = new self($dir);
|
||||
|
||||
return self::$instances[$dir];
|
||||
}
|
||||
|
||||
public function __construct(string $dir) {
|
||||
foreach (PluginHost::getInstance()->get_plugins() as $p) {
|
||||
if (implements_interface($p, "Cache_Adapter")) {
|
||||
|
||||
/** @var Cache_Adapter $p */
|
||||
$this->adapter = clone $p; // we need separate object instances for separate directories
|
||||
$this->adapter->set_dir($dir);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$this->adapter = new Cache_Local();
|
||||
$this->adapter->set_dir($dir);
|
||||
}
|
||||
|
||||
public function remove(string $filename): bool {
|
||||
$rc = $this->adapter->remove($filename);
|
||||
|
||||
return $rc;
|
||||
}
|
||||
|
||||
public function set_dir(string $dir) : void {
|
||||
$this->adapter->set_dir($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|false -1 if the file doesn't exist, false if an error occurred, timestamp otherwise
|
||||
*/
|
||||
public function get_mtime(string $filename) {
|
||||
return $this->adapter->get_mtime(basename($filename));
|
||||
}
|
||||
|
||||
public function make_dir(): bool {
|
||||
return $this->adapter->make_dir();
|
||||
}
|
||||
|
||||
/** @param string|null $filename null means check that cache directory itself is writable */
|
||||
public function is_writable(?string $filename = null): bool {
|
||||
return $this->adapter->is_writable($filename ? basename($filename) : null);
|
||||
}
|
||||
|
||||
public function exists(string $filename): bool {
|
||||
$rc = $this->adapter->exists(basename($filename));
|
||||
|
||||
return $rc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|false -1 if the file doesn't exist, false if an error occurred, size in bytes otherwise
|
||||
*/
|
||||
public function get_size(string $filename) {
|
||||
$rc = $this->adapter->get_size(basename($filename));
|
||||
|
||||
return $rc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
*
|
||||
* @return int|false Bytes written or false if an error occurred.
|
||||
*/
|
||||
public function put(string $filename, $data) {
|
||||
return $this->adapter->put(basename($filename), $data);
|
||||
}
|
||||
|
||||
/** @deprecated we can't assume cached files are local, and other storages
|
||||
* might not support this operation (object metadata may be immutable) */
|
||||
public function touch(string $filename): bool {
|
||||
user_error("DiskCache: called unsupported method touch() for $filename", E_USER_DEPRECATED);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function get(string $filename): ?string {
|
||||
return $this->adapter->get(basename($filename));
|
||||
}
|
||||
|
||||
public function expire_all(): void {
|
||||
$this->adapter->expire_all();
|
||||
}
|
||||
|
||||
public function get_dir(): string {
|
||||
return $this->adapter->get_dir();
|
||||
}
|
||||
|
||||
/** Downloads $url to cache as $local_filename if its missing (unless $force-ed)
|
||||
* @param string $url
|
||||
* @param string $local_filename
|
||||
* @param array<string,string|int|false> $options (additional params to UrlHelper::fetch())
|
||||
* @param bool $force
|
||||
* @return bool
|
||||
*/
|
||||
public function download(string $url, string $local_filename, array $options = [], bool $force = false) : bool {
|
||||
if ($this->exists($local_filename) && !$force)
|
||||
return true;
|
||||
|
||||
$data = UrlHelper::fetch([
|
||||
'url' => $url,
|
||||
'max_size' => Config::get(Config::MAX_CACHE_FILE_SIZE),
|
||||
...$options,
|
||||
]);
|
||||
|
||||
if ($data)
|
||||
return $this->put($local_filename, $data) > 0;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function send(string $filename) {
|
||||
$filename = basename($filename);
|
||||
|
||||
if (!$this->exists($filename)) {
|
||||
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
|
||||
echo "File not found.";
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$file_mtime = $this->get_mtime($filename);
|
||||
$gmt_modified = gmdate("D, d M Y H:i:s", (int)$file_mtime) . " GMT";
|
||||
|
||||
if (($_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? '') == $gmt_modified || ($_SERVER['HTTP_IF_NONE_MATCH'] ?? '') == $file_mtime) {
|
||||
header('HTTP/1.1 304 Not Modified');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$mimetype = $this->get_mime_type($filename);
|
||||
|
||||
if ($mimetype == "application/octet-stream")
|
||||
$mimetype = "video/mp4";
|
||||
|
||||
# block SVG because of possible embedded javascript (.....)
|
||||
$mimetype_blacklist = [ "image/svg+xml" ];
|
||||
|
||||
/* only serve video and images */
|
||||
if (!preg_match("/(image|audio|video)\//", (string)$mimetype) || in_array($mimetype, $mimetype_blacklist)) {
|
||||
http_response_code(400);
|
||||
header("Content-type: text/plain");
|
||||
|
||||
print "Stored file has disallowed content type ($mimetype)";
|
||||
return false;
|
||||
}
|
||||
|
||||
$fake_extension = $this->get_fake_extension($filename);
|
||||
|
||||
if ($fake_extension)
|
||||
$fake_extension = ".$fake_extension";
|
||||
|
||||
header("Content-Disposition: inline; filename=\"{$filename}{$fake_extension}\"");
|
||||
header("Content-type: $mimetype");
|
||||
|
||||
$stamp_expires = gmdate("D, d M Y H:i:s",
|
||||
(int)$this->get_mtime($filename) + 86400 * Config::get(Config::CACHE_MAX_DAYS)) . " GMT";
|
||||
|
||||
header("Expires: $stamp_expires", true);
|
||||
header("Last-Modified: $gmt_modified", true);
|
||||
header("Cache-Control: no-cache");
|
||||
header("ETag: $file_mtime");
|
||||
|
||||
header_remove("Pragma");
|
||||
|
||||
return $this->adapter->send($filename);
|
||||
}
|
||||
|
||||
public function get_full_path(string $filename): string {
|
||||
return $this->adapter->get_full_path(basename($filename));
|
||||
}
|
||||
|
||||
public function get_mime_type(string $filename) {
|
||||
return $this->adapter->get_mime_type(basename($filename));
|
||||
}
|
||||
|
||||
public function get_fake_extension(string $filename): string {
|
||||
$mimetype = $this->adapter->get_mime_type(basename($filename));
|
||||
|
||||
if ($mimetype)
|
||||
return $this->mimeMap[$mimetype] ?? "";
|
||||
else
|
||||
return "";
|
||||
}
|
||||
|
||||
public function get_url(string $filename): string {
|
||||
return Config::get_self_url() . "/public.php?op=cached&file=" . basename($this->adapter->get_dir()) . "/" . basename($filename);
|
||||
}
|
||||
|
||||
// check for locally cached (media) URLs and rewrite to local versions
|
||||
// this is called separately after sanitize() and plugin render article hooks to allow
|
||||
// plugins work on original source URLs used before caching
|
||||
// NOTE: URLs should be already absolutized because this is called after sanitize()
|
||||
static public function rewrite_urls(string $str): string {
|
||||
$res = trim($str);
|
||||
|
||||
if (!$res) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$doc = new DOMDocument();
|
||||
if (@$doc->loadHTML('<?xml encoding="UTF-8">' . $res)) {
|
||||
$xpath = new DOMXPath($doc);
|
||||
$cache = DiskCache::instance("images");
|
||||
|
||||
$need_saving = false;
|
||||
|
||||
$entries = $xpath->query('(//img[@src]|//source[@src|@srcset]|//video[@poster|@src])');
|
||||
|
||||
/** @var DOMElement $entry */
|
||||
foreach ($entries as $entry) {
|
||||
foreach (array('src', 'poster') as $attr) {
|
||||
if ($entry->hasAttribute($attr)) {
|
||||
$url = $entry->getAttribute($attr);
|
||||
$cached_filename = sha1($url);
|
||||
|
||||
if ($cache->exists($cached_filename)) {
|
||||
$url = $cache->get_url($cached_filename);
|
||||
|
||||
$entry->setAttribute($attr, $url);
|
||||
$entry->removeAttribute("srcset");
|
||||
|
||||
$need_saving = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($entry->hasAttribute("srcset")) {
|
||||
$matches = RSSUtils::decode_srcset($entry->getAttribute('srcset'));
|
||||
|
||||
for ($i = 0; $i < count($matches); $i++) {
|
||||
$cached_filename = sha1($matches[$i]["url"]);
|
||||
|
||||
if ($cache->exists($cached_filename)) {
|
||||
$matches[$i]["url"] = $cache->get_url($cached_filename);
|
||||
|
||||
$need_saving = true;
|
||||
}
|
||||
}
|
||||
|
||||
$entry->setAttribute("srcset", RSSUtils::encode_srcset($matches));
|
||||
}
|
||||
}
|
||||
|
||||
if ($need_saving) {
|
||||
if (isset($doc->firstChild))
|
||||
$doc->removeChild($doc->firstChild); //remove doctype
|
||||
|
||||
$res = $doc->saveHTML();
|
||||
}
|
||||
}
|
||||
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
class Errors {
|
||||
const E_SUCCESS = "E_SUCCESS";
|
||||
const E_UNAUTHORIZED = "E_UNAUTHORIZED";
|
||||
const E_UNKNOWN_METHOD = "E_UNKNOWN_METHOD";
|
||||
const E_UNKNOWN_PLUGIN = "E_UNKNOWN_PLUGIN";
|
||||
const E_SCHEMA_MISMATCH = "E_SCHEMA_MISMATCH";
|
||||
const E_URL_SCHEME_MISMATCH = "E_URL_SCHEME_MISMATCH";
|
||||
|
||||
/**
|
||||
* @param Errors::E_* $code
|
||||
* @param array<string, string> $params
|
||||
*/
|
||||
static function to_json(string $code, array $params = []): string {
|
||||
return json_encode(["error" => ["code" => $code, "params" => $params]]);
|
||||
}
|
||||
|
||||
static function libxml_last_error() : string {
|
||||
$error = libxml_get_last_error();
|
||||
$error_formatted = "";
|
||||
|
||||
if ($error) {
|
||||
foreach (libxml_get_errors() as $error) {
|
||||
if ($error->level == LIBXML_ERR_FATAL) {
|
||||
// currently only the first error is reported
|
||||
$error_formatted = self::format_libxml_error($error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return UConverter::transcode($error_formatted, 'UTF-8', 'UTF-8');
|
||||
}
|
||||
|
||||
static function format_libxml_error(LibXMLError $error) : string {
|
||||
return sprintf("LibXML error %s at line %d (column %d): %s",
|
||||
$error->code, $error->line, $error->column,
|
||||
$error->message);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
class FeedEnclosure {
|
||||
public string $link = '';
|
||||
public string $type = '';
|
||||
public string $length = '';
|
||||
public string $title = '';
|
||||
public string $height = '';
|
||||
public string $width = '';
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
abstract class FeedItem {
|
||||
abstract function get_id(): string;
|
||||
|
||||
/** @return int|false a timestamp on success, false otherwise */
|
||||
abstract function get_date(): false|int;
|
||||
|
||||
abstract function get_link(): string;
|
||||
abstract function get_title(): string;
|
||||
abstract function get_description(): string;
|
||||
abstract function get_content(): string;
|
||||
abstract function get_comments_url(): string;
|
||||
abstract function get_comments_count(): int;
|
||||
|
||||
/** @return array<int, string> */
|
||||
abstract function get_categories(): array;
|
||||
|
||||
/** @return array<int, FeedEnclosure> */
|
||||
abstract function get_enclosures(): array;
|
||||
|
||||
abstract function get_author(): string;
|
||||
abstract function get_language(): string;
|
||||
}
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
<?php
|
||||
class FeedItem_Atom extends FeedItem_Common {
|
||||
const NS_XML = "http://www.w3.org/XML/1998/namespace";
|
||||
|
||||
function get_id(): string {
|
||||
$id = $this->elem->getElementsByTagName("id")->item(0);
|
||||
|
||||
if ($id) {
|
||||
return $id->nodeValue;
|
||||
} else {
|
||||
return clean($this->get_link());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|false a timestamp on success, false otherwise
|
||||
*/
|
||||
function get_date(): false|int {
|
||||
$updated = $this->elem->getElementsByTagName("updated")->item(0);
|
||||
|
||||
if ($updated) {
|
||||
return strtotime($updated->nodeValue ?? '');
|
||||
}
|
||||
|
||||
$published = $this->elem->getElementsByTagName("published")->item(0);
|
||||
|
||||
if ($published) {
|
||||
return strtotime($published->nodeValue ?? '');
|
||||
}
|
||||
|
||||
$date = $this->xpath->query("dc:date", $this->elem)->item(0);
|
||||
|
||||
if ($date) {
|
||||
return strtotime($date->nodeValue ?? '');
|
||||
}
|
||||
|
||||
// consistent with strtotime failing to parse
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
function get_link(): string {
|
||||
$links = $this->elem->getElementsByTagName("link");
|
||||
|
||||
foreach ($links as $link) {
|
||||
if ($link->hasAttribute("href") &&
|
||||
(!$link->hasAttribute("rel")
|
||||
|| $link->getAttribute("rel") == "alternate"
|
||||
|| $link->getAttribute("rel") == "standout")) {
|
||||
$base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $link);
|
||||
|
||||
if ($base)
|
||||
return UrlHelper::rewrite_relative($base, clean(trim($link->getAttribute("href"))));
|
||||
else
|
||||
return clean(trim($link->getAttribute("href")));
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function get_title(): string {
|
||||
$title = $this->elem->getElementsByTagName("title")->item(0);
|
||||
return $title ? clean(trim($title->nodeValue)) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $base optional (returns $content if $base is null)
|
||||
* @param string $content an HTML string
|
||||
*
|
||||
* @return string the rewritten XML or original $content
|
||||
*/
|
||||
private function rewrite_content_to_base(?string $base = null, ?string $content = '') {
|
||||
|
||||
if (!empty($base) && !empty($content)) {
|
||||
|
||||
$tmpdoc = new DOMDocument();
|
||||
if (@$tmpdoc->loadHTML('<?xml encoding="UTF-8">' . $content)) {
|
||||
$tmpxpath = new DOMXPath($tmpdoc);
|
||||
|
||||
$elems = $tmpxpath->query("(//*[@href]|//*[@src])");
|
||||
|
||||
/** @var DOMElement $elem */
|
||||
foreach ($elems as $elem) {
|
||||
if ($elem->hasAttribute("href")) {
|
||||
$elem->setAttribute("href",
|
||||
UrlHelper::rewrite_relative($base, $elem->getAttribute("href")));
|
||||
} else if ($elem->hasAttribute("src")) {
|
||||
$elem->setAttribute("src",
|
||||
UrlHelper::rewrite_relative($base, $elem->getAttribute("src")));
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to $content if saveXML somehow fails (i.e. returns false)
|
||||
$modified_content = $tmpdoc->saveXML();
|
||||
return $modified_content !== false ? $modified_content : $content;
|
||||
}
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
function get_content(): string {
|
||||
/** @var DOMElement|null */
|
||||
$content = $this->elem->getElementsByTagName("content")->item(0);
|
||||
|
||||
if ($content) {
|
||||
$base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $content);
|
||||
|
||||
if ($content->hasAttribute('type')) {
|
||||
if ($content->getAttribute('type') == 'xhtml') {
|
||||
for ($i = 0; $i < $content->childNodes->length; $i++) {
|
||||
$child = $content->childNodes->item($i);
|
||||
|
||||
if ($child->hasChildNodes()) {
|
||||
return $this->rewrite_content_to_base($base, $this->doc->saveHTML($child));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->rewrite_content_to_base($base, $this->subtree_or_text($content));
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
// TODO: duplicate code should be merged with get_content()
|
||||
function get_description(): string {
|
||||
/** @var DOMElement|null */
|
||||
$content = $this->elem->getElementsByTagName("summary")->item(0);
|
||||
|
||||
if ($content) {
|
||||
$base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $content);
|
||||
|
||||
if ($content->hasAttribute('type')) {
|
||||
if ($content->getAttribute('type') == 'xhtml') {
|
||||
for ($i = 0; $i < $content->childNodes->length; $i++) {
|
||||
$child = $content->childNodes->item($i);
|
||||
|
||||
if ($child->hasChildNodes()) {
|
||||
return $this->rewrite_content_to_base($base, $this->doc->saveHTML($child));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->rewrite_content_to_base($base, $this->subtree_or_text($content));
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
function get_categories(): array {
|
||||
$categories = $this->elem->getElementsByTagName("category");
|
||||
$cats = [];
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
if ($cat->hasAttribute("term"))
|
||||
array_push($cats, $cat->getAttribute("term"));
|
||||
}
|
||||
|
||||
$categories = $this->xpath->query("dc:subject", $this->elem);
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
array_push($cats, $cat->nodeValue);
|
||||
}
|
||||
|
||||
return $this->normalize_categories($cats);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, FeedEnclosure>
|
||||
*/
|
||||
function get_enclosures(): array {
|
||||
$links = $this->elem->getElementsByTagName("link");
|
||||
|
||||
$encs = [];
|
||||
|
||||
foreach ($links as $link) {
|
||||
if ($link->hasAttribute("href") && $link->hasAttribute("rel")) {
|
||||
$base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $link);
|
||||
|
||||
if ($link->getAttribute("rel") == "enclosure") {
|
||||
$enc = new FeedEnclosure();
|
||||
$enc->type = clean($link->getAttribute('type'));
|
||||
$enc->length = clean($link->getAttribute('length'));
|
||||
$enc->link = clean($link->getAttribute('href'));
|
||||
|
||||
if (!empty($base)) {
|
||||
$enc->link = UrlHelper::rewrite_relative($base, $enc->link);
|
||||
}
|
||||
|
||||
array_push($encs, $enc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
array_push($encs, ...parent::get_enclosures());
|
||||
|
||||
return $encs;
|
||||
}
|
||||
|
||||
function get_language(): string {
|
||||
$lang = $this->elem->getAttributeNS(self::NS_XML, "lang");
|
||||
|
||||
if (!empty($lang)) {
|
||||
return clean($lang);
|
||||
} else {
|
||||
// Fall back to the language declared on the feed, if any.
|
||||
/** @var DOMElement|DOMNode $child */
|
||||
foreach ($this->doc->childNodes as $child) {
|
||||
if (method_exists($child, "getAttributeNS")) {
|
||||
return clean($child->getAttributeNS(self::NS_XML, "lang"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
<?php
|
||||
abstract class FeedItem_Common extends FeedItem {
|
||||
protected readonly DOMElement $elem;
|
||||
protected readonly DOMDocument $doc;
|
||||
protected readonly DOMXPath $xpath;
|
||||
|
||||
function __construct(DOMElement $elem, DOMDocument $doc, DOMXPath $xpath) {
|
||||
$this->elem = $elem;
|
||||
$this->doc = $doc;
|
||||
$this->xpath = $xpath;
|
||||
|
||||
try {
|
||||
$source = $elem->getElementsByTagName("source")->item(0);
|
||||
|
||||
// we don't need <source> element
|
||||
if ($source)
|
||||
$elem->removeChild($source);
|
||||
} catch (DOMException $e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
function get_element(): DOMElement {
|
||||
return $this->elem;
|
||||
}
|
||||
|
||||
function get_author(): string {
|
||||
/** @var DOMElement|null */
|
||||
$author = $this->elem->getElementsByTagName("author")->item(0);
|
||||
|
||||
if ($author) {
|
||||
$name = $author->getElementsByTagName("name")->item(0);
|
||||
|
||||
if ($name) return clean($name->nodeValue);
|
||||
|
||||
$email = $author->getElementsByTagName("email")->item(0);
|
||||
|
||||
if ($email) return clean($email->nodeValue);
|
||||
|
||||
if ($author->nodeValue)
|
||||
return clean($author->nodeValue);
|
||||
}
|
||||
|
||||
$author_elems = $this->xpath->query("dc:creator", $this->elem);
|
||||
$authors = [];
|
||||
|
||||
foreach ($author_elems as $author) {
|
||||
array_push($authors, clean($author->nodeValue));
|
||||
}
|
||||
|
||||
return implode(", ", $authors);
|
||||
}
|
||||
|
||||
function get_comments_url(): string {
|
||||
//RSS only. Use a query here to avoid namespace clashes (e.g. with slash).
|
||||
//might give a wrong result if a default namespace was declared (possible with XPath 2.0)
|
||||
$com_url = $this->xpath->query("comments", $this->elem)->item(0);
|
||||
|
||||
if ($com_url)
|
||||
return clean($com_url->nodeValue);
|
||||
|
||||
//Atom Threading Extension (RFC 4685) stuff. Could be used in RSS feeds, so it's in common.
|
||||
//'text/html' for type is too restrictive?
|
||||
$com_url = $this->xpath->query("atom:link[@rel='replies' and contains(@type,'text/html')]/@href", $this->elem)->item(0);
|
||||
|
||||
if ($com_url)
|
||||
return clean($com_url->nodeValue);
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function get_comments_count(): int {
|
||||
//also query for ATE stuff here
|
||||
$query = "slash:comments|thread:total|atom:link[@rel='replies']/@thread:count";
|
||||
$comments = $this->xpath->query($query, $this->elem)->item(0);
|
||||
|
||||
if ($comments && is_numeric($comments->nodeValue)) {
|
||||
return (int) clean($comments->nodeValue);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* this is common for both Atom and RSS types and deals with various 'media:' elements
|
||||
*
|
||||
* @see https://www.rssboard.org/media-rss
|
||||
* @return array<int, FeedEnclosure>
|
||||
*/
|
||||
function get_enclosures(): array {
|
||||
$encs = [];
|
||||
|
||||
$enclosures = $this->xpath->query("media:content", $this->elem);
|
||||
|
||||
/** @var DOMElement $enclosure */
|
||||
foreach ($enclosures as $enclosure) {
|
||||
$enc = new FeedEnclosure();
|
||||
$enc->type = clean($enclosure->getAttribute('type'));
|
||||
$enc->link = clean($enclosure->getAttribute('url'));
|
||||
$enc->length = clean($enclosure->getAttribute('length'));
|
||||
$enc->height = clean($enclosure->getAttribute('height'));
|
||||
$enc->width = clean($enclosure->getAttribute('width'));
|
||||
|
||||
$medium = clean($enclosure->getAttribute("medium"));
|
||||
if (!$enc->type && $medium) {
|
||||
$enc->type = strtolower("$medium/generic");
|
||||
}
|
||||
|
||||
$desc = $this->xpath->query("media:description", $enclosure)->item(0);
|
||||
if ($desc) $enc->title = clean($desc->nodeValue);
|
||||
|
||||
array_push($encs, $enc);
|
||||
}
|
||||
|
||||
$enclosures = $this->xpath->query("media:group", $this->elem);
|
||||
|
||||
foreach ($enclosures as $enclosure) {
|
||||
/** @var DOMElement|null */
|
||||
$content = $this->xpath->query("media:content", $enclosure)->item(0);
|
||||
|
||||
if ($content) {
|
||||
$enc = new FeedEnclosure();
|
||||
$enc->type = clean($content->getAttribute('type'));
|
||||
$enc->link = clean($content->getAttribute('url'));
|
||||
$enc->length = clean($content->getAttribute('length'));
|
||||
$enc->height = clean($content->getAttribute('height'));
|
||||
$enc->width = clean($content->getAttribute('width'));
|
||||
|
||||
$medium = clean($content->getAttribute("medium"));
|
||||
if (!$enc->type && $medium) {
|
||||
$enc->type = strtolower("$medium/generic");
|
||||
}
|
||||
|
||||
$desc = $this->xpath->query("media:description", $content)->item(0);
|
||||
if ($desc) {
|
||||
$enc->title = clean($desc->nodeValue);
|
||||
} else {
|
||||
$desc = $this->xpath->query("media:description", $enclosure)->item(0);
|
||||
if ($desc) $enc->title = clean($desc->nodeValue);
|
||||
}
|
||||
|
||||
array_push($encs, $enc);
|
||||
}
|
||||
}
|
||||
|
||||
$enclosures = $this->xpath->query("(.|media:content|media:group|media:group/media:content)/media:thumbnail", $this->elem);
|
||||
|
||||
/** @var DOMElement $enclosure */
|
||||
foreach ($enclosures as $enclosure) {
|
||||
$enc = new FeedEnclosure();
|
||||
$enc->type = 'image/generic';
|
||||
$enc->link = clean($enclosure->getAttribute('url'));
|
||||
$enc->height = clean($enclosure->getAttribute('height'));
|
||||
$enc->width = clean($enclosure->getAttribute('width'));
|
||||
|
||||
array_push($encs, $enc);
|
||||
}
|
||||
|
||||
return $encs;
|
||||
}
|
||||
|
||||
function count_children(DOMElement $node): int {
|
||||
return $node->getElementsByTagName("*")->length;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false|string false on failure, otherwise string contents
|
||||
*/
|
||||
function subtree_or_text(DOMElement $node): false|string {
|
||||
if ($this->count_children($node) == 0) {
|
||||
return $node->nodeValue;
|
||||
} else {
|
||||
return $node->c14n();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $cats
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
static function normalize_categories(array $cats): array {
|
||||
|
||||
$tmp = [];
|
||||
|
||||
foreach ($cats as $rawcat) {
|
||||
array_push($tmp, ...explode(",", $rawcat));
|
||||
}
|
||||
|
||||
$tmp = array_map(function($srccat) {
|
||||
$cat = clean(trim(mb_strtolower($srccat)));
|
||||
|
||||
// we don't support numeric tags
|
||||
if (is_numeric($cat))
|
||||
$cat = 't:' . $cat;
|
||||
|
||||
$cat = preg_replace('/[,\'\"]/', "", $cat);
|
||||
|
||||
if (mb_strlen($cat) > 250)
|
||||
$cat = mb_substr($cat, 0, 250);
|
||||
|
||||
return $cat;
|
||||
}, $tmp);
|
||||
|
||||
// remove empty values
|
||||
$tmp = array_filter($tmp, 'strlen');
|
||||
|
||||
asort($tmp);
|
||||
|
||||
return array_unique($tmp);
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
<?php
|
||||
class FeedItem_RSS extends FeedItem_Common {
|
||||
function get_id(): string {
|
||||
$id = $this->elem->getElementsByTagName("guid")->item(0);
|
||||
|
||||
if ($id) {
|
||||
return clean($id->nodeValue);
|
||||
} else {
|
||||
return clean($this->get_link());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|false a timestamp on success, false otherwise
|
||||
*/
|
||||
function get_date(): false|int {
|
||||
$pubDate = $this->elem->getElementsByTagName("pubDate")->item(0);
|
||||
|
||||
if ($pubDate) {
|
||||
return strtotime($pubDate->nodeValue ?? '');
|
||||
}
|
||||
|
||||
$date = $this->xpath->query("dc:date", $this->elem)->item(0);
|
||||
|
||||
if ($date) {
|
||||
return strtotime($date->nodeValue ?? '');
|
||||
}
|
||||
|
||||
// consistent with strtotime failing to parse
|
||||
return false;
|
||||
}
|
||||
|
||||
function get_link(): string {
|
||||
$links = $this->xpath->query("atom:link", $this->elem);
|
||||
|
||||
/** @var DOMElement $link */
|
||||
foreach ($links as $link) {
|
||||
if ($link->hasAttribute("href") &&
|
||||
(!$link->hasAttribute("rel")
|
||||
|| $link->getAttribute("rel") == "alternate"
|
||||
|| $link->getAttribute("rel") == "standout")) {
|
||||
|
||||
return clean(trim($link->getAttribute("href")));
|
||||
}
|
||||
}
|
||||
|
||||
/** @var DOMElement|null */
|
||||
$link = $this->elem->getElementsByTagName("guid")->item(0);
|
||||
|
||||
if ($link && $link->hasAttributes() && $link->getAttribute("isPermaLink") == "true") {
|
||||
return clean(trim($link->nodeValue));
|
||||
}
|
||||
|
||||
$link = $this->elem->getElementsByTagName("link")->item(0);
|
||||
|
||||
if ($link) {
|
||||
return clean(trim($link->nodeValue));
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function get_title(): string {
|
||||
$title = $this->xpath->query("title", $this->elem)->item(0);
|
||||
|
||||
if ($title) {
|
||||
return clean(trim($title->nodeValue));
|
||||
}
|
||||
|
||||
// if the document has a default namespace then querying for
|
||||
// title would fail because of reasons so let's try the old way
|
||||
$title = $this->elem->getElementsByTagName("title")->item(0);
|
||||
|
||||
if ($title) {
|
||||
return clean(trim($title->nodeValue));
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function get_content(): string {
|
||||
/** @var DOMElement|null */
|
||||
$contentA = $this->xpath->query("content:encoded", $this->elem)->item(0);
|
||||
|
||||
/** @var DOMElement|null */
|
||||
$contentB = $this->elem->getElementsByTagName("description")->item(0);
|
||||
|
||||
if ($contentA && $contentB) {
|
||||
$resultA = $this->subtree_or_text($contentA);
|
||||
$resultB = $this->subtree_or_text($contentB);
|
||||
|
||||
return mb_strlen($resultA) > mb_strlen($resultB) ? $resultA : $resultB;
|
||||
}
|
||||
|
||||
if ($contentA) {
|
||||
return $this->subtree_or_text($contentA);
|
||||
}
|
||||
|
||||
if ($contentB) {
|
||||
return $this->subtree_or_text($contentB);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function get_description(): string {
|
||||
$summary = $this->elem->getElementsByTagName("description")->item(0);
|
||||
|
||||
if ($summary) {
|
||||
return $summary->nodeValue;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
function get_categories(): array {
|
||||
$categories = $this->elem->getElementsByTagName("category");
|
||||
$cats = [];
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
array_push($cats, $cat->nodeValue);
|
||||
}
|
||||
|
||||
$categories = $this->xpath->query("dc:subject", $this->elem);
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
array_push($cats, $cat->nodeValue);
|
||||
}
|
||||
|
||||
return $this->normalize_categories($cats);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, FeedEnclosure>
|
||||
*/
|
||||
function get_enclosures(): array {
|
||||
$enclosures = $this->elem->getElementsByTagName("enclosure");
|
||||
|
||||
$encs = array();
|
||||
|
||||
foreach ($enclosures as $enclosure) {
|
||||
$enc = new FeedEnclosure();
|
||||
$enc->type = clean($enclosure->getAttribute('type'));
|
||||
$enc->link = clean($enclosure->getAttribute('url'));
|
||||
$enc->length = clean($enclosure->getAttribute('length'));
|
||||
$enc->height = clean($enclosure->getAttribute('height'));
|
||||
$enc->width = clean($enclosure->getAttribute('width'));
|
||||
|
||||
array_push($encs, $enc);
|
||||
}
|
||||
|
||||
array_push($encs, ...parent::get_enclosures());
|
||||
|
||||
return $encs;
|
||||
}
|
||||
|
||||
function get_language(): string {
|
||||
$languages = $this->doc->getElementsByTagName('language');
|
||||
|
||||
if (count($languages) == 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return clean($languages[0]->textContent);
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
<?php
|
||||
class FeedParser {
|
||||
private DOMDocument $doc;
|
||||
|
||||
private ?string $error = null;
|
||||
|
||||
/** @var array<string> */
|
||||
private array $libxml_errors = [];
|
||||
|
||||
/** @var array<FeedItem> */
|
||||
private array $items = [];
|
||||
|
||||
private ?string $link = null;
|
||||
|
||||
private ?string $title = null;
|
||||
|
||||
/** @var FeedParser::FEED_* */
|
||||
private int $type;
|
||||
|
||||
private ?DOMXPath $xpath = null;
|
||||
|
||||
const FEED_UNKNOWN = -1;
|
||||
const FEED_RDF = 0;
|
||||
const FEED_RSS = 1;
|
||||
const FEED_ATOM = 2;
|
||||
|
||||
function __construct(string $data) {
|
||||
libxml_use_internal_errors(true);
|
||||
libxml_clear_errors();
|
||||
|
||||
$this->type = $this::FEED_UNKNOWN;
|
||||
|
||||
$this->doc = new DOMDocument();
|
||||
$this->doc->loadXML($data);
|
||||
|
||||
mb_substitute_character("none");
|
||||
|
||||
$error = libxml_get_last_error();
|
||||
|
||||
if ($error) {
|
||||
foreach (libxml_get_errors() as $error) {
|
||||
if ($error->level == LIBXML_ERR_FATAL) {
|
||||
// currently only the first error is reported
|
||||
$this->error ??= Errors::format_libxml_error($error);
|
||||
$this->libxml_errors[] = Errors::format_libxml_error($error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
libxml_clear_errors();
|
||||
|
||||
if ($this->error)
|
||||
return;
|
||||
|
||||
$this->xpath = new DOMXPath($this->doc);
|
||||
$this->xpath->registerNamespace('atom', 'http://www.w3.org/2005/Atom');
|
||||
$this->xpath->registerNamespace('atom03', 'http://purl.org/atom/ns#');
|
||||
$this->xpath->registerNamespace('media', 'http://search.yahoo.com/mrss/');
|
||||
$this->xpath->registerNamespace('rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#');
|
||||
$this->xpath->registerNamespace('slash', 'http://purl.org/rss/1.0/modules/slash/');
|
||||
$this->xpath->registerNamespace('dc', 'http://purl.org/dc/elements/1.1/');
|
||||
$this->xpath->registerNamespace('content', 'http://purl.org/rss/1.0/modules/content/');
|
||||
$this->xpath->registerNamespace('thread', 'http://purl.org/syndication/thread/1.0');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool false if initialization couldn't occur (e.g. parsing error or unrecognized feed type), otherwise true
|
||||
*/
|
||||
function init(): bool {
|
||||
if ($this->error)
|
||||
return false;
|
||||
|
||||
$type = $this->get_type();
|
||||
|
||||
if ($type === self::FEED_UNKNOWN)
|
||||
return false;
|
||||
|
||||
$xpath = $this->xpath;
|
||||
|
||||
switch ($type) {
|
||||
case $this::FEED_ATOM:
|
||||
$title = $xpath->query('//atom:feed/atom:title')->item(0)
|
||||
?? $xpath->query('//atom03:feed/atom03:title')->item(0);
|
||||
|
||||
if ($title) {
|
||||
$this->title = $title->nodeValue;
|
||||
}
|
||||
|
||||
/** @var DOMElement|null $link */
|
||||
$link = $xpath->query('//atom:feed/atom:link[not(@rel)]')->item(0)
|
||||
?? $xpath->query("//atom:feed/atom:link[@rel='alternate']")->item(0)
|
||||
?? $xpath->query('//atom03:feed/atom03:link[not(@rel)]')->item(0)
|
||||
?? $xpath->query("//atom03:feed/atom03:link[@rel='alternate']")->item(0);
|
||||
|
||||
if ($link?->getAttribute('href'))
|
||||
$this->link = $link->getAttribute('href');
|
||||
|
||||
$articles = $xpath->query("//atom:entry");
|
||||
|
||||
if (empty($articles) || $articles->length == 0)
|
||||
$articles = $xpath->query("//atom03:entry");
|
||||
|
||||
foreach ($articles as $article) {
|
||||
array_push($this->items, new FeedItem_Atom($article, $this->doc, $this->xpath));
|
||||
}
|
||||
|
||||
break;
|
||||
case $this::FEED_RSS:
|
||||
$title = $xpath->query("//channel/title")->item(0);
|
||||
|
||||
if ($title) {
|
||||
$this->title = $title->nodeValue;
|
||||
}
|
||||
|
||||
/** @var DOMElement|null $link */
|
||||
$link = $xpath->query("//channel/link")->item(0);
|
||||
|
||||
if ($link) {
|
||||
if ($link->getAttribute("href"))
|
||||
$this->link = $link->getAttribute("href");
|
||||
else if ($link->nodeValue)
|
||||
$this->link = $link->nodeValue;
|
||||
}
|
||||
|
||||
$articles = $xpath->query("//channel/item");
|
||||
|
||||
foreach ($articles as $article) {
|
||||
array_push($this->items, new FeedItem_RSS($article, $this->doc, $this->xpath));
|
||||
}
|
||||
|
||||
break;
|
||||
case $this::FEED_RDF:
|
||||
$xpath->registerNamespace('rssfake', 'http://purl.org/rss/1.0/');
|
||||
|
||||
$title = $xpath->query("//rssfake:channel/rssfake:title")->item(0);
|
||||
|
||||
if ($title) {
|
||||
$this->title = $title->nodeValue;
|
||||
}
|
||||
|
||||
$link = $xpath->query("//rssfake:channel/rssfake:link")->item(0);
|
||||
|
||||
if ($link) {
|
||||
$this->link = $link->nodeValue;
|
||||
}
|
||||
|
||||
$articles = $xpath->query("//rssfake:item");
|
||||
|
||||
foreach ($articles as $article) {
|
||||
array_push($this->items, new FeedItem_RSS($article, $this->doc, $this->xpath));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if ($this->title)
|
||||
$this->title = trim($this->title);
|
||||
|
||||
if ($this->link)
|
||||
$this->link = trim($this->link);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @deprecated use Errors::format_libxml_error() instead */
|
||||
function format_error(LibXMLError $error) : string {
|
||||
return Errors::format_libxml_error($error);
|
||||
}
|
||||
|
||||
// libxml may have invalid unicode data in error messages
|
||||
function error() : string {
|
||||
return UConverter::transcode($this->error ?? '', 'UTF-8', 'UTF-8');
|
||||
}
|
||||
|
||||
/** @return array<string> - WARNING: may return invalid unicode data */
|
||||
function errors() : array {
|
||||
return $this->libxml_errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return FeedParser::FEED_*
|
||||
*/
|
||||
function get_type(): int {
|
||||
if ($this->type !== self::FEED_UNKNOWN || $this->error)
|
||||
return $this->type;
|
||||
|
||||
$root_list = $this->xpath->query('(//atom03:feed|//atom:feed|//channel|//rdf:rdf|//rdf:RDF)');
|
||||
|
||||
if ($root_list && $root_list->length > 0) {
|
||||
/** @var DOMElement $root */
|
||||
$root = $root_list->item(0);
|
||||
|
||||
$this->type = match (mb_strtolower($root->tagName)) {
|
||||
'rdf:rdf' => self::FEED_RDF,
|
||||
'channel' => self::FEED_RSS,
|
||||
'feed', 'atom:feed' => self::FEED_ATOM,
|
||||
default => self::FEED_UNKNOWN,
|
||||
};
|
||||
}
|
||||
|
||||
if ($this->type === self::FEED_UNKNOWN)
|
||||
$this->error ??= 'Unknown/unsupported feed type';
|
||||
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
function get_link() : string {
|
||||
return clean($this->link ?? '');
|
||||
}
|
||||
|
||||
function get_title() : string {
|
||||
return clean($this->title ?? '');
|
||||
}
|
||||
|
||||
/** @return array<FeedItem> */
|
||||
function get_items() : array {
|
||||
return $this->items;
|
||||
}
|
||||
|
||||
/** @return array<string> */
|
||||
function get_links(string $rel) : array {
|
||||
$rv = array();
|
||||
|
||||
switch ($this->type) {
|
||||
case $this::FEED_ATOM:
|
||||
$links = $this->xpath->query("//atom:feed/atom:link");
|
||||
|
||||
/** @var DOMElement $link */
|
||||
foreach ($links as $link) {
|
||||
if (!$rel || $link->hasAttribute('rel') && $link->getAttribute('rel') == $rel) {
|
||||
array_push($rv, clean(trim($link->getAttribute('href'))));
|
||||
}
|
||||
}
|
||||
break;
|
||||
case $this::FEED_RSS:
|
||||
$links = $this->xpath->query("//atom:link");
|
||||
|
||||
/** @var DOMElement $link */
|
||||
foreach ($links as $link) {
|
||||
if (!$rel || $link->hasAttribute('rel') && $link->getAttribute('rel') == $rel) {
|
||||
array_push($rv, clean(trim($link->getAttribute('href'))));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return $rv;
|
||||
}
|
||||
}
|
||||
2400
classes/Feeds.php
2400
classes/Feeds.php
File diff suppressed because it is too large
Load Diff
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
class Handler implements IHandler {
|
||||
protected PDO $pdo;
|
||||
|
||||
/** @var array<int|string, mixed> */
|
||||
protected array $args;
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $args
|
||||
*/
|
||||
function __construct(array $args) {
|
||||
$this->pdo = Db::pdo();
|
||||
$this->args = $args;
|
||||
}
|
||||
|
||||
function csrf_ignore(string $method): bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
function before(string $method): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
function after(): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $p
|
||||
*/
|
||||
public static function _param_to_bool($p): bool {
|
||||
$p = clean($p);
|
||||
return $p && ($p !== "f" && $p !== "false");
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
class Handler_Administrative extends Handler_Protected {
|
||||
function before(string $method): bool {
|
||||
if (parent::before($method)) {
|
||||
if (($_SESSION["access_level"] ?? 0) >= UserHelper::ACCESS_LEVEL_ADMIN) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<?php
|
||||
class Handler_Protected extends Handler {
|
||||
|
||||
function before(string $method): bool {
|
||||
return parent::before($method) && !empty($_SESSION['uid']);
|
||||
}
|
||||
}
|
||||
@@ -1,861 +0,0 @@
|
||||
<?php
|
||||
class Handler_Public extends Handler {
|
||||
|
||||
/**
|
||||
* @param string $feed may be a feed ID or tag
|
||||
*/
|
||||
private function generate_syndicated_feed(int $owner_uid, string $feed, bool $is_cat,
|
||||
int $limit, int $offset, string $search, string $view_mode = "",
|
||||
string $format = 'atom', string $order = "", string $orig_guid = "", string $start_ts = ""): void {
|
||||
|
||||
// fail early if the requested format isn't recognized
|
||||
if (!in_array($format, ['atom', 'json'])) {
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
print "Unknown format: $format.";
|
||||
return;
|
||||
}
|
||||
|
||||
$note_style = 'color: #9a8c59; background-color: #fff7d5; '
|
||||
. 'border: 1px dashed #e7d796; padding: 5px; margin-bottom: 1em;';
|
||||
|
||||
if (!$limit)
|
||||
$limit = 60;
|
||||
|
||||
list($override_order, $skip_first_id_check) = Feeds::_order_to_override_query($order);
|
||||
|
||||
if (!$override_order) {
|
||||
$override_order = match (true) {
|
||||
$feed == Feeds::FEED_PUBLISHED && !$is_cat => 'last_published DESC',
|
||||
$feed == Feeds::FEED_STARRED && !$is_cat => 'last_marked DESC',
|
||||
default => 'date_entered DESC, updated DESC',
|
||||
};
|
||||
}
|
||||
|
||||
$params = array(
|
||||
"owner_uid" => $owner_uid,
|
||||
"feed" => $feed,
|
||||
"limit" => $limit,
|
||||
"view_mode" => $view_mode,
|
||||
"cat_view" => $is_cat,
|
||||
"search" => $search,
|
||||
"override_order" => $override_order,
|
||||
"include_children" => true,
|
||||
"ignore_vfeed_group" => true,
|
||||
"offset" => $offset,
|
||||
"start_ts" => $start_ts
|
||||
);
|
||||
|
||||
if (!$is_cat && is_numeric($feed) && $feed < PLUGIN_FEED_BASE_INDEX && $feed > LABEL_BASE_INDEX) {
|
||||
// TODO: _ENABLED_PLUGINS is profile-specific, so use of the default profile's plugins here should
|
||||
// be called out in the docs, and/or access key stuff (see 'rss()') should also consider the profile
|
||||
$user_plugins = Prefs::get(Prefs::_ENABLED_PLUGINS, $owner_uid);
|
||||
|
||||
$tmppluginhost = new PluginHost();
|
||||
$tmppluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_ALL);
|
||||
$tmppluginhost->load((string)$user_plugins, PluginHost::KIND_USER, $owner_uid);
|
||||
//$tmppluginhost->load_data();
|
||||
|
||||
$handler = $tmppluginhost->get_feed_handler(
|
||||
PluginHost::feed_to_pfeed_id((int)$feed));
|
||||
|
||||
if ($handler) {
|
||||
$qfh_ret = $handler->get_headlines(PluginHost::feed_to_pfeed_id((int)$feed), $params);
|
||||
} else {
|
||||
user_error("Failed to find handler for plugin feed ID: $feed", E_USER_ERROR);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
} else {
|
||||
$qfh_ret = Feeds::_get_headlines($params);
|
||||
}
|
||||
|
||||
$result = $qfh_ret[0];
|
||||
$feed_title = htmlspecialchars($qfh_ret[1]);
|
||||
$feed_site_url = $qfh_ret[2];
|
||||
/* $last_error = $qfh_ret[3]; */
|
||||
|
||||
$feed_self_url = Config::get_self_url() .
|
||||
"/public.php?op=rss&id=$feed&key=" .
|
||||
Feeds::_get_access_key($feed, false, $owner_uid);
|
||||
|
||||
if (!$feed_site_url)
|
||||
$feed_site_url = Config::get_self_url();
|
||||
|
||||
if ($format == 'atom') {
|
||||
$tpl = new Templator();
|
||||
|
||||
$tpl->readTemplateFromFile("generated_feed.txt");
|
||||
|
||||
$tpl->setVariable('FEED_TITLE', $feed_title, true);
|
||||
$tpl->setVariable('FEED_UPDATED', date('c'), true);
|
||||
$tpl->setVariable('VERSION', Config::get_version(), true);
|
||||
$tpl->setVariable('FEED_URL', htmlspecialchars($feed_self_url), true);
|
||||
|
||||
$tpl->setVariable('SELF_URL', htmlspecialchars(Config::get_self_url()), true);
|
||||
|
||||
while ($line = $result->fetch()) {
|
||||
|
||||
$line["content_preview"] = Sanitizer::sanitize(truncate_string(strip_tags($line["content"]), 100, '...'));
|
||||
$line["tags"] = Article::_get_tags($line["id"], $owner_uid);
|
||||
|
||||
$max_excerpt_length = 250;
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES,
|
||||
function ($result) use (&$line) {
|
||||
$line = $result;
|
||||
},
|
||||
$line, $max_excerpt_length);
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_EXPORT_FEED,
|
||||
function ($result) use (&$line) {
|
||||
$line = $result;
|
||||
},
|
||||
$line, $feed, $is_cat, $owner_uid);
|
||||
|
||||
$tpl->setVariable('ARTICLE_ID',
|
||||
htmlspecialchars($orig_guid ? $line['link'] :
|
||||
$this->_make_article_tag_uri($line['id'], $line['date_entered'])), true);
|
||||
$tpl->setVariable('ARTICLE_LINK', htmlspecialchars($line['link']), true);
|
||||
$tpl->setVariable('ARTICLE_TITLE', htmlspecialchars($line['title']), true);
|
||||
$tpl->setVariable('ARTICLE_EXCERPT', $line["content_preview"], true);
|
||||
|
||||
$content = Sanitizer::sanitize($line["content"], false, $owner_uid,
|
||||
$feed_site_url, null, $line["id"]);
|
||||
|
||||
$content = DiskCache::rewrite_urls($content);
|
||||
|
||||
if ($line['note']) {
|
||||
$content = "<div style=\"$note_style\">Article note: " . $line['note'] . "</div>" . $content;
|
||||
$tpl->setVariable('ARTICLE_NOTE', htmlspecialchars($line['note']), true);
|
||||
}
|
||||
|
||||
$tpl->setVariable('ARTICLE_CONTENT', $content, true);
|
||||
|
||||
$tpl->setVariable('ARTICLE_UPDATED_ATOM',
|
||||
date('c', strtotime($line["updated"] ?? '')), true);
|
||||
$tpl->setVariable('ARTICLE_UPDATED_RFC822',
|
||||
date(DATE_RFC822, strtotime($line["updated"] ?? '')), true);
|
||||
|
||||
$tpl->setVariable('ARTICLE_AUTHOR', htmlspecialchars($line['author']), true);
|
||||
|
||||
$tpl->setVariable('ARTICLE_SOURCE_LINK', htmlspecialchars($line['site_url'] ? $line["site_url"] : Config::get_self_url()), true);
|
||||
$tpl->setVariable('ARTICLE_SOURCE_TITLE', htmlspecialchars($line['feed_title'] ?? $feed_title), true);
|
||||
|
||||
foreach ($line["tags"] as $tag) {
|
||||
$tpl->setVariable('ARTICLE_CATEGORY', htmlspecialchars($tag), true);
|
||||
$tpl->addBlock('category');
|
||||
}
|
||||
|
||||
$enclosures = Article::_get_enclosures($line["id"]);
|
||||
|
||||
if (count($enclosures) > 0) {
|
||||
foreach ($enclosures as $e) {
|
||||
$type = htmlspecialchars($e['content_type']);
|
||||
$url = htmlspecialchars($e['content_url']);
|
||||
$length = $e['duration'] ?: 1;
|
||||
|
||||
$tpl->setVariable('ARTICLE_ENCLOSURE_URL', $url, true);
|
||||
$tpl->setVariable('ARTICLE_ENCLOSURE_TYPE', $type, true);
|
||||
$tpl->setVariable('ARTICLE_ENCLOSURE_LENGTH', $length, true);
|
||||
|
||||
$tpl->addBlock('enclosure');
|
||||
}
|
||||
} else {
|
||||
$tpl->setVariable('ARTICLE_ENCLOSURE_URL', "", true);
|
||||
$tpl->setVariable('ARTICLE_ENCLOSURE_TYPE', "", true);
|
||||
$tpl->setVariable('ARTICLE_ENCLOSURE_LENGTH', "", true);
|
||||
}
|
||||
|
||||
list ($og_image, $og_stream) = Article::_get_image($enclosures, $line['content'], $feed_site_url, $line);
|
||||
|
||||
$tpl->setVariable('ARTICLE_OG_IMAGE', $og_image, true);
|
||||
|
||||
$tpl->addBlock('entry');
|
||||
}
|
||||
|
||||
$tmp = "";
|
||||
|
||||
$tpl->addBlock('feed');
|
||||
$tpl->generateOutputToString($tmp);
|
||||
|
||||
if (empty($_REQUEST["noxml"])) {
|
||||
header("Content-Type: text/xml; charset=utf-8");
|
||||
} else {
|
||||
header("Content-Type: text/plain; charset=utf-8");
|
||||
}
|
||||
|
||||
print $tmp;
|
||||
} else { // $format == 'json'
|
||||
|
||||
$feed = [
|
||||
'title' => $feed_title,
|
||||
'feed_url' => $feed_self_url,
|
||||
'self_url' => Config::get_self_url(),
|
||||
'articles' => [],
|
||||
];
|
||||
|
||||
while ($line = $result->fetch()) {
|
||||
|
||||
$line["content_preview"] = Sanitizer::sanitize(truncate_string(strip_tags($line["content"]), 100, '...'));
|
||||
$line["tags"] = Article::_get_tags($line["id"], $owner_uid);
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES,
|
||||
function ($result) use (&$line) {
|
||||
$line = $result;
|
||||
},
|
||||
$line);
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_EXPORT_FEED,
|
||||
function ($result) use (&$line) {
|
||||
$line = $result;
|
||||
},
|
||||
$line, $feed, $is_cat, $owner_uid);
|
||||
|
||||
$article = [
|
||||
'id' => $line['link'],
|
||||
'link' => $line['link'],
|
||||
'title' => $line['title'],
|
||||
'content' => Sanitizer::sanitize($line['content'], false, $owner_uid, $feed_site_url, null, $line['id']),
|
||||
'updated' => date('c', strtotime($line['updated'] ?? '')),
|
||||
'source' => [
|
||||
'link' => $line['site_url'] ?: Config::get_self_url(),
|
||||
'title' => $line['feed_title'] ?? $feed_title,
|
||||
],
|
||||
];
|
||||
|
||||
if (!empty($line['note']))
|
||||
$article['note'] = $line['note'];
|
||||
|
||||
if (!empty($line['author']))
|
||||
$article['author'] = $line['author'];
|
||||
|
||||
if (count($line['tags']) > 0)
|
||||
$article['tags'] = $line['tags'];
|
||||
|
||||
$enclosures = Article::_get_enclosures($line["id"]);
|
||||
|
||||
if (count($enclosures) > 0) {
|
||||
$article['enclosures'] = array();
|
||||
|
||||
foreach ($enclosures as $e) {
|
||||
$article['enclosures'][] = [
|
||||
'url' => $e['content_url'],
|
||||
'type' => $e['content_type'],
|
||||
'length' => $e['duration'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
array_push($feed['articles'], $article);
|
||||
}
|
||||
|
||||
header("Content-Type: application/json; charset=utf-8");
|
||||
print json_encode($feed);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
function getUnread(): void {
|
||||
$login = clean($_REQUEST["login"]);
|
||||
$fresh = clean($_REQUEST["fresh"] ?? "0") == "1";
|
||||
|
||||
$uid = UserHelper::find_user_by_login($login);
|
||||
|
||||
if ($uid) {
|
||||
print Feeds::_get_global_unread($uid);
|
||||
|
||||
if ($fresh) {
|
||||
print ";";
|
||||
print Feeds::_get_counters(Feeds::FEED_FRESH, false, true, $uid);
|
||||
}
|
||||
} else {
|
||||
print "-1;User not found";
|
||||
}
|
||||
}
|
||||
|
||||
function getProfiles(): void {
|
||||
$login = clean($_REQUEST["login"]);
|
||||
$rv = [];
|
||||
|
||||
if ($login) {
|
||||
$profiles = ORM::for_table('ttrss_settings_profiles')
|
||||
->table_alias('p')
|
||||
->select_many('title' , 'p.id')
|
||||
->join('ttrss_users', ['owner_uid', '=', 'u.id'], 'u')
|
||||
->where_raw('LOWER(login) = LOWER(?)', [$login])
|
||||
->order_by_asc('title')
|
||||
->find_many();
|
||||
|
||||
$rv = [ [ "value" => 0, "label" => __("Default profile") ] ];
|
||||
|
||||
foreach ($profiles as $profile) {
|
||||
array_push($rv, [ "label" => $profile->title, "value" => $profile->id ]);
|
||||
}
|
||||
}
|
||||
|
||||
print json_encode($rv);
|
||||
}
|
||||
|
||||
function logout(): void {
|
||||
if (validate_csrf($_POST["csrf_token"])) {
|
||||
|
||||
$login = $_SESSION["name"];
|
||||
$user_id = $_SESSION["uid"];
|
||||
|
||||
UserHelper::logout();
|
||||
|
||||
$redirect_url = "";
|
||||
|
||||
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_POST_LOGOUT,
|
||||
function ($result) use (&$redirect_url) {
|
||||
if (!empty($result[0]))
|
||||
$redirect_url = UrlHelper::validate($result[0]);
|
||||
},
|
||||
$login, $user_id);
|
||||
|
||||
if (!$redirect_url)
|
||||
$redirect_url = Config::get_self_url() . "/index.php";
|
||||
|
||||
header("Location: " . $redirect_url);
|
||||
} else {
|
||||
header("Content-Type: application/json");
|
||||
print Errors::to_json(Errors::E_UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
function rss(): void {
|
||||
$feed = clean($_REQUEST["id"]);
|
||||
$key = clean($_REQUEST["key"]);
|
||||
$is_cat = self::_param_to_bool($_REQUEST["is_cat"] ?? false);
|
||||
$limit = (int)clean($_REQUEST["limit"] ?? 0);
|
||||
$offset = (int)clean($_REQUEST["offset"] ?? 0);
|
||||
|
||||
$search = clean($_REQUEST["q"] ?? "");
|
||||
$view_mode = clean($_REQUEST["view-mode"] ?? "");
|
||||
$order = clean($_REQUEST["order"] ?? "");
|
||||
$start_ts = clean($_REQUEST["ts"] ?? "");
|
||||
|
||||
$format = clean($_REQUEST['format'] ?? "atom");
|
||||
$orig_guid = clean($_REQUEST["orig_guid"] ?? "");
|
||||
|
||||
if (Config::get(Config::SINGLE_USER_MODE)) {
|
||||
UserHelper::authenticate("admin", null);
|
||||
}
|
||||
|
||||
if ($key) {
|
||||
$access_key = ORM::for_table('ttrss_access_keys')
|
||||
->select('owner_uid')
|
||||
->where(['access_key' => $key, 'feed_id' => $feed])
|
||||
->find_one();
|
||||
|
||||
if ($access_key) {
|
||||
$this->generate_syndicated_feed($access_key->owner_uid, $feed, $is_cat, $limit,
|
||||
$offset, $search, $view_mode, $format, $order, $orig_guid, $start_ts);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
header('HTTP/1.1 403 Forbidden');
|
||||
}
|
||||
|
||||
function login(): void {
|
||||
if (!Config::get(Config::SINGLE_USER_MODE)) {
|
||||
|
||||
$login = clean($_POST["login"]);
|
||||
$password = clean($_POST["password"]);
|
||||
$remember_me = clean($_POST["remember_me"] ?? false);
|
||||
$safe_mode = checkbox_to_sql_bool($_POST["safe_mode"] ?? false);
|
||||
|
||||
if (session_status() != PHP_SESSION_ACTIVE) {
|
||||
if ($remember_me) {
|
||||
session_set_cookie_params(Config::get(Config::SESSION_COOKIE_LIFETIME));
|
||||
} else {
|
||||
session_set_cookie_params(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (UserHelper::authenticate($login, $password)) {
|
||||
$_POST["password"] = "";
|
||||
|
||||
$_SESSION["ref_schema_version"] = Config::get_schema_version();
|
||||
$_SESSION["bw_limit"] = !!clean($_POST["bw_limit"] ?? false);
|
||||
$_SESSION["safe_mode"] = $safe_mode;
|
||||
|
||||
if (!empty($_POST["profile"])) {
|
||||
$profile = (int) clean($_POST["profile"]);
|
||||
|
||||
$profile_obj = ORM::for_table('ttrss_settings_profiles')
|
||||
->where(['id' => $profile, 'owner_uid' => $_SESSION['uid']])
|
||||
->find_one();
|
||||
|
||||
$_SESSION["profile"] = $profile_obj ? $profile : null;
|
||||
}
|
||||
} else {
|
||||
|
||||
// start an empty session to deliver login error message
|
||||
if (session_status() != PHP_SESSION_ACTIVE)
|
||||
session_start();
|
||||
|
||||
$_SESSION["login_error_msg"] ??= __("Incorrect username or password");
|
||||
}
|
||||
|
||||
$return = clean($_REQUEST['return'] ?? '');
|
||||
|
||||
if ($return && mb_strpos($return, Config::get_self_url()) === 0) {
|
||||
header("Location: $return");
|
||||
} else {
|
||||
header("Location: " . Config::get_self_url());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function index(): void {
|
||||
header("Content-Type: text/plain");
|
||||
print Errors::to_json(Errors::E_UNKNOWN_METHOD);
|
||||
}
|
||||
|
||||
function forgotpass(): void {
|
||||
if (Config::get(Config::DISABLE_LOGIN_FORM) || !str_contains(Config::get(Config::PLUGINS), "auth_internal")) {
|
||||
header($_SERVER["SERVER_PROTOCOL"]." 403 Forbidden");
|
||||
echo "Forbidden.";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
startup_gettext();
|
||||
session_start();
|
||||
|
||||
$hash = clean($_REQUEST["hash"] ?? '');
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Tiny Tiny RSS</title>
|
||||
<link rel="shortcut icon" type="image/png" href="images/favicon.png">
|
||||
<link rel="icon" type="image/png" sizes="72x72" href="images/favicon-72px.png">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||
<?php
|
||||
echo javascript_tag("lib/dojo/dojo.js");
|
||||
echo javascript_tag("lib/dojo/tt-rss-layer.js");
|
||||
echo javascript_tag("js/common.js");
|
||||
echo javascript_tag("js/utility.js");
|
||||
?>
|
||||
<?= Config::get_override_links() ?>
|
||||
</head>
|
||||
<body class='flat ttrss_utility css_loading'>
|
||||
<div class='container'>
|
||||
|
||||
<script type="text/javascript">
|
||||
const __csrf_token = "<?= $_SESSION["csrf_token"]; ?>";
|
||||
|
||||
const __default_light_theme = "<?= get_theme_path(Config::get(Config::DEFAULT_LIGHT_THEME), 'themes/light.css') ?>";
|
||||
const __default_dark_theme = "<?= get_theme_path(Config::get(Config::DEFAULT_DARK_THEME), 'themes/night.css') ?>";
|
||||
</script>
|
||||
|
||||
<script type="text/javascript">
|
||||
require(['dojo/parser', "dojo/ready", 'dijit/form/Button','dijit/form/CheckBox', 'dijit/form/Form',
|
||||
'dijit/form/Select','dijit/form/TextBox','dijit/form/ValidationTextBox'],function(parser, ready){
|
||||
ready(function() {
|
||||
parser.parse();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style type="text/css">
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background : #303030;
|
||||
}
|
||||
}
|
||||
|
||||
body.css_loading * {
|
||||
display : none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<?php
|
||||
|
||||
print "<h1>".__("Password recovery")."</h1>";
|
||||
print "<div class='content'>";
|
||||
|
||||
$method = clean($_POST['method'] ?? '');
|
||||
|
||||
if ($hash) {
|
||||
$login = clean($_REQUEST["login"]);
|
||||
|
||||
if ($login) {
|
||||
$user = ORM::for_table('ttrss_users')
|
||||
->select_many('id', 'resetpass_token')
|
||||
->where_raw('LOWER(login) = LOWER(?)', [$login])
|
||||
->find_one();
|
||||
|
||||
if ($user) {
|
||||
list($timestamp, $resetpass_token) = explode(":", $user->resetpass_token);
|
||||
|
||||
if ($timestamp && $resetpass_token &&
|
||||
$timestamp >= time() - 15*60*60 &&
|
||||
$resetpass_token === $hash) {
|
||||
$user->resetpass_token = null;
|
||||
$user->save();
|
||||
|
||||
UserHelper::reset_password($user->id, true);
|
||||
|
||||
print "<p>"."Completed."."</p>";
|
||||
|
||||
} else {
|
||||
print_error("Some of the information provided is missing or incorrect.");
|
||||
}
|
||||
} else {
|
||||
print_error("Some of the information provided is missing or incorrect.");
|
||||
}
|
||||
} else {
|
||||
print_error("Some of the information provided is missing or incorrect.");
|
||||
}
|
||||
|
||||
print "<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>";
|
||||
|
||||
} else if (!$method) {
|
||||
print_notice(__("You will need to provide valid account name and email. Password reset link will be sent to your email address."));
|
||||
|
||||
print "<form method='POST' action='public.php'>
|
||||
<input type='hidden' name='method' value='do'>
|
||||
<input type='hidden' name='op' value='forgotpass'>
|
||||
|
||||
<fieldset>
|
||||
<label>".__("Login:")."</label>
|
||||
<input dojoType='dijit.form.TextBox' type='text' name='login' value='' required>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<label>".__("Email:")."</label>
|
||||
<input dojoType='dijit.form.TextBox' type='email' name='email' value='' required>
|
||||
</fieldset>";
|
||||
|
||||
$_SESSION["pwdreset:testvalue1"] = rand(1,10);
|
||||
$_SESSION["pwdreset:testvalue2"] = rand(1,10);
|
||||
|
||||
print "<fieldset>
|
||||
<label>".T_sprintf("How much is %d + %d:", $_SESSION["pwdreset:testvalue1"], $_SESSION["pwdreset:testvalue2"])."</label>
|
||||
<input dojoType='dijit.form.TextBox' type='text' name='test' value='' required>
|
||||
</fieldset>
|
||||
|
||||
<hr/>
|
||||
<fieldset>
|
||||
<button dojoType='dijit.form.Button' type='submit' class='alt-danger'>".__("Reset password")."</button>
|
||||
<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>
|
||||
</fieldset>
|
||||
|
||||
</form>";
|
||||
} else if ($method == 'do') {
|
||||
$login = clean($_POST["login"]);
|
||||
$email = clean($_POST["email"]);
|
||||
$test = clean($_POST["test"]);
|
||||
|
||||
if ($test != ($_SESSION["pwdreset:testvalue1"] + $_SESSION["pwdreset:testvalue2"]) || !$email || !$login) {
|
||||
print_error(__('Some of the required form parameters are missing or incorrect.'));
|
||||
|
||||
print "<form method='GET' action='public.php'>
|
||||
<input type='hidden' name='op' value='forgotpass'>
|
||||
<button dojoType='dijit.form.Button' type='submit' class='alt-primary'>".__("Go back")."</button>
|
||||
</form>";
|
||||
} else {
|
||||
// prevent submitting this form multiple times
|
||||
$_SESSION["pwdreset:testvalue1"] = rand(1, 1000);
|
||||
$_SESSION["pwdreset:testvalue2"] = rand(1, 1000);
|
||||
|
||||
$user = ORM::for_table('ttrss_users')
|
||||
->select('id')
|
||||
->where_raw('LOWER(login) = LOWER(?)', [$login])
|
||||
->where('email', $email)
|
||||
->find_one();
|
||||
|
||||
if ($user) {
|
||||
print_notice("Password reset instructions are being sent to your email address.");
|
||||
|
||||
$resetpass_token = sha1(get_random_bytes(128));
|
||||
$resetpass_link = Config::get_self_url() . "/public.php?op=forgotpass&hash=" . $resetpass_token .
|
||||
"&login=" . urlencode($login);
|
||||
|
||||
$tpl = new Templator();
|
||||
|
||||
$tpl->readTemplateFromFile("resetpass_link_template.txt");
|
||||
|
||||
$tpl->setVariable('LOGIN', $login);
|
||||
$tpl->setVariable('RESETPASS_LINK', $resetpass_link);
|
||||
$tpl->setVariable('TTRSS_HOST', Config::get_self_url());
|
||||
|
||||
$tpl->addBlock('message');
|
||||
|
||||
$message = "";
|
||||
|
||||
$tpl->generateOutputToString($message);
|
||||
|
||||
$mailer = new Mailer();
|
||||
|
||||
$rc = $mailer->mail(["to_name" => $login,
|
||||
"to_address" => $email,
|
||||
"subject" => __("[tt-rss] Password reset request"),
|
||||
"message" => $message]);
|
||||
|
||||
if (!$rc) print_error($mailer->error());
|
||||
|
||||
$user->resetpass_token = time() . ":" . $resetpass_token;
|
||||
$user->save();
|
||||
|
||||
print "<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>";
|
||||
} else {
|
||||
print_error(__("Sorry, login and email combination not found."));
|
||||
|
||||
print "<form method='GET' action='public.php'>
|
||||
<input type='hidden' name='op' value='forgotpass'>
|
||||
<button dojoType='dijit.form.Button' type='submit'>".__("Go back")."</button>
|
||||
</form>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print "</div>";
|
||||
print "</div>";
|
||||
print "</body>";
|
||||
print "</html>";
|
||||
}
|
||||
|
||||
function dbupdate(): void {
|
||||
startup_gettext();
|
||||
|
||||
if (!Config::get(Config::SINGLE_USER_MODE) && ($_SESSION["access_level"] ?? 0) < 10) {
|
||||
$_SESSION["login_error_msg"] = __("Your access level is insufficient to run this script.");
|
||||
$this->_render_login_form();
|
||||
exit;
|
||||
}
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Tiny Tiny RSS: Database Updater</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||
<link rel="icon" type="image/png" sizes="72x72" href="images/favicon-72px.png">
|
||||
<link rel="shortcut icon" type="image/png" href="images/favicon.png">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<?php
|
||||
foreach (["lib/dojo/dojo.js",
|
||||
"lib/dojo/tt-rss-layer.js",
|
||||
"js/common.js",
|
||||
"js/utility.js"] as $jsfile) {
|
||||
|
||||
echo javascript_tag($jsfile);
|
||||
|
||||
} ?>
|
||||
|
||||
<?= Config::get_override_links() ?>
|
||||
|
||||
<style type="text/css">
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background : #303030;
|
||||
}
|
||||
}
|
||||
|
||||
body.css_loading * {
|
||||
display : none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="text/javascript">
|
||||
require({cache:{}});
|
||||
</script>
|
||||
</head>
|
||||
<body class="flat ttrss_utility css_loading">
|
||||
|
||||
<script type="text/javascript">
|
||||
const UtilityApp = {
|
||||
init: function() {
|
||||
require(['dojo/parser', "dojo/ready", 'dijit/form/Button','dijit/form/CheckBox', 'dijit/form/Form',
|
||||
'dijit/form/Select','dijit/form/TextBox','dijit/form/ValidationTextBox'],function(parser, ready){
|
||||
ready(function() {
|
||||
parser.parse();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDbUpdate() {
|
||||
return confirm(__("Proceed with update?"));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<h1><?= __("Database Updater") ?></h1>
|
||||
|
||||
<div class="content">
|
||||
|
||||
<?php
|
||||
@$op = clean($_REQUEST["subop"] ?? "");
|
||||
|
||||
$migrations = Config::get_migrations();
|
||||
|
||||
if ($op == "performupdate") {
|
||||
if ($migrations->is_migration_needed()) {
|
||||
?>
|
||||
|
||||
<h2><?= T_sprintf("Performing updates to version %d", Config::SCHEMA_VERSION) ?></h2>
|
||||
|
||||
<code><pre class="small pre-wrap"><?php
|
||||
Debug::set_enabled(true);
|
||||
Debug::set_loglevel(Debug::LOG_VERBOSE);
|
||||
$result = $migrations->migrate();
|
||||
Debug::set_loglevel(Debug::LOG_NORMAL);
|
||||
Debug::set_enabled(false);
|
||||
?></pre></code>
|
||||
|
||||
<?php if (!$result) { ?>
|
||||
<?= format_error("One of migrations failed. Either retry the process or perform updates manually.") ?>
|
||||
|
||||
<form method="post">
|
||||
<?= \Controls\hidden_tag('subop', 'performupdate') ?>
|
||||
<?= \Controls\submit_tag(__("Update"), ["onclick" => "return confirmDbUpdate()"]) ?>
|
||||
</form>
|
||||
<?php } else { ?>
|
||||
<?= format_notice("Update successful.") ?>
|
||||
|
||||
<a href="index.php"><?= __("Return to Tiny Tiny RSS") ?></a>
|
||||
<?php }
|
||||
|
||||
} else { ?>
|
||||
|
||||
<?= format_notice("Database is already up to date.") ?>
|
||||
|
||||
<a href="index.php"><?= __("Return to Tiny Tiny RSS") ?></a>
|
||||
|
||||
<?php
|
||||
}
|
||||
} else {
|
||||
if ($migrations->is_migration_needed()) {
|
||||
|
||||
?>
|
||||
<h2><?= T_sprintf("Database schema needs update to the latest version (%d to %d).",
|
||||
Config::get_schema_version(), Config::SCHEMA_VERSION) ?></h2>
|
||||
|
||||
<?= format_warning("Please backup your database before proceeding.") ?>
|
||||
|
||||
<form method="post">
|
||||
<?= \Controls\hidden_tag('subop', 'performupdate') ?>
|
||||
<?= \Controls\submit_tag(__("Update"), ["onclick" => "return confirmDbUpdate()"]) ?>
|
||||
</form>
|
||||
|
||||
<?php
|
||||
} else { ?>
|
||||
|
||||
<?= format_notice("Database is already up to date.") ?>
|
||||
|
||||
<a href="index.php"><?= __("Return to Tiny Tiny RSS") ?></a>
|
||||
|
||||
<?php
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
}
|
||||
|
||||
function cached(): void {
|
||||
list ($cache_dir, $filename) = explode("/", $_GET["file"], 2);
|
||||
|
||||
// we do not allow files with extensions at the moment
|
||||
$filename = str_replace(".", "", $filename);
|
||||
|
||||
$cache = DiskCache::instance($cache_dir);
|
||||
|
||||
if ($cache->exists($filename)) {
|
||||
$size = $cache->get_size($filename);
|
||||
|
||||
if ($size && $size > 0)
|
||||
header("Content-Length: $size");
|
||||
|
||||
$cache->send($filename);
|
||||
} else {
|
||||
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
|
||||
echo "File not found.";
|
||||
}
|
||||
}
|
||||
|
||||
function feed_icon() : void {
|
||||
$id = (int)$_REQUEST['id'];
|
||||
$cache = DiskCache::instance('feed-icons');
|
||||
|
||||
if ($cache->exists((string)$id)) {
|
||||
$cache->send((string)$id);
|
||||
} else {
|
||||
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
|
||||
echo "File not found.";
|
||||
}
|
||||
}
|
||||
|
||||
private function _make_article_tag_uri(int $id, string $timestamp): string {
|
||||
|
||||
$timestamp = date("Y-m-d", strtotime($timestamp));
|
||||
|
||||
return "tag:" . parse_url(Config::get_self_url(), PHP_URL_HOST) . ",$timestamp:/$id";
|
||||
}
|
||||
|
||||
// this should be used very carefully because this endpoint is exposed to unauthenticated users
|
||||
// plugin data is not loaded because there's no user context and owner_uid/session may or may not be available
|
||||
// in general, don't do anything user-related in here and do not modify $_SESSION
|
||||
public function pluginhandler(): void {
|
||||
$host = new PluginHost();
|
||||
|
||||
$plugin_name = basename(clean($_REQUEST["plugin"]));
|
||||
$method = clean($_REQUEST["pmethod"]);
|
||||
|
||||
$host->load($plugin_name, PluginHost::KIND_ALL, 0);
|
||||
//$host->load_data();
|
||||
|
||||
$plugin = $host->get_plugin($plugin_name);
|
||||
|
||||
if ($plugin) {
|
||||
if (method_exists($plugin, $method)) {
|
||||
if ($plugin->is_public_method($method)) {
|
||||
$plugin->$method();
|
||||
} else {
|
||||
user_error("PluginHandler[PUBLIC]: Requested private method '$method' of plugin '$plugin_name'.", E_USER_WARNING);
|
||||
header("Content-Type: application/json");
|
||||
print Errors::to_json(Errors::E_UNAUTHORIZED);
|
||||
}
|
||||
} else {
|
||||
user_error("PluginHandler[PUBLIC]: Requested unknown method '$method' of plugin '$plugin_name'.", E_USER_WARNING);
|
||||
header("Content-Type: application/json");
|
||||
print Errors::to_json(Errors::E_UNKNOWN_METHOD);
|
||||
}
|
||||
} else {
|
||||
user_error("PluginHandler[PUBLIC]: Requested method '$method' of unknown plugin '$plugin_name'.", E_USER_WARNING);
|
||||
header("Content-Type: application/json");
|
||||
print Errors::to_json(Errors::E_UNKNOWN_PLUGIN, ['plugin' => $plugin_name]);
|
||||
}
|
||||
}
|
||||
|
||||
static function _render_login_form(string $return_to = ""): void {
|
||||
header('Cache-Control: public');
|
||||
|
||||
if ($return_to)
|
||||
$_REQUEST['return'] = $return_to;
|
||||
|
||||
require_once "login_form.php";
|
||||
exit;
|
||||
}
|
||||
|
||||
// implicit Config::sanity_check() does the actual checking */
|
||||
public function healthcheck() : void {
|
||||
header("Content-Type: text/plain");
|
||||
print "OK";
|
||||
}
|
||||
|
||||
}
|
||||
?>
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
interface IAuthModule {
|
||||
/**
|
||||
* @param string $login
|
||||
* @param string $password
|
||||
* @param string $service
|
||||
* @return int|false user_id
|
||||
*/
|
||||
function authenticate($login, $password, $service = '');
|
||||
|
||||
/** this is a pluginhost compatibility wrapper that invokes $this->authenticate(...$args) (Auth_Base)
|
||||
* @param string $login
|
||||
* @param string $password
|
||||
* @param string $service
|
||||
* @return int|false user_id
|
||||
*/
|
||||
function hook_auth_user($login, $password, $service = '');
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<?php
|
||||
interface IAuthModule2 extends IAuthModule {
|
||||
function change_password(int $owner_uid, string $old_password, string $new_password) : string;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<?php
|
||||
interface ICatchall {
|
||||
function catchall(string $method): void;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
<?php
|
||||
interface IHandler {
|
||||
function csrf_ignore(string $method): bool;
|
||||
function before(string $method): bool;
|
||||
function after(): bool;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
interface IVirtualFeed {
|
||||
function get_unread(int $feed_id) : int;
|
||||
function get_total(int $feed_id) : int;
|
||||
/**
|
||||
* @param int $feed_id
|
||||
* @param array<string,int|string|bool> $options
|
||||
* @return array<int,int|string>
|
||||
*/
|
||||
function get_headlines(int $feed_id, array $options) : array;
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
<?php
|
||||
class Labels
|
||||
{
|
||||
static function label_to_feed_id(int $label): int {
|
||||
return LABEL_BASE_INDEX - 1 - abs($label);
|
||||
}
|
||||
|
||||
static function feed_to_label_id(int $feed): int {
|
||||
return LABEL_BASE_INDEX - 1 + abs($feed);
|
||||
}
|
||||
|
||||
static function find_id(string $label, int $owner_uid): int {
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT id FROM ttrss_labels2 WHERE LOWER(caption) = LOWER(?)
|
||||
AND owner_uid = ? LIMIT 1");
|
||||
$sth->execute([$label, $owner_uid]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
return $row['id'];
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static function find_caption(int $label, int $owner_uid): string {
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT caption FROM ttrss_labels2 WHERE id = ?
|
||||
AND owner_uid = ? LIMIT 1");
|
||||
$sth->execute([$label, $owner_uid]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
return $row['caption'];
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
static function get_as_hash(int $owner_uid): array {
|
||||
$rv = [];
|
||||
$labels = Labels::get_all($owner_uid);
|
||||
|
||||
foreach ($labels as $i => $label) {
|
||||
$rv[(int)$label["id"]] = $labels[$i];
|
||||
}
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, string>> An array of label detail arrays
|
||||
*/
|
||||
static function get_all(int $owner_uid) {
|
||||
$rv = array();
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT id, fg_color, bg_color, caption FROM ttrss_labels2
|
||||
WHERE owner_uid = ? ORDER BY caption");
|
||||
$sth->execute([$owner_uid]);
|
||||
|
||||
while ($line = $sth->fetch(PDO::FETCH_ASSOC)) {
|
||||
array_push($rv, $line);
|
||||
}
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{'no-labels': 1}|array<int, array<int, array{0: int, 1: string, 2: string, 3: string}>> $labels
|
||||
* [label_id, caption, fg_color, bg_color]
|
||||
*
|
||||
* @see Article::_get_labels()
|
||||
*/
|
||||
static function update_cache(int $owner_uid, int $id, array $labels, bool $force = false): void {
|
||||
$pdo = Db::pdo();
|
||||
|
||||
if ($force)
|
||||
self::clear_cache($id);
|
||||
|
||||
if (!$labels)
|
||||
$labels = Article::_get_labels($id);
|
||||
|
||||
$labels = json_encode($labels);
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
label_cache = ? WHERE ref_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$labels, $id, $owner_uid]);
|
||||
|
||||
}
|
||||
|
||||
static function clear_cache(int $id): void {
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
label_cache = '' WHERE ref_id = ?");
|
||||
$sth->execute([$id]);
|
||||
|
||||
}
|
||||
|
||||
static function remove_article(int $id, string $label, int $owner_uid): void {
|
||||
|
||||
$label_id = self::find_id($label, $owner_uid);
|
||||
|
||||
if (!$label_id) return;
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("DELETE FROM ttrss_user_labels2
|
||||
WHERE
|
||||
label_id = ? AND
|
||||
article_id = ?");
|
||||
|
||||
$sth->execute([$label_id, $id]);
|
||||
|
||||
self::clear_cache($id);
|
||||
}
|
||||
|
||||
static function add_article(int $id, string $label, int $owner_uid): void {
|
||||
|
||||
$label_id = self::find_id($label, $owner_uid);
|
||||
|
||||
if (!$label_id) return;
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT
|
||||
article_id FROM ttrss_labels2, ttrss_user_labels2
|
||||
WHERE
|
||||
label_id = id AND
|
||||
label_id = ? AND
|
||||
article_id = ? AND owner_uid = ?
|
||||
LIMIT 1");
|
||||
|
||||
$sth->execute([$label_id, $id, $owner_uid]);
|
||||
|
||||
if (!$sth->fetch()) {
|
||||
$sth = $pdo->prepare("INSERT INTO ttrss_user_labels2
|
||||
(label_id, article_id) VALUES (?, ?)");
|
||||
|
||||
$sth->execute([$label_id, $id]);
|
||||
}
|
||||
|
||||
self::clear_cache($id);
|
||||
|
||||
}
|
||||
|
||||
static function remove(int $id, int $owner_uid): void {
|
||||
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
|
||||
|
||||
$pdo = Db::pdo();
|
||||
$tr_in_progress = false;
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
} catch (Exception $e) {
|
||||
$tr_in_progress = true;
|
||||
}
|
||||
|
||||
$sth = $pdo->prepare("SELECT caption FROM ttrss_labels2
|
||||
WHERE id = ?");
|
||||
$sth->execute([$id]);
|
||||
|
||||
$row = $sth->fetch();
|
||||
$caption = $row['caption'];
|
||||
|
||||
$sth = $pdo->prepare("DELETE FROM ttrss_labels2 WHERE id = ?
|
||||
AND owner_uid = ?");
|
||||
$sth->execute([$id, $owner_uid]);
|
||||
|
||||
if ($sth->rowCount() != 0 && $caption) {
|
||||
|
||||
/* Remove access key for the label */
|
||||
|
||||
$ext_id = LABEL_BASE_INDEX - 1 - $id;
|
||||
|
||||
$sth = $pdo->prepare("DELETE FROM ttrss_access_keys WHERE
|
||||
feed_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$ext_id, $owner_uid]);
|
||||
|
||||
/* Remove cached data */
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET label_cache = ''
|
||||
WHERE owner_uid = ?");
|
||||
$sth->execute([$owner_uid]);
|
||||
|
||||
}
|
||||
|
||||
if (!$tr_in_progress) $pdo->commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false|int false if the check for an existing label failed, otherwise the number of rows inserted (1 on success)
|
||||
*/
|
||||
static function create(string $caption, ?string $fg_color = '', ?string $bg_color = '', ?int $owner_uid = null): false|int {
|
||||
|
||||
if (!$owner_uid) $owner_uid = $_SESSION['uid'];
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$tr_in_progress = false;
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
} catch (Exception $e) {
|
||||
$tr_in_progress = true;
|
||||
}
|
||||
|
||||
$sth = $pdo->prepare("SELECT id FROM ttrss_labels2
|
||||
WHERE LOWER(caption) = LOWER(?) AND owner_uid = ?");
|
||||
$sth->execute([$caption, $owner_uid]);
|
||||
|
||||
if (!$sth->fetch()) {
|
||||
$sth = $pdo->prepare("INSERT INTO ttrss_labels2
|
||||
(caption,owner_uid,fg_color,bg_color) VALUES (?, ?, ?, ?)");
|
||||
|
||||
$sth->execute([$caption, $owner_uid, $fg_color, $bg_color]);
|
||||
|
||||
$result = $sth->rowCount();
|
||||
} else {
|
||||
$result = false;
|
||||
}
|
||||
|
||||
if (!$tr_in_progress) $pdo->commit();
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
<?php
|
||||
class Logger {
|
||||
private static ?Logger $instance = null;
|
||||
|
||||
private ?Logger_Adapter $adapter = null;
|
||||
|
||||
const LOG_DEST_SQL = "sql";
|
||||
const LOG_DEST_STDOUT = "stdout";
|
||||
const LOG_DEST_SYSLOG = "syslog";
|
||||
|
||||
const ERROR_NAMES = [
|
||||
1 => 'E_ERROR',
|
||||
2 => 'E_WARNING',
|
||||
4 => 'E_PARSE',
|
||||
8 => 'E_NOTICE',
|
||||
16 => 'E_CORE_ERROR',
|
||||
32 => 'E_CORE_WARNING',
|
||||
64 => 'E_COMPILE_ERROR',
|
||||
128 => 'E_COMPILE_WARNING',
|
||||
256 => 'E_USER_ERROR',
|
||||
512 => 'E_USER_WARNING',
|
||||
1024 => 'E_USER_NOTICE',
|
||||
2048 => 'E_STRICT',
|
||||
4096 => 'E_RECOVERABLE_ERROR',
|
||||
8192 => 'E_DEPRECATED',
|
||||
16384 => 'E_USER_DEPRECATED',
|
||||
32767 => 'E_ALL'];
|
||||
|
||||
static function log_error(int $errno, string $errstr, string $file, int $line, string $context): bool {
|
||||
return self::get_instance()->_log_error($errno, $errstr, $file, $line, $context);
|
||||
}
|
||||
|
||||
private function _log_error(int $errno, string $errstr, string $file, int $line, string $context): bool {
|
||||
//if ($errno == E_NOTICE) return false;
|
||||
|
||||
if ($this->adapter)
|
||||
return $this->adapter->log_error($errno, $errstr, $file, $line, $context);
|
||||
else
|
||||
return false;
|
||||
}
|
||||
|
||||
static function log(int $errno, string $errstr, string $context = ""): bool {
|
||||
return self::get_instance()->_log($errno, $errstr, $context);
|
||||
}
|
||||
|
||||
private function _log(int $errno, string $errstr, string $context = ""): bool {
|
||||
if ($this->adapter)
|
||||
return $this->adapter->log_error($errno, $errstr, '', 0, $context);
|
||||
else
|
||||
return user_error($errstr, $errno);
|
||||
}
|
||||
|
||||
private function __clone() {
|
||||
//
|
||||
}
|
||||
|
||||
function __construct() {
|
||||
$this->adapter = match (Config::get(Config::LOG_DESTINATION)) {
|
||||
self::LOG_DEST_SQL => new Logger_SQL(),
|
||||
self::LOG_DEST_SYSLOG => new Logger_Syslog(),
|
||||
self::LOG_DEST_STDOUT => new Logger_Stdout(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($this->adapter && !implements_interface($this->adapter, "Logger_Adapter"))
|
||||
user_error("Adapter for LOG_DESTINATION: " . Config::LOG_DESTINATION . " does not implement required interface.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
private static function get_instance() : Logger {
|
||||
if (self::$instance == null)
|
||||
self::$instance = new self();
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
static function get() : Logger {
|
||||
user_error("Please don't use Logger::get(), call Logger::log(...) instead.", E_USER_DEPRECATED);
|
||||
return self::get_instance();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<?php
|
||||
interface Logger_Adapter {
|
||||
function log_error(int $errno, string $errstr, string $file, int $line, string $context): bool;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
class Logger_SQL implements Logger_Adapter {
|
||||
|
||||
function __construct() {
|
||||
$conn = get_class($this);
|
||||
|
||||
ORM::configure(Db::get_dsn(), null, $conn);
|
||||
ORM::configure('username', Config::get(Config::DB_USER), $conn);
|
||||
ORM::configure('password', Config::get(Config::DB_PASS), $conn);
|
||||
ORM::configure('return_result_sets', true, $conn);
|
||||
}
|
||||
|
||||
function log_error(int $errno, string $errstr, string $file, int $line, string $context): bool {
|
||||
|
||||
if (Config::get_schema_version() > 117) {
|
||||
|
||||
// limit context length, DOMDocument dumps entire XML in here sometimes, which may be huge
|
||||
$context = mb_substr($context, 0, 8192);
|
||||
|
||||
$server_params = [
|
||||
"Real IP" => "HTTP_X_REAL_IP",
|
||||
"Forwarded For" => "HTTP_X_FORWARDED_FOR",
|
||||
"Forwarded Protocol" => "HTTP_X_FORWARDED_PROTO",
|
||||
"Remote IP" => "REMOTE_ADDR",
|
||||
"Request URI" => "REQUEST_URI",
|
||||
"User agent" => "HTTP_USER_AGENT",
|
||||
];
|
||||
|
||||
foreach ($server_params as $n => $p) {
|
||||
if (isset($_SERVER[$p]))
|
||||
$context .= "\n$n: " . $_SERVER[$p];
|
||||
}
|
||||
|
||||
// passed error message may contain invalid unicode characters, failing to insert an error here
|
||||
// would break the execution entirely by generating an actual fatal error instead of a E_WARNING etc
|
||||
$errstr = UConverter::transcode($errstr, 'UTF-8', 'UTF-8');
|
||||
$context = UConverter::transcode($context, 'UTF-8', 'UTF-8');
|
||||
|
||||
// can't use $_SESSION["uid"] ?? null because what if its, for example, false? or zero?
|
||||
// this would cause a PDOException on insert below
|
||||
$owner_uid = !empty($_SESSION["uid"]) ? $_SESSION["uid"] : null;
|
||||
|
||||
$entry = ORM::for_table('ttrss_error_log', get_class($this))->create();
|
||||
|
||||
$entry->set([
|
||||
'errno' => $errno,
|
||||
'errstr' => $errstr,
|
||||
'filename' => $file,
|
||||
'lineno' => (int)$line,
|
||||
'context' => $context,
|
||||
'owner_uid' => $owner_uid,
|
||||
'created_at' => Db::NOW(),
|
||||
]);
|
||||
|
||||
return $entry->save();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
class Logger_Stdout implements Logger_Adapter {
|
||||
|
||||
function log_error(int $errno, string $errstr, string $file, int $line, string $context): bool {
|
||||
|
||||
switch ($errno) {
|
||||
case E_ERROR:
|
||||
case E_PARSE:
|
||||
case E_CORE_ERROR:
|
||||
case E_COMPILE_ERROR:
|
||||
case E_USER_ERROR:
|
||||
$priority = LOG_ERR;
|
||||
break;
|
||||
case E_WARNING:
|
||||
case E_CORE_WARNING:
|
||||
case E_COMPILE_WARNING:
|
||||
case E_USER_WARNING:
|
||||
$priority = LOG_WARNING;
|
||||
break;
|
||||
default:
|
||||
$priority = LOG_INFO;
|
||||
}
|
||||
|
||||
$errname = Logger::ERROR_NAMES[$errno] . " ($errno)";
|
||||
|
||||
print "[EEE] $priority $errname ($file:$line) $errstr\n";
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
class Logger_Syslog implements Logger_Adapter {
|
||||
|
||||
function log_error(int $errno, string $errstr, string $file, int $line, string $context): bool {
|
||||
|
||||
switch ($errno) {
|
||||
case E_ERROR:
|
||||
case E_PARSE:
|
||||
case E_CORE_ERROR:
|
||||
case E_COMPILE_ERROR:
|
||||
case E_USER_ERROR:
|
||||
$priority = LOG_ERR;
|
||||
break;
|
||||
case E_WARNING:
|
||||
case E_CORE_WARNING:
|
||||
case E_COMPILE_WARNING:
|
||||
case E_USER_WARNING:
|
||||
$priority = LOG_WARNING;
|
||||
break;
|
||||
default:
|
||||
$priority = LOG_INFO;
|
||||
}
|
||||
|
||||
$errname = Logger::ERROR_NAMES[$errno] . " ($errno)";
|
||||
|
||||
syslog($priority, "[tt-rss] $errname ($file:$line) $errstr");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
class Mailer {
|
||||
private string $last_error = "";
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
* @return bool|int bool if the default mail function handled the request, otherwise an int as described in Mailer#mail()
|
||||
*/
|
||||
function mail(array $params): bool|int {
|
||||
|
||||
$to_name = $params["to_name"] ?? "";
|
||||
$to_address = $params["to_address"];
|
||||
$subject = $params["subject"];
|
||||
$message = $params["message"];
|
||||
// $message_html = $params["message_html"] ?? "";
|
||||
$from_name = $params["from_name"] ?? Config::get(Config::SMTP_FROM_NAME);
|
||||
$from_address = $params["from_address"] ?? Config::get(Config::SMTP_FROM_ADDRESS);
|
||||
$additional_headers = $params["headers"] ?? [];
|
||||
|
||||
$from_combined = $from_name ? "$from_name <$from_address>" : $from_address;
|
||||
$to_combined = $to_name ? "$to_name <$to_address>" : $to_address;
|
||||
|
||||
if (Config::get(Config::LOG_SENT_MAIL))
|
||||
Logger::log(E_USER_NOTICE, "Sending mail from $from_combined to $to_combined [$subject]: $message");
|
||||
|
||||
// HOOK_SEND_MAIL plugin instructions:
|
||||
// 1. return 1 or true if mail is handled
|
||||
// 2. return -1 if there's been a fatal error and no further action is allowed
|
||||
// 3. any other return value will allow cycling to the next handler and, eventually, to default mail() function
|
||||
// 4. set error message if needed via passed Mailer instance function set_error()
|
||||
|
||||
$hooks_tried = 0;
|
||||
|
||||
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_SEND_MAIL) as $p) {
|
||||
$rc = $p->hook_send_mail($this, $params);
|
||||
|
||||
if ($rc == 1)
|
||||
return $rc;
|
||||
|
||||
if ($rc == -1)
|
||||
return 0;
|
||||
|
||||
++$hooks_tried;
|
||||
}
|
||||
|
||||
$headers = [ "From: $from_combined", "Content-Type: text/plain; charset=UTF-8" ];
|
||||
|
||||
$rc = mail($to_combined, $subject, $message, implode("\r\n", [...$headers, ...$additional_headers]));
|
||||
|
||||
if (!$rc) {
|
||||
$this->set_error(error_get_last()['message'] ?? T_sprintf("Unknown error while sending mail. Hooks tried: %d.", $hooks_tried));
|
||||
}
|
||||
|
||||
return $rc;
|
||||
}
|
||||
|
||||
function set_error(string $message): void {
|
||||
$this->last_error = $message;
|
||||
user_error("Error sending mail: $message", E_USER_WARNING);
|
||||
}
|
||||
|
||||
function error(): string {
|
||||
return $this->last_error;
|
||||
}
|
||||
}
|
||||
657
classes/OPML.php
657
classes/OPML.php
@@ -1,657 +0,0 @@
|
||||
<?php
|
||||
class OPML extends Handler_Protected {
|
||||
|
||||
function csrf_ignore(string $method): bool {
|
||||
$csrf_ignored = array("export", "import");
|
||||
|
||||
return array_search($method, $csrf_ignored) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|int|null false if writing the file failed, true if printing succeeded, int if bytes were written to a file, or null if $owner_uid is missing
|
||||
*/
|
||||
function export(): bool|int|null {
|
||||
$output_name = sprintf("tt-rss_%s_%s.opml", $_SESSION["name"], date("Y-m-d"));
|
||||
$include_settings = $_REQUEST["include_settings"] == "1";
|
||||
$owner_uid = $_SESSION["uid"];
|
||||
|
||||
$rc = $this->opml_export($output_name, $owner_uid, false, $include_settings);
|
||||
|
||||
return $rc;
|
||||
}
|
||||
|
||||
// Export
|
||||
|
||||
private function opml_export_category(int $owner_uid, int $cat_id, bool $hide_private_feeds = false, bool $include_settings = true): string {
|
||||
|
||||
if ($hide_private_feeds)
|
||||
$hide_qpart = "(private IS false AND auth_login = '' AND auth_pass = '')";
|
||||
else
|
||||
$hide_qpart = "true";
|
||||
|
||||
$out = "";
|
||||
|
||||
$ttrss_specific_qpart = "";
|
||||
|
||||
if ($cat_id) {
|
||||
$sth = $this->pdo->prepare("SELECT title,order_id
|
||||
FROM ttrss_feed_categories WHERE id = ?
|
||||
AND owner_uid = ?");
|
||||
$sth->execute([$cat_id, $owner_uid]);
|
||||
$row = $sth->fetch();
|
||||
$cat_title = htmlspecialchars($row['title']);
|
||||
|
||||
if ($include_settings) {
|
||||
$order_id = (int)$row["order_id"];
|
||||
$ttrss_specific_qpart = "ttrssSortOrder=\"$order_id\"";
|
||||
}
|
||||
} else {
|
||||
$cat_title = "";
|
||||
}
|
||||
|
||||
if ($cat_title) $out .= "<outline text=\"$cat_title\" $ttrss_specific_qpart>\n";
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT id,title
|
||||
FROM ttrss_feed_categories WHERE
|
||||
(parent_cat = :cat OR (:cat = 0 AND parent_cat IS NULL)) AND
|
||||
owner_uid = :uid ORDER BY order_id, title");
|
||||
|
||||
$sth->execute([':cat' => $cat_id, ':uid' => $owner_uid]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
$out .= $this->opml_export_category($owner_uid, $line["id"], $hide_private_feeds, $include_settings);
|
||||
}
|
||||
|
||||
$fsth = $this->pdo->prepare("select title, feed_url, site_url, update_interval, order_id, purge_interval
|
||||
FROM ttrss_feeds WHERE
|
||||
(cat_id = :cat OR (:cat = 0 AND cat_id IS NULL)) AND owner_uid = :uid AND $hide_qpart
|
||||
ORDER BY order_id, title");
|
||||
|
||||
$fsth->execute([':cat' => $cat_id, ':uid' => $owner_uid]);
|
||||
|
||||
while ($fline = $fsth->fetch()) {
|
||||
$title = htmlspecialchars($fline["title"]);
|
||||
$url = htmlspecialchars($fline["feed_url"]);
|
||||
$site_url = htmlspecialchars($fline["site_url"]);
|
||||
|
||||
if ($include_settings) {
|
||||
$update_interval = (int)$fline["update_interval"];
|
||||
$order_id = (int)$fline["order_id"];
|
||||
$purge_interval = (int)$fline["purge_interval"];
|
||||
|
||||
$ttrss_specific_qpart = "ttrssSortOrder=\"$order_id\" ttrssPurgeInterval=\"$purge_interval\" ttrssUpdateInterval=\"$update_interval\"";
|
||||
} else {
|
||||
$ttrss_specific_qpart = "";
|
||||
}
|
||||
|
||||
if ($site_url) {
|
||||
$html_url_qpart = "htmlUrl=\"$site_url\"";
|
||||
} else {
|
||||
$html_url_qpart = "";
|
||||
}
|
||||
|
||||
$out .= "<outline type=\"rss\" text=\"$title\" xmlUrl=\"$url\" $ttrss_specific_qpart $html_url_qpart/>\n";
|
||||
}
|
||||
|
||||
if ($cat_title) $out .= "</outline>\n";
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|int|null false if writing the file failed, true if printing succeeded, int if bytes were written to a file, or null if $owner_uid is missing
|
||||
*/
|
||||
function opml_export(string $filename, int $owner_uid, bool $hide_private_feeds = false, bool $include_settings = true, bool $file_output = false): bool|int|null {
|
||||
if (!$owner_uid) return null;
|
||||
|
||||
if (!$file_output)
|
||||
if (!isset($_REQUEST["debug"])) {
|
||||
header("Content-type: application/xml+opml");
|
||||
header("Content-Disposition: attachment; filename=$filename");
|
||||
} else {
|
||||
header("Content-type: text/xml");
|
||||
}
|
||||
|
||||
$out = "<?xml version=\"1.0\" encoding=\"utf-8\"?".">";
|
||||
|
||||
$out .= "<opml version=\"1.0\">";
|
||||
$out .= "<head>
|
||||
<dateCreated>" . date("r", time()) . "</dateCreated>
|
||||
<title>Tiny Tiny RSS Feed Export</title>
|
||||
</head>";
|
||||
$out .= "<body>";
|
||||
|
||||
$out .= $this->opml_export_category($owner_uid, 0, $hide_private_feeds, $include_settings);
|
||||
|
||||
# export tt-rss settings
|
||||
|
||||
if ($include_settings) {
|
||||
$out .= "<outline text=\"tt-rss-prefs\" schema-version=\"".Config::SCHEMA_VERSION."\">";
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs2 WHERE
|
||||
profile IS NULL AND owner_uid = ? ORDER BY pref_name");
|
||||
$sth->execute([$owner_uid]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
$name = $line["pref_name"];
|
||||
$value = htmlspecialchars($line["value"]);
|
||||
|
||||
$out .= "<outline pref-name=\"$name\" value=\"$value\"/>";
|
||||
}
|
||||
|
||||
$out .= "</outline>";
|
||||
|
||||
$out .= "<outline text=\"tt-rss-labels\" schema-version=\"".Config::SCHEMA_VERSION."\">";
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT * FROM ttrss_labels2 WHERE
|
||||
owner_uid = ?");
|
||||
$sth->execute([$owner_uid]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
$name = htmlspecialchars($line['caption']);
|
||||
$fg_color = htmlspecialchars($line['fg_color']);
|
||||
$bg_color = htmlspecialchars($line['bg_color']);
|
||||
|
||||
$out .= "<outline label-name=\"$name\" label-fg-color=\"$fg_color\" label-bg-color=\"$bg_color\"/>";
|
||||
|
||||
}
|
||||
|
||||
$out .= "</outline>";
|
||||
|
||||
$out .= "<outline text=\"tt-rss-filters\" schema-version=\"".Config::SCHEMA_VERSION."\">";
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT * FROM ttrss_filters2
|
||||
WHERE owner_uid = ? ORDER BY id");
|
||||
$sth->execute([$owner_uid]);
|
||||
|
||||
while ($line = $sth->fetch(PDO::FETCH_ASSOC)) {
|
||||
$line["rules"] = array();
|
||||
$line["actions"] = array();
|
||||
|
||||
$tmph = $this->pdo->prepare("SELECT * FROM ttrss_filters2_rules
|
||||
WHERE filter_id = ?");
|
||||
$tmph->execute([$line['id']]);
|
||||
|
||||
while ($tmp_line = $tmph->fetch(PDO::FETCH_ASSOC)) {
|
||||
unset($tmp_line["id"]);
|
||||
unset($tmp_line["filter_id"]);
|
||||
|
||||
$cat_filter = $tmp_line["cat_filter"];
|
||||
|
||||
if (!$tmp_line["match_on"]) {
|
||||
if ($cat_filter && $tmp_line["cat_id"] || $tmp_line["feed_id"]) {
|
||||
$tmp_line["feed"] = Feeds::_get_title(
|
||||
$cat_filter ? $tmp_line["cat_id"] : $tmp_line["feed_id"],
|
||||
$owner_uid,
|
||||
$cat_filter);
|
||||
} else {
|
||||
$tmp_line["feed"] = "";
|
||||
}
|
||||
} else {
|
||||
$match = [];
|
||||
foreach (json_decode($tmp_line["match_on"], true) as $feed_id) {
|
||||
|
||||
if (str_starts_with($feed_id, "CAT:")) {
|
||||
$feed_id = (int)substr($feed_id, 4);
|
||||
if ($feed_id) {
|
||||
array_push($match, [Feeds::_get_cat_title($feed_id, $owner_uid), true, false]);
|
||||
} else {
|
||||
array_push($match, [0, true, true]);
|
||||
}
|
||||
} else {
|
||||
if ($feed_id) {
|
||||
array_push($match, [Feeds::_get_title((int)$feed_id, $owner_uid), false, false]);
|
||||
} else {
|
||||
array_push($match, [0, false, true]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$tmp_line["match"] = $match;
|
||||
unset($tmp_line["match_on"]);
|
||||
}
|
||||
|
||||
unset($tmp_line["feed_id"]);
|
||||
unset($tmp_line["cat_id"]);
|
||||
|
||||
array_push($line["rules"], $tmp_line);
|
||||
}
|
||||
|
||||
$tmph = $this->pdo->prepare("SELECT * FROM ttrss_filters2_actions
|
||||
WHERE filter_id = ?");
|
||||
$tmph->execute([$line['id']]);
|
||||
|
||||
while ($tmp_line = $tmph->fetch(PDO::FETCH_ASSOC)) {
|
||||
unset($tmp_line["id"]);
|
||||
unset($tmp_line["filter_id"]);
|
||||
|
||||
array_push($line["actions"], $tmp_line);
|
||||
}
|
||||
|
||||
unset($line["id"]);
|
||||
unset($line["owner_uid"]);
|
||||
$filter = json_encode($line);
|
||||
|
||||
$out .= "<outline filter-type=\"2\"><![CDATA[$filter]]></outline>";
|
||||
|
||||
}
|
||||
|
||||
|
||||
$out .= "</outline>";
|
||||
}
|
||||
|
||||
$out .= "</body></opml>";
|
||||
|
||||
// Format output.
|
||||
$doc = new DOMDocument();
|
||||
$doc->formatOutput = true;
|
||||
$doc->preserveWhiteSpace = false;
|
||||
$doc->loadXML($out);
|
||||
|
||||
$xpath = new DOMXPath($doc);
|
||||
$outlines = $xpath->query("//outline[@title]");
|
||||
|
||||
// cleanup empty categories
|
||||
foreach ($outlines as $node) {
|
||||
if ($node->getElementsByTagName('outline')->length == 0)
|
||||
$node->parentNode->removeChild($node);
|
||||
}
|
||||
|
||||
$res = $doc->saveXML();
|
||||
|
||||
/* // saveXML uses a two-space indent. Change to tabs.
|
||||
$res = preg_replace_callback('/^(?: )+/mu',
|
||||
create_function(
|
||||
'$matches',
|
||||
'return str_repeat("\t", intval(strlen($matches[0])/2));'),
|
||||
$res); */
|
||||
|
||||
if ($file_output)
|
||||
return file_put_contents($filename, $res) > 0;
|
||||
|
||||
print $res;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Import
|
||||
|
||||
private function opml_import_feed(DOMNode $node, int $cat_id, int $owner_uid, int $nest): void {
|
||||
$attrs = $node->attributes;
|
||||
|
||||
$feed_title = mb_substr($attrs->getNamedItem('text')->nodeValue, 0, 250);
|
||||
if (!$feed_title) $feed_title = mb_substr($attrs->getNamedItem('title')->nodeValue, 0, 250);
|
||||
|
||||
$feed_url = $attrs->getNamedItem('xmlUrl')->nodeValue;
|
||||
if (!$feed_url) $feed_url = $attrs->getNamedItem('xmlURL')->nodeValue;
|
||||
|
||||
$site_url = mb_substr($attrs->getNamedItem('htmlUrl')->nodeValue, 0, 250);
|
||||
|
||||
if ($feed_url) {
|
||||
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE
|
||||
feed_url = ? AND owner_uid = ?");
|
||||
$sth->execute([$feed_url, $owner_uid]);
|
||||
|
||||
if (!$feed_title) $feed_title = '[Unknown]';
|
||||
|
||||
if (!$sth->fetch()) {
|
||||
#$this->opml_notice("[FEED] [$feed_title/$feed_url] dst_CAT=$cat_id");
|
||||
$this->opml_notice(T_sprintf("Adding feed: %s", $feed_title == '[Unknown]' ? $feed_url : $feed_title), $nest);
|
||||
|
||||
if (!$cat_id) $cat_id = null;
|
||||
|
||||
$update_interval = (int) $attrs->getNamedItem('ttrssUpdateInterval')->nodeValue;
|
||||
if (!$update_interval) $update_interval = 0;
|
||||
|
||||
$order_id = (int) $attrs->getNamedItem('ttrssSortOrder')->nodeValue;
|
||||
if (!$order_id) $order_id = 0;
|
||||
|
||||
$purge_interval = (int) $attrs->getNamedItem('ttrssPurgeInterval')->nodeValue;
|
||||
if (!$purge_interval) $purge_interval = 0;
|
||||
|
||||
$sth = $this->pdo->prepare("INSERT INTO ttrss_feeds
|
||||
(title, feed_url, owner_uid, cat_id, site_url, order_id, update_interval, purge_interval) VALUES
|
||||
(?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
|
||||
$sth->execute([$feed_title, $feed_url, $owner_uid, $cat_id, $site_url, $order_id, $update_interval, $purge_interval]);
|
||||
|
||||
} else {
|
||||
$this->opml_notice(T_sprintf("Duplicate feed: %s", $feed_title == '[Unknown]' ? $feed_url : $feed_title), $nest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function opml_import_label(DOMNode $node, int $owner_uid, int $nest): void {
|
||||
$attrs = $node->attributes;
|
||||
$label_name = $attrs->getNamedItem('label-name')->nodeValue;
|
||||
|
||||
if ($label_name) {
|
||||
$fg_color = $attrs->getNamedItem('label-fg-color')->nodeValue;
|
||||
$bg_color = $attrs->getNamedItem('label-bg-color')->nodeValue;
|
||||
|
||||
if (!Labels::find_id($label_name, $owner_uid)) {
|
||||
$this->opml_notice(T_sprintf("Adding label %s", htmlspecialchars($label_name)), $nest);
|
||||
Labels::create($label_name, $fg_color, $bg_color, $owner_uid);
|
||||
} else {
|
||||
$this->opml_notice(T_sprintf("Duplicate label: %s", htmlspecialchars($label_name)), $nest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo support passing in $profile so 'update.php --opml-import' can import prefs to a user profile
|
||||
*/
|
||||
private function opml_import_preference(DOMNode $node, int $owner_uid, int $nest): void {
|
||||
$attrs = $node->attributes;
|
||||
$pref_name = $attrs->getNamedItem('pref-name')->nodeValue;
|
||||
|
||||
if ($pref_name) {
|
||||
$pref_value = $attrs->getNamedItem('value')->nodeValue;
|
||||
|
||||
$this->opml_notice(T_sprintf("Setting preference key %s to %s",
|
||||
$pref_name, $pref_value), $nest);
|
||||
|
||||
Prefs::set($pref_name, $pref_value, $owner_uid, $_SESSION['profile'] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
private function opml_import_filter(DOMNode $node, int $owner_uid, int $nest): void {
|
||||
$attrs = $node->attributes;
|
||||
|
||||
$filter_type = $attrs->getNamedItem('filter-type')->nodeValue;
|
||||
|
||||
if ($filter_type == '2') {
|
||||
$filter = json_decode($node->nodeValue, true);
|
||||
|
||||
if ($filter) {
|
||||
$match_any_rule = bool_to_sql_bool($filter["match_any_rule"]);
|
||||
$enabled = bool_to_sql_bool($filter["enabled"]);
|
||||
$inverse = bool_to_sql_bool($filter["inverse"]);
|
||||
$title = $filter["title"];
|
||||
|
||||
//print "F: $title, $inverse, $enabled, $match_any_rule";
|
||||
|
||||
$sth = $this->pdo->prepare("INSERT INTO ttrss_filters2 (match_any_rule,enabled,inverse,title,owner_uid)
|
||||
VALUES (?, ?, ?, ?, ?) RETURNING id");
|
||||
|
||||
$sth->execute([$match_any_rule, $enabled, $inverse, $title, $owner_uid]);
|
||||
|
||||
$row = $sth->fetch();
|
||||
$filter_id = $row['id'];
|
||||
|
||||
if ($filter_id) {
|
||||
$this->opml_notice(T_sprintf("Adding filter %s...", $title), $nest);
|
||||
//$this->opml_notice(json_encode($filter));
|
||||
|
||||
foreach ($filter["rules"] as $rule) {
|
||||
$feed_id = null;
|
||||
$cat_id = null;
|
||||
|
||||
if ($rule["match"] ?? false) {
|
||||
|
||||
$match_on = [];
|
||||
|
||||
foreach ($rule["match"] as $match) {
|
||||
list ($name, $is_cat, $is_id) = $match;
|
||||
|
||||
if ($is_id) {
|
||||
array_push($match_on, ($is_cat ? "CAT:" : "") . $name);
|
||||
} else {
|
||||
|
||||
$match_id = Feeds::_find_by_title($name, $is_cat, $owner_uid);
|
||||
|
||||
if ($match_id) {
|
||||
if ($is_cat) {
|
||||
array_push($match_on, "CAT:$match_id");
|
||||
} else {
|
||||
array_push($match_on, $match_id);
|
||||
}
|
||||
}
|
||||
|
||||
/*if (!$is_cat) {
|
||||
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feeds
|
||||
WHERE title = ? AND owner_uid = ?");
|
||||
|
||||
$tsth->execute([$name, $_SESSION['uid']]);
|
||||
|
||||
if ($row = $tsth->fetch()) {
|
||||
$match_id = $row['id'];
|
||||
|
||||
array_push($match_on, $match_id);
|
||||
}
|
||||
} else {
|
||||
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories
|
||||
WHERE title = ? AND owner_uid = ?");
|
||||
$tsth->execute([$name, $_SESSION['uid']]);
|
||||
|
||||
if ($row = $tsth->fetch()) {
|
||||
$match_id = $row['id'];
|
||||
|
||||
array_push($match_on, "CAT:$match_id");
|
||||
}
|
||||
} */
|
||||
}
|
||||
}
|
||||
|
||||
$reg_exp = $rule["reg_exp"];
|
||||
$filter_type = (int)$rule["filter_type"];
|
||||
$inverse = bool_to_sql_bool($rule["inverse"]);
|
||||
$match_on = json_encode($match_on);
|
||||
|
||||
$usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_rules
|
||||
(feed_id,cat_id,match_on,filter_id,filter_type,reg_exp,cat_filter,inverse)
|
||||
VALUES
|
||||
(NULL, NULL, ?, ?, ?, ?, false, ?)");
|
||||
$usth->execute([$match_on, $filter_id, $filter_type, $reg_exp, $inverse]);
|
||||
|
||||
} else {
|
||||
|
||||
$match_id = Feeds::_find_by_title($rule['feed'] ?? "", $rule['cat_filter'], $owner_uid);
|
||||
|
||||
if ($match_id) {
|
||||
if ($rule['cat_filter']) {
|
||||
$cat_id = $match_id;
|
||||
} else {
|
||||
$feed_id = $match_id;
|
||||
}
|
||||
}
|
||||
|
||||
/*if (!$rule["cat_filter"]) {
|
||||
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feeds
|
||||
WHERE title = ? AND owner_uid = ?");
|
||||
|
||||
$tsth->execute([$rule['feed'], $_SESSION['uid']]);
|
||||
|
||||
if ($row = $tsth->fetch()) {
|
||||
$feed_id = $row['id'];
|
||||
}
|
||||
} else {
|
||||
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories
|
||||
WHERE title = ? AND owner_uid = ?");
|
||||
|
||||
$tsth->execute([$rule['feed'], $_SESSION['uid']]);
|
||||
|
||||
if ($row = $tsth->fetch()) {
|
||||
$feed_id = $row['id'];
|
||||
}
|
||||
} */
|
||||
|
||||
$cat_filter = bool_to_sql_bool($rule["cat_filter"]);
|
||||
$reg_exp = $rule["reg_exp"];
|
||||
$filter_type = (int)$rule["filter_type"];
|
||||
$inverse = bool_to_sql_bool($rule["inverse"]);
|
||||
|
||||
$usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_rules
|
||||
(feed_id,cat_id,filter_id,filter_type,reg_exp,cat_filter,inverse)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, ?, ?)");
|
||||
$usth->execute([$feed_id, $cat_id, $filter_id, $filter_type, $reg_exp, $cat_filter, $inverse]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($filter["actions"] as $action) {
|
||||
|
||||
$action_id = (int)$action["action_id"];
|
||||
$action_param = $action["action_param"];
|
||||
|
||||
$usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_actions
|
||||
(filter_id,action_id,action_param)
|
||||
VALUES
|
||||
(?, ?, ?)");
|
||||
$usth->execute([$filter_id, $action_id, $action_param]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function opml_import_category(DOMDocument $doc, ?DOMNode $root_node, int $owner_uid, int $parent_id, int $nest): void {
|
||||
$default_cat_id = (int) $this->get_feed_category('Imported feeds', $owner_uid, 0);
|
||||
|
||||
if ($root_node) {
|
||||
$cat_title = mb_substr($root_node->attributes->getNamedItem('text')->nodeValue, 0, 250);
|
||||
|
||||
if (!$cat_title)
|
||||
$cat_title = mb_substr($root_node->attributes->getNamedItem('title')->nodeValue, 0, 250);
|
||||
|
||||
if (!in_array($cat_title, array("tt-rss-filters", "tt-rss-labels", "tt-rss-prefs"))) {
|
||||
$cat_id = $this->get_feed_category($cat_title, $owner_uid, $parent_id);
|
||||
|
||||
if ($cat_id === 0) {
|
||||
$order_id = (int) $root_node->attributes->getNamedItem('ttrssSortOrder')->nodeValue;
|
||||
|
||||
Feeds::_add_cat($cat_title, $owner_uid, $parent_id ? $parent_id : null, (int)$order_id);
|
||||
$cat_id = $this->get_feed_category($cat_title, $owner_uid, $parent_id);
|
||||
}
|
||||
|
||||
} else {
|
||||
$cat_id = 0;
|
||||
}
|
||||
|
||||
$outlines = $root_node->childNodes;
|
||||
|
||||
} else {
|
||||
$xpath = new DOMXPath($doc);
|
||||
$outlines = $xpath->query("//opml/body/outline");
|
||||
|
||||
$cat_id = 0;
|
||||
$cat_title = false;
|
||||
}
|
||||
|
||||
//$this->opml_notice("[CAT] $cat_title id: $cat_id P_id: $parent_id");
|
||||
$this->opml_notice(T_sprintf("Processing category: %s", $cat_title ? $cat_title : __("Uncategorized")), $nest);
|
||||
|
||||
foreach ($outlines as $node) {
|
||||
if ($node->hasAttributes() && strtolower($node->tagName) == "outline") {
|
||||
$attrs = $node->attributes;
|
||||
$node_cat_title = $attrs->getNamedItem('text') ? $attrs->getNamedItem('text')->nodeValue : false;
|
||||
|
||||
if (!$node_cat_title)
|
||||
$node_cat_title = $attrs->getNamedItem('title') ? $attrs->getNamedItem('title')->nodeValue : false;
|
||||
|
||||
$node_feed_url = $attrs->getNamedItem('xmlUrl') ? $attrs->getNamedItem('xmlUrl')->nodeValue : false;
|
||||
|
||||
if ($node_cat_title && !$node_feed_url) {
|
||||
$this->opml_import_category($doc, $node, $owner_uid, $cat_id, $nest+1);
|
||||
} else {
|
||||
|
||||
if (!$cat_id) {
|
||||
$dst_cat_id = $default_cat_id;
|
||||
} else {
|
||||
$dst_cat_id = $cat_id;
|
||||
}
|
||||
|
||||
match ($cat_title) {
|
||||
'tt-rss-prefs' => $this->opml_import_preference($node, $owner_uid, $nest+1),
|
||||
'tt-rss-labels' => $this->opml_import_label($node, $owner_uid, $nest+1),
|
||||
'tt-rss-filters' => $this->opml_import_filter($node, $owner_uid, $nest+1),
|
||||
default => $this->opml_import_feed($node, $dst_cat_id, $owner_uid, $nest+1),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** $filename is optional; assumes HTTP upload with $_FILES otherwise */
|
||||
/**
|
||||
* @return bool|null false on failure, true if successful, null if $owner_uid is missing
|
||||
*/
|
||||
function opml_import(int $owner_uid, string $filename = ""): ?bool {
|
||||
if (!$owner_uid) return null;
|
||||
|
||||
if (!$filename) {
|
||||
if ($_FILES['opml_file']['error'] != 0) {
|
||||
print_error(T_sprintf("Upload failed with error code %d",
|
||||
$_FILES['opml_file']['error']));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_uploaded_file($_FILES['opml_file']['tmp_name'])) {
|
||||
$tmp_file = (string)tempnam(Config::get(Config::CACHE_DIR) . '/upload', 'opml');
|
||||
|
||||
$result = move_uploaded_file($_FILES['opml_file']['tmp_name'],
|
||||
$tmp_file);
|
||||
|
||||
if (!$result) {
|
||||
print_error(__("Unable to move uploaded file."));
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
print_error(__('Error: please upload OPML file.'));
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
$tmp_file = $filename;
|
||||
}
|
||||
|
||||
if (!is_readable($tmp_file)) {
|
||||
$this->opml_notice(T_sprintf("Error: file is not readable: %s", $filename));
|
||||
return false;
|
||||
}
|
||||
|
||||
$doc = new DOMDocument();
|
||||
|
||||
$loaded = $doc->load($tmp_file);
|
||||
|
||||
// only remove temporary i.e. HTTP uploaded files
|
||||
if (!$filename)
|
||||
unlink($tmp_file);
|
||||
|
||||
if ($loaded) {
|
||||
// we're using ORM while importing so we can't transaction-lock things anymore
|
||||
//$this->pdo->beginTransaction();
|
||||
$this->opml_import_category($doc, null, $owner_uid, 0, 0);
|
||||
//$this->pdo->commit();
|
||||
} else {
|
||||
$this->opml_notice(__('Error while parsing document.'));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function opml_notice(string $msg, int $prefix_length = 0): void {
|
||||
if (php_sapi_name() == "cli") {
|
||||
Debug::log(str_repeat(" ", $prefix_length) . $msg);
|
||||
} else {
|
||||
// TODO: use better separator i.e. CSS-defined span of certain width or something
|
||||
print str_repeat(" ", $prefix_length) . $msg . "<br/>";
|
||||
}
|
||||
}
|
||||
|
||||
function get_feed_category(string $feed_cat, int $owner_uid, int $parent_cat_id) : int {
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories
|
||||
WHERE title = :title
|
||||
AND (parent_cat = :parent OR (:parent = 0 AND parent_cat IS NULL))
|
||||
AND owner_uid = :uid");
|
||||
|
||||
$sth->execute([':title' => $feed_cat, ':parent' => $parent_cat_id, ':uid' => $owner_uid]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
return $row['id'];
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,738 +0,0 @@
|
||||
<?php
|
||||
abstract class Plugin {
|
||||
const API_VERSION_COMPAT = 1;
|
||||
|
||||
/** @var PDO $pdo */
|
||||
protected $pdo;
|
||||
|
||||
/**
|
||||
* @param PluginHost $host
|
||||
*
|
||||
* @return void
|
||||
* */
|
||||
abstract function init($host);
|
||||
|
||||
/** @return array<null|float|string|bool> */
|
||||
abstract function about();
|
||||
// return array(1.0, "plugin", "No description", "No author", false);
|
||||
|
||||
function __construct() {
|
||||
$this->pdo = Db::pdo();
|
||||
}
|
||||
|
||||
/** @return array<string,bool> */
|
||||
function flags() {
|
||||
/* associative array, possible keys:
|
||||
needs_curl = boolean
|
||||
*/
|
||||
return array();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $method
|
||||
*
|
||||
* @return bool */
|
||||
function is_public_method($method) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $method
|
||||
*
|
||||
* @return bool */
|
||||
function csrf_ignore($method) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return string */
|
||||
function get_js() {
|
||||
return "";
|
||||
}
|
||||
|
||||
/** @return string */
|
||||
function get_login_js() {
|
||||
return "";
|
||||
}
|
||||
|
||||
/** @return string */
|
||||
function get_css() {
|
||||
return "";
|
||||
}
|
||||
|
||||
/** @return string */
|
||||
function get_prefs_js() {
|
||||
return "";
|
||||
}
|
||||
|
||||
/** @return int */
|
||||
function api_version() {
|
||||
return Plugin::API_VERSION_COMPAT;
|
||||
}
|
||||
|
||||
/* gettext-related helpers */
|
||||
|
||||
/**
|
||||
* @param string $msgid
|
||||
*
|
||||
* @return string */
|
||||
function __($msgid) {
|
||||
// this is a strictly template-related hack
|
||||
return _dgettext(PluginHost::object_to_domain($this), $msgid);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $singular
|
||||
* @param string $plural
|
||||
* @param int $number
|
||||
*
|
||||
* @return string */
|
||||
function _ngettext($singular, $plural, $number) {
|
||||
// this is a strictly template-related hack
|
||||
return _dngettext(PluginHost::object_to_domain($this), $singular, $plural, $number);
|
||||
}
|
||||
|
||||
/** @return string */
|
||||
function T_sprintf() {
|
||||
$args = func_get_args();
|
||||
$msgid = array_shift($args);
|
||||
|
||||
return vsprintf($this->__($msgid), $args);
|
||||
}
|
||||
|
||||
/* plugin hook methods */
|
||||
|
||||
/* GLOBAL hooks are invoked in global context, only available to system plugins (loaded via .env for all users) */
|
||||
|
||||
/** Adds buttons for article (on the right) - e.g. mail, share, add note. Generated markup must be valid XML.
|
||||
* @param array<string,mixed> $line
|
||||
* @return string
|
||||
* @see PluginHost::HOOK_ARTICLE_BUTTON
|
||||
* @see Plugin::hook_article_left_button()
|
||||
*/
|
||||
function hook_article_button($line) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Allows plugins to alter article data as gathered from feed XML, i.e. embed images, get full text content, etc.
|
||||
* @param array<string,mixed> $article
|
||||
* @return array<string,mixed>
|
||||
* @see PluginHost::HOOK_ARTICLE_FILTER
|
||||
*/
|
||||
function hook_article_filter($article) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Allow adding new UI elements (e.g. accordion panes) to (top) tab contents in Preferences
|
||||
* @param string $tab
|
||||
* @return void
|
||||
* @see PluginHost::HOOK_PREFS_TAB
|
||||
*/
|
||||
function hook_prefs_tab($tab) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** Allow adding new content to various sections of preferences UI (i.e. OPML import/export pane)
|
||||
* @param string $section
|
||||
* @return void
|
||||
* @see PluginHost::HOOK_PREFS_TAB_SECTION
|
||||
*/
|
||||
function hook_prefs_tab_section($section) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** Allows adding new (top) tabs in preferences UI
|
||||
* @return void
|
||||
* @see PluginHost::HOOK_PREFS_TABS
|
||||
*/
|
||||
function hook_prefs_tabs() {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** Invoked when feed XML is processed by FeedParser class
|
||||
* @param FeedParser $parser
|
||||
* @param int $feed_id
|
||||
* @return void
|
||||
* @see PluginHost::HOOK_FEED_PARSED
|
||||
*/
|
||||
function hook_feed_parsed($parser, $feed_id) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** GLOBAL: Invoked when a feed update task finishes
|
||||
* @param array<string,string> $cli_options
|
||||
* @return void
|
||||
* @see PluginHost::HOOK_UPDATE_TASK
|
||||
*/
|
||||
function hook_update_task($cli_options) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** This is a pluginhost compatibility wrapper that invokes $this->authenticate(...$args) (Auth_Base)
|
||||
* @param string $login
|
||||
* @param string $password
|
||||
* @param string $service
|
||||
* @return int|false user_id
|
||||
* @see PluginHost::HOOK_AUTH_USER
|
||||
*/
|
||||
function hook_auth_user($login, $password, $service = '') {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** IAuthModule only
|
||||
* @param string $login
|
||||
* @param string $password
|
||||
* @param string $service
|
||||
* @return int|false user_id
|
||||
*/
|
||||
function authenticate($login, $password, $service = '') {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Allows plugins to modify global hotkey map (hotkey sequence -> action)
|
||||
* @param array<string, string> $hotkeys
|
||||
* @return array<string, string>
|
||||
* @see PluginHost::HOOK_HOTKEY_MAP
|
||||
* @see Plugin::hook_hotkey_info()
|
||||
*/
|
||||
function hook_hotkey_map($hotkeys) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Invoked when article is rendered by backend (before it gets passed to frontent JS code) - three panel mode
|
||||
* @param array<string, mixed> $article
|
||||
* @return array<string, mixed>
|
||||
* @see PluginHost::HOOK_RENDER_ARTICLE
|
||||
*/
|
||||
function hook_render_article($article) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Invoked when article is rendered by backend (before it gets passed to frontent JS code) - combined mode
|
||||
* @param array<string, mixed> $article
|
||||
* @return array<string, mixed>
|
||||
* @see PluginHost::HOOK_RENDER_ARTICLE_CDM
|
||||
*/
|
||||
function hook_render_article_cdm($article) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Invoked when raw feed XML data has been successfully downloaded (but not parsed yet)
|
||||
* @param string $feed_data
|
||||
* @param string $fetch_url
|
||||
* @param int $owner_uid
|
||||
* @param int $feed
|
||||
* @return string
|
||||
* @see PluginHost::HOOK_FEED_FETCHED
|
||||
*/
|
||||
function hook_feed_fetched($feed_data, $fetch_url, $owner_uid, $feed) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Invoked on article content when it is sanitized (i.e. potentially harmful tags removed)
|
||||
* @param DOMDocument $doc
|
||||
* @param string $site_url
|
||||
* @param array<string> $allowed_elements
|
||||
* @param array<string> $disallowed_attributes
|
||||
* @param int $article_id
|
||||
* @return DOMDocument|array<int,DOMDocument|array<string>>
|
||||
* @see PluginHost::HOOK_SANITIZE
|
||||
*/
|
||||
function hook_sanitize($doc, $site_url, $allowed_elements, $disallowed_attributes, $article_id) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return $doc;
|
||||
}
|
||||
|
||||
/** Invoked when article is rendered by backend (before it gets passed to frontent JS code) - exclusive to API clients
|
||||
* @param array{'article': array<string,mixed>|null, 'headline': array<string,mixed>|null} $params
|
||||
* @return array<string, string>
|
||||
* @see PluginHost::HOOK_RENDER_ARTICLE_API
|
||||
*/
|
||||
function hook_render_article_api($params) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Allows adding new UI elements to tt-rss main toolbar (to the right, before Actions... dropdown)
|
||||
* @return string
|
||||
* @see PluginHost::HOOK_TOOLBAR_BUTTON
|
||||
*/
|
||||
function hook_toolbar_button() {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Allows adding new items to tt-rss main Actions... dropdown menu
|
||||
* @return string
|
||||
* @see PluginHost::HOOK_ACTION_ITEM
|
||||
*/
|
||||
function hook_action_item() {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Allows adding new UI elements to the toolbar area related to currently loaded feed headlines
|
||||
* @param int $feed_id
|
||||
* @param bool $is_cat
|
||||
* @return string
|
||||
* @see PluginHost::HOOK_HEADLINE_TOOLBAR_BUTTON
|
||||
*/
|
||||
function hook_headline_toolbar_button($feed_id, $is_cat) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Allows adding new hotkey action names and descriptions
|
||||
* @param array<string, array<string, string>> $hotkeys
|
||||
* @return array<string, array<string, string>>
|
||||
* @see PluginHost::HOOK_HOTKEY_INFO
|
||||
* @see Plugin::hook_hotkey_map()
|
||||
*/
|
||||
function hook_hotkey_info($hotkeys) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Adds per-article buttons on the left side. Generated markup must be valid XML.
|
||||
* @param array<string,mixed> $row
|
||||
* @return string
|
||||
* @see PluginHost::HOOK_ARTICLE_LEFT_BUTTON
|
||||
* @see Plugin::hook_article_button()
|
||||
*/
|
||||
function hook_article_left_button($row) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Allows adding new UI elements to the "Plugins" tab of the feed editor UI
|
||||
* @param int $feed_id
|
||||
* @return void
|
||||
* @see PluginHost::HOOK_PREFS_EDIT_FEED
|
||||
*/
|
||||
function hook_prefs_edit_feed($feed_id) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** Invoked when data is saved in the feed editor
|
||||
* @param int $feed_id
|
||||
* @return void
|
||||
* @see PluginHost::HOOK_PREFS_SAVE_FEED
|
||||
*/
|
||||
function hook_prefs_save_feed($feed_id) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** Allows overriding built-in fetching mechanism for feeds, substituting received data if necessary
|
||||
* (i.e. origin site doesn't actually provide any RSS feeds), or XML is invalid
|
||||
* @param string $feed_data
|
||||
* @param string $fetch_url
|
||||
* @param int $owner_uid
|
||||
* @param int $feed
|
||||
* @param int $last_article_timestamp
|
||||
* @param string $auth_login
|
||||
* @param string $auth_pass
|
||||
* @return string (possibly mangled feed data)
|
||||
* @see PluginHost::HOOK_FETCH_FEED
|
||||
*/
|
||||
function hook_fetch_feed($feed_data, $fetch_url, $owner_uid, $feed, $last_article_timestamp, $auth_login, $auth_pass) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Invoked when headlines data ($row) has been retrieved from the database
|
||||
* @param array<string,mixed> $row
|
||||
* @param int $excerpt_length
|
||||
* @return array<string,mixed>
|
||||
* @see PluginHost::HOOK_QUERY_HEADLINES
|
||||
*/
|
||||
function hook_query_headlines($row, $excerpt_length) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** This is run periodically by the update daemon when idle (available both to user and system plugins)
|
||||
* @return void
|
||||
* @see PluginHost::HOOK_HOUSE_KEEPING */
|
||||
function hook_house_keeping() {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** Allows overriding built-in article search
|
||||
* @param string $query
|
||||
* @return array<int, string|array<string>> - list(SQL search query, highlight keywords)
|
||||
* @see PluginHost::HOOK_SEARCH
|
||||
*/
|
||||
function hook_search($query) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Invoked when enclosures are rendered to HTML (when article itself is rendered)
|
||||
* @param string $enclosures_formatted
|
||||
* @param array<int, array<string, mixed>> $enclosures
|
||||
* @param int $article_id
|
||||
* @param bool $always_display_enclosures
|
||||
* @param string $article_content
|
||||
* @param bool $hide_images
|
||||
* @return string|array<string,array<int, array<string, mixed>>> ($enclosures_formatted, $enclosures)
|
||||
* @see PluginHost::HOOK_FORMAT_ENCLOSURES
|
||||
*/
|
||||
function hook_format_enclosures($enclosures_formatted, $enclosures, $article_id, $always_display_enclosures, $article_content, $hide_images) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Invoked during feed subscription (after data has been fetched)
|
||||
* @param string $contents
|
||||
* @param string $url
|
||||
* @param string $auth_login
|
||||
* @param string $auth_pass
|
||||
* @return string (possibly mangled feed data)
|
||||
* @see PluginHost::HOOK_SUBSCRIBE_FEED
|
||||
*/
|
||||
function hook_subscribe_feed($contents, $url, $auth_login, $auth_pass) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $feed
|
||||
* @param bool $is_cat
|
||||
* @param array<string,mixed> $qfh_ret (headlines object)
|
||||
* @return string
|
||||
* @see PluginHost::HOOK_HEADLINES_BEFORE
|
||||
*/
|
||||
function hook_headlines_before($feed, $is_cat, $qfh_ret) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $entry
|
||||
* @param int $article_id
|
||||
* @param array<string,mixed> $rv
|
||||
* @return string
|
||||
* @see PluginHost::HOOK_RENDER_ENCLOSURE
|
||||
*/
|
||||
function hook_render_enclosure($entry, $article_id, $rv) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $article
|
||||
* @param string $action
|
||||
* @return array<string,mixed> ($article)
|
||||
* @see PluginHost::HOOK_ARTICLE_FILTER_ACTION
|
||||
*/
|
||||
function hook_article_filter_action($article, $action) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $line
|
||||
* @param int $feed
|
||||
* @param bool $is_cat
|
||||
* @param int $owner_uid
|
||||
* @return array<string,mixed> ($line)
|
||||
* @see PluginHost::HOOK_ARTICLE_EXPORT_FEED
|
||||
*/
|
||||
function hook_article_export_feed($line, $feed, $is_cat, $owner_uid) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Allows adding custom buttons to tt-rss main toolbar (left side)
|
||||
* @return void
|
||||
* @see PluginHost::HOOK_MAIN_TOOLBAR_BUTTON
|
||||
*/
|
||||
function hook_main_toolbar_button() {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** Invoked for every enclosure entry as article is being rendered
|
||||
* @param array<string,string> $entry
|
||||
* @param int $id article id
|
||||
* @param array{'formatted': string, 'entries': array<int, array<string, mixed>>} $rv
|
||||
* @return array<string,string> ($entry)
|
||||
* @see PluginHost::HOOK_ENCLOSURE_ENTRY
|
||||
*/
|
||||
function hook_enclosure_entry($entry, $id, $rv) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Share plugins run this when article is being rendered as HTML for sharing
|
||||
* @param string $html
|
||||
* @param array<string,mixed> $row
|
||||
* @return string ($html)
|
||||
* @see PluginHost::HOOK_FORMAT_ARTICLE
|
||||
*/
|
||||
function hook_format_article($html, $row) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Invoked when basic feed information (title, site_url) is being collected, useful to override default if feed doesn't provide anything (or feed itself is synthesized)
|
||||
* @param array{"title": string, "site_url": string} $basic_info
|
||||
* @param string $fetch_url
|
||||
* @param int $owner_uid
|
||||
* @param int $feed_id
|
||||
* @param string $auth_login
|
||||
* @param string $auth_pass
|
||||
* @return array{"title": string, "site_url": string}
|
||||
* @see PluginHost::HOOK_FEED_BASIC_INFO
|
||||
*/
|
||||
function hook_feed_basic_info($basic_info, $fetch_url, $owner_uid, $feed_id, $auth_login, $auth_pass) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return $basic_info;
|
||||
}
|
||||
|
||||
/** Invoked when file (e.g. cache entry, static data) is being sent to client, may override default mechanism
|
||||
* using faster httpd-specific implementation (see nginx_xaccel)
|
||||
* @param string $filename
|
||||
* @return bool
|
||||
* @see PluginHost::HOOK_SEND_LOCAL_FILE
|
||||
*/
|
||||
function hook_send_local_file($filename) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Invoked when user tries to unsubscribe from a feed, returning true would prevent any further default actions
|
||||
* @param int $feed_id
|
||||
* @param int $owner_uid
|
||||
* @return bool
|
||||
* @see PluginHost::HOOK_UNSUBSCRIBE_FEED
|
||||
*/
|
||||
function hook_unsubscribe_feed($feed_id, $owner_uid) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Invoked when mail is being sent (if no hooks are registered, tt-rss uses PHP mail() as a fallback)
|
||||
* @param Mailer $mailer
|
||||
* @param array<string,mixed> $params
|
||||
* @return int
|
||||
* @see PluginHost::HOOK_SEND_MAIL
|
||||
*/
|
||||
function hook_send_mail($mailer, $params) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoked when filtering is triggered on an article. May be used to implement logging for filters, etc.
|
||||
* @param int $feed_id
|
||||
* @param int $owner_uid
|
||||
* @param array<string,mixed> $article
|
||||
* @param array<string,mixed> $matched_filters
|
||||
* @param array<string,string|bool|int> $matched_rules
|
||||
* @param array<int, array{'type': string, 'param': string}> $article_filter_actions An array of filter actions from matched filters
|
||||
* @return void
|
||||
* @see PluginHost::HOOK_FILTER_TRIGGERED
|
||||
*/
|
||||
function hook_filter_triggered($feed_id, $owner_uid, $article, $matched_filters, $matched_rules, $article_filter_actions) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** Plugins may provide this to allow getting full article text (af_readbility implements this)
|
||||
* @param string $url
|
||||
* @return string|false
|
||||
* @see PluginHost::HOOK_GET_FULL_TEXT
|
||||
*/
|
||||
function hook_get_full_text($url) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Invoked when article flavor image is being determined, allows overriding default selection logic
|
||||
* @param array<string,string> $enclosures
|
||||
* @param string $content
|
||||
* @param string $site_url
|
||||
* @param array<string,mixed> $article
|
||||
* @return string|array<int,string>
|
||||
* @see PluginHost::HOOK_ARTICLE_IMAGE
|
||||
*/
|
||||
function hook_article_image($enclosures, $content, $site_url, $article) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Allows adding arbitrary elements before feed tree
|
||||
* @return string HTML
|
||||
* @see PluginHost::HOOK_FEED_TREE
|
||||
* */
|
||||
function hook_feed_tree() {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Invoked for every iframe to determine if it is allowed to be displayed
|
||||
* @param string $url
|
||||
* @return bool
|
||||
* @see PluginHost::HOOK_IFRAME_WHITELISTED
|
||||
*/
|
||||
function hook_iframe_whitelisted($url) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param object $enclosure
|
||||
* @param int $feed
|
||||
* @return object ($enclosure)
|
||||
* @see PluginHost::HOOK_ENCLOSURE_IMPORTED
|
||||
*/
|
||||
function hook_enclosure_imported($enclosure, $feed) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return $enclosure;
|
||||
}
|
||||
|
||||
/** Allows adding custom elements to headline sort dropdown (name -> caption)
|
||||
* @return array<string,string>
|
||||
* @see PluginHost::HOOK_HEADLINES_CUSTOM_SORT_MAP
|
||||
*/
|
||||
function hook_headlines_custom_sort_map() {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return ["" => ""];
|
||||
}
|
||||
|
||||
/** Allows overriding headline sorting (or provide custom sort methods)
|
||||
* @param string $order
|
||||
* @return array<int, string|bool> -- (query, skip_first_id)
|
||||
* @see PluginHost::HOOK_HEADLINES_CUSTOM_SORT_OVERRIDE
|
||||
*/
|
||||
function hook_headlines_custom_sort_override($order) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return ["", false];
|
||||
}
|
||||
|
||||
/** Allows adding custom elements to headlines Select... dropdown
|
||||
* @deprecated removed, see Plugin::hook_headline_toolbar_select_menu_item2()
|
||||
* @param int $feed_id
|
||||
* @param int $is_cat
|
||||
* @return string
|
||||
* @see PluginHost::HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM
|
||||
*/
|
||||
function hook_headline_toolbar_select_menu_item($feed_id, $is_cat) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Allows adding custom elements to headlines Select... select dropdown (<option> format)
|
||||
* @param int $feed_id
|
||||
* @param int $is_cat
|
||||
* @return string
|
||||
* @see PluginHost::HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM2
|
||||
*/
|
||||
function hook_headline_toolbar_select_menu_item2($feed_id, $is_cat) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Invoked when user tries to subscribe to feed, may override information (i.e. feed URL) used afterwards
|
||||
* @param string $url
|
||||
* @param string $auth_login
|
||||
* @param string $auth_pass
|
||||
* @return bool
|
||||
* @see PluginHost::HOOK_PRE_SUBSCRIBE
|
||||
*/
|
||||
function hook_pre_subscribe(&$url, $auth_login, $auth_pass) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Invoked after user logout, may override built-in behavior (redirect back to login page)
|
||||
* @param string $login
|
||||
* @param int $user_id
|
||||
* @return array<mixed> - [0] - if set, url to redirect to
|
||||
*/
|
||||
function hook_post_logout($login, $user_id) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [""];
|
||||
}
|
||||
|
||||
/** Adds buttons to the right of default Login button
|
||||
* @return void
|
||||
*/
|
||||
function hook_loginform_additional_buttons() {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** Returns false if session is considered invalid, true cascades to next handler */
|
||||
function hook_validate_session(): bool {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Invoked after passed article IDs were either marked (i.e. starred) or unmarked.
|
||||
*
|
||||
* **Note** resulting state of the articles is not passed to this function (because
|
||||
* tt-rss may do invert operation on ID range), you will need to get this from the database.
|
||||
* @param array<int> $article_ids ref_ids
|
||||
* @return void
|
||||
*/
|
||||
function hook_articles_mark_toggled(array $article_ids) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** Invoked after passed article IDs were either published or unpublished.
|
||||
*
|
||||
* **Note** resulting state of the articles is not passed to this function (because
|
||||
* tt-rss may do invert operation on ID range), you will need to get this from the database.
|
||||
*
|
||||
* @param array<int> $article_ids ref_ids
|
||||
* @return void
|
||||
*/
|
||||
function hook_articles_publish_toggled(array $article_ids) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
class PluginHandler extends Handler_Protected {
|
||||
function csrf_ignore(string $method): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
function catchall(string $method): void {
|
||||
$plugin_name = clean($_REQUEST["plugin"]);
|
||||
$plugin = PluginHost::getInstance()->get_plugin($plugin_name);
|
||||
$csrf_token = ($_POST["csrf_token"] ?? "");
|
||||
|
||||
if ($plugin) {
|
||||
if (method_exists($plugin, $method)) {
|
||||
if (validate_csrf($csrf_token) || $plugin->csrf_ignore($method)) {
|
||||
$plugin->$method();
|
||||
} else {
|
||||
user_error("Rejected {$plugin_name}->{$method}(): invalid CSRF token.", E_USER_WARNING);
|
||||
print Errors::to_json(Errors::E_UNAUTHORIZED);
|
||||
}
|
||||
} else {
|
||||
user_error("Rejected {$plugin_name}->{$method}(): unknown method.", E_USER_WARNING);
|
||||
print Errors::to_json(Errors::E_UNKNOWN_METHOD);
|
||||
}
|
||||
} else {
|
||||
user_error("Rejected {$plugin_name}->{$method}(): unknown plugin.", E_USER_WARNING);
|
||||
print Errors::to_json(Errors::E_UNKNOWN_PLUGIN);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,943 +0,0 @@
|
||||
<?php
|
||||
class PluginHost {
|
||||
private ?PDO $pdo = null;
|
||||
|
||||
/**
|
||||
* separate handle for plugin data so transaction while saving wouldn't clash with possible main
|
||||
* tt-rss code transactions; only initialized when first needed
|
||||
*/
|
||||
private ?PDO $pdo_data = null;
|
||||
|
||||
/** @var array<string, array<int, array<int, Plugin>>> hook types -> priority levels -> Plugins */
|
||||
private array $hooks = [];
|
||||
|
||||
/** @var array<string, Plugin> */
|
||||
private array $plugins = [];
|
||||
|
||||
/** @var array<string, array<string, Plugin>> handler type -> method type -> Plugin */
|
||||
private array $handlers = [];
|
||||
|
||||
/** @var array<string, array{'description': string, 'suffix': string, 'arghelp': string, 'class': Plugin}> command type -> details array */
|
||||
private array $commands = [];
|
||||
|
||||
/** @var array<string, array<string, mixed>> plugin name -> (potential profile array) -> key -> value */
|
||||
private array $storage = [];
|
||||
|
||||
/** @var array<int, array{'id': int, 'title': string, 'sender': Plugin, 'icon': string}> */
|
||||
private array $special_feeds = [];
|
||||
|
||||
/** @var array<string, Plugin> API method name, Plugin sender */
|
||||
private array $api_methods = [];
|
||||
|
||||
/** @var array<string, array<int, array{'action': string, 'description': string, 'sender': Plugin}>> */
|
||||
private array $plugin_actions = [];
|
||||
|
||||
private ?int $owner_uid = null;
|
||||
|
||||
private bool $data_loaded = false;
|
||||
|
||||
private static ?PluginHost $instance = null;
|
||||
|
||||
private ?Scheduler $scheduler = null;
|
||||
|
||||
const API_VERSION = 2;
|
||||
const PUBLIC_METHOD_DELIMITER = "--";
|
||||
|
||||
/** @see Plugin::hook_article_button() */
|
||||
const HOOK_ARTICLE_BUTTON = "hook_article_button";
|
||||
|
||||
/** @see Plugin::hook_article_filter() */
|
||||
const HOOK_ARTICLE_FILTER = "hook_article_filter";
|
||||
|
||||
/** @see Plugin::hook_prefs_tab() */
|
||||
const HOOK_PREFS_TAB = "hook_prefs_tab";
|
||||
|
||||
/** @see Plugin::hook_prefs_tab_section() */
|
||||
const HOOK_PREFS_TAB_SECTION = "hook_prefs_tab_section";
|
||||
|
||||
/** @see Plugin::hook_prefs_tabs() */
|
||||
const HOOK_PREFS_TABS = "hook_prefs_tabs";
|
||||
|
||||
/** @see Plugin::hook_feed_parsed() */
|
||||
const HOOK_FEED_PARSED = "hook_feed_parsed";
|
||||
|
||||
/** @see Plugin::hook_update_task() */
|
||||
const HOOK_UPDATE_TASK = "hook_update_task"; //*1
|
||||
|
||||
/** @see Plugin::hook_auth_user() */
|
||||
const HOOK_AUTH_USER = "hook_auth_user";
|
||||
|
||||
/** @see Plugin::hook_hotkey_map() */
|
||||
const HOOK_HOTKEY_MAP = "hook_hotkey_map";
|
||||
|
||||
/** @see Plugin::hook_render_article() */
|
||||
const HOOK_RENDER_ARTICLE = "hook_render_article";
|
||||
|
||||
/** @see Plugin::hook_render_article_cdm() */
|
||||
const HOOK_RENDER_ARTICLE_CDM = "hook_render_article_cdm";
|
||||
|
||||
/** @see Plugin::hook_feed_fetched() */
|
||||
const HOOK_FEED_FETCHED = "hook_feed_fetched";
|
||||
|
||||
/** @see Plugin::hook_sanitize() */
|
||||
const HOOK_SANITIZE = "hook_sanitize";
|
||||
|
||||
/** @see Plugin::hook_render_article_api() */
|
||||
const HOOK_RENDER_ARTICLE_API = "hook_render_article_api";
|
||||
|
||||
/** @see Plugin::hook_toolbar_button() */
|
||||
const HOOK_TOOLBAR_BUTTON = "hook_toolbar_button";
|
||||
|
||||
/** @see Plugin::hook_action_item() */
|
||||
const HOOK_ACTION_ITEM = "hook_action_item";
|
||||
|
||||
/** @see Plugin::hook_headline_toolbar_button() */
|
||||
const HOOK_HEADLINE_TOOLBAR_BUTTON = "hook_headline_toolbar_button";
|
||||
|
||||
/** @see Plugin::hook_hotkey_info() */
|
||||
const HOOK_HOTKEY_INFO = "hook_hotkey_info";
|
||||
|
||||
/** @see Plugin::hook_article_left_button() */
|
||||
const HOOK_ARTICLE_LEFT_BUTTON = "hook_article_left_button";
|
||||
|
||||
/** @see Plugin::hook_prefs_edit_feed() */
|
||||
const HOOK_PREFS_EDIT_FEED = "hook_prefs_edit_feed";
|
||||
|
||||
/** @see Plugin::hook_prefs_save_feed() */
|
||||
const HOOK_PREFS_SAVE_FEED = "hook_prefs_save_feed";
|
||||
|
||||
/** @see Plugin::hook_fetch_feed() */
|
||||
const HOOK_FETCH_FEED = "hook_fetch_feed";
|
||||
|
||||
/** @see Plugin::hook_query_headlines() */
|
||||
const HOOK_QUERY_HEADLINES = "hook_query_headlines";
|
||||
|
||||
/** @see Plugin::hook_house_keeping() */
|
||||
const HOOK_HOUSE_KEEPING = "hook_house_keeping"; //*1
|
||||
|
||||
/** @see Plugin::hook_search() */
|
||||
const HOOK_SEARCH = "hook_search";
|
||||
|
||||
/** @see Plugin::hook_format_enclosures() */
|
||||
const HOOK_FORMAT_ENCLOSURES = "hook_format_enclosures";
|
||||
|
||||
/** @see Plugin::hook_subscribe_feed() */
|
||||
const HOOK_SUBSCRIBE_FEED = "hook_subscribe_feed";
|
||||
|
||||
/** @see Plugin::hook_headlines_before() */
|
||||
const HOOK_HEADLINES_BEFORE = "hook_headlines_before";
|
||||
|
||||
/** @see Plugin::hook_render_enclosure() */
|
||||
const HOOK_RENDER_ENCLOSURE = "hook_render_enclosure";
|
||||
|
||||
/** @see Plugin::hook_article_filter_action() */
|
||||
const HOOK_ARTICLE_FILTER_ACTION = "hook_article_filter_action";
|
||||
|
||||
/** @see Plugin::hook_article_export_feed() */
|
||||
const HOOK_ARTICLE_EXPORT_FEED = "hook_article_export_feed";
|
||||
|
||||
/** @see Plugin::hook_main_toolbar_button() */
|
||||
const HOOK_MAIN_TOOLBAR_BUTTON = "hook_main_toolbar_button";
|
||||
|
||||
/** @see Plugin::hook_enclosure_entry() */
|
||||
const HOOK_ENCLOSURE_ENTRY = "hook_enclosure_entry";
|
||||
|
||||
/** @see Plugin::hook_format_article() */
|
||||
const HOOK_FORMAT_ARTICLE = "hook_format_article";
|
||||
|
||||
/** @see Plugin::hook_feed_basic_info() */
|
||||
const HOOK_FEED_BASIC_INFO = "hook_feed_basic_info";
|
||||
|
||||
/** @see Plugin::hook_send_local_file() */
|
||||
const HOOK_SEND_LOCAL_FILE = "hook_send_local_file";
|
||||
|
||||
/** @see Plugin::hook_unsubscribe_feed() */
|
||||
const HOOK_UNSUBSCRIBE_FEED = "hook_unsubscribe_feed";
|
||||
|
||||
/** @see Plugin::hook_send_mail() */
|
||||
const HOOK_SEND_MAIL = "hook_send_mail";
|
||||
|
||||
/** @see Plugin::hook_filter_triggered() */
|
||||
const HOOK_FILTER_TRIGGERED = "hook_filter_triggered";
|
||||
|
||||
/** @see Plugin::hook_get_full_text() */
|
||||
const HOOK_GET_FULL_TEXT = "hook_get_full_text";
|
||||
|
||||
/** @see Plugin::hook_article_image() */
|
||||
const HOOK_ARTICLE_IMAGE = "hook_article_image";
|
||||
|
||||
/** @see Plugin::hook_feed_tree() */
|
||||
const HOOK_FEED_TREE = "hook_feed_tree";
|
||||
|
||||
/** @see Plugin::hook_iframe_whitelisted() */
|
||||
const HOOK_IFRAME_WHITELISTED = "hook_iframe_whitelisted";
|
||||
|
||||
/** @see Plugin::hook_enclosure_imported() */
|
||||
const HOOK_ENCLOSURE_IMPORTED = "hook_enclosure_imported";
|
||||
|
||||
/** @see Plugin::hook_headlines_custom_sort_map() */
|
||||
const HOOK_HEADLINES_CUSTOM_SORT_MAP = "hook_headlines_custom_sort_map";
|
||||
|
||||
/** @see Plugin::hook_headlines_custom_sort_override() */
|
||||
const HOOK_HEADLINES_CUSTOM_SORT_OVERRIDE = "hook_headlines_custom_sort_override";
|
||||
|
||||
/** @see Plugin::hook_headline_toolbar_select_menu_item()
|
||||
* @deprecated removed, see PluginHost::HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM2
|
||||
*/
|
||||
const HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM = "hook_headline_toolbar_select_menu_item";
|
||||
|
||||
/** @see Plugin::hook_headline_toolbar_select_menu_item() */
|
||||
const HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM2 = "hook_headline_toolbar_select_menu_item2";
|
||||
|
||||
/** @see Plugin::hook_pre_subscribe() */
|
||||
const HOOK_PRE_SUBSCRIBE = "hook_pre_subscribe";
|
||||
|
||||
/** @see Plugin::hook_post_logout() */
|
||||
const HOOK_POST_LOGOUT = "hook_post_logout";
|
||||
|
||||
/** @see Plugin::hook_loginform_additional_buttons() */
|
||||
const HOOK_LOGINFORM_ADDITIONAL_BUTTONS = "hook_loginform_additional_buttons";
|
||||
|
||||
/** @see Plugin::hook_validate_session() */
|
||||
const HOOK_VALIDATE_SESSION = "hook_validate_session";
|
||||
|
||||
/** @see Plugin::hook_articles_mark_toggled() */
|
||||
const HOOK_ARTICLES_MARK_TOGGLED = "hook_articles_mark_toggled";
|
||||
|
||||
/** @see Plugin::hook_articles_publish_toggled() */
|
||||
const HOOK_ARTICLES_PUBLISH_TOGGLED = "hook_articles_publish_toggled";
|
||||
|
||||
const KIND_ALL = 1;
|
||||
const KIND_SYSTEM = 2;
|
||||
const KIND_USER = 3;
|
||||
|
||||
static function object_to_domain(Plugin $plugin): string {
|
||||
return strtolower(get_class($plugin));
|
||||
}
|
||||
|
||||
function __construct() {
|
||||
$this->pdo = Db::pdo();
|
||||
$this->scheduler = new Scheduler('PluginHost Scheduler');
|
||||
$this->storage = [];
|
||||
}
|
||||
|
||||
private function __clone() {
|
||||
//
|
||||
}
|
||||
|
||||
public static function getInstance(): PluginHost {
|
||||
if (self::$instance == null)
|
||||
self::$instance = new self();
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
private function register_plugin(string $name, Plugin $plugin): void {
|
||||
//array_push($this->plugins, $plugin);
|
||||
$this->plugins[$name] = $plugin;
|
||||
}
|
||||
|
||||
/** needed for compatibility with API 1 */
|
||||
function get_link(): bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** needed for compatibility with API 2 (?) */
|
||||
function get_dbh(): bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
function get_pdo(): PDO {
|
||||
return $this->pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
function get_plugin_names(): array {
|
||||
$names = [];
|
||||
|
||||
foreach ($this->plugins as $p) {
|
||||
array_push($names, get_class($p));
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Plugin>
|
||||
*/
|
||||
function get_plugins(): array {
|
||||
return $this->plugins;
|
||||
}
|
||||
|
||||
function get_plugin(string $name): ?Plugin {
|
||||
return $this->plugins[strtolower($name)] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PluginHost::HOOK_* $hook
|
||||
* @param mixed $args
|
||||
*/
|
||||
function run_hooks(string $hook, ...$args): void {
|
||||
|
||||
$method = strtolower((string)$hook);
|
||||
|
||||
foreach ($this->get_hooks((string)$hook) as $plugin) {
|
||||
//Debug::log("invoking: " . get_class($plugin) . "->$hook()", Debug::$LOG_VERBOSE);
|
||||
|
||||
try {
|
||||
$plugin->$method(...$args);
|
||||
} catch (Exception $ex) {
|
||||
user_error($ex, E_USER_WARNING);
|
||||
} catch (Error $err) {
|
||||
user_error($err, E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PluginHost::HOOK_* $hook
|
||||
* @param mixed $args
|
||||
* @param mixed $check
|
||||
*/
|
||||
function run_hooks_until(string $hook, $check, ...$args): bool {
|
||||
$method = strtolower((string)$hook);
|
||||
|
||||
foreach ($this->get_hooks((string)$hook) as $plugin) {
|
||||
try {
|
||||
$result = $plugin->$method(...$args);
|
||||
|
||||
if ($result == $check)
|
||||
return true;
|
||||
|
||||
} catch (Exception $ex) {
|
||||
user_error($ex, E_USER_WARNING);
|
||||
} catch (Error $err) {
|
||||
user_error($err, E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PluginHost::HOOK_* $hook
|
||||
* @param mixed $args
|
||||
*/
|
||||
function run_hooks_callback(string $hook, Closure $callback, ...$args): void {
|
||||
$method = strtolower((string)$hook);
|
||||
|
||||
foreach ($this->get_hooks((string)$hook) as $plugin) {
|
||||
//Debug::log("invoking: " . get_class($plugin) . "->$hook()", Debug::$LOG_VERBOSE);
|
||||
|
||||
try {
|
||||
if ($callback($plugin->$method(...$args), $plugin))
|
||||
break;
|
||||
} catch (Exception $ex) {
|
||||
user_error($ex, E_USER_WARNING);
|
||||
} catch (Error $err) {
|
||||
user_error($err, E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PluginHost::HOOK_* $hook
|
||||
* @param mixed $args
|
||||
*/
|
||||
function chain_hooks_callback(string $hook, Closure $callback, &...$args): void {
|
||||
$method = strtolower((string)$hook);
|
||||
|
||||
foreach ($this->get_hooks((string)$hook) as $plugin) {
|
||||
try {
|
||||
if ($callback($plugin->$method(...$args), $plugin))
|
||||
break;
|
||||
} catch (Exception $ex) {
|
||||
user_error($ex, E_USER_WARNING);
|
||||
} catch (Error $err) {
|
||||
user_error($err, E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PluginHost::HOOK_* $type
|
||||
*/
|
||||
function add_hook(string $type, Plugin $sender, int $priority = 50): void {
|
||||
$priority = (int) $priority;
|
||||
|
||||
if (!method_exists($sender, strtolower((string)$type))) {
|
||||
user_error(
|
||||
sprintf("Plugin %s tried to register a hook without implementation: %s",
|
||||
get_class($sender), $type),
|
||||
E_USER_WARNING
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($this->hooks[$type])) {
|
||||
$this->hooks[$type] = [];
|
||||
}
|
||||
|
||||
if (empty($this->hooks[$type][$priority])) {
|
||||
$this->hooks[$type][$priority] = [];
|
||||
}
|
||||
|
||||
array_push($this->hooks[$type][$priority], $sender);
|
||||
ksort($this->hooks[$type]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PluginHost::HOOK_* $type
|
||||
*/
|
||||
function del_hook(string $type, Plugin $sender): void {
|
||||
if (array_key_exists($type, $this->hooks)) {
|
||||
foreach (array_keys($this->hooks[$type]) as $prio) {
|
||||
$key = array_search($sender, $this->hooks[$type][$prio]);
|
||||
|
||||
if ($key !== false) {
|
||||
unset($this->hooks[$type][$prio][$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PluginHost::HOOK_* $type
|
||||
* @return array<int, Plugin>
|
||||
*/
|
||||
function get_hooks(string $type) {
|
||||
if (isset($this->hooks[$type])) {
|
||||
$tmp = [];
|
||||
|
||||
foreach (array_keys($this->hooks[$type]) as $prio) {
|
||||
array_push($tmp, ...$this->hooks[$type][$prio]);
|
||||
}
|
||||
|
||||
return $tmp;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PluginHost::KIND_* $kind
|
||||
*/
|
||||
function load_all(int $kind, ?int $owner_uid = null, bool $skip_init = false): void {
|
||||
$plugins = [...(glob("plugins/*") ?: []), ...(glob("plugins.local/*") ?: [])];
|
||||
$plugins = array_filter($plugins, "is_dir");
|
||||
$plugins = array_map("basename", $plugins);
|
||||
|
||||
asort($plugins);
|
||||
|
||||
$this->load(join(",", $plugins), (int)$kind, $owner_uid, $skip_init);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PluginHost::KIND_* $kind
|
||||
*/
|
||||
function load(string $classlist, int $kind, ?int $owner_uid = null, bool $skip_init = false): void {
|
||||
$plugins = explode(",", $classlist);
|
||||
|
||||
$this->owner_uid = (int) $owner_uid;
|
||||
|
||||
if ($this->owner_uid) {
|
||||
$this->set_scheduler_name("PluginHost Scheduler for UID $owner_uid");
|
||||
}
|
||||
|
||||
foreach ($plugins as $class) {
|
||||
$class = trim($class);
|
||||
$class_file = strtolower(basename(clean($class)));
|
||||
|
||||
// try system plugin directory first
|
||||
$file = Config::get_self_dir() . "/plugins/$class_file/init.php";
|
||||
|
||||
if (!file_exists($file)) {
|
||||
$file = Config::get_self_dir() . "/plugins.local/$class_file/init.php";
|
||||
|
||||
if (!file_exists($file)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($this->plugins[$class])) {
|
||||
// WIP hack
|
||||
// we can't catch incompatible method signatures via Throwable
|
||||
// this also enables global tt-rss safe mode in case there are more plugins like this
|
||||
if (!getenv('TTRSS_XDEBUG_ENABLED') && ($_SESSION["plugin_blacklist"][$class] ?? 0)) {
|
||||
|
||||
// only report once per-plugin per-session
|
||||
if ($_SESSION["plugin_blacklist"][$class] < 2) {
|
||||
user_error("Plugin $class has caused a PHP fatal error so it won't be loaded again in this session.", E_USER_WARNING);
|
||||
$_SESSION["plugin_blacklist"][$class] = 2;
|
||||
}
|
||||
|
||||
$_SESSION["safe_mode"] = 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$_SESSION["plugin_blacklist"][$class] = 1;
|
||||
require_once $file;
|
||||
unset($_SESSION["plugin_blacklist"][$class]);
|
||||
|
||||
} catch (Error $err) {
|
||||
user_error($err, E_USER_WARNING);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (class_exists($class) && is_subclass_of($class, "Plugin")) {
|
||||
$plugin = new $class($this);
|
||||
$plugin_api = $plugin->api_version();
|
||||
|
||||
if ($plugin_api < self::API_VERSION) {
|
||||
user_error("Plugin $class is not compatible with current API version (need: " . self::API_VERSION . ", got: $plugin_api)", E_USER_WARNING);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file_exists(dirname($file) . "/locale")) {
|
||||
_bindtextdomain($class, dirname($file) . "/locale");
|
||||
_bind_textdomain_codeset($class, "UTF-8");
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($kind) {
|
||||
case $this::KIND_SYSTEM:
|
||||
if ($this->is_system($plugin)) {
|
||||
if (!$skip_init) $plugin->init($this);
|
||||
$this->register_plugin($class, $plugin);
|
||||
}
|
||||
break;
|
||||
case $this::KIND_USER:
|
||||
if (!$this->is_system($plugin)) {
|
||||
if (!$skip_init) $plugin->init($this);
|
||||
$this->register_plugin($class, $plugin);
|
||||
}
|
||||
break;
|
||||
case $this::KIND_ALL:
|
||||
if (!$skip_init) $plugin->init($this);
|
||||
$this->register_plugin($class, $plugin);
|
||||
break;
|
||||
}
|
||||
} catch (Exception $ex) {
|
||||
user_error($ex, E_USER_WARNING);
|
||||
} catch (Error $err) {
|
||||
user_error($err, E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->load_data();
|
||||
}
|
||||
|
||||
function is_system(Plugin $plugin): bool {
|
||||
$about = $plugin->about();
|
||||
|
||||
return ($about[3] ?? false) === true;
|
||||
}
|
||||
|
||||
// only system plugins are allowed to modify routing
|
||||
function add_handler(string $handler, string $method, Plugin $sender): void {
|
||||
$handler = str_replace("-", "_", strtolower($handler));
|
||||
$method = strtolower($method);
|
||||
|
||||
if ($this->is_system($sender)) {
|
||||
$this->handlers[$handler] ??= [];
|
||||
$this->handlers[$handler][$method] = $sender;
|
||||
}
|
||||
}
|
||||
|
||||
function del_handler(string $handler, string $method, Plugin $sender): void {
|
||||
$handler = str_replace("-", "_", strtolower($handler));
|
||||
$method = strtolower($method);
|
||||
|
||||
if ($this->is_system($sender)) {
|
||||
unset($this->handlers[$handler][$method]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false|Plugin false if the handler couldn't be found, otherwise the Plugin/handler
|
||||
*/
|
||||
function lookup_handler(string $handler, string $method): false|Plugin {
|
||||
$handler = str_replace("-", "_", strtolower($handler));
|
||||
$method = strtolower($method);
|
||||
|
||||
if (isset($this->handlers[$handler])) {
|
||||
return $this->handlers[$handler]["*"] ?? $this->handlers[$handler][$method];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function add_command(string $command, string $description, Plugin $sender, string $suffix = "", string $arghelp = ""): void {
|
||||
$command = str_replace("-", "_", strtolower($command));
|
||||
|
||||
$this->commands[$command] = array("description" => $description,
|
||||
"suffix" => $suffix,
|
||||
"arghelp" => $arghelp,
|
||||
"class" => $sender);
|
||||
}
|
||||
|
||||
function del_command(string $command): void {
|
||||
$command = "-" . strtolower($command);
|
||||
|
||||
unset($this->commands[$command]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false|Plugin false if the command couldn't be found, otherwise the registered Plugin
|
||||
*/
|
||||
function lookup_command(string $command): false|Plugin {
|
||||
$command = "-" . strtolower($command);
|
||||
|
||||
if (array_key_exists($command, $this->commands)) {
|
||||
return $this->commands[$command]["class"];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, array{'description': string, 'suffix': string, 'arghelp': string, 'class': Plugin}> command type -> details array */
|
||||
function get_commands() {
|
||||
return $this->commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $args
|
||||
*/
|
||||
function run_commands(array $args): void {
|
||||
foreach ($this->get_commands() as $command => $data) {
|
||||
if (isset($args[$command])) {
|
||||
$command = str_replace("-", "", $command);
|
||||
$data["class"]->$command($args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function load_data(): void {
|
||||
if ($this->owner_uid && !$this->data_loaded && Config::get_schema_version() > 100) {
|
||||
$sth = $this->pdo->prepare("SELECT name, content FROM ttrss_plugin_storage
|
||||
WHERE owner_uid = ?");
|
||||
$sth->execute([$this->owner_uid]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
$this->storage[$line["name"]] = unserialize($line["content"]);
|
||||
}
|
||||
|
||||
$this->data_loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
private function save_data(string $plugin): void {
|
||||
if ($this->owner_uid) {
|
||||
if (!$this->pdo_data)
|
||||
$this->pdo_data = Db::instance()->pdo_connect();
|
||||
|
||||
$this->pdo_data->beginTransaction();
|
||||
|
||||
$sth = $this->pdo_data->prepare("SELECT id FROM ttrss_plugin_storage WHERE
|
||||
owner_uid= ? AND name = ?");
|
||||
$sth->execute([$this->owner_uid, $plugin]);
|
||||
|
||||
$this->storage[$plugin] ??= [];
|
||||
|
||||
$content = serialize($this->storage[$plugin]);
|
||||
|
||||
if ($sth->fetch()) {
|
||||
$sth = $this->pdo_data->prepare("UPDATE ttrss_plugin_storage SET content = ?
|
||||
WHERE owner_uid= ? AND name = ?");
|
||||
$sth->execute([$content, $this->owner_uid, $plugin]);
|
||||
|
||||
} else {
|
||||
$sth = $this->pdo_data->prepare("INSERT INTO ttrss_plugin_storage
|
||||
(name,owner_uid,content) VALUES
|
||||
(?, ?, ?)");
|
||||
$sth->execute([$plugin, $this->owner_uid, $content]);
|
||||
}
|
||||
|
||||
$this->pdo_data->commit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* same as set(), but sets data to current preference profile
|
||||
*
|
||||
* @param mixed $value
|
||||
*/
|
||||
function profile_set(Plugin $sender, string $name, $value): void {
|
||||
$profile_id = $_SESSION["profile"] ?? null;
|
||||
|
||||
if ($profile_id) {
|
||||
$idx = get_class($sender);
|
||||
|
||||
$this->storage[$idx] ??= [];
|
||||
$this->storage[$idx][$profile_id] ??= [];
|
||||
$this->storage[$idx][$profile_id][$name] = $value;
|
||||
|
||||
$this->save_data(get_class($sender));
|
||||
} else {
|
||||
$this->set($sender, $name, $value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*/
|
||||
function set(Plugin $sender, string $name, $value): void {
|
||||
$idx = get_class($sender);
|
||||
|
||||
$this->storage[$idx] ??= [];
|
||||
$this->storage[$idx][$name] = $value;
|
||||
|
||||
$this->save_data(get_class($sender));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $params
|
||||
*/
|
||||
function set_array(Plugin $sender, array $params): void {
|
||||
$idx = get_class($sender);
|
||||
|
||||
$this->storage[$idx] ??= [];
|
||||
|
||||
foreach ($params as $name => $value)
|
||||
$this->storage[$idx][$name] = $value;
|
||||
|
||||
$this->save_data(get_class($sender));
|
||||
}
|
||||
|
||||
/**
|
||||
* same as get(), but sets data to current preference profile
|
||||
*
|
||||
* @param mixed $default_value
|
||||
* @return mixed
|
||||
*/
|
||||
function profile_get(Plugin $sender, string $name, $default_value = false) {
|
||||
$profile_id = $_SESSION["profile"] ?? null;
|
||||
|
||||
if ($profile_id) {
|
||||
$idx = get_class($sender);
|
||||
$this->load_data();
|
||||
return $this->storage[$idx][$profile_id][$name] ?? $default_value;
|
||||
} else {
|
||||
return $this->get($sender, $name, $default_value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $default_value
|
||||
* @return mixed
|
||||
*/
|
||||
function get(Plugin $sender, string $name, $default_value = false) {
|
||||
$idx = get_class($sender);
|
||||
$this->load_data();
|
||||
return $this->storage[$idx][$name] ?? $default_value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $default_value
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
function get_array(Plugin $sender, string $name, array $default_value = []): array {
|
||||
$tmp = $this->get($sender, $name);
|
||||
|
||||
if (!is_array($tmp)) $tmp = $default_value;
|
||||
|
||||
return $tmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function get_all(Plugin $sender) {
|
||||
$idx = get_class($sender);
|
||||
|
||||
return $this->storage[$idx] ?? [];
|
||||
}
|
||||
|
||||
function clear_data(Plugin $sender): void {
|
||||
if ($this->owner_uid) {
|
||||
$idx = get_class($sender);
|
||||
|
||||
unset($this->storage[$idx]);
|
||||
|
||||
$sth = $this->pdo->prepare("DELETE FROM ttrss_plugin_storage WHERE name = ?
|
||||
AND owner_uid = ?");
|
||||
$sth->execute([$idx, $this->owner_uid]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a special (plugin-provided) feed
|
||||
*
|
||||
* @param int $cat_id only -1 (Feeds::CATEGORY_SPECIAL) is supported
|
||||
* @return false|int false if the feed wasn't added (e.g. $cat_id wasn't Feeds::CATEGORY_SPECIAL),
|
||||
* otherwise an integer "feed ID" that might change between executions
|
||||
*/
|
||||
function add_feed(int $cat_id, string $title, string $icon, Plugin $sender): false|int {
|
||||
if ($cat_id !== Feeds::CATEGORY_SPECIAL)
|
||||
return false;
|
||||
|
||||
$id = count($this->special_feeds);
|
||||
|
||||
$this->special_feeds[] = [
|
||||
'id' => $id,
|
||||
'title' => $title,
|
||||
'sender' => $sender,
|
||||
'icon' => $icon,
|
||||
];
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get special (plugin-provided) feeds
|
||||
*
|
||||
* @param int $cat_id only -1 (Feeds::CATEGORY_SPECIAL) is supported
|
||||
* @return array<int, array{'id': int, 'title': string, 'sender': Plugin, 'icon': string}>
|
||||
*/
|
||||
function get_feeds(int $cat_id) {
|
||||
return $cat_id === Feeds::CATEGORY_SPECIAL ? $this->special_feeds : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Plugin handling a specific virtual feed.
|
||||
*
|
||||
* Convert feed_id (e.g. -129) to pfeed_id first.
|
||||
*
|
||||
* @return (Plugin&IVirtualFeed)|null a Plugin that implements IVirtualFeed, otherwise null
|
||||
*/
|
||||
function get_feed_handler(int $pfeed_id): ?Plugin {
|
||||
foreach ($this->special_feeds as $feed) {
|
||||
if ($feed['id'] == $pfeed_id) {
|
||||
/** @var Plugin&IVirtualFeed $feed['sender'] */
|
||||
return implements_interface($feed['sender'], 'IVirtualFeed') ? $feed['sender'] : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static function pfeed_to_feed_id(int $pfeed): int {
|
||||
return PLUGIN_FEED_BASE_INDEX - 1 - abs($pfeed);
|
||||
}
|
||||
|
||||
static function feed_to_pfeed_id(int $feed): int {
|
||||
return PLUGIN_FEED_BASE_INDEX - 1 + abs($feed);
|
||||
}
|
||||
|
||||
function add_api_method(string $name, Plugin $sender): void {
|
||||
if ($this->is_system($sender)) {
|
||||
$this->api_methods[strtolower($name)] = $sender;
|
||||
}
|
||||
}
|
||||
|
||||
function get_api_method(string $name): ?Plugin {
|
||||
return $this->api_methods[$name] ?? null;
|
||||
}
|
||||
|
||||
function add_filter_action(Plugin $sender, string $action_name, string $action_desc): void {
|
||||
$sender_class = get_class($sender);
|
||||
|
||||
$this->plugin_actions[$sender_class] ??= [];
|
||||
|
||||
$this->plugin_actions[$sender_class][] = [
|
||||
"action" => $action_name,
|
||||
"description" => $action_desc,
|
||||
"sender" => $sender,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, array{'action': string, 'description': string, 'sender': Plugin}>>
|
||||
*/
|
||||
function get_filter_actions() {
|
||||
return $this->plugin_actions;
|
||||
}
|
||||
|
||||
function get_owner_uid(): ?int {
|
||||
return $this->owner_uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* handled by classes/PluginHandler.php, requires valid session
|
||||
*
|
||||
* @param array<int|string, mixed> $params
|
||||
*/
|
||||
function get_method_url(Plugin $sender, string $method, array $params = []): string {
|
||||
return Config::get_self_url() . "/backend.php?" .
|
||||
http_build_query([
|
||||
'op' => 'pluginhandler',
|
||||
'plugin' => strtolower(get_class($sender)),
|
||||
'method' => $method,
|
||||
...$params,
|
||||
]);
|
||||
}
|
||||
|
||||
// shortcut syntax (disabled for now)
|
||||
/* function get_method_url(Plugin $sender, string $method, $params) {
|
||||
return Config::get_self_url() . "/backend.php?" .
|
||||
http_build_query(
|
||||
array_merge(
|
||||
[
|
||||
"op" => strtolower(get_class($sender) . self::PUBLIC_METHOD_DELIMITER . $method),
|
||||
],
|
||||
$params));
|
||||
} */
|
||||
|
||||
/**
|
||||
* WARNING: endpoint in public.php, exposed to unauthenticated users
|
||||
*
|
||||
* @param array<int|string, mixed> $params
|
||||
*/
|
||||
function get_public_method_url(Plugin $sender, string $method, array $params = []): ?string {
|
||||
if ($sender->is_public_method($method)) {
|
||||
return Config::get_self_url() . "/public.php?" .
|
||||
http_build_query([
|
||||
'op' => strtolower(get_class($sender) . self::PUBLIC_METHOD_DELIMITER . $method),
|
||||
...$params,
|
||||
]);
|
||||
}
|
||||
user_error("get_public_method_url: requested method '$method' of '" . get_class($sender) . "' is private.");
|
||||
return null;
|
||||
}
|
||||
|
||||
function get_plugin_dir(Plugin $plugin): string {
|
||||
$ref = new ReflectionClass(get_class($plugin));
|
||||
return dirname($ref->getFileName());
|
||||
}
|
||||
|
||||
// TODO: use get_plugin_dir()
|
||||
function is_local(Plugin $plugin): bool {
|
||||
$ref = new ReflectionClass(get_class($plugin));
|
||||
return basename(dirname(dirname($ref->getFileName()))) == "plugins.local";
|
||||
}
|
||||
|
||||
/**
|
||||
* This exposes sheduled tasks functionality to plugins. For system plugins, tasks registered here are
|
||||
* executed (if due) during housekeeping. For user plugins, tasks are only run after any feeds owned by
|
||||
* the user have been processed in an update batch (which means user is not inactive).
|
||||
*
|
||||
* This behaviour mirrors that of `HOOK_HOUSE_KEEPING` for user plugins.
|
||||
*
|
||||
* @see Scheduler::add_scheduled_task()
|
||||
* @see Plugin::hook_house_keeping()
|
||||
*/
|
||||
function add_scheduled_task(Plugin $sender, string $task_name, string $cron_expression, Closure $callback): bool {
|
||||
if ($this->is_system($sender))
|
||||
$task_name = get_class($sender) . ':' . $task_name;
|
||||
else
|
||||
$task_name = get_class($sender) . ':' . $task_name . ':' . $this->owner_uid;
|
||||
|
||||
return $this->scheduler->add_scheduled_task($task_name, $cron_expression, $callback);
|
||||
}
|
||||
|
||||
function run_due_tasks() : void {
|
||||
$this->scheduler->run_due_tasks();
|
||||
}
|
||||
|
||||
private function set_scheduler_name(string $name) : void {
|
||||
$this->scheduler->set_name($name);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,225 +0,0 @@
|
||||
<?php
|
||||
class Pref_Labels extends Handler_Protected {
|
||||
|
||||
function csrf_ignore(string $method): bool {
|
||||
$csrf_ignored = array("index", "getlabeltree");
|
||||
|
||||
return array_search($method, $csrf_ignored) !== false;
|
||||
}
|
||||
|
||||
function edit(): void {
|
||||
$label = ORM::for_table('ttrss_labels2')
|
||||
->where('owner_uid', $_SESSION['uid'])
|
||||
->find_one($_REQUEST['id']);
|
||||
|
||||
if ($label) {
|
||||
print json_encode($label->as_array());
|
||||
}
|
||||
}
|
||||
|
||||
function getlabeltree(): void {
|
||||
$root = array();
|
||||
$root['id'] = 'root';
|
||||
$root['name'] = __('Labels');
|
||||
$root['items'] = array();
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT *
|
||||
FROM ttrss_labels2
|
||||
WHERE owner_uid = ?
|
||||
ORDER BY caption");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
$label = array();
|
||||
$label['id'] = 'LABEL:' . $line['id'];
|
||||
$label['bare_id'] = $line['id'];
|
||||
$label['name'] = $line['caption'];
|
||||
$label['fg_color'] = $line['fg_color'];
|
||||
$label['bg_color'] = $line['bg_color'];
|
||||
$label['type'] = 'label';
|
||||
$label['checkbox'] = false;
|
||||
|
||||
array_push($root['items'], $label);
|
||||
}
|
||||
|
||||
$fl = array();
|
||||
$fl['identifier'] = 'id';
|
||||
$fl['label'] = 'name';
|
||||
$fl['items'] = array($root);
|
||||
|
||||
print json_encode($fl);
|
||||
}
|
||||
|
||||
function colorset(): void {
|
||||
$kind = clean($_REQUEST["kind"]);
|
||||
$ids = explode(',', clean($_REQUEST["ids"]));
|
||||
$color = clean($_REQUEST["color"]);
|
||||
$fg = clean($_REQUEST["fg"]);
|
||||
$bg = clean($_REQUEST["bg"]);
|
||||
|
||||
foreach ($ids as $id) {
|
||||
|
||||
if ($kind == "fg" || $kind == "bg") {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_labels2 SET
|
||||
{$kind}_color = ? WHERE id = ?
|
||||
AND owner_uid = ?");
|
||||
|
||||
$sth->execute([$color, $id, $_SESSION['uid']]);
|
||||
|
||||
} else {
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_labels2 SET
|
||||
fg_color = ?, bg_color = ? WHERE id = ?
|
||||
AND owner_uid = ?");
|
||||
|
||||
$sth->execute([$fg, $bg, $id, $_SESSION['uid']]);
|
||||
}
|
||||
|
||||
/* Remove cached data */
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET label_cache = ''
|
||||
WHERE owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
}
|
||||
}
|
||||
|
||||
function colorreset(): void {
|
||||
$ids = explode(',', clean($_REQUEST["ids"]));
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_labels2 SET
|
||||
fg_color = '', bg_color = '' WHERE id = ?
|
||||
AND owner_uid = ?");
|
||||
$sth->execute([$id, $_SESSION['uid']]);
|
||||
|
||||
/* Remove cached data */
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET label_cache = ''
|
||||
WHERE owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
}
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
|
||||
$id = clean($_REQUEST["id"]);
|
||||
$caption = clean($_REQUEST["caption"]);
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT caption FROM ttrss_labels2
|
||||
WHERE id = ? AND owner_uid = ?");
|
||||
$sth->execute([$id, $_SESSION['uid']]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$old_caption = $row["caption"];
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT id FROM ttrss_labels2
|
||||
WHERE caption = ? AND owner_uid = ?");
|
||||
$sth->execute([$caption, $_SESSION['uid']]);
|
||||
|
||||
if (!$sth->fetch()) {
|
||||
if ($caption) {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_labels2 SET
|
||||
caption = ? WHERE id = ? AND
|
||||
owner_uid = ?");
|
||||
$sth->execute([$caption, $id, $_SESSION['uid']]);
|
||||
|
||||
/* Update filters that reference label being renamed */
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_filters2_actions SET
|
||||
action_param = ? WHERE action_param = ?
|
||||
AND action_id = 7
|
||||
AND filter_id IN (SELECT id FROM ttrss_filters2 WHERE owner_uid = ?)");
|
||||
|
||||
$sth->execute([$caption, $old_caption, $_SESSION['uid']]);
|
||||
|
||||
print clean($_REQUEST["caption"]);
|
||||
} else {
|
||||
print $old_caption;
|
||||
}
|
||||
} else {
|
||||
print $old_caption;
|
||||
}
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
|
||||
}
|
||||
|
||||
function remove(): void {
|
||||
/** @var array<int, int> */
|
||||
$ids = array_map("intval", explode(",", clean($_REQUEST["ids"])));
|
||||
|
||||
foreach ($ids as $id) {
|
||||
Labels::remove($id, $_SESSION["uid"]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function add(): void {
|
||||
$caption = clean($_REQUEST["caption"]);
|
||||
$output = clean($_REQUEST["output"] ?? false);
|
||||
|
||||
if ($caption) {
|
||||
if (Labels::create($caption)) {
|
||||
if (!$output) {
|
||||
print T_sprintf("Created label <b>%s</b>", htmlspecialchars($caption));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function index(): void {
|
||||
?>
|
||||
<div dojoType='dijit.layout.BorderContainer' gutters='false'>
|
||||
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='top'>
|
||||
<div dojoType='fox.Toolbar'>
|
||||
<div dojoType='fox.form.DropDownButton'>
|
||||
<span><?= __('Select') ?></span>
|
||||
<div dojoType='dijit.Menu' style='display: none'>
|
||||
<div onclick="dijit.byId('labelTree').model.setAllChecked(true)"
|
||||
dojoType='dijit.MenuItem'><?=('All') ?></div>
|
||||
<div onclick="dijit.byId('labelTree').model.setAllChecked(false)"
|
||||
dojoType='dijit.MenuItem'><?=('None') ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button dojoType='dijit.form.Button' onclick='CommonDialogs.addLabel()'>
|
||||
<?=('Create label') ?></button dojoType='dijit.form.Button'>
|
||||
|
||||
<button dojoType='dijit.form.Button' onclick="dijit.byId('labelTree').removeSelected()">
|
||||
<?=('Remove') ?></button dojoType='dijit.form.Button'>
|
||||
|
||||
<button dojoType='dijit.form.Button' onclick="dijit.byId('labelTree').resetColors()">
|
||||
<?=('Clear colors') ?></button dojoType='dijit.form.Button'>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='center'>
|
||||
<div dojoType='dojo.data.ItemFileWriteStore' jsId='labelStore'
|
||||
url='backend.php?op=Pref_Labels&method=getlabeltree'>
|
||||
</div>
|
||||
|
||||
<div dojoType='lib.CheckBoxStoreModel' jsId='labelModel' store='labelStore'
|
||||
query="{id:'root'}" rootId='root'
|
||||
childrenAttrs='items' checkboxStrict='false' checkboxAll='false'>
|
||||
</div>
|
||||
|
||||
<div dojoType='fox.PrefLabelTree' id='labelTree' model='labelModel' openOnClick='true'>
|
||||
<script type='dojo/method' event='onClick' args='item'>
|
||||
var id = String(item.id);
|
||||
var bare_id = id.substr(id.indexOf(':')+1);
|
||||
|
||||
if (id.match('LABEL:')) {
|
||||
dijit.byId('labelTree').editLabel(bare_id);
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefLabels") ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,270 +0,0 @@
|
||||
<?php
|
||||
|
||||
class Pref_System extends Handler_Administrative {
|
||||
|
||||
private const LOG_PAGE_LIMIT = 15;
|
||||
|
||||
function csrf_ignore(string $method): bool {
|
||||
$csrf_ignored = array("index");
|
||||
|
||||
return array_search($method, $csrf_ignored) !== false;
|
||||
}
|
||||
|
||||
function clearLog(): void {
|
||||
$this->pdo->query("DELETE FROM ttrss_error_log");
|
||||
}
|
||||
|
||||
function sendTestEmail(): void {
|
||||
$mail_address = clean($_REQUEST["mail_address"]);
|
||||
|
||||
$mailer = new Mailer();
|
||||
|
||||
$rc = $mailer->mail(["to_name" => "",
|
||||
"to_address" => $mail_address,
|
||||
"subject" => __("Test message from tt-rss"),
|
||||
"message" => ("This message confirms that tt-rss can send outgoing mail.")
|
||||
]);
|
||||
|
||||
print json_encode(['rc' => $rc, 'error' => $mailer->error()]);
|
||||
}
|
||||
|
||||
function getscheduledtasks(): void {
|
||||
?>
|
||||
<table width='100%' class='event-log'>
|
||||
<tr>
|
||||
<th><?= __("Task name") ?></th>
|
||||
<th><?= __("Schedule") ?></th>
|
||||
<th><?= __("Last executed") ?></th>
|
||||
<th><?= __("Duration (seconds)") ?></th>
|
||||
<th><?= __("Return code") ?></th>
|
||||
</tr>
|
||||
<?php
|
||||
|
||||
$task_records = ORM::for_table('ttrss_scheduled_tasks')
|
||||
->order_by_asc(['last_cron_expression', 'task_name'])
|
||||
->find_many();
|
||||
|
||||
foreach ($task_records as $task) {
|
||||
$row_style = $task->last_rc === 0 ? 'text-success' : 'text-error';
|
||||
|
||||
?>
|
||||
<tr>
|
||||
<td class="<?= $row_style ?>"><?= $task->task_name ?></td>
|
||||
<td><?= $task->last_cron_expression ?></td>
|
||||
<td><?= TimeHelper::make_local_datetime($task->last_run) ?></td>
|
||||
<td><?= $task->last_duration ?></td>
|
||||
<td><?= $task->last_rc ?></td>
|
||||
</tr>
|
||||
<?php
|
||||
}
|
||||
|
||||
?>
|
||||
</table>
|
||||
<?php
|
||||
}
|
||||
|
||||
function getphpinfo(): void {
|
||||
ob_start();
|
||||
phpinfo();
|
||||
$info = ob_get_contents();
|
||||
ob_end_clean();
|
||||
|
||||
print preg_replace( '%^.*<body>(.*)</body>.*$%ms','$1', (string)$info);
|
||||
}
|
||||
|
||||
private function _log_viewer(int $page, int $severity): void {
|
||||
$errno_values = match ($severity) {
|
||||
E_USER_ERROR => [E_ERROR, E_USER_ERROR, E_PARSE, E_COMPILE_ERROR],
|
||||
E_USER_WARNING => [E_ERROR, E_USER_ERROR, E_PARSE, E_COMPILE_ERROR, E_WARNING, E_USER_WARNING, E_DEPRECATED, E_USER_DEPRECATED],
|
||||
default => [],
|
||||
};
|
||||
|
||||
if (count($errno_values) > 0) {
|
||||
$errno_qmarks = arr_qmarks($errno_values);
|
||||
$errno_filter_qpart = "errno IN ($errno_qmarks)";
|
||||
} else {
|
||||
$errno_filter_qpart = "true";
|
||||
}
|
||||
|
||||
$offset = self::LOG_PAGE_LIMIT * $page;
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT
|
||||
COUNT(id) AS total_pages
|
||||
FROM
|
||||
ttrss_error_log
|
||||
WHERE
|
||||
$errno_filter_qpart");
|
||||
|
||||
$sth->execute($errno_values);
|
||||
|
||||
if ($res = $sth->fetch()) {
|
||||
$total_pages = (int)($res["total_pages"] / self::LOG_PAGE_LIMIT);
|
||||
} else {
|
||||
$total_pages = 0;
|
||||
}
|
||||
|
||||
?>
|
||||
<div dojoType='dijit.layout.BorderContainer' gutters='false'>
|
||||
<div region='top' dojoType='fox.Toolbar'>
|
||||
|
||||
<button dojoType='dijit.form.Button' onclick='Helpers.EventLog.refresh()'>
|
||||
<?= __('Refresh') ?>
|
||||
</button>
|
||||
|
||||
<button dojoType='dijit.form.Button' <?= ($page <= 0 ? "disabled" : "") ?>
|
||||
onclick='Helpers.EventLog.prevPage()'>
|
||||
<?= __('<<') ?>
|
||||
</button>
|
||||
|
||||
<button dojoType='dijit.form.Button' disabled>
|
||||
<?= T_sprintf('Page %d of %d', $page+1, $total_pages+1) ?>
|
||||
</button>
|
||||
|
||||
<button dojoType='dijit.form.Button' <?= ($page >= $total_pages ? "disabled" : "") ?>
|
||||
onclick='Helpers.EventLog.nextPage()'>
|
||||
<?= __('>>') ?>
|
||||
</button>
|
||||
|
||||
<button dojoType='dijit.form.Button'
|
||||
onclick='Helpers.EventLog.clear()'>
|
||||
<?= __('Clear') ?>
|
||||
</button>
|
||||
|
||||
<div class='pull-right'>
|
||||
<label><?= __("Severity:") ?></label>
|
||||
|
||||
<?= \Controls\select_hash("severity", $severity,
|
||||
[
|
||||
E_USER_ERROR => __("Errors"),
|
||||
E_USER_WARNING => __("Warnings"),
|
||||
E_USER_NOTICE => __("Everything")
|
||||
], ["onchange"=> "Helpers.EventLog.refresh()"], "severity") ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding : 0px" dojoType="dijit.layout.ContentPane" region="center">
|
||||
|
||||
<table width='100%' class='event-log'>
|
||||
|
||||
<tr>
|
||||
<th width='5%'><?= __("Error") ?></th>
|
||||
<th><?= __("Filename") ?></th>
|
||||
<th><?= __("Message") ?></th>
|
||||
<th width='5%'><?= __("User") ?></th>
|
||||
<th width='5%'><?= __("Date") ?></th>
|
||||
</tr>
|
||||
|
||||
<?php
|
||||
$sth = $this->pdo->prepare("SELECT
|
||||
errno, errstr, filename, lineno, created_at, login, context
|
||||
FROM
|
||||
ttrss_error_log LEFT JOIN ttrss_users ON (owner_uid = ttrss_users.id)
|
||||
WHERE
|
||||
$errno_filter_qpart
|
||||
ORDER BY
|
||||
ttrss_error_log.id DESC
|
||||
LIMIT ". self::LOG_PAGE_LIMIT ." OFFSET $offset");
|
||||
|
||||
$sth->execute($errno_values);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
foreach ($line as $k => $v) { $line[$k] = htmlspecialchars($v ?? ''); }
|
||||
?>
|
||||
<tr>
|
||||
<td class='errno'>
|
||||
<?= Logger::ERROR_NAMES[$line["errno"]] . " (" . $line["errno"] . ")" ?>
|
||||
</td>
|
||||
<td class='filename'><?= $line["filename"] . ":" . $line["lineno"] ?></td>
|
||||
<td class='errstr'><?= $line["errstr"] . "\n" . $line["context"] ?></td>
|
||||
<td class='login'><?= $line["login"] ?></td>
|
||||
<td class='timestamp'>
|
||||
<?= TimeHelper::make_local_datetime($line['created_at']) ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
function index(): void {
|
||||
|
||||
$severity = (int) ($_REQUEST["severity"] ?? E_USER_WARNING);
|
||||
$page = (int) ($_REQUEST["page"] ?? 0);
|
||||
?>
|
||||
<div dojoType='dijit.layout.AccordionContainer' region='center'>
|
||||
<?php if (Config::get(Config::LOG_DESTINATION) == Logger::LOG_DEST_SQL) { ?>
|
||||
<div dojoType='dijit.layout.AccordionPane' style='padding : 0' title='<i class="material-icons">report</i> <?= __('Event log') ?>'>
|
||||
<?php
|
||||
$this->_log_viewer($page, $severity);
|
||||
?>
|
||||
</div>
|
||||
<?php } ?>
|
||||
<div dojoType='dijit.layout.AccordionPane' style='padding : 0' title='<i class="material-icons">mail</i> <?= __('Mail configuration') ?>'>
|
||||
<div dojoType="dijit.layout.ContentPane">
|
||||
|
||||
<form dojoType="dijit.form.Form">
|
||||
<script type="dojo/method" event="onSubmit" args="evt">
|
||||
evt.preventDefault();
|
||||
if (this.validate()) {
|
||||
xhr.json("backend.php", this.getValues(), (reply) => {
|
||||
const msg = App.byId("mail-test-result");
|
||||
|
||||
if (reply.rc) {
|
||||
msg.innerHTML = __("Mail sent.");
|
||||
msg.className = 'alert alert-success';
|
||||
} else {
|
||||
msg.innerHTML = reply.error;
|
||||
msg.className = 'alert alert-danger';
|
||||
}
|
||||
|
||||
msg.show();
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<?= \Controls\hidden_tag("op", "Pref_System") ?>
|
||||
<?= \Controls\hidden_tag("method", "sendTestEmail") ?>
|
||||
|
||||
<?php
|
||||
$user = ORM::for_table('ttrss_users')->find_one($_SESSION["uid"]);
|
||||
?>
|
||||
|
||||
<fieldset>
|
||||
<label><?= __("To:") ?></label>
|
||||
<?= \Controls\input_tag("mail_address",$user->email, "text", ['required' => 1]) ?>
|
||||
<?= \Controls\submit_tag(__("Send test email")) ?>
|
||||
<span style="display: none; margin-left : 10px" class="alert alert-error" id="mail-test-result">...</span>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div dojoType='dijit.layout.AccordionPane' title='<i class="material-icons">alarm</i> <?= __('Scheduled tasks') ?>'>
|
||||
<script type='dojo/method' event='onSelected' args='evt'>
|
||||
if (this.domNode.querySelector('.loading'))
|
||||
window.setTimeout(() => {
|
||||
xhr.post("backend.php", {op: 'Pref_System', method: 'getscheduledtasks'}, (reply) => {
|
||||
this.attr('content', `<div class='phpinfo'>${reply}</div>`);
|
||||
});
|
||||
}, 200);
|
||||
</script>
|
||||
<span class='loading'><?= __("Loading, please wait...") ?></span>
|
||||
</div>
|
||||
<div dojoType='dijit.layout.AccordionPane' title='<i class="material-icons">info</i> <?= __('PHP Information') ?>'>
|
||||
<script type='dojo/method' event='onSelected' args='evt'>
|
||||
if (this.domNode.querySelector('.loading'))
|
||||
window.setTimeout(() => {
|
||||
xhr.post("backend.php", {op: 'Pref_System', method: 'getphpinfo'}, (reply) => {
|
||||
this.attr('content', `<div class='phpinfo'>${reply}</div>`);
|
||||
});
|
||||
}, 200);
|
||||
</script>
|
||||
<span class='loading'><?= __("Loading, please wait...") ?></span>
|
||||
</div>
|
||||
|
||||
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefSystem") ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
<?php
|
||||
class Pref_Users extends Handler_Administrative {
|
||||
function csrf_ignore(string $method): bool {
|
||||
return $method == "index";
|
||||
}
|
||||
|
||||
function edit(): void {
|
||||
$user = ORM::for_table('ttrss_users')
|
||||
->select_many('id', 'login', 'access_level', 'email', 'full_name', 'otp_enabled')
|
||||
->find_one((int)$_REQUEST["id"])
|
||||
->as_array();
|
||||
|
||||
global $access_level_names;
|
||||
|
||||
if ($user) {
|
||||
print json_encode([
|
||||
"user" => $user,
|
||||
"access_level_names" => $access_level_names
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function userdetails(): void {
|
||||
$id = (int) clean($_REQUEST["id"]);
|
||||
|
||||
$user = ORM::for_table('ttrss_users')
|
||||
->table_alias('u')
|
||||
->select_many('u.login', 'u.access_level')
|
||||
->select_many_expr([
|
||||
'created' => 'SUBSTRING_FOR_DATE(u.created,1,16)',
|
||||
'last_login' => 'SUBSTRING_FOR_DATE(u.last_login,1,16)',
|
||||
'stored_articles' => '(SELECT COUNT(ue.int_id) FROM ttrss_user_entries ue WHERE ue.owner_uid = u.id)',
|
||||
])
|
||||
->find_one($id);
|
||||
|
||||
if ($user) {
|
||||
$created = TimeHelper::make_local_datetime($user->created);
|
||||
$last_login = TimeHelper::make_local_datetime($user->last_login);
|
||||
|
||||
$user_owned_feeds = ORM::for_table('ttrss_feeds')
|
||||
->select_many('id', 'title', 'site_url')
|
||||
->where('owner_uid', $id)
|
||||
->order_by_expr('LOWER(title)')
|
||||
->find_many();
|
||||
|
||||
?>
|
||||
|
||||
<fieldset>
|
||||
<label><?= __('Registered') ?>:</label>
|
||||
<?= $created ?>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<label><?= __('Last logged in') ?>:</label>
|
||||
<?= $last_login ?>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<label><?= __('Subscribed feeds') ?>:</label>
|
||||
<?= count($user_owned_feeds) ?>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<label><?= __('Stored articles') ?>:</label>
|
||||
<?= $user->stored_articles ?>
|
||||
</fieldset>
|
||||
|
||||
<ul class="panel panel-scrollable list list-unstyled">
|
||||
<?php foreach ($user_owned_feeds as $feed) { ?>
|
||||
<li>
|
||||
<?php
|
||||
$icon_url = Feeds::_get_icon_url($feed->id, 'images/blank_icon.gif');
|
||||
?>
|
||||
|
||||
<img class="icon" src="<?= htmlspecialchars($icon_url) ?>">
|
||||
|
||||
<a target="_blank" href="<?= htmlspecialchars($feed->site_url) ?>">
|
||||
<?= htmlspecialchars($feed->title) ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php } ?>
|
||||
</ul>
|
||||
|
||||
<?php
|
||||
|
||||
} else {
|
||||
print_error(__('User not found'));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function editSave(): void {
|
||||
$id = (int)$_REQUEST['id'];
|
||||
$password = clean($_REQUEST["password"]);
|
||||
$user = ORM::for_table('ttrss_users')->find_one($id);
|
||||
|
||||
if ($user) {
|
||||
$login = clean($_REQUEST["login"]);
|
||||
|
||||
if ($id == 1) $login = "admin";
|
||||
if (!$login) return;
|
||||
|
||||
$user->login = mb_strtolower($login);
|
||||
$user->access_level = (int) clean($_REQUEST["access_level"]);
|
||||
$user->email = clean($_REQUEST["email"]);
|
||||
$user->otp_enabled = checkbox_to_sql_bool($_REQUEST["otp_enabled"] ?? "");
|
||||
|
||||
// force new OTP secret when next enabled
|
||||
if (Config::get_schema_version() >= 143 && !$user->otp_enabled) {
|
||||
$user->otp_secret = null;
|
||||
}
|
||||
|
||||
$user->save();
|
||||
}
|
||||
|
||||
if ($password) {
|
||||
UserHelper::reset_password($id, false, $password);
|
||||
}
|
||||
}
|
||||
|
||||
function remove(): void {
|
||||
$ids = explode(",", clean($_REQUEST["ids"]));
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if ($id != $_SESSION["uid"] && $id != 1) {
|
||||
ORM::for_table('ttrss_tags')->where('owner_uid', $id)->delete_many();
|
||||
ORM::for_table('ttrss_feeds')->where('owner_uid', $id)->delete_many();
|
||||
ORM::for_table('ttrss_users')->where('id', $id)->delete_many();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function add(): void {
|
||||
$login = clean($_REQUEST["login"]);
|
||||
|
||||
if (!$login) return; // no blank usernames
|
||||
|
||||
if (!UserHelper::find_user_by_login($login)) {
|
||||
|
||||
$new_password = make_password();
|
||||
|
||||
$user = ORM::for_table('ttrss_users')->create();
|
||||
|
||||
$user->salt = UserHelper::get_salt();
|
||||
$user->login = mb_strtolower($login);
|
||||
$user->pwd_hash = UserHelper::hash_password($new_password, $user->salt);
|
||||
$user->access_level = 0;
|
||||
$user->created = Db::NOW();
|
||||
$user->save();
|
||||
|
||||
if (!is_null(UserHelper::find_user_by_login($login))) {
|
||||
print T_sprintf("Added user %s with password %s",
|
||||
$login, $new_password);
|
||||
} else {
|
||||
print T_sprintf("Could not create user %s", $login);
|
||||
}
|
||||
} else {
|
||||
print T_sprintf("User %s already exists.", $login);
|
||||
}
|
||||
}
|
||||
|
||||
function resetPass(): void {
|
||||
UserHelper::reset_password(clean($_REQUEST["id"]));
|
||||
}
|
||||
|
||||
function index(): void {
|
||||
|
||||
global $access_level_names;
|
||||
|
||||
$user_search = clean($_REQUEST["search"] ?? "");
|
||||
|
||||
if (array_key_exists("search", $_REQUEST)) {
|
||||
$_SESSION["prefs_user_search"] = $user_search;
|
||||
} else {
|
||||
$user_search = ($_SESSION["prefs_user_search"] ?? "");
|
||||
}
|
||||
|
||||
$sort = clean($_REQUEST["sort"] ?? "");
|
||||
|
||||
if (!$sort || $sort == "undefined") {
|
||||
$sort = "login";
|
||||
}
|
||||
|
||||
if (!in_array($sort, ["login", "access_level", "created", "num_feeds", "created", "last_login"]))
|
||||
$sort = "login";
|
||||
|
||||
if ($sort != "login") $sort = "$sort DESC";
|
||||
?>
|
||||
|
||||
<div dojoType='dijit.layout.BorderContainer' gutters='false'>
|
||||
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='top'>
|
||||
<div dojoType='fox.Toolbar'>
|
||||
|
||||
<div style='float : right'>
|
||||
<form dojoType="dijit.form.Form" onsubmit="Users.reload(); return false;">
|
||||
<input dojoType='dijit.form.TextBox' id='user_search' size='20' type='search'
|
||||
value="<?= htmlspecialchars($user_search) ?>">
|
||||
<button dojoType='dijit.form.Button' type='submit'>
|
||||
<?= __('Search') ?>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div dojoType='fox.form.DropDownButton'>
|
||||
<span><?= __('Select') ?></span>
|
||||
<div dojoType='dijit.Menu' style='display: none'>
|
||||
<div onclick="Tables.select('users-list', true)"
|
||||
dojoType='dijit.MenuItem'><?= __('All') ?></div>
|
||||
<div onclick="Tables.select('users-list', false)"
|
||||
dojoType='dijit.MenuItem'><?= __('None') ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button dojoType='dijit.form.Button' onclick='Users.add()'>
|
||||
<?= __('Create user') ?>
|
||||
</button>
|
||||
|
||||
<button dojoType='dijit.form.Button' onclick='Users.removeSelected()'>
|
||||
<?= __('Remove') ?>
|
||||
</button>
|
||||
|
||||
<button dojoType='dijit.form.Button' onclick='Users.resetSelected()'>
|
||||
<?= __('Reset password') ?>
|
||||
</button>
|
||||
|
||||
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB_SECTION, "prefUsersToolbar") ?>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='center'>
|
||||
|
||||
<table width='100%' class='users-list' id='users-list'>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th><a href='#' onclick="Users.reload('login')"><?= __('Login') ?></a></th>
|
||||
<th><a href='#' onclick="Users.reload('access_level')"><?= __('Access level') ?></a></th>
|
||||
<th><a href='#' onclick="Users.reload('num_feeds')"><?= __('Subscribed feeds') ?></a></th>
|
||||
<th><a href='#' onclick="Users.reload('created')"><?= __('Registered') ?></a></th>
|
||||
<th><a href='#' onclick="Users.reload('last_login')"><?= __('Last login') ?></a></th>
|
||||
</tr>
|
||||
|
||||
<?php
|
||||
$users = ORM::for_table('ttrss_users')
|
||||
->table_alias('u')
|
||||
->left_outer_join("ttrss_feeds", ["owner_uid", "=", "u.id"], 'f')
|
||||
->select_expr('u.*,COUNT(f.id) AS num_feeds')
|
||||
->where_like("login", $user_search ? "%$user_search%" : "%")
|
||||
->order_by_expr($sort)
|
||||
->group_by_expr('u.id')
|
||||
->find_many();
|
||||
|
||||
foreach ($users as $user) { ?>
|
||||
|
||||
<tr data-row-id='<?= $user["id"] ?>' onclick='Users.edit(<?= $user["id"] ?>)' title="<?= __('Click to edit') ?>">
|
||||
<td class='checkbox'>
|
||||
<input onclick='Tables.onRowChecked(this); event.stopPropagation();'
|
||||
dojoType='dijit.form.CheckBox' type='checkbox'>
|
||||
</td>
|
||||
|
||||
<td width='30%'>
|
||||
<i class='material-icons'>person</i>
|
||||
<strong><?= htmlspecialchars($user["login"]) ?></strong>
|
||||
</td>
|
||||
<td><?= $access_level_names[$user["access_level"]] ?></td>
|
||||
<td><?= $user["num_feeds"] ?></td>
|
||||
<td class='text-muted'><?= TimeHelper::make_local_datetime($user['created']) ?></td>
|
||||
<td class='text-muted'><?= TimeHelper::make_local_datetime($user['last_login']) ?></td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
</table>
|
||||
</div>
|
||||
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefUsers") ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,405 +0,0 @@
|
||||
<?php
|
||||
class Prefs {
|
||||
// (this is the database-backed version of Config.php)
|
||||
|
||||
const PURGE_OLD_DAYS = "PURGE_OLD_DAYS";
|
||||
const DEFAULT_UPDATE_INTERVAL = "DEFAULT_UPDATE_INTERVAL";
|
||||
//const DEFAULT_ARTICLE_LIMIT = "DEFAULT_ARTICLE_LIMIT";
|
||||
//const ALLOW_DUPLICATE_POSTS = "ALLOW_DUPLICATE_POSTS";
|
||||
const ENABLE_FEED_CATS = "ENABLE_FEED_CATS";
|
||||
const SHOW_CONTENT_PREVIEW = "SHOW_CONTENT_PREVIEW";
|
||||
const SHORT_DATE_FORMAT = "SHORT_DATE_FORMAT";
|
||||
const LONG_DATE_FORMAT = "LONG_DATE_FORMAT";
|
||||
const COMBINED_DISPLAY_MODE = "COMBINED_DISPLAY_MODE";
|
||||
const HIDE_READ_FEEDS = "HIDE_READ_FEEDS";
|
||||
const ON_CATCHUP_SHOW_NEXT_FEED = "ON_CATCHUP_SHOW_NEXT_FEED";
|
||||
const FEEDS_SORT_BY_UNREAD = "FEEDS_SORT_BY_UNREAD";
|
||||
const REVERSE_HEADLINES = "REVERSE_HEADLINES";
|
||||
const DIGEST_ENABLE = "DIGEST_ENABLE";
|
||||
const CONFIRM_FEED_CATCHUP = "CONFIRM_FEED_CATCHUP";
|
||||
const CDM_AUTO_CATCHUP = "CDM_AUTO_CATCHUP";
|
||||
const _DEFAULT_VIEW_MODE = "_DEFAULT_VIEW_MODE";
|
||||
const _DEFAULT_VIEW_LIMIT = "_DEFAULT_VIEW_LIMIT";
|
||||
//const _PREFS_ACTIVE_TAB = "_PREFS_ACTIVE_TAB";
|
||||
//const STRIP_UNSAFE_TAGS = "STRIP_UNSAFE_TAGS";
|
||||
const BLACKLISTED_TAGS = "BLACKLISTED_TAGS";
|
||||
const FRESH_ARTICLE_MAX_AGE = "FRESH_ARTICLE_MAX_AGE";
|
||||
const DIGEST_CATCHUP = "DIGEST_CATCHUP";
|
||||
const CDM_EXPANDED = "CDM_EXPANDED";
|
||||
const PURGE_UNREAD_ARTICLES = "PURGE_UNREAD_ARTICLES";
|
||||
const HIDE_READ_SHOWS_SPECIAL = "HIDE_READ_SHOWS_SPECIAL";
|
||||
const VFEED_GROUP_BY_FEED = "VFEED_GROUP_BY_FEED";
|
||||
const STRIP_IMAGES = "STRIP_IMAGES";
|
||||
const _DEFAULT_VIEW_ORDER_BY = "_DEFAULT_VIEW_ORDER_BY";
|
||||
const ENABLE_API_ACCESS = "ENABLE_API_ACCESS";
|
||||
//const _COLLAPSED_SPECIAL = "_COLLAPSED_SPECIAL";
|
||||
//const _COLLAPSED_LABELS = "_COLLAPSED_LABELS";
|
||||
//const _COLLAPSED_UNCAT = "_COLLAPSED_UNCAT";
|
||||
//const _COLLAPSED_FEEDLIST = "_COLLAPSED_FEEDLIST";
|
||||
//const _MOBILE_ENABLE_CATS = "_MOBILE_ENABLE_CATS";
|
||||
//const _MOBILE_SHOW_IMAGES = "_MOBILE_SHOW_IMAGES";
|
||||
//const _MOBILE_HIDE_READ = "_MOBILE_HIDE_READ";
|
||||
//const _MOBILE_SORT_FEEDS_UNREAD = "_MOBILE_SORT_FEEDS_UNREAD";
|
||||
//const _MOBILE_BROWSE_CATS = "_MOBILE_BROWSE_CATS";
|
||||
//const _THEME_ID = "_THEME_ID";
|
||||
const USER_TIMEZONE = "USER_TIMEZONE";
|
||||
const USER_STYLESHEET = "USER_STYLESHEET";
|
||||
//const SORT_HEADLINES_BY_FEED_DATE = "SORT_HEADLINES_BY_FEED_DATE";
|
||||
const SSL_CERT_SERIAL = "SSL_CERT_SERIAL";
|
||||
const DIGEST_PREFERRED_TIME = "DIGEST_PREFERRED_TIME";
|
||||
//const _PREFS_SHOW_EMPTY_CATS = "_PREFS_SHOW_EMPTY_CATS";
|
||||
const _DEFAULT_INCLUDE_CHILDREN = "_DEFAULT_INCLUDE_CHILDREN";
|
||||
//const AUTO_ASSIGN_LABELS = "AUTO_ASSIGN_LABELS";
|
||||
const _ENABLED_PLUGINS = "_ENABLED_PLUGINS";
|
||||
//const _MOBILE_REVERSE_HEADLINES = "_MOBILE_REVERSE_HEADLINES";
|
||||
const USER_CSS_THEME = "USER_CSS_THEME";
|
||||
const USER_LANGUAGE = "USER_LANGUAGE";
|
||||
const DEFAULT_SEARCH_LANGUAGE = "DEFAULT_SEARCH_LANGUAGE";
|
||||
const _PREFS_MIGRATED = "_PREFS_MIGRATED";
|
||||
const HEADLINES_NO_DISTINCT = "HEADLINES_NO_DISTINCT";
|
||||
const DEBUG_HEADLINE_IDS = "DEBUG_HEADLINE_IDS";
|
||||
const DISABLE_CONDITIONAL_COUNTERS = "DISABLE_CONDITIONAL_COUNTERS";
|
||||
const WIDESCREEN_MODE = "WIDESCREEN_MODE";
|
||||
const CDM_ENABLE_GRID = "CDM_ENABLE_GRID";
|
||||
const DIGEST_MIN_SCORE = "DIGEST_MIN_SCORE";
|
||||
|
||||
private const _DEFAULTS = [
|
||||
Prefs::PURGE_OLD_DAYS => [ 60, Config::T_INT ],
|
||||
Prefs::DEFAULT_UPDATE_INTERVAL => [ 30, Config::T_INT ],
|
||||
//Prefs::DEFAULT_ARTICLE_LIMIT => [ 30, Config::T_INT ],
|
||||
//Prefs::ALLOW_DUPLICATE_POSTS => [ false, Config::T_BOOL ],
|
||||
Prefs::ENABLE_FEED_CATS => [ true, Config::T_BOOL ],
|
||||
Prefs::SHOW_CONTENT_PREVIEW => [ true, Config::T_BOOL ],
|
||||
Prefs::SHORT_DATE_FORMAT => [ "M d, G:i", Config::T_STRING ],
|
||||
Prefs::LONG_DATE_FORMAT => [ "D, M d Y - G:i", Config::T_STRING ],
|
||||
Prefs::COMBINED_DISPLAY_MODE => [ true, Config::T_BOOL ],
|
||||
Prefs::HIDE_READ_FEEDS => [ false, Config::T_BOOL ],
|
||||
Prefs::ON_CATCHUP_SHOW_NEXT_FEED => [ false, Config::T_BOOL ],
|
||||
Prefs::FEEDS_SORT_BY_UNREAD => [ false, Config::T_BOOL ],
|
||||
Prefs::REVERSE_HEADLINES => [ false, Config::T_BOOL ],
|
||||
Prefs::DIGEST_ENABLE => [ false, Config::T_BOOL ],
|
||||
Prefs::CONFIRM_FEED_CATCHUP => [ true, Config::T_BOOL ],
|
||||
Prefs::CDM_AUTO_CATCHUP => [ false, Config::T_BOOL ],
|
||||
Prefs::_DEFAULT_VIEW_MODE => [ "adaptive", Config::T_STRING ],
|
||||
Prefs::_DEFAULT_VIEW_LIMIT => [ 30, Config::T_INT ],
|
||||
//Prefs::_PREFS_ACTIVE_TAB => [ "", Config::T_STRING ],
|
||||
//Prefs::STRIP_UNSAFE_TAGS => [ true, Config::T_BOOL ],
|
||||
Prefs::BLACKLISTED_TAGS => [ 'main, generic, misc, uncategorized, blog, blogroll, general, news', Config::T_STRING ],
|
||||
Prefs::FRESH_ARTICLE_MAX_AGE => [ 24, Config::T_INT ],
|
||||
Prefs::DIGEST_CATCHUP => [ false, Config::T_BOOL ],
|
||||
Prefs::CDM_EXPANDED => [ true, Config::T_BOOL ],
|
||||
Prefs::PURGE_UNREAD_ARTICLES => [ true, Config::T_BOOL ],
|
||||
Prefs::HIDE_READ_SHOWS_SPECIAL => [ true, Config::T_BOOL ],
|
||||
Prefs::VFEED_GROUP_BY_FEED => [ false, Config::T_BOOL ],
|
||||
Prefs::STRIP_IMAGES => [ false, Config::T_BOOL ],
|
||||
Prefs::_DEFAULT_VIEW_ORDER_BY => [ "default", Config::T_STRING ],
|
||||
Prefs::ENABLE_API_ACCESS => [ false, Config::T_BOOL ],
|
||||
//Prefs::_COLLAPSED_SPECIAL => [ false, Config::T_BOOL ],
|
||||
//Prefs::_COLLAPSED_LABELS => [ false, Config::T_BOOL ],
|
||||
//Prefs::_COLLAPSED_UNCAT => [ false, Config::T_BOOL ],
|
||||
//Prefs::_COLLAPSED_FEEDLIST => [ false, Config::T_BOOL ],
|
||||
//Prefs::_MOBILE_ENABLE_CATS => [ false, Config::T_BOOL ],
|
||||
//Prefs::_MOBILE_SHOW_IMAGES => [ false, Config::T_BOOL ],
|
||||
//Prefs::_MOBILE_HIDE_READ => [ false, Config::T_BOOL ],
|
||||
//Prefs::_MOBILE_SORT_FEEDS_UNREAD => [ false, Config::T_BOOL ],
|
||||
//Prefs::_MOBILE_BROWSE_CATS => [ true, Config::T_BOOL ],
|
||||
//Prefs::_THEME_ID => [ 0, Config::T_BOOL ],
|
||||
Prefs::USER_TIMEZONE => [ "Automatic", Config::T_STRING ],
|
||||
Prefs::USER_STYLESHEET => [ "", Config::T_STRING ],
|
||||
//Prefs::SORT_HEADLINES_BY_FEED_DATE => [ false, Config::T_BOOL ],
|
||||
Prefs::SSL_CERT_SERIAL => [ "", Config::T_STRING ],
|
||||
Prefs::DIGEST_PREFERRED_TIME => [ "00:00", Config::T_STRING ],
|
||||
//Prefs::_PREFS_SHOW_EMPTY_CATS => [ false, Config::T_BOOL ],
|
||||
Prefs::_DEFAULT_INCLUDE_CHILDREN => [ false, Config::T_BOOL ],
|
||||
//Prefs::AUTO_ASSIGN_LABELS => [ false, Config::T_BOOL ],
|
||||
Prefs::_ENABLED_PLUGINS => [ "", Config::T_STRING ],
|
||||
//Prefs::_MOBILE_REVERSE_HEADLINES => [ false, Config::T_BOOL ],
|
||||
Prefs::USER_CSS_THEME => [ "" , Config::T_STRING ],
|
||||
Prefs::USER_LANGUAGE => [ "" , Config::T_STRING ],
|
||||
Prefs::DEFAULT_SEARCH_LANGUAGE => [ "" , Config::T_STRING ],
|
||||
Prefs::_PREFS_MIGRATED => [ false, Config::T_BOOL ],
|
||||
Prefs::HEADLINES_NO_DISTINCT => [ false, Config::T_BOOL ],
|
||||
Prefs::DEBUG_HEADLINE_IDS => [ false, Config::T_BOOL ],
|
||||
Prefs::DISABLE_CONDITIONAL_COUNTERS => [ false, Config::T_BOOL ],
|
||||
Prefs::WIDESCREEN_MODE => [ false, Config::T_BOOL ],
|
||||
Prefs::CDM_ENABLE_GRID => [ false, Config::T_BOOL ],
|
||||
Prefs::DIGEST_MIN_SCORE => [ 0, Config::T_INT ],
|
||||
];
|
||||
|
||||
const _PROFILE_BLACKLIST = [
|
||||
//Prefs::ALLOW_DUPLICATE_POSTS,
|
||||
Prefs::PURGE_OLD_DAYS,
|
||||
Prefs::PURGE_UNREAD_ARTICLES,
|
||||
Prefs::DIGEST_ENABLE,
|
||||
Prefs::DIGEST_CATCHUP,
|
||||
Prefs::BLACKLISTED_TAGS,
|
||||
Prefs::ENABLE_API_ACCESS,
|
||||
//Prefs::UPDATE_POST_ON_CHECKSUM_CHANGE,
|
||||
Prefs::DEFAULT_UPDATE_INTERVAL,
|
||||
Prefs::USER_TIMEZONE,
|
||||
//Prefs::SORT_HEADLINES_BY_FEED_DATE,
|
||||
Prefs::SSL_CERT_SERIAL,
|
||||
Prefs::DIGEST_PREFERRED_TIME,
|
||||
Prefs::DIGEST_MIN_SCORE,
|
||||
Prefs::_PREFS_MIGRATED
|
||||
];
|
||||
|
||||
private static ?Prefs $instance = null;
|
||||
|
||||
/** @var array<string, bool|int|string> */
|
||||
private array $cache = [];
|
||||
|
||||
private ?PDO $pdo = null;
|
||||
|
||||
public static function get_instance() : Prefs {
|
||||
if (self::$instance == null)
|
||||
self::$instance = new self();
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
static function is_valid(string $pref_name): bool {
|
||||
return isset(self::_DEFAULTS[$pref_name]);
|
||||
}
|
||||
|
||||
static function get_default(string $pref_name): bool|int|null|string {
|
||||
if (self::is_valid($pref_name))
|
||||
return self::_DEFAULTS[$pref_name][0];
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
function __construct() {
|
||||
$this->pdo = Db::pdo();
|
||||
|
||||
if (!empty($_SESSION["uid"])) {
|
||||
$owner_uid = (int) $_SESSION["uid"];
|
||||
$profile_id = $_SESSION["profile"] ?? null;
|
||||
|
||||
$this->cache_all($owner_uid, $profile_id);
|
||||
$this->migrate($owner_uid, $profile_id);
|
||||
};
|
||||
}
|
||||
|
||||
private function __clone() {
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, bool|int|null|string>>
|
||||
*/
|
||||
static function get_all(int $owner_uid, ?int $profile_id = null): array {
|
||||
return self::get_instance()->_get_all($owner_uid, $profile_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, bool|int|null|string>>
|
||||
*/
|
||||
private function _get_all(int $owner_uid, ?int $profile_id = null): array {
|
||||
$rv = [];
|
||||
|
||||
$ref = new ReflectionClass(get_class($this));
|
||||
|
||||
foreach ($ref->getConstants() as $const => $cvalue) {
|
||||
if (isset($this::_DEFAULTS[$const])) {
|
||||
list ($def_val, $type_hint) = $this::_DEFAULTS[$const];
|
||||
|
||||
array_push($rv, [
|
||||
"pref_name" => $const,
|
||||
"value" => $this->_get($const, $owner_uid, $profile_id),
|
||||
"type_hint" => $type_hint,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
private function cache_all(int $owner_uid, ?int $profile_id): void {
|
||||
if (!$profile_id) $profile_id = null;
|
||||
|
||||
// fill cache with defaults
|
||||
$ref = new ReflectionClass(get_class($this));
|
||||
foreach ($ref->getConstants() as $const => $cvalue) {
|
||||
if (isset($this::_DEFAULTS[$const])) {
|
||||
list ($def_val, $type_hint) = $this::_DEFAULTS[$const];
|
||||
|
||||
$this->_set_cache($const, $def_val, $owner_uid, $profile_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (Config::get_schema_version() >= 141) {
|
||||
// fill in any overrides from the database
|
||||
$sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs2
|
||||
WHERE owner_uid = :uid AND
|
||||
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
|
||||
|
||||
$sth->execute(["uid" => $owner_uid, "profile" => $profile_id]);
|
||||
|
||||
while ($row = $sth->fetch()) {
|
||||
$this->_set_cache($row["pref_name"], $row["value"], $owner_uid, $profile_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static function get(string $pref_name, int $owner_uid, ?int $profile_id = null): bool|int|null|string {
|
||||
return self::get_instance()->_get($pref_name, $owner_uid, $profile_id);
|
||||
}
|
||||
|
||||
private function _get(string $pref_name, int $owner_uid, ?int $profile_id): bool|int|null|string {
|
||||
if (isset(self::_DEFAULTS[$pref_name])) {
|
||||
if (!$profile_id || in_array($pref_name, self::_PROFILE_BLACKLIST)) $profile_id = null;
|
||||
|
||||
list ($def_val, $type_hint) = self::_DEFAULTS[$pref_name];
|
||||
|
||||
if ($this->_is_cached($pref_name, $owner_uid, $profile_id)) {
|
||||
$cached_value = $this->_get_cache($pref_name, $owner_uid, $profile_id);
|
||||
return Config::cast_to($cached_value, $type_hint);
|
||||
} else if (Config::get_schema_version() >= 141) {
|
||||
$sth = $this->pdo->prepare("SELECT value FROM ttrss_user_prefs2
|
||||
WHERE pref_name = :name AND owner_uid = :uid AND
|
||||
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
|
||||
|
||||
$sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name ]);
|
||||
|
||||
if ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
|
||||
$this->_set_cache($pref_name, $row["value"], $owner_uid, $profile_id);
|
||||
|
||||
return Config::cast_to($row["value"], $type_hint);
|
||||
} else {
|
||||
$this->_set_cache($pref_name, $def_val, $owner_uid, $profile_id);
|
||||
|
||||
return $def_val;
|
||||
}
|
||||
} else {
|
||||
return Config::cast_to($def_val, $type_hint);
|
||||
|
||||
}
|
||||
} else {
|
||||
user_error("Attempt to get invalid preference key: $pref_name (UID: $owner_uid, profile: $profile_id)", E_USER_WARNING);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function _is_cached(string $pref_name, int $owner_uid, ?int $profile_id): bool {
|
||||
$cache_key = sprintf("%d/%d/%s", $owner_uid, $profile_id, $pref_name);
|
||||
return isset($this->cache[$cache_key]);
|
||||
}
|
||||
|
||||
private function _get_cache(string $pref_name, int $owner_uid, ?int $profile_id): bool|int|null|string {
|
||||
$cache_key = sprintf("%d/%d/%s", $owner_uid, $profile_id, $pref_name);
|
||||
return $this->cache[$cache_key] ?? null;
|
||||
}
|
||||
|
||||
private function _set_cache(string $pref_name, bool|int|string $value, int $owner_uid, ?int $profile_id): void {
|
||||
$cache_key = sprintf("%d/%d/%s", $owner_uid, $profile_id, $pref_name);
|
||||
|
||||
$this->cache[$cache_key] = $value;
|
||||
}
|
||||
|
||||
static function set(string $pref_name, bool|int|string $value, int $owner_uid, ?int $profile_id, bool $strip_tags = true): bool {
|
||||
return self::get_instance()->_set($pref_name, $value, $owner_uid, $profile_id);
|
||||
}
|
||||
|
||||
private function _set(string $pref_name, bool|int|string $value, int $owner_uid, ?int $profile_id, bool $strip_tags = true): bool {
|
||||
if (!$profile_id) $profile_id = null;
|
||||
|
||||
if ($profile_id && in_array($pref_name, self::_PROFILE_BLACKLIST))
|
||||
return false;
|
||||
|
||||
if (isset(self::_DEFAULTS[$pref_name])) {
|
||||
list ($def_val, $type_hint) = self::_DEFAULTS[$pref_name];
|
||||
|
||||
if ($strip_tags)
|
||||
$value = trim(strip_tags($value));
|
||||
|
||||
$value = Config::cast_to($value, $type_hint);
|
||||
|
||||
if ($value == $this->_get($pref_name, $owner_uid, $profile_id))
|
||||
return true; // no need to actually set this to the same value, let's just say we did
|
||||
|
||||
$this->_set_cache($pref_name, $value, $owner_uid, $profile_id);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT COUNT(pref_name) AS count FROM ttrss_user_prefs2
|
||||
WHERE pref_name = :name AND owner_uid = :uid AND
|
||||
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
|
||||
$sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name ]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
if ($row["count"] == 0) {
|
||||
$sth = $this->pdo->prepare("INSERT INTO ttrss_user_prefs2
|
||||
(pref_name, value, owner_uid, profile)
|
||||
VALUES
|
||||
(:name, :value, :uid, :profile)");
|
||||
|
||||
return $sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name, "value" => $value ]);
|
||||
|
||||
} else {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_prefs2
|
||||
SET value = :value
|
||||
WHERE pref_name = :name AND owner_uid = :uid AND
|
||||
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
|
||||
|
||||
return $sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name, "value" => $value ]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
user_error("Attempt to set invalid preference key: $pref_name (UID: $owner_uid, profile: $profile_id)", E_USER_WARNING);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function migrate(int $owner_uid, ?int $profile_id): void {
|
||||
if (Config::get_schema_version() < 141)
|
||||
return;
|
||||
|
||||
if (!$profile_id) $profile_id = null;
|
||||
|
||||
if (!$this->_get(Prefs::_PREFS_MIGRATED, $owner_uid, $profile_id)) {
|
||||
|
||||
$in_nested_tr = false;
|
||||
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
} catch (PDOException $e) {
|
||||
$in_nested_tr = true;
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs
|
||||
WHERE owner_uid = :uid AND
|
||||
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
|
||||
$sth->execute(["uid" => $owner_uid, "profile" => $profile_id]);
|
||||
|
||||
while ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
|
||||
if (isset(self::_DEFAULTS[$row["pref_name"]])) {
|
||||
list ($def_val, $type_hint) = self::_DEFAULTS[$row["pref_name"]];
|
||||
|
||||
$user_val = Config::cast_to($row["value"], $type_hint);
|
||||
|
||||
if ($user_val != $def_val) {
|
||||
$this->_set($row["pref_name"], $user_val, $owner_uid, $profile_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->_set(Prefs::_PREFS_MIGRATED, "1", $owner_uid, $profile_id);
|
||||
|
||||
if (!$in_nested_tr)
|
||||
$this->pdo->commit();
|
||||
|
||||
Logger::log(E_USER_NOTICE, sprintf("Migrated preferences of user %d (profile %d)", $owner_uid, $profile_id));
|
||||
}
|
||||
}
|
||||
|
||||
static function reset(int $owner_uid, ?int $profile_id): void {
|
||||
if (!$profile_id) $profile_id = null;
|
||||
|
||||
$sth = Db::pdo()->prepare("DELETE FROM ttrss_user_prefs2
|
||||
WHERE owner_uid = :uid AND pref_name != :mig_key AND
|
||||
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
|
||||
|
||||
$sth->execute(["uid" => $owner_uid, "mig_key" => self::_PREFS_MIGRATED, "profile" => $profile_id]);
|
||||
}
|
||||
}
|
||||
725
classes/RPC.php
725
classes/RPC.php
@@ -1,725 +0,0 @@
|
||||
<?php
|
||||
class RPC extends Handler_Protected {
|
||||
|
||||
/*function csrf_ignore(string $method): bool {
|
||||
$csrf_ignored = array("completelabels");
|
||||
|
||||
return array_search($method, $csrf_ignored) !== false;
|
||||
}*/
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function _translations_as_array(): array {
|
||||
|
||||
global $text_domains;
|
||||
|
||||
$rv = [];
|
||||
|
||||
foreach (array_keys($text_domains) as $domain) {
|
||||
|
||||
/** @var gettext_reader $l10n */
|
||||
$l10n = _get_reader($domain);
|
||||
|
||||
for ($i = 0; $i < $l10n->total; $i++) {
|
||||
if (isset($l10n->table_originals[$i * 2 + 2]) && $orig = $l10n->get_original_string($i)) {
|
||||
if(str_contains($orig, "\000")) { // Plural forms
|
||||
$key = explode(chr(0), $orig);
|
||||
|
||||
$rv[$key[0]] = _ngettext($key[0], $key[1], 1); // Singular
|
||||
$rv[$key[1]] = _ngettext($key[0], $key[1], 2); // Plural
|
||||
} else {
|
||||
$translation = _dgettext($domain,$orig);
|
||||
$rv[$orig] = $translation;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
|
||||
function togglepref(): void {
|
||||
$key = clean($_REQUEST["key"]);
|
||||
$profile = $_SESSION['profile'] ?? null;
|
||||
Prefs::set($key, !Prefs::get($key, $_SESSION['uid'], $profile), $_SESSION['uid'], $profile);
|
||||
$value = Prefs::get($key, $_SESSION['uid'], $profile);
|
||||
|
||||
print json_encode(array("param" =>$key, "value" => $value));
|
||||
}
|
||||
|
||||
function setpref(): void {
|
||||
// set_pref escapes input, so no need to double escape it here
|
||||
$key = clean($_REQUEST['key']);
|
||||
$value = $_REQUEST['value'];
|
||||
|
||||
Prefs::set($key, $value, $_SESSION['uid'], $_SESSION['profile'] ?? null, $key != 'USER_STYLESHEET');
|
||||
|
||||
print json_encode(array("param" =>$key, "value" => $value));
|
||||
}
|
||||
|
||||
function mark(): void {
|
||||
$mark = clean($_REQUEST["mark"]);
|
||||
$id = clean($_REQUEST["id"]);
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET marked = ?,
|
||||
last_marked = NOW()
|
||||
WHERE ref_id = ? AND owner_uid = ?");
|
||||
|
||||
$sth->execute([$mark, $id, $_SESSION['uid']]);
|
||||
|
||||
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_MARK_TOGGLED, [$id]);
|
||||
|
||||
print json_encode(array("message" => "UPDATE_COUNTERS"));
|
||||
}
|
||||
|
||||
function delete(): void {
|
||||
$ids = explode(",", clean($_REQUEST["ids"]));
|
||||
$ids_qmarks = arr_qmarks($ids);
|
||||
|
||||
$sth = $this->pdo->prepare("DELETE FROM ttrss_user_entries
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
$sth->execute([...$ids, $_SESSION['uid']]);
|
||||
|
||||
print json_encode(array("message" => "UPDATE_COUNTERS"));
|
||||
}
|
||||
|
||||
function publ(): void {
|
||||
$pub = clean($_REQUEST["pub"]);
|
||||
$id = clean($_REQUEST["id"]);
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
published = ?, last_published = NOW()
|
||||
WHERE ref_id = ? AND owner_uid = ?");
|
||||
|
||||
$sth->execute([$pub, $id, $_SESSION['uid']]);
|
||||
|
||||
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, [$id]);
|
||||
|
||||
print json_encode(array("message" => "UPDATE_COUNTERS"));
|
||||
}
|
||||
|
||||
function getRuntimeInfo(): void {
|
||||
$reply = [
|
||||
'runtime-info' => $this->_make_runtime_info()
|
||||
];
|
||||
|
||||
print json_encode($reply);
|
||||
}
|
||||
|
||||
function getAllCounters(): void {
|
||||
@$seq = (int) $_REQUEST['seq'];
|
||||
|
||||
$feed_id_count = (int) ($_REQUEST["feed_id_count"] ?? -1);
|
||||
$label_id_count = (int) ($_REQUEST["label_id_count"] ?? -1);
|
||||
|
||||
// it seems impossible to distinguish empty array [] from a null - both become unset in $_REQUEST
|
||||
// so, count is >= 0 means we had an array, -1 means null
|
||||
// we need null because it means "return all counters"; [] would return nothing
|
||||
if ($feed_id_count == -1)
|
||||
$feed_ids = null;
|
||||
else
|
||||
$feed_ids = array_map("intval", clean($_REQUEST["feed_ids"] ?? []));
|
||||
|
||||
if ($label_id_count == -1)
|
||||
$label_ids = null;
|
||||
else
|
||||
$label_ids = array_map("intval", clean($_REQUEST["label_ids"] ?? []));
|
||||
|
||||
$counters = is_array($feed_ids)
|
||||
&& !Prefs::get(Prefs::DISABLE_CONDITIONAL_COUNTERS, $_SESSION['uid'], $_SESSION['profile'] ?? null) ?
|
||||
Counters::get_conditional($feed_ids, $label_ids) : Counters::get_all();
|
||||
|
||||
$reply = [
|
||||
'counters' => $counters,
|
||||
'seq' => $seq
|
||||
];
|
||||
|
||||
print json_encode($reply);
|
||||
}
|
||||
|
||||
/* GET["cmode"] = 0 - mark as read, 1 - as unread, 2 - toggle */
|
||||
function catchupSelected(): void {
|
||||
$ids = array_map("intval", clean($_REQUEST["ids"] ?? []));
|
||||
$cmode = (int)clean($_REQUEST["cmode"]);
|
||||
|
||||
if (count($ids) > 0)
|
||||
Article::_catchup_by_id($ids, $cmode);
|
||||
|
||||
print json_encode(["message" => "UPDATE_COUNTERS",
|
||||
"labels" => Article::_labels_of($ids),
|
||||
"feeds" => Article::_feeds_of($ids)]);
|
||||
}
|
||||
|
||||
function markSelected(): void {
|
||||
$ids = array_map("intval", clean($_REQUEST["ids"] ?? []));
|
||||
$cmode = (int)clean($_REQUEST["cmode"]);
|
||||
|
||||
if (count($ids) > 0)
|
||||
$this->markArticlesById($ids, $cmode);
|
||||
|
||||
print json_encode(["message" => "UPDATE_COUNTERS",
|
||||
"labels" => Article::_labels_of($ids),
|
||||
"feeds" => Article::_feeds_of($ids)]);
|
||||
}
|
||||
|
||||
function publishSelected(): void {
|
||||
$ids = array_map("intval", clean($_REQUEST["ids"] ?? []));
|
||||
$cmode = (int)clean($_REQUEST["cmode"]);
|
||||
|
||||
if (count($ids) > 0)
|
||||
$this->publishArticlesById($ids, $cmode);
|
||||
|
||||
print json_encode(["message" => "UPDATE_COUNTERS",
|
||||
"labels" => Article::_labels_of($ids),
|
||||
"feeds" => Article::_feeds_of($ids)]);
|
||||
}
|
||||
|
||||
function sanityCheck(): void {
|
||||
$_SESSION["hasSandbox"] = self::_param_to_bool($_REQUEST["hasSandbox"] ?? false);
|
||||
$_SESSION["clientTzOffset"] = clean($_REQUEST["clientTzOffset"]);
|
||||
|
||||
$client_location = $_REQUEST["clientLocation"];
|
||||
|
||||
$error = Errors::E_SUCCESS;
|
||||
$error_params = [];
|
||||
|
||||
$client_scheme = parse_url($client_location, PHP_URL_SCHEME);
|
||||
$server_scheme = parse_url(Config::get_self_url(), PHP_URL_SCHEME);
|
||||
|
||||
if (Config::is_migration_needed()) {
|
||||
$error = Errors::E_SCHEMA_MISMATCH;
|
||||
} else if ($client_scheme != $server_scheme) {
|
||||
$error = Errors::E_URL_SCHEME_MISMATCH;
|
||||
$error_params["client_scheme"] = $client_scheme;
|
||||
$error_params["server_scheme"] = $server_scheme;
|
||||
$error_params["self_url_path"] = Config::get_self_url();
|
||||
}
|
||||
|
||||
if ($error == Errors::E_SUCCESS) {
|
||||
$reply = [];
|
||||
|
||||
$reply['init-params'] = $this->_make_init_params();
|
||||
$reply['runtime-info'] = $this->_make_runtime_info();
|
||||
$reply['translations'] = $this->_translations_as_array();
|
||||
|
||||
print json_encode($reply);
|
||||
} else {
|
||||
print Errors::to_json($error, $error_params);
|
||||
}
|
||||
}
|
||||
|
||||
/*function completeLabels() {
|
||||
$search = clean($_REQUEST["search"]);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT DISTINCT caption FROM
|
||||
ttrss_labels2
|
||||
WHERE owner_uid = ? AND
|
||||
LOWER(caption) LIKE LOWER(?) ORDER BY caption
|
||||
LIMIT 5");
|
||||
$sth->execute([$_SESSION['uid'], "%$search%"]);
|
||||
|
||||
print "<ul>";
|
||||
while ($line = $sth->fetch()) {
|
||||
print "<li>" . $line["caption"] . "</li>";
|
||||
}
|
||||
print "</ul>";
|
||||
}*/
|
||||
|
||||
function catchupFeed(): void {
|
||||
$feed_id = clean($_REQUEST['feed_id']);
|
||||
$is_cat = self::_param_to_bool($_REQUEST['is_cat'] ?? false);
|
||||
$mode = clean($_REQUEST['mode'] ?? '');
|
||||
$search_query = clean($_REQUEST['search_query']);
|
||||
$search_lang = clean($_REQUEST['search_lang']);
|
||||
|
||||
Feeds::_catchup($feed_id, $is_cat, null, $mode, [$search_query, $search_lang]);
|
||||
|
||||
// return counters here synchronously so that frontend can figure out next unread feed properly
|
||||
print json_encode(['counters' => Counters::get_all()]);
|
||||
|
||||
//print json_encode(array("message" => "UPDATE_COUNTERS"));
|
||||
}
|
||||
|
||||
function setWidescreen(): void {
|
||||
$wide = (int) clean($_REQUEST["wide"]);
|
||||
|
||||
Prefs::set(Prefs::WIDESCREEN_MODE, $wide, $_SESSION['uid'], $_SESSION['profile'] ?? null);
|
||||
|
||||
print json_encode(["wide" => $wide]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $ids
|
||||
*/
|
||||
private function markArticlesById(array $ids, int $cmode): void {
|
||||
|
||||
$ids_qmarks = arr_qmarks($ids);
|
||||
|
||||
if ($cmode == Article::CATCHUP_MODE_MARK_AS_READ) {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
marked = false, last_marked = NOW()
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
} else if ($cmode == Article::CATCHUP_MODE_MARK_AS_UNREAD) {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
marked = true, last_marked = NOW()
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
} else {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
marked = NOT marked,last_marked = NOW()
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
}
|
||||
|
||||
$sth->execute([...$ids, $_SESSION['uid']]);
|
||||
|
||||
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_MARK_TOGGLED, $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $ids
|
||||
*/
|
||||
private function publishArticlesById(array $ids, int $cmode): void {
|
||||
|
||||
$ids_qmarks = arr_qmarks($ids);
|
||||
|
||||
if ($cmode == Article::CATCHUP_MODE_MARK_AS_READ) {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
published = false, last_published = NOW()
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
} else if ($cmode == Article::CATCHUP_MODE_MARK_AS_UNREAD) {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
published = true, last_published = NOW()
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
} else {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
published = NOT published,last_published = NOW()
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
}
|
||||
|
||||
$sth->execute([...$ids, $_SESSION['uid']]);
|
||||
|
||||
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, $ids);
|
||||
}
|
||||
|
||||
function log(): void {
|
||||
$msg = clean($_REQUEST['msg'] ?? "");
|
||||
$file = basename(clean($_REQUEST['file'] ?? ""));
|
||||
$line = (int) clean($_REQUEST['line'] ?? 0);
|
||||
$context = clean($_REQUEST['context'] ?? "");
|
||||
|
||||
if ($msg) {
|
||||
Logger::log_error(E_USER_WARNING,
|
||||
$msg, 'client-js:' . $file, $line, $context);
|
||||
|
||||
echo json_encode(array("message" => "HOST_ERROR_LOGGED"));
|
||||
}
|
||||
}
|
||||
|
||||
function checkforupdates(): void {
|
||||
$rv = ["changeset" => [], "plugins" => []];
|
||||
|
||||
$version = Config::get_version(false);
|
||||
|
||||
$git_timestamp = $version["timestamp"] ?? false;
|
||||
$git_commit = $version["commit"] ?? false;
|
||||
|
||||
// TODO: Get this working again. https://tt-rss.org/version.json won't exist after 2025-11-01 (probably).
|
||||
if (Config::get(Config::CHECK_FOR_UPDATES) && $_SESSION["access_level"] >= UserHelper::ACCESS_LEVEL_ADMIN && $git_timestamp) {
|
||||
// $content = @UrlHelper::fetch(["url" => "https://tt-rss.org/version.json"]);
|
||||
$content = false;
|
||||
|
||||
/** @phpstan-ignore if.alwaysFalse (intentionally disabling for now) */
|
||||
if ($content) {
|
||||
$content = json_decode($content, true);
|
||||
|
||||
if ($content && isset($content["changeset"])) {
|
||||
if ($git_timestamp < (int)$content["changeset"]["timestamp"] &&
|
||||
$git_commit != $content["changeset"]["id"]) {
|
||||
|
||||
$rv["changeset"] = $content["changeset"];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$rv["plugins"] = Pref_Prefs::_get_updated_plugins();
|
||||
}
|
||||
|
||||
print json_encode($rv);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function _make_init_params(): array {
|
||||
$profile = $_SESSION['profile'] ?? null;
|
||||
$params = array();
|
||||
|
||||
foreach ([Prefs::ON_CATCHUP_SHOW_NEXT_FEED, Prefs::HIDE_READ_FEEDS,
|
||||
Prefs::ENABLE_FEED_CATS, Prefs::FEEDS_SORT_BY_UNREAD,
|
||||
Prefs::CONFIRM_FEED_CATCHUP, Prefs::CDM_AUTO_CATCHUP,
|
||||
Prefs::FRESH_ARTICLE_MAX_AGE, Prefs::HIDE_READ_SHOWS_SPECIAL,
|
||||
Prefs::COMBINED_DISPLAY_MODE, Prefs::DEBUG_HEADLINE_IDS, Prefs::CDM_ENABLE_GRID] as $param) {
|
||||
|
||||
$params[strtolower($param)] = (int) Prefs::get($param, $_SESSION['uid'], $profile);
|
||||
}
|
||||
|
||||
$params["safe_mode"] = !empty($_SESSION["safe_mode"]);
|
||||
$params["check_for_updates"] = Config::get(Config::CHECK_FOR_UPDATES);
|
||||
$params["icons_url"] = Config::get_self_url() . '/public.php';
|
||||
$params["cookie_lifetime"] = Config::get(Config::SESSION_COOKIE_LIFETIME);
|
||||
$params["default_view_mode"] = Prefs::get(Prefs::_DEFAULT_VIEW_MODE, $_SESSION['uid'], $profile);
|
||||
$params["default_view_limit"] = (int) Prefs::get(Prefs::_DEFAULT_VIEW_LIMIT, $_SESSION['uid'], $profile);
|
||||
$params["default_view_order_by"] = Prefs::get(Prefs::_DEFAULT_VIEW_ORDER_BY, $_SESSION['uid'], $profile);
|
||||
$params["bw_limit"] = (int) ($_SESSION["bw_limit"] ?? false);
|
||||
$params["is_default_pw"] = UserHelper::is_default_password();
|
||||
$params["label_base_index"] = LABEL_BASE_INDEX;
|
||||
|
||||
$theme = Prefs::get(Prefs::USER_CSS_THEME, $_SESSION['uid'], $profile);
|
||||
$params["theme"] = theme_exists($theme) ? $theme : "";
|
||||
|
||||
$params["plugins"] = implode(", ", PluginHost::getInstance()->get_plugin_names());
|
||||
|
||||
$params["php_platform"] = PHP_OS;
|
||||
$params["php_version"] = PHP_VERSION;
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT MAX(id) AS mid, COUNT(*) AS nf FROM
|
||||
ttrss_feeds WHERE owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
$row = $sth->fetch();
|
||||
|
||||
$max_feed_id = $row["mid"];
|
||||
$num_feeds = $row["nf"];
|
||||
|
||||
$params["self_url_prefix"] = Config::get_self_url();
|
||||
$params["max_feed_id"] = (int) $max_feed_id;
|
||||
$params["num_feeds"] = (int) $num_feeds;
|
||||
$params["hotkeys"] = $this->get_hotkeys_map();
|
||||
$params["widescreen"] = (int) Prefs::get(Prefs::WIDESCREEN_MODE, $_SESSION['uid'], $profile);
|
||||
$params["icon_indicator_white"] = $this->image_to_base64("images/indicator_white.gif");
|
||||
$params["icon_oval"] = $this->image_to_base64("images/oval.svg");
|
||||
$params["icon_three_dots"] = $this->image_to_base64("images/three-dots.svg");
|
||||
$params["icon_blank"] = $this->image_to_base64("images/blank_icon.gif");
|
||||
$params["labels"] = Labels::get_all($_SESSION["uid"]);
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
private function image_to_base64(string $filename): string {
|
||||
if (file_exists($filename)) {
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION);
|
||||
|
||||
if ($ext == "svg") $ext = "svg+xml";
|
||||
|
||||
return "data:image/$ext;base64," . base64_encode((string)file_get_contents($filename));
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
static function _make_runtime_info(): array {
|
||||
$data = array();
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT MAX(id) AS mid, COUNT(*) AS nf FROM
|
||||
ttrss_feeds WHERE owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
$row = $sth->fetch();
|
||||
|
||||
$max_feed_id = $row['mid'];
|
||||
$num_feeds = $row['nf'];
|
||||
|
||||
$data["max_feed_id"] = (int) $max_feed_id;
|
||||
$data["num_feeds"] = (int) $num_feeds;
|
||||
$data['cdm_expanded'] = Prefs::get(Prefs::CDM_EXPANDED, $_SESSION['uid'], $_SESSION['profile'] ?? null);
|
||||
$data["labels"] = Labels::get_all($_SESSION["uid"]);
|
||||
|
||||
if (Config::get(Config::LOG_DESTINATION) == 'sql' && $_SESSION['access_level'] >= UserHelper::ACCESS_LEVEL_ADMIN) {
|
||||
|
||||
$sth = $pdo->prepare("SELECT COUNT(id) AS cid
|
||||
FROM ttrss_error_log
|
||||
WHERE
|
||||
errno NOT IN (".E_USER_NOTICE.", ".E_USER_DEPRECATED.") AND
|
||||
created_at > NOW() - INTERVAL '1 hour' AND
|
||||
errstr NOT LIKE '%Returning bool from comparison function is deprecated%' AND
|
||||
errstr NOT LIKE '%imagecreatefromstring(): Data is not in a recognized format%'");
|
||||
$sth->execute();
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$data['recent_log_events'] = $row['cid'];
|
||||
}
|
||||
}
|
||||
|
||||
if (file_exists(Config::get(Config::LOCK_DIRECTORY) . "/update_daemon.lock")) {
|
||||
|
||||
$data['daemon_is_running'] = (int) file_is_locked("update_daemon.lock");
|
||||
|
||||
if (time() - ($_SESSION["daemon_stamp_check"] ?? 0) > 30) {
|
||||
|
||||
$stamp = (int) @file_get_contents(Config::get(Config::LOCK_DIRECTORY) . "/update_daemon.stamp");
|
||||
|
||||
if ($stamp) {
|
||||
$stamp_delta = time() - $stamp;
|
||||
|
||||
if ($stamp_delta > 1800) {
|
||||
$stamp_check = 0;
|
||||
} else {
|
||||
$stamp_check = 1;
|
||||
$_SESSION["daemon_stamp_check"] = time();
|
||||
}
|
||||
|
||||
$data['daemon_stamp_ok'] = $stamp_check;
|
||||
|
||||
$stamp_fmt = date("Y.m.d, G:i", $stamp);
|
||||
|
||||
$data['daemon_stamp'] = $stamp_fmt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, string>>
|
||||
*/
|
||||
static function get_hotkeys_info(): array {
|
||||
$hotkeys = array(
|
||||
__("Navigation") => array(
|
||||
"next_feed" => __("Open next feed"),
|
||||
"next_unread_feed" => __("Open next unread feed"),
|
||||
"prev_feed" => __("Open previous feed"),
|
||||
"prev_unread_feed" => __("Open previous unread feed"),
|
||||
"next_article_or_scroll" => __("Open next article (in combined mode, scroll down)"),
|
||||
"prev_article_or_scroll" => __("Open previous article (in combined mode, scroll up)"),
|
||||
"next_headlines_page" => __("Scroll headlines by one page down"),
|
||||
"prev_headlines_page" => __("Scroll headlines by one page up"),
|
||||
"next_article_noscroll" => __("Open next article"),
|
||||
"prev_article_noscroll" => __("Open previous article"),
|
||||
"next_article_noexpand" => __("Move to next article (don't expand)"),
|
||||
"prev_article_noexpand" => __("Move to previous article (don't expand)"),
|
||||
"search_dialog" => __("Show search dialog"),
|
||||
"cancel_search" => __("Cancel active search")),
|
||||
__("Article") => array(
|
||||
"toggle_mark" => __("Toggle starred"),
|
||||
"toggle_publ" => __("Toggle published"),
|
||||
"toggle_unread" => __("Toggle unread"),
|
||||
"edit_tags" => __("Edit tags"),
|
||||
"open_in_new_window" => __("Open in new window"),
|
||||
"catchup_below" => __("Mark below as read"),
|
||||
"catchup_above" => __("Mark above as read"),
|
||||
"article_scroll_down" => __("Scroll down"),
|
||||
"article_scroll_up" => __("Scroll up"),
|
||||
"article_page_down" => __("Scroll down page"),
|
||||
"article_page_up" => __("Scroll up page"),
|
||||
"select_article_cursor" => __("Select article under cursor"),
|
||||
"email_article" => __("Email article"),
|
||||
"close_article" => __("Close/collapse article"),
|
||||
"toggle_expand" => __("Toggle article expansion (combined mode)"),
|
||||
"toggle_widescreen" => __("Toggle widescreen mode"),
|
||||
"toggle_full_text" => __("Toggle full article text via Readability")),
|
||||
__("Article selection") => array(
|
||||
"select_all" => __("Select all articles"),
|
||||
"select_unread" => __("Select unread"),
|
||||
"select_marked" => __("Select starred"),
|
||||
"select_published" => __("Select published"),
|
||||
"select_invert" => __("Invert selection"),
|
||||
"select_none" => __("Deselect everything")),
|
||||
__("Feed") => array(
|
||||
"feed_refresh" => __("Refresh current feed"),
|
||||
"feed_unhide_read" => __("Un/hide read feeds"),
|
||||
"feed_subscribe" => __("Subscribe to feed"),
|
||||
"feed_edit" => __("Edit feed"),
|
||||
"feed_catchup" => __("Mark as read"),
|
||||
"feed_reverse" => __("Reverse headlines"),
|
||||
"feed_toggle_vgroup" => __("Toggle headline grouping"),
|
||||
"feed_toggle_grid" => __("Toggle grid view"),
|
||||
"feed_debug_update" => __("Debug feed update"),
|
||||
"feed_debug_viewfeed" => __("Debug viewfeed()"),
|
||||
"catchup_all" => __("Mark all feeds as read"),
|
||||
"cat_toggle_collapse" => __("Un/collapse current category"),
|
||||
"toggle_cdm_expanded" => __("Toggle auto expand in combined mode"),
|
||||
"toggle_combined_mode" => __("Toggle combined mode")),
|
||||
__("Go to") => array(
|
||||
"goto_all" => __("All articles"),
|
||||
"goto_fresh" => __("Fresh"),
|
||||
"goto_marked" => __("Starred"),
|
||||
"goto_published" => __("Published"),
|
||||
"goto_read" => __("Recently read"),
|
||||
"goto_prefs" => __("Preferences")),
|
||||
__("Other") => array(
|
||||
"create_label" => __("Create label"),
|
||||
"create_filter" => __("Create filter"),
|
||||
"collapse_sidebar" => __("Un/collapse sidebar"),
|
||||
"help_dialog" => __("Show help dialog"))
|
||||
);
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HOTKEY_INFO,
|
||||
function ($result) use (&$hotkeys) {
|
||||
$hotkeys = $result;
|
||||
},
|
||||
$hotkeys);
|
||||
|
||||
return $hotkeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* {3} - 3 panel mode only
|
||||
* {C} - combined mode only
|
||||
*
|
||||
* @return array{0: array<int, string>, 1: array<string, string>} $prefixes, $hotkeys
|
||||
*/
|
||||
static function get_hotkeys_map() {
|
||||
$hotkeys = array(
|
||||
"k" => "next_feed",
|
||||
"K" => "next_unread_feed",
|
||||
"j" => "prev_feed",
|
||||
"J" => "prev_unread_feed",
|
||||
"n" => "next_article_noscroll",
|
||||
"p" => "prev_article_noscroll",
|
||||
"N" => "article_page_down",
|
||||
"P" => "article_page_up",
|
||||
"*(33)|Shift+PgUp" => "article_page_up",
|
||||
"*(34)|Shift+PgDn" => "article_page_down",
|
||||
"{3}(38)|Up" => "prev_article_or_scroll",
|
||||
"{3}(40)|Down" => "next_article_or_scroll",
|
||||
"*(38)|Shift+Up" => "article_scroll_up",
|
||||
"*(40)|Shift+Down" => "article_scroll_down",
|
||||
"^(38)|Ctrl+Up" => "prev_article_noscroll",
|
||||
"^(40)|Ctrl+Down" => "next_article_noscroll",
|
||||
"/" => "search_dialog",
|
||||
"\\" => "cancel_search",
|
||||
"s" => "toggle_mark",
|
||||
"S" => "toggle_publ",
|
||||
"u" => "toggle_unread",
|
||||
"T" => "edit_tags",
|
||||
"o" => "open_in_new_window",
|
||||
"c p" => "catchup_below",
|
||||
"c n" => "catchup_above",
|
||||
"a W" => "toggle_widescreen",
|
||||
"a e" => "toggle_full_text",
|
||||
"e" => "email_article",
|
||||
"a q" => "close_article",
|
||||
"a s" => "article_span_grid",
|
||||
"a a" => "select_all",
|
||||
"a u" => "select_unread",
|
||||
"a U" => "select_marked",
|
||||
"a p" => "select_published",
|
||||
"a i" => "select_invert",
|
||||
"a n" => "select_none",
|
||||
"f r" => "feed_refresh",
|
||||
"f a" => "feed_unhide_read",
|
||||
"f s" => "feed_subscribe",
|
||||
"f e" => "feed_edit",
|
||||
"f q" => "feed_catchup",
|
||||
"f x" => "feed_reverse",
|
||||
"f g" => "feed_toggle_vgroup",
|
||||
"f G" => "feed_toggle_grid",
|
||||
"f D" => "feed_debug_update",
|
||||
"f %" => "feed_debug_viewfeed",
|
||||
"f C" => "toggle_combined_mode",
|
||||
"f c" => "toggle_cdm_expanded",
|
||||
"Q" => "catchup_all",
|
||||
"x" => "cat_toggle_collapse",
|
||||
"g a" => "goto_all",
|
||||
"g f" => "goto_fresh",
|
||||
"g s" => "goto_marked",
|
||||
"g p" => "goto_published",
|
||||
"g r" => "goto_read",
|
||||
"g P" => "goto_prefs",
|
||||
"r" => "select_article_cursor",
|
||||
"c l" => "create_label",
|
||||
"c f" => "create_filter",
|
||||
"c s" => "collapse_sidebar",
|
||||
"?" => "help_dialog",
|
||||
);
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HOTKEY_MAP,
|
||||
function ($result) use (&$hotkeys) {
|
||||
$hotkeys = $result;
|
||||
},
|
||||
$hotkeys);
|
||||
|
||||
$prefixes = array();
|
||||
|
||||
foreach (array_keys($hotkeys) as $hotkey) {
|
||||
$pair = explode(" ", (string)$hotkey, 2);
|
||||
|
||||
if (count($pair) > 1 && !in_array($pair[0], $prefixes)) {
|
||||
array_push($prefixes, $pair[0]);
|
||||
}
|
||||
}
|
||||
|
||||
return array($prefixes, $hotkeys);
|
||||
}
|
||||
|
||||
function hotkeyHelp(): void {
|
||||
$info = self::get_hotkeys_info();
|
||||
$imap = self::get_hotkeys_map();
|
||||
$omap = [];
|
||||
|
||||
foreach ($imap[1] as $sequence => $action) {
|
||||
$omap[$action] ??= [];
|
||||
$omap[$action][] = $sequence;
|
||||
}
|
||||
|
||||
?>
|
||||
<ul class='panel panel-scrollable hotkeys-help' style='height : 300px'>
|
||||
<?php
|
||||
|
||||
foreach ($info as $section => $hotkeys) {
|
||||
?>
|
||||
<li><h3><?= $section ?></h3></li>
|
||||
<?php
|
||||
|
||||
foreach ($hotkeys as $action => $description) {
|
||||
|
||||
if (!empty($omap[$action])) {
|
||||
foreach ($omap[$action] as $sequence) {
|
||||
if (str_contains($sequence, "|")) {
|
||||
$sequence = substr($sequence,
|
||||
strpos($sequence, "|")+1,
|
||||
strlen($sequence));
|
||||
} else {
|
||||
$keys = explode(" ", $sequence);
|
||||
|
||||
for ($i = 0; $i < count($keys); $i++) {
|
||||
if (strlen($keys[$i]) > 1) {
|
||||
$tmp = '';
|
||||
foreach (str_split($keys[$i]) as $c) {
|
||||
$tmp .= match ($c) {
|
||||
'*' => __('Shift') . '+',
|
||||
'^' => __('Ctrl') . '+',
|
||||
default => $c,
|
||||
};
|
||||
}
|
||||
$keys[$i] = $tmp;
|
||||
}
|
||||
}
|
||||
$sequence = join(" ", $keys);
|
||||
}
|
||||
|
||||
?>
|
||||
<li>
|
||||
<div class='hk'><code><?= $sequence ?></code></div>
|
||||
<div class='desc'><?= $description ?></div>
|
||||
</li>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
</ul>
|
||||
<footer class='text-center'>
|
||||
<?= \Controls\submit_tag(__('Close this window')) ?>
|
||||
</footer>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
2009
classes/RSSUtils.php
2009
classes/RSSUtils.php
File diff suppressed because it is too large
Load Diff
@@ -1,274 +0,0 @@
|
||||
<?php
|
||||
class Sanitizer {
|
||||
/**
|
||||
* @param array<int, string> $allowed_elements
|
||||
* @param array<int, string> $disallowed_attributes
|
||||
*/
|
||||
private static function strip_harmful_tags(DOMDocument $doc, array $allowed_elements, $disallowed_attributes): DOMDocument {
|
||||
$xpath = new DOMXPath($doc);
|
||||
$entries = $xpath->query('//*');
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
/** @var DOMElement $entry */
|
||||
|
||||
if (!in_array($entry->nodeName, $allowed_elements)) {
|
||||
$entry->parentNode->removeChild($entry);
|
||||
}
|
||||
|
||||
if ($entry->hasAttributes()) {
|
||||
$attrs_to_remove = array();
|
||||
|
||||
foreach ($entry->attributes as $attr) {
|
||||
|
||||
if (str_starts_with($attr->nodeName, 'on')) {
|
||||
array_push($attrs_to_remove, $attr);
|
||||
}
|
||||
|
||||
if (str_starts_with($attr->nodeName, 'data-')) {
|
||||
array_push($attrs_to_remove, $attr);
|
||||
}
|
||||
|
||||
if ($attr->nodeName == 'href' && stripos($attr->value, 'javascript:') === 0) {
|
||||
array_push($attrs_to_remove, $attr);
|
||||
}
|
||||
|
||||
if (in_array($attr->nodeName, $disallowed_attributes)) {
|
||||
array_push($attrs_to_remove, $attr);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($attrs_to_remove as $attr) {
|
||||
$entry->removeAttributeNode($attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $doc;
|
||||
}
|
||||
|
||||
public static function iframe_whitelisted(DOMElement $entry): bool {
|
||||
$src = parse_url($entry->getAttribute("src"), PHP_URL_HOST);
|
||||
|
||||
if (!empty($src))
|
||||
return PluginHost::getInstance()->run_hooks_until(PluginHost::HOOK_IFRAME_WHITELISTED, true, $src);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function is_prefix_https(): bool {
|
||||
return parse_url(Config::get_self_url(), PHP_URL_SCHEME) == 'https';
|
||||
}
|
||||
|
||||
/** @param array<string> $words */
|
||||
public static function highlight_words_str(string $str, array $words) : string {
|
||||
$doc = new DOMDocument();
|
||||
|
||||
if ($doc->loadHTML('<?xml encoding="UTF-8"><span>' . $str . '</span>')) {
|
||||
$xpath = new DOMXPath($doc);
|
||||
|
||||
if (self::highlight_words($doc, $xpath, $words)) {
|
||||
$res = $doc->saveHTML();
|
||||
|
||||
/* strip everything outside of <body>...</body> */
|
||||
$res_frag = array();
|
||||
|
||||
if (preg_match('/<body>(.*)<\/body>/is', $res, $res_frag)) {
|
||||
return $res_frag[1];
|
||||
} else {
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $str;
|
||||
}
|
||||
|
||||
/** @param array<string> $words */
|
||||
public static function highlight_words(DOMDocument &$doc, DOMXPath $xpath, array $words) : bool {
|
||||
$rv = false;
|
||||
|
||||
foreach ($words as $word) {
|
||||
|
||||
// http://stackoverflow.com/questions/4081372/highlight-keywords-in-a-paragraph
|
||||
$elements = $xpath->query("//*/text()");
|
||||
|
||||
foreach ($elements as $child) {
|
||||
|
||||
$fragment = $doc->createDocumentFragment();
|
||||
$text = $child->textContent;
|
||||
|
||||
while (($pos = mb_stripos($text, $word)) !== false) {
|
||||
$fragment->appendChild(new DOMText(mb_substr($text, 0, (int)$pos)));
|
||||
$word = mb_substr($text, (int)$pos, mb_strlen($word));
|
||||
$highlight = $doc->createElement('span');
|
||||
$highlight->appendChild(new DOMText($word));
|
||||
$highlight->setAttribute('class', 'highlight');
|
||||
$fragment->appendChild($highlight);
|
||||
$text = mb_substr($text, $pos + mb_strlen($word));
|
||||
}
|
||||
|
||||
if (!empty($text)) $fragment->appendChild(new DOMText($text));
|
||||
|
||||
$child->parentNode->replaceChild($fragment, $child);
|
||||
|
||||
$rv = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string>|null $highlight_words Words to highlight in the HTML output.
|
||||
*
|
||||
* @return false|string The HTML, or false if an error occurred.
|
||||
*/
|
||||
public static function sanitize(string $str, ?bool $force_remove_images = false, ?int $owner = null, ?string $site_url = null, ?array $highlight_words = null, ?int $article_id = null): false|string {
|
||||
if (!$owner && isset($_SESSION["uid"]))
|
||||
$owner = $_SESSION["uid"];
|
||||
|
||||
$profile = isset($_SESSION['uid']) && $owner == $_SESSION['uid'] && isset($_SESSION['profile']) ? $_SESSION['profile'] : null;
|
||||
|
||||
$res = trim($str); if (!$res) return '';
|
||||
|
||||
$doc = new DOMDocument();
|
||||
$doc->loadHTML('<?xml encoding="UTF-8">' . $res);
|
||||
$xpath = new DOMXPath($doc);
|
||||
|
||||
// is it a good idea to possibly rewrite urls to our own prefix?
|
||||
// $rewrite_base_url = $site_url ? $site_url : Config::get_self_url();
|
||||
$rewrite_base_url = $site_url ? $site_url : "http://domain.invalid/";
|
||||
|
||||
$entries = $xpath->query('(//a[@href]|//img[@src]|//source[@srcset|@src]|//video[@poster])');
|
||||
|
||||
/** @var DOMElement $entry */
|
||||
foreach ($entries as $entry) {
|
||||
|
||||
if ($entry->hasAttribute('href')) {
|
||||
$entry->setAttribute('href',
|
||||
UrlHelper::rewrite_relative($rewrite_base_url, $entry->getAttribute('href'), $entry->tagName, "href"));
|
||||
|
||||
$entry->setAttribute('rel', 'noopener noreferrer');
|
||||
$entry->setAttribute("target", "_blank");
|
||||
}
|
||||
|
||||
if ($entry->hasAttribute('src')) {
|
||||
$entry->setAttribute('src',
|
||||
UrlHelper::rewrite_relative($rewrite_base_url, $entry->getAttribute('src'), $entry->tagName, "src"));
|
||||
}
|
||||
|
||||
if ($entry->nodeName == 'img') {
|
||||
$entry->setAttribute('referrerpolicy', 'no-referrer');
|
||||
$entry->setAttribute('loading', 'lazy');
|
||||
}
|
||||
|
||||
if ($entry->hasAttribute('srcset')) {
|
||||
$matches = RSSUtils::decode_srcset($entry->getAttribute('srcset'));
|
||||
|
||||
for ($i = 0; $i < count($matches); $i++) {
|
||||
$matches[$i]["url"] = UrlHelper::rewrite_relative($rewrite_base_url, $matches[$i]["url"]);
|
||||
}
|
||||
|
||||
$entry->setAttribute("srcset", RSSUtils::encode_srcset($matches));
|
||||
}
|
||||
|
||||
if ($entry->hasAttribute('poster')) {
|
||||
$entry->setAttribute('poster',
|
||||
UrlHelper::rewrite_relative($rewrite_base_url, $entry->getAttribute('poster'), $entry->tagName, "poster"));
|
||||
}
|
||||
|
||||
if ($entry->hasAttribute('src') &&
|
||||
($owner && Prefs::get(Prefs::STRIP_IMAGES, $owner, $profile)) || $force_remove_images || ($_SESSION['bw_limit'] ?? false)) {
|
||||
$p = $doc->createElement('p');
|
||||
|
||||
$a = $doc->createElement('a');
|
||||
$a->setAttribute('href', $entry->getAttribute('src'));
|
||||
|
||||
$a->appendChild(new DOMText($entry->getAttribute('src')));
|
||||
$a->setAttribute('target', '_blank');
|
||||
$a->setAttribute('rel', 'noopener noreferrer');
|
||||
|
||||
$p->appendChild($a);
|
||||
|
||||
if ($entry->nodeName == 'source') {
|
||||
|
||||
if ($entry->parentNode && $entry->parentNode->parentNode)
|
||||
$entry->parentNode->parentNode->replaceChild($p, $entry->parentNode);
|
||||
|
||||
} else if ($entry->nodeName == 'img') {
|
||||
if ($entry->parentNode)
|
||||
$entry->parentNode->replaceChild($p, $entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$entries = $xpath->query('//iframe');
|
||||
|
||||
/** @var DOMElement $entry */
|
||||
foreach ($entries as $entry) {
|
||||
if (!self::iframe_whitelisted($entry)) {
|
||||
$entry->setAttribute('sandbox', 'allow-scripts');
|
||||
} else {
|
||||
if (self::is_prefix_https()) {
|
||||
$entry->setAttribute("src",
|
||||
str_replace("http://", "https://",
|
||||
$entry->getAttribute("src")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$allowed_elements = array('a', 'abbr', 'address', 'acronym', 'audio', 'article', 'aside',
|
||||
'b', 'bdi', 'bdo', 'big', 'blockquote', 'body', 'br',
|
||||
'caption', 'cite', 'center', 'code', 'col', 'colgroup',
|
||||
'data', 'dd', 'del', 'details', 'description', 'dfn', 'div', 'dl', 'font',
|
||||
'dt', 'em', 'footer', 'figure', 'figcaption',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'html', 'i',
|
||||
'img', 'ins', 'kbd', 'li', 'main', 'mark', 'nav', 'noscript',
|
||||
'ol', 'p', 'picture', 'pre', 'q', 'ruby', 'rp', 'rt', 's', 'samp', 'section',
|
||||
'small', 'source', 'span', 'strike', 'strong', 'sub', 'summary',
|
||||
'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time',
|
||||
'tr', 'track', 'tt', 'u', 'ul', 'var', 'wbr', 'video', 'xml:namespace' );
|
||||
|
||||
if ($_SESSION['hasSandbox'] ?? false) $allowed_elements[] = 'iframe';
|
||||
|
||||
$disallowed_attributes = array('id', 'style', 'class', 'width', 'height', 'allow');
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_SANITIZE,
|
||||
function ($result) use (&$doc, &$allowed_elements, &$disallowed_attributes) {
|
||||
if (is_array($result)) {
|
||||
$doc = $result[0];
|
||||
$allowed_elements = $result[1];
|
||||
$disallowed_attributes = $result[2];
|
||||
} else {
|
||||
$doc = $result;
|
||||
}
|
||||
},
|
||||
$doc, $site_url, $allowed_elements, $disallowed_attributes, $article_id);
|
||||
|
||||
$doc->removeChild($doc->firstChild); //remove doctype
|
||||
$doc = self::strip_harmful_tags($doc, $allowed_elements, $disallowed_attributes);
|
||||
|
||||
$entries = $xpath->query('//iframe');
|
||||
foreach ($entries as $entry) {
|
||||
$div = $doc->createElement('div');
|
||||
$div->setAttribute('class', 'embed-responsive');
|
||||
$entry->parentNode->replaceChild($div, $entry);
|
||||
$div->appendChild($entry);
|
||||
}
|
||||
|
||||
if (is_array($highlight_words))
|
||||
self::highlight_words($doc, $xpath, $highlight_words);
|
||||
|
||||
$res = $doc->saveHTML();
|
||||
|
||||
/* strip everything outside of <body>...</body> */
|
||||
|
||||
$res_frag = array();
|
||||
if (preg_match('/<body>(.*)<\/body>/is', $res, $res_frag)) {
|
||||
return $res_frag[1];
|
||||
} else {
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
<?php
|
||||
class Scheduler {
|
||||
private static ?Scheduler $instance = null;
|
||||
|
||||
const TASK_RC_EXCEPTION = -100;
|
||||
const DEFAULT_NAME = 'Default Scheduler';
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private array $scheduled_tasks = [];
|
||||
|
||||
private string $name;
|
||||
|
||||
function __construct(string $name = self::DEFAULT_NAME) {
|
||||
$this->set_name($name);
|
||||
|
||||
if ($name === self::DEFAULT_NAME) {
|
||||
$this->add_scheduled_task('purge_orphaned_scheduled_tasks', '@weekly',
|
||||
function() {
|
||||
return $this->purge_orphaned_tasks();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function getInstance(): Scheduler {
|
||||
if (self::$instance == null)
|
||||
self::$instance = new self();
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/** Sets specific identifier for this instance of Scheduler used in debug logging */
|
||||
public function set_name(string $name) : void {
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a backend scheduled task which will be executed by updater (if due) during housekeeping.
|
||||
*
|
||||
* The granularity is not strictly guaranteed, housekeeping is invoked several times per hour
|
||||
* depending on how fast feed batch was processed, but no more than once per minute.
|
||||
*
|
||||
* Tasks do not run in user context. Task names may not overlap. Plugins should register tasks
|
||||
* via PluginHost methods (to be implemented later).
|
||||
*
|
||||
* Tasks should return an integer value (return code) which is stored in the database, a value of
|
||||
* 0 is considered successful.
|
||||
*
|
||||
* @param string $task_name unique name for this task, plugins should prefix this with plugin name
|
||||
* @param string $cron_expression schedule for this task in cron format
|
||||
* @param Closure $callback task code that gets executed
|
||||
*/
|
||||
function add_scheduled_task(string $task_name, string $cron_expression, Closure $callback) : bool {
|
||||
$task_name = strtolower($task_name);
|
||||
|
||||
if (isset($this->scheduled_tasks[$task_name])) {
|
||||
user_error("[$this->name] Attempted to override already registered scheduled task $task_name", E_USER_WARNING);
|
||||
return false;
|
||||
} else {
|
||||
try {
|
||||
$cron = new Cron\CronExpression($cron_expression);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
user_error("[$this->name] Attempt to register scheduled task $task_name failed: " . $e->getMessage(), E_USER_WARNING);
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->scheduled_tasks[$task_name] = [
|
||||
"cron" => $cron,
|
||||
"callback" => $callback,
|
||||
];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute scheduled tasks which are due to run and record last run timestamps.
|
||||
*/
|
||||
function run_due_tasks() : void {
|
||||
Debug::log("[$this->name] Processing all scheduled tasks...");
|
||||
|
||||
$tasks_succeeded = 0;
|
||||
$tasks_failed = 0;
|
||||
|
||||
foreach ($this->scheduled_tasks as $task_name => $task) {
|
||||
$task_record = ORM::for_table('ttrss_scheduled_tasks')
|
||||
->where('task_name', $task_name)
|
||||
->find_one();
|
||||
|
||||
if ($task_record)
|
||||
$last_run = $task_record->last_run;
|
||||
else
|
||||
$last_run = '1970-01-01 00:00';
|
||||
|
||||
// because we don't schedule tasks every minute, we assume that task is due if its
|
||||
// next estimated run based on previous timestamp is in the past
|
||||
if ($task['cron']->getNextRunDate($last_run)->getTimestamp() - time() < 0) {
|
||||
Debug::log("=> Scheduled task $task_name is due, executing...");
|
||||
|
||||
$task_started = time();
|
||||
|
||||
try {
|
||||
$rc = (int) $task['callback']();
|
||||
} catch (Exception $e) {
|
||||
user_error("[$this->name] Scheduled task $task_name failed with exception: " . $e->getMessage(), E_USER_WARNING);
|
||||
|
||||
$rc = self::TASK_RC_EXCEPTION;
|
||||
}
|
||||
|
||||
$task_duration = time() - $task_started;
|
||||
|
||||
if ($rc === 0) {
|
||||
++$tasks_succeeded;
|
||||
Debug::log("<= Scheduled task $task_name has finished in $task_duration seconds.");
|
||||
} else {
|
||||
$tasks_failed++;
|
||||
Debug::log("!! Scheduled task $task_name has failed with RC: $rc after $task_duration seconds.");
|
||||
}
|
||||
|
||||
if ($task_record) {
|
||||
$task_record->last_run = Db::NOW();
|
||||
$task_record->last_duration = $task_duration;
|
||||
$task_record->last_rc = $rc;
|
||||
$task_record->last_cron_expression = $task['cron']->getExpression();
|
||||
|
||||
$task_record->save();
|
||||
} else {
|
||||
$task_record = ORM::for_table('ttrss_scheduled_tasks')->create();
|
||||
|
||||
$task_record->set([
|
||||
'task_name' => $task_name,
|
||||
'last_duration' => $task_duration,
|
||||
'last_rc' => $rc,
|
||||
'last_run' => Db::NOW(),
|
||||
'last_cron_expression' => $task['cron']->getExpression()
|
||||
]);
|
||||
|
||||
$task_record->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Debug::log("[$this->name] Processing scheduled tasks finished with $tasks_succeeded tasks succeeded and $tasks_failed tasks failed.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge records of scheduled tasks that aren't currently registered
|
||||
* and haven't ran for a long time.
|
||||
*
|
||||
* @return int 0 if successful, 1 on failure
|
||||
*/
|
||||
private function purge_orphaned_tasks(): int {
|
||||
if (!$this->scheduled_tasks) {
|
||||
Debug::log(__METHOD__ . ' was invoked before scheduled tasks have been registered. This should never happen.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$result = ORM::for_table('ttrss_scheduled_tasks')
|
||||
->where_not_in('task_name', array_keys($this->scheduled_tasks))
|
||||
->where_raw("last_run < NOW() - INTERVAL '5 weeks'")
|
||||
->delete_many();
|
||||
|
||||
if ($result) {
|
||||
$deleted_count = ORM::get_last_statement()->rowCount();
|
||||
|
||||
if ($deleted_count)
|
||||
Debug::log("Purged {$deleted_count} orphaned scheduled tasks.");
|
||||
}
|
||||
|
||||
return $result ? 0 : 1;
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
<?php
|
||||
require_once 'lib/gettext/gettext.inc.php';
|
||||
|
||||
/**
|
||||
* @todo look into making this behave closer to what SessionHandlerInterface intends
|
||||
*/
|
||||
class Sessions implements \SessionHandlerInterface {
|
||||
private int $session_expire;
|
||||
private string $session_name;
|
||||
|
||||
public function __construct() {
|
||||
$this->session_expire = min(2147483647 - time() - 1, Config::get(Config::SESSION_COOKIE_LIFETIME));
|
||||
$this->session_name = Config::get(Config::SESSION_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts session-related PHP configuration options
|
||||
*/
|
||||
public function configure(): void {
|
||||
if (Config::is_server_https()) {
|
||||
ini_set('session.cookie_secure', 'true');
|
||||
}
|
||||
|
||||
ini_set('session.gc_probability', '75');
|
||||
ini_set('session.name', $this->session_name);
|
||||
ini_set('session.use_only_cookies', 'true');
|
||||
ini_set('session.gc_maxlifetime', $this->session_expire);
|
||||
ini_set('session.cookie_lifetime', '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the validity of the PHP session cookie (if it exists) and is persistent (expire > 0)
|
||||
* @return bool Whether the new cookie was set successfully
|
||||
*/
|
||||
public function extend_session(): bool {
|
||||
if (isset($_COOKIE[$this->session_name]) && $this->session_expire > 0) {
|
||||
return setcookie($this->session_name,
|
||||
$_COOKIE[$this->session_name],
|
||||
time() + $this->session_expire,
|
||||
ini_get('session.cookie_path'),
|
||||
ini_get('session.cookie_domain'),
|
||||
ini_get('session.cookie_secure'),
|
||||
ini_get('session.cookie_httponly'));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function open(string $path, string $name): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
public function close(): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
public function read(string $id): false|string {
|
||||
$sth = Db::pdo()->prepare('SELECT data FROM ttrss_sessions WHERE id=?');
|
||||
$sth->execute([$id]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$data = base64_decode($row['data']);
|
||||
|
||||
if (Config::get(Config::ENCRYPTION_KEY)) {
|
||||
$unserialized_data = @unserialize($data); // avoid leaking plaintext session via error message
|
||||
|
||||
if ($unserialized_data !== false)
|
||||
return Crypt::decrypt_string($unserialized_data);
|
||||
}
|
||||
|
||||
// if encryption key is missing or session data is not in serialized format, assume plaintext data and return as-is
|
||||
return $data;
|
||||
}
|
||||
|
||||
$expire = time() + $this->session_expire;
|
||||
|
||||
$sth = Db::pdo()->prepare("INSERT INTO ttrss_sessions (id, data, expire)
|
||||
VALUES (?, '', ?)");
|
||||
return $sth->execute([$id, $expire]) ? '' : false;
|
||||
}
|
||||
|
||||
public function write(string $id, string $data): bool {
|
||||
|
||||
if (Config::get(Config::ENCRYPTION_KEY))
|
||||
$data = serialize(Crypt::encrypt_string($data));
|
||||
|
||||
$data = base64_encode($data);
|
||||
|
||||
$expire = time() + $this->session_expire;
|
||||
|
||||
$sth = Db::pdo()->prepare('SELECT id FROM ttrss_sessions WHERE id=?');
|
||||
$sth->execute([$id]);
|
||||
|
||||
if ($sth->fetch()) {
|
||||
$sth = Db::pdo()->prepare('UPDATE ttrss_sessions SET data=?, expire=? WHERE id=?');
|
||||
return $sth->execute([$data, $expire, $id]);
|
||||
}
|
||||
|
||||
$sth = Db::pdo()->prepare('INSERT INTO ttrss_sessions (id, data, expire) VALUES (?, ?, ?)');
|
||||
return $sth->execute([$id, $data, $expire]);
|
||||
}
|
||||
|
||||
public function destroy(string $id): bool {
|
||||
$sth = Db::pdo()->prepare('DELETE FROM ttrss_sessions WHERE id = ?');
|
||||
return $sth->execute([$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|false the number of deleted sessions on success, or false on failure
|
||||
*/
|
||||
public function gc(int $max_lifetime): false|int {
|
||||
$result = Db::pdo()->query('DELETE FROM ttrss_sessions WHERE expire < ' . time());
|
||||
return $result === false ? false : $result->rowCount();
|
||||
}
|
||||
|
||||
public static function validate_session(): bool {
|
||||
if (Config::get(Config::SINGLE_USER_MODE)) return true;
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
if (!empty($_SESSION['uid'])) {
|
||||
$user = ORM::for_table('ttrss_users')->find_one($_SESSION['uid']);
|
||||
|
||||
if ($user) {
|
||||
if ($user->pwd_hash != $_SESSION['pwd_hash']) {
|
||||
$_SESSION['login_error_msg'] = __('Session failed to validate (password changed)');
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user->access_level == UserHelper::ACCESS_LEVEL_DISABLED) {
|
||||
$_SESSION['login_error_msg'] = __('Session failed to validate (account is disabled)');
|
||||
return false;
|
||||
}
|
||||
|
||||
// default to true because there might not be any hooks and this is our last check
|
||||
$hook_result = true;
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_VALIDATE_SESSION,
|
||||
function ($result) use (&$hook_result) {
|
||||
$hook_result = $result;
|
||||
|
||||
if (!$result) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
return $hook_result;
|
||||
|
||||
} else {
|
||||
$_SESSION['login_error_msg'] = __('Session failed to validate (user not found)');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
require_once "lib/MiniTemplator.class.php";
|
||||
|
||||
class Templator extends MiniTemplator {
|
||||
|
||||
/* this reads tt-rss template from templates.local/ or templates/ if only base filename is given */
|
||||
function readTemplateFromFile ($fileName) {
|
||||
if (!str_contains($fileName, "/")) {
|
||||
|
||||
$fileName = basename($fileName);
|
||||
|
||||
if (file_exists("templates.local/$fileName"))
|
||||
return parent::readTemplateFromFile("templates.local/$fileName");
|
||||
else
|
||||
return parent::readTemplateFromFile("templates/$fileName");
|
||||
|
||||
} else {
|
||||
return parent::readTemplateFromFile($fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
<?php
|
||||
class TimeHelper {
|
||||
|
||||
static function smart_date_time(int $timestamp, int $tz_offset = 0, ?int $owner_uid = null, bool $eta_min = false): string {
|
||||
// i.e. if the Unix epoch
|
||||
if ($timestamp - $tz_offset === 0)
|
||||
return __('Never');
|
||||
|
||||
if (!$owner_uid) $owner_uid = $_SESSION['uid'];
|
||||
$profile = isset($_SESSION['uid']) && $owner_uid == $_SESSION['uid'] && isset($_SESSION['profile']) ? $_SESSION['profile'] : null;
|
||||
|
||||
if ($eta_min && time() + $tz_offset - $timestamp < 3600) {
|
||||
return T_sprintf("%d min", date("i", time() + $tz_offset - $timestamp));
|
||||
} else if (date("Y.m.d", $timestamp) == date("Y.m.d", time() + $tz_offset)) {
|
||||
$format = Prefs::get(Prefs::SHORT_DATE_FORMAT, $owner_uid, $profile);
|
||||
if (!str_contains((strtolower($format)), "a"))
|
||||
return date("G:i", $timestamp);
|
||||
else
|
||||
return date("g:i a", $timestamp);
|
||||
} else if (date("Y", $timestamp) == date("Y", time() + $tz_offset)) {
|
||||
$format = Prefs::get(Prefs::SHORT_DATE_FORMAT, $owner_uid, $profile);
|
||||
return date($format, $timestamp);
|
||||
} else {
|
||||
$format = Prefs::get(Prefs::LONG_DATE_FORMAT, $owner_uid, $profile);
|
||||
return date($format, $timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $long Whether to display the datetime in a 'long' format. Only used if $no_smart_dt is true.
|
||||
*/
|
||||
static function make_local_datetime(?string $timestamp, bool $long = false, ?int $owner_uid = null,
|
||||
bool $no_smart_dt = false, bool $eta_min = false): string {
|
||||
|
||||
if (!$owner_uid) $owner_uid = $_SESSION['uid'];
|
||||
$profile = isset($_SESSION['uid']) && $owner_uid == $_SESSION['uid'] && isset($_SESSION['profile']) ? $_SESSION['profile'] : null;
|
||||
|
||||
if (!$timestamp) $timestamp = '1970-01-01 0:00';
|
||||
|
||||
global $utc_tz;
|
||||
global $user_tz;
|
||||
|
||||
if (!$utc_tz) $utc_tz = new DateTimeZone('UTC');
|
||||
|
||||
$timestamp = substr($timestamp, 0, 19);
|
||||
|
||||
# We store date in UTC internally
|
||||
$dt = new DateTime($timestamp, $utc_tz);
|
||||
|
||||
$user_tz_string = Prefs::get(Prefs::USER_TIMEZONE, $owner_uid);
|
||||
|
||||
if ($user_tz_string != 'Automatic') {
|
||||
|
||||
try {
|
||||
if (!$user_tz) $user_tz = new DateTimeZone($user_tz_string);
|
||||
} catch (Exception $e) {
|
||||
$user_tz = $utc_tz;
|
||||
}
|
||||
|
||||
$tz_offset = $user_tz->getOffset($dt);
|
||||
} else {
|
||||
$tz_offset = (int) -($_SESSION["clientTzOffset"] ?? 0);
|
||||
}
|
||||
|
||||
$user_timestamp = $dt->format('U') + $tz_offset;
|
||||
|
||||
if (!$no_smart_dt) {
|
||||
return self::smart_date_time($user_timestamp,
|
||||
$tz_offset, $owner_uid, $eta_min);
|
||||
} else {
|
||||
if ($long)
|
||||
$format = Prefs::get(Prefs::LONG_DATE_FORMAT, $owner_uid, $profile);
|
||||
else
|
||||
$format = Prefs::get(Prefs::SHORT_DATE_FORMAT, $owner_uid, $profile);
|
||||
|
||||
return date($format, $user_timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
static function convert_timestamp(int $timestamp, string $source_tz, string $dest_tz): int {
|
||||
|
||||
try {
|
||||
$source_tz = new DateTimeZone($source_tz);
|
||||
} catch (Exception $e) {
|
||||
$source_tz = new DateTimeZone('UTC');
|
||||
}
|
||||
|
||||
try {
|
||||
$dest_tz = new DateTimeZone($dest_tz);
|
||||
} catch (Exception $e) {
|
||||
$dest_tz = new DateTimeZone('UTC');
|
||||
}
|
||||
|
||||
$dt = new DateTime(date('Y-m-d H:i:s', $timestamp), $source_tz);
|
||||
|
||||
return (int)$dt->format('U') + $dest_tz->getOffset($dt);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,485 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
|
||||
class UrlHelper {
|
||||
const EXTRA_HREF_SCHEMES = [
|
||||
"magnet",
|
||||
"mailto",
|
||||
"tel"
|
||||
];
|
||||
|
||||
const EXTRA_SCHEMES_BY_CONTENT_TYPE = [
|
||||
"application/x-bittorrent" => [ "magnet" ],
|
||||
];
|
||||
|
||||
static string $fetch_last_error;
|
||||
static int $fetch_last_error_code;
|
||||
static string $fetch_last_error_content;
|
||||
static string $fetch_last_content_type;
|
||||
static string $fetch_last_modified;
|
||||
static string $fetch_effective_url;
|
||||
static string $fetch_effective_ip_addr;
|
||||
|
||||
public static ?GuzzleHttp\ClientInterface $client = null;
|
||||
|
||||
private static function get_client(): GuzzleHttp\ClientInterface {
|
||||
if (self::$client == null) {
|
||||
self::$client = new GuzzleHttp\Client([
|
||||
GuzzleHttp\RequestOptions::COOKIES => false,
|
||||
GuzzleHttp\RequestOptions::PROXY => Config::get(Config::HTTP_PROXY) ?: null,
|
||||
]);
|
||||
}
|
||||
|
||||
return self::$client;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|int> $parts
|
||||
*/
|
||||
static function build_url(array $parts): string {
|
||||
$tmp = $parts['scheme'] . "://" . $parts['host'];
|
||||
|
||||
if (isset($parts['path'])) $tmp .= $parts['path'];
|
||||
if (isset($parts['query'])) $tmp .= '?' . $parts['query'];
|
||||
if (isset($parts['fragment'])) $tmp .= '#' . $parts['fragment'];
|
||||
|
||||
return $tmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a (possibly) relative URL to a absolute one, using provided base URL.
|
||||
* Provides some exceptions for additional schemes like data: if called with owning element/attribute.
|
||||
*
|
||||
* @param string $base_url Base URL (i.e. from where the document is)
|
||||
* @param string $rel_url Possibly relative URL in the document
|
||||
* @param string $owner_element Owner element tag name (i.e. "a") (optional)
|
||||
* @param string $owner_attribute Owner attribute (i.e. "href") (optional)
|
||||
* @param string $content_type URL content type as specified by enclosures, etc.
|
||||
*
|
||||
* @return false|string Absolute URL or false on failure (either during URL parsing or validation)
|
||||
*/
|
||||
public static function rewrite_relative($base_url,
|
||||
$rel_url,
|
||||
string $owner_element = "",
|
||||
string $owner_attribute = "",
|
||||
string $content_type = ""): false|string {
|
||||
|
||||
$rel_parts = parse_url($rel_url);
|
||||
|
||||
if (!$rel_url) return $base_url;
|
||||
|
||||
/**
|
||||
* If parse_url failed to parse $rel_url return false to match the current "invalid thing" behavior
|
||||
* of UrlHelper::validate().
|
||||
*
|
||||
* TODO: There are many places where a string return value is assumed. We should either update those
|
||||
* to account for the possibility of failure, or look into updating this function's return values.
|
||||
*/
|
||||
if ($rel_parts === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!empty($rel_parts['host']) && !empty($rel_parts['scheme'])) {
|
||||
return self::validate($rel_url);
|
||||
|
||||
// protocol-relative URL (rare but they exist)
|
||||
} else if (str_starts_with($rel_url, "//")) {
|
||||
return self::validate("https:" . $rel_url);
|
||||
// allow some extra schemes for A href
|
||||
} else if (in_array($rel_parts["scheme"] ?? "", self::EXTRA_HREF_SCHEMES, true) &&
|
||||
$owner_element == "a" &&
|
||||
$owner_attribute == "href") {
|
||||
return $rel_url;
|
||||
// allow some extra schemes for links with feed-specified content type i.e. enclosures
|
||||
} else if ($content_type &&
|
||||
isset(self::EXTRA_SCHEMES_BY_CONTENT_TYPE[$content_type]) &&
|
||||
in_array($rel_parts["scheme"], self::EXTRA_SCHEMES_BY_CONTENT_TYPE[$content_type])) {
|
||||
return $rel_url;
|
||||
// allow limited subset of inline base64-encoded images for IMG elements
|
||||
} else if (($rel_parts["scheme"] ?? "") == "data" &&
|
||||
preg_match('%^image/(webp|gif|jpg|png|svg);base64,%', $rel_parts["path"]) &&
|
||||
$owner_element == "img" &&
|
||||
$owner_attribute == "src") {
|
||||
return $rel_url;
|
||||
} else {
|
||||
$base_parts = parse_url($base_url);
|
||||
|
||||
$rel_parts['host'] = $base_parts['host'] ?? "";
|
||||
$rel_parts['scheme'] = $base_parts['scheme'] ?? "";
|
||||
|
||||
if ($rel_parts['path'] ?? "") {
|
||||
|
||||
// we append dirname() of base path to relative URL path as per RFC 3986 section 5.2.2
|
||||
$base_path = with_trailing_slash(dirname($base_parts['path'] ?? ""));
|
||||
|
||||
// 1. absolute relative path (/test.html) = no-op, proceed as is
|
||||
|
||||
// 2. dotslash relative URI (./test.html) - strip "./", append base path
|
||||
if (str_starts_with($rel_parts['path'], './')) {
|
||||
$rel_parts['path'] = $base_path . substr($rel_parts['path'], 2);
|
||||
// 3. anything else relative (test.html) - append dirname() of base path
|
||||
} else if (!str_starts_with($rel_parts['path'], '/')) {
|
||||
$rel_parts['path'] = $base_path . $rel_parts['path'];
|
||||
}
|
||||
|
||||
//$rel_parts['path'] = str_replace("/./", "/", $rel_parts['path']);
|
||||
//$rel_parts['path'] = str_replace("//", "/", $rel_parts['path']);
|
||||
}
|
||||
|
||||
return self::validate(self::build_url($rel_parts));
|
||||
}
|
||||
}
|
||||
|
||||
/** extended filtering involves validation for safe ports and loopback
|
||||
* @return false|string false if something went wrong, otherwise the URL string
|
||||
*/
|
||||
static function validate(string $url, bool $extended_filtering = false): false|string {
|
||||
|
||||
$url = clean($url);
|
||||
|
||||
# fix protocol-relative URLs
|
||||
if (str_starts_with($url, "//"))
|
||||
$url = "https:" . $url;
|
||||
|
||||
$tokens = parse_url($url);
|
||||
|
||||
// this isn't really necessary because filter_var(... FILTER_VALIDATE_URL) requires host and scheme
|
||||
// as per https://php.watch/versions/7.3/filter-var-flag-deprecation but it might save time
|
||||
if (empty($tokens['host']))
|
||||
return false;
|
||||
|
||||
if (!in_array(strtolower($tokens['scheme']), ['http', 'https']))
|
||||
return false;
|
||||
|
||||
//convert IDNA hostname to punycode if possible
|
||||
if (function_exists("idn_to_ascii")) {
|
||||
if (mb_detect_encoding($tokens['host']) != 'ASCII') {
|
||||
if (defined('IDNA_NONTRANSITIONAL_TO_ASCII') && defined('INTL_IDNA_VARIANT_UTS46')) {
|
||||
$tokens['host'] = idn_to_ascii($tokens['host'], IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46);
|
||||
} else {
|
||||
$tokens['host'] = idn_to_ascii($tokens['host']);
|
||||
}
|
||||
|
||||
// if `idn_to_ascii` failed
|
||||
if ($tokens['host'] === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// separate set of tokens with urlencoded 'path' because filter_var() rightfully fails on non-latin characters
|
||||
// (used for validation only, we actually request the original URL, in case of urlencode breaking it)
|
||||
$tokens_filter_var = $tokens;
|
||||
|
||||
if ($tokens['path'] ?? false) {
|
||||
$tokens_filter_var['path'] = implode("/",
|
||||
array_map("rawurlencode",
|
||||
array_map("rawurldecode",
|
||||
explode("/", $tokens['path']))));
|
||||
}
|
||||
|
||||
$url = self::build_url($tokens);
|
||||
$url_filter_var = self::build_url($tokens_filter_var);
|
||||
|
||||
if (filter_var($url_filter_var, FILTER_VALIDATE_URL) === false)
|
||||
return false;
|
||||
|
||||
if ($extended_filtering) {
|
||||
if (!in_array($tokens['port'] ?? '', [80, 443, '']))
|
||||
return false;
|
||||
|
||||
if (strtolower($tokens['host']) == 'localhost' || $tokens['host'] == '::1'
|
||||
|| str_starts_with($tokens['host'], '127.'))
|
||||
return false;
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
static function resolve_redirects(string $url, int $timeout): false|string {
|
||||
$client = self::get_client();
|
||||
|
||||
try {
|
||||
$response = $client->request('HEAD', $url, [
|
||||
GuzzleHttp\RequestOptions::CONNECT_TIMEOUT => $timeout ?: Config::get(Config::FILE_FETCH_CONNECT_TIMEOUT),
|
||||
GuzzleHttp\RequestOptions::TIMEOUT => $timeout ?: Config::get(Config::FILE_FETCH_TIMEOUT),
|
||||
GuzzleHttp\RequestOptions::ALLOW_REDIRECTS => ['max' => 10, 'track_redirects' => true],
|
||||
GuzzleHttp\RequestOptions::HTTP_ERRORS => false,
|
||||
GuzzleHttp\RequestOptions::HEADERS => [
|
||||
'User-Agent' => Config::get_user_agent(),
|
||||
'Connection' => 'close',
|
||||
],
|
||||
]);
|
||||
} catch (Exception $ex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If a history header value doesn't exist there was no redirection and the original URL is fine.
|
||||
$history_header = $response->getHeader(GuzzleHttp\RedirectMiddleware::HISTORY_HEADER);
|
||||
return ($history_header ? end($history_header) : $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, bool|int|string>|string $options
|
||||
* @return false|string false if something went wrong, otherwise string contents
|
||||
*/
|
||||
// TODO: max_size currently only works for CURL transfers
|
||||
// TODO: multiple-argument way is deprecated, first parameter is a hash now
|
||||
public static function fetch(array|string $options /* previously: 0: $url , 1: $type = false, 2: $login = false, 3: $pass = false,
|
||||
4: $post_query = false, 5: $timeout = false, 6: $timestamp = 0, 7: $useragent = false, 8: $encoding = false,
|
||||
9: $auth_type = "basic" */): false|string {
|
||||
|
||||
self::$fetch_last_error = "";
|
||||
self::$fetch_last_error_code = -1;
|
||||
self::$fetch_last_error_content = "";
|
||||
self::$fetch_last_content_type = "";
|
||||
self::$fetch_last_modified = "";
|
||||
self::$fetch_effective_url = "";
|
||||
self::$fetch_effective_ip_addr = "";
|
||||
|
||||
if (!is_array($options)) {
|
||||
|
||||
// falling back on compatibility shim
|
||||
$option_names = [ "url", "type", "login", "pass", "post_query", "timeout", "last_modified", "useragent", "encoding", "auth_type" ];
|
||||
$tmp = [];
|
||||
|
||||
for ($i = 0; $i < func_num_args(); $i++) {
|
||||
$tmp[$option_names[$i]] = func_get_arg($i);
|
||||
}
|
||||
|
||||
$options = $tmp;
|
||||
|
||||
/*$options = array(
|
||||
"url" => func_get_arg(0),
|
||||
"type" => @func_get_arg(1),
|
||||
"login" => @func_get_arg(2),
|
||||
"pass" => @func_get_arg(3),
|
||||
"post_query" => @func_get_arg(4),
|
||||
"timeout" => @func_get_arg(5),
|
||||
"timestamp" => @func_get_arg(6),
|
||||
"useragent" => @func_get_arg(7),
|
||||
"encoding" => @func_get_arg(8),
|
||||
"auth_type" => @func_get_arg(9),
|
||||
); */
|
||||
}
|
||||
|
||||
$url = $options["url"];
|
||||
$type = $options["type"] ?? false;
|
||||
$login = $options["login"] ?? false;
|
||||
$pass = $options["pass"] ?? false;
|
||||
$auth_type = $options["auth_type"] ?? "basic";
|
||||
$post_query = $options["post_query"] ?? false;
|
||||
$timeout = $options["timeout"] ?? false;
|
||||
$last_modified = $options["last_modified"] ?? "";
|
||||
$useragent = $options["useragent"] ?? false;
|
||||
$followlocation = $options["followlocation"] ?? true;
|
||||
$max_size = $options["max_size"] ?? Config::get(Config::MAX_DOWNLOAD_FILE_SIZE); // in bytes
|
||||
$http_accept = $options["http_accept"] ?? false;
|
||||
$http_referrer = $options["http_referrer"] ?? false;
|
||||
$encoding = $options["encoding"] ?? false;
|
||||
|
||||
$url = ltrim($url, ' ');
|
||||
$url = str_replace(' ', '%20', $url);
|
||||
|
||||
Debug::log("[UrlHelper] fetching: $url", Debug::LOG_EXTENDED);
|
||||
|
||||
$url = self::validate($url, true);
|
||||
|
||||
if (!$url) {
|
||||
self::$fetch_last_error = 'Requested URL failed extended validation.';
|
||||
return false;
|
||||
}
|
||||
|
||||
// this skip is needed for integration tests, please don't enable in production
|
||||
if (!getenv('__URLHELPER_ALLOW_LOOPBACK')) {
|
||||
$url_host = parse_url($url, PHP_URL_HOST);
|
||||
$ip_addr = gethostbyname($url_host);
|
||||
|
||||
if (!$ip_addr || str_starts_with($ip_addr, '127.')) {
|
||||
self::$fetch_last_error = "URL hostname failed to resolve or resolved to a loopback address ($ip_addr)";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$req_options = [
|
||||
GuzzleHttp\RequestOptions::CONNECT_TIMEOUT => $timeout ?: Config::get(Config::FILE_FETCH_CONNECT_TIMEOUT),
|
||||
GuzzleHttp\RequestOptions::TIMEOUT => $timeout ?: Config::get(Config::FILE_FETCH_TIMEOUT),
|
||||
GuzzleHttp\RequestOptions::HEADERS => [
|
||||
'User-Agent' => $useragent ?: Config::get_user_agent(),
|
||||
],
|
||||
'curl' => [],
|
||||
];
|
||||
|
||||
if ($followlocation) {
|
||||
$req_options[GuzzleHttp\RequestOptions::ALLOW_REDIRECTS] = [
|
||||
'max' => 20,
|
||||
'track_redirects' => true,
|
||||
'on_redirect' => function(RequestInterface $request, ResponseInterface $response, UriInterface $uri) {
|
||||
if (!self::validate($uri, true)) {
|
||||
self::$fetch_effective_url = (string) $uri;
|
||||
throw new GuzzleHttp\Exception\RequestException('URL received during redirection failed extended validation.',
|
||||
$request, $response);
|
||||
}
|
||||
},
|
||||
];
|
||||
} else {
|
||||
$req_options[GuzzleHttp\RequestOptions::ALLOW_REDIRECTS] = false;
|
||||
}
|
||||
|
||||
if ($last_modified && !$post_query)
|
||||
$req_options[GuzzleHttp\RequestOptions::HEADERS]['If-Modified-Since'] = $last_modified;
|
||||
|
||||
if ($http_accept)
|
||||
$req_options[GuzzleHttp\RequestOptions::HEADERS]['Accept'] = $http_accept;
|
||||
|
||||
if ($encoding)
|
||||
$req_options[GuzzleHttp\RequestOptions::HEADERS]['Accept-Encoding'] = $encoding;
|
||||
|
||||
if ($http_referrer)
|
||||
$req_options[GuzzleHttp\RequestOptions::HEADERS]['Referer'] = $http_referrer;
|
||||
|
||||
if ($login && $pass && in_array($auth_type, ['basic', 'digest', 'ntlm'])) {
|
||||
// Let Guzzle handle the details for auth types it supports
|
||||
$req_options[GuzzleHttp\RequestOptions::AUTH] = [$login, $pass, $auth_type];
|
||||
} elseif ($auth_type === 'any') {
|
||||
// https://docs.guzzlephp.org/en/stable/faq.html#how-can-i-add-custom-curl-options
|
||||
$req_options['curl'][\CURLOPT_HTTPAUTH] = \CURLAUTH_ANY;
|
||||
if ($login && $pass)
|
||||
$req_options['curl'][\CURLOPT_USERPWD] = "$login:$pass";
|
||||
}
|
||||
|
||||
if ($post_query)
|
||||
$req_options[GuzzleHttp\RequestOptions::FORM_PARAMS] = $post_query;
|
||||
|
||||
if ($max_size) {
|
||||
$req_options[GuzzleHttp\RequestOptions::PROGRESS] = function($download_size, $downloaded, $upload_size, $uploaded) use(&$max_size, $url) {
|
||||
//Debug::log("[curl progressfunction] $downloaded $max_size", Debug::$LOG_EXTENDED);
|
||||
|
||||
if ($downloaded > $max_size) {
|
||||
Debug::log("[UrlHelper] fetch error: max size of $max_size bytes exceeded when downloading $url . Aborting.", Debug::LOG_VERBOSE);
|
||||
throw new \LengthException("Download exceeded size limit");
|
||||
}
|
||||
};
|
||||
|
||||
# Alternative/supplement to `progress` checking
|
||||
$req_options[GuzzleHttp\RequestOptions::ON_HEADERS] = function(ResponseInterface $response) use(&$max_size, $url) {
|
||||
$content_length = $response->getHeaderLine('Content-Length');
|
||||
if ($content_length > $max_size) {
|
||||
Debug::log("[UrlHelper] fetch error: server indicated (via 'Content-Length: {$content_length}') max size of $max_size bytes " .
|
||||
"would be exceeded when downloading $url . Aborting.", Debug::LOG_VERBOSE);
|
||||
throw new \LengthException("Server sent 'Content-Length' exceeding download limit");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$client = self::get_client();
|
||||
|
||||
try {
|
||||
$response = $client->request($post_query ? 'POST' : 'GET', $url, $req_options);
|
||||
} catch (\LengthException $ex) {
|
||||
// Either 'Content-Length' indicated the download limit would be exceeded, or the transfer actually exceeded the download limit.
|
||||
self::$fetch_last_error = $ex->getMessage();
|
||||
return false;
|
||||
} catch (GuzzleHttp\Exception\GuzzleException $ex) {
|
||||
self::$fetch_last_error = $ex->getMessage();
|
||||
|
||||
if ($ex instanceof GuzzleHttp\Exception\RequestException) {
|
||||
if ($ex instanceof GuzzleHttp\Exception\BadResponseException) {
|
||||
// 4xx or 5xx
|
||||
self::$fetch_last_error_code = $ex->getResponse()->getStatusCode();
|
||||
|
||||
// If credentials were provided and we got a 403 back, retry once with auth type 'any'
|
||||
// to attempt compatibility with unusual configurations.
|
||||
if ($login && $pass && self::$fetch_last_error_code === 403 && $auth_type !== 'any') {
|
||||
$options['auth_type'] = 'any';
|
||||
return self::fetch($options);
|
||||
}
|
||||
|
||||
self::$fetch_last_content_type = $ex->getResponse()->getHeaderLine('content-type');
|
||||
|
||||
if ($type && !str_contains(self::$fetch_last_content_type, "$type"))
|
||||
self::$fetch_last_error_content = (string) $ex->getResponse()->getBody();
|
||||
} elseif (array_key_exists('errno', $ex->getHandlerContext())) {
|
||||
$errno = (int) $ex->getHandlerContext()['errno'];
|
||||
|
||||
// By default, all supported encoding types are sent via `Accept-Encoding` and decoding of
|
||||
// responses with `Content-Encoding` is automatically attempted. If this fails, we do a
|
||||
// single retry with `Accept-Encoding: none` to try and force an unencoded response.
|
||||
if (($errno === \CURLE_WRITE_ERROR || $errno === \CURLE_BAD_CONTENT_ENCODING) &&
|
||||
$ex->getRequest()->getHeaderLine('accept-encoding') !== 'none') {
|
||||
$options['encoding'] = 'none';
|
||||
return self::fetch($options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Keep setting expected 'fetch_last_error_code' and 'fetch_last_error' values
|
||||
self::$fetch_last_error_code = $response->getStatusCode();
|
||||
self::$fetch_last_error = "HTTP/{$response->getProtocolVersion()} {$response->getStatusCode()} {$response->getReasonPhrase()}";
|
||||
self::$fetch_last_modified = $response->getHeaderLine('last-modified');
|
||||
self::$fetch_last_content_type = $response->getHeaderLine('content-type');
|
||||
|
||||
// If a history header value doesn't exist there was no redirection and the original URL is fine.
|
||||
$history_header = $response->getHeader(GuzzleHttp\RedirectMiddleware::HISTORY_HEADER);
|
||||
self::$fetch_effective_url = $history_header ? end($history_header) : $url;
|
||||
|
||||
// This shouldn't be necessary given the checks that occur during potential redirects, but we'll do it anyway.
|
||||
if (!self::validate(self::$fetch_effective_url, true)) {
|
||||
self::$fetch_last_error = "URL received after redirection failed extended validation.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// @phpstan-ignore argument.type (prior validation ensures the host value exists)
|
||||
self::$fetch_effective_ip_addr = gethostbyname(parse_url(self::$fetch_effective_url, PHP_URL_HOST));
|
||||
|
||||
// this skip is needed for integration tests, please don't enable in production
|
||||
if (!getenv('__URLHELPER_ALLOW_LOOPBACK')) {
|
||||
if (!self::$fetch_effective_ip_addr || str_starts_with(self::$fetch_effective_ip_addr, '127.')) {
|
||||
self::$fetch_last_error = 'URL hostname received after redirection failed to resolve or resolved to a loopback address (' .
|
||||
self::$fetch_effective_ip_addr . ')';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$body = (string) $response->getBody();
|
||||
|
||||
if (!$body) {
|
||||
self::$fetch_last_error = 'Successful response, but no content was received.';
|
||||
return false;
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false|string false if the provided URL didn't match expected patterns, otherwise the video ID string
|
||||
*/
|
||||
public static function url_to_youtube_vid(string $url): false|string {
|
||||
$url = str_replace("youtube.com", "youtube-nocookie.com", $url);
|
||||
|
||||
$regexps = [
|
||||
"/\/\/www\.youtube-nocookie\.com\/v\/([\w-]+)/",
|
||||
"/\/\/www\.youtube-nocookie\.com\/embed\/([\w-]+)/",
|
||||
"/\/\/www\.youtube-nocookie\.com\/watch\?v=([\w-]+)/",
|
||||
"/\/\/youtu.be\/([\w-]+)/",
|
||||
];
|
||||
|
||||
foreach ($regexps as $re) {
|
||||
$matches = [];
|
||||
|
||||
if (preg_match($re, $url, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,500 +0,0 @@
|
||||
<?php
|
||||
use OTPHP\TOTP;
|
||||
|
||||
class UserHelper {
|
||||
|
||||
const HASH_ALGO_SSHA512 = 'SSHA-512';
|
||||
const HASH_ALGO_SSHA256 = 'SSHA-256';
|
||||
const HASH_ALGO_MODE2 = 'MODE2';
|
||||
const HASH_ALGO_SHA1X = 'SHA1X';
|
||||
const HASH_ALGO_SHA1 = 'SHA1';
|
||||
|
||||
const HASH_ALGOS = [
|
||||
self::HASH_ALGO_SSHA512,
|
||||
self::HASH_ALGO_SSHA256,
|
||||
self::HASH_ALGO_MODE2,
|
||||
self::HASH_ALGO_SHA1X,
|
||||
self::HASH_ALGO_SHA1
|
||||
];
|
||||
|
||||
const ACCESS_LEVELS = [
|
||||
self::ACCESS_LEVEL_DISABLED,
|
||||
self::ACCESS_LEVEL_READONLY,
|
||||
self::ACCESS_LEVEL_USER,
|
||||
self::ACCESS_LEVEL_POWERUSER,
|
||||
self::ACCESS_LEVEL_ADMIN,
|
||||
self::ACCESS_LEVEL_KEEP_CURRENT
|
||||
];
|
||||
|
||||
/** forbidden to login */
|
||||
const ACCESS_LEVEL_DISABLED = -2;
|
||||
|
||||
/** can't subscribe to new feeds, feeds are not updated */
|
||||
const ACCESS_LEVEL_READONLY = -1;
|
||||
|
||||
/** no restrictions, regular user */
|
||||
const ACCESS_LEVEL_USER = 0;
|
||||
|
||||
/** not used, same as regular user */
|
||||
const ACCESS_LEVEL_POWERUSER = 5;
|
||||
|
||||
/** has administrator permissions */
|
||||
const ACCESS_LEVEL_ADMIN = 10;
|
||||
|
||||
/** used by self::user_modify() to keep current access level */
|
||||
const ACCESS_LEVEL_KEEP_CURRENT = -1024;
|
||||
|
||||
/**
|
||||
* @param int $level integer loglevel value
|
||||
* @return UserHelper::ACCESS_LEVEL_* if valid, warn and return ACCESS_LEVEL_KEEP_CURRENT otherwise
|
||||
*/
|
||||
public static function map_access_level(int $level) : int {
|
||||
if (in_array($level, self::ACCESS_LEVELS)) {
|
||||
/** @phpstan-ignore return.type (yes it is a UserHelper::ACCESS_LEVEL_* value) */
|
||||
return $level;
|
||||
} else {
|
||||
user_error("Passed invalid user access level: $level", E_USER_WARNING);
|
||||
return self::ACCESS_LEVEL_KEEP_CURRENT;
|
||||
}
|
||||
}
|
||||
|
||||
static function authenticate(?string $login = null, ?string $password = null, bool $check_only = false, ?string $service = null): bool {
|
||||
if (!Config::get(Config::SINGLE_USER_MODE)) {
|
||||
$user_id = false;
|
||||
$auth_module = false;
|
||||
$login = mb_strtolower($login);
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_AUTH_USER,
|
||||
function ($result, $plugin) use (&$user_id, &$auth_module) {
|
||||
if ($result) {
|
||||
$user_id = (int)$result;
|
||||
$auth_module = strtolower(get_class($plugin));
|
||||
return true;
|
||||
}
|
||||
},
|
||||
$login, $password, $service);
|
||||
|
||||
if ($user_id && !$check_only) {
|
||||
|
||||
if (session_status() != PHP_SESSION_ACTIVE)
|
||||
session_start();
|
||||
|
||||
session_regenerate_id(true);
|
||||
|
||||
$user = ORM::for_table('ttrss_users')->find_one($user_id);
|
||||
|
||||
if ($user && $user->access_level != self::ACCESS_LEVEL_DISABLED) {
|
||||
self::set_session_for_user($user_id);
|
||||
$_SESSION["auth_module"] = $auth_module;
|
||||
$_SESSION["name"] = $user->login;
|
||||
$_SESSION["access_level"] = $user->access_level;
|
||||
$_SESSION["pwd_hash"] = $user->pwd_hash;
|
||||
|
||||
$user->last_login = Db::NOW();
|
||||
$user->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($login && $password && !$user_id && !$check_only)
|
||||
Logger::log(E_USER_WARNING, "Failed login attempt for $login (service: $service) from " . UserHelper::get_user_ip());
|
||||
|
||||
return false;
|
||||
|
||||
} else {
|
||||
self::set_session_for_user(1);
|
||||
$_SESSION["name"] = "admin";
|
||||
$_SESSION["access_level"] = self::ACCESS_LEVEL_ADMIN;
|
||||
|
||||
$_SESSION["hide_hello"] = true;
|
||||
$_SESSION["hide_logout"] = true;
|
||||
|
||||
$_SESSION["auth_module"] = false;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
static function set_session_for_user(int $owner_uid): void {
|
||||
$_SESSION["uid"] = $owner_uid;
|
||||
$_SESSION["last_login_update"] = time();
|
||||
$_SESSION["ip_address"] = UserHelper::get_user_ip();
|
||||
|
||||
if (empty($_SESSION["csrf_token"]))
|
||||
$_SESSION["csrf_token"] = bin2hex(get_random_bytes(16));
|
||||
}
|
||||
|
||||
static function load_user_plugins(int $owner_uid, ?PluginHost $pluginhost = null): void {
|
||||
|
||||
if (!$pluginhost) $pluginhost = PluginHost::getInstance();
|
||||
|
||||
if ($owner_uid && Config::get_schema_version() >= 100 && empty($_SESSION["safe_mode"])) {
|
||||
$profile = isset($_SESSION['uid']) && $owner_uid == $_SESSION['uid'] && isset($_SESSION['profile']) ? $_SESSION['profile'] : null;
|
||||
$plugins = Prefs::get(Prefs::_ENABLED_PLUGINS, $owner_uid, $profile);
|
||||
|
||||
$pluginhost->load((string)$plugins, PluginHost::KIND_USER, $owner_uid);
|
||||
|
||||
/*if (get_schema_version() > 100) {
|
||||
$pluginhost->load_data();
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
static function login_sequence(): void {
|
||||
$pdo = Db::pdo();
|
||||
|
||||
if (Config::get(Config::SINGLE_USER_MODE)) {
|
||||
if (session_status() != PHP_SESSION_ACTIVE)
|
||||
session_start();
|
||||
|
||||
self::authenticate("admin", null);
|
||||
startup_gettext();
|
||||
self::load_user_plugins($_SESSION["uid"]);
|
||||
} else {
|
||||
if (!Sessions::validate_session())
|
||||
$_SESSION["uid"] = null;
|
||||
|
||||
if (empty($_SESSION["uid"])) {
|
||||
|
||||
if (Config::get(Config::AUTH_AUTO_LOGIN) && self::authenticate(null, null)) {
|
||||
$_SESSION["ref_schema_version"] = Config::get_schema_version();
|
||||
} else {
|
||||
self::authenticate(null, null, true);
|
||||
}
|
||||
|
||||
if (empty($_SESSION["uid"])) {
|
||||
UserHelper::logout();
|
||||
|
||||
Handler_Public::_render_login_form();
|
||||
exit;
|
||||
}
|
||||
|
||||
} else {
|
||||
/* bump login timestamp */
|
||||
$user = ORM::for_table('ttrss_users')->find_one($_SESSION["uid"]);
|
||||
$user->last_login = Db::NOW();
|
||||
$user->save();
|
||||
|
||||
$_SESSION["last_login_update"] = time();
|
||||
}
|
||||
|
||||
startup_gettext();
|
||||
self::load_user_plugins($_SESSION["uid"]);
|
||||
}
|
||||
}
|
||||
|
||||
static function print_user_stylesheet(): void {
|
||||
$value = Prefs::get(Prefs::USER_STYLESHEET, $_SESSION['uid'], $_SESSION['profile'] ?? null);
|
||||
|
||||
if ($value) {
|
||||
print "<style type='text/css' id='user_css_style'>";
|
||||
print str_replace("<br/>", "\n", $value);
|
||||
print "</style>";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static function get_user_ip(): ?string {
|
||||
foreach (["HTTP_X_REAL_IP", "REMOTE_ADDR"] as $hdr) {
|
||||
if (isset($_SERVER[$hdr]))
|
||||
return $_SERVER[$hdr];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static function get_login_by_id(int $id): ?string {
|
||||
$user = ORM::for_table('ttrss_users')
|
||||
->find_one($id);
|
||||
|
||||
if ($user)
|
||||
return $user->login;
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
static function find_user_by_login(string $login): ?int {
|
||||
$user = ORM::for_table('ttrss_users')
|
||||
->where_raw('LOWER(login) = LOWER(?)', [$login])
|
||||
->find_one();
|
||||
|
||||
if ($user)
|
||||
return $user->id;
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
static function logout(): void {
|
||||
if (session_status() === PHP_SESSION_ACTIVE)
|
||||
session_destroy();
|
||||
|
||||
if (isset($_COOKIE[session_name()])) {
|
||||
setcookie(session_name(), '', time()-42000, '/');
|
||||
|
||||
}
|
||||
session_commit();
|
||||
}
|
||||
|
||||
static function get_salt(): string {
|
||||
return substr(bin2hex(get_random_bytes(125)), 0, 250);
|
||||
}
|
||||
|
||||
/** TODO: this should invoke UserHelper::user_modify() */
|
||||
static function reset_password(int $uid, bool $format_output = false, string $new_password = ""): void {
|
||||
|
||||
$user = ORM::for_table('ttrss_users')->find_one($uid);
|
||||
|
||||
if ($user) {
|
||||
|
||||
$login = $user->login;
|
||||
|
||||
$new_salt = self::get_salt();
|
||||
$tmp_user_pwd = $new_password ? $new_password : make_password();
|
||||
|
||||
$pwd_hash = self::hash_password($tmp_user_pwd, $new_salt, self::HASH_ALGOS[0]);
|
||||
|
||||
$user->pwd_hash = $pwd_hash;
|
||||
$user->salt = $new_salt;
|
||||
$user->save();
|
||||
|
||||
$message = T_sprintf("Changed password of user %s to %s", "<strong>$login</strong>", "<strong>$tmp_user_pwd</strong>");
|
||||
} else {
|
||||
$message = __("User not found");
|
||||
}
|
||||
|
||||
if ($format_output)
|
||||
print_notice($message);
|
||||
else
|
||||
print $message;
|
||||
}
|
||||
|
||||
static function check_otp(int $owner_uid, int $otp_check) : bool {
|
||||
$otp = TOTP::create(self::get_otp_secret($owner_uid, true));
|
||||
|
||||
return $otp->now() == $otp_check;
|
||||
}
|
||||
|
||||
static function disable_otp(int $owner_uid) : bool {
|
||||
$user = ORM::for_table('ttrss_users')->find_one($owner_uid);
|
||||
|
||||
if ($user) {
|
||||
$user->otp_enabled = false;
|
||||
|
||||
// force new OTP secret when next enabled
|
||||
if (Config::get_schema_version() >= 143) {
|
||||
$user->otp_secret = null;
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static function enable_otp(int $owner_uid, int $otp_check) : bool {
|
||||
$secret = self::get_otp_secret($owner_uid);
|
||||
|
||||
if ($secret) {
|
||||
$otp = TOTP::create($secret);
|
||||
$user = ORM::for_table('ttrss_users')->find_one($owner_uid);
|
||||
|
||||
if ($otp->now() == $otp_check && $user) {
|
||||
|
||||
$user->otp_enabled = true;
|
||||
$user->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
static function is_otp_enabled(int $owner_uid) : bool {
|
||||
$user = ORM::for_table('ttrss_users')->find_one($owner_uid);
|
||||
|
||||
if ($user) {
|
||||
return $user->otp_enabled;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static function get_otp_secret(int $owner_uid, bool $show_if_enabled = false): ?string {
|
||||
$user = ORM::for_table('ttrss_users')->find_one($owner_uid);
|
||||
|
||||
if ($user) {
|
||||
|
||||
$salt_based_secret = mb_substr(sha1($user->salt), 0, 12);
|
||||
|
||||
if (Config::get_schema_version() >= 143) {
|
||||
$secret = $user->otp_secret;
|
||||
|
||||
if (empty($secret)) {
|
||||
|
||||
/* migrate secret if OTP is already enabled, otherwise make a new one */
|
||||
if ($user->otp_enabled) {
|
||||
$user->otp_secret = $salt_based_secret;
|
||||
} else {
|
||||
$user->otp_secret = bin2hex(get_random_bytes(10));
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
$secret = $user->otp_secret;
|
||||
}
|
||||
} else {
|
||||
$secret = $salt_based_secret;
|
||||
}
|
||||
|
||||
if (!$user->otp_enabled || $show_if_enabled) {
|
||||
return \ParagonIE\ConstantTime\Base32::encodeUpperUnpadded($secret);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|int $owner_uid if null, checks current user via session-specific auth module, if set works on internal database only
|
||||
* @throws PDOException
|
||||
* @throws Exception
|
||||
*/
|
||||
static function is_default_password(?int $owner_uid = null): bool {
|
||||
return self::user_has_password($owner_uid, 'password');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $algo should be one of UserHelper::HASH_ALGO_*
|
||||
*
|
||||
* @return false|string False if the password couldn't be hashed, otherwise the hash string.
|
||||
*/
|
||||
static function hash_password(string $pass, string $salt, string $algo = self::HASH_ALGOS[0]): false|string {
|
||||
$pass_hash = match ($algo) {
|
||||
self::HASH_ALGO_SHA1 => sha1($pass),
|
||||
self::HASH_ALGO_SHA1X => sha1("$salt:$pass"),
|
||||
self::HASH_ALGO_MODE2, self::HASH_ALGO_SSHA256 => hash('sha256', $salt . $pass),
|
||||
self::HASH_ALGO_SSHA512 => hash('sha512', $salt . $pass),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($pass_hash === null)
|
||||
user_error("hash_password: unknown hash algo: $algo", E_USER_ERROR);
|
||||
|
||||
return $pass_hash ? "$algo:$pass_hash" : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $login Login for new user (case-insensitive)
|
||||
* @param string $password Password for new user (may not be blank)
|
||||
* @param UserHelper::ACCESS_LEVEL_* $access_level Access level for new user
|
||||
* @return bool true if user has been created
|
||||
*/
|
||||
static function user_add(string $login, string $password, int $access_level) : bool {
|
||||
$login = clean($login);
|
||||
|
||||
if ($login &&
|
||||
$password &&
|
||||
!self::find_user_by_login($login) &&
|
||||
self::map_access_level((int)$access_level) != self::ACCESS_LEVEL_KEEP_CURRENT) {
|
||||
|
||||
$user = ORM::for_table('ttrss_users')->create();
|
||||
|
||||
$user->salt = self::get_salt();
|
||||
$user->login = mb_strtolower($login);
|
||||
$user->pwd_hash = self::hash_password($password, $user->salt);
|
||||
$user->access_level = $access_level;
|
||||
$user->created = Db::NOW();
|
||||
|
||||
return $user->save();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $uid User ID to modify
|
||||
* @param string $new_password set password to this value if its not blank
|
||||
* @param UserHelper::ACCESS_LEVEL_* $access_level set user access level to this value if it is set (default ACCESS_LEVEL_KEEP_CURRENT)
|
||||
* @return bool true if user record has been saved
|
||||
*
|
||||
* NOTE: $access_level is of mixed type because of intellephense
|
||||
*/
|
||||
static function user_modify(int $uid, string $new_password = '', $access_level = self::ACCESS_LEVEL_KEEP_CURRENT) : bool {
|
||||
$user = ORM::for_table('ttrss_users')->find_one($uid);
|
||||
|
||||
if ($user) {
|
||||
if ($new_password != '') {
|
||||
$new_salt = self::get_salt();
|
||||
$pwd_hash = self::hash_password($new_password, $new_salt, self::HASH_ALGOS[0]);
|
||||
|
||||
$user->pwd_hash = $pwd_hash;
|
||||
$user->salt = $new_salt;
|
||||
}
|
||||
|
||||
if ($access_level != self::ACCESS_LEVEL_KEEP_CURRENT) {
|
||||
$user->access_level = (int)$access_level;
|
||||
}
|
||||
|
||||
return $user->save();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $uid user ID to delete (this won't delete built-in admin user with UID 1)
|
||||
* @return bool true if user has been deleted
|
||||
*/
|
||||
static function user_delete(int $uid) : bool {
|
||||
if ($uid != 1) {
|
||||
|
||||
$user = ORM::for_table('ttrss_users')->find_one($uid);
|
||||
|
||||
if ($user) {
|
||||
// TODO: is it still necessary to split those queries?
|
||||
|
||||
ORM::for_table('ttrss_tags')
|
||||
->where('owner_uid', $uid)
|
||||
->delete_many();
|
||||
|
||||
ORM::for_table('ttrss_feeds')
|
||||
->where('owner_uid', $uid)
|
||||
->delete_many();
|
||||
|
||||
return $user->delete();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|int $owner_uid if null, checks current user via session-specific auth module, if set works on internal database only
|
||||
* @param string $password password to compare hash against
|
||||
*/
|
||||
static function user_has_password(?int $owner_uid, string $password) : bool {
|
||||
if ($owner_uid) {
|
||||
$authenticator = new Auth_Internal();
|
||||
|
||||
return $authenticator->check_password($owner_uid, $password);
|
||||
} else {
|
||||
$authenticator = PluginHost::getInstance()->get_plugin($_SESSION["auth_module"]);
|
||||
|
||||
if ($authenticator &&
|
||||
method_exists($authenticator, "check_password") &&
|
||||
$authenticator->check_password($_SESSION["uid"], $password)) {
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
888
classes/api.php
Executable file
888
classes/api.php
Executable file
@@ -0,0 +1,888 @@
|
||||
<?php
|
||||
class API extends Handler {
|
||||
|
||||
const API_LEVEL = 14;
|
||||
|
||||
const STATUS_OK = 0;
|
||||
const STATUS_ERR = 1;
|
||||
|
||||
private $seq;
|
||||
|
||||
static function param_to_bool($p) {
|
||||
return $p && ($p !== "f" && $p !== "false");
|
||||
}
|
||||
|
||||
function before($method) {
|
||||
if (parent::before($method)) {
|
||||
header("Content-Type: text/json");
|
||||
|
||||
if (!$_SESSION["uid"] && $method != "login" && $method != "isloggedin") {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => 'NOT_LOGGED_IN'));
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($_SESSION["uid"] && $method != "logout" && !get_pref('ENABLE_API_ACCESS')) {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => 'API_DISABLED'));
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->seq = (int) clean($_REQUEST['seq']);
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function wrap($status, $reply) {
|
||||
print json_encode(array("seq" => $this->seq,
|
||||
"status" => $status,
|
||||
"content" => $reply));
|
||||
}
|
||||
|
||||
function getVersion() {
|
||||
$rv = array("version" => VERSION);
|
||||
$this->wrap(self::STATUS_OK, $rv);
|
||||
}
|
||||
|
||||
function getApiLevel() {
|
||||
$rv = array("level" => self::API_LEVEL);
|
||||
$this->wrap(self::STATUS_OK, $rv);
|
||||
}
|
||||
|
||||
function login() {
|
||||
@session_destroy();
|
||||
@session_start();
|
||||
|
||||
$login = clean($_REQUEST["user"]);
|
||||
$password = clean($_REQUEST["password"]);
|
||||
$password_base64 = base64_decode(clean($_REQUEST["password"]));
|
||||
|
||||
if (SINGLE_USER_MODE) $login = "admin";
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT id FROM ttrss_users WHERE login = ?");
|
||||
$sth->execute([$login]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$uid = $row["id"];
|
||||
} else {
|
||||
$uid = 0;
|
||||
}
|
||||
|
||||
if (!$uid) {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => "LOGIN_ERROR"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (get_pref("ENABLE_API_ACCESS", $uid)) {
|
||||
if (authenticate_user($login, $password)) { // try login with normal password
|
||||
$this->wrap(self::STATUS_OK, array("session_id" => session_id(),
|
||||
"api_level" => self::API_LEVEL));
|
||||
} else if (authenticate_user($login, $password_base64)) { // else try with base64_decoded password
|
||||
$this->wrap(self::STATUS_OK, array("session_id" => session_id(),
|
||||
"api_level" => self::API_LEVEL));
|
||||
} else { // else we are not logged in
|
||||
user_error("Failed login attempt for $login from {$_SERVER['REMOTE_ADDR']}", E_USER_WARNING);
|
||||
$this->wrap(self::STATUS_ERR, array("error" => "LOGIN_ERROR"));
|
||||
}
|
||||
} else {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => "API_DISABLED"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function logout() {
|
||||
logout_user();
|
||||
$this->wrap(self::STATUS_OK, array("status" => "OK"));
|
||||
}
|
||||
|
||||
function isLoggedIn() {
|
||||
$this->wrap(self::STATUS_OK, array("status" => $_SESSION["uid"] != ''));
|
||||
}
|
||||
|
||||
function getUnread() {
|
||||
$feed_id = clean($_REQUEST["feed_id"]);
|
||||
$is_cat = clean($_REQUEST["is_cat"]);
|
||||
|
||||
if ($feed_id) {
|
||||
$this->wrap(self::STATUS_OK, array("unread" => getFeedUnread($feed_id, $is_cat)));
|
||||
} else {
|
||||
$this->wrap(self::STATUS_OK, array("unread" => Feeds::getGlobalUnread()));
|
||||
}
|
||||
}
|
||||
|
||||
/* Method added for ttrss-reader for Android */
|
||||
function getCounters() {
|
||||
$this->wrap(self::STATUS_OK, Counters::getAllCounters());
|
||||
}
|
||||
|
||||
function getFeeds() {
|
||||
$cat_id = clean($_REQUEST["cat_id"]);
|
||||
$unread_only = API::param_to_bool(clean($_REQUEST["unread_only"]));
|
||||
$limit = (int) clean($_REQUEST["limit"]);
|
||||
$offset = (int) clean($_REQUEST["offset"]);
|
||||
$include_nested = API::param_to_bool(clean($_REQUEST["include_nested"]));
|
||||
|
||||
$feeds = $this->api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested);
|
||||
|
||||
$this->wrap(self::STATUS_OK, $feeds);
|
||||
}
|
||||
|
||||
function getCategories() {
|
||||
$unread_only = API::param_to_bool(clean($_REQUEST["unread_only"]));
|
||||
$enable_nested = API::param_to_bool(clean($_REQUEST["enable_nested"]));
|
||||
$include_empty = API::param_to_bool(clean($_REQUEST['include_empty']));
|
||||
|
||||
// TODO do not return empty categories, return Uncategorized and standard virtual cats
|
||||
|
||||
if ($enable_nested)
|
||||
$nested_qpart = "parent_cat IS NULL";
|
||||
else
|
||||
$nested_qpart = "true";
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT
|
||||
id, title, order_id, (SELECT COUNT(id) FROM
|
||||
ttrss_feeds WHERE
|
||||
ttrss_feed_categories.id IS NOT NULL AND cat_id = ttrss_feed_categories.id) AS num_feeds,
|
||||
(SELECT COUNT(id) FROM
|
||||
ttrss_feed_categories AS c2 WHERE
|
||||
c2.parent_cat = ttrss_feed_categories.id) AS num_cats
|
||||
FROM ttrss_feed_categories
|
||||
WHERE $nested_qpart AND owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
|
||||
$cats = array();
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
if ($include_empty || $line["num_feeds"] > 0 || $line["num_cats"] > 0) {
|
||||
$unread = getFeedUnread($line["id"], true);
|
||||
|
||||
if ($enable_nested)
|
||||
$unread += Feeds::getCategoryChildrenUnread($line["id"]);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
array_push($cats, array("id" => $line["id"],
|
||||
"title" => $line["title"],
|
||||
"unread" => $unread,
|
||||
"order_id" => (int) $line["order_id"],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array(-2,-1,0) as $cat_id) {
|
||||
if ($include_empty || !$this->isCategoryEmpty($cat_id)) {
|
||||
$unread = getFeedUnread($cat_id, true);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
array_push($cats, array("id" => $cat_id,
|
||||
"title" => Feeds::getCategoryTitle($cat_id),
|
||||
"unread" => $unread));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->wrap(self::STATUS_OK, $cats);
|
||||
}
|
||||
|
||||
function getHeadlines() {
|
||||
$feed_id = clean($_REQUEST["feed_id"]);
|
||||
if ($feed_id !== "") {
|
||||
|
||||
if (is_numeric($feed_id)) $feed_id = (int) $feed_id;
|
||||
|
||||
$limit = (int)clean($_REQUEST["limit"]);
|
||||
|
||||
if (!$limit || $limit >= 200) $limit = 200;
|
||||
|
||||
$offset = (int)clean($_REQUEST["skip"]);
|
||||
$filter = clean($_REQUEST["filter"]);
|
||||
$is_cat = API::param_to_bool(clean($_REQUEST["is_cat"]));
|
||||
$show_excerpt = API::param_to_bool(clean($_REQUEST["show_excerpt"]));
|
||||
$show_content = API::param_to_bool(clean($_REQUEST["show_content"]));
|
||||
/* all_articles, unread, adaptive, marked, updated */
|
||||
$view_mode = clean($_REQUEST["view_mode"]);
|
||||
$include_attachments = API::param_to_bool(clean($_REQUEST["include_attachments"]));
|
||||
$since_id = (int)clean($_REQUEST["since_id"]);
|
||||
$include_nested = API::param_to_bool(clean($_REQUEST["include_nested"]));
|
||||
$sanitize_content = !isset($_REQUEST["sanitize"]) ||
|
||||
API::param_to_bool($_REQUEST["sanitize"]);
|
||||
$force_update = API::param_to_bool(clean($_REQUEST["force_update"]));
|
||||
$has_sandbox = API::param_to_bool(clean($_REQUEST["has_sandbox"]));
|
||||
$excerpt_length = (int)clean($_REQUEST["excerpt_length"]);
|
||||
$check_first_id = (int)clean($_REQUEST["check_first_id"]);
|
||||
$include_header = API::param_to_bool(clean($_REQUEST["include_header"]));
|
||||
|
||||
$_SESSION['hasSandbox'] = $has_sandbox;
|
||||
|
||||
$skip_first_id_check = false;
|
||||
|
||||
$override_order = false;
|
||||
switch (clean($_REQUEST["order_by"])) {
|
||||
case "title":
|
||||
$override_order = "ttrss_entries.title, date_entered, updated";
|
||||
break;
|
||||
case "date_reverse":
|
||||
$override_order = "score DESC, date_entered, updated";
|
||||
$skip_first_id_check = true;
|
||||
break;
|
||||
case "feed_dates":
|
||||
$override_order = "updated DESC";
|
||||
break;
|
||||
}
|
||||
|
||||
/* do not rely on params below */
|
||||
|
||||
$search = clean($_REQUEST["search"]);
|
||||
|
||||
list($headlines, $headlines_header) = $this->api_get_headlines($feed_id, $limit, $offset,
|
||||
$filter, $is_cat, $show_excerpt, $show_content, $view_mode, $override_order,
|
||||
$include_attachments, $since_id, $search,
|
||||
$include_nested, $sanitize_content, $force_update, $excerpt_length, $check_first_id, $skip_first_id_check);
|
||||
|
||||
if ($include_header) {
|
||||
$this->wrap(self::STATUS_OK, array($headlines_header, $headlines));
|
||||
} else {
|
||||
$this->wrap(self::STATUS_OK, $headlines);
|
||||
}
|
||||
} else {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => 'INCORRECT_USAGE'));
|
||||
}
|
||||
}
|
||||
|
||||
function updateArticle() {
|
||||
$article_ids = explode(",", clean($_REQUEST["article_ids"]));
|
||||
$mode = (int) clean($_REQUEST["mode"]);
|
||||
$data = clean($_REQUEST["data"]);
|
||||
$field_raw = (int)clean($_REQUEST["field"]);
|
||||
|
||||
$field = "";
|
||||
$set_to = "";
|
||||
|
||||
switch ($field_raw) {
|
||||
case 0:
|
||||
$field = "marked";
|
||||
$additional_fields = ",last_marked = NOW()";
|
||||
break;
|
||||
case 1:
|
||||
$field = "published";
|
||||
$additional_fields = ",last_published = NOW()";
|
||||
break;
|
||||
case 2:
|
||||
$field = "unread";
|
||||
$additional_fields = ",last_read = NOW()";
|
||||
break;
|
||||
case 3:
|
||||
$field = "note";
|
||||
};
|
||||
|
||||
switch ($mode) {
|
||||
case 1:
|
||||
$set_to = "true";
|
||||
break;
|
||||
case 0:
|
||||
$set_to = "false";
|
||||
break;
|
||||
case 2:
|
||||
$set_to = "NOT $field";
|
||||
break;
|
||||
}
|
||||
|
||||
if ($field == "note") $set_to = $this->pdo->quote($data);
|
||||
|
||||
if ($field && $set_to && count($article_ids) > 0) {
|
||||
|
||||
$article_qmarks = arr_qmarks($article_ids);
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
$field = $set_to $additional_fields
|
||||
WHERE ref_id IN ($article_qmarks) AND owner_uid = ?");
|
||||
$sth->execute(array_merge($article_ids, [$_SESSION['uid']]));
|
||||
|
||||
$num_updated = $sth->rowCount();
|
||||
|
||||
if ($num_updated > 0 && $field == "unread") {
|
||||
$sth = $this->pdo->prepare("SELECT DISTINCT feed_id FROM ttrss_user_entries
|
||||
WHERE ref_id IN ($article_qmarks)");
|
||||
$sth->execute($article_ids);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
CCache::update($line["feed_id"], $_SESSION["uid"]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->wrap(self::STATUS_OK, array("status" => "OK",
|
||||
"updated" => $num_updated));
|
||||
|
||||
} else {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => 'INCORRECT_USAGE'));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function getArticle() {
|
||||
|
||||
$article_ids = explode(",", clean($_REQUEST["article_id"]));
|
||||
$sanitize_content = !isset($_REQUEST["sanitize"]) ||
|
||||
API::param_to_bool($_REQUEST["sanitize"]);
|
||||
|
||||
if ($article_ids) {
|
||||
|
||||
$article_qmarks = arr_qmarks($article_ids);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT id,guid,title,link,content,feed_id,comments,int_id,
|
||||
marked,unread,published,score,note,lang,
|
||||
".SUBSTRING_FOR_DATE."(updated,1,16) as updated,
|
||||
author,(SELECT title FROM ttrss_feeds WHERE id = feed_id) AS feed_title,
|
||||
(SELECT site_url FROM ttrss_feeds WHERE id = feed_id) AS site_url,
|
||||
(SELECT hide_images FROM ttrss_feeds WHERE id = feed_id) AS hide_images
|
||||
FROM ttrss_entries,ttrss_user_entries
|
||||
WHERE id IN ($article_qmarks) AND ref_id = id AND owner_uid = ?");
|
||||
|
||||
$sth->execute(array_merge($article_ids, [$_SESSION['uid']]));
|
||||
|
||||
$articles = array();
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
|
||||
$attachments = Article::get_article_enclosures($line['id']);
|
||||
|
||||
$article = array(
|
||||
"id" => $line["id"],
|
||||
"guid" => $line["guid"],
|
||||
"title" => $line["title"],
|
||||
"link" => $line["link"],
|
||||
"labels" => Article::get_article_labels($line['id']),
|
||||
"unread" => API::param_to_bool($line["unread"]),
|
||||
"marked" => API::param_to_bool($line["marked"]),
|
||||
"published" => API::param_to_bool($line["published"]),
|
||||
"comments" => $line["comments"],
|
||||
"author" => $line["author"],
|
||||
"updated" => (int) strtotime($line["updated"]),
|
||||
"feed_id" => $line["feed_id"],
|
||||
"attachments" => $attachments,
|
||||
"score" => (int)$line["score"],
|
||||
"feed_title" => $line["feed_title"],
|
||||
"note" => $line["note"],
|
||||
"lang" => $line["lang"]
|
||||
);
|
||||
|
||||
if ($sanitize_content) {
|
||||
$article["content"] = sanitize(
|
||||
$line["content"],
|
||||
API::param_to_bool($line['hide_images']),
|
||||
false, $line["site_url"], false, $line["id"]);
|
||||
} else {
|
||||
$article["content"] = $line["content"];
|
||||
}
|
||||
|
||||
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_RENDER_ARTICLE_API) as $p) {
|
||||
$article = $p->hook_render_article_api(array("article" => $article));
|
||||
}
|
||||
|
||||
$article['content'] = rewrite_cached_urls($article['content']);
|
||||
|
||||
array_push($articles, $article);
|
||||
|
||||
}
|
||||
|
||||
$this->wrap(self::STATUS_OK, $articles);
|
||||
} else {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => 'INCORRECT_USAGE'));
|
||||
}
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
$config = array(
|
||||
"icons_dir" => ICONS_DIR,
|
||||
"icons_url" => ICONS_URL);
|
||||
|
||||
$config["daemon_is_running"] = file_is_locked("update_daemon.lock");
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT COUNT(*) AS cf FROM
|
||||
ttrss_feeds WHERE owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
$row = $sth->fetch();
|
||||
|
||||
$config["num_feeds"] = $row["cf"];
|
||||
|
||||
$this->wrap(self::STATUS_OK, $config);
|
||||
}
|
||||
|
||||
function updateFeed() {
|
||||
$feed_id = (int) clean($_REQUEST["feed_id"]);
|
||||
|
||||
if (!ini_get("open_basedir")) {
|
||||
RSSUtils::update_rss_feed($feed_id);
|
||||
}
|
||||
|
||||
$this->wrap(self::STATUS_OK, array("status" => "OK"));
|
||||
}
|
||||
|
||||
function catchupFeed() {
|
||||
$feed_id = clean($_REQUEST["feed_id"]);
|
||||
$is_cat = clean($_REQUEST["is_cat"]);
|
||||
|
||||
Feeds::catchup_feed($feed_id, $is_cat);
|
||||
|
||||
$this->wrap(self::STATUS_OK, array("status" => "OK"));
|
||||
}
|
||||
|
||||
function getPref() {
|
||||
$pref_name = clean($_REQUEST["pref_name"]);
|
||||
|
||||
$this->wrap(self::STATUS_OK, array("value" => get_pref($pref_name)));
|
||||
}
|
||||
|
||||
function getLabels() {
|
||||
$article_id = (int)clean($_REQUEST['article_id']);
|
||||
|
||||
$rv = array();
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT id, caption, fg_color, bg_color
|
||||
FROM ttrss_labels2
|
||||
WHERE owner_uid = ? ORDER BY caption");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
|
||||
if ($article_id)
|
||||
$article_labels = Article::get_article_labels($article_id);
|
||||
else
|
||||
$article_labels = array();
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
|
||||
$checked = false;
|
||||
foreach ($article_labels as $al) {
|
||||
if (Labels::feed_to_label_id($al[0]) == $line['id']) {
|
||||
$checked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
array_push($rv, array(
|
||||
"id" => (int)Labels::label_to_feed_id($line['id']),
|
||||
"caption" => $line['caption'],
|
||||
"fg_color" => $line['fg_color'],
|
||||
"bg_color" => $line['bg_color'],
|
||||
"checked" => $checked));
|
||||
}
|
||||
|
||||
$this->wrap(self::STATUS_OK, $rv);
|
||||
}
|
||||
|
||||
function setArticleLabel() {
|
||||
|
||||
$article_ids = explode(",", clean($_REQUEST["article_ids"]));
|
||||
$label_id = (int) clean($_REQUEST['label_id']);
|
||||
$assign = API::param_to_bool(clean($_REQUEST['assign']));
|
||||
|
||||
$label = Labels::find_caption(Labels::feed_to_label_id($label_id), $_SESSION["uid"]);
|
||||
|
||||
$num_updated = 0;
|
||||
|
||||
if ($label) {
|
||||
|
||||
foreach ($article_ids as $id) {
|
||||
|
||||
if ($assign)
|
||||
Labels::add_article($id, $label, $_SESSION["uid"]);
|
||||
else
|
||||
Labels::remove_article($id, $label, $_SESSION["uid"]);
|
||||
|
||||
++$num_updated;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
$this->wrap(self::STATUS_OK, array("status" => "OK",
|
||||
"updated" => $num_updated));
|
||||
|
||||
}
|
||||
|
||||
function index($method) {
|
||||
$plugin = PluginHost::getInstance()->get_api_method(strtolower($method));
|
||||
|
||||
if ($plugin && method_exists($plugin, $method)) {
|
||||
$reply = $plugin->$method();
|
||||
|
||||
$this->wrap($reply[0], $reply[1]);
|
||||
|
||||
} else {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => 'UNKNOWN_METHOD', "method" => $method));
|
||||
}
|
||||
}
|
||||
|
||||
function shareToPublished() {
|
||||
$title = strip_tags(clean($_REQUEST["title"]));
|
||||
$url = strip_tags(clean($_REQUEST["url"]));
|
||||
$content = strip_tags(clean($_REQUEST["content"]));
|
||||
|
||||
if (Article::create_published_article($title, $url, $content, "", $_SESSION["uid"])) {
|
||||
$this->wrap(self::STATUS_OK, array("status" => 'OK'));
|
||||
} else {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => 'Publishing failed'));
|
||||
}
|
||||
}
|
||||
|
||||
static function api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested = false) {
|
||||
|
||||
$feeds = array();
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$limit = (int) $limit;
|
||||
$offset = (int) $offset;
|
||||
$cat_id = (int) $cat_id;
|
||||
|
||||
/* Labels */
|
||||
|
||||
if ($cat_id == -4 || $cat_id == -2) {
|
||||
$counters = Counters::getLabelCounters(true);
|
||||
|
||||
foreach (array_values($counters) as $cv) {
|
||||
|
||||
$unread = $cv["counter"];
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
|
||||
$row = array(
|
||||
"id" => (int) $cv["id"],
|
||||
"title" => $cv["description"],
|
||||
"unread" => $cv["counter"],
|
||||
"cat_id" => -2,
|
||||
);
|
||||
|
||||
array_push($feeds, $row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Virtual feeds */
|
||||
|
||||
if ($cat_id == -4 || $cat_id == -1) {
|
||||
foreach (array(-1, -2, -3, -4, -6, 0) as $i) {
|
||||
$unread = getFeedUnread($i);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
$title = Feeds::getFeedTitle($i);
|
||||
|
||||
$row = array(
|
||||
"id" => $i,
|
||||
"title" => $title,
|
||||
"unread" => $unread,
|
||||
"cat_id" => -1,
|
||||
);
|
||||
array_push($feeds, $row);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/* Child cats */
|
||||
|
||||
if ($include_nested && $cat_id) {
|
||||
$sth = $pdo->prepare("SELECT
|
||||
id, title, order_id FROM ttrss_feed_categories
|
||||
WHERE parent_cat = ? AND owner_uid = ? ORDER BY id, title");
|
||||
|
||||
$sth->execute([$cat_id, $_SESSION['uid']]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
$unread = getFeedUnread($line["id"], true) +
|
||||
Feeds::getCategoryChildrenUnread($line["id"]);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
$row = array(
|
||||
"id" => (int) $line["id"],
|
||||
"title" => $line["title"],
|
||||
"unread" => $unread,
|
||||
"is_cat" => true,
|
||||
"order_id" => (int) $line["order_id"]
|
||||
);
|
||||
array_push($feeds, $row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Real feeds */
|
||||
|
||||
if ($limit) {
|
||||
$limit_qpart = "LIMIT $limit OFFSET $offset";
|
||||
} else {
|
||||
$limit_qpart = "";
|
||||
}
|
||||
|
||||
if ($cat_id == -4 || $cat_id == -3) {
|
||||
$sth = $pdo->prepare("SELECT
|
||||
id, feed_url, cat_id, title, order_id, ".
|
||||
SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated
|
||||
FROM ttrss_feeds WHERE owner_uid = ?
|
||||
ORDER BY cat_id, title " . $limit_qpart);
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
|
||||
} else {
|
||||
|
||||
$sth = $pdo->prepare("SELECT
|
||||
id, feed_url, cat_id, title, order_id, ".
|
||||
SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated
|
||||
FROM ttrss_feeds WHERE
|
||||
(cat_id = :cat OR (:cat = 0 AND cat_id IS NULL))
|
||||
AND owner_uid = :uid
|
||||
ORDER BY cat_id, title " . $limit_qpart);
|
||||
$sth->execute([":uid" => $_SESSION['uid'], ":cat" => $cat_id]);
|
||||
}
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
|
||||
$unread = getFeedUnread($line["id"]);
|
||||
|
||||
$has_icon = Feeds::feedHasIcon($line['id']);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
|
||||
$row = array(
|
||||
"feed_url" => $line["feed_url"],
|
||||
"title" => $line["title"],
|
||||
"id" => (int)$line["id"],
|
||||
"unread" => (int)$unread,
|
||||
"has_icon" => $has_icon,
|
||||
"cat_id" => (int)$line["cat_id"],
|
||||
"last_updated" => (int) strtotime($line["last_updated"]),
|
||||
"order_id" => (int) $line["order_id"],
|
||||
);
|
||||
|
||||
array_push($feeds, $row);
|
||||
}
|
||||
}
|
||||
|
||||
return $feeds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
|
||||
*/
|
||||
static function api_get_headlines($feed_id, $limit, $offset,
|
||||
$filter, $is_cat, $show_excerpt, $show_content, $view_mode, $order,
|
||||
$include_attachments, $since_id,
|
||||
$search = "", $include_nested = false, $sanitize_content = true,
|
||||
$force_update = false, $excerpt_length = 100, $check_first_id = false, $skip_first_id_check = false) {
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
if ($force_update && $feed_id > 0 && is_numeric($feed_id)) {
|
||||
// Update the feed if required with some basic flood control
|
||||
|
||||
$sth = $pdo->prepare(
|
||||
"SELECT cache_images,".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated
|
||||
FROM ttrss_feeds WHERE id = ?");
|
||||
$sth->execute([$feed_id]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$last_updated = strtotime($row["last_updated"]);
|
||||
$cache_images = API::param_to_bool($row["cache_images"]);
|
||||
|
||||
if (!$cache_images && time() - $last_updated > 120) {
|
||||
RSSUtils::update_rss_feed($feed_id, true);
|
||||
} else {
|
||||
$sth = $pdo->prepare("UPDATE ttrss_feeds SET last_updated = '1970-01-01', last_update_started = '1970-01-01'
|
||||
WHERE id = ?");
|
||||
$sth->execute([$feed_id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$params = array(
|
||||
"feed" => $feed_id,
|
||||
"limit" => $limit,
|
||||
"view_mode" => $view_mode,
|
||||
"cat_view" => $is_cat,
|
||||
"search" => $search,
|
||||
"override_order" => $order,
|
||||
"offset" => $offset,
|
||||
"since_id" => $since_id,
|
||||
"include_children" => $include_nested,
|
||||
"check_first_id" => $check_first_id,
|
||||
"skip_first_id_check" => $skip_first_id_check
|
||||
);
|
||||
|
||||
$qfh_ret = Feeds::queryFeedHeadlines($params);
|
||||
|
||||
$result = $qfh_ret[0];
|
||||
$feed_title = $qfh_ret[1];
|
||||
$first_id = $qfh_ret[6];
|
||||
|
||||
$headlines = array();
|
||||
|
||||
$headlines_header = array(
|
||||
'id' => $feed_id,
|
||||
'first_id' => $first_id,
|
||||
'is_cat' => $is_cat);
|
||||
|
||||
if (!is_numeric($result)) {
|
||||
while ($line = $result->fetch()) {
|
||||
$line["content_preview"] = truncate_string(strip_tags($line["content"]), $excerpt_length);
|
||||
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_QUERY_HEADLINES) as $p) {
|
||||
$line = $p->hook_query_headlines($line, $excerpt_length, true);
|
||||
}
|
||||
|
||||
$is_updated = ($line["last_read"] == "" &&
|
||||
($line["unread"] != "t" && $line["unread"] != "1"));
|
||||
|
||||
$tags = explode(",", $line["tag_cache"]);
|
||||
|
||||
$label_cache = $line["label_cache"];
|
||||
$labels = false;
|
||||
|
||||
if ($label_cache) {
|
||||
$label_cache = json_decode($label_cache, true);
|
||||
|
||||
if ($label_cache) {
|
||||
if ($label_cache["no-labels"] == 1)
|
||||
$labels = array();
|
||||
else
|
||||
$labels = $label_cache;
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_array($labels)) $labels = Article::get_article_labels($line["id"]);
|
||||
|
||||
$headline_row = array(
|
||||
"id" => (int)$line["id"],
|
||||
"guid" => $line["guid"],
|
||||
"unread" => API::param_to_bool($line["unread"]),
|
||||
"marked" => API::param_to_bool($line["marked"]),
|
||||
"published" => API::param_to_bool($line["published"]),
|
||||
"updated" => (int)strtotime($line["updated"]),
|
||||
"is_updated" => $is_updated,
|
||||
"title" => $line["title"],
|
||||
"link" => $line["link"],
|
||||
"feed_id" => $line["feed_id"] ? $line['feed_id'] : 0,
|
||||
"tags" => $tags,
|
||||
);
|
||||
|
||||
if ($include_attachments)
|
||||
$headline_row['attachments'] = Article::get_article_enclosures(
|
||||
$line['id']);
|
||||
|
||||
if ($show_excerpt)
|
||||
$headline_row["excerpt"] = $line["content_preview"];
|
||||
|
||||
if ($show_content) {
|
||||
|
||||
if ($sanitize_content) {
|
||||
$headline_row["content"] = sanitize(
|
||||
$line["content"],
|
||||
API::param_to_bool($line['hide_images']),
|
||||
false, $line["site_url"], false, $line["id"]);
|
||||
} else {
|
||||
$headline_row["content"] = $line["content"];
|
||||
}
|
||||
}
|
||||
|
||||
// unify label output to ease parsing
|
||||
if ($labels["no-labels"] == 1) $labels = array();
|
||||
|
||||
$headline_row["labels"] = $labels;
|
||||
|
||||
$headline_row["feed_title"] = $line["feed_title"] ? $line["feed_title"] :
|
||||
$feed_title;
|
||||
|
||||
$headline_row["comments_count"] = (int)$line["num_comments"];
|
||||
$headline_row["comments_link"] = $line["comments"];
|
||||
|
||||
$headline_row["always_display_attachments"] = API::param_to_bool($line["always_display_enclosures"]);
|
||||
|
||||
$headline_row["author"] = $line["author"];
|
||||
|
||||
$headline_row["score"] = (int)$line["score"];
|
||||
$headline_row["note"] = $line["note"];
|
||||
$headline_row["lang"] = $line["lang"];
|
||||
|
||||
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_RENDER_ARTICLE_API) as $p) {
|
||||
$headline_row = $p->hook_render_article_api(array("headline" => $headline_row));
|
||||
}
|
||||
|
||||
$headline_row['content'] = rewrite_cached_urls($headline_row['content']);
|
||||
|
||||
array_push($headlines, $headline_row);
|
||||
}
|
||||
} else if (is_numeric($result) && $result == -1) {
|
||||
$headlines_header['first_id_changed'] = true;
|
||||
}
|
||||
|
||||
return array($headlines, $headlines_header);
|
||||
}
|
||||
|
||||
function unsubscribeFeed() {
|
||||
$feed_id = (int) clean($_REQUEST["feed_id"]);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE
|
||||
id = ? AND owner_uid = ?");
|
||||
$sth->execute([$feed_id, $_SESSION['uid']]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
Pref_Feeds::remove_feed($feed_id, $_SESSION["uid"]);
|
||||
$this->wrap(self::STATUS_OK, array("status" => "OK"));
|
||||
} else {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => "FEED_NOT_FOUND"));
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeToFeed() {
|
||||
$feed_url = clean($_REQUEST["feed_url"]);
|
||||
$category_id = (int) clean($_REQUEST["category_id"]);
|
||||
$login = clean($_REQUEST["login"]);
|
||||
$password = clean($_REQUEST["password"]);
|
||||
|
||||
if ($feed_url) {
|
||||
$rc = Feeds::subscribe_to_feed($feed_url, $category_id, $login, $password);
|
||||
|
||||
$this->wrap(self::STATUS_OK, array("status" => $rc));
|
||||
} else {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => 'INCORRECT_USAGE'));
|
||||
}
|
||||
}
|
||||
|
||||
function getFeedTree() {
|
||||
$include_empty = API::param_to_bool(clean($_REQUEST['include_empty']));
|
||||
|
||||
$pf = new Pref_Feeds($_REQUEST);
|
||||
|
||||
$_REQUEST['mode'] = 2;
|
||||
$_REQUEST['force_show_empty'] = $include_empty;
|
||||
|
||||
if ($pf){
|
||||
$data = $pf->makefeedtree();
|
||||
$this->wrap(self::STATUS_OK, array("categories" => $data));
|
||||
} else {
|
||||
$this->wrap(self::STATUS_ERR, array("error" =>
|
||||
'UNABLE_TO_INSTANTIATE_OBJECT'));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// only works for labels or uncategorized for the time being
|
||||
private function isCategoryEmpty($id) {
|
||||
|
||||
if ($id == -2) {
|
||||
$sth = $this->pdo->prepare("SELECT COUNT(id) AS count FROM ttrss_labels2
|
||||
WHERE owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
$row = $sth->fetch();
|
||||
|
||||
return $row["count"] == 0;
|
||||
|
||||
} else if ($id == 0) {
|
||||
$sth = $this->pdo->prepare("SELECT COUNT(id) AS count FROM ttrss_feeds
|
||||
WHERE cat_id IS NULL AND owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
$row = $sth->fetch();
|
||||
|
||||
return $row["count"] == 0;
|
||||
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
824
classes/article.php
Executable file
824
classes/article.php
Executable file
@@ -0,0 +1,824 @@
|
||||
<?php
|
||||
class Article extends Handler_Protected {
|
||||
|
||||
function csrf_ignore($method) {
|
||||
$csrf_ignored = array("redirect", "editarticletags");
|
||||
|
||||
return array_search($method, $csrf_ignored) !== false;
|
||||
}
|
||||
|
||||
function redirect() {
|
||||
$id = clean($_REQUEST['id']);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT link FROM ttrss_entries, ttrss_user_entries
|
||||
WHERE id = ? AND id = ref_id AND owner_uid = ?
|
||||
LIMIT 1");
|
||||
$sth->execute([$id, $_SESSION['uid']]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$article_url = $row['link'];
|
||||
$article_url = str_replace("\n", "", $article_url);
|
||||
|
||||
header("Location: $article_url");
|
||||
return;
|
||||
|
||||
} else {
|
||||
print_error(__("Article not found."));
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
function view() {
|
||||
$id = clean($_REQUEST["id"]);
|
||||
$cids = explode(",", clean($_REQUEST["cids"]));
|
||||
$mode = clean($_REQUEST["mode"]);
|
||||
|
||||
// in prefetch mode we only output requested cids, main article
|
||||
// just gets marked as read (it already exists in client cache)
|
||||
|
||||
$articles = array();
|
||||
|
||||
if ($mode == "") {
|
||||
array_push($articles, $this->format_article($id, false));
|
||||
} else if ($mode == "zoom") {
|
||||
array_push($articles, $this->format_article($id, true, true));
|
||||
} else if ($mode == "raw") {
|
||||
if (isset($_REQUEST['html'])) {
|
||||
header("Content-Type: text/html");
|
||||
print '<link rel="stylesheet" type="text/css" href="css/default.css"/>';
|
||||
}
|
||||
|
||||
$article = $this->format_article($id, false, isset($_REQUEST["zoom"]));
|
||||
print $article['content'];
|
||||
return;
|
||||
}
|
||||
|
||||
$this->catchupArticleById($id, 0);
|
||||
|
||||
if (!$_SESSION["bw_limit"]) {
|
||||
foreach ($cids as $cid) {
|
||||
if ($cid) {
|
||||
array_push($articles, $this->format_article($cid, false, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print json_encode($articles);
|
||||
} */
|
||||
|
||||
/*
|
||||
private function catchupArticleById($id, $cmode) {
|
||||
|
||||
if ($cmode == 0) {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
unread = false,last_read = NOW()
|
||||
WHERE ref_id = ? AND owner_uid = ?");
|
||||
} else if ($cmode == 1) {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
unread = true
|
||||
WHERE ref_id = ? AND owner_uid = ?");
|
||||
} else {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
unread = NOT unread,last_read = NOW()
|
||||
WHERE ref_id = ? AND owner_uid = ?");
|
||||
}
|
||||
|
||||
$sth->execute([$id, $_SESSION['uid']]);
|
||||
|
||||
$feed_id = $this->getArticleFeed($id);
|
||||
CCache::update($feed_id, $_SESSION["uid"]);
|
||||
}
|
||||
*/
|
||||
|
||||
static function create_published_article($title, $url, $content, $labels_str,
|
||||
$owner_uid) {
|
||||
|
||||
$guid = 'SHA1:' . sha1("ttshared:" . $url . $owner_uid); // include owner_uid to prevent global GUID clash
|
||||
|
||||
if (!$content) {
|
||||
$pluginhost = new PluginHost();
|
||||
$pluginhost->load_all(PluginHost::KIND_ALL, $owner_uid);
|
||||
$pluginhost->load_data();
|
||||
|
||||
foreach ($pluginhost->get_hooks(PluginHost::HOOK_GET_FULL_TEXT) as $p) {
|
||||
$extracted_content = $p->hook_get_full_text($url);
|
||||
|
||||
if ($extracted_content) {
|
||||
$content = $extracted_content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$content_hash = sha1($content);
|
||||
|
||||
if ($labels_str != "") {
|
||||
$labels = explode(",", $labels_str);
|
||||
} else {
|
||||
$labels = array();
|
||||
}
|
||||
|
||||
$rc = false;
|
||||
|
||||
if (!$title) $title = $url;
|
||||
if (!$title && !$url) return false;
|
||||
|
||||
if (filter_var($url, FILTER_VALIDATE_URL) === FALSE) return false;
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$pdo->beginTransaction();
|
||||
|
||||
// only check for our user data here, others might have shared this with different content etc
|
||||
$sth = $pdo->prepare("SELECT id FROM ttrss_entries, ttrss_user_entries WHERE
|
||||
guid = ? AND ref_id = id AND owner_uid = ? LIMIT 1");
|
||||
$sth->execute([$guid, $owner_uid]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$ref_id = $row['id'];
|
||||
|
||||
$sth = $pdo->prepare("SELECT int_id FROM ttrss_user_entries WHERE
|
||||
ref_id = ? AND owner_uid = ? LIMIT 1");
|
||||
$sth->execute([$ref_id, $owner_uid]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$int_id = $row['int_id'];
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_entries SET
|
||||
content = ?, content_hash = ? WHERE id = ?");
|
||||
$sth->execute([$content, $content_hash, $ref_id]);
|
||||
|
||||
if (DB_TYPE == "pgsql"){
|
||||
$sth = $pdo->prepare("UPDATE ttrss_entries
|
||||
SET tsvector_combined = to_tsvector( :ts_content)
|
||||
WHERE id = :id");
|
||||
$params = [
|
||||
":ts_content" => mb_substr(strip_tags($content ), 0, 900000),
|
||||
":id" => $ref_id];
|
||||
$sth->execute($params);
|
||||
}
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET published = true,
|
||||
last_published = NOW() WHERE
|
||||
int_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$int_id, $owner_uid]);
|
||||
|
||||
} else {
|
||||
|
||||
$sth = $pdo->prepare("INSERT INTO ttrss_user_entries
|
||||
(ref_id, uuid, feed_id, orig_feed_id, owner_uid, published, tag_cache, label_cache,
|
||||
last_read, note, unread, last_published)
|
||||
VALUES
|
||||
(?, '', NULL, NULL, ?, true, '', '', NOW(), '', false, NOW())");
|
||||
$sth->execute([$ref_id, $owner_uid]);
|
||||
}
|
||||
|
||||
if (count($labels) != 0) {
|
||||
foreach ($labels as $label) {
|
||||
Labels::add_article($ref_id, trim($label), $owner_uid);
|
||||
}
|
||||
}
|
||||
|
||||
$rc = true;
|
||||
|
||||
} else {
|
||||
$sth = $pdo->prepare("INSERT INTO ttrss_entries
|
||||
(title, guid, link, updated, content, content_hash, date_entered, date_updated)
|
||||
VALUES
|
||||
(?, ?, ?, NOW(), ?, ?, NOW(), NOW())");
|
||||
$sth->execute([$title, $guid, $url, $content, $content_hash]);
|
||||
|
||||
$sth = $pdo->prepare("SELECT id FROM ttrss_entries WHERE guid = ?");
|
||||
$sth->execute([$guid]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$ref_id = $row["id"];
|
||||
if (DB_TYPE == "pgsql"){
|
||||
$sth = $pdo->prepare("UPDATE ttrss_entries
|
||||
SET tsvector_combined = to_tsvector( :ts_content)
|
||||
WHERE id = :id");
|
||||
$params = [
|
||||
":ts_content" => mb_substr(strip_tags($content ), 0, 900000),
|
||||
":id" => $ref_id];
|
||||
$sth->execute($params);
|
||||
}
|
||||
$sth = $pdo->prepare("INSERT INTO ttrss_user_entries
|
||||
(ref_id, uuid, feed_id, orig_feed_id, owner_uid, published, tag_cache, label_cache,
|
||||
last_read, note, unread, last_published)
|
||||
VALUES
|
||||
(?, '', NULL, NULL, ?, true, '', '', NOW(), '', false, NOW())");
|
||||
$sth->execute([$ref_id, $owner_uid]);
|
||||
|
||||
if (count($labels) != 0) {
|
||||
foreach ($labels as $label) {
|
||||
Labels::add_article($ref_id, trim($label), $owner_uid);
|
||||
}
|
||||
}
|
||||
|
||||
$rc = true;
|
||||
}
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
return $rc;
|
||||
}
|
||||
|
||||
function editArticleTags() {
|
||||
|
||||
$param = clean($_REQUEST['param']);
|
||||
|
||||
$tags = Article::get_article_tags($param);
|
||||
|
||||
$tags_str = join(", ", $tags);
|
||||
|
||||
print_hidden("id", "$param");
|
||||
print_hidden("op", "article");
|
||||
print_hidden("method", "setArticleTags");
|
||||
|
||||
print "<header class='horizontal'>" . __("Tags for this article (separated by commas):")."</header>";
|
||||
|
||||
print "<section>";
|
||||
print "<textarea dojoType='dijit.form.SimpleTextarea' rows='4'
|
||||
style='height : 100px; font-size : 12px; width : 98%' id='tags_str'
|
||||
name='tags_str'>$tags_str</textarea>
|
||||
<div class='autocomplete' id='tags_choices'
|
||||
style='display:none'></div>";
|
||||
print "</section>";
|
||||
|
||||
print "<footer>";
|
||||
print "<button dojoType='dijit.form.Button'
|
||||
type='submit' class='alt-primary' onclick=\"dijit.byId('editTagsDlg').execute()\">".__('Save')."</button> ";
|
||||
print "<button dojoType='dijit.form.Button'
|
||||
onclick=\"dijit.byId('editTagsDlg').hide()\">".__('Cancel')."</button>";
|
||||
print "</footer>";
|
||||
|
||||
}
|
||||
|
||||
function setScore() {
|
||||
$ids = explode(",", clean($_REQUEST['id']));
|
||||
$score = (int)clean($_REQUEST['score']);
|
||||
|
||||
$ids_qmarks = arr_qmarks($ids);
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
score = ? WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
|
||||
$sth->execute(array_merge([$score], $ids, [$_SESSION['uid']]));
|
||||
|
||||
print json_encode(["id" => $ids, "score" => (int)$score]);
|
||||
}
|
||||
|
||||
function getScore() {
|
||||
$id = clean($_REQUEST['id']);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT score FROM ttrss_user_entries WHERE ref_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$id, $_SESSION['uid']]);
|
||||
$row = $sth->fetch();
|
||||
|
||||
$score = $row['score'];
|
||||
|
||||
print json_encode(["id" => $id, "score" => (int)$score]);
|
||||
}
|
||||
|
||||
|
||||
function setArticleTags() {
|
||||
|
||||
$id = clean($_REQUEST["id"]);
|
||||
|
||||
$tags_str = clean($_REQUEST["tags_str"]);
|
||||
$tags = array_unique(trim_array(explode(",", $tags_str)));
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT int_id FROM ttrss_user_entries WHERE
|
||||
ref_id = ? AND owner_uid = ? LIMIT 1");
|
||||
$sth->execute([$id, $_SESSION['uid']]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
|
||||
$tags_to_cache = array();
|
||||
|
||||
$int_id = $row['int_id'];
|
||||
|
||||
$sth = $this->pdo->prepare("DELETE FROM ttrss_tags WHERE
|
||||
post_int_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$int_id, $_SESSION['uid']]);
|
||||
|
||||
foreach ($tags as $tag) {
|
||||
$tag = Article::sanitize_tag($tag);
|
||||
|
||||
if (!Article::tag_is_valid($tag)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match("/^[0-9]*$/", $tag)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// print "<!-- $id : $int_id : $tag -->";
|
||||
|
||||
if ($tag != '') {
|
||||
$sth = $this->pdo->prepare("INSERT INTO ttrss_tags
|
||||
(post_int_id, owner_uid, tag_name)
|
||||
VALUES (?, ?, ?)");
|
||||
|
||||
$sth->execute([$int_id, $_SESSION['uid'], $tag]);
|
||||
}
|
||||
|
||||
array_push($tags_to_cache, $tag);
|
||||
}
|
||||
|
||||
/* update tag cache */
|
||||
|
||||
sort($tags_to_cache);
|
||||
$tags_str = join(",", $tags_to_cache);
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries
|
||||
SET tag_cache = ? WHERE ref_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$tags_str, $id, $_SESSION['uid']]);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
|
||||
$tags = Article::get_article_tags($id);
|
||||
$tags_str = $this->format_tags_string($tags, $id);
|
||||
$tags_str_full = join(", ", $tags);
|
||||
|
||||
if (!$tags_str_full) $tags_str_full = __("no tags");
|
||||
|
||||
print json_encode(array("id" => (int)$id,
|
||||
"content" => $tags_str, "content_full" => $tags_str_full));
|
||||
}
|
||||
|
||||
|
||||
function completeTags() {
|
||||
$search = clean($_REQUEST["search"]);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT DISTINCT tag_name FROM ttrss_tags
|
||||
WHERE owner_uid = ? AND
|
||||
tag_name LIKE ? ORDER BY tag_name
|
||||
LIMIT 10");
|
||||
|
||||
$sth->execute([$_SESSION['uid'], "$search%"]);
|
||||
|
||||
print "<ul>";
|
||||
while ($line = $sth->fetch()) {
|
||||
print "<li>" . $line["tag_name"] . "</li>";
|
||||
}
|
||||
print "</ul>";
|
||||
}
|
||||
|
||||
function assigntolabel() {
|
||||
return $this->labelops(true);
|
||||
}
|
||||
|
||||
function removefromlabel() {
|
||||
return $this->labelops(false);
|
||||
}
|
||||
|
||||
private function labelops($assign) {
|
||||
$reply = array();
|
||||
|
||||
$ids = explode(",", clean($_REQUEST["ids"]));
|
||||
$label_id = clean($_REQUEST["lid"]);
|
||||
|
||||
$label = Labels::find_caption($label_id, $_SESSION["uid"]);
|
||||
|
||||
$reply["info-for-headlines"] = array();
|
||||
|
||||
if ($label) {
|
||||
|
||||
foreach ($ids as $id) {
|
||||
|
||||
if ($assign)
|
||||
Labels::add_article($id, $label, $_SESSION["uid"]);
|
||||
else
|
||||
Labels::remove_article($id, $label, $_SESSION["uid"]);
|
||||
|
||||
$labels = $this->get_article_labels($id, $_SESSION["uid"]);
|
||||
|
||||
array_push($reply["info-for-headlines"],
|
||||
array("id" => $id, "labels" => $this->format_article_labels($labels)));
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
$reply["message"] = "UPDATE_COUNTERS";
|
||||
|
||||
print json_encode($reply);
|
||||
}
|
||||
|
||||
function getArticleFeed($id) {
|
||||
$sth = $this->pdo->prepare("SELECT feed_id FROM ttrss_user_entries
|
||||
WHERE ref_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$id, $_SESSION['uid']]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
return $row["feed_id"];
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static function format_article_enclosures($id, $always_display_enclosures,
|
||||
$article_content, $hide_images = false) {
|
||||
|
||||
$result = Article::get_article_enclosures($id);
|
||||
$rv = '';
|
||||
|
||||
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_FORMAT_ENCLOSURES) as $plugin) {
|
||||
$retval = $plugin->hook_format_enclosures($rv, $result, $id, $always_display_enclosures, $article_content, $hide_images);
|
||||
if (is_array($retval)) {
|
||||
$rv = $retval[0];
|
||||
$result = $retval[1];
|
||||
} else {
|
||||
$rv = $retval;
|
||||
}
|
||||
}
|
||||
unset($retval); // Unset to prevent breaking render if there are no HOOK_RENDER_ENCLOSURE hooks below.
|
||||
|
||||
if ($rv === '' && !empty($result)) {
|
||||
$entries_html = array();
|
||||
$entries = array();
|
||||
$entries_inline = array();
|
||||
|
||||
foreach ($result as $line) {
|
||||
|
||||
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_ENCLOSURE_ENTRY) as $plugin) {
|
||||
$line = $plugin->hook_enclosure_entry($line);
|
||||
}
|
||||
|
||||
$url = $line["content_url"];
|
||||
$ctype = $line["content_type"];
|
||||
$title = $line["title"];
|
||||
$width = $line["width"];
|
||||
$height = $line["height"];
|
||||
|
||||
if (!$ctype) $ctype = __("unknown type");
|
||||
|
||||
//$filename = substr($url, strrpos($url, "/")+1);
|
||||
$filename = basename($url);
|
||||
|
||||
$player = format_inline_player($url, $ctype);
|
||||
|
||||
if ($player) array_push($entries_inline, $player);
|
||||
|
||||
# $entry .= " <a target=\"_blank\" href=\"" . htmlspecialchars($url) . "\" rel=\"noopener noreferrer\">" .
|
||||
# $filename . " (" . $ctype . ")" . "</a>";
|
||||
|
||||
$entry = "<div onclick=\"popupOpenUrl('".htmlspecialchars($url)."')\"
|
||||
dojoType=\"dijit.MenuItem\">$filename ($ctype)</div>";
|
||||
|
||||
array_push($entries_html, $entry);
|
||||
|
||||
$entry = array();
|
||||
|
||||
$entry["type"] = $ctype;
|
||||
$entry["filename"] = $filename;
|
||||
$entry["url"] = $url;
|
||||
$entry["title"] = $title;
|
||||
$entry["width"] = $width;
|
||||
$entry["height"] = $height;
|
||||
|
||||
array_push($entries, $entry);
|
||||
}
|
||||
|
||||
if ($_SESSION['uid'] && !get_pref("STRIP_IMAGES") && !$_SESSION["bw_limit"]) {
|
||||
if ($always_display_enclosures ||
|
||||
!preg_match("/<img/i", $article_content)) {
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
|
||||
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_RENDER_ENCLOSURE) as $plugin)
|
||||
$retval = $plugin->hook_render_enclosure($entry, $hide_images);
|
||||
|
||||
|
||||
if ($retval) {
|
||||
$rv .= $retval;
|
||||
} else {
|
||||
|
||||
if (preg_match("/image/", $entry["type"])) {
|
||||
|
||||
if (!$hide_images) {
|
||||
$encsize = '';
|
||||
if ($entry['height'] > 0)
|
||||
$encsize .= ' height="' . intval($entry['height']) . '"';
|
||||
if ($entry['width'] > 0)
|
||||
$encsize .= ' width="' . intval($entry['width']) . '"';
|
||||
$rv .= "<p><img
|
||||
alt=\"".htmlspecialchars($entry["filename"])."\"
|
||||
src=\"" .htmlspecialchars($entry["url"]) . "\"
|
||||
" . $encsize . " /></p>";
|
||||
} else {
|
||||
$rv .= "<p><a target=\"_blank\" rel=\"noopener noreferrer\"
|
||||
href=\"".htmlspecialchars($entry["url"])."\"
|
||||
>" .htmlspecialchars($entry["url"]) . "</a></p>";
|
||||
}
|
||||
|
||||
if ($entry['title']) {
|
||||
$rv.= "<div class=\"enclosure_title\">${entry['title']}</div>";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count($entries_inline) > 0) {
|
||||
//$rv .= "<hr clear='both'/>";
|
||||
foreach ($entries_inline as $entry) { $rv .= $entry; };
|
||||
$rv .= "<br clear='both'/>";
|
||||
}
|
||||
|
||||
$rv .= "<div class=\"attachments\" dojoType=\"fox.form.DropDownButton\">".
|
||||
"<span>" . __('Attachments')."</span>";
|
||||
|
||||
$rv .= "<div dojoType=\"dijit.Menu\" style=\"display: none;\">";
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry["title"])
|
||||
$title = " — " . truncate_string($entry["title"], 30);
|
||||
else
|
||||
$title = "";
|
||||
|
||||
if ($entry["filename"])
|
||||
$filename = truncate_middle(htmlspecialchars($entry["filename"]), 60);
|
||||
else
|
||||
$filename = "";
|
||||
|
||||
$rv .= "<div onclick='popupOpenUrl(\"".htmlspecialchars($entry["url"])."\")'
|
||||
dojoType=\"dijit.MenuItem\">".$filename . $title."</div>";
|
||||
|
||||
};
|
||||
|
||||
$rv .= "</div>";
|
||||
$rv .= "</div>";
|
||||
}
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
static function get_article_tags($id, $owner_uid = 0, $tag_cache = false) {
|
||||
|
||||
$a_id = $id;
|
||||
|
||||
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT DISTINCT tag_name,
|
||||
owner_uid as owner FROM ttrss_tags
|
||||
WHERE post_int_id = (SELECT int_id FROM ttrss_user_entries WHERE
|
||||
ref_id = ? AND owner_uid = ? LIMIT 1) ORDER BY tag_name");
|
||||
|
||||
$tags = array();
|
||||
|
||||
/* check cache first */
|
||||
|
||||
if ($tag_cache === false) {
|
||||
$csth = $pdo->prepare("SELECT tag_cache FROM ttrss_user_entries
|
||||
WHERE ref_id = ? AND owner_uid = ?");
|
||||
$csth->execute([$id, $owner_uid]);
|
||||
|
||||
if ($row = $csth->fetch()) $tag_cache = $row["tag_cache"];
|
||||
}
|
||||
|
||||
if ($tag_cache) {
|
||||
$tags = explode(",", $tag_cache);
|
||||
} else {
|
||||
|
||||
/* do it the hard way */
|
||||
|
||||
$sth->execute([$a_id, $owner_uid]);
|
||||
|
||||
while ($tmp_line = $sth->fetch()) {
|
||||
array_push($tags, $tmp_line["tag_name"]);
|
||||
}
|
||||
|
||||
/* update the cache */
|
||||
|
||||
$tags_str = join(",", $tags);
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries
|
||||
SET tag_cache = ? WHERE ref_id = ?
|
||||
AND owner_uid = ?");
|
||||
$sth->execute([$tags_str, $id, $owner_uid]);
|
||||
}
|
||||
|
||||
return $tags;
|
||||
}
|
||||
|
||||
static function format_tags_string($tags) {
|
||||
if (!is_array($tags) || count($tags) == 0) {
|
||||
return __("no tags");
|
||||
} else {
|
||||
$maxtags = min(5, count($tags));
|
||||
$tags_str = "";
|
||||
|
||||
for ($i = 0; $i < $maxtags; $i++) {
|
||||
$tags_str .= "<a class=\"tag\" href=\"#\" onclick=\"Feeds.open({feed:'".$tags[$i]."'})\">" . $tags[$i] . "</a>, ";
|
||||
}
|
||||
|
||||
$tags_str = mb_substr($tags_str, 0, mb_strlen($tags_str)-2);
|
||||
|
||||
if (count($tags) > $maxtags)
|
||||
$tags_str .= ", …";
|
||||
|
||||
return $tags_str;
|
||||
}
|
||||
}
|
||||
|
||||
static function format_article_labels($labels) {
|
||||
|
||||
if (!is_array($labels)) return '';
|
||||
|
||||
$labels_str = "";
|
||||
|
||||
foreach ($labels as $l) {
|
||||
$labels_str .= sprintf("<div class='label'
|
||||
style='color : %s; background-color : %s'>%s</div>",
|
||||
$l[2], $l[3], $l[1]);
|
||||
}
|
||||
|
||||
return $labels_str;
|
||||
|
||||
}
|
||||
|
||||
static function format_article_note($id, $note, $allow_edit = true) {
|
||||
|
||||
if ($allow_edit) {
|
||||
$onclick = "onclick='Plugins.Note.edit($id)'";
|
||||
$note_class = 'editable';
|
||||
} else {
|
||||
$onclick = '';
|
||||
$note_class = '';
|
||||
}
|
||||
|
||||
return "<div class='article-note $note_class'>
|
||||
<i class='material-icons'>note</i>
|
||||
<div $onclick class='body'>$note</div>
|
||||
</div>";
|
||||
|
||||
return $str;
|
||||
}
|
||||
|
||||
static function get_article_enclosures($id) {
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT * FROM ttrss_enclosures
|
||||
WHERE post_id = ? AND content_url != ''");
|
||||
$sth->execute([$id]);
|
||||
|
||||
$rv = array();
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
|
||||
if (file_exists(CACHE_DIR . '/images/' . sha1($line["content_url"]))) {
|
||||
$line["content_url"] = get_self_url_prefix() . '/public.php?op=cached_url&hash=' . sha1($line["content_url"]);
|
||||
}
|
||||
|
||||
array_push($rv, $line);
|
||||
}
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
static function purge_orphans() {
|
||||
|
||||
// purge orphaned posts in main content table
|
||||
|
||||
if (DB_TYPE == "mysql")
|
||||
$limit_qpart = "LIMIT 5000";
|
||||
else
|
||||
$limit_qpart = "";
|
||||
|
||||
$pdo = Db::pdo();
|
||||
$res = $pdo->query("DELETE FROM ttrss_entries WHERE
|
||||
NOT EXISTS (SELECT ref_id FROM ttrss_user_entries WHERE ref_id = id) $limit_qpart");
|
||||
|
||||
if (Debug::enabled()) {
|
||||
$rows = $res->rowCount();
|
||||
Debug::log("Purged $rows orphaned posts.");
|
||||
}
|
||||
}
|
||||
|
||||
static function catchupArticlesById($ids, $cmode, $owner_uid = false) {
|
||||
|
||||
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$ids_qmarks = arr_qmarks($ids);
|
||||
|
||||
if ($cmode == 1) {
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
unread = true
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
} else if ($cmode == 2) {
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
unread = NOT unread,last_read = NOW()
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
} else {
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
unread = false,last_read = NOW()
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
}
|
||||
|
||||
$sth->execute(array_merge($ids, [$owner_uid]));
|
||||
|
||||
/* update ccache */
|
||||
|
||||
$sth = $pdo->prepare("SELECT DISTINCT feed_id FROM ttrss_user_entries
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
$sth->execute(array_merge($ids, [$owner_uid]));
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
CCache::update($line["feed_id"], $owner_uid);
|
||||
}
|
||||
}
|
||||
|
||||
static function getLastArticleId() {
|
||||
$pdo = DB::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT ref_id AS id FROM ttrss_user_entries
|
||||
WHERE owner_uid = ? ORDER BY ref_id DESC LIMIT 1");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
return $row['id'];
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
static function get_article_labels($id, $owner_uid = false) {
|
||||
$rv = array();
|
||||
|
||||
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT label_cache FROM
|
||||
ttrss_user_entries WHERE ref_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$id, $owner_uid]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$label_cache = $row["label_cache"];
|
||||
|
||||
if ($label_cache) {
|
||||
$tmp = json_decode($label_cache, true);
|
||||
|
||||
if (!$tmp || $tmp["no-labels"] == 1)
|
||||
return $rv;
|
||||
else
|
||||
return $tmp;
|
||||
}
|
||||
}
|
||||
|
||||
$sth = $pdo->prepare("SELECT DISTINCT label_id,caption,fg_color,bg_color
|
||||
FROM ttrss_labels2, ttrss_user_labels2
|
||||
WHERE id = label_id
|
||||
AND article_id = ?
|
||||
AND owner_uid = ?
|
||||
ORDER BY caption");
|
||||
$sth->execute([$id, $owner_uid]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
$rk = array(Labels::label_to_feed_id($line["label_id"]),
|
||||
$line["caption"], $line["fg_color"],
|
||||
$line["bg_color"]);
|
||||
array_push($rv, $rk);
|
||||
}
|
||||
|
||||
if (count($rv) > 0)
|
||||
Labels::update_cache($owner_uid, $id, $rv);
|
||||
else
|
||||
Labels::update_cache($owner_uid, $id, array("no-labels" => 1));
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
static function sanitize_tag($tag) {
|
||||
$tag = trim($tag);
|
||||
|
||||
$tag = mb_strtolower($tag, 'utf-8');
|
||||
|
||||
$tag = preg_replace('/[,\'\"\+\>\<]/', "", $tag);
|
||||
|
||||
if (DB_TYPE == "mysql") {
|
||||
$tag = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $tag);
|
||||
}
|
||||
|
||||
return $tag;
|
||||
}
|
||||
|
||||
static function tag_is_valid($tag) {
|
||||
if (!$tag || is_numeric($tag) || mb_strlen($tag) > 250)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
62
classes/auth/base.php
Normal file
62
classes/auth/base.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
class Auth_Base {
|
||||
private $pdo;
|
||||
|
||||
function __construct() {
|
||||
$this->pdo = Db::pdo();
|
||||
}
|
||||
|
||||
/**
|
||||
* @SuppressWarnings(unused)
|
||||
*/
|
||||
function check_password($owner_uid, $password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @SuppressWarnings(unused)
|
||||
*/
|
||||
function authenticate($login, $password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Auto-creates specified user if allowed by system configuration
|
||||
// Can be used instead of find_user_by_login() by external auth modules
|
||||
function auto_create_user($login, $password = false) {
|
||||
if ($login && defined('AUTH_AUTO_CREATE') && AUTH_AUTO_CREATE) {
|
||||
$user_id = $this->find_user_by_login($login);
|
||||
|
||||
if (!$password) $password = make_password();
|
||||
|
||||
if (!$user_id) {
|
||||
$salt = substr(bin2hex(get_random_bytes(125)), 0, 250);
|
||||
$pwd_hash = encrypt_password($password, $salt, true);
|
||||
|
||||
$sth = $this->pdo->prepare("INSERT INTO ttrss_users
|
||||
(login,access_level,last_login,created,pwd_hash,salt)
|
||||
VALUES (?, 0, null, NOW(), ?,?)");
|
||||
$sth->execute([$login, $pwd_hash, $salt]);
|
||||
|
||||
return $this->find_user_by_login($login);
|
||||
|
||||
} else {
|
||||
return $user_id;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->find_user_by_login($login);
|
||||
}
|
||||
|
||||
function find_user_by_login($login) {
|
||||
$sth = $this->pdo->prepare("SELECT id FROM ttrss_users WHERE
|
||||
login = ?");
|
||||
$sth->execute([$login]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
return $row["id"];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
163
classes/backend.php
Normal file
163
classes/backend.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
class Backend extends Handler {
|
||||
function loading() {
|
||||
header("Content-type: text/html");
|
||||
print __("Loading, please wait...") . " " .
|
||||
"<img src='images/indicator_tiny.gif'>";
|
||||
}
|
||||
|
||||
function digestTest() {
|
||||
if (isset($_SESSION['uid'])) {
|
||||
header("Content-type: text/html");
|
||||
|
||||
$rv = Digest::prepare_headlines_digest($_SESSION['uid'], 1, 1000);
|
||||
|
||||
print "<h1>HTML</h1>";
|
||||
print $rv[0];
|
||||
print "<h1>Plain text</h1>";
|
||||
print "<pre>".$rv[3]."</pre>";
|
||||
} else {
|
||||
print error_json(6);
|
||||
}
|
||||
}
|
||||
|
||||
private function display_main_help() {
|
||||
$info = get_hotkeys_info();
|
||||
$imap = get_hotkeys_map();
|
||||
$omap = array();
|
||||
|
||||
foreach ($imap[1] as $sequence => $action) {
|
||||
if (!isset($omap[$action])) $omap[$action] = array();
|
||||
|
||||
array_push($omap[$action], $sequence);
|
||||
}
|
||||
|
||||
print "<ul class='panel panel-scrollable hotkeys-help' style='height : 300px'>";
|
||||
|
||||
print "<h2>" . __("Keyboard Shortcuts") . "</h2>";
|
||||
|
||||
foreach ($info as $section => $hotkeys) {
|
||||
|
||||
print "<li><hr></li>";
|
||||
print "<li><h3>" . $section . "</h3></li>";
|
||||
|
||||
foreach ($hotkeys as $action => $description) {
|
||||
|
||||
if (is_array($omap[$action])) {
|
||||
foreach ($omap[$action] as $sequence) {
|
||||
if (strpos($sequence, "|") !== FALSE) {
|
||||
$sequence = substr($sequence,
|
||||
strpos($sequence, "|")+1,
|
||||
strlen($sequence));
|
||||
} else {
|
||||
$keys = explode(" ", $sequence);
|
||||
|
||||
for ($i = 0; $i < count($keys); $i++) {
|
||||
if (strlen($keys[$i]) > 1) {
|
||||
$tmp = '';
|
||||
foreach (str_split($keys[$i]) as $c) {
|
||||
switch ($c) {
|
||||
case '*':
|
||||
$tmp .= __('Shift') . '+';
|
||||
break;
|
||||
case '^':
|
||||
$tmp .= __('Ctrl') . '+';
|
||||
break;
|
||||
default:
|
||||
$tmp .= $c;
|
||||
}
|
||||
}
|
||||
$keys[$i] = $tmp;
|
||||
}
|
||||
}
|
||||
$sequence = join(" ", $keys);
|
||||
}
|
||||
|
||||
print "<li>";
|
||||
print "<div class='hk'><code>$sequence</code></div>";
|
||||
print "<div class='desc'>$description</div>";
|
||||
print "</li>";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print "</ul>";
|
||||
|
||||
|
||||
}
|
||||
|
||||
function help() {
|
||||
$topic = basename(clean($_REQUEST["topic"])); // only one for now
|
||||
|
||||
if ($topic == "main") {
|
||||
$info = get_hotkeys_info();
|
||||
$imap = get_hotkeys_map();
|
||||
$omap = array();
|
||||
|
||||
foreach ($imap[1] as $sequence => $action) {
|
||||
if (!isset($omap[$action])) $omap[$action] = array();
|
||||
|
||||
array_push($omap[$action], $sequence);
|
||||
}
|
||||
|
||||
print "<ul class='panel panel-scrollable hotkeys-help' style='height : 300px'>";
|
||||
|
||||
$cur_section = "";
|
||||
foreach ($info as $section => $hotkeys) {
|
||||
|
||||
if ($cur_section) print "<li> </li>";
|
||||
print "<li><h3>" . $section . "</h3></li>";
|
||||
$cur_section = $section;
|
||||
|
||||
foreach ($hotkeys as $action => $description) {
|
||||
|
||||
if (is_array($omap[$action])) {
|
||||
foreach ($omap[$action] as $sequence) {
|
||||
if (strpos($sequence, "|") !== FALSE) {
|
||||
$sequence = substr($sequence,
|
||||
strpos($sequence, "|")+1,
|
||||
strlen($sequence));
|
||||
} else {
|
||||
$keys = explode(" ", $sequence);
|
||||
|
||||
for ($i = 0; $i < count($keys); $i++) {
|
||||
if (strlen($keys[$i]) > 1) {
|
||||
$tmp = '';
|
||||
foreach (str_split($keys[$i]) as $c) {
|
||||
switch ($c) {
|
||||
case '*':
|
||||
$tmp .= __('Shift') . '+';
|
||||
break;
|
||||
case '^':
|
||||
$tmp .= __('Ctrl') . '+';
|
||||
break;
|
||||
default:
|
||||
$tmp .= $c;
|
||||
}
|
||||
}
|
||||
$keys[$i] = $tmp;
|
||||
}
|
||||
}
|
||||
$sequence = join(" ", $keys);
|
||||
}
|
||||
|
||||
print "<li>";
|
||||
print "<div class='hk'><code>$sequence</code></div>";
|
||||
print "<div class='desc'>$description</div>";
|
||||
print "</li>";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print "</ul>";
|
||||
}
|
||||
|
||||
print "<footer class='text-center'>";
|
||||
print "<button dojoType='dijit.form.Button'
|
||||
onclick=\"return dijit.byId('helpDlg').hide()\">".__('Close this window')."</button>";
|
||||
print "</footer>";
|
||||
|
||||
}
|
||||
}
|
||||
211
classes/ccache.php
Normal file
211
classes/ccache.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
class CCache {
|
||||
static function zero_all($owner_uid) {
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_counters_cache SET
|
||||
value = 0 WHERE owner_uid = ?");
|
||||
$sth->execute([$owner_uid]);
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_cat_counters_cache SET
|
||||
value = 0 WHERE owner_uid = ?");
|
||||
$sth->execute([$owner_uid]);
|
||||
}
|
||||
|
||||
static function remove($feed_id, $owner_uid, $is_cat = false) {
|
||||
|
||||
$feed_id = (int) $feed_id;
|
||||
|
||||
if (!$is_cat) {
|
||||
$table = "ttrss_counters_cache";
|
||||
} else {
|
||||
$table = "ttrss_cat_counters_cache";
|
||||
}
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("DELETE FROM $table WHERE
|
||||
feed_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$feed_id, $owner_uid]);
|
||||
|
||||
}
|
||||
|
||||
static function update_all($owner_uid) {
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
if (get_pref('ENABLE_FEED_CATS', $owner_uid)) {
|
||||
|
||||
$sth = $pdo->prepare("SELECT feed_id FROM ttrss_cat_counters_cache
|
||||
WHERE feed_id > 0 AND owner_uid = ?");
|
||||
$sth->execute([$owner_uid]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
CCache::update($line["feed_id"], $owner_uid, true);
|
||||
}
|
||||
|
||||
/* We have to manually include category 0 */
|
||||
|
||||
CCache::update(0, $owner_uid, true);
|
||||
|
||||
} else {
|
||||
$sth = $pdo->prepare("SELECT feed_id FROM ttrss_counters_cache
|
||||
WHERE feed_id > 0 AND owner_uid = ?");
|
||||
$sth->execute([$owner_uid]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
print CCache::update($line["feed_id"], $owner_uid);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
static function find($feed_id, $owner_uid, $is_cat = false,
|
||||
$no_update = false) {
|
||||
|
||||
// "" (null) is valid and should be cast to 0 (uncategorized)
|
||||
// everything else i.e. tags are not
|
||||
if (!is_numeric($feed_id) && $feed_id)
|
||||
return;
|
||||
|
||||
$feed_id = (int) $feed_id;
|
||||
|
||||
if (!$is_cat) {
|
||||
$table = "ttrss_counters_cache";
|
||||
} else {
|
||||
$table = "ttrss_cat_counters_cache";
|
||||
}
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT value FROM $table
|
||||
WHERE owner_uid = ? AND feed_id = ?
|
||||
LIMIT 1");
|
||||
|
||||
$sth->execute([$owner_uid, $feed_id]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
return $row["value"];
|
||||
} else {
|
||||
if ($no_update) {
|
||||
return -1;
|
||||
} else {
|
||||
return CCache::update($feed_id, $owner_uid, $is_cat);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static function update($feed_id, $owner_uid, $is_cat = false,
|
||||
$update_pcat = true, $pcat_fast = false) {
|
||||
|
||||
// "" (null) is valid and should be cast to 0 (uncategorized)
|
||||
// everything else i.e. tags are not
|
||||
if (!is_numeric($feed_id) && $feed_id)
|
||||
return;
|
||||
|
||||
$feed_id = (int) $feed_id;
|
||||
|
||||
$prev_unread = CCache::find($feed_id, $owner_uid, $is_cat, true);
|
||||
|
||||
/* When updating a label, all we need to do is recalculate feed counters
|
||||
* because labels are not cached */
|
||||
|
||||
if ($feed_id < 0) {
|
||||
CCache::update_all($owner_uid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$is_cat) {
|
||||
$table = "ttrss_counters_cache";
|
||||
} else {
|
||||
$table = "ttrss_cat_counters_cache";
|
||||
}
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
if ($is_cat && $feed_id >= 0) {
|
||||
/* Recalculate counters for child feeds */
|
||||
|
||||
if (!$pcat_fast) {
|
||||
$sth = $pdo->prepare("SELECT id FROM ttrss_feeds
|
||||
WHERE owner_uid = :uid AND
|
||||
(cat_id = :cat OR (:cat = 0 AND cat_id IS NULL))");
|
||||
$sth->execute([":uid" => $owner_uid, ":cat" => $feed_id]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
CCache::update((int)$line["id"], $owner_uid, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
$sth = $pdo->prepare("SELECT SUM(value) AS sv
|
||||
FROM ttrss_counters_cache, ttrss_feeds
|
||||
WHERE ttrss_feeds.id = feed_id AND
|
||||
(cat_id = :cat OR (:cat = 0 AND cat_id IS NULL)) AND
|
||||
ttrss_counters_cache.owner_uid = :uid AND
|
||||
ttrss_feeds.owner_uid = :uid");
|
||||
$sth->execute([":uid" => $owner_uid, ":cat" => $feed_id]);
|
||||
$row = $sth->fetch();
|
||||
|
||||
$unread = (int) $row["sv"];
|
||||
|
||||
} else {
|
||||
$unread = (int) Feeds::getFeedArticles($feed_id, $is_cat, true, $owner_uid);
|
||||
}
|
||||
|
||||
$tr_in_progress = false;
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
} catch (Exception $e) {
|
||||
$tr_in_progress = true;
|
||||
}
|
||||
|
||||
$sth = $pdo->prepare("SELECT feed_id FROM $table
|
||||
WHERE owner_uid = ? AND feed_id = ? LIMIT 1");
|
||||
$sth->execute([$owner_uid, $feed_id]);
|
||||
|
||||
if ($sth->fetch()) {
|
||||
|
||||
$sth = $pdo->prepare("UPDATE $table SET
|
||||
value = ?, updated = NOW() WHERE
|
||||
feed_id = ? AND owner_uid = ?");
|
||||
|
||||
$sth->execute([$unread, $feed_id, $owner_uid]);
|
||||
|
||||
} else {
|
||||
$sth = $pdo->prepare("INSERT INTO $table
|
||||
(feed_id, value, owner_uid, updated)
|
||||
VALUES
|
||||
(?, ?, ?, NOW())");
|
||||
$sth->execute([$feed_id, $unread, $owner_uid]);
|
||||
}
|
||||
|
||||
if (!$tr_in_progress) $pdo->commit();
|
||||
|
||||
if ($feed_id > 0 && $prev_unread != $unread) {
|
||||
|
||||
if (!$is_cat) {
|
||||
|
||||
/* Update parent category */
|
||||
|
||||
if ($update_pcat) {
|
||||
|
||||
$sth = $pdo->prepare("SELECT cat_id FROM ttrss_feeds
|
||||
WHERE owner_uid = ? AND id = ?");
|
||||
$sth->execute([$owner_uid, $feed_id]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
CCache::update((int)$row["cat_id"], $owner_uid, true, true, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ($feed_id < 0) {
|
||||
CCache::update_all($owner_uid);
|
||||
}
|
||||
|
||||
return $unread;
|
||||
}
|
||||
|
||||
}
|
||||
216
classes/counters.php
Normal file
216
classes/counters.php
Normal file
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
class Counters {
|
||||
|
||||
static function getAllCounters() {
|
||||
$data = Counters::getGlobalCounters();
|
||||
|
||||
$data = array_merge($data, Counters::getVirtCounters());
|
||||
$data = array_merge($data, Counters::getLabelCounters());
|
||||
$data = array_merge($data, Counters::getFeedCounters());
|
||||
$data = array_merge($data, Counters::getCategoryCounters());
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
static function getCategoryCounters() {
|
||||
$ret_arr = array();
|
||||
|
||||
/* Labels category */
|
||||
|
||||
$cv = array("id" => -2, "kind" => "cat",
|
||||
"counter" => Feeds::getCategoryUnread(-2));
|
||||
|
||||
array_push($ret_arr, $cv);
|
||||
|
||||
$pdo = DB::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT ttrss_feed_categories.id AS cat_id, value AS unread,
|
||||
(SELECT COUNT(id) FROM ttrss_feed_categories AS c2
|
||||
WHERE c2.parent_cat = ttrss_feed_categories.id) AS num_children
|
||||
FROM ttrss_feed_categories, ttrss_cat_counters_cache
|
||||
WHERE ttrss_cat_counters_cache.feed_id = ttrss_feed_categories.id AND
|
||||
ttrss_cat_counters_cache.owner_uid = ttrss_feed_categories.owner_uid AND
|
||||
ttrss_feed_categories.owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
$line["cat_id"] = (int) $line["cat_id"];
|
||||
|
||||
if ($line["num_children"] > 0) {
|
||||
$child_counter = Feeds::getCategoryChildrenUnread($line["cat_id"], $_SESSION["uid"]);
|
||||
} else {
|
||||
$child_counter = 0;
|
||||
}
|
||||
|
||||
$cv = array("id" => $line["cat_id"], "kind" => "cat",
|
||||
"counter" => $line["unread"] + $child_counter);
|
||||
|
||||
array_push($ret_arr, $cv);
|
||||
}
|
||||
|
||||
/* Special case: NULL category doesn't actually exist in the DB */
|
||||
|
||||
$cv = array("id" => 0, "kind" => "cat",
|
||||
"counter" => (int) CCache::find(0, $_SESSION["uid"], true));
|
||||
|
||||
array_push($ret_arr, $cv);
|
||||
|
||||
return $ret_arr;
|
||||
}
|
||||
|
||||
static function getGlobalCounters($global_unread = -1) {
|
||||
$ret_arr = array();
|
||||
|
||||
if ($global_unread == -1) {
|
||||
$global_unread = Feeds::getGlobalUnread();
|
||||
}
|
||||
|
||||
$cv = array("id" => "global-unread",
|
||||
"counter" => (int) $global_unread);
|
||||
|
||||
array_push($ret_arr, $cv);
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT COUNT(id) AS fn FROM
|
||||
ttrss_feeds WHERE owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
$row = $sth->fetch();
|
||||
|
||||
$subscribed_feeds = $row["fn"];
|
||||
|
||||
$cv = array("id" => "subscribed-feeds",
|
||||
"counter" => (int) $subscribed_feeds);
|
||||
|
||||
array_push($ret_arr, $cv);
|
||||
|
||||
return $ret_arr;
|
||||
}
|
||||
|
||||
static function getVirtCounters() {
|
||||
|
||||
$ret_arr = array();
|
||||
|
||||
for ($i = 0; $i >= -4; $i--) {
|
||||
|
||||
$count = getFeedUnread($i);
|
||||
|
||||
if ($i == 0 || $i == -1 || $i == -2)
|
||||
$auxctr = Feeds::getFeedArticles($i, false);
|
||||
else
|
||||
$auxctr = 0;
|
||||
|
||||
$cv = array("id" => $i,
|
||||
"counter" => (int) $count,
|
||||
"auxcounter" => (int) $auxctr);
|
||||
|
||||
// if (get_pref('EXTENDED_FEEDLIST'))
|
||||
// $cv["xmsg"] = getFeedArticles($i)." ".__("total");
|
||||
|
||||
array_push($ret_arr, $cv);
|
||||
}
|
||||
|
||||
$feeds = PluginHost::getInstance()->get_feeds(-1);
|
||||
|
||||
if (is_array($feeds)) {
|
||||
foreach ($feeds as $feed) {
|
||||
$cv = array("id" => PluginHost::pfeed_to_feed_id($feed['id']),
|
||||
"counter" => $feed['sender']->get_unread($feed['id']));
|
||||
|
||||
if (method_exists($feed['sender'], 'get_total'))
|
||||
$cv["auxcounter"] = $feed['sender']->get_total($feed['id']);
|
||||
|
||||
array_push($ret_arr, $cv);
|
||||
}
|
||||
}
|
||||
|
||||
return $ret_arr;
|
||||
}
|
||||
|
||||
static function getLabelCounters($descriptions = false) {
|
||||
|
||||
$ret_arr = array();
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT id,caption,SUM(CASE WHEN u1.unread = true THEN 1 ELSE 0 END) AS unread, COUNT(u1.unread) AS total
|
||||
FROM ttrss_labels2 LEFT JOIN ttrss_user_labels2 ON
|
||||
(ttrss_labels2.id = label_id)
|
||||
LEFT JOIN ttrss_user_entries AS u1 ON u1.ref_id = article_id
|
||||
WHERE ttrss_labels2.owner_uid = :uid AND u1.owner_uid = :uid
|
||||
GROUP BY ttrss_labels2.id,
|
||||
ttrss_labels2.caption");
|
||||
$sth->execute([":uid" => $_SESSION['uid']]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
|
||||
$id = Labels::label_to_feed_id($line["id"]);
|
||||
|
||||
$cv = array("id" => $id,
|
||||
"counter" => (int) $line["unread"],
|
||||
"auxcounter" => (int) $line["total"]);
|
||||
|
||||
if ($descriptions)
|
||||
$cv["description"] = $line["caption"];
|
||||
|
||||
array_push($ret_arr, $cv);
|
||||
}
|
||||
|
||||
return $ret_arr;
|
||||
}
|
||||
|
||||
static function getFeedCounters($active_feed = false) {
|
||||
|
||||
$ret_arr = array();
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT ttrss_feeds.id,
|
||||
ttrss_feeds.title,
|
||||
".SUBSTRING_FOR_DATE."(ttrss_feeds.last_updated,1,19) AS last_updated,
|
||||
last_error, value AS count
|
||||
FROM ttrss_feeds, ttrss_counters_cache
|
||||
WHERE ttrss_feeds.owner_uid = ?
|
||||
AND ttrss_counters_cache.owner_uid = ttrss_feeds.owner_uid
|
||||
AND ttrss_counters_cache.feed_id = ttrss_feeds.id");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
|
||||
$id = $line["id"];
|
||||
$count = $line["count"];
|
||||
$last_error = htmlspecialchars($line["last_error"]);
|
||||
|
||||
$last_updated = make_local_datetime($line['last_updated'], false);
|
||||
|
||||
if (Feeds::feedHasIcon($id)) {
|
||||
$has_img = filemtime(Feeds::getIconFile($id));
|
||||
} else {
|
||||
$has_img = false;
|
||||
}
|
||||
|
||||
if (date('Y') - date('Y', strtotime($line['last_updated'])) > 2)
|
||||
$last_updated = '';
|
||||
|
||||
$cv = array("id" => $id,
|
||||
"updated" => $last_updated,
|
||||
"counter" => (int) $count,
|
||||
"has_img" => (int) $has_img);
|
||||
|
||||
if ($last_error)
|
||||
$cv["error"] = $last_error;
|
||||
|
||||
// if (get_pref('EXTENDED_FEEDLIST'))
|
||||
// $cv["xmsg"] = getFeedArticles($id)." ".__("total");
|
||||
|
||||
if ($active_feed && $id == $active_feed)
|
||||
$cv["title"] = truncate_string($line["title"], 30);
|
||||
|
||||
array_push($ret_arr, $cv);
|
||||
|
||||
}
|
||||
|
||||
return $ret_arr;
|
||||
}
|
||||
|
||||
}
|
||||
116
classes/db.php
Executable file
116
classes/db.php
Executable file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
class Db
|
||||
{
|
||||
|
||||
/* @var Db $instance */
|
||||
private static $instance;
|
||||
|
||||
/* @var IDb $adapter */
|
||||
private $adapter;
|
||||
|
||||
private $link;
|
||||
|
||||
/* @var PDO $pdo */
|
||||
private $pdo;
|
||||
|
||||
private function __clone() {
|
||||
//
|
||||
}
|
||||
|
||||
private function legacy_connect() {
|
||||
|
||||
user_error("Legacy connect requested to " . DB_TYPE, E_USER_NOTICE);
|
||||
|
||||
$er = error_reporting(E_ALL);
|
||||
|
||||
switch (DB_TYPE) {
|
||||
case "mysql":
|
||||
$this->adapter = new Db_Mysqli();
|
||||
break;
|
||||
case "pgsql":
|
||||
$this->adapter = new Db_Pgsql();
|
||||
break;
|
||||
default:
|
||||
die("Unknown DB_TYPE: " . DB_TYPE);
|
||||
}
|
||||
|
||||
if (!$this->adapter) {
|
||||
print("Error initializing database adapter for " . DB_TYPE);
|
||||
exit(100);
|
||||
}
|
||||
|
||||
$this->link = $this->adapter->connect(DB_HOST, DB_USER, DB_PASS, DB_NAME, defined('DB_PORT') ? DB_PORT : "");
|
||||
|
||||
if (!$this->link) {
|
||||
print("Error connecting through adapter: " . $this->adapter->last_error());
|
||||
exit(101);
|
||||
}
|
||||
|
||||
error_reporting($er);
|
||||
}
|
||||
|
||||
// this really shouldn't be used unless a separate PDO connection is needed
|
||||
// normal usage is Db::pdo()->prepare(...) etc
|
||||
public function pdo_connect() {
|
||||
|
||||
$db_port = defined('DB_PORT') && DB_PORT ? ';port=' . DB_PORT : '';
|
||||
$db_host = defined('DB_HOST') && DB_HOST ? ';host=' . DB_HOST : '';
|
||||
|
||||
try {
|
||||
$pdo = new PDO(DB_TYPE . ':dbname=' . DB_NAME . $db_host . $db_port,
|
||||
DB_USER,
|
||||
DB_PASS);
|
||||
} catch (Exception $e) {
|
||||
print "<pre>Exception while creating PDO object:" . $e->getMessage() . "</pre>";
|
||||
exit(101);
|
||||
}
|
||||
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
if (DB_TYPE == "pgsql") {
|
||||
|
||||
$pdo->query("set client_encoding = 'UTF-8'");
|
||||
$pdo->query("set datestyle = 'ISO, european'");
|
||||
$pdo->query("set TIME ZONE 0");
|
||||
$pdo->query("set cpu_tuple_cost = 0.5");
|
||||
|
||||
} else if (DB_TYPE == "mysql") {
|
||||
$pdo->query("SET time_zone = '+0:0'");
|
||||
|
||||
if (defined('MYSQL_CHARSET') && MYSQL_CHARSET) {
|
||||
$pdo->query("SET NAMES " . MYSQL_CHARSET);
|
||||
}
|
||||
}
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
public static function instance() {
|
||||
if (self::$instance == null)
|
||||
self::$instance = new self();
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public static function get() {
|
||||
if (self::$instance == null)
|
||||
self::$instance = new self();
|
||||
|
||||
if (!self::$instance->adapter) {
|
||||
self::$instance->legacy_connect();
|
||||
}
|
||||
|
||||
return self::$instance->adapter;
|
||||
}
|
||||
|
||||
public static function pdo() {
|
||||
if (self::$instance == null)
|
||||
self::$instance = new self();
|
||||
|
||||
if (!self::$instance->pdo) {
|
||||
self::$instance->pdo = self::$instance->pdo_connect();
|
||||
}
|
||||
|
||||
return self::$instance->pdo;
|
||||
}
|
||||
}
|
||||
85
classes/db/mysqli.php
Normal file
85
classes/db/mysqli.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
class Db_Mysqli implements IDb {
|
||||
private $link;
|
||||
private $last_error;
|
||||
|
||||
function connect($host, $user, $pass, $db, $port) {
|
||||
if ($port)
|
||||
$this->link = mysqli_connect($host, $user, $pass, $db, $port);
|
||||
else
|
||||
$this->link = mysqli_connect($host, $user, $pass, $db);
|
||||
|
||||
if ($this->link) {
|
||||
$this->init();
|
||||
|
||||
return $this->link;
|
||||
} else {
|
||||
print("Unable to connect to database (as $user to $host, database $db): " . mysqli_connect_error());
|
||||
exit(102);
|
||||
}
|
||||
}
|
||||
|
||||
function escape_string($s, $strip_tags = true) {
|
||||
if ($strip_tags) $s = strip_tags($s);
|
||||
|
||||
return mysqli_real_escape_string($this->link, $s);
|
||||
}
|
||||
|
||||
function query($query, $die_on_error = true) {
|
||||
$result = @mysqli_query($this->link, $query);
|
||||
if (!$result) {
|
||||
$this->last_error = @mysqli_error($this->link);
|
||||
|
||||
@mysqli_query($this->link, "ROLLBACK");
|
||||
user_error("Query $query failed: " . ($this->link ? $this->last_error : "No connection"),
|
||||
$die_on_error ? E_USER_ERROR : E_USER_WARNING);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
function fetch_assoc($result) {
|
||||
return mysqli_fetch_assoc($result);
|
||||
}
|
||||
|
||||
|
||||
function num_rows($result) {
|
||||
return mysqli_num_rows($result);
|
||||
}
|
||||
|
||||
function fetch_result($result, $row, $param) {
|
||||
if (mysqli_data_seek($result, $row)) {
|
||||
$line = mysqli_fetch_assoc($result);
|
||||
return $line[$param];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
return mysqli_close($this->link);
|
||||
}
|
||||
|
||||
function affected_rows($result) {
|
||||
return mysqli_affected_rows($this->link);
|
||||
}
|
||||
|
||||
function last_error() {
|
||||
return mysqli_error($this->link);
|
||||
}
|
||||
|
||||
function last_query_error() {
|
||||
return $this->last_error;
|
||||
}
|
||||
|
||||
function init() {
|
||||
$this->query("SET time_zone = '+0:0'");
|
||||
|
||||
if (defined('MYSQL_CHARSET') && MYSQL_CHARSET) {
|
||||
mysqli_set_charset($this->link, MYSQL_CHARSET);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user