mirror of
https://github.com/BillyOutlast/Gazelle-Porn.git
synced 2026-07-01 06:41:50 -04:00
Initial commit
This commit is contained in:
Executable
+16
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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" ]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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|
|
||||
Executable
+22
@@ -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 ""
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
public
|
||||
vendor
|
||||
src/js/forked
|
||||
src/css/forked
|
||||
compser.lock
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
}
|
||||
+18
@@ -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
@@ -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" ]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,41 @@
|
||||
中文 | [English](./README.md)
|
||||
|
||||
# GazellePW
|
||||
|
||||
全称 GazellePosterWall,一个 PT(Private Tracker)Web 框架,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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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']];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)];
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace Gazelle\Exception;
|
||||
|
||||
class UserCreatorException extends \Exception {
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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 .= " Hidden";
|
||||
$stats['paranoid']++;
|
||||
} else {
|
||||
$markup .= sprintf(
|
||||
" Uploaded: <strong>%s</strong> Downloaded: <strong>%s</strong> Ratio: <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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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. It’s 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 can’t 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 I’m 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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&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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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=\"<img class="large_tile" src="$image" alt="" /›\"";
|
||||
$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]';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user