Initial commit

This commit is contained in:
gpw
2022-05-28 13:46:52 +08:00
commit 39f7e2792d
2110 changed files with 233010 additions and 0 deletions
Executable
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# This is a wrapper script around calling vendor/bin/phpcbf as it returns a exit 1
# if it fixes anything, which does not mesh well with CI pipelines.
# See https://github.com/squizlabs/PHP_CodeSniffer/issues/1818#issuecomment-354420927
root=$( dirname $0 )/..
$root/vendor/bin/phpcbf $@
exit=$?
if [[ $exit == 1 ]]; then
exit=0
fi
exit $exit
Executable
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# This is a wrapper script around calling vendor/bin/phpcs as it will return
# 0 when no errors or warnings are found, 1 for only warnings, and 2 if any errors
# are found. We do not want to fail our CI pipeline on warnings, but still want to
# show them.
# See https://github.com/squizlabs/PHP_CodeSniffer/issues/1818#issuecomment-354420927
root=$( dirname $0 )/..
$root/vendor/bin/phpcs $@
exit=$?
if [[ $exit == 1 ]]; then
exit=0
fi
exit $exit
+6
View File
@@ -0,0 +1,6 @@
# this is only for the mysqld standalone daemon
[mysqld]
sql_mode = ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION
innodb_strict_mode = 0;
character-set-server=utf8
+29
View File
@@ -0,0 +1,29 @@
FROM debian:buster-slim
ENV SPHINX_VERSION 2.2.11
ENV SPHINX_FULL_STRING ${SPHINX_VERSION}-release
RUN apt-get update && apt-get install -y --no-install-recommends \
cron \
curl \
default-libmysqlclient-dev \
mariadb-client \
sphinxsearch \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -pv /var/lib/sphinxsearch/data/ /var/lib/sphinxsearch/conf/
VOLUME /var/lib/sphinxsearch/data/
VOLUME /var/lib/sphinxsearch/conf/
COPY crontab /var/lib/sphinxsearch/conf/
COPY entrypoint.sh /var/lib/sphinxsearch/conf/
# redirect logs to stdout
RUN ln -sv /dev/stdout /var/log/sphinxsearch/query.log \
&& ln -sv /dev/stdout /var/log/sphinxsearch/searchd.log
EXPOSE 36307
ENTRYPOINT [ "/bin/bash", "/var/lib/sphinxsearch/conf/entrypoint.sh" ]
+2
View File
@@ -0,0 +1,2 @@
* * * * * /usr/bin/indexer -c /var/lib/sphinxsearch/conf/sphinx.conf --rotate delta
5 * * * * /usr/bin/indexer -c /var/lib/sphinxsearch/conf/sphinx.conf --rotate --all
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Wait for MySQL...
counter=1
while ! mysql -h mysql -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "show databases;" > /dev/null 2>&1; do
sleep 1
counter=$((counter + 1))
if [ $((counter % 20)) -eq 0 ]; then
mysql -h mysql -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "show databases;"
>&2 echo "Still waiting for MySQL (Count: $counter)."
fi
done
counter=1
while ! curl --fail http://web > /dev/null 2>&1; do
sleep 1
counter=$((counter + 1))
if [ $((counter % 20)) -eq 0 ]; then
>&2 echo "Still waiting for Web (Count: $counter)."
fi
done
indexer -c /var/lib/sphinxsearch/conf/sphinx.conf --all
service cron start
crontab /var/lib/sphinxsearch/conf/crontab
searchd --nodetach --config /var/lib/sphinxsearch/conf/sphinx.conf
+279
View File
@@ -0,0 +1,279 @@
# Sphinx 2.2.9
# WARNING: key 'enable_star' was permanently removed from Sphinx configuration. Refer to documentation for details.
# WARNING: key 'enable_star' was permanently removed from Sphinx configuration. Refer to documentation for details.
# WARNING: key 'max_matches' was permanently removed from Sphinx configuration. Refer to documentation for details.
# ERROR: unknown key name 'compat_sphinxql_magics' in /etc/sphinxsearch/sphinx.conf line 481 col 24.
# FATAL: failed to parse config file '/etc/sphinxsearch/sphinx.conf'
# the above error was fixed by commenting compat_sphinxql_magics
source connect {
type = mysql
sql_host = mysql
sql_user = gazelle
sql_pass = password
sql_db = gazelle
sql_port = 3306
}
source torrents_base : connect {
sql_attr_uint = groupid
sql_attr_uint = time
sql_attr_uint = categoryid
sql_attr_uint = releasetype
sql_attr_bigint = size
sql_attr_uint = snatched
sql_attr_uint = seeders
sql_attr_uint = leechers
sql_attr_uint = year
sql_attr_bool = scene
sql_attr_uint = freetorrent
sql_attr_float = imdbrating
sql_attr_float = doubanrating
sql_attr_float = rtrating
sql_attr_uint = diy
sql_attr_uint = buy
sql_attr_uint = chinesedubbed
sql_attr_uint = specialsub
}
source torrents : torrents_base {
#By inheriting from torrents_base, we keep all the connection info
sql_query_pre = SET group_concat_max_len = 101400
sql_query_pre = SET @starttime = NOW()
sql_query_pre = SET NAMES UTF8
sql_query_pre = REPLACE INTO sphinx_index_last_pos VALUES ('torrents', UNIX_TIMESTAMP(@starttime))
sql_query_pre = TRUNCATE sphinx_tg
sql_query_pre = INSERT INTO sphinx_tg \
(id, name, year, catid, reltype, \
tags, imdbid, imdbrating, doubanrating, region, language, rtrating) \
SELECT tg.ID, CONCAT_WS(' ', tg.Name, tg.SubName), tg.Year, \
tg.Categoryid, tg.Releasetype,\
replace(group_concat(t.Name SEPARATOR ' '), '.', '_'), \
tg.IMDBID, tg.IMDBRating, tg.DoubanRating, tg.Region, tg.Language, replace(tg.RTRating,"%","")/100 \
FROM torrents_group tg \
INNER JOIN torrents_tags tt ON (tt.GroupID = tg.ID) \
INNER JOIN tags t ON (t.ID = tt.TagID) \
WHERE tg.time < @starttime \
GROUP BY tg.ID
sql_query_pre = TRUNCATE sphinx_t
sql_query_pre = INSERT INTO sphinx_t \
(id, gid, size, snatched, seeders, leechers, time, scene, \
freetorrent, description, \
filelist, uid, source, codec, container, resolution, subtitles, processing, remtitle, \
diy, buy, chinesedubbed, specialsub) \
SELECT t.ID, t.GroupID, t.Size, t.Snatched, t.Seeders, t.Leechers, UNIX_TIMESTAMP(t.Time), \
CAST(t.Scene AS CHAR), CAST(t.FreeTorrent AS CHAR), t.Description, \
t.FileList, t.UserID, t.Source, t.Codec, t.Container, t.Resolution, t.Subtitles, t.Processing, t.RemasterTitle, \
t.Diy, t.Buy, t.ChineseDubbed, t.SpecialSub \
FROM torrents t \
WHERE t.Time < @starttime
sql_query_pre = TRUNCATE sphinx_a
sql_query_pre = INSERT INTO sphinx_a \
(gid, aname) \
SELECT GroupID, GROUP_CONCAT(aa.Name SEPARATOR ' ') \
FROM torrents_artists AS ta \
JOIN artists_alias AS aa ON (ta.ArtistID = aa.ArtistID) \
WHERE Importance IN ('1','2','3','4','5','6') \
GROUP BY ta.groupid \
ORDER BY NULL
sql_query = SELECT t.id, g.id AS groupid, g.name AS groupname, \
tags AS taglist, year, year AS yearfulltext, \
catid AS categoryid, t.time, reltype AS releasetype, \
size, snatched, seeders, leechers, \
scene, freetorrent, description, remtitle, \
source, codec, container, resolution, processing,\
language, region, imdbid, imdbrating, doubanrating, rtrating, subtitles,\
diy, buy, chinesedubbed, specialsub, \
REPLACE(filelist, '_', ' ') AS filelist \
FROM sphinx_t AS t \
JOIN sphinx_tg AS g ON t.gid = g.id
sql_joined_field = artistname from query; \
SELECT t.id, aname FROM sphinx_a JOIN sphinx_t AS t USING(gid) ORDER BY t.id ASC;
sql_query_post_index = DELETE FROM sphinx_delta WHERE Time <= \
(SELECT id FROM sphinx_index_last_pos WHERE type = 'torrents')
}
index torrents {
source = torrents
path = /var/lib/sphinxsearch/data/torrents
docinfo = extern ##### 文档信息存储方式
preopen = 1
morphology = none
phrase_boundary = U+F7 # This needs to the the same as the file delimiter in classes/torrents.class.php
phrase_boundary_step = 50
charset_type = utf-8
min_word_len = 1
min_prefix_len = 0
min_infix_len = 1
ngram_len = 1
charset_table = U+FF10..U+FF19->0..9, 0..9, U+FF41..U+FF5A->a..z, U+FF21..U+FF3A->a..z,\
A..Z->a..z, a..z, U+0149, U+017F, U+0138, U+00DF, U+00FF, U+00C0..U+00D6->U+00E0..U+00F6,\
U+00E0..U+00F6, U+00D8..U+00DE->U+00F8..U+00FE, U+00F8..U+00FE, U+0100->U+0101, U+0101,\
U+0102->U+0103, U+0103, U+0104->U+0105, U+0105, U+0106->U+0107, U+0107, U+0108->U+0109,\
U+0109, U+010A->U+010B, U+010B, U+010C->U+010D, U+010D, U+010E->U+010F, U+010F,\
U+0110->U+0111, U+0111, U+0112->U+0113, U+0113, U+0114->U+0115, U+0115, \
U+0116->U+0117,U+0117, U+0118->U+0119, U+0119, U+011A->U+011B, U+011B, U+011C->U+011D,\
U+011D,U+011E->U+011F, U+011F, U+0130->U+0131, U+0131, U+0132->U+0133, U+0133, \
U+0134->U+0135,U+0135, U+0136->U+0137, U+0137, U+0139->U+013A, U+013A, U+013B->U+013C, \
U+013C,U+013D->U+013E, U+013E, U+013F->U+0140, U+0140, U+0141->U+0142, U+0142, \
U+0143->U+0144,U+0144, U+0145->U+0146, U+0146, U+0147->U+0148, U+0148, U+014A->U+014B, \
U+014B,U+014C->U+014D, U+014D, U+014E->U+014F, U+014F, U+0150->U+0151, U+0151, \
U+0152->U+0153,U+0153, U+0154->U+0155, U+0155, U+0156->U+0157, U+0157, U+0158->U+0159,\
U+0159,U+015A->U+015B, U+015B, U+015C->U+015D, U+015D, U+015E->U+015F, U+015F, \
U+0160->U+0161,U+0161, U+0162->U+0163, U+0163, U+0164->U+0165, U+0165, U+0166->U+0167, \
U+0167,U+0168->U+0169, U+0169, U+016A->U+016B, U+016B, U+016C->U+016D, U+016D, \
U+016E->U+016F,U+016F, U+0170->U+0171, U+0171, U+0172->U+0173, U+0173, U+0174->U+0175,\
U+0175,U+0176->U+0177, U+0177, U+0178->U+00FF, U+00FF, U+0179->U+017A, U+017A, \
U+017B->U+017C,U+017C, U+017D->U+017E, U+017E, U+0410..U+042F->U+0430..U+044F, \
U+0430..U+044F,U+05D0..U+05EA, U+0531..U+0556->U+0561..U+0586, U+0561..U+0587, \
U+0621..U+063A, U+01B9,U+01BF, U+0640..U+064A, U+0660..U+0669, U+066E, U+066F, \
U+0671..U+06D3, U+06F0..U+06FF,U+0904..U+0939, U+0958..U+095F, U+0960..U+0963, \
U+0966..U+096F, U+097B..U+097F,U+0985..U+09B9, U+09CE, U+09DC..U+09E3, U+09E6..U+09EF, \
U+0A05..U+0A39, U+0A59..U+0A5E,U+0A66..U+0A6F, U+0A85..U+0AB9, U+0AE0..U+0AE3, \
U+0AE6..U+0AEF, U+0B05..U+0B39,U+0B5C..U+0B61, U+0B66..U+0B6F, U+0B71, U+0B85..U+0BB9, \
U+0BE6..U+0BF2, U+0C05..U+0C39,U+0C66..U+0C6F, U+0C85..U+0CB9, U+0CDE..U+0CE3, \
U+0CE6..U+0CEF, U+0D05..U+0D39, U+0D60,U+0D61, U+0D66..U+0D6F, U+0D85..U+0DC6, \
U+1900..U+1938, U+1946..U+194F, U+A800..U+A805,U+A807..U+A822, U+0386->U+03B1, \
U+03AC->U+03B1, U+0388->U+03B5, U+03AD->U+03B5,U+0389->U+03B7, U+03AE->U+03B7, \
U+038A->U+03B9, U+0390->U+03B9, U+03AA->U+03B9,U+03AF->U+03B9, U+03CA->U+03B9, \
U+038C->U+03BF, U+03CC->U+03BF, U+038E->U+03C5,U+03AB->U+03C5, U+03B0->U+03C5, \
U+03CB->U+03C5, U+03CD->U+03C5, U+038F->U+03C9,U+03CE->U+03C9, U+03C2->U+03C3, \
U+0391..U+03A1->U+03B1..U+03C1,U+03A3..U+03A9->U+03C3..U+03C9, U+03B1..U+03C1, \
U+03C3..U+03C9, U+0E01..U+0E2E,U+0E30..U+0E3A, U+0E40..U+0E45, U+0E47, U+0E50..U+0E59, \
U+A000..U+A48F, U+4E00..U+9FBF,U+3400..U+4DBF, U+20000..U+2A6DF, U+F900..U+FAFF, \
U+2F800..U+2FA1F, U+2E80..U+2EFF,U+2F00..U+2FDF, U+3100..U+312F, U+31A0..U+31BF, \
U+3040..U+309F, U+30A0..U+30FF,U+31F0..U+31FF, U+AC00..U+D7AF, U+1100..U+11FF, \
U+3130..U+318F, U+A000..U+A48F,U+A490..U+A4CF
blend_chars = !, ", U+23, $, %, &, ', (, ), *, +, U+2C, -, ., /, :, U+3B, <, =, >, ?, @, U+5B, U+5C, U+5D, ^, U+60, U+7C, U+7E, U+A1..U+BF
blend_mode = trim_none, trim_head, trim_tail, trim_both
ngram_chars = U+4E00..U+9FBF, U+3400..U+4DBF, U+20000..U+2A6DF, U+F900..U+FAFF,\
U+2F800..U+2FA1F, U+2E80..U+2EFF, U+2F00..U+2FDF, U+3100..U+312F, U+31A0..U+31BF,\
U+3040..U+309F, U+30A0..U+30FF, U+31F0..U+31FF, U+AC00..U+D7AF, U+1100..U+11FF,\
U+3130..U+318F, U+A000..U+A48F, U+A490..U+A4CF
}
source delta : torrents_base {
sql_query = SELECT *, Year AS yearfulltext FROM sphinx_delta WHERE Size > 0;
sql_query_killlist = SELECT ID FROM sphinx_delta
}
index delta : torrents {
source = delta
path = /var/lib/sphinxsearch/data/delta
}
source requests_base : connect {
sql_attr_uint = UserID
sql_attr_uint = TimeAdded
sql_attr_uint = LastVote
sql_attr_uint = CategoryID
sql_attr_uint = Year
sql_attr_uint = ReleaseType
sql_attr_uint = FillerID
sql_attr_uint = TorrentID
sql_attr_uint = TimeFilled
sql_attr_uint = Visible
sql_attr_uint = Votes
sql_attr_uint = Bounty
}
source requests : requests_base {
sql_query_pre = TRUNCATE TABLE sphinx_requests
sql_query_pre = SET group_concat_max_len = 10140
sql_query_pre = SET @starttime = NOW()
sql_query_pre = REPLACE INTO sphinx_index_last_pos VALUES ('requests', UNIX_TIMESTAMP(@starttime))
sql_query_pre = INSERT INTO sphinx_requests ( \
ID, UserID, TimeAdded, LastVote, CategoryID, Title, \
Year, ReleaseType, \
CodecList, SourceList, ContainerList, ResolutionList, FillerID, \
TorrentID, TimeFilled, Visible, Votes, Bounty ) \
SELECT \
r.ID, r.UserID, UNIX_TIMESTAMP(TimeAdded), \
UNIX_TIMESTAMP(LastVote), CategoryID, Title, Year, \
ReleaseType, \
CodecList, SourceList, ContainerList, ResolutionList, FillerID, TorrentID, \
UNIX_TIMESTAMP(TimeFilled), Visible, \
COUNT(rv.RequestID), SUM(rv.Bounty) >> 10 \
FROM requests AS r \
JOIN requests_votes AS rv ON rv.RequestID = r.ID \
GROUP BY rv.RequestID
sql_query_pre = INSERT INTO sphinx_requests ( \
ID, ArtistList ) \
SELECT \
RequestID, \
GROUP_CONCAT(aa.Name SEPARATOR ' ') \
FROM requests_artists AS ra \
JOIN artists_alias AS aa ON aa.AliasID = ra.AliasID \
JOIN requests AS r ON r.ID = ra.RequestID \
WHERE TimeAdded <= @starttime \
GROUP BY r.ID \
ON DUPLICATE KEY UPDATE ArtistList = VALUES(ArtistList)
sql_query = SELECT ID, UserID, TimeAdded, LastVote, CategoryID, Title, \
Year, ArtistList, ReleaseType, FillerID, \
TorrentID, TimeFilled, Visible, Votes, Bounty, \
Year AS YearFullText \
FROM sphinx_requests
sql_joined_field = taglist from query; \
SELECT rt.RequestID, REPLACE(t.Name, '.', '_') \
FROM requests_tags AS rt \
JOIN tags AS t ON TagID = ID \
ORDER BY requestid ASC;
sql_attr_multi = uint Voter from query; \
SELECT RequestID AS ID, UserID FROM requests_votes
sql_attr_multi = uint Bookmarker from query; \
SELECT RequestID AS ID, UserID FROM bookmarks_requests
sql_query_post_index = DELETE FROM sphinx_requests_delta WHERE TimeAdded <= \
(SELECT ID FROM sphinx_index_last_pos WHERE type = 'requests')
}
source requests_delta : requests_base {
sql_query = SELECT ID, UserID, TimeAdded, LastVote, CategoryID, Title, TagList, \
Year, ArtistList, ReleaseType, FillerID, \
TorrentID, TimeFilled, Visible, Votes, Bounty, \
Year AS YearFullText \
FROM sphinx_requests_delta
sql_query_killlist = SELECT ID FROM sphinx_requests_delta
sql_attr_multi = uint Voter from query; \
SELECT v.RequestID, v.UserID FROM requests_votes AS v \
JOIN sphinx_requests_delta AS d ON d.ID = v.RequestID
sql_attr_multi = uint Bookmarker from query; \
SELECT b.RequestID, b.UserID FROM bookmarks_requests AS b \
JOIN sphinx_requests_delta AS d ON d.ID = b.RequestID
}
index requests : torrents {
source = requests
path = /var/lib/sphinxsearch/data/requests
infix_fields = taglist
min_infix_len = 3
}
index requests_delta : requests {
source = requests_delta
path = /var/lib/sphinxsearch/data/requests_delta
}
source log : connect {
sql_attr_uint = Time
sql_query = SELECT ID, UNIX_TIMESTAMP(Time) AS Time, Message FROM log
sql_query_post_index = REPLACE INTO sphinx_index_last_pos VALUES ('log', $maxid)
}
source log_delta : log {
sql_query_pre = SELECT ID FROM sphinx_index_last_pos WHERE type = 'log' INTO @lastid
sql_query = SELECT ID, UNIX_TIMESTAMP(Time) AS Time, Message FROM log WHERE ID > @lastid
sql_query_post_index = SET @nothing = 0
}
index log : torrents {
source = log
path = /var/lib/sphinxsearch/data/log
min_word_len = 1
min_infix_len = 0
infix_fields =
}
index log_delta : log {
source = log_delta
path = /var/lib/sphinxsearch/data/log_delta
}
indexer {
mem_limit = 128M
}
searchd {
listen = 9312
listen = 9306:mysql41
log = /var/log/sphinxsearch/searchd.log
query_log = /var/log/sphinxsearch/query.log
pid_file = /var/run/sphinxsearch/searchd.pid
mva_updates_pool = 1M
#compat_sphinxql_magics = 0
}
+2
View File
@@ -0,0 +1,2 @@
*/15 * * * * /usr/bin/php /var/www/scripts/schedule.php OL9n0m2JxhBxYyMvXWJg >> /tmp/schedule.log
10-59/15 * * * * /usr/bin/php /var/www/scripts/peerupdate.php OL9n0m2JxhBxYyMvXWJg >> /tmp/peerupdate.log
+59
View File
@@ -0,0 +1,59 @@
#!/bin/bash
run_service()
{
/etc/init.d/$1 start || exit 1
}
# We'll need these anyway so why not kill some time while waiting on MySQL to be ready
su -c 'echo -e "====== Composer Install ======"; \
composer --version; \
composer install; \
echo -e "\n====== Yarn Install ======"; \
yarn; \
echo -e "\n====== Yarn Start ======"; \
yarn start & \
' gazelle
# Wait for MySQL...
counter=1
while ! mysql -h mysql -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "show databases;" > /dev/null 2>&1; do
sleep 1
counter=$((counter + 1))
if [ $((counter % 20)) -eq 0 ]; then
mysql -h mysql -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "show databases;"
>&2 echo "Still waiting for MySQL (Count: $counter)."
fi;
done
if [ ! -f /var/www/classes/config.php ]; then
bash /var/www/.docker/web/generate-config.sh
chmod 664 /var/www/classes/config.php
chown -R gazelle:gazelle /var/www/classes/config.php
fi
echo "Run migrations..."
if ! FKEY_MY_DATABASE=1 LOCK_MY_DATABASE=1 /var/www/vendor/bin/phinx migrate; then
echo "PHINX FAILED TO RUN MIGRATIONS"
exit 1
fi
if [ ! -f /etc/php/7.3/cli/conf.d/99-boris.ini ]; then
echo "Initialize Boris..."
grep '^disable_functions' /etc/php/7.3/cli/php.ini \
| sed -r 's/pcntl_(fork|signal|signal_dispatch|waitpid),//g' \
> /etc/php/7.3/cli/conf.d/99-boris.ini
fi
echo "Start services..."
touch /var/log/fpm-php.www.log
chmod 777 /var/log/fpm-php.www.log
run_service cron
run_service nginx
run_service php7.3-fpm
crontab /var/www/.docker/web/crontab
tail -f /var/log/nginx/access.log
+3
View File
@@ -0,0 +1,3 @@
s/('SQLHOST', *')localhost/\1mysql/
s/('SPHINX(QL)?_HOST', *')(localhost|127\.0\.0\.1)/\1sphinxsearch/
s|('host' *=>) *'unix:///var/run/memcached.sock'(, *'port' *=>) *0|\1 'memcached'\2 11211|
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
THIS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
TARGET=${THIS_DIR}/../../classes/config.php
if [ -f ${TARGET} ]; then
exit 0;
fi
echo "GENERATING GAZELLE CONFIG..."
echo ""
sed -Ef $THIS_DIR/generate-config.sed \
-e "s~\\\$MYSQL_USER~${MYSQL_USER}~" \
-e "s~\\\$MYSQL_PASSWORD~${MYSQL_PASSWORD}~" \
-e "s~\\\$TMDB_API_KEY~${TMDB_API_KEY}~" \
-e "s~\\\$OMDB_API_KEY~${OMDB_API_KEY}~" \
-e "s~\\\$DOUBAN_API_URL~${DOUBAN_API_URL}~" \
-e "s~\\\$SITE_HOST~${SITE_HOST}~" \
${THIS_DIR}/../../classes/config.template.php > ${TARGET}
echo ""
+64
View File
@@ -0,0 +1,64 @@
##
# You should look at the following URL's in order to grasp a solid understanding
# of Nginx configuration files in order to fully unleash the power of Nginx.
# http://wiki.nginx.org/Pitfalls
# http://wiki.nginx.org/QuickStart
# http://wiki.nginx.org/Configuration
#
# Please see /usr/share/doc/nginx-doc/examples/ for more detailed examples.
##
server {
listen 80; ## listen for ipv4; this line is default and implied
listen [::]:80 default_server ipv6only=on; ## listen for ipv6
root /var/www/public;
index index.html index.htm index.php;
client_max_body_size 20m;
server_name localhost;
location / {
try_files $uri $uri/ /index.php;
}
location ~ ^/src {
root /var/www;
}
location /logs/ {
add_header Content-Type text/plain;
}
location /static/userscripts/ {
add_header Content-Type text/plain;
}
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
try_files $fastcgi_script_name @missing;
set $path_info $fastcgi_path_info;
fastcgi_param PATH_INFO $path_info;
fastcgi_param HTTP_AUTHORIZATION $http_authorization;
fastcgi_pass unix:/var/run/php/php-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
}
location @missing {
fastcgi_pass unix:/var/run/php/php-fpm.sock;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
set $path_info $fastcgi_path_info;
fastcgi_param PATH_INFO $path_info;
fastcgi_param HTTP_AUTHORIZATION $http_authorization;
fastcgi_index index.php;
include fastcgi.conf;
fastcgi_param SCRIPT_FILENAME "${document_root}/index.php";
}
location ~* ^.+\.(js|css)$ {
expires -1;
}
}
+1918
View File
File diff suppressed because it is too large Load Diff
+411
View File
@@ -0,0 +1,411 @@
; Start a new pool named 'www'.
; the variable $pool can we used in any directive and will be replaced by the
; pool name ('www' here)
[www]
; Per pool prefix
; It only applies on the following directives:
; - 'access.log'
; - 'slowlog'
; - 'listen' (unixsocket)
; - 'chroot'
; - 'chdir'
; - 'php_values'
; - 'php_admin_values'
; When not set, the global prefix (or /usr) applies instead.
; Note: This directive can also be relative to the global prefix.
; Default Value: none
;prefix = /path/to/pools/$pool
; Unix user/group of processes
; Note: The user is mandatory. If the group is not set, the default user's group
; will be used.
user = gazelle
group = gazelle
; The address on which to accept FastCGI requests.
; Valid syntaxes are:
; 'ip.add.re.ss:port' - to listen on a TCP socket to a specific IPv4 address on
; a specific port;
; '[ip:6:addr:ess]:port' - to listen on a TCP socket to a specific IPv6 address on
; a specific port;
; 'port' - to listen on a TCP socket to all IPv4 addresses on a
; specific port;
; '[::]:port' - to listen on a TCP socket to all addresses
; (IPv6 and IPv4-mapped) on a specific port;
; '/path/to/unix/socket' - to listen on a unix socket.
; Note: This value is mandatory.
listen = /var/run/php/php-fpm.sock
; Set listen(2) backlog.
; Default Value: 65535 (-1 on FreeBSD and OpenBSD)
;listen.backlog = 65535
; Set permissions for unix socket, if one is used. In Linux, read/write
; permissions must be set in order to allow connections from a web server. Many
; BSD-derived systems allow connections regardless of permissions.
; Default Values: user and group are set as the running user
; mode is set to 0660
listen.owner = www-data
listen.group = www-data
;listen.mode = 0660
; When POSIX Access Control Lists are supported you can set them using
; these options, value is a comma separated list of user/group names.
; When set, listen.owner and listen.group are ignored
;listen.acl_users =
;listen.acl_groups =
; List of addresses (IPv4/IPv6) of FastCGI clients which are allowed to connect.
; Equivalent to the FCGI_WEB_SERVER_ADDRS environment variable in the original
; PHP FCGI (5.2.2+). Makes sense only with a tcp listening socket. Each address
; must be separated by a comma. If this value is left blank, connections will be
; accepted from any ip address.
; Default Value: any
;listen.allowed_clients = 127.0.0.1
; Specify the nice(2) priority to apply to the pool processes (only if set)
; The value can vary from -19 (highest priority) to 20 (lower priority)
; Note: - It will only work if the FPM master process is launched as root
; - The pool processes will inherit the master process priority
; unless it specified otherwise
; Default Value: no set
; process.priority = -19
; Choose how the process manager will control the number of child processes.
; Possible Values:
; static - a fixed number (pm.max_children) of child processes;
; dynamic - the number of child processes are set dynamically based on the
; following directives. With this process management, there will be
; always at least 1 children.
; pm.max_children - the maximum number of children that can
; be alive at the same time.
; pm.start_servers - the number of children created on startup.
; pm.min_spare_servers - the minimum number of children in 'idle'
; state (waiting to process). If the number
; of 'idle' processes is less than this
; number then some children will be created.
; pm.max_spare_servers - the maximum number of children in 'idle'
; state (waiting to process). If the number
; of 'idle' processes is greater than this
; number then some children will be killed.
; ondemand - no children are created at startup. Children will be forked when
; new requests will connect. The following parameter are used:
; pm.max_children - the maximum number of children that
; can be alive at the same time.
; pm.process_idle_timeout - The number of seconds after which
; an idle process will be killed.
; Note: This value is mandatory.
pm = dynamic
; The number of child processes to be created when pm is set to 'static' and the
; maximum number of child processes when pm is set to 'dynamic' or 'ondemand'.
; This value sets the limit on the number of simultaneous requests that will be
; served. Equivalent to the ApacheMaxClients directive with mpm_prefork.
; Equivalent to the PHP_FCGI_CHILDREN environment variable in the original PHP
; CGI. The below defaults are based on a server without much resources. Don't
; forget to tweak pm.* to fit your needs.
; Note: Used when pm is set to 'static', 'dynamic' or 'ondemand'
; Note: This value is mandatory.
pm.max_children = 5
; The number of child processes created on startup.
; Note: Used only when pm is set to 'dynamic'
; Default Value: min_spare_servers + (max_spare_servers - min_spare_servers) / 2
pm.start_servers = 2
; The desired minimum number of idle server processes.
; Note: Used only when pm is set to 'dynamic'
; Note: Mandatory when pm is set to 'dynamic'
pm.min_spare_servers = 1
; The desired maximum number of idle server processes.
; Note: Used only when pm is set to 'dynamic'
; Note: Mandatory when pm is set to 'dynamic'
pm.max_spare_servers = 3
; The number of seconds after which an idle process will be killed.
; Note: Used only when pm is set to 'ondemand'
; Default Value: 10s
;pm.process_idle_timeout = 10s;
; The number of requests each child process should execute before respawning.
; This can be useful to work around memory leaks in 3rd party libraries. For
; endless request processing specify '0'. Equivalent to PHP_FCGI_MAX_REQUESTS.
; Default Value: 0
;pm.max_requests = 500
; The URI to view the FPM status page. If this value is not set, no URI will be
; recognized as a status page. It shows the following informations:
; pool - the name of the pool;
; process manager - static, dynamic or ondemand;
; start time - the date and time FPM has started;
; start since - number of seconds since FPM has started;
; accepted conn - the number of request accepted by the pool;
; listen queue - the number of request in the queue of pending
; connections (see backlog in listen(2));
; max listen queue - the maximum number of requests in the queue
; of pending connections since FPM has started;
; listen queue len - the size of the socket queue of pending connections;
; idle processes - the number of idle processes;
; active processes - the number of active processes;
; total processes - the number of idle + active processes;
; max active processes - the maximum number of active processes since FPM
; has started;
; max children reached - number of times, the process limit has been reached,
; when pm tries to start more children (works only for
; pm 'dynamic' and 'ondemand');
; Value are updated in real time.
; Example output:
; pool: www
; process manager: static
; start time: 01/Jul/2011:17:53:49 +0200
; start since: 62636
; accepted conn: 190460
; listen queue: 0
; max listen queue: 1
; listen queue len: 42
; idle processes: 4
; active processes: 11
; total processes: 15
; max active processes: 12
; max children reached: 0
;
; By default the status page output is formatted as text/plain. Passing either
; 'html', 'xml' or 'json' in the query string will return the corresponding
; output syntax. Example:
; http://www.foo.bar/status
; http://www.foo.bar/status?json
; http://www.foo.bar/status?html
; http://www.foo.bar/status?xml
;
; By default the status page only outputs short status. Passing 'full' in the
; query string will also return status for each pool process.
; Example:
; http://www.foo.bar/status?full
; http://www.foo.bar/status?json&full
; http://www.foo.bar/status?html&full
; http://www.foo.bar/status?xml&full
; The Full status returns for each process:
; pid - the PID of the process;
; state - the state of the process (Idle, Running, ...);
; start time - the date and time the process has started;
; start since - the number of seconds since the process has started;
; requests - the number of requests the process has served;
; request duration - the duration in µs of the requests;
; request method - the request method (GET, POST, ...);
; request URI - the request URI with the query string;
; content length - the content length of the request (only with POST);
; user - the user (PHP_AUTH_USER) (or '-' if not set);
; script - the main script called (or '-' if not set);
; last request cpu - the %cpu the last request consumed
; it's always 0 if the process is not in Idle state
; because CPU calculation is done when the request
; processing has terminated;
; last request memory - the max amount of memory the last request consumed
; it's always 0 if the process is not in Idle state
; because memory calculation is done when the request
; processing has terminated;
; If the process is in Idle state, then informations are related to the
; last request the process has served. Otherwise informations are related to
; the current request being served.
; Example output:
; ************************
; pid: 31330
; state: Running
; start time: 01/Jul/2011:17:53:49 +0200
; start since: 63087
; requests: 12808
; request duration: 1250261
; request method: GET
; request URI: /test_mem.php?N=10000
; content length: 0
; user: -
; script: /home/fat/web/docs/php/test_mem.php
; last request cpu: 0.00
; last request memory: 0
;
; Note: There is a real-time FPM status monitoring sample web page available
; It's available in: /usr/share/php5/fpm/status.html
;
; Note: The value must start with a leading slash (/). The value can be
; anything, but it may not be a good idea to use the .php extension or it
; may conflict with a real PHP file.
; Default Value: not set
;pm.status_path = /status
; The ping URI to call the monitoring page of FPM. If this value is not set, no
; URI will be recognized as a ping page. This could be used to test from outside
; that FPM is alive and responding, or to
; - create a graph of FPM availability (rrd or such);
; - remove a server from a group if it is not responding (load balancing);
; - trigger alerts for the operating team (24/7).
; Note: The value must start with a leading slash (/). The value can be
; anything, but it may not be a good idea to use the .php extension or it
; may conflict with a real PHP file.
; Default Value: not set
;ping.path = /ping
; This directive may be used to customize the response of a ping request. The
; response is formatted as text/plain with a 200 response code.
; Default Value: pong
;ping.response = pong
; The access log file
; Default: not set
;access.log = log/$pool.access.log
; The access log format.
; The following syntax is allowed
; %%: the '%' character
; %C: %CPU used by the request
; it can accept the following format:
; - %{user}C for user CPU only
; - %{system}C for system CPU only
; - %{total}C for user + system CPU (default)
; %d: time taken to serve the request
; it can accept the following format:
; - %{seconds}d (default)
; - %{miliseconds}d
; - %{mili}d
; - %{microseconds}d
; - %{micro}d
; %e: an environment variable (same as $_ENV or $_SERVER)
; it must be associated with embraces to specify the name of the env
; variable. Some exemples:
; - server specifics like: %{REQUEST_METHOD}e or %{SERVER_PROTOCOL}e
; - HTTP headers like: %{HTTP_HOST}e or %{HTTP_USER_AGENT}e
; %f: script filename
; %l: content-length of the request (for POST request only)
; %m: request method
; %M: peak of memory allocated by PHP
; it can accept the following format:
; - %{bytes}M (default)
; - %{kilobytes}M
; - %{kilo}M
; - %{megabytes}M
; - %{mega}M
; %n: pool name
; %o: output header
; it must be associated with embraces to specify the name of the header:
; - %{Content-Type}o
; - %{X-Powered-By}o
; - %{Transfert-Encoding}o
; - ....
; %p: PID of the child that serviced the request
; %P: PID of the parent of the child that serviced the request
; %q: the query string
; %Q: the '?' character if query string exists
; %r: the request URI (without the query string, see %q and %Q)
; %R: remote IP address
; %s: status (response code)
; %t: server time the request was received
; it can accept a strftime(3) format:
; %d/%b/%Y:%H:%M:%S %z (default)
; %T: time the log has been written (the request has finished)
; it can accept a strftime(3) format:
; %d/%b/%Y:%H:%M:%S %z (default)
; %u: remote user
;
; Default: "%R - %u %t \"%m %r\" %s"
;access.format = "%R - %u %t \"%m %r%Q%q\" %s %f %{mili}d %{kilo}M %C%%"
; The log file for slow requests
; Default Value: not set
; Note: slowlog is mandatory if request_slowlog_timeout is set
;slowlog = log/$pool.log.slow
; The timeout for serving a single request after which a PHP backtrace will be
; dumped to the 'slowlog' file. A value of '0s' means 'off'.
; Available units: s(econds)(default), m(inutes), h(ours), or d(ays)
; Default Value: 0
;request_slowlog_timeout = 0
; The timeout for serving a single request after which the worker process will
; be killed. This option should be used when the 'max_execution_time' ini option
; does not stop script execution for some reason. A value of '0' means 'off'.
; Available units: s(econds)(default), m(inutes), h(ours), or d(ays)
; Default Value: 0
;request_terminate_timeout = 0
; Set open file descriptor rlimit.
; Default Value: system defined value
;rlimit_files = 1024
; Set max core size rlimit.
; Possible Values: 'unlimited' or an integer greater or equal to 0
; Default Value: system defined value
;rlimit_core = 0
; Chroot to this directory at the start. This value must be defined as an
; absolute path. When this value is not set, chroot is not used.
; Note: you can prefix with '$prefix' to chroot to the pool prefix or one
; of its subdirectories. If the pool prefix is not set, the global prefix
; will be used instead.
; Note: chrooting is a great security feature and should be used whenever
; possible. However, all PHP paths will be relative to the chroot
; (error_log, sessions.save_path, ...).
; Default Value: not set
;chroot =
; Chdir to this directory at the start.
; Note: relative path can be used.
; Default Value: current directory or / when chroot
chdir = /
; Redirect worker stdout and stderr into main error log. If not set, stdout and
; stderr will be redirected to /dev/null according to FastCGI specs.
; Note: on highloaded environement, this can cause some delay in the page
; process time (several ms).
; Default Value: no
;catch_workers_output = yes
; Clear environment in FPM workers
; Prevents arbitrary environment variables from reaching FPM worker processes
; by clearing the environment in workers before env vars specified in this
; pool configuration are added.
; Setting to "no" will make all environment variables available to PHP code
; via getenv(), $_ENV and $_SERVER.
; Default Value: yes
;clear_env = no
; Limits the extensions of the main script FPM will allow to parse. This can
; prevent configuration mistakes on the web server side. You should only limit
; FPM to .php extensions to prevent malicious users to use other extensions to
; exectute php code.
; Note: set an empty value to allow all extensions.
; Default Value: .php
;security.limit_extensions = .php .php3 .php4 .php5
; Pass environment variables like LD_LIBRARY_PATH. All $VARIABLEs are taken from
; the current environment.
; Default Value: clean env
;env[HOSTNAME] = $HOSTNAME
;env[PATH] = /usr/local/bin:/usr/bin:/bin
;env[TMP] = /tmp
;env[TMPDIR] = /tmp
;env[TEMP] = /tmp
; Additional php.ini defines, specific to this pool of workers. These settings
; overwrite the values previously defined in the php.ini. The directives are the
; same as the PHP SAPI:
; php_value/php_flag - you can set classic ini defines which can
; be overwritten from PHP call 'ini_set'.
; php_admin_value/php_admin_flag - these directives won't be overwritten by
; PHP call 'ini_set'
; For php_*flag, valid values are on, off, 1, 0, true, false, yes or no.
; Defining 'extension' will load the corresponding shared extension from
; extension_dir. Defining 'disable_functions' or 'disable_classes' will not
; overwrite previously defined php.ini values, but will append the new value
; instead.
; Note: path INI options can be relative and will be expanded with the prefix
; (pool, global or /usr)
; Default Value: nothing is defined by default except the values in php.ini and
; specified at startup with the -d argument
;php_admin_value[sendmail_path] = /usr/sbin/sendmail -t -i -f www@my.domain.com
;php_flag[display_errors] = off
php_admin_value[error_log] = /var/log/fpm-php.www.log
;php_admin_flag[log_errors] = on
;php_admin_value[memory_limit] = 32M
+6
View File
@@ -0,0 +1,6 @@
zend_extension=xdebug.so
xdebug.remote_port=9000
xdebug.remote_handler="dbgp"
xdebug.remote_enable=On
xdebug.remote_connect_back=On
xdebug.remote_log=/var/log/xdebug.log
+10
View File
@@ -0,0 +1,10 @@
SITE_HOST=localhost
# TMDB Open API Key available at TheMovieDB official site
TMDB_API_KEY=
# OMDB API Key, available at OMDB API official site.
OMDB_API_KEY=
# Douban API URL(optional), provides movie info partially
DOUBAN_API_URL=
+22
View File
@@ -0,0 +1,22 @@
module.exports = {
extends: [
'standard',
'prettier',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
],
plugins: ['jest'],
env: {
'jest/globals': true,
},
globals: {
$: true,
readFixture: true,
Mousetrap: true,
translation: true,
},
rules: {
'dot-notation': 'off',
'react/prop-types': 'off',
},
}
+29
View File
@@ -0,0 +1,29 @@
.vs/
.vscode/
.vagrant/
.well-known/
logs/
old/
phpMyAdmin/
vendor/
static/similar/
mix-manifest.json
classes/config.php
classes/config.env.php
archive.tgz
.metals/
adminer.php
adminer.css
*.swp
node_modules
.docker/data
.DS_Store
public/js
public/css
public/app
public/image
public/manifest.json
package-lock.json
.cache
yarn-error.log
.env
+1
View File
@@ -0,0 +1 @@
lts/*
+5
View File
@@ -0,0 +1,5 @@
public
vendor
src/js/forked
src/css/forked
compser.lock
+4
View File
@@ -0,0 +1,4 @@
{
"semi": false,
"singleQuote": true
}
+18
View File
@@ -0,0 +1,18 @@
{
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits"
}
],
[
"@semantic-release/release-notes-generator",
{
"preset": "conventionalcommits"
}
],
"@semantic-release/gitlab",
"@semantic-release/git"
]
}
+130
View File
@@ -0,0 +1,130 @@
FROM debian:buster-slim
WORKDIR /var/www
# Software package layer
# Nodesource setup comes after yarnpkg because it runs `apt-get update`
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
composer \
cron \
curl \
git \
gnupg \
imagemagick \
libboost-dev \
libbz2-dev \
libssl-dev \
libsqlite3-dev \
libtcmalloc-minimal4 \
make \
mariadb-client \
netcat-openbsd \
nginx \
php7.3-cli \
php7.3-curl \
php7.3-fpm \
php7.3-gd \
php7.3-mbstring \
php7.3-mysql \
php7.3-xml \
php7.3-zip \
php-apcu \
php-bcmath \
php-memcached \
php-xdebug \
python3 \
python3-pip \
python3-setuptools \
python3-wheel \
python3-dev \
software-properties-common \
unzip \
wget \
zlib1g-dev \
&& curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
&& echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list \
&& curl -sL https://deb.nodesource.com/setup_12.x | bash - \
&& apt-get install -y --no-install-recommends \
nodejs \
yarn
# Python tools layer
RUN pip3 install chardet
# Puppeteer layer
# This installs the necessary packages to run the bundled version of chromium for puppeteer
RUN apt-get install -y --no-install-recommends \
gconf-service \
libasound2 \
libatk1.0-0 \
libc6 \
libcairo2 \
libcups2 \
libdbus-1-3 \
libexpat1 \
libfontconfig1 \
libgcc1 \
libgconf-2-4 \
libgdk-pixbuf2.0-0 \
libglib2.0-0 \
libgtk-3-0 \
libnspr4 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libstdc++6 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcomposite1 \
libxcursor1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxi6 \
libxrandr2 \
libxrender1 \
libxss1 \
libxtst6 \
fonts-liberation \
libappindicator1 \
libnss3 \
lsb-release \
xdg-utils \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g git-cz
RUN npm install --save-dev @commitlint/config-conventional @commitlint/cli
# If running Docker >= 1.13.0 use docker run's --init arg to reap zombie processes, otherwise
# uncomment the following lines to have `dumb-init` as PID 1
# ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.0/dumb-init_1.2.0_amd64 /usr/local/bin/dumb-init
# RUN chmod +x /usr/local/bin/dumb-init
# ENTRYPOINT ["dumb-init", "--"]
# Uncomment to skip the chromium download when installing puppeteer. If you do,
# you'll need to launch puppeteer with:
# browser.launch({executablePath: 'google-chrome-unstable'})
# ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true
COPY . /var/www
# Permissions and configuration layer
RUN useradd -ms /bin/bash gazelle \
&& chown -R gazelle:gazelle /var/www \
&& cp /var/www/.docker/web/php.ini /etc/php/7.3/cli/php.ini \
&& cp /var/www/.docker/web/php.ini /etc/php/7.3/fpm/php.ini \
&& cp /var/www/.docker/web/xdebug.ini /etc/php/7.3/mods-available/xdebug.ini \
&& cp /var/www/.docker/web/www.conf /etc/php/7.3/fpm/pool.d/www.conf \
&& cp /var/www/.docker/web/nginx.conf /etc/nginx/sites-available/gazelle.conf \
&& ln -s /etc/nginx/sites-available/gazelle.conf /etc/nginx/sites-enabled/gazelle.conf \
&& rm -f /etc/nginx/sites-enabled/default
EXPOSE 80/tcp
EXPOSE 3306/tcp
EXPOSE 34000/tcp
ENTRYPOINT [ "/bin/bash", "/var/www/.docker/web/entrypoint.sh" ]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 GazellePW
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+51
View File
@@ -0,0 +1,51 @@
.DEFAULT_GOAL := help
.SILENT: help
.PHONY: help
help:
echo ' help - output this message'
echo ' build-css - build the CSS'
echo ' lint-css - lint (style check) the CSS'
echo ' mysqldump - dump mysql database from docker to db/data/gazelle.sql'
echo ' ocelot-reload-conf - signal Ocelot to reload its configuration'
echo ' ocelot-reload-db - signal Ocelot to reload from database'
echo ' test - run all linters and unit test suite'
echo ' twig-flush - purge the Twig cache'
echo ' update - pull from git and run production composer install'
.PHONY: build-css
build-css:
yarn build:scss
.PHONY: lint-css
lint-css:
yarn lint:css
yarn lint:css-checkstyle
.PHONY: mysqldump
mysqldump:
mysqldump -h 127.0.0.1 -P 36000 -u gazelle --password=password -d gazelle --skip-add-drop-table --skip-add-locks --single-transaction | sed 's/ AUTO_INCREMENT=[0-9]*//g' > db/data/gazelle.sql
.PHONY: ocelot-reload-conf
ocelot-reload-conf:
pkill -HUP ocelot
.PHONY: ocelot-reload-db
ocelot-reload-reload:
pkill -USR1 ocelot
.PHONY: test
test: lint-css
yarn lint:php:internal
yarn lint:php:phpcs || exit 0
composer phpstan
composer test
.PHONY: twig-flush
twig-flush:
find cache/twig -mindepth 1 -depth -delete
.PHONY: update
update:
git pull
composer install --no-dev --optimize-autoloader --no-suggest --no-progress
+41
View File
@@ -0,0 +1,41 @@
中文 | [English](./README.md)
# GazellePW
全称 GazellePosterWall,一个 PTPrivate TrackerWeb 框架,Gazelle 的 **影视版本**
## 背景
[WhatCD/Gazelle](https://github.com/WhatCD/Gazelle) 最初诞生于音乐站点,尽管后来 OPSnet 开发组在其基础上做了一些代码重构,也只是为其音乐内容锦上添花。而 Gazelle 的应用不止于此,我们基于 [OPSnet/Gazelle](https://github.com/OPSnet/Gazelle) 的某个版本,进行了大量的功能新增和逻辑优化,使 Gazelle 适用于电影站的建设,我们称其为 GazellePosterWall,而如果想要基于 GazellePW 搭建 TV 甚至是其他类别的站点,相较原版 Gazelle,也会更加容易。
## 特性
- 精美的界面:响应式布局,手机端界面适配,BBCode 工具栏,所有图标都是 SVG 格式等
- 主题: 自动明/暗色主题切换,一小时创建出一个新主题,基于组件的样式
- 影视优化:发布时自动获取影片信息,截图对比图(支持像素对比和曲线滤镜),MediaInfo,海报墙,多版本槽位分类、搜索,种子槽位系统等
- 多语言支持:中英双语
- 图床:本地或者[Minio](https://github.com/minio/minio)
- 在免费和中性基础上,额外增加 25%,50%,75% 种子免费
- 现代化开发:Docker, Vite, React
- ...
## 文档
- [快速开始](docs/Getting-Started.md)
- [前端开发指南](docs/Frontend-Development-Guide.md)
## 参与贡献
我们非常欢迎来自社区的各种贡献!
- 通过 [Issues]() 报告 bug 或者提出功能需求
- 通过 [Pull requests]() 提交代码修改
## 特别鸣谢
- 所有开发人员和贡献者
- [TheMovieDB](https://www.themoviedb.org/)
- [OMDb API](https://www.omdbapi.com/)
- [imdbphp](https://github.com/tboothman/imdbphp)
- [WhatCD/Gazelle](https://github.com/WhatCD/Gazelle)
- [OPSnet/Gazelle](https://github.com/WhatCD/Gazelle)
+41
View File
@@ -0,0 +1,41 @@
[中文](./README-ZH.md) | English
# GazellePW
GazellePW (GazellePosterWall) is a web framework geared towards private BitTorrent trackers. It's a **movie version** of Gazelle.
## Background
[WhatCD/Gazelle](https://github.com/WhatCD/Gazelle) is naturally focusing on music. OPSnet team also does some great things based on it, but Gazelle could be applied to more scenarios. We forked some versions of [OPSnet/Gazelle](https://github.com/OPSnet/Gazelle), added lots of features, and made cleanups. Now it works for movie sites. In addition, it also contributes to TV sites and even other sites.
## Features
- Beautiful and modern UI: Responsive design, Mobile-friendly, BBCode toolbar, All icons in svg format etc
- Theme: Auto dark/light theme, Create a new theme in one hour, Component-based style
- Optimization for movie content: Upload auto-fill movie info, Screenshot comparison (supports PixelCompare and SolarCurve), MediaInfo parser, Movie poster wall, Multiple formats and editions, Torrent slot system, Movie search etc
- Localization: Simplified Chinese and English languages
- Image hosting service: Local or [Minio](https://github.com/minio/minio)
- Additional torrents off: 25%, 50%, 75%
- Modern development: Docker, Vite, React
- ...
## Documentation
- [Getting Started](docs/Getting-Started.md)
- [Frontend Development Guide](docs/Frontend-Development-Guide.md)
## Contributing
Any contributions you make are greatly appreciated!
- Please report bugs by submitting a [Issues]().
- Please submit contributions using [Pull requests]().
## Special Thanks
- All developers & contributors
- [TheMovieDB](https://www.themoviedb.org/)
- [OMDb API](https://www.omdbapi.com/)
- [imdbphp](https://github.com/tboothman/imdbphp)
- [WhatCD/Gazelle](https://github.com/WhatCD/Gazelle)
- [OPSnet/Gazelle](https://github.com/WhatCD/Gazelle)
Vendored
+39
View File
@@ -0,0 +1,39 @@
# -*- mode: ruby -*-
# vi: set ft=ruby :
Vagrant.configure("2") do |config|
# All Vagrant configuration is done here. The most common configuration
# options are documented and commented below. For a complete reference,
# please see the online documentation at vagrantup.com.
# Every Vagrant virtual environment requires a box to build off of.
config.vm.box = "debian/contrib-stretch64"
config.vm.synced_folder ".", "/var/www/",
owner: "www-data"
# only sync if it exists
if File.directory?(File.expand_path("ocelot"))
config.vm.synced_folder "ocelot/", "/var/ocelot"
end
config.vm.provision :shell, :path => ".vagrant/gazelle-setup.sh"
# HTTP access
config.vm.network :forwarded_port, guest: 80, host: 8080
# MySQL access
config.vm.network :forwarded_port, guest: 3306, host: 36000
# Ocelot
config.vm.network :forwarded_port, guest: 34000, host: 34000
# Sometimes its useful to have a head to our VM (like if you wanted to
# install EAC for the first time
#config.vm.provider 'virtualbox' do |vb|
# vb.gui = true
#end
# This appears to fix a bug with the vbguest plugin so that it properly
# installs instead of getting stuck at a user confirmation and dying
if Vagrant.has_plugin? "vagrant-vbguest"
config.vbguest.installer_arguments = ['--nox11 -- --force']
end
end
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Gazelle\API;
abstract class AbstractAPI extends \Gazelle\Base {
protected $twig;
protected $config;
public function __construct(\Twig\Environment $twig, array $config) {
parent::__construct();
$this->twig = $twig;
$this->config = $config;
}
abstract public function run();
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Gazelle\API;
class Artist extends AbstractAPI {
public function run() {
if (!isset($_GET['artist_id'])) {
json_error('Missing artist id');
}
$this->db->prepared_query("
SELECT
ArtistID,
Name
FROM
artists_group
WHERE
ArtistID = ?", $_GET['artist_id']);
if (!$this->db->has_results()) {
json_error('Artist not found');
}
$artist = $this->db->next_record(MYSQLI_ASSOC, false);
return $artist;
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Gazelle\API;
class Collage extends AbstractAPI {
public function run() {
if (!isset($_GET['collage_id'])) {
json_error('Missing collage id');
}
$this->db->prepared_query("
SELECT
ID,
Name,
CategoryID
FROM
collages
WHERE
ID = ?", $_GET['collage_id']);
if (!$this->db->has_results()) {
json_error('Collage not found');
}
$collage = $this->db->next_record(MYSQLI_ASSOC, false);
$collage['Category'] = $collage['CategoryID'];
return $collage;
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Gazelle\API;
class Forum extends AbstractAPI {
private $fid = null;
private $tid = null;
public function run() {
if (!isset($_GET['topic_id'])) {
json_error('Missing topic id');
}
$this->db->prepared_query("
SELECT
ft.ID,
ft.Title,
um.Username AS Author,
f.Name AS Forum,
f.MinClassRead
FROM
forums_topics AS ft
INNER JOIN users_main AS um ON um.ID = ft.AuthorID
INNER JOIN forums AS f ON f.ID = ft.ForumID
WHERE
ft.ID = ?", $_GET['topic_id']);
if (!$this->db->has_results()) {
json_error('Topic not found');
}
$thread = $this->db->next_record(MYSQLI_ASSOC, false);
return $thread;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace Gazelle\API;
class GenerateInvite extends AbstractAPI {
public function run() {
if (!isset($_GET['interviewer_id']) && !isset($_GET['interviewer_name'])) {
json_error('Missing interviewer_id or interviewer_name');
}
if (isset($_GET['interviewer_id'])) {
$where = "ID";
$param = intval($_GET['interviewer_id']);
} else {
$where = "Username";
$param = $_GET['interview_name'];
}
$this->db->prepared_query("SELECT ID, Username FROM users_main WHERE {$where}=?", $param);
if ($this->db->record_count() === 0) {
json_error("Could not find interviewer");
}
$user = $this->db->next_record();
$interviewer_id = $user['ID'];
$interviewer_name = $user['Username'];
$email = $_GET['email'] ?? '';
if (!empty($_GET['email'])) {
if ($this->db->scalar("SELECT 1 FROM users_main WHERE Email = ?", $email)) {
json_error("Email address already in use");
}
if ($this->db->scalar("SELECT 1 FROM invites WHERE Email = ?", $email)) {
json_error("Invite code already generated for this email address");
}
}
$key = randomString();
$this->db->prepared_query(
"INSERT INTO invites
(InviterID, InviteKey, Email, Reason, Expires)
VALUES (?, ?, ?, ?, now() + INTERVAL 3 DAY)",
$interviewer_id,
$key,
$email,
"Passed Interview"
);
$site_url = SITE_URL . "/register.php?invite={$key}";
if (!empty($_GET['email'])) {
$body = $this->twig->render('emails/invite.twig', [
'InviterName' => $interviewer_name,
'InviteKey' => $key,
'Email' => $_GET['email'],
'SITE_NAME' => SITE_NAME,
'SITE_URL' => SITE_URL,
'IRC_SERVER' => BOT_SERVER,
'DISABLED_CHAN' => BOT_DISABLED_CHAN
]);
\Misc::send_email($_GET['email'], 'New account confirmation at ' . SITE_NAME, $body, 'noreply');
}
return ["key" => $key, "invite_url" => $site_url];
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Gazelle\API;
class Request extends AbstractAPI {
public function run() {
if (!isset($_GET['request_id'])) {
json_error('Missing request id');
}
$request = \Requests::get_request($_GET['request_id']);
if ($request === false) {
json_error('Request not found');
}
$artists = \Requests::get_artists($_GET['request_id']);
$request['Artists'] = $artists;
$request['DisplayArtists'] = \Artists::display_artists($artists, false, false, false);
$request['Category'] = $this->config['Categories'][$request['CategoryID'] - 1];
return $request;
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace Gazelle\API;
class Torrent extends AbstractAPI {
public function run() {
switch ($_GET['req']) {
case 'group':
return $this->getGroup();
break;
default:
case 'torrent':
return $this->getTorrent();
break;
}
}
private function getTorrent() {
if (!isset($_GET['torrent_id'])) {
json_error('Missing torrent id');
}
$this->db->prepared_query("
SELECT
tg.ID,
tg.Name,
tg.Year,
tg.ReleaseType AS ReleaseTypeID,
t.Codec,
t.Resolution,
t.Container,
t.Processing,
t.Source,
tls.Snatched,
tls.Seeders,
tls.Leechers
FROM
torrents AS t
INNER JOIN torrents_leech_stats tls ON (tls.TorrentID = t.ID)
INNER JOIN torrents_group AS tg ON (tg.ID = t.GroupID)
WHERE
t.ID = ?", $_GET['torrent_id']);
if (!$this->db->has_results()) {
json_error('Torrent not found');
}
$torrent = $this->db->next_record(MYSQLI_ASSOC, false);
$torrent['ReleaseType'] = $torrent['ReleaseTypeID'];
$artists = \Artists::get_artist($torrent['ID']);
$torrent['Artists'] = $artists;
$torrent['DisplayArtists'] = \Artists::display_artists(
$artists,
false,
false,
false
);
return $torrent;
}
private function getGroup() {
if (!isset($_GET['group_id'])) {
json_error('Missing group id');
}
$this->db->prepared_query("
SELECT
ID,
Name,
Year,
ReleaseType AS ReleaseTypeID
FROM
torrents_group
WHERE
ID = ?", $_GET['group_id']);
if (!$this->db->has_results()) {
json_error('Group not found');
}
$group = $this->db->next_record(MYSQLI_ASSOC, false);
$group['ReleaseType'] = $group['ReleaseTypeID'];
$artists = \Artists::get_artist($group['ID']);
$group['Artists'] = $artists;
$group['DisplayArtists'] = \Artists::display_artists(
$artists,
false,
false,
false
);
return $group;
}
}
+182
View File
@@ -0,0 +1,182 @@
<?php
namespace Gazelle\API;
class User extends AbstractAPI {
private $id = null;
private $username = null;
private $clear_tokens = false;
public function run() {
if (isset($_GET['user_id'])) {
$this->id = intval($_GET['user_id']);
} else if (isset($_GET['username'])) {
$this->username = $_GET['username'];
} else {
json_error("Need to supply either user_id or username");
}
if (isset($_GET['clear_tokens'])) {
$this->clear_tokens = true;
}
switch ($_GET['req']) {
case 'enable':
return $this->enableUser();
break;
case 'disable':
return $this->disableUser();
break;
default:
case 'stats':
return $this->getUser();
break;
}
}
private function getUser() {
$where = ($this->id !== null) ? "um.ID = ?" : "um.Username = ?";
$this->db->prepared_query("
SELECT
um.ID,
um.Username,
um.Enabled,
um.IRCKey,
um.Uploaded,
um.Downloaded,
um.PermissionID AS Class,
um.Paranoia,
um.BonusPoints,
ui.DisableIRC,
p.Name as ClassName,
p.Level,
GROUP_CONCAT(ul.PermissionID SEPARATOR ',') AS SecondaryClasses
FROM
users_main AS um
INNER JOIN users_info AS ui ON (ui.UserID = um.ID)
INNER JOIN permissions AS p ON (p.ID = um.PermissionID)
LEFT JOIN users_levels AS ul ON (ul.UserID = um.ID)
WHERE
{$where}", ($this->id !== null) ? $this->id : $this->username);
$user = $this->db->next_record(MYSQLI_ASSOC, ['IRCKey', 'Paranoia']);
if (empty($user['Username'])) {
json_error("User not found");
}
$user['SecondaryClasses'] = array_map("intval", explode(",", $user['SecondaryClasses']));
foreach (['ID', 'Uploaded', 'Downloaded', 'Class', 'Level'] as $key) {
$user[$key] = intval($user[$key]);
}
$user['Paranoia'] = unserialize_array($user['Paranoia']);
$user['Ratio'] = \Format::get_ratio($user['Uploaded'], $user['Downloaded']);
$user['DisplayStats'] = [
'Downloaded' => \Format::get_size($user['Downloaded']),
'Uploaded' => \Format::get_size($user['Uploaded']),
'Ratio' => $user['Ratio']
];
foreach (['Downloaded', 'Uploaded', 'Ratio'] as $key) {
if (in_array(strtolower($key), $user['Paranoia'])) {
$user['DisplayStats'][$key] = "Hidden";
}
}
$user['UserPage'] = SITE_URL . "/user.php?id={$user['ID']}";
return $user;
}
private function disableUser() {
if ($this->id === null) {
$this->db->prepared_query(
"SELECT ID FROM users_main WHERE Username = ?",
$this->username
);
if ($this->db->has_results()) {
$user = $this->db->next_record(MYSQLI_ASSOC, false);
$this->id = $user['ID'];
} else {
json_error("No user found with username {$this->username}");
}
}
\Tools::disable_users($this->id, 'Disabled via API', 1);
return ['disabled' => true, 'user_id' => $this->id, 'username' => $this->username];
}
private function enableUser() {
$where = ($this->id !== null) ? "um.ID = ?" : "um.Username = ?";
$this->db->prepared_query("
SELECT
um.ID,
um.Username,
um.IP,
um.Enabled,
um.Uploaded,
um.Downloaded,
um.Visible,
ui.AdminComment,
um.torrent_pass,
um.RequiredRatio,
ui.RatioWatchEnds
FROM users_main AS um
INNER JOIN users_info AS ui ON (ui.UserID = um.ID)
INNER JOIN user_flt AS uf ON (uf.user_id = um.ID)
WHERE
{$where}", ($this->id !== null) ? $this->id : $this->username);
// TODO: merge this and the version in takemoderate.php
$UpdateSet = [];
$Cur = $this->db->next_record(MYSQLI_ASSOC, false);
$Comment = 'Enabled via API';
if ($this->clear_tokens) {
$UpdateSet[] = "um.Invites = '0'";
$UpdateSet[] = "uf.tokens = 0";
$Comment = 'Tokens and invites reset, enabled via API';
}
$this->cache->increment('stats_user_count');
$VisibleTrIp = $Cur['Visible'] && $Cur['IP'] != '127.0.0.1' ? '1' : '0';
\Tracker::update_tracker('add_user', [
'id' => $this->id,
'passkey' => $Cur['torrent_pass'], 'visible' => $VisibleTrIp
]);
if (($Cur['Downloaded'] == 0) || ($Cur['Uploaded'] / $Cur['Downloaded'] >=
$Cur['RequiredRatio'])) {
$UpdateSet[] = "ui.RatioWatchEnds = NULL";
$UpdateSet[] = "um.can_leech = '1'";
$UpdateSet[] = "ui.RatioWatchDownload = '0'";
} else {
if (!is_null($Cur['RatioWatchEnds'])) {
$UpdateSet[] = "ui.RatioWatchEnds = NOW()";
$UpdateSet[] = "ui.RatioWatchDownload = um.Downloaded";
$Comment .= ' (Ratio: ' . \Format::get_ratio_html(
$Cur['Uploaded'],
$Cur['Downloaded'],
false
) . ', RR: ' . number_format($Cur['RequiredRatio'], 2) . ')';
}
\Tracker::update_tracker('update_user', [
'passkey' => $Cur['torrent_pass'],
'can_leech' => 0
]);
}
$UpdateSet[] = "ui.BanReason = '0'";
$UpdateSet[] = "um.Enabled = '1'";
$set = implode(', ', $UpdateSet);
$this->db->prepared_query("
UPDATE users_main AS um
INNER users_info AS ui ON (ui.UserID = um.ID)
INNER user_flt AS uf ON (uf.user_id = um.ID)
SET
{$set},
ui.AdminComment = CONCAT('" . sqltime() . " - " . $Comment . "\n\n', ui.AdminComment)
WHERE
um.ID = ?", $Cur['ID']);
return ['enabled' => true, 'user_id' => $Cur['ID'], 'username' => $Cur['Username']];
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Gazelle\API;
class Wiki extends AbstractAPI {
public function run() {
if (!isset($_GET['wiki_id'])) {
json_error('Missing wiki article id');
}
$this->db->prepared_query("
SELECT
wa.Title,
wa.MinClassRead,
um.Username AS Author,
wa.Date
FROM
wiki_articles AS wa
INNER JOIN users_main AS um ON um.ID = wa.Author
WHERE
wa.ID = ?", $_GET['wiki_id']);
if (!$this->db->has_results()) {
json_error('Wiki article not found');
}
$article = $this->db->next_record(MYSQLI_ASSOC, false);
return $article;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Gazelle;
abstract class Base {
/** @var \DB_MYSQL */
protected $db;
/** @var \CACHE */
protected $cache;
public function __construct() {
$this->cache = \G::$Cache;
$this->db = \G::$DB;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace Gazelle;
abstract class BaseObject extends Base {
protected $id;
/* used for handling updates */
protected $updateField = [];
public function __construct(int $id) {
parent::__construct();
$this->id = $id;
}
abstract public function tableName(): string;
abstract public function flush();
public function id(): int {
return $this->id;
}
public function setUpdate(string $field, $value) {
$this->updateField[$field] = $value;
return $this;
}
public function modify(): bool {
if (!$this->updateField) {
return false;
}
$set = implode(', ', array_map(function ($f) {
return "$f = ?";
}, array_keys($this->updateField)));
$args = array_values($this->updateField);
$args[] = $this->id;
$this->db->prepared_query(
"UPDATE " . $this->tableName() . " SET
$set WHERE ID = ?
",
...$args
);
$success = ($this->db->affected_rows() === 1);
if ($success) {
$this->flush();
}
return $success;
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace Gazelle;
class Blog extends Base {
protected $id;
protected $title;
protected $body;
protected $topicId;
public function __construct(int $id) {
parent::__construct();
$this->id = $id;
[$this->title, $this->body, $this->topicId] = $this->db->row(
"
SELECT Title, Body, ThreadID
FROM blog
WHERE ID = ?
",
$this->id
);
if (!$this->title) {
throw new Exception\ResourceNotFoundException($id);
}
}
/**
* The ID of the blog
* @return int $id
*/
public function id(): int {
return $this->id;
}
/**
* The title of the blog
* @return string $title
*/
public function title(): string {
return $this->title;
}
/**
* The body of the blog
* @return string $body
*/
public function body(): string {
return $this->body;
}
/**
* The forum topic ID of the blog
* @return int $topicId
*/
public function topicId(): int {
return $this->topicId;
}
}
+349
View File
@@ -0,0 +1,349 @@
<?php
namespace Gazelle;
class Bonus {
private $items;
/** @var \DB_MYSQL */
private $db;
/** @var \CACHE */
private $cache;
const CACHE_ITEM = 'bonus_item';
const CACHE_SUMMARY = 'bonus_summary.';
const CACHE_HISTORY = 'bonus_history.';
public function __construct($db, $cache) {
$this->db = $db;
$this->cache = $cache;
$this->items = $this->cache->get_value(self::CACHE_ITEM);
if ($this->items === false) {
$this->db->query("SELECT ID, Price, Amount, MinClass, FreeClass, OffPrice, OffClass, Label, Title FROM bonus_item order by rank");
$this->items = $this->db->has_results() ? $this->db->to_array('Label') : [];
$this->cache->cache_value(self::CACHE_ITEM, $this->items, 86400 * 30);
}
}
public function getList() {
return $this->items;
}
public function getItem($label) {
return array_key_exists($label, $this->items) ? $this->items[$label] : null;
}
public function getEffectivePrice($label, $effective_class) {
$item = $this->items[$label];
$price = $item['Price'];
if ($effective_class >= $item['OffClass']) {
$price = $item['OffPrice'];
}
if ($effective_class >= $item['FreeClass']) {
$price = 0;
}
return $price;
}
public function getListOther($user_id, $balance) {
$list_other = [];
foreach ($this->items as $label => $item) {
if (!\Users::canPurchaseInvite($user_id, $item['MinClass'])) {
continue;
}
if (preg_match('/^other-\d$/', $label) && $balance >= $item['Price']) {
$list_other[] = [
'Label' => $item['Label'],
'Name' => $item['Title'],
'Price' => $item['Price'],
'After' => $balance - $item['Price'],
];
}
}
return $list_other;
}
public function getUserSummary($user_id) {
$key = self::CACHE_SUMMARY . $user_id;
$summary = $this->cache->get_value($key);
if ($summary === false) {
$this->db->prepared_query('SELECT count(*) AS nr, sum(price) AS total FROM bonus_history WHERE UserID = ?', $user_id);
$summary = $this->db->has_results() ? $this->db->next_record(MYSQLI_ASSOC) : ['nr' => 0, 'total' => 0];
$this->cache->cache_value($key, $summary, 86400 * 7);
}
return $summary;
}
public function getUserHistory($user_id, $page, $items_per_page) {
$key = self::CACHE_HISTORY . "{$user_id}.{$page}";
$history = $this->cache->get_value($key);
if ($history === false) {
$this->db->prepared_query(
'
SELECT i.Title, i.Label, h.Price, h.PurchaseDate, h.OtherUserID
FROM bonus_history h
INNER JOIN bonus_item i ON i.ID = h.ItemID
WHERE h.UserID = ?
ORDER BY PurchaseDate DESC
LIMIT ? OFFSET ?
',
$user_id,
$items_per_page,
$items_per_page * ($page - 1)
);
$history = $this->db->has_results() ? $this->db->to_array() : null;
$this->cache->cache_value($key, $history, 86400 * 3);
/* since we had to fetch this page, invalidate the next one */
$this->cache->delete_value(self::CACHE_HISTORY . "{$user_id}." . ($page + 1));
}
return $history;
}
public function purchaseInvite($user_id) {
$item = $this->items['invite'];
if (!\Users::canPurchaseInvite($user_id, $item['MinClass'])) {
return false;
}
$this->db->begin_transaction();
$this->db->prepared_query(
"UPDATE users_main SET Invites = Invites + 1, BonusPoints = BonusPoints - ? WHERE BonusPoints >= ? AND ID = ?",
$item['Price'],
$item['Price'],
$user_id
);
if ($this->db->affected_rows() != 1) {
$this->db->rollback();
return false;
}
$this->addPurchaseHistory($item['ID'], $user_id, $item['Price']);
$this->db->commit();
$this->cache->delete_value('user_stats_' . $user_id);
$this->cache->delete_value('user_info_heavy_' . $user_id);
return true;
}
public function purchaseHNR($user_id) {
$item = $this->items['eliminate_a_hnr'];
if (!\Users::canPurchaseInvite($user_id, $item['MinClass'])) {
return false;
}
$price = $item['Price'];
$stats = \Users::user_stats($user_id, true);
if ($stats['BonusPoints'] < $price) {
return false;
}
$this->db->begin_transaction();
if ($price > 0) {
/* if the price is 0, nothing changes so avoid hitting the db */
$this->db->prepared_query(
'UPDATE users_main SET BonusPoints = BonusPoints - ? WHERE BonusPoints >= ? AND ID = ?',
$price,
$price,
$user_id
);
if ($this->db->affected_rows() != 1) {
$this->db->rollback();
return false;
}
// Sanity check
$new_stats = \Users::user_stats($user_id, true);
if (!($new_stats['BonusPoints'] >= 0 && $new_stats['BonusPoints'] < $stats['BonusPoints'])) {
$this->db->rollback();
return false;
}
}
// get latest hr
$Stats = \Users::eliminate_latest_hnr($user_id);
if ($Stats != 1) {
$this->db->rollback();
return $Stats;
}
$this->addPurchaseHistory($item['ID'], $user_id, $price);
$this->db->commit();
$this->cache->delete_value('user_info_heavy_' . $user_id);
return true;
}
public function purchaseTitle($user_id, $label, $title, $effective_class) {
$item = $this->items[$label];
$title = $label === 'title-bb-y' ? \Text::full_format($title) : \Text::strip_bbcode($title);
$price = $this->getEffectivePrice($label, $effective_class);
$stats = \Users::user_stats($user_id, true);
if ($stats['BonusPoints'] < $price) {
return false;
}
$this->db->begin_transaction();
if ($price > 0) {
/* if the price is 0, nothing changes so avoid hitting the db */
$this->db->prepared_query(
'UPDATE users_main SET BonusPoints = BonusPoints - ? WHERE BonusPoints >= ? AND ID = ?',
$price,
$price,
$user_id
);
if ($this->db->affected_rows() != 1) {
$this->db->rollback();
return false;
}
// Sanity check
$new_stats = \Users::user_stats($user_id, true);
if (!($new_stats['BonusPoints'] >= 0 && $new_stats['BonusPoints'] < $stats['BonusPoints'])) {
$this->db->rollback();
return false;
}
}
if (!\Users::setCustomTitle($user_id, $title)) {
$this->db->rollback();
return false;
}
$this->addPurchaseHistory($item['ID'], $user_id, $price);
$this->db->commit();
$this->cache->delete_value('user_info_heavy_' . $user_id);
return true;
}
public function purchaseUpload($user_id, $label) {
if (!array_key_exists($label, $this->items)) {
return false;
}
$item = $this->items[$label];
$amount = $item['Amount'] * 1024 * 1024 * 1024; // 单位GB
$price = $item['Price'];
$stats = \Users::user_stats($user_id, true);
if ($stats['BonusPoints'] < $price) {
return false;
}
$this->db->begin_transaction();
$this->db->prepared_query(
'UPDATE users_main SET Uploaded = Uploaded + ?, BonusUploaded = BonusUploaded + ?, BonusPoints = BonusPoints - ? WHERE BonusPoints >= ? AND ID = ?',
$amount,
$amount,
$price,
$price,
$user_id
);
if ($this->db->affected_rows() != 1) {
$this->db->rollback();
return false;
}
$new_stats = \Users::user_stats($user_id, true);
if (!($new_stats['BonusPoints'] >= 0 && $new_stats['BonusPoints'] < $stats['BonusPoints'])) {
$this->db->rollback();
return false;
}
$this->addPurchaseHistory($item['ID'], $user_id, $price);
$this->db->commit();
$this->cache->delete_value('user_info_heavy_' . $user_id);
return true;
}
public function purchaseToken($user_id, $label) {
if (!array_key_exists($label, $this->items)) {
return false;
}
$item = $this->items[$label];
$amount = $item['Amount'];
$price = $item['Price'];
$stats = \Users::user_stats($user_id, true);
if ($stats['BonusPoints'] < $price) {
return false;
}
$this->db->begin_transaction();
$this->db->prepared_query(
'UPDATE users_main SET FLTokens = FLTokens + ?, BonusPoints = BonusPoints - ? WHERE BonusPoints >= ? AND ID = ?',
$amount,
$price,
$price,
$user_id
);
if ($this->db->affected_rows() != 1) {
$this->db->rollback();
return false;
}
$new_stats = \Users::user_stats($user_id, true);
if (!($new_stats['BonusPoints'] >= 0 && $new_stats['BonusPoints'] < $stats['BonusPoints'])) {
$this->db->rollback();
return false;
}
$this->addPurchaseHistory($item['ID'], $user_id, $price);
$this->db->commit();
$this->cache->delete_value('user_info_heavy_' . $user_id);
return true;
}
public function purchaseTokenOther($fromID, $toID, $label, &$logged_user) {
if ($fromID === $toID) {
return 0;
}
if (!array_key_exists($label, $this->items)) {
return 0;
}
$item = $this->items[$label];
$amount = $item['Amount'];
$price = $item['Price'];
if (!isset($price) and !($price > 0)) {
return 0;
}
$From = \Users::user_info($fromID);
$To = \Users::user_info($toID);
if ($From['Enabled'] != 1 || $To['Enabled'] != 1) {
return 0;
}
// get the bonus points of the giver from the database
// verify they could be legally spent, and then update the receiver
$stats = \Users::user_stats($fromID, true);
if ($stats['BonusPoints'] < $price) {
return 0;
}
$this->db->begin_transaction();
$this->db->prepared_query('UPDATE users_main SET BonusPoints = BonusPoints - ? WHERE BonusPoints >= 0 AND ID = ?', $price, $fromID);
if ($this->db->affected_rows() != 1) {
$this->db->rollback();
return 0;
}
$new_stats = \Users::user_stats($fromID, true);
if (!($new_stats['BonusPoints'] >= 0 && $new_stats['BonusPoints'] < $stats['BonusPoints'])) {
$this->db->rollback();
return 0;
}
$this->db->prepared_query("UPDATE users_main SET FLTokens = FLTokens + ? WHERE ID=?", $amount, $toID);
if ($this->db->affected_rows() != 1) {
$this->db->rollback();
return 0;
}
$this->addPurchaseHistory($item['ID'], $fromID, $price, $toID);
$this->db->commit();
$this->cache->delete_value("user_info_heavy_{$fromID}");
$this->cache->delete_value("user_info_heavy_{$toID}");
// the calling code may not know this has been invalidated, so we cheat
$logged_user['BonusPoints'] = $new_stats['BonusPoints'];
$this->sendPmToOther($From['Username'], $toID, $amount);
return $amount;
}
public function sendPmToOther($from, $toID, $amount) {
$to = \Users::user_info($toID);
\Misc::send_pm_with_tpl($toID, 'token_other', ['To' => $to['Username'], 'From' => $from, 'Amount' => $amount]);
}
private function addPurchaseHistory($item_id, $user_id, $price, $other_user_id = null) {
$this->cache->delete_value(self::CACHE_SUMMARY . $user_id);
$this->cache->delete_value(self::CACHE_HISTORY . $user_id . ".1");
$this->db->prepared_query(
'INSERT INTO bonus_history (ItemID, UserID, price, OtherUserID) VALUES (?, ?, ?, ?)',
$item_id,
$user_id,
$price,
$other_user_id
);
return $this->db->affected_rows();
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace Gazelle;
class BonusPool extends Base {
protected $id;
const CACHE_SENT = 'bonuspool_sent_%d';
public function __construct(int $id) {
parent::__construct();
$this->id = $id;
}
public function contribute(int $user_id, $value_recv, $value_sent): int {
$this->db->prepared_query(
"
INSERT INTO bonus_pool_contrib
(bonus_pool_id, user_id, amount_recv, amount_sent)
VALUES (?, ?, ?, ?)
",
$this->id,
$user_id,
$value_recv,
$value_sent
);
$this->db->prepared_query(
"
UPDATE bonus_pool SET
total = total + ?
WHERE bonus_pool_id = ?
",
$value_sent,
$this->id
);
$this->cache->delete_value(sprintf(self::CACHE_SENT, $this->id));
return $this->db->affected_rows();
}
public function total(): int {
$key = sprintf(self::CACHE_SENT, $this->id);
if (($total = $this->cache->get_value($key)) === false) {
$total = $this->db->scalar(
"
SELECT total FROM bonus_pool WHERE bonus_pool_id = ?
",
$this->id
) ?? 0;
$this->cache->cache_value($key, $total, 6 * 3600);
}
return $total;
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace Gazelle;
class DB extends Base {
/**
* Skip foreign key checks
* @param $relax true if foreign key checks should be skipped
*/
public function relaxConstraints(bool $relax) {
if ($relax) {
$this->db->prepared_query("SET foreign_key_checks = 0");
} else {
$this->db->prepared_query("SET foreign_key_checks = 1");
}
return $this;
}
/**
* Soft delete a row from a table <t> by inserting it into deleted_<t> and then delete from <t>
* @param string $schema the schema name
* @param string $table the table name
* @param array $condition Must be an array of arrays, e.g. [[column_name, column_value]] or [[col1, val1], [col2, val2]]
* Will be used to identify the row (or rows) to delete
* @param boolean $delete whether to delete the matched rows
* @return array 2 elements, true/false and message if false
*/
public function softDelete($schema, $table, array $condition, $delete = true) {
$sql = 'SELECT column_name, column_type FROM information_schema.columns WHERE table_schema = ? AND table_name = ? ORDER BY 1';
$this->db->prepared_query($sql, $schema, $table);
$t1 = $this->db->to_array();
$n1 = count($t1);
$softDeleteTable = 'deleted_' . $table;
$this->db->prepared_query($sql, $schema, $softDeleteTable);
$t2 = $this->db->to_array();
$n2 = count($t2);
if (!$n1) {
return [false, "No such table $table"];
} elseif (!$n2) {
return [false, "No such table $softDeleteTable"];
} elseif ($n1 != $n2) {
// tables do not have the same number of columns
return [false, "$table and $softDeleteTable column count mismatch ($n1 != $n2)"];
}
$column = [];
for ($i = 0; $i < $n1; ++$i) {
// a column does not have the same name or datatype
if (strtolower($t1[$i][0]) != strtolower($t2[$i][0]) || $t1[$i][1] != $t2[$i][1]) {
return [false, "{$table}: column {$t1[$i][0]} name or datatype mismatch {$t1[$i][0]}:{$t2[$i][0]} {$t1[$i][1]}:{$t2[$i][1]}"];
}
$column[] = $t1[$i][0];
}
$columnList = implode(', ', $column);
$conditionList = implode(' AND ', array_map(function ($c) {
return "{$c[0]} = ?";
}, $condition));
$argList = array_map(function ($c) {
return $c[1];
}, $condition);
$sql = "INSERT INTO $softDeleteTable
($columnList)
SELECT $columnList
FROM $table
WHERE $conditionList";
$this->db->prepared_query($sql, ...$argList);
if ($this->db->affected_rows() == 0) {
return [false, "condition selected 0 rows"];
}
if (!$delete) {
return [true, "rows affected: " . $this->db->affected_rows()];
}
$sql = "DELETE FROM $table WHERE $conditionList";
$this->db->prepared_query($sql, ...$argList);
return [true, "rows deleted: " . $this->db->affected_rows()];
}
/**
* Calculate page and SQL limit
* @param int $pageSize records per page
* @param int $page current page or a falsey value to fetch from $_REQUEST
*/
public static function pageLimit(int $pageSize, int $page = 0) {
if (!$page) {
$page = max(1, (int)($_REQUEST['page'] ?? 0));
}
return [$page, $pageSize, $pageSize * ($page - 1)];
}
}
+6
View File
@@ -0,0 +1,6 @@
<?php
namespace Gazelle\Exception;
class BonusException extends \Exception {
}
@@ -0,0 +1,6 @@
<?php
namespace Gazelle\Exception;
class BookmarkIdentifierException extends \Exception {
}
@@ -0,0 +1,6 @@
<?php
namespace Gazelle\Exception;
class BookmarkUnknownTypeException extends \Exception {
}
@@ -0,0 +1,6 @@
<?php
namespace Gazelle\Exception;
class CollageUserNotSetException extends \Exception {
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace Gazelle\Exception;
use Throwable;
class InvalidAccessException extends \RuntimeException {
public function __construct(string $message = 'You are not authorized to access this action', int $code = 0, Throwable $previous = null) {
parent::__construct($message, $code, $previous);
}
}
@@ -0,0 +1,6 @@
<?php
namespace Gazelle\Exception;
class PaymentFetchForexException extends \Exception {
}
@@ -0,0 +1,6 @@
<?php
namespace Gazelle\Exception;
class ResourceNotFoundException extends \Exception {
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace Gazelle\Exception;
use Throwable;
class RouterException extends \RuntimeException {
public function __construct(string $message = "The route you tried to access is not available.", int $code = 0, Throwable $previous = null) {
parent::__construct($message, $code, $previous);
}
}
@@ -0,0 +1,6 @@
<?php
namespace Gazelle\Exception;
class TorrentManagerIdNotSetException extends \Exception {
}
@@ -0,0 +1,6 @@
<?php
namespace Gazelle\Exception;
class TorrentManagerUserNotSetException extends \Exception {
}
+6
View File
@@ -0,0 +1,6 @@
<?php
namespace Gazelle\Exception;
class UserCreatorException extends \Exception {
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace Gazelle;
abstract class File extends Base {
/**
* Store a file on disk at the specified path.
*
* @param string $source The contents of the file
* @param integer|array $id The unique identifier of the object
* @return boolean Success of the operation
*/
public function put(string $source, $id) {
return file_put_contents($this->path($id), $source);
}
/**
* Does the file exist?
*
* @param integer|array $id The unique identifier of the object
* @return boolean Existence
*/
public function exists($id) {
return file_exists($this->path($id));
}
/**
* Retrieve the contents of the stored file.
*
* @param integer|array $id The unique identifier of the object
* @return string File contents
*/
public function get($id) {
return file_get_contents($this->path($id));
}
/**
* Remove the stored file.
*
* @param integer|array $id The unique identifier of the object
* @return boolean Success of unlink operation
*/
public function remove(/* mixed */$id) {
return @unlink($this->path($id));
}
/**
* Path of stored file
*
* @param integer|array $id The unique identifier of the object
* @return string Fully qualified filename of object
*/
public function path(/* mixed */$id) {
return "/tmp/$id";
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Gazelle\File;
class Torrent extends \Gazelle\File {
const STORAGE = STORAGE_PATH_TORRENT;
/**
* Path of a torrent file
*
* @param int id of torrent
* @return Fully qualified filename
*/
public function path(/* array */$id) {
$key = strrev(sprintf('%04d', $id));
$k1 = substr($key, 0, 2);
$k2 = substr($key, 2, 2);
return sprintf('%s/%02d/%02d/%d.torrent', self::STORAGE, $k1, $k2, $id);
}
}
+129
View File
@@ -0,0 +1,129 @@
<?php
namespace Gazelle;
class Image {
protected $image;
protected $height;
protected $width;
protected $type;
public function __construct(string $data) {
$this->image = imagecreatefromstring($data);
if ($this->image) {
[$this->height, $this->width, $this->type] = getimagesizefromstring($data);
} else {
[$this->height, $this->width, $this->type] = [0, 0, 0];
}
}
function height(): int {
return $this->height;
}
function width(): int {
return $this->width;
}
function display() {
switch ($this->type) {
case IMG_BMP:
return imagebmp($this->image);
case IMG_GIF:
return imagegif($this->image);
case IMG_JPG:
return imagejpeg($this->image, null, 90);
case IMG_PNG:
return imagepng($this->image);
case IMG_WBMP:
return imagewbmp($this->image);
case IMG_WEBP:
return imagewebp($this->image);
case IMG_XPM:
return imagexbm($this->image, null);
}
}
function type(): string {
switch ($this->type) {
case IMG_BMP:
return 'bmp';
case IMG_GIF:
return 'gif';
case IMG_JPG:
return 'jpg';
case IMG_PNG:
return 'png';
case IMG_WBMP:
return 'wbmp';
case IMG_WEBP:
return 'webp';
case IMG_XPM:
return 'xpm';
default:
return 'error';
}
}
public function error(): bool {
return $this->type === 0;
}
public function invisible(): bool {
$count = imagecolorstotal($this->image);
if ($count == 0) {
return false;
}
$alpha = 0;
for ($i = 0; $i < $count; ++$i) {
$color = imagecolorsforindex($this->image, $i);
$alpha += $color['alpha'];
}
return $alpha / $count == 127;
}
public function verysmall(): bool {
return $this->height * $this->width <= 256;
}
/**
* Build and emit an image containing a simple text message.
*/
public static function render(string $text) {
$font = realpath(__DIR__ . '/../fonts/VERDANAB.TTF');
$pointSize = 40.0;
while (true) {
[$left,, $right] = imageftbbox($pointSize, 0, $font, $text);
$width = $right - $left;
if ($width < 200) {
break;
}
// too wide, but now we know what point size will make it fit
$pointSize /= ($width + 10) / 200;
}
$image = imagecreatetruecolor(200, 200);
$foreground = imagecolorallocate($image, 0x1f, 0xd5, 0x4f);
$background = imagecolorallocate($image, 0x05, 0x14, 0x01);
imagefill($image, 0, 0, $background);
imagefttext($image, $pointSize, 0, (200 - $width) / 2, 120, $foreground, $font, $text);
imagepng($image);
imagedestroy($image);
}
/**
* Debugging responses that return images can be tricky,
* you cannot var_dump() your way out. Use this function
* to create simple pngs that are stored in a temp
* directory, and then you can have a look afterwards.
*/
public static function debug(string $text) {
ob_start();
self::render($text);
$data = ob_get_contents();
ob_end_clean();
$out = fopen(TMPDIR . "/$text.png", 'wb');
fputs($out, $data);
fclose($out);
}
}
+260
View File
@@ -0,0 +1,260 @@
<?php
namespace Gazelle;
class Inbox extends Base {
const ALT_SORT = 2;
const CUR_SORT = 1;
const DEFAULT_SORT = 0;
const HTML = true;
const RAW = false;
const SEARCH_FIELDS = [
'user' => 'um.Username',
'subject' => 'c.Subject',
'message' => 'm.Body',
];
const SECTIONS = [
'inbox' => [
'title' => 'Inbox',
'dateField' => 'cu.ReceivedDate',
],
'sentbox' => [
'title' => 'Sentbox',
'dateField' => 'cu.SentDate',
],
];
// These two need to match values of $LoggedUser['ListUnreadPMsFirst']
const UNREAD_FIRST = true;
const NEWEST_FIRST = false;
/** @var int */
private $userId;
/** @var bool */
private $unreadFirstDefault;
/** @var string */
private $section;
/** @var bool */
private $unreadFirst;
/** @var string */
private $searchField;
/** @var string */
private $searchTerm;
/** @var string */
private $sql = '';
/**
* Inbox constructor
*
* @param int $userId usually $LoggedUser['ID']
* @param bool $unreadFirstDefault the user's inbox sort setting
* @param array $params associative config array, usually $_GET
*/
public function __construct($userId, $unreadFirstDefault = self::NEWEST_FIRST, array $params = []) {
parent::__construct();
$this->userId = $userId;
$this->unreadFirstDefault = (bool) $unreadFirstDefault;
if (empty($params)) {
$params = $_GET;
}
$this->section = $params['section'] ?? $params['action'] ?? key(self::SECTIONS);
if (!isset(self::SECTIONS[$this->section])) {
throw new \Exception('Inbox:new:badsection');
}
$this->unreadFirst = (isset($params['sort']) && $params['sort'] == 'unread')
? self::UNREAD_FIRST
: self::NEWEST_FIRST;
$this->searchField = $params['searchtype'] ?? null;
$this->searchTerm = $params['search'] ?? null;
if (
isset($this->searchField)
&& !isset(self::SEARCH_FIELDS[$this->searchField])
) {
throw new \Exception('Inbox:new:badsearchfield');
}
$this->sql = "
SELECT %s
FROM pm_conversations AS c
INNER JOIN pm_conversations_users AS cu ON (cu.ConvID = c.ID AND cu.UserID = ?)
LEFT JOIN pm_conversations_users AS cu2 ON (cu2.ConvID = c.ID AND cu2.UserID != ? AND cu2.ForwardedTo = 0)
LEFT JOIN users_main AS um ON (um.ID = cu2.UserID)
%s
WHERE cu.In" . ucfirst($this->section) . " = '1' %s
ORDER BY cu.Sticky,
" . (($this->unreadFirst === self::UNREAD_FIRST)
? "cu.Unread = '1' DESC,"
: '') . self::SECTIONS[$this->section]['dateField'] . " DESC
%s";
}
/**
* Generate the link to a user's inbox.
*
* @param string $section whether the inbox or sentbox should be used
* @param bool $html whether the output should have HTML entities
* @param bool $sort whether to sort according to current setting
* @return string the URL to a user's inbox
*/
public function getLink($section = null, $html = self::HTML, $sort = self::DEFAULT_SORT) {
$unreadFirst = self::NEWEST_FIRST;
if (($sort === self::DEFAULT_SORT && $this->unreadFirstDefault === self::UNREAD_FIRST)
|| ($sort === self::CUR_SORT && $this->unreadFirst === self::UNREAD_FIRST)
|| ($sort === self::ALT_SORT && $this->unreadFirst === self::NEWEST_FIRST)
) {
$unreadFirst = self::UNREAD_FIRST;
}
$search = [];
if ($this->searchField && $this->searchTerm) {
$search['searchtype'] = $this->searchField;
$search['search'] = $this->searchTerm;
}
return self::getLinkQuick($section, $unreadFirst, $html, $search);
}
/**
* Generate the link to a user's inbox.
*
* @param string $section whether the inbox or sentbox should be used
* @param bool $unreadFirst whether to sort by unread first
* @param bool $html whether the output should have HTML entities
* @return string the URL to a user's inbox
*/
public static function getLinkQuick($section = null, $unreadFirst = self::NEWEST_FIRST, $html = self::HTML, $search = []) {
if (empty($section) || !isset(self::SECTIONS[$section])) {
$section = key(self::SECTIONS);
}
$query = [];
if ($section !== key(self::SECTIONS)) {
$query['section'] = $section;
}
if ($unreadFirst === self::UNREAD_FIRST) {
$query['sort'] = 'unread';
}
$query = http_build_query(array_merge($query, $search));
return (empty($query))
? 'inbox.php'
: 'inbox.php?'
. (($html === self::HTML) ? display_str($query) : $query);
}
/**
* Return the current sort value we're using
*
* @return string
*/
public function getSort() {
return $this->unreadFirst;
}
/**
* Return the current section we're in
*
* @return string
*/
public function section() {
return $this->section;
}
/**
* Return a section title
*
* @param string $section The section's title you want, or 'opposite' of current
* @return string
*/
public function title($section = null) {
if (!isset($section)) {
$section = $this->section;
} else if (!isset(self::SECTIONS[$section])) {
throw new \Exception('Inbox:title:badsection');
}
return self::SECTIONS[$section]['title'];
}
/**
* Runs the query and returns the total result count,
* the count on this page, and the results on this page.
*
* @return void
*/
public function result() {
$searching = (!empty($this->searchField) && !empty($this->searchTerm));
$table = ($searching && $this->searchField === 'message')
? 'INNER JOIN pm_messages AS m ON (c.ID = m.ConvID) '
: '';
$search = '';
$searchWords = (!empty($this->searchTerm))
? array_map(function ($val) {
return "%$val%";
}, explode(' ', $this->searchTerm))
: [];
if ($searching) {
for ($i = 0, $wc = count($searchWords); $i < $wc; $i++) {
$search .= 'AND ' . self::SEARCH_FIELDS[$this->searchField] . ' LIKE ? ';
}
}
// No limit - get total matching record count
$totalCount = $this->db->scalar(
sprintf(
$this->sql,
'count(*)',
$table,
$search,
''
),
$this->userId,
$this->userId,
...$searchWords
);
// Now set up the main query for this page's results
$cols = "
c.ID,
c.Subject,
cu.Unread,
cu.Sticky,
cu.ForwardedTo,
cu2.UserID,
" . self::SECTIONS[$this->section]['dateField'];
$search .= 'GROUP BY c.ID';
// TODO: the function below has a fragile dep on $_GET
$limit = "LIMIT " . \Format::page_limit(MESSAGES_PER_PAGE)[1];
$this->db->prepared_query(
sprintf(
$this->sql,
$cols,
$table,
$search,
$limit
),
$this->userId,
$this->userId,
...$searchWords
);
// The count on this page
$pageCount = $this->db->record_count();
$results = [];
while ($data = $this->db->next_record()) {
// Each result
$results[] = $data;
}
return [$totalCount, $pageCount, $results];
}
}
+312
View File
@@ -0,0 +1,312 @@
<?php
namespace Gazelle;
/* The invite tree is a bodge because Mysql cannot do recursive tree queries.
* When looking at the Invite Tree page, `TreePosition` is a measure of how
* far down the user appears, and `TreeLevel` represents how far across they
* are indented.
*/
class InviteTree extends Base {
protected $userId;
protected $treeId;
protected $treeLevel;
protected $treePosition;
protected $maxPosition;
public function __construct(int $userId) {
parent::__construct();
$this->userId = $userId;
[$this->treeId, $this->treeLevel, $this->treePosition, $this->maxPosition] = $this->db->row(
"
SELECT
t1.TreeID,
t1.TreeLevel,
t1.TreePosition,
(
SELECT t2.TreePosition
FROM invite_tree AS t2
WHERE t2.TreeID = t1.TreeID
AND t2.TreeLevel = t1.TreeLevel
AND t2.TreePosition > t1.TreePosition
ORDER BY t2.TreePosition
LIMIT 1
)
FROM invite_tree AS t1
WHERE t1.UserID = ?
",
$this->userId
);
}
public function treeId(): ?int {
return $this->treeId;
}
public function hasInvitees(): bool {
return $this->db->scalar(
"
SELECT 1
FROM invite_tree
WHERE InviterId = ?
LIMIT 1
",
$this->userId
) ? true : false;
}
public function inviteeList(): array {
$this->db->prepared_query(
"
SELECT UserID
FROM invite_tree
WHERE TreeID = ?
AND TreeLevel > ?
AND TreePosition > ?
AND TreePosition < coalesce(?, 100000000)
ORDER BY TreePosition
",
$this->treeId,
$this->treeLevel,
$this->treePosition,
$this->maxPosition
);
return $this->db->collect('UserID');
}
public function add(int $userId) {
// TODO: use the new instance variables instead of doing a lookup here
while (true) {
[$treeId, $inviterPosition, $level] = $this->db->row(
"
SELECT TreeID, TreePosition, TreeLevel
FROM invite_tree
WHERE UserID = ?
",
$this->userId
);
if ($treeId) {
break;
}
// Not everyone is created by the genesis user. Invite trees may be disconnected.
$this->db->prepared_query(
"
INSERT INTO invite_tree
(UserID, TreeID)
VALUES (?, (SELECT coalesce(max(it.TreeID), 0) + 1 FROM invite_tree AS it))
",
$this->userId
);
}
$nextPosition = $this->db->scalar(
"
SELECT TreePosition
FROM invite_tree
WHERE TreeID = ?
AND TreePosition > ?
AND TreeLevel <= ?
ORDER BY TreePosition LIMIT 1
",
$treeId,
$inviterPosition,
$level
);
if (!$nextPosition) {
// Tack them on the end of the list.
$nextPosition = $this->db->scalar(
"
SELECT max(TreePosition) + 1
FROM invite_tree
WHERE TreeID = ?
",
$treeId
);
} else {
// Someone invited Alice and then Bob. Later on, Alice invites Carol,
// so Bob and others have to "pushed down" a row so that Carol can
// be lodged under Alice.
$this->db->prepared_query(
"
UPDATE invite_tree SET
TreePosition = TreePosition + 1
WHERE TreeID = ?
AND TreePosition >= ?
",
$treeId,
$nextPosition
);
}
$this->db->prepared_query(
"
INSERT INTO invite_tree
(UserID, InviterID, TreeID, TreePosition, TreeLevel)
VALUES (?, ?, ?, ?, ?)
",
$userId,
$this->userId,
$treeId,
$nextPosition,
$level + 1
);
}
function render(\Twig\Environment $twig): string {
$qid = $this->db->get_query_id();
[$treeId, $position, $level] = $this->db->row(
"
SELECT TreeID, TreePosition, TreeLevel
FROM invite_tree
WHERE UserID = ?
",
$this->userId
);
if (!$treeId) {
return '';
}
$maxLevel = $level; // The deepest level (this changes)
$startLevel = $level; // The level of the user we're viewing
$prevLevel = $level;
$stats = [
'total' => 0,
'branch' => 0,
'disabled' => 0,
'donor' => 0,
'paranoid' => 0,
'upload_total' => 0,
'download_total' => 0,
'upload_top' => 0,
'download_top' => 0,
];
$args = [$treeId, $position, $level];
$maxPosition = $this->db->scalar(
"
SELECT TreePosition
FROM invite_tree
WHERE TreeID = ?
AND TreePosition > ?
AND TreeLevel = ?
ORDER BY TreePosition ASC
LIMIT 1
",
$treeId,
$position,
$level
);
if (is_null($maxPosition)) {
$maxCond = '/* no max pos */';
} else {
$maxCond = 'AND TreePosition < ?';
$args[] = $maxPosition;
}
$treeQ = $this->db->prepared_query(
"
SELECT
it.UserID,
um.Enabled,
um.PermissionID,
(donor.UserID IS NOT NULL) AS Donor,
uls.Uploaded,
uls.Downloaded,
um.Paranoia,
it.TreePosition,
it.TreeLevel
FROM invite_tree AS it
INNER JOIN users_main AS um ON (um.ID = it.UserID)
INNER JOIN users_leech_stats AS uls ON (uls.UserID = it.UserID)
LEFT JOIN users_levels AS donor ON (donor.UserID = it.UserID
AND donor.PermissionID = (SELECT ID FROM permissions WHERE Name = 'Donor' LIMIT 1)
)
WHERE TreeID = ?
AND TreePosition > ?
AND TreeLevel > ?
$maxCond
ORDER BY TreePosition
",
...$args
);
$markup = '';
$classSummary = [];
while ([$inviteeId, $enabled, $permissionId, $donor, $uploaded, $downloaded, $paranoia, $position, $level]
= $this->db->next_record(MYSQLI_NUM, false)
) {
$stats['total']++;
if ($enabled == 2) {
$stats['disabled']++;
}
if ($donor) {
$stats['donor']++;
}
if ($level == $startLevel + 1) {
$stats['branch']++;
$stats['upload_top'] += $uploaded;
$stats['download_top'] += $downloaded;
}
if (!isset($classSummary[$permissionId])) {
$classSummary[$permissionId] = 0;
}
$classSummary[$permissionId]++;
// Manage tree depth
if ($level > $prevLevel) {
$markup .= str_repeat("<ul class=\"invitetree\">\n<li>\n", $level - $prevLevel);
} elseif ($level < $prevLevel) {
$markup .= str_repeat("</li>\n</ul>\n", $prevLevel - $level) . "</li>\n<li>\n";
} else {
$markup .= "</li>\n<li>\n";
}
$markup .= '<strong>' . \Users::format_username($inviteeId, true, true, ($enabled != 2 ? false : true), true)
. '</strong>';
global $Classes;
if (!check_paranoia(['uploaded', 'downloaded'], $paranoia, $Classes[$permissionId]['Level'])) {
$markup .= "&nbsp;Hidden";
$stats['paranoid']++;
} else {
$markup .= sprintf(
" Uploaded:&nbsp;<strong>%s</strong> Downloaded:&nbsp;<strong>%s</strong> Ratio:&nbsp;<strong>%s</strong>",
\Format::get_size($uploaded),
\Format::get_size($downloaded),
\Format::get_ratio_html($uploaded, $downloaded)
);
$stats['upload_total'] += $uploaded;
$stats['download_total'] += $downloaded;
}
if ($maxLevel < $level) {
$maxLevel = $level;
}
$prevLevel = $level;
$this->db->set_query_id($treeQ);
}
$markup .= str_repeat("</li>\n</ul>\n", $prevLevel - $startLevel);
if (!$stats['total']) {
$summary = '';
} else {
$className = [];
foreach ($classSummary as $id => $count) {
$name = \Users::make_class_string($id);
if ($count > 1) {
$name = ($name == 'Torrent Celebrity') ? 'Torrent Celebrities' : "{$name}s";
}
$className[] = "$count $name (" . number_format(($count / $stats['total']) * 100) . '%)';
}
$summary = $twig->render('user/invite-tree.twig', [
'classes' => $className,
'depth' => $maxLevel - $startLevel,
'pc_disabled' => $stats['disabled'] / $stats['total'] * 100,
'pc_donor' => $stats['donor'] / $stats['total'] * 100,
'pc_paranoid' => $stats['paranoid'] / $stats['total'] * 100,
'stats' => $stats,
]);
}
return '<div class="invitetree pad">' . $summary . $markup . '</div>';
$this->db->set_query_id($qid);
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace Gazelle;
abstract class Json extends Base {
protected $version;
protected $source;
protected $mode;
public function __construct() {
parent::__construct();
$this->source = SITE_NAME;
$this->mode = 0;
$this->version = 1;
}
/**
* The payload of a valid JSON response, implemented in the child class.
* @return array Payload to be passed to json_encode()
* null if the payload cannot be produced (permissions, id not found, ...).
*/
abstract public function payload(): ?array;
/**
* Configure JSON printing (any of the json_encode JSON_* constants)
*
* @param int $mode the bit-or'ed values to confgure encoding results
*/
public function setMode(string $mode) {
$this->mode = $mode;
return $this;
}
/**
* set the version of the Json payload. Increment the
* value when there is significant change in the payload.
* If not called, the version defaults to 1.
*
* @param int version
*/
public function setVersion(int $version) {
$this->version = $version;
return $this;
}
/**
* General failure routine for when bad things happen.
*
* @param string $message The error set in the JSON response
*/
public function failure(string $message) {
print json_encode(
array_merge(
[
'status' => 'failure',
'response' => [],
'error' => $message,
],
$this->info(),
$this->debug()
),
$this->mode
);
}
public function emit() {
$payload = $this->payload();
if (!$payload) {
return;
}
print json_encode(
array_merge(
[
'status' => 'success',
'response' => $payload,
],
$this->info(),
$this->debug()
),
$this->mode
);
}
protected function debug() {
if (!check_perms('site_debug')) {
return [];
}
global $Debug;
return [
'debug' => [
'queries' => $Debug->get_queries(),
'searches' => $Debug->get_sphinxql_queries(),
],
];
}
protected function info() {
return [
'info' => [
'source' => $this->source,
'version' => $this->version,
]
];
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace Gazelle;
class Log extends Base {
/**
* Write a general message to the system log.
*
* @param string $message the message to write.
*/
public function general(string $message) {
$qid = $this->db->get_query_id();
$this->db->prepared_query(
"
INSERT INTO log (Message) VALUES (?)
",
trim($message)
);
$this->db->set_query_id($QueryID);
return $this;
}
/**
* Write a group entry
*
* @param int $groupId
* @param int $userId
* @param string $message
*/
public function group(int $groupId, int $userId, $message) {
$qid = $this->db->get_query_id();
$this->db->prepared_query(
"
INSERT INTO group_log
(GroupID, UserID, Info, TorrentID, Hidden)
VALUES (?, ?, ?, 0, 0)
",
$groupId,
$userId,
$message
);
$this->db->set_query_id($qid);
return $this;
}
/**
* Write a torrent entry
*
* @param int $groupId
* @param int $torrentId
* @param int $userId
* @param string $message
*/
public function torrent(int $groupId, int $torrentId, int $userId, $message) {
$qid = $this->db->get_query_id();
$this->db->prepared_query(
"
INSERT INTO group_log
(GroupID, TorrentID, UserID, Info, Hidden)
VALUES (?, ?, ?, ?, 0)
",
$groupId,
$torrentId,
$userId,
$message
);
$this->db->set_query_id($qid);
return $this;
}
}
+262
View File
@@ -0,0 +1,262 @@
<?php
namespace Gazelle;
class LoginWatch extends Base {
protected $watchId;
/**
* Set the context of a watched IP address (to save passing it in to each method call).
* On a virgin login with no previous errors there may not even be a watch yet
* @param int ID of the watch
*/
public function setWatch($watchId) {
if (!is_null($watchId)) {
$this->watchId = $watchId;
}
return $this;
}
/**
* Find a login watch by IP address
* @param string IPv4 address
* @return array [watchId, nrAttemtps, nrBans, bannedUntil]
*/
public function findByIp(string $ipaddr): ?array {
return $this->db->row(
"
SELECT ID, Attempts, Bans, BannedUntil
FROM login_attempts
WHERE IP = ?
",
$_SERVER['REMOTE_ADDR']
);
}
/**
* Create a new login watch on an userid/username/ipaddress
* @param string IPv4 address
* @param string|null $capture The username captured on the form
* @param int $userId
* @return int ID of watch
*/
public function create(string $ipaddr, ?string $capture, int $userId = 0) {
$this->db->prepared_query(
"
INSERT INTO login_attempts
(IP, capture, UserID)
VALUES (?, ?, ?)
",
$ipaddr,
$capture,
$userId
);
return ($this->watchId = $this->db->inserted_id());
}
/**
* Record another failure attempt on this watch. If the user has not
* logged in recently from this IP address then subsequent logins
* will be blocked for increasingly longer times, otherwise 1 minute.
*
* @param int $userId The ID of the user
* @param string $ipaddr The IP the user is coming from
* @param string $capture The username captured on the form
* @return int 1 if the watch was updated
*/
public function increment(int $userId, string $ipaddr, ?string $capture): int {
$seen = $this->db->scalar(
"
SELECT 1
FROM users_history_ips
WHERE (EndTime IS NULL OR EndTime > now() - INTERVAL 1 WEEK)
AND UserID = ?
AND IP = ?
",
$userId,
$ipaddr
);
$delay = $seen ? 60 : LOGIN_ATTEMPT_BACKOFF[min($this->nrAttempts(), count(LOGIN_ATTEMPT_BACKOFF) - 1)];
$this->db->prepared_query(
'
UPDATE login_attempts SET
Attempts = Attempts + 1,
LastAttempt = now(),
BannedUntil = now() + INTERVAL ? SECOND,
UserID = ?,
capture = ?
WHERE ID = ?
',
$delay,
$userId,
$capture,
$this->watchId
);
return $this->db->affected_rows();
}
/**
* Ban subsequent attempts to login from this watched IP address for 6 hours
* @param int $attempts How many attempts so far?
* @param string the username captured on the form (which may not even be a valid user)
* @param int $userId user ID of a valid user (or 0 if invalid username)
* @return int 1 if the watch was banned
*/
public function ban(int $attempts, ?string $capture, int $userId = 0): int {
$this->db->prepared_query(
'
UPDATE login_attempts SET
Bans = Bans + 1,
LastAttempt = now(),
BannedUntil = now() + INTERVAL 6 HOUR,
Attempts = ?,
capture = ?,
UserID = ?
WHERE ID = ?
',
$attempts,
$capture,
$userId,
$this->watchId
);
return $this->db->affected_rows();
}
/**
* When does the login ban expire?
* @return string datestamp of expiry
*/
public function bannedUntil(): ?string {
return $this->db->scalar(
"
SELECT BannedUntil FROM login_attempts WHERE ID = ?
",
$this->watchId
);
}
/**
* If the login ban was in the past then they get 6 more shots
* @return int 1 if a prior ban was cleared
*/
public function clearPriorBan(): int {
$this->db->prepared_query(
"
UPDATE login_attempts SET
BannedUntil = NULL,
Attempts = 0
WHERE BannedUntil < now() AND ID = ?
",
$this->watchId
);
return $this->db->affected_rows();
}
/**
* If the login was successful, clear prior attempts
* @return int 1 if an update was made
*/
public function clearAttempts(): int {
$this->db->prepared_query(
"
UPDATE login_attempts SET
Attempts = 0
WHERE ID = ?
",
$this->watchId
);
return $this->db->affected_rows();
}
/**
* How many attempts have been made on this watch?
* @return int Number of attempts
*/
public function nrAttempts(): int {
return (int)$this->db->scalar(
"
SELECT Attempts FROM login_attempts WHERE ID = ?
",
$this->watchId
) ?? 0;
}
/**
* Get the list of login failures
* @return array list [ID, ipaddr, userid, LastAttempt (datetime), Attempts, BannedUntil (datetime), Bans]
*/
public function activeList(string $orderBy, string $orderWay): array {
$this->db->prepared_query("
SELECT
w.ID AS id,
w.IP AS ipaddr,
w.UserID AS user_id,
w.LastAttempt AS last_attempt,
w.Attempts AS attempts,
w.BannedUntil AS banned_until,
w.Bans AS bans,
w.capture,
um.Username AS username,
(ip.FromIP IS NOT NULL) AS banned
FROM login_attempts w
LEFT JOIN users_main um ON (um.ID = w.UserID)
LEFT JOIN ip_bans ip ON (ip.FromIP = inet_aton(w.IP))
WHERE (w.BannedUntil > now() OR w.LastAttempt > now() - INTERVAL 6 HOUR)
ORDER BY $orderBy $orderWay
");
return $this->db->to_array('id', MYSQLI_ASSOC, false);
}
/**
* Ban the IP addresses pointed to by the IDs that are on login watch.
* @param array list of IDs to ban.
* @return number of addresses banned
*/
public function setBan(int $userId, string $reason, array $list): int {
if (!$list) {
return 0;
}
$reason = trim($reason);
$n = 0;
foreach ($list as $id) {
$ipv4 = $this->db->scalar(
"
SELECT inet_aton(IP) FROM login_attempts WHERE ID = ?
",
$id
);
$this->db->prepared_query(
"
INSERT IGNORE INTO ip_bans
(user_id, Reason, FromIP, ToIP)
VALUES (?, ?, ?, ?)
",
$userId,
$reason,
$ipv4,
$ipv4
);
$n += $this->db->affected_rows();
}
return $n;
}
/**
* Clear the list of IDs that are on login watch.
* @param array list of IDs to clear.
* @return number of rows removed
*/
public function setClear(array $list): int {
if (!$list) {
return 0;
}
$this->db->prepared_query(
"
DELETE FROM login_attempts
WHERE ID in (" . placeholders($list) . ")
",
...$list
);
return $this->db->affected_rows();
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Gazelle\Manager;
class Artist extends \Gazelle\Base {
public function createArtist($name) {
$this->db->prepared_query(
'
INSERT INTO artists_group (Name)
VALUES (?)
',
$name
);
$artistId = $this->db->inserted_id();
$this->db->prepared_query(
'
INSERT INTO artists_alias (ArtistID, Name)
VALUES (?, ?)
',
$artistId,
$name
);
$aliasId = $this->db->inserted_id();
$this->cache->increment('stats_artist_count');
return [$artistId, $aliasId];
}
}
+178
View File
@@ -0,0 +1,178 @@
<?php
namespace Gazelle\Manager;
class Blog extends \Gazelle\Base {
const CACHE_KEY = 'blogv2';
public function flushCache() {
$this->cache->deleteMulti(['feed_blog', self::CACHE_KEY]);
}
/**
* Create a blog article
* @param array
* - userId The UserID of the author
* - title The title of the article
* - body The body of the article
* - threadId The associated threadId
* - important The level of importance
* @return ID of new article
*/
public function create(array $info): \Gazelle\Blog {
$this->db->prepared_query(
"
INSERT INTO blog
(UserID, Title, Body, ThreadID, Important)
VALUES (?, ?, ?, ?, ?)
",
$info['userId'],
trim($info['title']),
trim($info['body']),
$info['threadId'],
$info['important']
);
$this->flushCache();
return new \Gazelle\Blog($this->db->inserted_id());
}
/**
* Modify an existing blog article
*
* @param array
* - id The id of the news article
* - title The title of the article
* - body The body of the article
* - threadId The associated threadId
* - important The level of importance
* @return 1 if successful
*/
public function modify(array $info): int {
$this->db->prepared_query(
"
UPDATE blog SET
Title = ?,
Body = ?,
ThreadID = ?,
Important = ?
WHERE ID = ?
",
trim($info['title']),
trim($info['body']),
$info['threadId'],
$info['important'],
$info['id']
);
$this->flushCache();
return $this->db->affected_rows();
}
/**
* Remove an existing blog article
* @param int ID of the blog article
* @return bool true if the supplied ID corresponded to a blog article
*/
public function remove(int $blogId): bool {
$this->db->prepared_query(
"
DELETE FROM blog WHERE ID = ?
",
$blogId
);
$removed = $this->db->affected_rows() == 1;
if ($removed) {
$this->flushCache();
}
return $removed;
}
/**
* Remove an the link to the forum topic of the blog article
* @param int ID of the blog article
* @return bool true if there was a thread to remove
*/
public function removeThread(int $blogId): bool {
$this->db->prepared_query(
"
UPDATE blog SET
ThreadID = NULL
WHERE ID = ?
",
$blogId
);
$removed = $this->db->affected_rows() == 1;
if ($removed) {
$this->flushCache();
}
return $removed;
}
/**
* Get a number of most recent articles.
* (hard-coded to 20 max, otherwise cache invalidation becomes difficult)
*
* @return array
* - id of article
* - title of article
* - name of author
* - id of author
* - body of article
* - article creation date
* - threadId of associated thread
*/
public function headlines(): array {
if (($headlines = $this->cache->get_value(self::CACHE_KEY)) === false) {
$this->db->prepared_query("
SELECT b.ID, b.Title, um.Username, b.UserID, b.Body, b.Time, b.ThreadID
FROM blog b
INNER JOIN users_main um ON (um.ID = b.UserID)
WHERE b.Time <= now()
ORDER BY b.Time DESC
LIMIT 20
");
$headlines = $this->db->to_array(false, MYSQLI_NUM, false);
$this->cache->cache_value(self::CACHE_KEY, $headlines, 86400);
}
return $headlines;
}
/**
* Get the latest blog article id and title
* ID will be -1 if no article yet exists.
*
* @return array [$id, $title]
*/
public function latest(): array {
$headlines = $this->headlines();
return $headlines[0] ?? [-1, null, null, null, null, null, null];
}
/**
* Get the latest blog article id
* ID will be -1 if no article yet exists.
*
* @return int $id blog article id
*/
public function latestId(): int {
[$blogId] = $this->latest();
return $blogId;
}
/**
* Indicate a user has read the given blog entry
* @param int $userId The user
* @return bool true if there was a change in status (you will need to flush their heavy cache)
*/
public function catchupUser(int $userId): bool {
$this->db->prepared_query(
"
UPDATE users_info SET
LastReadBlog = (SELECT max(ID) FROM blog)
WHERE UserID = ?
",
$userId
);
return $this->db->affected_rows() == 1;
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace Gazelle\Manager;
class ClientWhitelist extends \Gazelle\Base {
const CACHE_KEY = 'whitelisted_clients';
/**
* Get the peer ID of client
*
* @param int $clientId The ID of the client
* @return string The peer identifier
*/
public function peerId(int $clientId) {
return $this->db->scalar(
"
SELECT peer_id
FROM xbt_client_whitelist
WHERE id = ?
",
$clientId
);
}
public function list() {
if (($list = $this->cache->get_value(self::CACHE_KEY)) === false) {
$this->db->prepared_query("
SELECT id as client_id, vstring, peer_id
FROM xbt_client_whitelist
ORDER BY peer_id ASC
");
$list = $this->db->to_array('client_id', MYSQLI_ASSOC);
$this->cache->cache_value(self::CACHE_KEY, $list, 0);
}
return $list;
}
/**
* Create a client
*
* @param string $peer The new peer identifier
* @param string $vstring The new client vstring
* @return string The new peer identifier (unchanged)
*/
public function create(string $peer, string $vstring) {
$this->db->prepared_query(
"
INSERT INTO xbt_client_whitelist
(peer_id, vstring)
VALUES (?, ?)
",
$peer,
$vstring
);
$this->cache->delete_value(self::CACHE_KEY);
return $peer;
}
/**
* Modify a client
*
* @param int $clientId The ID of the client
* @param string $peer The new peer identifier
* @param string $vstring The new client vstring
* @return string The previous peer identifier
*/
public function modify(int $clientId, string $peer, string $vstring) {
$prevPeer = $this->peerId($clientId);
$this->db->prepared_query(
"
UPDATE xbt_client_whitelist SET
peer_id = ?,
vstring = ?
WHERE id = ?
",
$peer,
$vstring,
$clientId
);
$this->cache->delete_value(self::CACHE_KEY);
return $prevPeer . $this->db->affected_rows();
}
/**
* Remove a client
*
* @param int $clientID The ID of the client
* @return int 0/1 Whether a client was found
*/
public function remove(int $clientId) {
$this->db->prepared_query(
"
DELETE FROM xbt_client_whitelist
WHERE id = ?
",
$clientId
);
$this->cache->delete_value(self::CACHE_KEY);
return $this->db->affected_rows();
}
}
+945
View File
@@ -0,0 +1,945 @@
<?php
namespace Gazelle\Manager;
use Misc;
use Lang;
use DB_MYSQL_DuplicateKeyException;
class DonationSource {
const PrepaidCard = "Prepaid Card";
const AddPoint = "Add Points";
const StaffPM = "Staff PM";
const ModifyValue = "Modify Values";
}
class PrepaidCardStatus {
const Pending = '1';
const Passed = '2';
const Reject = '3';
}
class DonationCurrency {
const CNY = "CNY";
const BTC = "BTC";
}
class Donation extends \Gazelle\Base {
private static $ForumDescriptions = array(
"I want only two houses, rather than seven... I feel like letting go of things",
"A billion here, a billion there, sooner or later it adds up to real money.",
"I've cut back, because I'm buying a house in the West Village.",
"Some girls are just born with glitter in their veins.",
"I get half a million just to show up at parties. My life is, like, really, really fun.",
"Some people change when they think they're a star or something",
"I'd rather not talk about money. Its kind of gross.",
"I have not been to my house in Bermuda for two or three years, and the same goes for my house in Portofino. How long do I have to keep leading this life of sacrifice?",
"When I see someone who is making anywhere from $300,000 to $750,000 a year, that's middle class.",
"Money doesn't make you happy. I now have $50 million but I was just as happy when I had $48 million.",
"I'd rather smoke crack than eat cheese from a tin.",
"I am who I am. I cant pretend to be somebody who makes $25,000 a year.",
"A girl never knows when she might need a couple of diamonds at ten 'o' clock in the morning.",
"I wouldn't run for president. I wouldn't want to move to a smaller house.",
"I have the stardom glow.",
"What's Walmart? Do they like, sell wall stuff?",
"Whenever I watch TV and see those poor starving kids all over the world, I can't help but cry. I mean I'd love to be skinny like that, but not with all those flies and death and stuff.",
"Too much money ain't enough money.",
"What's a soup kitchen?",
"I work very hard and Im worth every cent!",
"To all my Barbies out there who date Benjamin Franklin, George Washington, Abraham Lincoln, you'll be better off in life. Get that money."
);
public function moderatorAdjust(int $UserID, int $Rank, int $TotalRank, string $Reason, int $who) {
$this->donate($UserID, [
"Source" => "Modify Values",
"Rank" => (int)$Rank,
"TotalRank" => (int)$TotalRank,
"SendPM" => false,
"Reason" => $Reason,
"AddedBy" => $who,
]);
}
public function moderatorDonate(int $UserID, string $amount, string $Currency, string $Reason, string $source, int $who) {
$this->donate($UserID, [
"Source" => $source,
"Amount" => $amount,
"Currency" => $Currency,
"SendPM" => true,
"Reason" => $Reason,
"AddedBy" => $who,
]);
}
public function prepaidCardDonate(int $PrepaidCardID, $Who) {
$prepaidCardInfo = $this->prepaidCard($PrepaidCardID);
if (empty($prepaidCardInfo)) {
return Lang::get('donate', 'donate_error');
}
$UserID = $prepaidCardInfo['user_id'];
$this->db->prepared_query(
'
UPDATE donations_prepaid_card SET STATUS = ? WHERE id = ?',
PrepaidCardStatus::Passed,
$PrepaidCardID
);
$text = $this->donate($UserID, [
"Source" => DonationSource::PrepaidCard,
"Currency" => DonationCurrency::CNY,
"SendPM" => true,
"AddedBy" => $Who,
'Amount' => $prepaidCardInfo['face_value'],
'Reason' => $PrepaidCardID,
]);
$this->cache->delete_value("user_donations_prepaid_card_$UserID");
return $text;
}
public function regularDonate(int $UserID, string $DonationAmount, string $Source, string $Reason, $Currency = "CNY") {
$this->donate($UserID, [
"Source" => $Source,
"Amount" => $DonationAmount,
"Currency" => $Currency,
"SendPM" => true,
"Reason" => $Reason,
"AddedBy" => $UserID,
]);
}
private function currency_exchange($Amount, $Currency) {
switch ($Currency) {
case 'BTC':
$XBT = new \Gazelle\Manager\XBT;
$forexRate = $XBT->latestRate('CNY');
$Amount = $Amount * $forexRate;
break;
default:
break;
}
return round($Amount, 2);
}
public function prepaidCard(int $id) {
$this->db->prepared_query(
"SELECT * FROM donations_prepaid_card WHERE id = ?",
$id
);
$Result = $this->db->next_record();
return $Result;
}
public function getAllPrepaidCardDonations($Limit) {
$this->db->prepared_query(
"SELECT SQL_CALC_FOUND_ROWS * FROM donations_prepaid_card ORDER BY create_time desc limit $Limit "
);
$Result = $this->db->to_array();
$this->db->query('SELECT FOUND_ROWS()');
list($NumResults) = $this->db->next_record();
return [$NumResults, $Result];
}
public function getPrepaidCardDonations($UserID) {
if (!$Value = $this->cache->get_value("user_donations_prepaid_card_$UserID")) {
$this->db->prepared_query(
'SELECT * FROM donations_prepaid_card WHERE user_id = ? ORDER BY create_time desc',
$UserID
);
$Value = $this->db->to_array(false, MYSQLI_NUM);
$this->cache->cache_value("user_donations_prepaid_card_$UserID", $Value);
}
return $Value;
}
public function preDonatePrepaidCard($UserID, $CardNum, $CardSecret, $FaceValue) {
$Date = sqltime();
try {
$this->db->prepared_query('INSERT INTO donations_prepaid_card (user_id, create_time, card_num, card_secret, face_value)
VALUES (?, ?, ?, ?, ?)', $UserID, $Date, $CardNum, $CardSecret, $FaceValue);
} catch (DB_MYSQL_DuplicateKeyException $e) {
return Lang::get('donate', 'duplicated_card');
}
$this->cache->delete_value("user_donations_prepaid_card_$UserID");
$this->cache->delete_value("donations_pending_count");
return '';
}
public function getPendingDonationCount() {
if (!$Count = $this->cache->get_value("donations_pending_count")) {
$this->db->prepared_query('SELECT count(*) FROM donations_prepaid_card where status = ?', PrepaidCardStatus::Pending);
list($Count) = $this->db->next_record();
$this->cache->cache_value("donations_pending_count", $Count);
}
return $Count;
}
public function getYearProgress() {
if (!$YearSum = $this->cache->get_value("donations_year_sum")) {
$this->db->query(
"SELECT sum(rank) from donations
where time >= '" . date("Y-01-01") . "'"
);
list($YearSum) = $this->db->next_record();
$this->cache->cache_value("donations_year_sum", $YearSum);
}
if (empty($YearSum)) {
return 0;
}
return number_format(($YearSum * 50 / (DONATE_MONTH_GOAL * 12) * 100));
}
public function rejectPrepaidCard($PrepaidCardID) {
$prepaidCardInfo = $this->prepaidCard($PrepaidCardID);
if (empty($prepaidCardInfo)) {
return Lang::get('donate', 'donate_error');
}
$UserID = $prepaidCardInfo['user_id'];
$this->db->prepared_query(
'
UPDATE donations_prepaid_card SET STATUS = ? WHERE id = ?',
PrepaidCardStatus::Reject,
$PrepaidCardID
);
Misc::send_pm_with_tpl($UserID, 'reject_prepaid_card', ['SiteURL' => SITE_URL]);
$this->cache->delete_value("user_donations_prepaid_card_$UserID");
$this->cache->delete_value("donations_pending_count");
return "";
}
private function donate(int $UserID, array $Args) {
$UserID = (int)$UserID;
$QueryID = $this->db->get_query_id();
$this->db->query("
SELECT 1
FROM users_main
WHERE ID = '$UserID'
LIMIT 1");
if (!$this->db->has_results()) {
return Lang::get('donate', 'donate_error');
}
$this->cache->InternalCache = false;
foreach ($Args as &$Arg) {
$Arg = db_string($Arg);
}
extract($Args);
if (empty($Date)) {
$Date = sqltime();
}
// Legacy donor, should remove at some point
$this->db->query("
UPDATE users_info
SET Donor = '1'
WHERE UserID = '$UserID'");
// Give them the extra invite
$ExtraInvite = $this->db->affected_rows();
// A staff member is directly manipulating donor points
if (isset($Manipulation) && $Manipulation === "Direct") {
$DonorPoints = $Rank;
$AdjustedRank = $Rank >= MAX_RANK ? MAX_RANK : $Rank;
$this->db->query("
INSERT INTO users_donor_ranks
(UserID, Rank, TotalRank, DonationTime, RankExpirationTime)
VALUES
('$UserID', '$AdjustedRank', '$TotalRank', '$Date', NOW())
ON DUPLICATE KEY UPDATE
Rank = '$AdjustedRank',
TotalRank = '$TotalRank',
DonationTime = '$Date',
RankExpirationTime = NOW()");
} else {
// Donations from the store get donor points directly, no need to calculate them
$ConvertedPrice = $this->currency_exchange($Amount, $Currency);
// 计算捐赠点数
$DonorPoints = $ConvertedPrice / 50;
$IncreaseRank = $DonorPoints;
// Rank is the same thing as DonorPoints
$CurrentRank = $this->rank($UserID);
// A user's donor rank can never exceed MAX_RANK
// If the amount they donated causes it to overflow, chnage it to MAX_RANK
// The total rank isn't affected by this, so their original donor point value is added to it
if (($CurrentRank + $DonorPoints) >= MAX_RANK) {
$AdjustedRank = MAX_RANK;
} else {
$AdjustedRank = $CurrentRank + $DonorPoints;
}
$this->db->query("
INSERT INTO users_donor_ranks
(UserID, Rank, TotalRank, DonationTime, RankExpirationTime)
VALUES
('$UserID', '$AdjustedRank', '$DonorPoints', '$Date', NOW())
ON DUPLICATE KEY UPDATE
Rank = '$AdjustedRank',
TotalRank = TotalRank + '$DonorPoints',
DonationTime = '$Date',
RankExpirationTime = NOW()");
}
// Donor cache key is outdated
$this->cache->delete_value("donor_info_$UserID");
// Get their rank
$Rank = $this->rank($UserID);
$TotalRank = $this->totalRank($UserID);
// Now that their rank and total rank has been set, we can calculate their special rank
$this->calculateSpecialRank($UserID);
// Lastly, add this donation to our history
$this->db->query("
INSERT INTO donations
(UserID, Amount, Source, Reason, Currency, Email, Time, AddedBy, Rank, TotalRank)
VALUES
('$UserID', '$ConvertedPrice', '$Source', '$Reason', '$Currency', '', '$Date', '$AddedBy', '$DonorPoints', '$TotalRank' )");
// Send them a thank you PM
if ($SendPM) {
Misc::send_pm_with_tpl(
$UserID,
'donation_received',
[
'DonationAmount' => $Amount . ' ' . $Currency,
'ReceivedRank' => $IncreaseRank,
'CurrentRank' => $this->rankLabel($Rank, $SpecialRank),
'SiteName' => SITE_NAME,
'SiteURL' => SITE_URL,
]
);
}
// Clear their user cache keys because the users_info values has been modified
$this->cache->delete_value("user_info_$UserID");
$this->cache->delete_value("user_info_heavy_$UserID");
$this->cache->delete_value("donor_info_$UserID");
$this->cache->delete_value("donations_pending_count");
$this->cache->delete_value("donations_year_sum");
$this->db->set_query_id($QueryID);
return '';
}
public static function rankLabel($rank, $specialRank, $ShowOverflow = true) {
if ($specialRank == MAX_SPECIAL_RANK) {
return '∞ [' . Lang::get('donate', 'diamond_rank') . ']';
}
$label = $rank >= MAX_RANK ? MAX_RANK : $rank;
$overflow = $rank - $label;
if ($ShowOverflow && $overflow) {
$label .= " (+$overflow)";
}
if ($rank >= 6) {
$label .= ' [' . Lang::get('donate', 'gold_rank') . ']';
} elseif ($rank >= 4) {
$label .= ' [' . Lang::get('donate', 'silver_rank') . ']';
} elseif ($rank >= 3) {
$label .= ' [' . Lang::get('donate', 'bronze_rank') . ']';
} elseif ($rank >= 2) {
$label .= ' [' . Lang::get('donate', 'copper_rank') . ']';
} elseif ($rank >= 1) {
$label .= ' [' . Lang::get('donate', 'red_rank') . ']';
}
return $label;
}
public function forumDescription() {
return self::$ForumDescriptions[rand(0, count(self::$ForumDescriptions) - 1)];
}
protected function calculateSpecialRank(int $UserID) {
$UserID = (int)$UserID;
$UserLang = Lang::getUserLang($UserID);
$QueryID = $this->db->get_query_id();
// Are they are special?
$TotalRank = $this->totalRank($UserID);
$SpecialRank = $this->specialRank($UserID);
$Invite = 0;
// Adjust their special rank depending on the total rank.
if ($TotalRank < 2) {
$SpecialRank = 0;
}
if ($SpecialRank < 1 && $TotalRank >= 2) {
$Invite += 2;
$SpecialRank = 1;
}
if ($SpecialRank < 2 && $TotalRank >= 6) {
$Invite += 2;
$SpecialRank = 2;
}
if ($SpecialRank < 3 && $TotalRank >= 12) {
$Invite += 2;
$SpecialRank = 3;
}
if ($SpecialRank < 4 && $TotalRank >= 24) {
$Invite += 4;
$SpecialRank = 4;
}
if ($SpecialRank < 5 && $TotalRank >= 50) {
$Invite += 6;
$SpecialRank = 5;
}
$this->db->query("
UPDATE users_donor_ranks
SET SpecialRank = '$SpecialRank'
WHERE UserID = '$UserID'");
if ($Invite > 0) {
$this->db->query("
UPDATE users_main
SET Invites = Invites + '$Invite'
WHERE ID = $UserID");
}
$this->cache->delete_value("donor_info_$UserID");
$this->db->set_query_id($QueryID);
}
protected function addDonorStatus(int $UserID): int {
if (($class = $this->db->scalar('SELECT ID FROM permissions WHERE Name = ?', 'Donor')) !== null) {
$this->db->prepared_query(
'
INSERT IGNORE INTO users_levels
(UserID, PermissionID)
VALUES (?, ?)
',
$UserID,
$class
);
return $this->db->affected_rows();
}
return 0;
}
protected function removeDonorStatus(int $UserID): int {
$class = $this->db->scalar('SELECT ID FROM permissions WHERE Name = ?', 'Donor');
if ($class) {
$this->db->prepared_query(
'
DELETE FROM users_levels
WHERE UserID = ?
AND PermissionID = ?
',
$UserID,
$class
);
}
$this->db->prepared_query(
'
UPDATE users_donor_ranks SET
SpecialRank = 0
WHERE UserID = ?
',
$UserID
);
return $this->db->affected_rows();
}
protected function toggleHidden(int $userId, string $state): int {
$this->db->prepared_query(
"
INSERT INTO users_donor_ranks
(UserID, Hidden)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE
Hidden = ?
",
$userId,
$state,
$state
);
return $this->db->affected_rows();
}
public function hide(int $userId): int {
return $this->toggleHidden($userId, '1');
}
public function show(int $userId): int {
return $this->toggleHidden($userId, '0');
}
public function hasForumAccess($UserID) {
return $this->rank($UserID) >= DONOR_FORUM_RANK || $this->specialRank($UserID) >= MAX_SPECIAL_RANK;
}
/**
* Put all the common donor info in the same cache key to save some cache calls
*/
public function info($UserID) {
// Our cache class should prevent identical memcached requests
$DonorInfo = $this->cache->get_value("donor_info_$UserID");
if ($DonorInfo === false) {
$QueryID = $this->db->get_query_id();
$this->db->prepared_query(
'
SELECT
Rank,
SpecialRank,
TotalRank,
DonationTime,
RankExpirationTime + INTERVAL 766 HOUR,
InvitesRecievedRank
FROM users_donor_ranks
WHERE UserID = ?
',
$UserID
);
// 2 hours less than 32 days to account for schedule run times
if ($this->db->has_results()) {
[$Rank, $SpecialRank, $TotalRank, $DonationTime, $ExpireTime, $InvitesRecievedRank]
= $this->db->next_record(MYSQLI_NUM, false);
if ($DonationTime === null) {
$DonationTime = 0;
}
if ($ExpireTime === null) {
$ExpireTime = 0;
}
} else {
$Rank = $SpecialRank = $TotalRank = $DonationTime = $ExpireTime = $InvitesRecievedRank = 0;
}
if (\Permissions::is_mod($UserID)) {
$Rank = MAX_RANK;
$SpecialRank = MAX_SPECIAL_RANK;
}
$this->db->prepared_query(
'
SELECT
IconMouseOverText,
AvatarMouseOverText,
CustomIcon,
CustomIconLink,
SecondAvatar,
ColorUsername,
GradientsColor
FROM donor_rewards
WHERE UserID = ?
',
$UserID
);
$Rewards = $this->db->next_record(MYSQLI_ASSOC);
$this->db->set_query_id($QueryID);
$DonorInfo = [
'Rank' => (int)$Rank,
'SRank' => (int)$SpecialRank,
'TotRank' => (int)$TotalRank,
'Time' => $DonationTime,
'ExpireTime' => $ExpireTime,
'Rewards' => $Rewards,
'IRank' => $InvitesRecievedRank,
];
$this->cache->cache_value("donor_info_$UserID", $DonorInfo, 86400);
}
return $DonorInfo;
}
public function rank($UserID) {
return $this->info($UserID)['Rank'];
}
public function specialRank($UserID) {
return $this->info($UserID)['SRank'];
}
private function invitesReceivedRank($UserID) {
return $this->info($UserID)['IRank'];
}
public function totalRank($UserID) {
return $this->info($UserID)['TotRank'];
}
public function lastDonation($UserID) {
return $this->info($UserID)['Time'];
}
public function personalCollages($UserID) {
$DonorInfo = $this->info($UserID);
if ($DonorInfo['SRank'] == MAX_SPECIAL_RANK) {
$Collages = 5;
} else {
$Collages = min($DonorInfo['Rank'], 5); // One extra collage per donor rank up to 5
}
return $Collages;
}
public function titles($UserID) {
$Results = $this->cache->get_value("donor_title_$UserID");
if ($Results === false) {
$QueryID = $this->db->get_query_id();
$this->db->prepared_query(
'
SELECT Prefix, Suffix, UseComma
FROM donor_forum_usernames
WHERE UserID = ?
',
$UserID
);
$Results = $this->db->next_record();
$this->db->set_query_id($QueryID);
$this->cache->cache_value("donor_title_$UserID", $Results, 0);
}
return $Results;
}
public function enabledRewards($UserID) {
$Rewards = [];
$Rank = $this->rank($UserID);
$SpecialRank = $this->specialRank($UserID);
$HasAll = $SpecialRank == MAX_SPECIAL_RANK;
$Rewards = array(
'HasAvatarMouseOverText' => false,
'HasCustomDonorIcon' => false,
'HasDonorForum' => false,
'HasDonorIconLink' => false,
'HasDonorIconMouseOverText' => false,
'HasProfileInfo1' => false,
'HasProfileInfo2' => false,
'HasProfileInfo3' => false,
'HasProfileInfo4' => false,
'HasSecondAvatar' => false,
'HasLimitedColorName' => false,
'HasUnlimitedColor' => false,
'HasGradientsColor' => false,
);
if ($Rank >= 2 || $HasAll) {
$Rewards["HasDonorIconMouseOverText"] = true;
}
if ($Rank >= 3 || $HasAll) {
$Rewards["HasAvatarMouseOverText"] = true;
}
if ($Rank >= 4 || $HasAll) {
$Rewards["HasDonorIconLink"] = true;
}
if ($Rank >= MAX_RANK || $HasAll) {
$Rewards["HasDonorForum"] = true;
$Rewards["HasSecondAvatar"] = true;
}
if ($SpecialRank >= 1 || $HasAll) {
$Rewards['HasProfileInfo1'] = true;
}
if ($SpecialRank >= 2 || $HasAll) {
$Rewards['HasProfileInfo2'] = true;
}
if ($SpecialRank >= 3 || $HasAll) {
$Rewards['HasProfileInfo3'] = true;
$Rewards['HasCustomDonorIcon'] = true;
}
if ($SpecialRank >= 4 || $HasAll) {
$Rewards['HasProfileInfo4'] = true;
$Rewards["HasLimitedColorName"] = true;
}
if ($SpecialRank >= MAX_SPECIAL_RANK || $HasAll) {
$Rewards['HasUnlimitedColor'] = true;
$Rewards['HasGradientsColor'] = true;
}
return $Rewards;
}
public function rewards($UserID) {
return $this->info($UserID)['Rewards'];
}
public function profileRewards($UserID) {
$Results = $this->cache->get_value("donor_profile_rewards_$UserID");
if ($Results === false) {
$QueryID = $this->db->get_query_id();
$this->db->prepared_query(
'
SELECT
ProfileInfo1,
ProfileInfoTitle1,
ProfileInfo2,
ProfileInfoTitle2,
ProfileInfo3,
ProfileInfoTitle3,
ProfileInfo4,
ProfileInfoTitle4
FROM donor_rewards
WHERE UserID = ?
',
$UserID
);
$Results = $this->db->next_record();
$this->db->set_query_id($QueryID);
$this->cache->cache_value("donor_profile_rewards_$UserID", $Results, 0);
}
return $Results;
}
public function updateReward($UserID) {
// TODO: could this be rewritten to avoid accessing $_POST directly?
$Rank = $this->rank($UserID);
$SpecialRank = $this->specialRank($UserID);
$HasAll = $SpecialRank == MAX_SPECIAL_RANK;
$Counter = 0;
$Insert = array();
$Values = array();
$Update = array();
$Insert[] = "UserID";
$Values[] = "'$UserID'";
if ($Rank >= 1 || $HasAll) {
}
if ($Rank >= 2 || $HasAll) {
if (isset($_POST['donor_icon_mouse_over_text'])) {
$IconMouseOverText = db_string($_POST['donor_icon_mouse_over_text']);
$Insert[] = "IconMouseOverText";
$Values[] = "'$IconMouseOverText'";
$Update[] = "IconMouseOverText = '$IconMouseOverText'";
}
$Counter++;
}
if ($Rank >= 3 || $HasAll) {
if (isset($_POST['avatar_mouse_over_text'])) {
$AvatarMouseOverText = db_string($_POST['avatar_mouse_over_text']);
$Insert[] = "AvatarMouseOverText";
$Values[] = "'$AvatarMouseOverText'";
$Update[] = "AvatarMouseOverText = '$AvatarMouseOverText'";
}
$Counter++;
}
if ($Rank >= 4 || $HasAll) {
if (isset($_POST['donor_icon_link'])) {
$CustomIconLink = db_string($_POST['donor_icon_link']);
if (!Misc::is_valid_url($CustomIconLink)) {
$CustomIconLink = '';
}
$Insert[] = "CustomIconLink";
$Values[] = "'$CustomIconLink'";
$Update[] = "CustomIconLink = '$CustomIconLink'";
}
$Counter++;
}
for ($i = 1; $i <= $Counter; $i++) {
$this->addProfileInfoReward($i, $Insert, $Values, $Update);
}
if ($Rank >= MAX_RANK || $HasAll) {
if (isset($_POST['donor_icon_custom_url'])) {
$CustomIcon = db_string($_POST['donor_icon_custom_url']);
if (!Misc::is_valid_url($CustomIcon)) {
$CustomIcon = '';
}
$Insert[] = "CustomIcon";
$Values[] = "'$CustomIcon'";
$Update[] = "CustomIcon = '$CustomIcon'";
}
$this->updateTitle($UserID, $_POST['donor_title_prefix'], $_POST['donor_title_suffix'], $_POST['donor_title_comma']);
$Counter++;
}
if ($SpecialRank >= 4) {
if (isset($_POST['second_avatar'])) {
$SecondAvatar = db_string($_POST['second_avatar']);
if (!Misc::is_valid_url($SecondAvatar)) {
$SecondAvatar = '';
}
$Insert[] = "SecondAvatar";
$Values[] = "'$SecondAvatar'";
$Update[] = "SecondAvatar = '$SecondAvatar'";
}
if (isset($_POST['limitedcolor']) && (preg_match('/^#[a-fA-F0-9]{6}$/', $_POST['limitedcolor']) || $_POST['limitedcolor'] == '')) {
$ColorUsername = db_string($_POST['limitedcolor']);
$Insert[] = "ColorUsername";
$Values[] = "'$ColorUsername'";
$Update[] = "ColorUsername = '$ColorUsername'";
}
}
if ($SpecialRank >= 5) {
if (isset($_POST['unlimitedcolor']) && (preg_match('/^#[a-fA-F0-9]{6}$/', $_POST['unlimitedcolor']) || $_POST['unlimitedcolor'] == '')) {
$ColorUsername = db_string($_POST['unlimitedcolor']);
$Insert[] = "ColorUsername";
$Values[] = "'$ColorUsername'";
$Update[] = "ColorUsername = '$ColorUsername'";
}
if (isset($_POST['gradientscolor']) && (preg_match('/^#[a-fA-F0-9]{6}(,#[a-fA-F0-9]{6}){1,2}$/', $_POST['gradientscolor']) || $_POST['gradientscolor'] == '')) {
$GradientsColor = db_string($_POST['gradientscolor']);
$Insert[] = "GradientsColor";
$Values[] = "'$GradientsColor'";
$Update[] = "GradientsColor = '$GradientsColor'";
}
}
$Insert = implode(', ', $Insert);
$Values = implode(', ', $Values);
$Update = implode(', ', $Update);
if ($Counter > 0) {
$QueryID = $this->db->get_query_id();
$this->db->query("
INSERT INTO donor_rewards
($Insert)
VALUES
($Values)
ON DUPLICATE KEY UPDATE
$Update");
$this->db->set_query_id($QueryID);
}
$this->cache->delete_value("donor_profile_rewards_$UserID");
$this->cache->delete_value("donor_info_$UserID");
}
private function addProfileInfoReward($Counter, &$Insert, &$Values, &$Update) {
if (isset($_POST["profile_title_" . $Counter]) && isset($_POST["profile_info_" . $Counter])) {
$ProfileTitle = db_string($_POST["profile_title_" . $Counter]);
$ProfileInfo = db_string($_POST["profile_info_" . $Counter]);
$ProfileInfoTitleSQL = "ProfileInfoTitle" . $Counter;
$ProfileInfoSQL = "ProfileInfo" . $Counter;
$Insert[] = "$ProfileInfoTitleSQL";
$Values[] = "'$ProfileTitle'";
$Update[] = "$ProfileInfoTitleSQL = '$ProfileTitle'";
$Insert[] = "$ProfileInfoSQL";
$Values[] = "'$ProfileInfo'";
$Update[] = "$ProfileInfoSQL = '$ProfileInfo'";
}
}
// TODO: make $UseComma more sane
public function updateTitle($UserID, $Prefix, $Suffix, $UseComma) {
$QueryID = $this->db->get_query_id();
$Prefix = trim($Prefix);
$Suffix = trim($Suffix);
$UseComma = empty($UseComma) ? true : false;
$this->db->prepared_query(
'
INSERT INTO donor_forum_usernames
(UserID, Prefix, Suffix, UseComma)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
Prefix = ?, Suffix = ?, UseComma = ?
',
$UserID,
$Prefix,
$Suffix,
$UseComma ? 1 : 0,
$Prefix,
$Suffix,
$UseComma ? 1 : 0
);
$this->cache->delete_value("donor_title_$UserID");
$this->db->set_query_id($QueryID);
}
public function history(int $UserID) {
if ($UserID < 1) {
error(404);
}
$QueryID = $this->db->get_query_id();
$this->db->prepared_query(
'
SELECT Amount, Time, Currency, Reason, Source, AddedBy, Rank, TotalRank
FROM donations
WHERE UserID = ?
ORDER BY Time DESC
',
$UserID
);
$DonationHistory = $this->db->to_array(false, MYSQLI_ASSOC, false);
$this->db->set_query_id($QueryID);
return $DonationHistory;
}
public function rankExpiry($UserID) {
$DonorInfo = $this->info($UserID);
if ($DonorInfo['SRank'] == MAX_SPECIAL_RANK || $DonorInfo['Rank'] == 1) {
$Return = 'Never';
} elseif ($DonorInfo['ExpireTime']) {
$ExpireTime = strtotime($DonorInfo['ExpireTime']);
if ($ExpireTime - time() < 60) {
$Return = 'Soon';
} else {
$Expiration = time_diff($ExpireTime); // 32 days
$Return = "in $Expiration";
}
} else {
$Return = '';
}
return $Return;
}
public function leaderboardRank(int $UserID): int {
$this->db->prepared_query("SET @RowNum := 0");
$Position = $this->db->scalar(
"
SELECT Position
FROM (
SELECT d.UserID, @RowNum := @RowNum + 1 AS Position
FROM users_donor_ranks AS d
ORDER BY TotalRank DESC
) l
WHERE UserID = ?
",
$UserID
);
return $Position ?? 0;
}
public function isDonor(int $userId) {
return $this->rank($userId) > 0;
}
public function isVisible(int $userId): int {
return is_null($this->db->scalar(
"
SELECT Hidden
FROM users_donor_ranks
WHERE Hidden = '1'
AND UserID = ?
",
$userId
));
}
public function totalMonth(int $month) {
if (($donations = $this->cache->get_value("donations_month_$month")) === false) {
$donations = $this->db->scalar(
"
SELECT sum(xbt)
FROM donations
WHERE time >= CAST(DATE_FORMAT(NOW() ,'%Y-%m-01') as DATE) - INTERVAL ? MONTH
",
$month - 1
);
$this->cache->cache_value("donations_month_$month", $donations, 3600 * 36);
}
return $donations;
}
public function expireRanks(): int {
$this->db->prepared_query("
SELECT UserID
FROM users_donor_ranks
WHERE Rank > 1
AND SpecialRank < ?
AND RankExpirationTime < NOW() - INTERVAL 1440 HOUR
", MAX_SPECIAL_RANK); // 2 hours less than 32 days to account for schedule run times
$userIds = [];
while ([$id] = $this->db->next_record()) {
$this->cache->delete_value("donor_info_$id");
$this->cache->delete_value("donor_title_$id");
$this->cache->delete_value("donor_profile_rewards_$id");
$userIds[] = $id;
}
if ($userIds) {
$this->db->prepared_query(
"
UPDATE users_donor_ranks SET
Rank = Rank - 1,
RankExpirationTime = now()
WHERE Rank > 1
AND UserID IN (" . placeholders($userIds) . ")
",
...$userIds
);
}
return count($userIds);
}
}
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace Gazelle\Manager;
class IPv4 extends \Gazelle\Base {
const CACHE_KEY = 'ipv4_bans_';
/**
* Returns the unsigned 32bit form of an IPv4 address
*
* @param string $ipv4 The IP address x.x.x.x
* @return string the long it represents.
*/
public function ip2ulong(string $ipv4) {
return sprintf('%u', ip2long($ipv4));
}
/**
* Returns true if given IP is banned.
* TODO: This looks really braindead. Why not compare the 32bit address
* directly BETWEEN FromIP AND ToIP? Apart from dubious merits of
* caching?
*
* @param string $IP
* @return bool True if banned
*/
public function isBanned(string $IP) {
$A = substr($IP, 0, strcspn($IP, '.'));
$key = self::CACHE_KEY . $A;
$IPBans = $this->cache->get_value($key);
if (!is_array($IPBans)) {
$this->db->prepared_query(
"
SELECT FromIP, ToIP, ID
FROM ip_bans
WHERE FromIP BETWEEN ? << 24 AND (? << 24) - 1
",
$A,
$A + 1
);
$IPBans = $this->db->to_array(0, MYSQLI_NUM);
$this->cache->cache_value($key, $IPBans, 0);
}
$IPNum = $this->ip2ulong($IP);
foreach ($IPBans as $IPBan) {
list($FromIP, $ToIP) = $IPBan;
if ($IPNum >= $FromIP && $IPNum <= $ToIP) {
return true;
}
}
return false;
}
/**
* Create an ip address ban over a range of addresses. Will append
* the given reason to an existing ban.
*
* @param int $userId The person doing the band (0 for system)
* @param string $from The first address (dotted quad a.b.c.d)
* @param string $to The last adddress in the range (may equal $from)
* @param string $reason Why ban?
*/
public function createBan(int $userId, $ipv4From, string $ipv4To, string $reason) {
$from = $this->ip2ulong($ipv4From);
$to = $this->ip2ulong($ipv4To);
$current = $this->db->scalar(
'
SELECT Reason
FROM ip_bans
WHERE ? BETWEEN FromIP AND ToIP
',
$from
);
if ($current) {
if ($current != $reason) {
$this->db->prepared_query(
"
UPDATE ip_bans SET
Reason = concat(?, ' AND ', Reason),
user_id = ?,
created = now()
WHERE FromIP = ?
AND ToIP = ?
",
$reason,
$userId,
$from,
$to
);
}
} else { // Not yet banned
$this->db->prepared_query(
"
INSERT INTO ip_bans
(Reason, FromIP, ToIP, user_id)
VALUES (?, ?, ?, ?)
",
$reason,
$from,
$to,
$userId
);
$this->cache->delete_value(
self::CACHE_KEY . substr($ipv4From, 0, strcspn($ipv4From, '.'))
);
}
}
/**
* Remove an ip ban
*
* param int $id Row to remove
*/
public function removeBan(int $id) {
$fromClassA = $this->db->scalar(
"
SELECT FromIP >> 24 FROM ip_bans WHERE ID = ?
",
$id
);
if (is_null($fromClassA)) {
return;
}
$this->db->prepared_query(
"
DELETE FROM ip_bans WHERE ID = ?
",
$id
);
if ($this->db->affected_rows()) {
$this->cache->delete_value(self::CACHE_KEY . $fromClassA);
}
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace Gazelle\Manager;
class Invite extends \Gazelle\Base {
protected $search;
/**
* Set a text filter on email addresses
*
* @param string email address fragment
*/
public function setSearch(string $search) {
$this->search = $search;
return $this;
}
/**
* How many pending invites are in circulation?
*
* @return int number of invites
*/
public function totalPending(): int {
return $this->db->scalar("
SELECT count(*) FROM invites WHERE Expires > now()
");
}
/**
* Get a page of pending invites
*
* @param string limit (e.g. "20, 60" for LIMIT 20 OFFSET 60)
* @return array list of pending invites [inviter_id, ipaddr, invite_key, expires, email]
*/
public function pendingInvites(string $limit): array {
if (is_null($this->search)) {
$where = "/* no email filter */";
$args = [];
} else {
$where = "WHERE i.Email REGEXP ?";
$args = [$this->search];
}
$this->db->prepared_query(
"
SELECT i.InviterID AS user_id,
um.IP AS ipaddr,
i.InviteKey AS `key`,
i.Expires AS expires,
i.Email AS email
FROM invites AS i
INNER JOIN users_main AS um ON (um.ID = i.InviterID)
$where
ORDER BY i.Expires DESC
LIMIT $limit
",
...$args
);
return $this->db->to_array(false, MYSQLI_ASSOC, false);
}
/**
* Remove an invite
*
* @param string invite key
* @return bool true if something was actually removed
*/
public function removeInviteKey(string $key): bool {
$this->db->prepared_query(
"
DELETE FROM invites
WHERE InviteKey = ?
",
trim($key)
);
return $this->db->affected_rows() !== 0;
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
namespace Gazelle\Manager;
class News extends \Gazelle\Base {
const CACHE_KEY = 'newsv2';
/**
* Create a news article
*
* @param $userId The UserID of the author
* @param $title The title of the article
* @param $body The body of the article
* @return ID of new article
*/
public function create(int $userId, string $title, string $body): int {
$this->db->prepared_query(
"
INSERT INTO news
(UserID, Title, Body)
VALUES (?, ?, ?)
",
$userId,
trim($title),
trim($body)
);
$this->cache->deleteMulti(['feed_news', self::CACHE_KEY]);
return $this->db->inserted_id();
}
/**
* Modify an existing news article (the author remains unchanged)
*
* @param $id The article ID
* @param $title The title of the article
* @param $body The body of the article
* @return 1 if successful
*/
public function modify(int $id, string $title, string $body): int {
$this->db->prepared_query(
"
UPDATE news SET
Title = ?,
Body = ?
WHERE ID = ?
",
trim($title),
trim($body),
$id
);
$this->cache->deleteMulti(['feed_news', self::CACHE_KEY]);
return $this->db->affected_rows();
}
/**
* Remove an existing news article
*
* @param $id The id of the news article
* @return 1 if successful
*/
public function remove(int $id): int {
$this->db->prepared_query(
"
DELETE FROM news WHERE ID = ?
",
$id
);
$this->cache->deleteMulti(['feed_news', self::CACHE_KEY]);
return $this->db->affected_rows();
}
/**
* Get a number of most recent articles.
* (hard-coded to 20 max, otherwise cache invalidation becomes difficult)
*
* @return array
* - id of article
* - title of article
* - body of article
* - article creation date
*/
public function headlines(): array {
if (($headlines = $this->cache->get_value(self::CACHE_KEY)) === false) {
$this->db->prepared_query("
SELECT ID, Title, Body, Time
FROM news
WHERE Time < now()
ORDER BY Time DESC
LIMIT 20
");
$headlines = $this->db->to_array(false, MYSQLI_NUM, false);
$this->cache->cache_value(self::CACHE_KEY, $headlines, 0);
}
return $headlines;
}
/**
* Get the title and body of an article
*
* @param $id ID of article
* @return array [string $title, string $body] or null if no such article
*
*/
public function fetch(int $id): array {
return $this->db->row(
"
SELECT Title, Body
FROM news
WHERE ID = ?
",
$id
);
}
/**
* Get the latest news article id and title
* ID will be -1 if no news yet exists.
*
* @return array [$id, $title]
*/
public function latest(): array {
$headlines = $this->headlines();
return $headlines[0] ?? [-1, null, null, null];
}
/**
* Get the latest news article id
* ID will be -1 if no news yet exists.
*
* @return int $id news article id
*/
public function latestId(): int {
[$newsId] = $this->latest();
return $newsId;
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
namespace Gazelle\Manager;
use \Gazelle\Exception\PaymentFetchForexException;
class Payment extends \Gazelle\Base {
const LIST_KEY = 'payment_list';
const RENT_KEY = 'payment_monthly_rental';
public function create(array $val) {
$this->db->prepared_query(
'
INSERT INTO payment_reminders
(Text, Expiry, AnnualRent, cc, Active)
VALUES (?, ?, ?, ?, ?)
',
$val['text'],
$val['expiry'],
$val['rent'],
$val['cc'],
isset($val['active'])
);
$this->flush();
return $this->db->inserted_id();
}
public function modify($id, array $val) {
$this->db->prepared_query(
"
UPDATE payment_reminders SET
Text = ?, Expiry = ?, AnnualRent = ?, cc = ?, Active = ?
WHERE ID = ?
",
$val['text'],
$val['expiry'],
$val['rent'],
$val['cc'],
isset($val['active']),
$id
);
$this->flush();
return $this->db->affected_rows();
}
public function remove($id) {
$this->db->prepared_query(
'
DELETE
FROM payment_reminders
WHERE ID = ?
',
$id
);
$this->flush();
return $this->db->affected_rows();
}
public function flush() {
$this->cache->deleteMulti([self::LIST_KEY, self::RENT_KEY, 'due_payments']);
}
public function list() {
if (($list = $this->cache->get_value(self::LIST_KEY)) === false) {
$this->db->prepared_query("
SELECT ID, Text, Expiry, AnnualRent, cc, Active
FROM payment_reminders
ORDER BY Expiry
");
$list = $this->db->to_array('ID', MYSQLI_ASSOC);
$this->cache->cache_value(self::LIST_KEY, $list, 86400 * 30);
}
// update with latest forex rates
$XBT = new XBT;
foreach ($list as &$l) {
if ($l['cc'] == 'XBT') {
$l['fiatRate'] = 1.0;
$l['Rent'] = $l['btcRent'] = sprintf('%0.6f', $l['AnnualRent']);
} else {
$l['fiatRate'] = $XBT->fetchRate($l['cc']);
if (!$l['fiatRate']) {
// fallback to last known rate if there is one
$l['fiatRate'] = $this->db->scalar(
'
SELECT rate
FROM xbt_forex
WHERE forex_date = (
SELECT max(forex_date)
FROM xbt_forex
WHERE cc = ?
)
AND cc = ?
',
$l['cc'],
$l['cc']
);
if (!$l['fiatRate']) {
throw new PaymentFetchForexException(sprintf('XBT id=%d cc=%s', $l['ID'], $l['cc']));
}
}
$l['Rent'] = sprintf('%0.2f', $l['AnnualRent']);
$l['btcRent'] = sprintf('%0.6f', $l['AnnualRent'] / $l['fiatRate']);
}
}
return $list;
}
public function monthlyRental() {
if (($rental = $this->cache->get_value(self::RENT_KEY)) === false) {
$list = $this->list();
$rental = 0;
foreach ($list as $l) {
if ($l['Active']) {
$rental += $l['btcRent'];
}
}
$this->cache->cache_value(self::RENT_KEY, $rental / 12, 86400);
}
return $rental;
}
public function due() {
if (($due = $this->cache->get_value('due_payments')) === false) {
$this->db->prepared_query('
SELECT Text, Expiry
FROM payment_reminders
WHERE Active = 1 AND Expiry < now() + INTERVAL 1 WEEK
ORDER BY Expiry
');
$due = $this->db->to_array(false, MYSQLI_ASSOC);
$this->cache->cache_value('due_payments', $due, 3600);
}
return $due;
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace Gazelle\Manager;
class Privilege extends \Gazelle\Base {
protected $classList;
protected $privilege;
public function __construct() {
parent::__construct();
$this->init();
}
/**
* The list of primary and secondary user classes.
*
* @return array
* - id
* - name
* - primary 0, 1
*/
public function classList() {
return $this->classList;
}
/**
* The list of defined privileges. The `can` field
* in the returned array acts as a sparse matrix.
*
* @return array
* - name (Short name of privilege)
* - description (Longer description of privilege)
* - orphan (Is this a privileges that no longer exists)
* - can (array of user class permission IDs that have this privilege)
*/
public function privilege() {
return $this->privilege;
}
/**
* Fully initialize the object
*/
protected function init() {
// Grab the privileges defined in the Permissions class
// TODO:: migrate here
$this->privilege = [];
$plist = \Permissions::list();
foreach ($plist as $name => $description) {
$this->privilege[$name] = [
'can' => [],
'description' => $description,
'name' => $name,
'orphan' => 0
];
}
// lowercase column names, because this is going straight to twig
$this->db->prepared_query("
SELECT ID as id,
Name as name,
CASE WHEN Secondary = 1 THEN 0 ELSE 1 END AS \"primary\"
FROM permissions
ORDER BY Secondary DESC, Level, Name
");
$this->classList = $this->db->to_array('id', MYSQLI_ASSOC);
// decorate the privilges with those user classes that have benn granted access
foreach ($this->classList as $c) {
$perm = \Permissions::get_permissions($c['id'])['Permissions'];
foreach (array_keys($perm) as $p) {
if (!isset($this->privilege[$p])) {
// orphan permissions in the db that no longer do anything
$this->privilege[$p] = [
'can' => [],
'description' => $p,
'name' => $p,
'orphan' => 1
];
}
$this->privilege[$p]['can'][] = $c['id'];
}
}
}
}
+594
View File
@@ -0,0 +1,594 @@
<?php
namespace Gazelle\Manager;
use Gazelle\Util\Crypto;
use Gazelle\Util\Proxy;
class Referral extends \Gazelle\Base {
private $accounts;
private $proxy;
public $readOnly;
const CACHE_ACCOUNTS = 'referral_accounts';
const CACHE_BOUNCER = 'bouncer_status';
// Do not change the ordering in this array after launch.
const ACCOUNT_TYPES = ['Gazelle (API)', '', '', 'Luminance', 'Gazelle (HTML)', ''];
// Accounts which use the user ID instead of username.
const ID_TYPES = [3, 4, 5];
public function __construct() {
parent::__construct();
$this->accounts = $this->cache->get_value(self::CACHE_ACCOUNTS);
$this->proxy = new Proxy(REFERRAL_KEY, REFERRAL_BOUNCER);
if ($this->accounts === false) {
$this->db->prepared_query("SELECT ID, Site, Active, Type FROM referral_accounts");
$this->accounts = $this->db->has_results() ? $this->db->to_array('ID') : [];
foreach ($this->accounts as &$acc) {
$acc["UserIsId"] = in_array($acc["Type"], self::ID_TYPES);
unset($acc);
}
$this->cache->cache_value(self::CACHE_ACCOUNTS, $this->accounts, 86400 * 30);
}
$this->readOnly = !apcu_exists('DB_KEY');
if (!$this->readOnly) {
$url = $this->db->scalar("SELECT URL FROM referral_accounts LIMIT 1");
if ($url) {
$this->readOnly = Crypto::dbDecrypt($url) == null;
}
}
}
public function checkBouncer() {
if (!count($this->accounts)) {
return true;
}
$status = $this->cache->get_value(self::CACHE_BOUNCER);
if ($status === false) {
$req = $this->proxy->fetch(SITE_URL, [], [], false);
$status = $req == null ? 'dead' : 'alive';
$this->cache->cache_value(self::CACHE_BOUNCER, $status, 60 * 15);
}
return $status == 'alive';
}
public function generateToken() {
return 'OPS|' . randomString(64) . '|OPS';
}
public function getTypes() {
return self::ACCOUNT_TYPES;
}
public function getAccounts() {
return $this->accounts;
}
public function getActiveAccounts() {
return array_filter(
$this->accounts,
function ($i) {
return $i['Active'] == '1' && !$this->readOnly;
}
);
}
public function getAccount($id) {
return array_key_exists($id, $this->accounts) ? $this->accounts[$id] : null;
}
public function getFullAccount($id) {
$this->db->prepared_query(
"
SELECT ID, Site, URL, User, Password, Active, Type, Cookie
FROM referral_accounts
WHERE ID = ?
",
$id
);
$account = null;
if ($this->db->has_results()) {
$account = $this->db->next_record();
foreach (['URL', 'User', 'Password', 'Cookie'] as $key) {
if (array_key_exists($key, $account)) {
$account[$key] = Crypto::dbDecrypt($account[$key]);
}
}
$account["Cookie"] = json_decode($account["Cookie"], true);
$account["UserIsId"] = in_array($account["Type"], self::ID_TYPES);
}
return $account;
}
public function getFullAccounts() {
$this->db->prepared_query("
SELECT ID, Site, URL, User, Password, Active, Type, Cookie
FROM referral_accounts");
if ($this->db->has_results()) {
$accounts = $this->db->to_array('ID', MYSQLI_ASSOC);
foreach ($accounts as &$account) {
foreach (['URL', 'User', 'Password', 'Cookie'] as $key) {
if (array_key_exists($key, $account)) {
$account[$key] = Crypto::dbDecrypt($account[$key]);
}
}
$account["Cookie"] = json_decode($account["Cookie"], true);
$account["UserIsId"] = in_array($account["Type"], self::ID_TYPES);
}
return $accounts;
}
return [];
}
public function createAccount($site, $url, $user, $password, $active, $type, $cookie) {
if ($this->readOnly) {
return;
}
if (strlen($cookie) < 2) {
$cookie = '[]';
}
json_decode($cookie);
if (json_last_error() != JSON_ERROR_NONE) {
$cookie = '[]';
}
$this->db->prepared_query(
"
INSERT INTO referral_accounts
(Site, URL, User, Password, Active, Type, Cookie)
VALUES
(?, ?, ?, ?, ?, ?, ?)
",
$site,
Crypto::dbEncrypt($url),
Crypto::dbEncrypt($user),
Crypto::dbEncrypt($password),
$active,
$type,
Crypto::dbEncrypt($cookie)
);
$this->cache->delete_value(self::CACHE_ACCOUNTS);
}
private function updateCookie($id, $cookie) {
if ($this->readOnly) {
return;
}
$this->db->prepared_query(
"
UPDATE referral_accounts
SET Cookie = ?
WHERE ID = ?
",
Crypto::dbEncrypt(json_encode($cookie)),
$id
);
}
public function updateAccount($id, $site, $url, $user, $password, $active, $type, $cookie) {
if ($this->readOnly) {
return;
}
$account = $this->getFullAccount($id);
if (strlen($cookie) < 2) {
$cookie = '[]';
}
json_decode($cookie);
if (json_last_error() != JSON_ERROR_NONE) {
$cookie = '[]';
}
if ($cookie == '[]') {
$cookie = json_encode($account["Cookie"]);
}
if (strlen($password) == 0) {
$password = $account["Password"];
}
$this->db->prepared_query(
"
UPDATE referral_accounts SET
Site = ?,
URL = ?,
User = ?,
Password = ?,
Active = ?,
Type = ?,
Cookie = ?
WHERE ID = ?
",
$site,
Crypto::dbEncrypt($url),
Crypto::dbEncrypt($user),
Crypto::dbEncrypt($password),
$active,
$type,
Crypto::dbEncrypt($cookie),
$id
);
$this->cache->delete_value(self::CACHE_ACCOUNTS);
}
public function deleteAccount($id) {
$this->db->prepared_query("DELETE FROM referral_accounts WHERE ID = ?", $id);
$this->cache->delete_value(self::CACHE_ACCOUNTS);
}
public function getReferredUsers($startDate, $endDate, $site, $username, $invite, $limit, $view) {
if ($startDate == NULL) {
$startDate = \Gazelle\Util\Time::timeOffset(- (3600 * 24 * 30), true);
}
if ($endDate == NULL) {
$endDate = \Gazelle\Util\Time::sqlTime();
}
$filter = ['ru.Created BETWEEN ? AND ?'];
$params = [$startDate, $endDate];
if ($view === 'pending') {
$filter[] = 'ru.Active = 0';
} else if ($view === 'processed') {
$filter[] = 'ru.Active = 1';
}
if ($site != NULL) {
$filter[] = 'ru.Site LIKE ?';
$params[] = $site;
}
if ($username != NULL) {
$filter[] = '(ru.Username LIKE ? OR um.Username LIKE ?)';
$params[] = $username;
$params[] = $username;
}
if ($invite != NULL) {
$filter[] = 'ru.InviteKey LIKE ?';
$params[] = $invite;
}
$filter = implode(' AND ', $filter);
$this->db->prepared_query(
"
SELECT SQL_CALC_FOUND_ROWS ru.ID, ru.UserID, ru.Site, ru.Username, ru.Created, ru.Joined, ru.IP, ru.Active, ru.InviteKey
FROM referral_users ru
LEFT JOIN users_main um ON um.ID = ru.UserID
WHERE $filter
ORDER BY ru.Created DESC
LIMIT $limit
",
...$params
);
$results = $this->db->scalar("SELECT found_rows()");
$users = $results > 0 ? $this->db->to_array('ID', MYSQLI_ASSOC) : [];
return ["Results" => $results, "Users" => $users];
}
public function deleteUserReferral($id) {
$this->db->prepared_query(
"
DELETE FROM referral_users
WHERE ID = ?
",
$id
);
}
public function validateCookie($acc) {
switch ($acc["Type"]) {
case 0:
return $this->validateGazelleCookie($acc);
break;
case 1:
return true;
break;
case 2:
return false;
break;
case 3:
case 4:
case 5:
return $this->validateLuminanceCookie($acc);
break;
}
return false;
}
private function validateGazelleCookie($acc) {
$url = $acc["URL"] . 'ajax.php';
$result = $this->proxy->fetch($url, ["action" => "index"], $acc["Cookie"], false);
$json = json_decode($result["response"], true);
return $json["status"] === 'success';
}
private function validateLuminanceCookie($acc) {
$url = $acc["URL"];
$result = $this->proxy->fetch($url, [], $acc["Cookie"], false);
$match = strpos($result["response"], "authkey");
return $match !== false;
}
public function loginAccount(&$acc) {
switch ($acc["Type"]) {
case 0:
return $this->loginGazelleAccount($acc);
break;
case 1:
return true;
break;
case 2:
return false;
case 3:
return $this->loginLuminanceAccount($acc);
break;
case 4:
return $this->loginGazelleHTMLAccount($acc);
break;
case 5:
return false;
}
return false;
}
private function loginGazelleAccount(&$acc) {
if ($this->validateGazelleCookie($acc)) {
return true;
}
$url = $acc["URL"] . "login.php";
$result = $this->proxy->fetch($url, [
"username" => $acc["User"],
"password" => $acc["Password"], "keeplogged" => "1"
], [], true);
if ($result["status"] == 200) {
$acc["Cookie"] = $result["cookies"];
$this->updateCookie($acc["ID"], $acc["Cookie"]);
}
return $result["status"] == 200;
}
private function loginLuminanceAccount(&$acc) {
if ($this->validateLuminanceCookie($acc)) {
return true;
}
$url = $acc["URL"] . "login";
$result = $this->proxy->fetch($url, [], [], false);
$doc = new \DOMDocument();
libxml_use_internal_errors(true);
@$doc->loadHTML($result["response"]);
$xpath = new \DOMXPath($doc);
$token = $xpath->evaluate("string(//input[@name='token']/@value)");
$result = $this->proxy->fetch($url, [
"username" => $acc["User"],
"password" => $acc["Password"], "keeploggedin" => "1",
"token" => $token, "cinfo" => "1024|768|24|0",
"iplocked" => "1"
], $result["cookies"], true);
if ($result["status"] == 200) {
$acc["Cookie"] = $result["cookies"];
$this->updateCookie($acc["ID"], $acc["Cookie"]);
}
return $result["status"] == 200;
}
private function loginGazelleHTMLAccount(&$acc) {
if ($this->validateLuminanceCookie($acc)) {
return true;
}
$url = $acc["URL"] . "login.php";
$result = $this->proxy->fetch($url, [
"username" => $acc["User"],
"password" => $acc["Password"], "keeplogged" => "1"
], [], true);
if ($result["status"] == 200) {
$acc["Cookie"] = $result["cookies"];
$this->updateCookie($acc["ID"], $acc["Cookie"]);
}
return $result["status"] == 200;
}
public function verifyAccount($acc, $user, $key) {
switch ($acc["Type"]) {
case 0:
return $this->verifyGazelleAccount($acc, $user, $key);
break;
case 1:
return false;
case 2:
return false;
case 3:
return $this->verifyLuminanceAccount($acc, $user, $key);
break;
case 4:
return $this->verifyGazelleHTMLAccount($acc, $user, $key);
break;
case 5:
return false;
}
return "Unrecognised account type";
}
private function verifyGazelleAccount($acc, $user, $key) {
if (!$this->loginGazelleAccount($acc)) {
return "Internal error 10";
}
$url = $acc["URL"] . 'ajax.php';
$result = $this->proxy->fetch(
$url,
["action" => "usersearch", "search" => $user],
$acc["Cookie"],
false
);
$json = json_decode($result["response"], true);
if ($json["status"] === 'success') {
$match = false;
$userId = null;
foreach ($json["response"]["results"] as $userResult) {
if ($userResult["username"] == $user) {
$match = true;
$userId = $userResult["userId"];
break;
}
}
if ($match) {
$result = $this->proxy->fetch(
$url,
["action" => "user", "id" => $userId],
$acc["Cookie"],
false
);
$json = json_decode($result["response"], true);
$profile = $json["response"]["profileText"];
$match = strpos($profile, $key);
if ($match !== false) {
return true;
} else {
return "Token not found. Please try again.";
}
}
}
return "Token not found. Please try again.";
}
private function verifyLuminanceAccount($acc, $user, $key) {
if (!$this->loginLuminanceAccount($acc)) {
return "Internal error 12";
}
$url = $acc["URL"] . 'user.php';
$result = $this->proxy->fetch($url, ["id" => $user], $acc["Cookie"], false);
$profile = $result["response"];
$match = strpos($profile, $key);
if ($match !== false) {
return true;
} else {
return "Token not found. Please try again.";
}
}
private function verifyGazelleHTMLAccount($acc, $user, $key) {
if (!$this->loginGazelleHTMLAccount($acc)) {
return "Internal error 13";
}
$url = $acc["URL"] . 'user.php';
$result = $this->proxy->fetch(
$url,
["id" => $user],
$acc["Cookie"],
false
);
$profile = $result["response"];
$match = strpos($profile, $key);
if ($match !== false) {
return true;
} else {
return "Token not found. Please try again.";
}
}
public function generateInvite($acc, $username, $email, $twig) {
$existing = $this->db->scalar(
"
SELECT Username
FROM referral_users
WHERE Username = ? AND Site = ?
",
$username,
$acc["Site"]
);
if ($existing) {
return [false, "Account already used for referral, join " . BOT_DISABLED_CHAN . " on " . BOT_SERVER . " for help."];
}
$inviteKey = randomString();
$this->db->prepared_query(
"
INSERT INTO invites
(InviteKey, Email, Reason, Expires)
VALUES (?, ?, ?, now() + INTERVAL 3 DAY)
",
$inviteKey,
$email,
'This user was referred from their account on ' . $acc["Site"] . '.'
);
$this->db->prepared_query(
"
INSERT INTO referral_users
(Username, Site, IP, InviteKey)
VALUES
(?, ?, ?, ?)
",
$username,
$acc["Site"],
$_SERVER["REMOTE_ADDR"],
$inviteKey
);
if (defined('REFERRAL_SEND_EMAIL') && REFERRAL_SEND_EMAIL) {
$message = $twig->render('emails/referral.twig', [
'Email' => $email,
'InviteKey' => $inviteKey,
'DISABLED_CHAN' => BOT_DISABLED_CHAN,
'IRC_SERVER' => BOT_SERVER,
'SITE_NAME' => SITE_NAME,
'SITE_URL' => SITE_URL
]);
\Misc::send_email($email, 'You have been invited to ' . SITE_NAME, $message, 'noreply', 'text/plain');
}
return [true, $inviteKey];
}
}
+230
View File
@@ -0,0 +1,230 @@
<?php
namespace Gazelle\Manager;
class SiteLog extends \Gazelle\Base {
protected $debug;
protected $logQuery;
protected $totalMatches;
protected $queryStatus;
protected $queryError;
protected $qid;
protected $usernames;
public function __construct(\DEBUG $debug) {
parent::__construct();
$this->debug = $debug;
$this->usernames = [];
}
public function totalMatches() {
return $this->totalMatches;
}
public function error() {
return $this->queryStatus;
}
public function errorMessage() {
return $this->queryError;
}
public function next() {
$this->db->set_query_id($this->qid);
while ($result = $this->db->next_record(MYSQLI_NUM, false)) {
yield $result;
$this->db->set_query_id($this->qid);
}
}
public function load(int $page, int $offset, string $searchTerm) {
if ($searchTerm === '') {
$this->logQuery = $this->db->prepared_query(
"
SELECT ID, Message, Time
FROM log
ORDER BY ID DESC
LIMIT ?, ?
",
$offset,
LOG_ENTRIES_PER_PAGE
);
$this->totalMatches = $this->db->record_count();
if ($this->totalMatches == LOG_ENTRIES_PER_PAGE) {
// This is a lot faster than SQL_CALC_FOUND_ROWS
$sq = new \SphinxqlQuery();
$result = $sq->select('id')->from('log, log_delta')->limit(0, 1, 1)->query();
$this->debug->log_var($result, '$result');
$this->totalMatches = min(SPHINX_MAX_MATCHES, $result->get_meta('total_found'));
} else {
$this->totalMatches += $offset;
}
$this->queryStatus = 0;
} else {
$page = min(SPHINX_MAX_MATCHES / TORRENTS_PER_PAGE, $page);
$sq = new \SphinxqlQuery();
$sq->select('id')
->from('log, log_delta')
->order_by('id', 'DESC')
->limit($offset, LOG_ENTRIES_PER_PAGE, $offset + LOG_ENTRIES_PER_PAGE);
foreach (explode(' ', $searchTerm) as $s) {
$sq->where_match($s, 'message');
}
$result = $sq->query();
$this->debug->log_var($result, '$result');
$this->debug->set_flag('Finished SphQL query');
if ($this->queryStatus = $result->Errno) {
$this->queryError = $result->Error;
$this->logQuery = $this->db->prepared_query('SET @nothing = 0');
} else {
$this->totalMatches = min(SPHINX_MAX_MATCHES, $result->get_meta('total_found'));
$logIds = $result->collect('id') ?: [0];
$this->logQuery = $this->db->prepared_query(
"
SELECT ID, Message, Time
FROM log
WHERE ID IN (" . placeholders($logIds) . ")
ORDER BY ID DESC
",
...$logIds
);
}
}
$this->qid = $this->db->get_query_id();
}
public function colorize(string $logMessage) {
$messageParts = explode(' ', $logMessage);
$message = '';
$color = $colon = false;
for ($i = 0, $n = count($messageParts); $i < $n; $i++) {
if (strpos($messageParts[$i], SITE_URL) === 0) {
$offset = strlen(SITE_URL) + 1; // trailing slash
$messageParts[$i] = '<a href="' . substr($messageParts[$i], $offset) . '">' . substr($messageParts[$i], $offset) . '</a>';
}
switch ($messageParts[$i]) {
case 'Torrent':
case 'torrent':
$TorrentID = $messageParts[$i + 1];
if ((int)$TorrentID) {
$message .= ' ' . $messageParts[$i++] . " <a href=\"torrents.php?torrentid=$TorrentID\">$TorrentID</a>";
} else {
$message .= ' ' . $messageParts[$i];
}
break;
case 'Request':
$RequestID = $messageParts[$i + 1];
if ((int)$RequestID) {
$message .= ' ' . $messageParts[$i++] . " <a href=\"requests.php?action=view&amp;id=$RequestID\">$RequestID</a>";
} else {
$message .= ' ' . $messageParts[$i];
}
break;
case 'Artist':
case 'artist':
$ArtistID = $messageParts[$i + 1];
if ((int)$ArtistID) {
$message .= ' ' . $messageParts[$i++] . " <a href=\"artist.php?id=$ArtistID\">$ArtistID</a>";
} else {
$message .= ' ' . $messageParts[$i];
}
break;
case 'Group':
case 'group':
$GroupID = $messageParts[$i + 1];
if ((int)$GroupID) {
$message .= ' ' . $messageParts[$i] . " <a href=\"torrents.php?id=$GroupID\">$GroupID</a>";
} else {
$message .= ' ' . $messageParts[$i];
}
$i++;
break;
case 'by':
$userId = 0;
$user = '';
$URL = '';
if ($messageParts[$i + 1] == 'user') {
$i++;
if ((int)($messageParts[$i + 1])) {
$userId = $messageParts[++$i];
}
$URL = "user $userId (<a href=\"user.php?id=$userId\">" . substr($messageParts[++$i], 1, -1) . '</a>)';
} elseif (in_array($messageParts[$i - 1], ['deleted', 'uploaded', 'edited', 'created', 'recovered'])) {
$user = $messageParts[++$i];
if (substr($user, -1) == ':') {
$user = substr($user, 0, -1);
$colon = true;
}
if (!isset($this->usernames[$user])) {
$userId = $this->usernameLookup($user);
$this->usernames[$user] = $userId ? $userId : '';
} else {
$userId = $this->usernames[$user];
}
$this->db->set_query_id($Log);
$URL = $this->usernames[$user] ? "<a href=\"user.php?id=$userId\">$user</a>" . ($colon ? ':' : '') : $user;
}
$message .= " by $URL";
break;
case 'uploaded':
if ($color === false) {
$color = 'forestgreen';
}
$message .= ' ' . $messageParts[$i];
break;
case 'deleted':
if ($color === false || $color === 'forestgreen') {
$color = 'crimson';
}
$message .= ' ' . $messageParts[$i];
break;
case 'edited':
if ($color === false) {
$color = 'royalblue';
}
$message .= ' ' . $messageParts[$i];
break;
case 'un-filled':
if ($color === false) {
$color = '';
}
$message .= ' ' . $messageParts[$i];
break;
case 'marked':
if ($i == 1) {
$user = $messageParts[$i - 1];
if (!isset($this->usernames[$user])) {
$userId = $this->usernameLookup($user);
$this->usernames[$user] = $userId ? $userId : '';
} else {
$userId = $this->usernames[$user];
}
$URL = $this->usernames[$user] ? "<a href=\"user.php?id=$userId\">$user</a>" : $user;
$message = $URL . " " . $messageParts[$i];
} else {
$message .= ' ' . $messageParts[$i];
}
break;
case 'Collage':
$CollageID = $messageParts[$i + 1];
if (is_numeric($CollageID)) {
$message .= ' ' . $messageParts[$i] . " <a href=\"collages.php?id=$CollageID\">$CollageID</a>";
$i++;
} else {
$message .= ' ' . $messageParts[$i];
}
break;
default:
$message .= ' ' . $messageParts[$i];
}
}
return [$color, $message];
}
protected function usernameLookup(string $username) {
return $this->db->scalar(
"
SELECT ID FROM users_main WHERE Username = ?
",
$username
);
}
}
+185
View File
@@ -0,0 +1,185 @@
<?php
namespace Gazelle\Manager;
class StaffBlog extends \Gazelle\Base {
/** @var int */
protected $blogId;
/** @var int */
protected $authorId;
/** @var string */
protected $body;
/** @var string */
protected $title;
protected const CACHE_KEY = 'staff_blogv3';
/**
* Update the last visited timestamp
*
* @param int ID of the vistort
*/
public function visit(int $userId) {
$this->db->prepared_query(
"
INSERT INTO staff_blog_visits
(UserID)
VALUES (?)
ON DUPLICATE KEY UPDATE Time = now()
",
$userId
);
$this->cache->delete_value("staff_blog_read_{$userId}");
}
public function blogId(): ?int {
return $this->blogId;
}
public function authorId(): ?int {
return $this->authorId;
}
public function body(): ?string {
return $this->body;
}
public function title(): ?string {
return $this->title;
}
public function load(int $blogId) {
$this->blogId = $blogId;
[$this->body, $this->title] = $this->db->row(
"
SELECT Body, Title
FROM staff_blog
WHERE ID = ?
",
$this->blogId
);
return $this;
}
/**
* Get the list of blog entries, most recent first
*
* @return array list of entries
*/
public function blogList(): array {
if (($list = $this->cache->get_value(self::CACHE_KEY)) === false) {
$this->db->prepared_query("
SELECT
b.ID AS id,
um.Username AS author,
b.Title AS title,
b.Body AS body,
b.Time AS time
FROM staff_blog AS b
INNER JOIN users_main AS um ON (b.UserID = um.ID)
ORDER BY Time DESC
");
$list = $this->db->to_array(false, MYSQLI_ASSOC, false);
$this->cache->cache_value(self::CACHE_KEY, $list, 1209600);
}
return $list;
}
/**
* Set the ID of a blog post. If this is set, calling modify()
* will issue an update, rather than an insert.
*
* @param int blog id
*/
public function setId(int $blogId) {
$this->blogId = $blogId;
return $this;
}
/**
* Set the author user ID of a blog post. Used during creation of a post.
*
* @param int user id
*/
public function setAuthorId(int $userId) {
$this->authorId = $userId;
return $this;
}
/**
* Set the title of a blog post
*
* @param string title of the blog post
*/
public function setTitle(string $title) {
$this->title = $title;
return $this;
}
/**
* Set the body of a blog post
*
* @param string body of the blog post
*/
public function setBody(string $body) {
$this->body = $body;
return $this;
}
/**
* Save the changes of the blog post.
*
* @return bool success
*/
public function modify() {
if ($this->blogId) {
$this->db->prepared_query(
"
UPDATE staff_blog SET
Title = ?,
Body = ?
WHERE ID = ?
",
$this->title,
$this->body,
$this->blogId
);
} else {
$this->db->prepared_query(
"
INSERT INTO staff_blog
(UserID, Title, Body)
VALUES (?, ?, ?)
",
$this->authorId,
$this->title,
$this->body
);
$this->blogId = $this->db->inserted_id();
}
$this->cache->deleteMulti(['staff_feed_blog', self::CACHE_KEY]);
return $this->db->affected_rows() === 1;
}
/**
* Remove a blog post.
*
* @return bool success
*/
public function remove(int $blogId) {
$this->db->prepared_query(
"
DELETE FROM staff_blog WHERE ID = ?
",
$blogId
);
$this->blogId = null;
$this->body = null;
$this->title = null;
$this->cache->deleteMulti(['staff_feed_blog', self::CACHE_KEY]);
return $this->db->affected_rows() === 1;
}
}
+636
View File
@@ -0,0 +1,636 @@
<?php
namespace Gazelle\Manager;
class Tag extends \Gazelle\Base {
protected $foo;
/**
* Get a tag ready for database input and display.
* Trim whitespace, force to lower case, internal spaces and dashes become dots,
* remove all that is not alphanumeric + dot,
* remove leading and trailing dots and remove doubled-up dots.
*
* @param string $tag
* @return string cleaned-up version of $tag
*/
public function sanitize($tag) {
return preg_replace(
'/\.+/',
'.', // remove doubled-up dots
trim( // trim leading, trailing dots
preg_replace(
'/[^a-z0-9.]+/',
'', // remove non alphanum, dot
str_replace(
[' ', '-'],
'.', // dash and internal space to dot
strtolower( // lowercase
trim($tag) // whitespace
)
)
),
'.' // trim-a-dot
)
);
}
/**
* Normalize a list of tags (sanitize them and remove duplicates)
*
* @param string space-separated list of tags
* @param string tidy list of space-separated tags
*/
public function normalize(string $tagList): string {
$tags = preg_split('/[\s]+/', $tagList);
$clean = [];
foreach ($tags as $t) {
$clean[$this->sanitize($t)] = 1;
}
return implode(' ', array_keys($clean));
}
/**
* Get the ID of a tag
*
* @param string $tag
* @return int ID of tag, or null if no such tag
*/
public function lookup(string $tag) {
return $this->db->scalar(
"
SELECT ID
FROM tags
WHERE Name = ?
",
$tag
);
}
/**
* Get the name of an ID
*
* @param int $id ID of the tag
* @return string $name Name of the tag
*/
public function name(int $id) {
return $this->db->scalar(
"
SELECT Name
FROM tags
WHERE ID = ?
",
$id
);
}
/**
* Create a tag. If the tag already exists its usage is incremented.
*
* @param string $tag
* @param int $userId The id of the user creating the tag.
* @return int ID of tag
*/
public function create(string $name, int $userId) {
$this->db->prepared_query(
"
INSERT INTO tags
(Name, UserID)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE
Uses = Uses + 1
",
$this->resolve($this->sanitize($name)),
$userId
);
return $this->db->inserted_id();
}
/**
* See if a tag is marked as bad (would be replaced by an alias)
*
* @see resolve()
* @param string $tag
* @return int ID of tag, or null if no such tag
*/
public function lookupBad(string $tag) {
return $this->db->scalar(
"
SELECT ID
FROM tag_aliases
WHERE BadTag = ?
",
$tag
);
}
/**
* Make a tag official
*
* @param string $tag
* @param int $userId Who is doing the officializing/
* @return int $tagId id of the officialized tag.
*/
public function officialize(string $tag, int $userId) {
$tag = $this->sanitize($tag);
$id = $this->lookup($tag);
if ($id) {
// Tag already exists
$this->db->prepared_query(
"
UPDATE tags SET
TagType = 'genre'
WHERE ID = ?
",
$id
);
} else {
// Tag doesn't exist yet: create it
$this->db->prepared_query(
"
INSERT INTO tags
(Name, UserID, TagType, Uses)
VALUES (?, ?, 'genre', 0)
",
$tag,
$userId
);
$id = $this->db->inserted_id();
}
return $id;
}
/**
* Make a list of tags unofficial
*
* @param array $id list of ids to unofficialize
* @return int Number of tags that were actually unofficialized
*/
public function unofficialize(array $id) {
$this->db->prepared_query(
"
UPDATE tags SET
TagType = 'other'
WHERE ID IN (" . placeholders($id) . ")
",
...$id
);
return $this->db->affected_rows();
}
/**
* Return an iterator to loop over all the official tags.
*
* @param int $gather number of rows to gather per iteration
* @return array [id, name, uses]
*/
public function listOfficial($columns, $order = 'name') {
$orderBy = $order == 'name' ? '2, 3 DESC' : '3 DESC, 2';
$this->db->prepared_query(
"
SELECT ID AS id, Name AS name, Uses AS uses
FROM tags
WHERE TagType = ?
ORDER BY $orderBy
",
'genre'
);
$list = $this->db->to_array('id', MYSQLI_ASSOC);
$n = count($list);
$result = [];
if ($n < $columns) {
foreach ($list as $l) {
$result[] = [$l];
}
} else {
for ($i = 0; $i < $columns - 1; ++$i) {
$column = (int)ceil(count($list) / ($columns - $i));
$result[] = array_splice($list, 0, $column);
}
$result = array_merge($result, [$list]);
}
return $result;
}
/**
* Get the names of all genre tags
*
* @return array List of names
*/
public function genreList(): array {
$list = $this->cache->get_value('genre_tags');
if (!$list) {
$this->db->prepared_query("
SELECT Name
FROM tags
WHERE TagType = 'genre'
ORDER BY Name
");
$list = $this->db->collect('Name');
$this->cache->cache_value('genre_tags', $list, 3600 * 24);
}
return $list;
}
/**
* Rename a tag
*
* @param int $tagId
* @param string $tag new tag name
* @param int $userId who is doing the rename
* @return int number of affected rows (should be 0 or 1)
*/
public function rename(int $tagId, string $name, int $userId) {
$this->db->prepared_query(
"
UPDATE tags SET
Name = ?,
UserID = ?
WHERE ID = ?
",
$name,
$userId,
$tagId
);
return $this->db->affected_rows();
}
/**
* Merge (or split) a tag
*
* @param int $currentId The tag that will be removed in the merge
* @param array $replacement The replacement tags for $currentId
* @param int $userID The ID of the moderator performing the merge
* @return int number of items changed by the merge
*/
public function merge(int $currentId, array $replacement, int $userId) {
$totalChanged = 0;
$totalRenamed = 0;
foreach ($replacement as $r) {
$replacementId = $this->lookup($r);
if (is_null($replacementId)) {
// if we are splitting to more than one tag that does not exist
// then we can rename the first one but after that we have to
// begin creating additional tags.
if ($totalRenamed == 0) {
$totalRenamed += $this->rename($currentId, $r, $userId);
} else {
$replacementId = $this->create($r, $userId);
++$totalRenamed;
}
continue;
}
// If the torrent has the old tag, but not the replacement, add it,
$changed = 0;
$this->db->prepared_query(
"
INSERT INTO torrents_tags (TagID, UserID, GroupID, PositiveVotes, NegativeVotes)
SELECT ?, ?, curr.GroupID, curr.PositiveVotes, curr.NegativeVotes
FROM torrents_tags curr
LEFT JOIN torrents_tags merge ON (merge.GroupID = curr.GroupID AND merge.TagID = ?)
WHERE curr.TagID = ? AND merge.TagID IS NULL
",
$replacementId,
$userId,
$replacementId,
$currentId
);
$changed += $this->db->affected_rows();
// same for artists,
$this->db->prepared_query(
'
INSERT INTO artists_tags (TagID, UserID, ArtistID, PositiveVotes, NegativeVotes)
SELECT ?, ?, curr.ArtistID, curr.PositiveVotes, curr.NegativeVotes
FROM artists_tags curr
LEFT JOIN artists_tags merge ON (merge.ArtistID = curr.ArtistID AND merge.TagID = ?)
WHERE curr.TagID = ? AND merge.TagID IS NULL
',
$replacementId,
$userId,
$replacementId,
$currentId
);
$changed += $this->db->affected_rows();
// and requests.
$this->db->prepared_query(
"
INSERT INTO requests_tags (TagID, RequestID)
SELECT ?, curr.RequestID
FROM requests_tags curr
LEFT JOIN requests_tags merge ON (merge.RequestID = curr.RequestID AND merge.TagID = ?)
WHERE curr.TagID = ? AND merge.TagID IS NULL
",
$replacementId,
$replacementId,
$currentId
);
$changed += $this->db->affected_rows();
// update usage count for replacement tag
$this->db->prepared_query(
"
UPDATE tags SET
Uses = Uses + ?
WHERE ID = ?
",
$changed,
$replacementId
);
$totalChanged += $changed;
}
// Kill the old tag everywhere
$this->db->prepared_query(
"
DELETE t, at, rt, tt
FROM tags t
LEFT JOIN artists_tags at ON (at.TagID = t.ID)
LEFT JOIN requests_tags rt ON (rt.TagID = t.ID)
LEFT JOIN torrents_tags tt ON (tt.TagID = t.ID)
WHERE t.ID = ?
",
$currentId
);
return $totalChanged + $totalRenamed;
}
/**
* Add a mapping of a bad tag alias to a acceptble alias
*
* @param string $bad The bad tag name (to be replaced upon usage by)
* @param string $good The good name.
* @return int Number of rows added (0 or 1)
*/
public function createAlias(string $bad, string $good) {
$this->db->prepared_query(
"
INSERT INTO tag_aliases
(BadTag, AliasTag)
VALUES (?, ?)
",
$this->sanitize($bad),
$this->sanitize($good)
);
return $this->db->affected_rows();
}
/**
* Modify the mapping of a bad tag alias to a acceptble alias
*
* @param int $aliasId The id of the alias to change
* @param string $bad The bad tag name (to be replaced upon usage by)
* @param string $good The good name.
* @return int Number of rows changed (0 or 1)
*/
public function modifyAlias(int $aliasId, string $bad, string $good) {
$this->db->prepared_query(
"
UPDATE tag_aliases SET
BadTag = ?,
AliasTag = ?
WHERE ID = ?
",
$this->sanitize($bad),
$this->sanitize($good),
$aliasId
);
return $this->db->affected_rows();
}
/**
* Remove the mapping of a bad tag alias.
*
* @param int $aliasId The id of the alias to remove
* @return int Number of rows deleted (0 or 1)
*/
public function removeAlias(int $aliasId) {
$this->db->prepared_query(
"
DELETE FROM tag_aliases WHERE ID = ?
",
$aliasId
);
return $this->db->affected_rows();
}
/**
* Resolve the alias of a tag.
*
* @see lookupBad()
* @param string $tag the name we want to change if has an alias
* @return string The resolved tag name, its alias or itself
*/
public function resolve($name) {
$QueryID = $this->db->get_query_id();
$resolved = $this->db->scalar(
"
SELECT AliasTag
FROM tag_aliases
WHERE BadTag = ?
",
$name
);
$this->db->set_query_id($QueryID);
return $resolved ?: $name;
}
/**
* Return the list of aliases
*
* @param bool $orderByBad true to order by bad, otherwise alias
* @return array list of [id, bad, alias]
*/
public function listAlias(bool $orderByBad) {
$column = $orderByBad ? 2 : 3;
$this->db->prepared_query("
SELECT ID AS id, BadTag AS bad, AliasTag AS alias
FROM tag_aliases
ORDER BY $column
");
return $this->db->to_array('id', MYSQLI_ASSOC);
}
/**
* Get the list of torrents matched by a tag
*
* @param int $tagId
* @return array [artistId, artistName, torrentGroupId, torrentGroupName]
* (artist elements may be null)
*/
public function torrentLookup(int $tagId) {
$this->db->prepared_query(
"
SELECT
ag.ArtistID AS artistId,
ag.Name AS artistName,
tg.ID AS torrentGroupId,
tg.Name AS torrentGroupName
FROM torrents_group AS tg
INNER JOIN torrents_tags AS t ON (t.GroupID = tg.ID)
LEFT JOIN torrents_artists AS ta ON (ta.GroupID = tg.ID)
LEFT JOIN artists_group AS ag ON (ag.ArtistID = ta.ArtistID)
WHERE t.TagID = ?
",
$tagId
);
return $this->db->to_array(false, MYSQLI_ASSOC, false);
}
/**
* Get the list of torrents matched by a tag
*
* @param int $tagId
* @return array [artistId, artistName, requestId, requestName]
* (artist elements may be null)
*/
public function requestLookup(int $tagId) {
$this->db->prepared_query(
"
SELECT
ag.ArtistID AS artistId,
ag.Name AS artistName,
ra.RequestID AS requestId,
r.Title AS requestName
FROM requests AS r
INNER JOIN requests_tags AS t ON (t.RequestID = r.ID)
LEFT JOIN requests_artists AS ra ON (r.ID = ra.RequestID)
LEFT JOIN artists_group AS ag ON (ag.ArtistID = ra.ArtistID)
WHERE t.TagID = ?
",
$tagId
);
return $this->db->to_array(false, MYSQLI_ASSOC, false);
}
/**
* Create a tag on a request
*
* @param int $tagId The id of the tag
* @param int $requestId The id of the request
* @return int Number of rows affected
*/
public function createRequestTag(int $tagId, int $requestId) {
$this->db->prepared_query(
"
INSERT IGNORE INTO requests_tags
(TagID, RequestID)
VALUES (?, ?)
",
$tagId,
$requestId
);
return $this->db->affected_rows();
}
/**
* Create a tag on a torrent group
*
* @param int $tagId The id of the tag
* @param int $groupId The id of the torrent group
* @param int $userId The id of the user
* @param int $weight The the weight of this addition
* @return int Number of rows affected
*/
public function createTorrentTag(int $tagId, int $groupId, int $userId, int $weight) {
$this->db->prepared_query(
"
INSERT INTO torrents_tags
(TagID, GroupID, UserID, PositiveVotes)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
PositiveVotes = PositiveVotes + 2
",
$tagId,
$groupId,
$userId,
$weight
);
return $this->db->affected_rows();
}
/**
* Create a tag vote on a torrent group
*
* @param int $tagId The id of the tag
* @param int $groupId The id of the torrent group
* @param int $userId The id of the user
* @param string $vote 'up' or 'down'
*
* @return int Number of rows affected
*/
public function createTorrentTagVote(int $tagId, int $groupId, int $userId, string $vote) {
$this->db->prepared_query(
"
INSERT INTO torrents_tags_votes
(TagID, GroupID, UserID, Way)
VALUES (?, ?, ?, ?)
",
$tagId,
$groupId,
$userId,
$vote
);
return $this->db->affected_rows();
}
/**
* Check if a user has voted on a torrent tag
*
* @param int $tagId The id of the tag
* @param int $groupId The id of the torrent group
* @param int $userId The id of the user
* @return bool True if the user as already voted on this tag
*/
public function torrentTagHasVote(int $tagId, int $groupId, int $userId) {
$this->db->prepared_query(
"
SELECT 1
FROM torrents_tags_votes
WHERE TagID = ?
AND GroupID = ?
AND UserID = ?
",
$tagId,
$groupId,
$userId
);
return $this->db->has_results();
}
/**
* Get some autocomplete tags encoded in JSON
*
* @param string $word the stem of the tags to search for
* @return array of array of JSON key=>value names
* [['value' => 'tag1'], ['value' => 'tag2'], ...]]
*/
public function autocompleteAsJson(string $word) {
$maxKeySize = 4;
$keySize = min($maxKeySize, max(1, strlen($word)));
$letters = strtolower(substr($word, 0, $keySize));
$key = "autocomplete_tags_{$keySize}_$letters";
if (($suggestions = $this->cache->get($key)) == false) {
$this->db->prepared_query(
"
SELECT Name
FROM tags
WHERE (Uses > 700 OR TagType = 'genre')
AND Name REGEXP concat('^', ?)
ORDER BY TagType = 'genre' DESC, Uses DESC
LIMIT ?
",
$word,
10
);
$suggestions = $this->db->to_array(false, MYSQLI_NUM, false);
$this->cache->cache_value($key, $suggestions, 1800 + 7200 * ($maxKeySize - $keySize)); // Can't cache things for too long in case names are edited
}
return array_map(function ($v) {
return ['value' => $v[0]];
}, $suggestions);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Gazelle\Manager;
/* Conversation thread, allows private staff notes intermingled
* with the public notes that both staff and member can see.
* A collection of notes is called a story.
*/
class Thread extends \Gazelle\Base {
/**
*/
public function createThread($type) {
parent::__construct();
$this->db->prepared_query("
INSERT INTO thread (ThreadTypeID) VALUES (
(SELECT ID FROM thread_type WHERE Name = ?)
)
", $type);
return new \Gazelle\Thread($this->db->inserted_id());
}
}
+357
View File
@@ -0,0 +1,357 @@
<?php
namespace Gazelle\Manager;
class Wiki extends \Gazelle\Base {
protected $aliases;
protected const CACHE_KEY = 'wiki_article_v3_%d';
/**
* Find a wiki article based on its title.
*
* @param string Title
* @return int|null id of article if it exists
*/
public function findByTitle(string $title): ?int {
return $this->db->scalar(
"
SELECT ID
FROM wiki_articles
WHERE Title = ?
",
trim($title)
);
}
/**
* Create a wiki article
*
* @param string title
* @param string body
* @param int minimum class to read
* @param int minimum class to modify
* @param int author id
* @return int article id
*/
public function create(string $title, string $body, int $minRead, int $minEdit, int $userId) {
$title = trim($title);
$this->db->begin_transaction();
$this->db->prepared_query(
"
INSERT INTO wiki_articles
(Title, Body, MinClassRead, MinClassEdit, Author, Date, Revision)
VALUES (?, ?, ?, ?, ?, now(), 1)
",
$title,
trim($body),
$minRead,
$minEdit,
$userId
);
$articleId = $this->db->inserted_id();
$alias = $this->normalizeAlias($title);
if ($alias && !$this->alias($alias)) {
$this->addAlias($articleId, $alias, $userId);
}
$this->db->commit();
$this->flushArticle($articleId);
return $articleId;
}
/**
* Modifiy a wiki article
*
* @param int article id
* @param string title
* @param string body
* @param int minimum class to read
* @param int minimum class to modify
* @param int author id
* @return int article id
*/
public function modify(int $articleId, string $title, string $body, int $minRead, int $minEdit, int $userId) {
$this->db->begin_transaction();
$this->db->prepared_query(
"
INSERT INTO wiki_revisions
(ID, Revision, Title, Body, Author, Date)
SELECT ID, Revision, Title, Body, Author, Date
FROM wiki_articles
WHERE ID = ?
GROUP BY ID
",
$articleId
);
$revision = $this->db->scalar(
"
SELECT max(Revision) FROM wiki_articles WHERE ID = ?
",
$articleId
);
$this->db->prepared_query(
"
UPDATE wiki_articles SET
Date = now(),
Revision = ?,
Title = ?,
Body = ?,
MinClassRead = ?,
MinClassEdit = ?,
Author = ?
WHERE ID = ?
",
$revision + 1,
trim($title),
trim($body),
$minRead,
$minEdit,
$userId,
$articleId
);
$this->db->commit();
return $this->flushArticle($articleId);
}
/**
* Remove an article
*
* param int the article to remove
*/
public function remove(int $articleId) {
$this->db->begin_transaction();
$this->db->prepared_query("DELETE FROM wiki_articles WHERE ID = ?", $articleId);
$this->db->prepared_query("DELETE FROM wiki_aliases WHERE ArticleID = ?", $articleId);
$this->db->prepared_query("DELETE FROM wiki_revisions WHERE ID = ?", $articleId);
$this->db->commit();
return $this->flushArticle($articleId);
}
/**
* Determine what the read and write access levels should be, based on the editor
*
* @param int can the viewer administrate the wiki
* @param int viewer class
* @param int the proposed read class
* @param int the proposed edit class
* @return array [read class, edit class, error]
* The error entry will be non-null in case of an error and read and edit will be null.
*/
public function configureAccess(int $isAdmin, int $class, int $minRead, int $minEdit) {
if (!$isAdmin) {
return [100, 100, null];
}
if (!$minRead) {
return [null, null, 'read permission not set'];
} elseif ($minRead > $class) {
return [null, null, 'You cannot restrict views above your own level'];
}
if (!$minEdit) {
return [null, null, 'edit permission not set'];
} else {
if ($minEdit < $minRead) {
$minEdit = $minRead;
}
if ($minEdit > $class) {
return [null, null, 'You cannot restrict edits above your own level'];
}
}
return [$minRead, $minEdit, null];
}
/**
* Normalize an alias
* @param string $str
* @return string
*/
public function normalizeAlias(string $alias): string {
return trim(substr(preg_replace('/[^a-z0-9]/', '', strtolower(htmlentities(trim($alias)))), 0, 50));
}
/**
* Get all aliases in an associative array of Alias => ArticleID
* @return array
*/
public function aliasList(): array {
$this->aliases = $this->cache->get_value('wiki_aliases');
if (!$this->aliases) {
$qid = $this->db->get_query_id();
$this->db->prepared_query("
SELECT Alias, ArticleID FROM wiki_aliases
");
$this->aliases = [];
while ([$alias, $articleId] = $this->db->next_row()) {
$this->aliases[$alias] = (int)$articleId;
}
$this->db->set_query_id($qid);
$this->cache->cache_value('wiki_aliases', $this->aliases, 3600 * 24 * 14); // 2 weeks
}
return $this->aliases;
}
/**
* Flush the alias cache. Call this whenever you touch the wiki_aliases table.
*/
public function flush() {
$this->cache->delete_value('wiki_aliases');
return $this;
}
/**
* Get the article an alias points to
*
* @param string $alias
* @return int|null
*/
public function alias(string $alias): ?int {
$aliases = $this->aliasList();
return $aliases[$this->normalizeAlias($alias)] ?? null;
}
/**
* Get an article
* @param int $articleId
* @throws Gazelle\Exception\ResourceNotFoundException
* @return array
*/
public function article(int $articleId): array {
$key = sprintf(self::CACHE_KEY, $articleId);
$contents = $this->cache->get_value($key);
if (!$contents) {
$qid = $this->db->get_query_id();
$contents = $this->db->row(
"
SELECT w.Revision,
w.Title,
w.Body,
w.MinClassRead,
w.MinClassEdit,
w.Date,
w.Author,
GROUP_CONCAT(a.Alias) as aliases,
GROUP_CONCAT(a.UserID) as users
FROM wiki_articles AS w
LEFT JOIN wiki_aliases AS a ON (w.ID = a.ArticleID)
LEFT JOIN users_main AS u ON (u.ID = w.Author)
WHERE w.ID = ?
GROUP BY w.ID
",
$articleId
);
if (empty($contents)) {
throw new \Gazelle\Exception\ResourceNotFoundException($articleId);
}
$this->db->set_query_id($qid);
$this->cache->cache_value($key, $contents, 3600 * 24 * 14); // 2 weeks
}
return $contents;
}
/**
* Get the list of wiki articles
*
* @param int class of viewer, to return only the articles they are allowed to read
* @param string first letter of articles to see, or 'All' for all
* @return array [id, title, date, authorId]
*/
public function articles(int $class, $letter): array {
$sql = "
SELECT ID,
Title,
Date,
Author
FROM wiki_articles
WHERE MinClassRead <= ?
";
$args = [$class];
if (!empty($letter) && $letter !== '1') { // '1' denotes All
$sql .= " AND LEFT(Title,1) = ?";
$args[] = $letter;
}
$sql .= " ORDER BY Title";
$this->db->prepared_query($sql, ...$args);
return $this->db->to_array(false, MYSQLI_ASSOC);
}
public function revisions(int $articleId) {
$this->db->prepared_query(
"
SELECT Revision,
Title,
Author,
Date
FROM wiki_revisions
WHERE ID = ?
ORDER BY Revision DESC
",
$articleId
);
return $this->db;
}
/**
* Flush an article's cache. Call this whenever you edited a wiki article or its aliases.
* @param int $articleId
*/
public function flushArticle(int $articleId) {
$this->cache->delete_value(sprintf(self::CACHE_KEY, $articleId));
return $this->flush();
}
/**
* Can the viewer edit this article?
* NB: currently there is no equivalent readAllowed() method.
* Further restructuring is necessary before that makes sense.
*
* @param int article id
* @param int viewer class
* @return bool viewer can edit
*/
public function editAllowed(int $articleId, int $class): bool {
return $class >= $this->db->scalar(
"
SELECT MinClassEdit FROM wiki_articles WHERE ID = ?
",
$articleId
);
}
/**
* Add an alias to an existing article
*
* @param int article id
* @param string alias
* @param int user id of the person adding the alias
*/
public function addAlias(int $articleId, string $alias, int $userId) {
$this->db->prepared_query(
"
INSERT INTO wiki_aliases
(ArticleID, Alias, UserID)
VALUES (?, ?, ?)
",
$articleId,
$this->normalizeAlias($alias),
$userId
);
return $this->flushArticle($articleId);
}
/**
* Remove an alias of an article.
*
* @param string the alias to remove
*/
public function removeAlias(string $alias) {
$articleId = $this->alias($alias);
if (!$articleId) {
return $this;
}
$this->db->prepared_query(
"
DELETE FROM wiki_aliases WHERE Alias = ?
",
$this->normalizeAlias(trim($alias))
);
return $this->flushArticle($articleId);
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
namespace Gazelle\Manager;
class XBT extends \Gazelle\Base {
const CACHE_KEY = 'xbt_rate_%s';
/* Coinbase quotes have a 1% fee, but we lose more in tumbling, so whatever.
* Coinbase never realised that BTC collides with Bhutan, XBT is the ISO-4217 code.
*/
const FX_QUOTE_URL = 'https://api.coinbase.com/v2/prices/BTC-%s/buy';
/* Fetch the current XBT rate for a given currency code (ISO 4217)
*
* @param string $CC Currency Code
* @return float current rate, or null if API endpoint cannot be reached or is in error.
*/
public function fetchRate(string $CC) {
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => sprintf(self::FX_QUOTE_URL, $CC),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
if (defined('HTTP_PROXY')) {
curl_setopt_array($curl, [
CURLOPT_HTTPPROXYTUNNEL => true,
CURLOPT_PROXY => HTTP_PROXY,
]);
}
$result = curl_exec($curl);
if ($result === false || curl_getinfo($curl, CURLINFO_RESPONSE_CODE) !== 200) {
return null;
}
// {"data":{"base":"BTC","currency":"USD","amount":"8165.93"}}
$payload = json_decode($result);
return $payload->data->amount;
}
/* Persist the Forex rate for this currency
*
* @param string $CC Currency Code
* @param float $rate The current rate (e.g. from fetchRate())
* @return boolean Success
*/
public function saveRate(string $CC, float $rate) {
$this->db->prepared_query(
'
INSERT INTO xbt_forex
(cc, rate)
VALUES (?, ?)
',
$CC,
$rate
);
return $this->db->affected_rows() == 1;
}
/* Get the latest Forex rate for this currency
*
* @param string $CC Currency Code
* @return float Current rate, or null on failure
*/
public function latestRate(string $CC) {
$key = sprintf(self::CACHE_KEY, $CC);
if (($rate = $this->cache->get_value($key)) === false) {
$rate = $this->db->scalar(
'
SELECT rate
FROM xbt_forex
WHERE forex_date > now() - INTERVAL 6 HOUR
AND cc = ? GROUP BY cc
ORDER BY forex_date DESC
LIMIT 1
',
$CC
);
if (is_null($rate)) {
$rate = $this->fetchRate($CC);
if (is_null($rate)) {
return null;
}
$this->saveRate($CC, $rate);
}
$this->cache->cache_value($key, $rate, 3600 * 6);
}
return $rate;
}
/* Convert the fiat currency amount to XBT at current rates
*
* @param float $amount Amount of fiat currency
* @param string $CC Currency Code
* @return float Current amount in XBT, or null on failure
*/
public function fiat2xbt(float $amount, string $CC) {
$rate = $this->latestRate($CC);
return is_null($rate) ? null : $amount / $rate;
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Gazelle;
class Permission extends Base {
protected $list;
const CACHE_KEY = 'permissions';
public function list() {
if (($this->list = $this->cache->get_value(self::CACHE_KEY)) === false) {
$this->db->prepared_query('
SELECT ID, Name
FROM permissions
WHERE Secondary = 0
ORDER BY level
');
$this->list = $this->db->to_array('ID', MYSQLI_ASSOC, false);
$this->cache->cache_value(self::CACHE_KEY, $this->list, 84600 * 7);
}
return $this->list;
}
}
+519
View File
@@ -0,0 +1,519 @@
<?php
namespace Gazelle;
class Recovery {
static function email_check($raw) {
$raw = strtolower(trim($raw));
$parts = explode('@', $raw);
if (count($parts) != 2) {
return null;
}
list($lhs, $rhs) = $parts;
if ($rhs == 'gmail.com') {
$lhs = str_replace('.', '', $lhs);
}
$lhs = preg_replace('/\+.*$/', '', $lhs);
return [$raw, "$lhs@$rhs"];
}
static function validate($info) {
$data = [];
foreach (explode(' ', 'username email announce invite info') as $key) {
if (!isset($info[$key])) {
continue;
}
switch ($key) {
case 'email':
$email = self::email_check($info['email']);
if (!$email) {
return [];
}
$data['email'] = $email[0];
$data['email_clean'] = $email[1];
break;
default:
$data[$key] = trim($info[$key]);
break;
}
}
return $data;
}
static function save_screenshot($upload) {
if (!isset($upload['screen'])) {
return [false, "File form name missing"];
}
$file = $upload['screen'];
if (!isset($file['error']) || is_array($file['error'])) {
return [false, "Never received the uploaded file."];
}
switch ($file['error']) {
case UPLOAD_ERR_OK:
break;
case UPLOAD_ERR_NO_FILE:
return [true, null];
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
return [false, "File was too large, please make sure it is less than 10MB in size."];
default:
return [false, "There was a problem with the screenshot file."];
}
if ($file['size'] > 10 * 1024 * 1024) {
return [false, "File was too large, please make sure it is less than 10MB in size."];
}
$filename = sha1(RECOVERY_SALT . mt_rand(0, 10000000) . sha1_file($file['tmp_name']));
$destination = sprintf(
'%s/%s/%s/%s/%s',
RECOVERY_PATH,
substr($filename, 0, 1),
substr($filename, 1, 1),
substr($filename, 2, 1),
$filename
);
if (!move_uploaded_file($file['tmp_name'], $destination)) {
return [false, "Unable to persist your upload."];
}
return [true, $filename];
}
static function check_password($user, $pw, $db) {
$db->prepared_query('SELECT PassHash FROM ' . RECOVERY_DB . '.users_main WHERE Username = ?', $user);
if (!$db->has_results()) {
return false;
}
list($prevhash) = $db->next_record();
return password_verify($pw, $prevhash);
}
static function persist($info, $db) {
$db->prepared_query(
"INSERT INTO recovery (token, ipaddr, username, password_ok, email, email_clean, announce, screenshot, invite, info, state )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'PENDING')",
$info['token'],
$info['ipaddr'],
$info['username'],
$info['password_ok'],
$info['email'],
$info['email_clean'],
$info['announce'],
$info['screenshot'],
$info['invite'],
$info['info']
);
return $db->affected_rows();
}
static function get_total($state, $admin_id, $db) {
$state = strtoupper($state);
switch ($state) {
case 'CLAIMED':
$db->prepared_query("SELECT count(*) FROM recovery WHERE state = ? and admin_user_id = ?", $state, $admin_id);
break;
case 'PENDING':
$db->prepared_query("SELECT count(*) FROM recovery WHERE state = ? and (admin_user_id is null or admin_user_id != ?)", $state, $admin_id);
break;
default:
$db->prepared_query("SELECT count(*) FROM recovery WHERE state = ?", strtoupper($state));
break;
}
list($total) = $db->next_record();
return $total;
}
static function get_list($limit, $offset, $state, $admin_id, $db) {
$state = strtoupper($state);
$sql_header = 'SELECT recovery_id, username, token, email, announce, created_dt, updated_dt, state FROM recovery';
$sql_footer = 'ORDER BY updated_dt DESC LIMIT ? OFFSET ?';
switch ($state) {
case 'CLAIMED':
$db->prepared_query(
"$sql_header
WHERE admin_user_id = ?
$sql_footer
",
$admin_id,
$limit,
$offset
);
break;
case 'PENDING':
$db->prepared_query(
"$sql_header
WHERE admin_user_id IS NULL
AND state = ?
$sql_footer
",
$state,
$limit,
$offset
);
break;
default:
$db->prepared_query(
"$sql_header
WHERE state = ?
$sql_footer
",
$state,
$limit,
$offset
);
break;
}
return $db->to_array();
}
static function validate_pending($db) {
$db->prepared_query("SELECT recovery_id
FROM recovery r
INNER JOIN " . RECOVERY_DB . ".users_main m ON (m.torrent_pass = r.announce)
WHERE r.state = 'PENDING' AND r.admin_user_id IS NULL AND char_length(r.announce) = 32
LIMIT ?
", RECOVERY_AUTOVALIDATE_LIMIT);
$recover = $db->to_array();
foreach ($recover as $r) {
self::accept($r['recovery_id'], RECOVERY_ADMIN_ID, RECOVERY_ADMIN_NAME, $db);
}
$db->prepared_query("SELECT recovery_id
FROM recovery r
INNER JOIN " . RECOVERY_DB . ".users_main m ON (m.Email = r.email)
WHERE r.state = 'PENDING' AND r.admin_user_id IS NULL AND locate('@', r.email) > 1
LIMIT ?
", RECOVERY_AUTOVALIDATE_LIMIT);
$recover = $db->to_array();
foreach ($recover as $r) {
self::accept($r['recovery_id'], RECOVERY_ADMIN_ID, RECOVERY_ADMIN_NAME, $db);
}
}
static function claim($id, $admin_id, $admin_username, $db) {
$db->prepared_query(
"
UPDATE recovery
SET admin_user_id = ?,
updated_dt = now(),
log = concat(coalesce(log, ''), ?)
WHERE recovery_id = ?
AND (admin_user_id IS NULL OR admin_user_id != ?)
",
$admin_id,
("\r\n" . Date('Y-m-d H:i') . " claimed by $admin_username"),
$id,
$admin_id
);
return $db->affected_rows();
}
static function unclaim($id, $admin_username, $db) {
$db->prepared_query(
"
UPDATE recovery
SET admin_user_id = NULL,
state = 'PENDING',
updated_dt = now(),
log = concat(coalesce(log, ''), ?)
WHERE recovery_id = ?
",
("\r\n" . Date('Y-m-d H:i') . " unclaimed by $admin_username"),
$id
);
return $db->affected_rows();
}
static function deny($id, $admin_id, $admin_username, $db) {
$db->prepared_query(
"
UPDATE recovery
SET state = 'DENIED',
updated_dt = now(),
log = concat(coalesce(log, ''), ?)
WHERE recovery_id = ?
",
("\r\n" . Date('Y-m-d H:i') . " recovery denied by $admin_username"),
$id
);
return $db->affected_rows();
}
static function accept_fail($id, $reason, $db) {
$db->prepared_query(
"
UPDATE recovery
SET state = 'PENDING',
updated_dt = now(),
log = concat(coalesce(log, ''), ?)
WHERE recovery_id = ?
",
("\r\n" . Date('Y-m-d H:i') . " recovery failed: $reason"),
$id
);
}
static function accept($id, $admin_id, $admin_username, $db) {
$db->prepared_query(
"
SELECT username, email_clean
FROM recovery WHERE state != 'DENIED' AND recovery_id = ?
",
$id
);
if (!$db->has_results()) {
return false;
}
list($username, $email) = $db->next_record();
$db->prepared_query('select 1 from users_main where email = ?', $email);
if ($db->record_count() > 0) {
self::accept_fail($id, "an existing user $username already registered with $email", $db);
return false;
}
$db->prepared_query('select InviteKey from invites where email = ?', $email);
if ($db->record_count() > 0) {
list($key) = $db->next_record();
self::accept_fail($id, "invite key $key already issued to $email", $db);
return false;
}
$key = randomString();
$db->prepared_query(
"
INSERT INTO invites (InviterID, InviteKey, Email, Reason, Expires)
VALUES (?, ?, ?, ?, now() + interval 1 week)
",
$admin_id,
$key,
$email,
"Account recovery id={$id} key={$key}"
);
require(SERVER_ROOT . '/classes/templates.class.php');
$Tpl = new \TEMPLATE;
$Tpl->open(SERVER_ROOT . '/templates/recover.tpl');
$Tpl->set('SiteName', SITE_NAME);
$Tpl->set('SiteURL', SITE_URL);
\Misc::send_email($email, 'Account recovery confirmation at ' . SITE_NAME, $Tpl->get(), 'noreply');
$db->prepared_query(
"
UPDATE recovery
SET state = ?,
updated_dt = now(),
log = concat(coalesce(log, ''), ?)
WHERE recovery_id = ?
",
($admin_id == RECOVERY_ADMIN_ID ? 'VALIDATED' : 'ACCEPTED'),
("\r\n" . Date('Y-m-d H:i') . " recovery accepted by $admin_username invite=$key"),
$id
);
return true;
}
static function get_details($id, $db) {
$db->prepared_query(
"
SELECT
recovery_id, state, admin_user_id, created_dt, updated_dt,
token, username, ipaddr, password_ok, email, email_clean,
announce, screenshot, invite, info, log
FROM recovery
WHERE recovery_id = ?
",
$id
);
return $db->next_record();
}
static function search($terms, $db) {
$cond = [];
$args = [];
foreach ($terms as $t) {
foreach ($t as $field => $value) {
$cond[] = "$field = ?";
$args[] = $value;
}
}
$condition = implode(' AND ', $cond);
$db->prepared_query(
"
SELECT
recovery_id, state, admin_user_id, created_dt, updated_dt,
token, username, ipaddr, password_ok, email, email_clean,
announce, screenshot, invite, info, log
FROM recovery
WHERE $condition
",
...$args
);
return $db->next_record();
}
static private function get_candidate_sql() {
return sprintf(
'
SELECT m.Username, m.torrent_pass, m.Email, m.Uploaded, m.Downloaded, m.Enabled, m.PermissionID, m.ID as UserID,
(SELECT count(t.ID) FROM %s.torrents t WHERE m.ID = t.UserID) as nr_torrents,
group_concat(DISTINCT(h.IP) ORDER BY h.ip) as ips
FROM %s.users_main m LEFT JOIN %s.users_history_ips h ON (m.ID = h.UserID)
',
RECOVERY_DB,
RECOVERY_DB,
RECOVERY_DB
);
}
static function get_candidate($username, $db) {
$db->prepared_query(
self::get_candidate_sql() . "
WHERE m.Username LIKE ? GROUP BY m.Username
",
$username
);
return $db->next_record();
}
static function get_candidate_by_username($username, $db) {
$db->prepared_query(
self::get_candidate_sql() . "
WHERE m.Username LIKE ? GROUP BY m.Username
",
$username
);
return $db->to_array();
}
static function get_candidate_by_announce($announce, $db) {
$db->prepared_query(
self::get_candidate_sql() . "
WHERE m.torrent_pass LIKE ? GROUP BY m.torrent_pass
",
$announce
);
return $db->to_array();
}
static function get_candidate_by_email($email, $db) {
$db->prepared_query(
self::get_candidate_sql() . "
WHERE m.Email LIKE ? GROUP BY m.Email
",
$email
);
return $db->to_array();
}
static function get_candidate_by_id($id, $db) {
$db->prepared_query(
self::get_candidate_sql() . "
WHERE m.ID = ? GROUP BY m.ID
",
$id
);
return $db->to_array();
}
static private function get_user_details_sql($schema = null) {
if ($schema) {
$permission_t = "$schema.permissions";
$users_main_t = "$schema.users_main";
$torrents_t = "$schema.torrents";
} else {
$permission_t = 'permissions';
$users_main_t = 'users_main';
$torrents_t = 'torrents';
}
return "
SELECT u.ID, u.Username, u.Email, u.torrent_pass, p.Name as UserClass, count(t.ID) as nr_torrents
FROM $users_main_t u
INNER JOIN $permission_t p ON (p.ID = u.PermissionID)
LEFT JOIN $torrents_t t ON (t.UserID = u.ID)
WHERE u.ID = ?
GROUP BY u.ID, u.Username, u.Email, u.torrent_pass, p.Name
";
}
static public function get_pair_confirmation($prev_id, $curr_id, $db) {
$db->prepared_query(self::get_user_details_sql(RECOVERY_DB), $prev_id);
$prev = $db->next_record();
$db->prepared_query(self::get_user_details_sql(), $curr_id);
$curr = $db->next_record();
return [$prev, $curr];
}
public static function is_mapped($ID, $db) {
$db->prepared_query(sprintf("SELECT MappedID AS ID FROM %s.%s WHERE UserID = ?", RECOVERY_DB, RECOVERY_MAPPING_TABLE), $ID);
return $db->to_array();
}
public static function is_mapped_local($ID, $db) {
$db->prepared_query(sprintf("SELECT UserID AS ID FROM %s.%s WHERE MappedID = ?", RECOVERY_DB, RECOVERY_MAPPING_TABLE), $ID);
return $db->to_array();
}
public static function map_to_previous($ops_user_id, $prev_user_id, $admin_username, $db) {
$db->prepared_query(
sprintf("INSERT INTO %s.%s (UserID, MappedID) VALUES (?, ?)", RECOVERY_DB, RECOVERY_MAPPING_TABLE),
$prev_user_id,
$ops_user_id
);
if ($db->affected_rows() != 1) {
return false;
}
/* staff note */
$db->prepared_query(
"
UPDATE users_info
SET AdminComment = CONCAT(?, AdminComment)
WHERE UserID = ?
",
sqltime() . " mapped to previous id $prev_user_id by $admin_username\n\n",
$ops_user_id
);
return true;
}
public static function reclaimPreviousUpload($db, $userId) {
$db->prepared_query(
sprintf(
'
UPDATE torrents curr
INNER JOIN %s.torrents prev USING (ID)
SET
curr.UserID = ?
WHERE prev.UserID = (
SELECT userid
FROM %s.%s
WHERE MappedID = ?
AND mapped = 0
)
',
RECOVERY_DB,
RECOVERY_DB,
RECOVERY_MAPPING_TABLE
),
$userId,
$userId
);
$reclaimed = $db->affected_rows();
if ($reclaimed == 0) {
$reclaimed = -1;
}
$db->prepared_query(
sprintf(
'
UPDATE %s.users_apl_mapping SET mapped = ? WHERE MappedID = ?
',
RECOVERY_DB
),
$reclaimed,
$userId
);
return $reclaimed;
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace Gazelle;
class Report {
public static function search($db, array $filter) {
$cond = [];
$args = [];
if (array_key_exists('reporter', $filter) && $filter['reporter']) {
$cond[] = 'r.ReporterID = ?';
$args[] = self::username2id($db, $filter['reporter']);
}
if (array_key_exists('handler', $filter) && $filter['handler']) {
$cond[] = 'r.ResolverID = ?';
$args[] = self::username2id($db, $filter['handler']);
}
if (array_key_exists('report-type', $filter)) {
$cond[] = 'r.Type in (' . implode(', ', array_fill(0, count($filter['report-type']), '?')) . ')';
$args = array_merge($args, $filter['report-type']);
}
if (array_key_exists('dt-from', $filter)) {
$cond[] = 'r.ReportedTime >= ?';
$args[] = $filter['dt-from'];
}
if (array_key_exists('dt-until', $filter)) {
$rpt_cond[] = 'r.ReportedTime <= ? + INTERVAL 1 DAY';
$rpt_args[] = $filter['dt-until'];
}
if (array_key_exists('torrent', $filter)) {
$rpt_cond[] = 'r.TorrentID = ?';
$rpt_args[] = $filter['torrent'];
}
if (array_key_exists('uploader', $filter) && $filter['uploader']) {
$cond[] = 't.UserID = ?';
$args[] = self::username2id($db, $filter['uploader']);
}
if (array_key_exists('torrent', $filter)) {
$cond[] = 'r.TorrentID = ?';
$args[] = $filter['torrent'];
}
if (array_key_exists('group', $filter)) {
$cond[] = 't.GroupID = ?';
$args[] = $filter['group'];
}
if (count($cond) == 0) {
$cond = ['1 = 1'];
}
$conds = implode(' AND ', $cond);
/* The construct below is pretty sick: we alias the group_log table to t
* which means that t.GroupID in a condition refers to the same thing in
* the `torrents` table as well. I am not certain this is entirely sane.
*/
$sql_base = "
FROM reportsv2 r
LEFT JOIN torrents t ON (t.ID = r.TorrentID)
LEFT JOIN torrents_group g on (g.ID = t.GroupID)
LEFT JOIN (
SELECT max(t.ID) AS ID, t.TorrentID
FROM group_log t
INNER JOIN reportsv2 r using (TorrentID)
WHERE t.Info NOT LIKE 'uploaded%'
AND $conds
GROUP BY t.TorrentID
) LASTLOG USING (TorrentID)
LEFT JOIN group_log gl ON (gl.ID = LASTLOG.ID)
WHERE
$conds";
$sql = "SELECT count(*) $sql_base";
$db->prepared_query_array($sql, array_merge($args, $args));
list($total_results) = $db->next_record();
if (!$total_results) {
return [[], $total_results];
}
$sql = "
SELECT r.ID, r.ReporterID, r.ResolverID, r.TorrentID, t.UserID, t.GroupID, coalesce(g.Name, gl.Info) as Name, g.Year, r.Type, r.ReportedTime, g.SubName
$sql_base
ORDER BY r.ReportedTime DESC LIMIT ? OFFSET ?
";
$args = array_merge(
$args,
$args,
[
TORRENTS_PER_PAGE, // LIMIT
TORRENTS_PER_PAGE * (max($filter['page'], 1) - 1), // OFFSET
]
);
$db->prepared_query_array($sql, $args);
return [$db->to_array(), $total_results];
}
private static function username2id($db, $name) {
$db->prepared_query('SELECT ID FROM users_main WHERE Username = ?', $name);
$user = $db->next_record();
return $user['ID'];
}
}
+185
View File
@@ -0,0 +1,185 @@
<?php
namespace Gazelle;
class Request extends Base {
protected $id;
public function __construct(int $id) {
parent::__construct();
$this->id = $id;
}
/**
* Get the title of a request
* TODO: should tie this into the caching infrastructure
*
* @return string Title of request
*/
public function title() {
return $this->db->scalar(
"
SELECT Title FROM requests WHERE ID = ?
",
$this->id
);
}
/**
* Get the bounty of request, by user
*
* @return array keyed by user ID
*/
public function bounty() {
$this->db->prepared_query(
"
SELECT UserID, Bounty
FROM requests_votes
WHERE RequestID = ?
ORDER BY Bounty DESC, UserID DESC
",
$this->id
);
return $this->db->to_array('UserID', MYSQLI_ASSOC, false);
}
/**
* Get the total bounty that a user has added to a request
* @param int $userId ID of user
* @return int keyed by user ID
*/
public function userBounty(int $userId) {
return $this->db->scalar(
"
SELECT Bounty
FROM requests_votes
WHERE RequestID = ? AND UserID = ?
",
$this->id,
$userId
);
}
/**
* Refund the bounty of a user on a request
* @param int $userId ID of user
* @param int $staffName name of staff performing the operation
*/
public function refundBounty(int $userId, string $staffName) {
$bounty = $this->userBounty($userId);
$this->db->prepared_query(
"
DELETE FROM requests_votes
WHERE RequestID = ? AND UserID = ?
",
$this->id,
$userId
);
if ($this->db->affected_rows() == 1) {
$this->informRequestFillerReduction($bounty, $staffName);
$message = sprintf(
"%s Refund of %s bounty (%s b) on %s by %s\n\n",
sqltime(),
\Format::get_size($bounty),
$bounty,
SITE_URL . '/requests.php?action=view&id=' . $this->id,
$staffName
);
$this->db->prepared_query(
"
UPDATE users_info ui
INNER JOIN users_leech_stats uls USING (UserID)
SET
uls.Uploaded = uls.Uploaded + ?,
ui.AdminComment = concat(?, ui.AdminComment)
WHERE ui.UserId = ?
",
$bounty,
$message,
$userId
);
}
}
/**
* Remove the bounty of a user on a request
* @param int $userId ID of user
* @param int $staffName name of staff performing the operation
*/
public function removeBounty(int $userId, string $staffName) {
$bounty = $this->userBounty($userId);
$this->db->prepared_query(
"
DELETE FROM requests_votes
WHERE RequestID = ? AND UserID = ?
",
$this->id,
$userId
);
if ($this->db->affected_rows() == 1) {
$this->informRequestFillerReduction($bounty, $staffName);
$message = sprintf(
"%s Removal of %s bounty (%s b) on %s by %s\n\n",
sqltime(),
\Format::get_size($bounty),
$bounty,
SITE_URL . '/requests.php?action=view&id=' . $this->id,
$staffName
);
$this->db->prepared_query(
"
UPDATE users_info ui SET
ui.AdminComment = concat(?, ui.AdminComment)
WHERE ui.UserId = ?
",
$message,
$userId
);
}
}
/**
* Inform the filler of a request that their bounty was reduced
*
* @param int $bounty The amount of bounty reduction
* @param int $staffName name of staff performing the operation
*/
public function informRequestFillerReduction(int $bounty, string $staffName) {
list($fillerId, $fillDate) = $this->db->row(
"
SELECT FillerID, date(TimeFilled)
FROM requests
WHERE TimeFilled IS NOT NULL AND ID = ?
",
$this->id
);
if (!$fillerId) {
return;
}
$requestUrl = SITE_URL . '/requests.php?action=view&id=' . $this->id;
$message = sprintf(
"%s Reduction of %s bounty (%s b) on filled request %s by %s\n\n",
sqltime(),
\Format::get_size($bounty),
$bounty,
$requestUrl,
$staffName
);
$this->db->prepared_query(
"
UPDATE users_info ui
INNER JOIN users_leech_stats uls USING (UserID)
SET
uls.Uploaded = uls.Uploaded - ?,
ui.AdminComment = concat(?, ui.AdminComment)
WHERE ui.UserId = ?
",
$bounty,
$message,
$fillerId
);
$this->cache->delete_value("user_stats_$fillerId");
// TODO: make it easy to use Twig here
\Misc::send_pm_with_tpl($fillerId, 'bounty_reduced', ['Bounty' => \Format::get_size($bounty), 'FillDate' => $fillDate, 'RequestURL' => $requestUrl, 'StaffUserName' => $staffName]);
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
namespace Gazelle;
use Gazelle\Exception\InvalidAccessException;
use Gazelle\Exception\RouterException;
/**
* Router class to be used by Gazelle to serve up the necessary file on
* a specified route. Current usage would be that the Router is initialized
* within script_start.php and then all sections/<*>/index.php files would
* set their specific section routes on that global Router object. script_start.php
* would then include that index.php for a given section to populate the router,
* and then call the route from within script_start.php.
*
* By default, we assume that any POST requests will require authorization and any
* GET request will not.
*/
class Router {
private $authorize = ['GET' => false, 'POST' => true];
private $routes = ['GET' => [], 'POST' => []];
private $auth_key = null;
/**
* Router constructor.
* @param string $auth_key Authorization key for a user
*/
public function __construct($auth_key = '') {
$this->auth_key = $auth_key;
}
/**
* @param string|array $methods
* @param string $action
* @param string $path
* @param bool $authorize
*/
public function addRoute($methods = ['GET'], string $action, string $path, bool $authorize = false) {
if (is_array($methods)) {
foreach ($methods as $method) {
$this->addRoute($method, $action, $path, $authorize);
}
} else {
if (strtoupper($methods) === 'GET') {
$this->addGet($action, $path, $authorize);
} elseif (strtoupper($methods) === 'POST') {
$this->addPost($action, $path, $authorize);
}
}
}
public function addGet(string $action, string $file, bool $authorize = false) {
$this->routes['GET'][$action] = ['file' => $file, 'authorize' => $authorize];
}
public function addPost(string $action, string $file, bool $authorize = true) {
$this->routes['POST'][$action] = ['file' => $file, 'authorize' => $authorize];
}
public function authorizeGet(bool $authorize = true) {
$this->authorize['GET'] = $authorize;
}
public function authorizePost(bool $authorize = true) {
$this->authorize['POST'] = $authorize;
}
public function authorized() {
return !empty($_REQUEST['auth']) && $_REQUEST['auth'] === $this->auth_key;
}
public function hasRoutes() {
return array_sum(array_map("count", $this->routes)) > 0;
}
/**
* @param string $action
* @return string path to file to load
* @throws RouterException
*/
public function getRoute(string $action) {
$request_method = strtoupper(empty($_SERVER['REQUEST_METHOD']) ? 'GET' : $_SERVER['REQUEST_METHOD']);
if (isset($this->routes[$request_method]) && isset($this->routes[$request_method][$action])) {
$method = $this->routes[$request_method][$action];
} else {
throw new RouterException("Invalid action for '${request_method}' request method");
}
if (($this->authorize[$request_method] || $method['authorize']) && !$this->authorized()) {
throw new InvalidAccessException();
} else {
return $method['file'];
}
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
namespace Gazelle;
use \Gazelle\Util\Crypto;
class Session extends Base {
private $id;
public function __construct($id) {
parent::__construct();
$this->id = $id;
}
public function sessions(): array {
if (($sessions = $this->cache->get_value('users_sessions_' . $this->id)) === false) {
$this->db->prepared_query(
"
SELECT
SessionID,
Browser,
OperatingSystem,
IP,
LastUpdate
FROM users_sessions
WHERE Active = 1
AND UserID = ?
ORDER BY LastUpdate DESC
",
$this->id
);
$sessions = $this->db->to_array('SessionID', MYSQLI_ASSOC);
$this->cache->cache_value('users_sessions_' . $this->id, $sessions, 43200);
}
return $sessions ?: [];
}
public function create(array $info): array {
$sessionId = randomString();
$this->db->prepared_query(
'
INSERT INTO users_sessions
(UserID, SessionID, KeepLogged, Browser, OperatingSystem, IP, FullUA, LastUpdate)
VALUES (?, ?, ?, ?, ?, ?, ?, now())
',
$this->id,
$sessionId,
$info['keep-logged'],
$info['browser'],
$info['os'],
$info['ipaddr'],
$info['useragent']
);
$this->db->prepared_query(
'
INSERT INTO user_last_access
(user_id, last_access)
VALUES (?, now())
ON DUPLICATE KEY UPDATE last_access = now()
',
$this->id
);
$this->cache->delete_value("users_sessions_" . $this->id);
$sessions = $this->sessions();
return $sessions[$sessionId];
}
public function cookie(string $sessionId): string {
return Crypto::encrypt(Crypto::encrypt($sessionId . '|~|' . $this->id, ENCKEY), ENCKEY);
}
public function update(array $info): array {
$this->db->prepared_query(
"
UPDATE user_last_access SET
last_access = now()
WHERE user_id = ?
",
$this->id
);
$this->db->prepared_query(
"
UPDATE users_sessions SET
LastUpdate = now(),
IP = ?,
Browser = ?,
BrowserVersion = ?,
OperatingSystem = ?,
OperatingSystemVersion = ?
WHERE UserID = ? AND SessionID = ?
",
$info['ip-address'],
$info['browser'],
$info['browser-version'],
$info['os'],
$info['os-version'],
/* where */
$this->id,
$info['session-id']
);
$this->cache->delete_value('users_sessions_' . $this->id);
return $this->sessions();
}
public function drop(string $sessionId): int {
$this->db->prepared_query(
'
DELETE FROM users_sessions
WHERE UserID = ? AND SessionID = ?
',
$this->id,
$sessionId
);
$this->cache->deleteMulti([
'session_' . $sessionId,
'users_sessions_' . $this->id,
'user_info_' . $this->id,
'user_info_heavy_' . $this->id,
'user_stats_' . $this->id,
'enabled_' . $this->id,
]);
return $this->db->affected_rows();
}
public function purgeDead(): int {
$this->db->prepared_query("
SELECT concat('users_sessions_', UserID) as ck
FROM users_sessions
WHERE (LastUpdate < (now() - INTERVAL 30 DAY) AND KeepLogged = '1')
OR (LastUpdate < (now() - INTERVAL 60 MINUTE) AND KeepLogged = '0')
");
if (!$this->db->has_results()) {
return 0;
}
$cacheKeys = $this->db->collect('ck', false);
$this->db->prepared_query("
DELETE FROM users_sessions
WHERE (LastUpdate < (now() - INTERVAL 30 DAY) AND KeepLogged = '1')
OR (LastUpdate < (now() - INTERVAL 60 MINUTE) AND KeepLogged = '0')
");
$this->cache->deleteMulti($cacheKeys);
return count($cacheKeys);
}
public function dropAll(): int {
$this->db->prepared_query(
"
SELECT concat('session_', SessionID) AS ck
FROM users_sessions
WHERE UserID = ?
",
$this->id
);
$this->cache->deleteMulti(array_merge(
$this->db->collect('ck'),
[
'users_sessions_' . $this->id,
'user_info_' . $this->id,
'user_info_heavy_' . $this->id,
'user_stats_' . $this->id,
'enabled_' . $this->id,
]
));
$this->db->prepared_query(
'
DELETE FROM users_sessions WHERE UserID = ?
',
$this->id
);
return $this->db->affected_rows();
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
namespace Gazelle;
use Gazelle\Util\Time;
class SiteInfo extends Base {
public function gitBranch() {
return trim(shell_exec('git rev-parse --abbrev-ref HEAD'));
}
public function gitHash() {
return trim(shell_exec('git rev-parse HEAD'));
}
public function gitHashRemote() {
return trim(shell_exec("git rev-parse origin/" . $this->gitBranch()));
}
public function phpinfo() {
ob_start();
phpinfo();
$p = ob_get_contents();
ob_end_clean();
return substr($p, strpos($p, '<body>') + 6, strpos($p, '</body>'));
}
public function uptime() {
$in = fopen('/proc/uptime', 'r');
list($uptime, $idletime) = explode(' ', trim(fgets($in)));
fclose($in);
$in = fopen('/proc/cpuinfo', 'r');
$ncpu = 0;
while (($line = fgets($in)) !== false) {
if (preg_match('/^processor\s+:\s+\d+/', $line)) {
++$ncpu;
}
}
fclose($in);
$now = time();
$idletime = str_replace(
' ago',
'',
Time::timeDiff($now - (int)($idletime / $ncpu))
);
return [
'uptime' => Time::timeDiff($now - (int)$uptime),
'idletime' => $idletime,
];
}
public function phinx() {
$phinxBinary = realpath(__DIR__ . '/../vendor/bin/phinx');
$phinxScript = realpath(__DIR__ . '/../phinx.php');
return [
'version' => explode(' ', shell_exec("$phinxBinary --version"))[5],
'migration' => array_filter(
json_decode(
shell_exec("$phinxBinary status -c $phinxScript --format=json|tail -n 1"),
true
)['migrations'],
function ($v) {
return count($v) > 0;
}
)
];
}
public function composerVersion() {
return trim(shell_exec('composer --version 2>/dev/null'));
}
public function composerPackages() {
$packages = [];
$root = realpath(__DIR__ . '/../');
$composer = json_decode(file_get_contents(realpath("$root/composer.json")), true);
foreach ($composer['require'] as $name => $version) {
if ($name != 'php') {
$packages[$name] = [
'name' => $name,
'require' => $version,
'installed' => null,
];
}
}
$lock = json_decode(file_get_contents(realpath("$root/composer.lock")), true);
foreach ($lock['packages'] as $p) {
if (isset($packages[$p['name']])) {
if ($p['name'] == $packages[$p['name']]['name']) {
$packages[$p['name']]['installed'] = $p['version'];
}
}
}
$info = json_decode(shell_exec("composer info -d $root --format=json 2>/dev/null"), true);
foreach ($info['installed'] as $name => $p) {
if (!isset($packages[$p['name']])) {
$packages[$p['name']] = [
'name' => $p['name'],
'installed' => $p['version'],
'require' => '-',
];
}
}
ksort($packages);
return $packages;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace Gazelle;
use Permissions;
use Users;
class Staff extends Base {
/** @var array */
protected $user;
public function __construct(array $user) {
parent::__construct();
$this->user = $user;
}
public function id() {
return $this->user['ID'];
}
public function blogAlert() {
if (($readTime = $this->cache->get_value('staff_blog_read_' . $this->user['ID'])) === false) {
$readTime = $this->db->scalar(
'
SELECT unix_timestamp(Time)
FROM staff_blog_visits
WHERE UserID = ?
',
$this->user['ID']
) ?? 0;
$this->cache->cache_value('staff_blog_read_' . $this->user['ID'], $readTime, 1209600);
}
if (($blogTime = $this->cache->get_value('staff_blog_latest_time')) === false) {
$blogTime = $this->db->scalar(
'
SELECT unix_timestamp(max(Time))
FROM staff_blog
'
) ?? 0;
$this->cache->cache_value('staff_blog_latest_time', $blogTime, 1209600);
}
return $readTime < $blogTime;
}
public function pmCount() {
$conditions = [
"Status = 'Unanswered'",
'(AssignedToUser = ? OR LEAST(?, Level) <= ?)',
];
$params = [$this->user['ID'], Permissions::get_level_cap(), $this->user['EffectiveClass']];
[$classes] = Users::get_classes();
if ($this->user['EffectiveClass'] >= $classes[MOD]['Level']) {
$conditions[] = 'Level >= ?';
$params[] = $classes[MOD]['Level'];
} elseif ($this->user['EffectiveClass'] === $classes[FORUM_MOD]['Level']) {
$conditions[] = 'Level >= ?';
$params[] = $classes[FORUM_MOD]['Level'];
}
return $this->db->scalar("
SELECT count(*)
FROM staff_pm_conversations
WHERE " . implode(' AND ', $conditions), ...$params);
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Gazelle\Stats;
class Collage extends \Gazelle\Base {
public function collageCount(): int {
if (($count = $this->cache->get_value('stats_collages')) === false) {
$count = $this->db->scalar("
SELECT count(*) FROM collages WHERE Deleted = '0'
");
$this->cache->cache_value('stats_collages', $count, 43200 + rand(0, 300));
}
return $count;
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace Gazelle\Stats;
class Economic extends \Gazelle\Base {
protected $stats;
const CACHE_KEY = 'stats_economic';
public function get($key) {
return $this->stats[$key] ?? null;
}
public function dump() {
foreach ($this->stats as $k => $v) {
printf("%s\t%s\n", $k, $v);
}
}
public function __construct() {
parent::__construct();
if (($this->stats = $this->cache->get_value(self::CACHE_KEY)) === false) {
list(
$this->stats['totalUpload'],
$this->stats['totalDownload'],
$this->stats['totalEnabled'],
) = $this->db->row(
'
SELECT sum(uls.Uploaded), sum(uls.Downloaded), count(*)
FROM users_main um
INNER JOIN users_leech_stats AS uls ON (uls.UserID = um.ID)
WHERE um.Enabled = ?
',
'1'
);
$this->stats['totalBounty'] = $this->db->scalar('
SELECT SUM(Bounty)
FROM requests_votes
');
$this->stats['availableBounty'] = $this->db->scalar('
SELECT SUM(rv.Bounty)
FROM requests_votes AS rv
INNER JOIN requests AS r ON (r.ID = rv.RequestID)
');
list(
$this->stats['totalLiveSnatches'],
$this->stats['totalTorrents'],
) = $this->db->row('
SELECT sum(tls.Snatched), count(*)
FROM torrents_leech_stats tls
');
$this->stats['totalOverallSnatches'] = $this->db->scalar('
SELECT count(*)
FROM xbt_snatched
');
list(
$this->stats['totalSeeders'],
$this->stats['totalLeechers'],
$this->stats['totalPeers'],
) = $this->db->row('
SELECT
coalesce(sum(remaining = 0), 0) as seeders,
coalesce(sum(remaining > 0), 0) as leechers,
count(*)
FROM xbt_files_users
');
$this->stats['totalPeerUsers'] = $this->db->scalar('
SELECT count(distinct uid)
FROM xbt_files_users xfu
WHERE remaining = 0
AND active = 1
');
$this->cache->cache_value(self::CACHE_KEY, $this->stats, 3600);
}
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Gazelle;
class Stylesheet extends Base {
private $stylesheets;
public function __construct() {
parent::__construct();
if (($this->stylesheets = $this->cache->get_value('stylesheets')) === false) {
$this->db->prepared_query("
SELECT
ID,
lower(replace(Name, ' ', '_')) AS Name,
Name AS ProperName
FROM stylesheets
ORDER BY ID ASC
");
$this->stylesheets = $this->db->to_array('ID', MYSQLI_BOTH);
$this->cache->cache_value('stylesheets', $this->stylesheets, 86400 * 7);
}
}
public function list() {
return $this->stylesheets;
}
public function getName($id) {
return $this->stylesheets[$id]['Name'];
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace Gazelle;
/* Conversation thread, allows protected staff notes intermingled
* with the public notes that both staff and member can see.
* A collection of notes is called a story.
*/
class Thread extends Base {
protected $id; // the ID of the row in the thread table
protected $type; // the type of thread
protected $created; // date created
protected $story; // the array of notes in the conversation
/**
*/
public function __construct(int $id) {
parent::__construct();
$this->id = $id;
$key = "thread_$id";
$data = $this->cache->get_value($key);
if ($data === false) {
$data = $this->db->row("
SELECT tt.Name as ThreadType, t.Created
FROM thread t
INNER JOIN thread_type tt ON (tt.ID = t.ThreadTypeID)
WHERE t.ID = ?
", $id);
if (!$data) {
throw new Exception\ResourceNotFoundException($id);
}
$data = $this->db->next_record();
$this->cache->cache_value($key, $data, 86400);
}
[$this->type, $this->created] = $data;
return $this->refresh(); /* load the story */
}
/**
* @return int The id of a Thread
*/
public function id() {
return $this->id;
}
/**
* Get the array of notes of the thread. A note is a hash with the following items:
* id - the id in thread_note table
* user_id - the id of the author in the users_main table
* user_name - the name of the member (normally we don't need anything else from there).
* visibility - this note is 'public' or just visibible to 'staff'
* created - when was this note created
* body - the note text itself
* @return array The list of notes in a thread ordered by most recent first.
*/
public function story() {
return $this->story;
}
/**
* Persist a note to the db.
* @param int $userId The note author
* @param string $body The note text
* @param int $visibility 'public' or 'staff'
*/
public function saveNote($userId, $body, $visibility) {
$this->db->prepared_query(
"
INSERT INTO thread_note
(ThreadID, UserID, Body, Visibility)
VALUES (?, ?, ?, ?)
",
$this->id,
$userId,
$body,
$visibility
);
return $this->refresh();
}
/**
* Persist a change to the note
* @param int $id The id to identify a note
* @param string $body The note text
* @param int $visibility 'public' or 'staff'
*/
public function modifyNote($id, $body, $visibility) {
$this->db->prepared_query(
"
UPDATE thread_note SET
Body = ?,
Visibility = ?
WHERE ID = ?
",
$body,
$visibility,
$id
);
return $this->refresh();
}
/**
* Delete a note.
* @param int $note_id The id to identify a note
*/
public function removeNote($note_id) {
$this->db->prepared_query(
"
DELETE FROM thread_note
WHERE ThreadID = ? AND ID = ?
",
$this->id(),
$note_id
);
return $this->refresh();
}
/**
* Refresh the story cache when a note is added, changed, deleted.
*/
protected function refresh() {
$key = "thread_story_" . $this->id;
$this->db->prepared_query("
SELECT ID, UserID, Visibility, Created, Body
FROM thread_note
WHERE ThreadID = ?
ORDER BY created;
", $this->id);
$this->story = [];
if ($this->db->has_results()) {
$user_cache = [];
while (($row = $this->db->next_record())) {
if (!in_array($row['UserID'], $user_cache)) {
$user = \Users::user_info($row['UserID']);
$user_cache[$row['UserID']] = $user['Username'];
}
$this->story[] = [
'id' => $row['ID'],
'user_id' => $row['UserID'],
'user_name' => $user_cache[$row['UserID']],
'visibility' => $row['Visibility'],
'created' => $row['Created'],
'body' => $row['Body']
];
}
}
$this->cache->cache_value($key, $this->story, 3600);
return $this;
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace Gazelle;
class Top10 {
public static function renderLinkbox(string $selected) {
?>
<div class="BodyNavLinks">
<a href="top10.php?type=torrents" class="brackets"><?= self::selectedLink("Torrents", $selected == "torrents") ?></a>
<a href="top10.php?type=users" class="brackets"><?= self::selectedLink("Users", $selected == "users") ?></a>
<a href="top10.php?type=tags" class="brackets"><?= self::selectedLink("Tags", $selected == "tags") ?></a>
<?
if (ENABLE_VOTES) {
?>
<a href="top10.php?type=votes" class="brackets"><?= self::selectedLink("Favorites", $selected == "votes") ?></a>
<? } ?>
<a href="top10.php?type=donors" class="brackets"><?= self::selectedLink("Donors", $selected == "donors") ?></a>
</div>
<?php
}
private static function selectedLink($string, $selected) {
if ($selected) {
return "<strong>$string</strong>";
} else {
return $string;
}
}
public static function renderArtistTile($artist, $category) {
if (self::isValidArtist($artist)) {
switch ($category) {
case 'weekly':
case 'hyped':
self::renderTile("artist.php?artistname=", $artist['name'], $artist['image'][3]['#text']);
break;
default:
break;
}
}
}
private static function renderTile($url, $name, $image) {
if (!empty($image)) {
$name = display_str($name);
?>
<li>
<a href="<?= $url ?><?= $name ?>">
<img class="large_tile" alt="<?= $name ?>" data-tooltip="<?= $name ?>" src="<?= \ImageTools::process($image) ?>" />
</a>
</li>
<?php
}
}
public static function renderArtistList($artist, $category) {
if (self::isValidArtist($artist)) {
switch ($category) {
case 'weekly':
case 'hyped':
self::renderList("artist.php?artistname=", $artist['name'], $artist['image'][3]['#text']);
break;
default:
break;
}
}
}
private static function renderList($url, $name, $image) {
if (!empty($image)) {
$image = \ImageTools::process($image);
$tooltip = "data-tooltip-image=\"&lt;img class=&quot;large_tile&quot; src=&quot;$image&quot; alt=&quot;&quot; /&rsaquo;\"";
$name = display_str($name);
?>
<li>
<a data-title-plain="<?= $name ?>" <?= $tooltip ?> href="<?= $url ?><?= $name ?>"><?= $name ?></a>
</li>
<?php
}
}
private static function isValidArtist($artist) {
return $artist['name'] != '[unknown]';
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Gazelle\Top10;
class Donor extends \Gazelle\Base {
// TODO: move this method to Manager\Donation and kill this class
public function getTopDonors($limit) {
return $this->db->prepared_query(
'
SELECT UserID, TotalRank, Rank, SpecialRank, DonationTime, Hidden
FROM users_donor_ranks
WHERE TotalRank > 0
ORDER BY TotalRank DESC
LIMIT ?',
$limit
);
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace Gazelle\Top10;
class Tag extends \Gazelle\Base {
public function getTopUsedTags($limit) {
if (!$topUsedTags = $this->cache->get_value('topusedtag_' . $limit)) {
$topUsedTags = $this->db->prepared_query("
SELECT
t.ID,
t.Name,
COUNT(tt.GroupID) AS Uses,
SUM(tt.PositiveVotes - 1) AS PositiveVotes,
SUM(tt.NegativeVotes - 1) AS NegativeVotes
FROM tags AS t
JOIN torrents_tags AS tt ON (tt.TagID = t.ID)
GROUP BY tt.TagID
ORDER BY Uses DESC, t.name
LIMIT ?", $limit);
$topUsedTags = $this->db->to_array();
$this->cache->cache_value('topusedtag_' . $limit, $topUsedTags, 3600 * 12);
}
return $topUsedTags;
}
public function getTopRequestTags($limit) {
if (!$topRequestTags = $this->cache->get_value('toprequesttag_' . $limit)) {
$this->db->prepared_query("
SELECT
t.ID,
t.Name,
COUNT(r.RequestID) AS Uses,
'',''
FROM tags AS t
JOIN requests_tags AS r ON (r.TagID = t.ID)
GROUP BY r.TagID
ORDER BY Uses DESC, t.name
LIMIT ?", $limit);
$topRequestTags = $this->db->to_array();
$this->cache->cache_value('toprequesttag_' . $limit, $topRequestTags, 3600 * 12);
}
return $topRequestTags;
}
public function getTopVotedTags($limit) {
if (!$topVotedTags = $this->cache->get_value('topvotedtag_' . $limit)) {
$topVotedTags = $this->db->prepared_query("
SELECT
t.ID,
t.Name,
COUNT(tt.GroupID) AS Uses,
SUM(tt.PositiveVotes - 1) AS PositiveVotes,
SUM(tt.NegativeVotes - 1) AS NegativeVotes
FROM tags AS t
JOIN torrents_tags AS tt ON (tt.TagID = t.ID)
GROUP BY tt.TagID
ORDER BY PositiveVotes DESC, t.name
LIMIT ?", $limit);
$topVotedTags = $this->db->to_array();
$this->cache->cache_value('topvotedtag_' . $limit, $topVotedTags, 3600 * 12);
}
return $topVotedTags;
}
}
+299
View File
@@ -0,0 +1,299 @@
<?php
namespace Gazelle\Top10;
class Torrent extends \Gazelle\Base {
/** @var Array */
private $formats;
private $currentUser;
public function __construct(array $formats, $currentUser) {
parent::__construct();
$this->formats = $formats;
$this->currentUser = $currentUser;
}
public function getTopTorrents($getParameters, $details = 'all', $limit = 10) {
$cacheKey = 'top10_v2_' . $details . '_' . md5(implode($getParameters, '')) . '_' . $limit;
$topTorrents = $this->cache->get_value($cacheKey);
if ($topTorrents !== false) {
return $topTorrents;
}
if (!$this->cache->get_query_lock($cacheKey)) {
return false;
}
$where = [];
$anyTags = isset($getParameters['anyall']) && $getParameters['anyall'] == 'any';
if (isset($getParameters['format'])) {
$where[] = $this->formatWhere($getParameters['format']);
}
if (isset($getParameters['tags'])) {
$where[] = $this->tagWhere($getParameters['tags'], $anyTags);
}
$where[] = $this->freeleechWhere($getParameters);
$where[] = $this->detailsWhere($details);
$where[] = ["parameters" => null, "where" => "tls.Seeders > 0"];
$whereFilter = function ($value) {
return $value["where"] ?? null;
};
$parameterFilter = function ($value) {
return $value["parameters"] ?? null;
};
$filteredWhere = array_filter(array_map($whereFilter, $where));
$parameters = $this->flatten(array_filter(array_map($parameterFilter, $where)));
$innerQuery = '';
$joinParameters = [];
if (!empty($getParameters['excluded_artists'])) {
[$clause, $artists] = $this->excludedArtistClause($getParameters['excluded_artists']);
$innerQuery .= $clause;
$joinParameters[] = $artists;
$filteredWhere[] = "ta.ArtistCount IS NULL";
}
if (count($joinParameters)) {
$joinParameters = $this->flatten($joinParameters);
$parameters = array_merge($joinParameters, $parameters);
}
$innerQuery .= " WHERE " . implode(" AND ", $filteredWhere);
$innerQuery = $innerQuery . (isset($getParameters['groups']) && $getParameters['groups'] == 'show' ? ' GROUP BY g.ID ' : '');
$orderBy = 'ORDER BY ' . $this->orderBy($details) . ' DESC';
$query = sprintf(
$this->baseQuery,
$innerQuery,
($getParameters['groups'] ?? 'hide') == 'show' ? 'g.ID' : 't.ID, g.ID',
$this->orderBy($details) . ' DESC',
$limit
);
$this->db->prepared_query($query, ...$parameters);
$topTorrents = $this->db->to_array();
$this->cache->cache_value($cacheKey, $topTorrents, 3600 * 6);
$this->cache->clear_query_lock($cacheKey);
return $topTorrents;
}
public function showFreeleechTorrents($freeleechParameters) {
if (isset($freeleechParameters)) {
return $freeleechParameters == 'hide' ? 1 : 0;
} else if (isset($this->currentUser['DisableFreeTorrentTop10'])) {
return $this->currentUser['DisableFreeTorrentTop10'];
} else {
return 0;
}
}
private function orderBy($details) {
switch ($details) {
case 'snatched':
return 'tls.Snatched';
break;
case 'seeded':
return 'tls.Seeders';
break;
case 'data':
return 'Data';
break;
default:
return '(tls.Seeders + tls.Leechers)';
break;
}
}
private function detailsWhere($detailsParameters) {
switch ($detailsParameters) {
case 'day':
return ["parameters" => null, "where" => "t.Time > now() - INTERVAL 1 DAY"];
break;
case 'week':
return ["parameters" => null, "where" => "t.Time > now() - INTERVAL 1 WEEK"];
break;
case 'month':
return ["parameters" => null, "where" => "t.Time > now() - INTERVAL 1 MONTH"];
break;
case 'year':
return ["parameters" => null, "where" => "t.Time > now() - INTERVAL 1 YEAR"];
break;
default:
return [];
break;
}
}
private function excludedArtistClause($artistParameter) {
if (!empty($artistParameter)) {
$artists = preg_split('/\r\n|\r|\n/', trim($artistParameter));
$artistPrepare = function ($artist) {
return trim($artist);
};
$artists = array_map($artistPrepare, $artists);
$sql = "
LEFT JOIN (
SELECT COUNT(*) AS ArtistCount, ta.GroupID
FROM torrents_artists AS ta
INNER JOIN artists_alias AS aa ON (ta.AliasID = aa.AliasID)
WHERE ta.Importance != '2' AND aa.Name IN (" . placeholders($artists) . ")
GROUP BY ta.GroupID
) AS ta ON (g.ID = ta.GroupID)";
return [$sql, $artists];
}
return ['', []];
}
private function formatWhere($formatParameters) {
if (in_array($formatParameters, $this->formats)) {
return ["parameters" => $formatParameters, "where" => "t.Format = ?"];
}
}
private function freeleechWhere($getParameters) {
$disableFreeTorrentTop10 = isset($this->currentUser['DisableFreeTorrentTop10']) ? $this->currentUser['DisableFreeTorrentTop10'] : 0;
if (isset($getParameters['freeleech'])) {
$disableFreeTorrentTop10 = ($getParameters['freeleech'] == 'hide' ? 1 : 0);
}
if ($disableFreeTorrentTop10) {
return ["parameters" => null, "where" => "t.FreeTorrent = '0'"];
}
return [];
}
private function tagWhere($getParameters, $any = false) {
if (!empty($getParameters)) {
$tags = explode(',', trim($getParameters));
$replace = function ($tag) {
return preg_replace('/[^a-z0-9.]/', '', $tag);
};
$tags = array_map($replace, $tags);
$tags = array_filter($tags);
// This is to make the prepared query work.
$where = implode(array_fill(0, count($tags), "t.Name = ?"), ' OR ');
$clause = "
g.ID IN (
SELECT tt.GroupID
FROM torrents_tags tt
INNER JOIN tags t ON (t.ID = tt.TagID)
WHERE $where
GROUP BY tt.GroupID
HAVING count(*) >= ?
)";
$tags[] = $any ? 1 : count($tags);
return ['parameters' => $tags, 'where' => $clause];
}
return [];
}
private function flatten(array $array) {
$return = [];
array_walk_recursive($array, function ($a) use (&$return) {
$return[] = $a;
});
return $return;
}
public function storeTop10($type, $key, $days) {
$this->db->prepared_query(
"
INSERT INTO top10_history (Type) VALUES (?)
",
$type
);
$historyID = $this->db->inserted_id();
$top10 = $this->cache->get_value('top10tor_v2_' . $key . '_10');
if ($top10 === false) {
$query = sprintf(
$this->baseQuery,
'WHERE tls.Seeders > 0 AND t.Time > (now() - INTERVAL ? DAY)',
'(tls.Seeders + tls.Leechers) DESC',
't.ID, g.ID',
'10'
);
$this->db->prepared_query($query, $days);
$top10 = $this->db->to_array(MYSQLI_NUM);
}
$groupIds = array_column($top10, 1);
// exclude artists because it's retarded
$groups = \Torrents::get_groups($groupIds, true, false);
$artists = \Artists::get_artists($groupIds);
global $Debug;
foreach ($top10 as $i => $torrent) {
[$torrentID, $groupID, $data] = $torrent;
$group = $groups[$groupID];
$displayName = '';
if (!empty($artists[$groupID])) {
$displayName = \Artists::display_artists($artists[$groupID], false, true);
}
$displayName .= $group['Name'];
if ($group['CategoryID'] == 1 && $group['Year'] > 0) {
$displayName .= " [${group['Year']}]";
}
$torrentDetails = $group['Torrents'][$torrentID];
unset($torrentDetails['IsSnatched']);
unset($torrentDetails['FreeTorrent']);
unset($torrentDetails['PersonalFL']);
$extraInfo = \Torrents::torrent_info($torrentDetails, true);
if ($extraInfo != '') {
$extraInfo = "- [$extraInfo]";
}
$titleString = "$displayName $extraInfo";
$Debug->log_var($group, 'group');
$this->db->prepared_query(
'
INSERT INTO top10_history_torrents
(HistoryID, Rank, TorrentID, TitleString, TagString)
VALUES
(?, ?, ?, ?, ?)
',
$historyID,
$i,
$torrentID,
$titleString,
$group['TagList']
);
}
}
private $baseQuery = "
SELECT
t.ID,
g.ID,
((t.Size * tls.Snatched) + (t.Size * 0.5 * tls.Leechers)) AS Data
FROM torrents AS t
INNER JOIN torrents_leech_stats tls ON (tls.TorrentID = t.ID)
INNER JOIN torrents_group AS g ON (g.ID = t.GroupID)
%s
GROUP BY %s
ORDER BY %s
LIMIT %s";
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace Gazelle\Top10;
class User extends \Gazelle\Base {
public const UPLOADERS = 'uploaders';
public const DOWNLOADERS = 'downloaders';
public const UPLOADS = 'uploads';
public const REQUEST_VOTES = 'request_votes';
public const REQUEST_FILLS = 'request_fills';
public const UPLOAD_SPEED = 'upload_speed';
public const DOWNLOAD_SPEED = 'download_speed';
private const CACHE_KEY = 'topusers_%s_%d';
private $sortMap = [
self::UPLOADERS => 'uploaded',
self::DOWNLOADERS => 'downloaded',
self::UPLOADS => 'num_uploads',
self::REQUEST_VOTES => 'request_votes',
self::REQUEST_FILLS => 'request_fills',
self::UPLOAD_SPEED => 'up_speed',
self::DOWNLOAD_SPEED => 'down_speed',
];
public function fetch(string $type, int $limit) {
if (!array_key_exists($type, $this->sortMap)) {
return [];
}
if (!$results = $this->cache->get_value(sprintf(self::CACHE_KEY, $type, $limit))) {
$orderBy = $this->sortMap[$type];
$this->db->prepared_query(
sprintf(
"
SELECT
um.ID AS id,
ui.JoinDate AS join_date,
uls.Uploaded AS uploaded,
uls.Downloaded AS downloaded,
coalesce(bs.Bounty, 0) AS request_votes,
coalesce(bf.Fills, 0) AS request_fills,
abs(uls.Uploaded - ?) / (unix_timestamp() - unix_timestamp(ui.JoinDate)) AS up_speed,
uls.Downloaded / (unix_timestamp() - unix_timestamp(ui.JoinDate)) AS down_speed,
count(t.ID) AS num_uploads
FROM users_main AS um
INNER JOIN users_info AS ui ON (ui.UserID = um.ID)
INNER JOIN users_leech_stats AS uls ON (uls.UserID = um.ID)
LEFT JOIN torrents AS t ON (t.UserID = um.ID)
LEFT JOIN
(
SELECT UserID, sum(Bounty) AS Bounty
FROM requests_votes
GROUP BY UserID
) AS bs ON (bs.UserID = um.ID)
LEFT JOIN
(
SELECT FillerID, count(*) AS Fills
FROM requests
GROUP BY FillerID
) AS bf ON (bf.FillerID = um.ID)
WHERE um.Enabled = '1'
AND uls.Uploaded > ?
AND uls.Downloaded > ?
AND (um.Paranoia IS NULL OR (um.Paranoia NOT LIKE '%%\"uploaded\"%%' AND um.Paranoia NOT LIKE '%%\"downloaded\"%%'))
GROUP BY um.ID
ORDER BY %s DESC
LIMIT ?",
$orderBy
),
STARTING_UPLOAD,
5 * 1024 * 1024 * 1024,
5 * 1024 * 1024 * 1024,
$limit
);
$results = $this->db->to_array();
$this->cache->cache_value(sprintf(self::CACHE_KEY, $type, $limit), $results, 3600 * 12);
}
return $results;
}
}
+56
View File
@@ -0,0 +1,56 @@
<?
namespace Gazelle\Torrent;
use Lang;
class EditionType {
const Edition = 1;
const Feature = 2;
const Collection = 3;
const Remaster = 4;
const ThreeD = 5;
}
class EditionInfo {
const collections = ['masters_of_cinema', 'the_criterion_collection', 'warner_archive_collection'];
const editions = ['director_s_cut', 'extended_edition', 'rifftrax', 'theatrical_cut', 'uncut', 'unrated'];
const features = ['2_disc_set', '2_in_1', '2d_3d_edition', '3d_anaglyph', '3d_full_sbs', '3d_half_ou', '3d_half_sbs', '4k_restoration', '4k_remaster', 'remaster', '10_bit', 'dts_x', 'dolby_atmos', 'dolby_vision', 'dual_audio', 'english_dub', 'extras', 'hdr10', 'hdr10plus', 'with_commentary'];
const remasters = ['remaster', '4k_remaster', '4k_restoration', 'warner_archive_collection', 'masters_of_cinema', 'the_criterion_collection'];
const threeD = ['3d_anaglyph', '3d_full_sbs', '3d_half_ou', '3d_half_sbs'];
public static function allEditionKey($type = null): ?array {
switch ($type) {
case EditionType::Collection:
return self::collections;
case EditionType::Edition:
return self::editions;
case EditionType::Feature:
return self::features;
case EditionType::Remaster:
return self::remasters;
case EditionType::ThreeD:
return self::threeD;
}
return array_merge(self::collections, self::editions, self::features);
}
public static function text(string $key): ?string {
return Lang::get('editioninfo', $key);
}
public static function key(string $text): ?string {
// TODO by qwerty 硬编码中英双语
$L = ['en', 'chs'];
foreach ($L as $k => $v) {
$key = Lang::get_key('editioninfo', $text, $v);
if (!empty($key)) {
return $key;
}
}
// TODO by qwerty fix
return "invalid text";
}
}
+606
View File
@@ -0,0 +1,606 @@
<?php
namespace Gazelle\Torrent;
use Torrents;
use Lang;
class TorrentSlotGroupStatus {
const Full = 1; // 满
const Free = 2; // 有空闲
const Empty = 3; // 空
}
class TorrentSlotResolution {
const None = 0;
const SD = 1;
const HD720P = 2;
const HD1080P = 3;
const UHD = 4;
}
class TorrentSlotType {
const None = 0;
const Quality = 1;
const NTSCUntouched = 2;
const PALUntouched = 3;
const Retention = 4;
const Feature = 5;
const ChineseQuality = 6;
const EnglishQuality = 7;
const X265ChineseQuality = 8;
const X265EnglishQuality = 9;
const Remux = 10;
const DIY = 11;
const Untouched = 12;
}
class TorrentSlotGroup {
const SDEncode = 1;
const SDUntouched = 2;
const HDEncode = 3;
const HDUntouched = 4;
const UHDEncode = 5;
const UHDUntouched = 6;
}
class TorrentSlot {
const SDEncodeSlots = [
TorrentSlotType::Quality,
TorrentSlotType::ChineseQuality,
];
const SDUntouchedSlots = [
TorrentSlotType::NTSCUntouched,
TorrentSlotType::PALUntouched,
];
const HD720PEncodeSlots = [
TorrentSlotType::Retention,
TorrentSlotType::Feature,
TorrentSlotType::ChineseQuality,
TorrentSlotType::EnglishQuality,
];
const HD1080PEncodeSlots = [
TorrentSlotType::Retention,
TorrentSlotType::Feature,
TorrentSlotType::ChineseQuality,
TorrentSlotType::EnglishQuality,
TorrentSlotType::X265ChineseQuality,
TorrentSlotType::X265EnglishQuality,
];
const HD720PUntouchedSlots = [
TorrentSlotType::Remux,
TorrentSlotType::Untouched,
];
const HD1080PUntouchedSlots = [
TorrentSlotType::Remux,
TorrentSlotType::Untouched,
TorrentSlotType::DIY,
];
const UHDEncodeSlots = [
TorrentSlotType::Retention,
TorrentSlotType::Feature,
TorrentSlotType::ChineseQuality,
TorrentSlotType::EnglishQuality,
];
const UHDUntouchedSlots = [
TorrentSlotType::Remux,
TorrentSlotType::DIY,
TorrentSlotType::Untouched,
];
const SDSlots = [
TorrentSlotType::None,
TorrentSlotType::Quality,
TorrentSlotType::ChineseQuality,
TorrentSlotType::NTSCUntouched,
TorrentSlotType::PALUntouched,
];
const HD720PSlots = [
TorrentSlotType::None,
TorrentSlotType::Retention,
TorrentSlotType::Feature,
TorrentSlotType::ChineseQuality,
TorrentSlotType::EnglishQuality,
TorrentSlotType::Remux,
TorrentSlotType::Untouched,
];
const HD1080PSlots = [
TorrentSlotType::None,
TorrentSlotType::Retention,
TorrentSlotType::Feature,
TorrentSlotType::ChineseQuality,
TorrentSlotType::EnglishQuality,
TorrentSlotType::X265ChineseQuality,
TorrentSlotType::X265EnglishQuality,
TorrentSlotType::Remux,
TorrentSlotType::DIY,
TorrentSlotType::Untouched,
];
const UHDSlots = [
TorrentSlotType::None,
TorrentSlotType::Retention,
TorrentSlotType::Feature,
TorrentSlotType::ChineseQuality,
TorrentSlotType::EnglishQuality,
TorrentSlotType::Remux,
TorrentSlotType::DIY,
TorrentSlotType::Untouched,
];
const Slots = [
TorrentSlotResolution::SD => self::SDSlots,
TorrentSlotResolution::HD720P => self::HD720PSlots,
TorrentSlotResolution::HD1080P => self::HD1080PSlots,
TorrentSlotResolution::UHD => self::UHDSlots,
];
const MaxSlotCount = [TorrentSlotType::Quality => 2];
public static function get_slot_resolution($Resolution) {
global $StandardDefinition, $UltraDefinition;
if (in_array($Resolution, $StandardDefinition)) {
return TorrentSlotResolution::SD;
} else if ($Resolution == '720p') {
return TorrentSlotResolution::HD720P;
} else if (in_array($Resolution, ['1080i', '1080p'])) {
return TorrentSlotResolution::HD1080P;
} else if (in_array($Resolution, $UltraDefinition)) {
return TorrentSlotResolution::UHD;
} else if (empty($Resolution)) {
return TorrentSlotResolution::None;
}
return TorrentSlotResolution::SD;
}
public static function get_resolution_slots($Resolution) {
global $StandardDefinition, $UltraDefinition;
if (in_array($Resolution, $StandardDefinition)) {
return self::SDSlots;
} else if ($Resolution == '720p') {
return self::HD720PSlots;
} else if (in_array($Resolution, ['1080i', '1080p'])) {
return self::HD1080PSlots;
} else if (in_array($Resolution, $UltraDefinition)) {
return self::UHDSlots;
}
return self::SDSlots;
}
public static function get_slot_group_status($Torrents) {
$SDSlotTorrents = [];
$HD720PSlotTorrents = [];
$HD1080PSlotTorrents = [];
$UHDSlotTorrents = [];
foreach ($Torrents as $Torrent) {
$Resolution = self::get_slot_resolution($Torrent['Resolution']);
if ($Resolution == TorrentSlotResolution::SD) {
if (isset($SDSlotTorrents[$Torrent['Slot']])) {
$SDSlotTorrents[$Torrent['Slot']]++;
} else {
$SDSlotTorrents[$Torrent['Slot']] = 1;
}
} else if (in_array($Resolution, [TorrentSlotResolution::HD720P])) {
if (isset($HD720PSlotTorrents[$Torrent['Slot']])) {
$HD720PSlotTorrents[$Torrent['Slot']]++;
} else {
$HD720PSlotTorrents[$Torrent['Slot']] = 1;
}
} else if (in_array($Resolution, [TorrentSlotResolution::HD1080P])) {
if (isset($HD1080PSlotTorrents[$Torrent['Slot']])) {
$HD1080PSlotTorrents[$Torrent['Slot']]++;
} else {
$HD1080PSlotTorrents[$Torrent['Slot']] = 1;
}
} else if ($Resolution == TorrentSlotResolution::UHD) {
if (isset($UHDSlotTorrents[$Torrent['Slot']])) {
$UHDSlotTorrents[$Torrent['Slot']]++;
} else {
$UHDSlotTorrents[$Torrent['Slot']] = 1;
}
}
}
list($HD720PEncodeStatus, $HD720PEncodeMissSlots) = self::check_slot_status($HD720PSlotTorrents, self::HD720PEncodeSlots);
list($HD1080PEncodeStatus, $HD10800PEncodeMissSlots) = self::check_slot_status($HD1080PSlotTorrents, self::HD1080PEncodeSlots);
$HDEncodeStatus = TorrentSlotGroupStatus::Free;
if ($HD720PEncodeStatus == TorrentSlotGroupStatus::Full && $HD1080PEncodeStatus == TorrentSlotGroupStatus::Full) {
$HDEncodeStatus = TorrentSlotGroupStatus::Full;
}
if ($HD720PEncodeStatus == TorrentSlotGroupStatus::Empty && $HD1080PEncodeStatus == TorrentSlotGroupStatus::Empty) {
$HDEncodeStatus = TorrentSlotGroupStatus::Empty;
}
$HDEncodeMissSlots = $HD720PEncodeMissSlots;
foreach ($HD10800PEncodeMissSlots as $MissSlot) {
if (!in_array($MissSlot, $HDEncodeMissSlots)) {
$HDEncodeMissSlots[] = $MissSlot;
}
}
list($HD720PUntouchedStatus, $HD720PUntouchedMissSlots) = self::check_slot_status($HD720PSlotTorrents, self::HD720PUntouchedSlots);
list($HD1080PUntouchedStatus, $HD1080PUntouchedMissSlots) = self::check_slot_status($HD1080PSlotTorrents, self::HD1080PUntouchedSlots);
$HDUntouchedStatus = TorrentSlotGroupStatus::Free;
if ($HD720PUntouchedStatus == TorrentSlotGroupStatus::Full && $HD1080PUntouchedStatus == TorrentSlotGroupStatus::Full) {
$HDUntouchedStatus = TorrentSlotGroupStatus::Full;
}
if ($HD720PEncodeStatus == TorrentSlotGroupStatus::Empty && $HD1080PUntouchedStatus == TorrentSlotGroupStatus::Empty) {
$HDUntouchedStatus = TorrentSlotGroupStatus::Empty;
}
$HDUntouchedMissSlots = $HD720PUntouchedMissSlots;
foreach ($HD1080PUntouchedMissSlots as $MissSlot) {
if (!in_array($MissSlot, $HDEncodeMissSlots)) {
$HDuntouchedMissSlots[] = $MissSlot;
}
}
return [
TorrentSlotGroup::SDEncode => self::check_slot_status($SDSlotTorrents, self::SDEncodeSlots),
TorrentSlotGroup::SDUntouched => self::check_slot_status($SDSlotTorrents, self::SDUntouchedSlots),
TorrentSlotGroup::HDEncode => [$HDEncodeStatus, $HDEncodeMissSlots],
TorrentSlotGroup::HDUntouched => [$HDUntouchedStatus, $HDUntouchedMissSlots],
TorrentSlotGroup::UHDEncode => self::check_slot_status($UHDSlotTorrents, self::UHDEncodeSlots),
TorrentSlotGroup::UHDUntouched => self::check_slot_status($UHDSlotTorrents, self::UHDUntouchedSlots),
];
}
private static function check_slot_status($SlotTorrents, $SlotGroup) {
$allempty = true;
$allfull = true;
$MissSlots = [];
foreach ($SlotGroup as $Slot) {
$free = false;
$count = isset($SlotTorrents[$Slot]) ? $SlotTorrents[$Slot] : 0;
if ($count > 0) {
$allempty = false;
}
if ((isset(self::MaxSlotCount[$Slot]) && $count < self::MaxSlotCount[$Slot]) || $count < 1) {
$free = true;
$allfull = false;
}
if ($free) {
$MissSlots[] = $Slot;
}
}
if ($allempty) {
return [TorrentSlotGroupStatus::Empty, $MissSlots];
}
if (!$allfull) {
return [TorrentSlotGroupStatus::Free, $MissSlots];
}
return [TorrentSlotGroupStatus::Full, $MissSlots];
}
public static function convert_slot_torrents($Torrents) {
$SDTorrents = [];
$HD720PTorrents = [];
$HD1080PTorrents = [];
$UHDTorrents = [];
foreach ($Torrents as $Torrent) {
$RemasterTitle = $Torrent['RemasterTitle'];
$RemasterCustomTitle = $Torrent['RemasterCustomTitle'];
$Resolution = $Torrent['Resolution'];
$NotMainMovie = $Torrent['NotMainMovie'];
$Slot = $Torrent['Slot'];
$IsExtraSlot = $Torrent['IsExtraSlot'];
if ($IsExtraSlot) {
$Slot = $Slot . '*';
}
if (in_array(Torrents::get_edition($Resolution, $RemasterTitle, $RemasterCustomTitle, $NotMainMovie), ['extra_definition', '3d'])) {
continue;
}
switch (self::get_slot_resolution($Torrent['Resolution'])) {
case TorrentSlotResolution::SD:
$SDTorrents[$Slot][] = $Torrent;
break;
case TorrentSlotResolution::HD720P:
$HD720PTorrents[$Slot][] = $Torrent;
break;
case TorrentSlotResolution::HD1080P:
$HD1080PTorrents[$Slot][] = $Torrent;
break;
case TorrentSlotResolution::UHD:
$UHDTorrents[$Slot][] = $Torrent;
break;
}
}
$Ret = [];
$Missing = [];
// 720P的任意质量槽如果存在,那么和SD的质量槽就会冲突Dupe
$Has720PQualitySlot = false;
$HD720TS = self::filter_slot_torrent(self::HD720PSlots, $HD720PTorrents, '720p');
foreach ($HD720TS[0] as $T) {
if (!isset($T['Missing']) && in_array($T['Slot'], [TorrentSlotType::ChineseQuality, TorrentSlotType::EnglishQuality])) {
$Has720PQualitySlot = true;
}
}
$TS = self::filter_slot_torrent(self::SDSlots, $SDTorrents, 'NTSC');
foreach ($TS[0] as $T) {
if ($T['Slot'] == TorrentSlotType::Quality && $Has720PQualitySlot && !isset($T['ExtraSlot'])) {
$T['Dupe'] = true;
}
$Ret[] = $T;
}
foreach ($HD720TS[0] as $T) {
$Ret[] = $T;
}
$Missing[TorrentSlotResolution::SD] = $TS[1];
$Missing[TorrentSlotResolution::HD720P] = $HD720TS[1];
$TS = self::filter_slot_torrent(self::HD1080PSlots, $HD1080PTorrents, '1080p');
foreach ($TS[0] as $T) {
$Ret[] = $T;
}
$Missing[TorrentSlotResolution::HD1080P] = $TS[1];
$TS = self::filter_slot_torrent(self::UHDSlots, $UHDTorrents, '2160p');
foreach ($TS[0] as $T) {
$Ret[] = $T;
}
$Missing[TorrentSlotResolution::UHD] = $TS[1];
return [$Ret, $Missing];
}
private static function filter_slot_torrent($Slots, $Torrents, $Resolution) {
$MissingSlot = [];
foreach ($Slots as $Slot) {
$SlotTorrents = isset($Torrents[$Slot]) ? $Torrents[$Slot] : [];
$ExtraSlotTorents = isset($Torrents[$Slot . '*']) ? $Torrents[$Slot . '*'] : [];
$count = count($SlotTorrents);
if ($count > 1) {
if (isset(self::MaxSlotCount[$Slot]) && $count <= self::MaxSlotCount[$Slot]) {
foreach ($SlotTorrents as $SlotTorrent) {
$Ret[] = $SlotTorrent;
}
} else {
foreach ($SlotTorrents as $SlotTorrent) {
$SlotTorrent['Dupe'] = true;
$Ret[] = $SlotTorrent;
}
}
} else if ($count <= 0) {
if ($Slot == TorrentSlotType::None) {
continue;
}
$MissingSlot[] = $Slot;
$Ret[] = ['Missing' => true, 'Slot' => $Slot, 'Resolution' => $Resolution];
} else {
$Ret[] = $SlotTorrents[0];
}
foreach ($ExtraSlotTorents as $ExtraSlotTorrent) {
$ExtraSlotTorrent['ExtraSlot'] = true;
$Ret[] = $ExtraSlotTorrent;
}
}
return [$Ret, $MissingSlot];
}
public static function CalSlot($Torrent) {
$Processing = Torrents::processing_value($Torrent);
$Resolution = $Torrent['Resolution'];
if (in_array(Torrents::resolution_level($Torrent), [SUBGROUP_3D, SUBGROUP_Extra])) {
return TorrentSlotType::None;
}
$Codec = $Torrent['Codec'];
$SpecialSub = isset($Torrent['SpecialSub']) && !empty($Torrent['SpecialSub']);
$ChineseDubbed = isset($Torrent['ChineseDubbed']) && !empty($Torrent['ChineseDubbed']);
$ChineseSubtitle = isset($Torrent['Subtitles']) && strstr($Torrent['Subtitles'], 'chinese');
foreach (explode(',', $Torrent['Subtitles']) as $Subtitle) {
if (!in_array($Subtitle, ['chinese_simplified', 'chinese_traditional', 'english'])) {
$ChineseSubtitle = false;
break;
}
}
switch (self::get_slot_resolution($Resolution)) {
case TorrentSlotResolution::SD:
if ($Processing == 'Encode') {
if ($ChineseSubtitle) {
return TorrentSlotType::ChineseQuality;
}
return TorrentSlotType::Quality;
}
if ($Processing == 'Untouched') {
if ($Resolution == 'NTSC') {
return TorrentSlotType::NTSCUntouched;
} else if ($Resolution = 'PAL') {
return TorrentSlotType::PALUntouched;
}
}
return TorrentSlotType::None;
case TorrentSlotResolution::HD720P:
if ($Processing == 'Untouched') {
return TorrentSlotType::Untouched;
} else if ($Processing == 'Remux') {
return TorrentSlotType::Remux;
} else {
if ($SpecialSub || $ChineseDubbed) {
return TorrentSlotType::Feature;
}
if ($ChineseSubtitle) {
return TorrentSlotType::ChineseQuality;
} else {
return TorrentSlotType::EnglishQuality;
}
}
return TorrentSlotType::None;
case TorrentSlotResolution::HD1080P:
if ($Processing == 'Untouched') {
return TorrentSlotType::Untouched;
} else if ($Processing == 'Remux') {
return TorrentSlotType::Remux;
} else if ($Processing == 'DIY') {
return TorrentSlotType::DIY;
} else {
if ($Codec == 'x265' || $Codec == 'H.265') {
if ($ChineseSubtitle) {
return TorrentSlotType::X265ChineseQuality;
} else {
return TorrentSlotType::X265EnglishQuality;
}
} else if ($Codec == 'x264' || $Codec == 'H.264') {
if ($SpecialSub || $ChineseDubbed) {
return TorrentSlotType::Feature;
}
if ($ChineseSubtitle) {
return TorrentSlotType::ChineseQuality;
} else {
return TorrentSlotType::EnglishQuality;
}
}
}
return TorrentSlotType::None;
case TorrentSlotResolution::UHD:
if ($Processing == 'Untouched') {
return TorrentSlotType::Untouched;
} else if ($Processing == 'Remux') {
return TorrentSlotType::Remux;
} else if ($Processing == 'DIY') {
return TorrentSlotType::DIY;
} else {
if ($SpecialSub || $ChineseDubbed) {
return TorrentSlotType::Feature;
}
if ($ChineseSubtitle) {
return TorrentSlotType::ChineseQuality;
} else {
return TorrentSlotType::EnglishQuality;
}
}
return TorrentSlotType::None;
}
return TorrentSlotType::None;
}
public static function empty_slot_title($SlotResolution) {
switch ($SlotResolution) {
case TorrentSlotResolution::SD:
return "empty_slots";
case TorrentSlotResolution::HD720P:
return "720p_empty_slots";
case TorrentSlotResolution::HD1080P:
return "1080p_empty_slots";
case TorrentSlotResolution::UHD:
return "empty_slots";
}
}
public static function empty_slot_tooltip($Slot) {
$str = '';
switch ($Slot) {
case TorrentSlotType::Quality:
$str = 'quality_slot_requirements';
break;
case TorrentSlotType::NTSCUntouched:
$str = 'untouched_slot_requirements';
break;
case TorrentSlotType::PALUntouched:
$str = 'untouched_slot_requirements';
break;
case TorrentSlotType::X265ChineseQuality:
$str = 'cn_quality_slot_requirements';
break;
case TorrentSlotType::X265EnglishQuality:
$str = 'en_quality_slot_requirements';
break;
case TorrentSlotType::ChineseQuality:
$str = 'cn_quality_slot_requirements';
break;
case TorrentSlotType::EnglishQuality:
$str = 'en_quality_slot_requirements';
break;
case TorrentSlotType::Retention:
$str = 'retention_slot_requirements';
break;
case TorrentSlotType::Feature:
$str = 'feature_slot_requirements';
break;
case TorrentSlotType::Remux:
$str = 'remux_slot_requirements';
break;
case TorrentSlotType::Untouched:
$str = 'untouched_slot_requirements';
break;
case TorrentSlotType::DIY:
$str = 'diy_slot_requirements';
break;
default:
return '';
}
return Lang::get('torrents', $str);
}
public static function slot_option_lang($Slot) {
switch ($Slot) {
case TorrentSlotType::Quality:
return 'quality_slot';
case TorrentSlotType::NTSCUntouched:
return 'untouched_slot_ntsc';
case TorrentSlotType::PALUntouched:
return 'untouched_slot_pal';
case TorrentSlotType::X265ChineseQuality:
return 'cn_quality_slot_x265';
case TorrentSlotType::X265EnglishQuality:
return 'en_quality_slot_x265';
case TorrentSlotType::ChineseQuality:
return 'cn_quality_slot';
case TorrentSlotType::EnglishQuality:
return 'en_quality_slot';
case TorrentSlotType::Retention:
return 'retention_slot';
case TorrentSlotType::Feature:
return 'feature_slot';
case TorrentSlotType::Remux:
return 'remux_slot';
case TorrentSlotType::Untouched:
return 'untouched_slot';
case TorrentSlotType::DIY:
return 'diy_slot';
}
return '';
}
public static function slot_option($Slot, $IsExtra, $TorrentSlot, $TorrentIsExtra) {
$Selected = '';
if ($Slot == $TorrentSlot && $IsExtra == $TorrentIsExtra) {
$Selected = 'selected';
}
if (empty($Slot)) {
$text = '---';
} else {
$text = Lang::get('torrents', self::slot_option_lang($Slot));
if ($IsExtra) {
$text .= '*';
$Slot .= '*';
}
}
$Ret = "<option class='Select-option' $Selected value='$Slot'>$text</option>";
return $Ret;
}
public static function slot_name($Slot) {
switch ($Slot) {
case TorrentSlotType::None:
return "empty";
case TorrentSlotType::ChineseQuality:
case TorrentSlotType::X265ChineseQuality:
return 'cn_quality';
case TorrentSlotType::Quality:
return 'quality';
case TorrentSlotType::EnglishQuality:
case TorrentSlotType::X265EnglishQuality:
return 'en_quality';
case TorrentSlotType::Retention:
return 'retention';
case TorrentSlotType::Feature:
return 'feature';
case TorrentSlotType::DIY:
return 'diy';
case TorrentSlotType::Remux:
return 'remux';
case TorrentSlotType::Untouched:
case TorrentSlotType::NTSCUntouched:
case TorrentSlotType::PALUntouched:
return 'untouched';
}
return '';
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
namespace Gazelle;
/* The purpose of the UserRank classes is to add a level of abstraction
* to the calculation of user ranks (most uploaded, most forum posts).
* The aim is to drive as much as possible from a static configuration
* table and have the code do its work, to allow other users of Gazelle
* to add different dimensions to ranks without have to monkey patch
* the internals.
*
* It begins with a RANKING_WEIGHT table in the configuration, which
* specifies the weight a dimension has towards the overall score, and
* a class name X that points to \Gazelle\UserRank\X.
*
* To explore and test in Boris:
* Consider that there are two users, one who has up/down votes a
* single release, and another who has voted on two:
*
* > $config = new Gazelle\UserRank\Configuration(RANKING_WEIGHT);
* > $config->instance('votes')->build();
* // array(
* // 0 => 1,
* // 1 => 2
* // )
* > $config->instance('votes')->rank(0);
* // 0
* > $config->instance('votes')->rank(1);
* // 50
* > $config->instance('votes')->rank(2);
* // 100
* > $config->instance('votes')->rank(3);
* // 100
*
* The UserRank object adds a wrapper over the top of the config
* object. It ignores the notion of paranoia, so metrics must be
* set to 0 when calculating the rank of paranoid people.
*
* Adding a new dimension should be as simple as adding an entry to the
* RANKING_WEIGHT table and writing a new class in app/UserRank/<whatever>.php
* This then has to hooked up to sections/user/user.php and
* sections/ajax/user.php
*
* Future directions: pass a \Gazelle\User object to the UserRank
* object, and define the appropriate ethod names in the ranking
* table so that the dimension classes can obtain the metrics
* directly and not need to have them passed in.
*/
class UserRank extends Base {
var $config;
var $rank;
var $score;
const PREFIX = 'percentiles_'; // Prefix for memcache keys, to make life easier
public function score(): int {
return $this->score;
}
public function rank(string $dimension): int {
return $this->rank[$dimension];
}
public function __construct(\Gazelle\UserRank\Configuration $config, array $dimension) {
parent::__construct();
$this->config = $config;
$definition = $this->config->definition();
$dimension['uploaded'] -= STARTING_UPLOAD;
$this->rank = [];
$ok = true;
foreach ($definition as $d) {
$this->rank[$d] = $this->config->instance($d)->rank($dimension[$d]);
if ($this->rank[$d] === false) {
$ok = false;
}
}
if (!$ok) {
$this->score = false;
return;
}
$this->score = 0;
$totalWeight = 0;
foreach ($definition as $d) {
$weight = $this->config->weight($d);
$this->score += $weight * $this->rank[$d];
$totalWeight += $weight;
}
$this->score /= $totalWeight;
if ($dimension['downloaded'] == 0) {
$ratio = 1;
} elseif ($dimension['uploaded'] == 0) {
$ratio = 0.5;
} else {
$ratio = min(1, round($dimension['uploaded'] / $dimension['downloaded']));
}
$this->score *= $ratio;
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace Gazelle\UserRank;
/* The common code for extracting the metric counts
* from the tables that represent the ranking dimensions
* happens here. Call the build() method interactively to
* see what the results look like. New dimensions should
* follow the same pattern (count by user, group by count).
*/
abstract class AbstractUserRank extends \Gazelle\Base {
abstract public function cacheKey(): string;
abstract public function selector(): string;
/**
* Build the ranking table from a dimension's
* selector. This is then folded down into a
* series of buckets to map raw metrics into
* a percentile value from 0 to 100. The table
* is cached, not persisted to the database.
*
* This will possibly need to be moved to a
* scheduler task, as some aggregations are very
* slow.
*/
public function build(): array {
$this->db->prepared_query("
DROP TEMPORARY TABLE IF EXISTS temp_stats
");
$this->db->prepared_query("
CREATE TEMPORARY TABLE temp_stats (
id int(10) NOT NULL PRIMARY KEY AUTO_INCREMENT,
val bigint(20) unsigned NOT NULL
)
");
$this->db->prepared_query(
"
INSERT INTO temp_stats (val) " . $this->selector()
);
$bucket = $this->db->scalar("
SELECT count(*) FROM temp_stats
") / 100;
$this->db->prepared_query(
"
SELECT min(val) as bucket
FROM temp_stats
GROUP BY ceil(id / ?);
",
$bucket
);
$raw = $this->db->collect('bucket');
/* We now have a list of at most 100 elements. For a number
* of metrics the series will follow a sharp exponential
* curve with many repeated values (because most of the
* activity comes from a relatively small number of users).
*
* 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
* 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2,
* 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3,
* 3, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 7, 7, 7, 8, 8,
* 9, 10, 10, 11, 11, 12, 13, 14, 15, 17, 18, 20, 23, 25,
* 28, 32, 36, 43, 51, 67, 83, 113, 170, 357
*
* The storage can be reduced by recording only the
* percentiles breaks, making the above become:
*
* 357 => 100,
* 170 => 99,
* 113 => 98,
* ...
* 3 => 50,
* 2 => 33,
* 1 => 1
*
* It is then trivial to walk down the list and read off
* the percentile. If the user metric is greater than the
* current value, this is their percentile. Note that some
* dimensions like data upload and download will show little
* to no reduction in size, as such metrics are generally
* unique per user.
*/
$previous = 0;
$percentile = 0;
$increment = max(1, 100 / count($raw));
$table = [];
foreach ($raw as $bucket) {
$percentile += $increment;
if ($previous != $bucket) {
$table[$bucket] = (int)$percentile;
}
$previous = $bucket;
}
$table = array_reverse($table, true);
// add some fuzz to the expiry time, so all the tables don't expire at once
$this->cache->cache_value($this->cacheKey(), $table, 86400 + rand(0, 3600));
return $table;
}
/**
* Map a user's raw metric (e.g. uploads = 648) to a percentile
* rank (e.g. 86).
*
* @param int $metric A result from a query of the form
* 'select count(*) from t where user = ?' or anything
* else that can be counted.
* @return int rank between 0 and 100
*/
public function rank(int $metric): int {
if ($metric == 0) {
return 0;
}
if (($table = $this->cache->get_value($this->cacheKey())) === false) {
$cacheLock = $this->cacheKey() . '_lock';
if ($this->cache->get_value($cacheLock) !== false) {
return false;
}
$this->cache->cache_value($cacheLock, true, 300);
$table = $this->build();
$this->cache->delete_value($cacheLock);
}
foreach ($table as $value => $percentile) {
if ($metric >= $value) {
return $percentile;
}
}
return 1;
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Gazelle\UserRank;
class Configuration {
var $config;
public function __construct(array $config) {
$this->config = $config;
}
public function definition(): array {
return array_keys($this->config);
}
public function instance(string $dimension): \Gazelle\UserRank\AbstractUserRank {
$className = "\\Gazelle\\UserRank\\Dimension\\" . $this->config[$dimension][1];
return new $className;
}
public function weight(string $dimension): int {
return $this->config[$dimension][0];
}
}

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