Browse Source

Empty commit

biotorrents 4 years ago
parent
commit
b4fb8c0c52
100 changed files with 30718 additions and 0 deletions
  1. 9
    0
      .gitignore
  2. 4
    0
      ajax.php
  3. 1
    0
      announce.php
  4. 5
    0
      artist.php
  5. 5
    0
      better.php
  6. 4
    0
      blog.php
  7. 5
    0
      bookmarks.php
  8. 4
    0
      browse.php
  9. 286
    0
      classes/artists.class.php
  10. 499
    0
      classes/autoenable.class.php
  11. 67
    0
      classes/autoload.php
  12. 163
    0
      classes/badges.class.php
  13. 111
    0
      classes/bencode.class.php
  14. 208
    0
      classes/bencodedecode.class.php
  15. 175
    0
      classes/bencodetorrent.class.php
  16. 39
    0
      classes/bitcoinrpc.class.php
  17. 110
    0
      classes/bookmarks.class.php
  18. 5
    0
      classes/browse.class.php
  19. 476
    0
      classes/cache.class.php
  20. 232
    0
      classes/charts.class.php
  21. 78
    0
      classes/collages.class.php
  22. 573
    0
      classes/comments.class.php
  23. 116
    0
      classes/commentsview.class.php
  24. 1316
    0
      classes/config.template
  25. 54
    0
      classes/cookie.class.php
  26. 41
    0
      classes/crypto.class.php
  27. 827
    0
      classes/debug.class.php
  28. 881
    0
      classes/donations.class.php
  29. 214
    0
      classes/donationsbitcoin.class.php
  30. 227
    0
      classes/donationsview.class.php
  31. 302
    0
      classes/env.class.php
  32. 114
    0
      classes/feed.class.php
  33. 699
    0
      classes/format.class.php
  34. 417
    0
      classes/forums.class.php
  35. 17
    0
      classes/g.class.php
  36. 67
    0
      classes/image.class.php
  37. 58
    0
      classes/imagetools.class.php
  38. 31
    0
      classes/inbox.class.php
  39. 270
    0
      classes/invite_tree.class.php
  40. 153
    0
      classes/ipv4.class.php
  41. 131
    0
      classes/json.class.php
  42. 60
    0
      classes/lockedaccounts.class.php
  43. 305
    0
      classes/loginwatch.class.php
  44. 89
    0
      classes/mass_user_bookmarks_editor.class.php
  45. 66
    0
      classes/mass_user_torrents_editor.class.php
  46. 300
    0
      classes/mass_user_torrents_table_view.class.php
  47. 580
    0
      classes/misc.class.php
  48. 656
    0
      classes/mysql.class.php
  49. 729
    0
      classes/notificationsmanager.class.php
  50. 140
    0
      classes/notificationsmanagerview.class.php
  51. 115
    0
      classes/paranoia.class.php
  52. 112
    0
      classes/permissions.class.php
  53. 278
    0
      classes/permissions_form.php
  54. 45
    0
      classes/proxies.class.php
  55. 30
    0
      classes/reports.class.php
  56. 264
    0
      classes/requests.class.php
  57. 30
    0
      classes/revisionhistory.class.php
  58. 46
    0
      classes/revisionhistoryview.class.php
  59. 572
    0
      classes/script_start.php
  60. 86
    0
      classes/security.class.php
  61. 304
    0
      classes/sitehistory.class.php
  62. 236
    0
      classes/sitehistoryview.class.php
  63. 16
    0
      classes/slaves.class.php
  64. 162
    0
      classes/sphinxql.class.php
  65. 410
    0
      classes/sphinxqlquery.class.php
  66. 156
    0
      classes/sphinxqlresult.class.php
  67. 422
    0
      classes/subscriptions.class.php
  68. 323
    0
      classes/tags.class.php
  69. 83
    0
      classes/templates.class.php
  70. 169
    0
      classes/testing.class.php
  71. 182
    0
      classes/testingview.class.php
  72. 1197
    0
      classes/text.class.php
  73. 244
    0
      classes/textarea_preview.class.php
  74. 183
    0
      classes/time.class.php
  75. 341
    0
      classes/tools.class.php
  76. 91
    0
      classes/top10view.class.php
  77. 348
    0
      classes/torrent.class.php
  78. 1494
    0
      classes/torrent_form.class.NEW.2020-12-10.php
  79. 1478
    0
      classes/torrent_form.class.php
  80. 1288
    0
      classes/torrents.class.php
  81. 261
    0
      classes/torrentsdl.class.php
  82. 737
    0
      classes/torrentsearch.class.php
  83. 195
    0
      classes/tracker.class.php
  84. 138
    0
      classes/twofa.class.php
  85. 576
    0
      classes/u2f.class.php
  86. 168
    0
      classes/useragent.class.php
  87. 173
    0
      classes/userrank.class.php
  88. 1016
    0
      classes/users.class.php
  89. 653
    0
      classes/util.php
  90. 566
    0
      classes/validate.class.php
  91. 1994
    0
      classes/vendor/Parsedown.php
  92. 686
    0
      classes/vendor/ParsedownExtra.php
  93. 410
    0
      classes/vendor/TwitterAPIExchange.php
  94. 216
    0
      classes/view.class.php
  95. 111
    0
      classes/wiki.class.php
  96. 178
    0
      classes/zip.class.php
  97. 6
    0
      collage.php
  98. 5
    0
      collages.php
  99. 5
    0
      comments.php
  100. 0
    0
      contest.php

+ 9
- 0
.gitignore View File

@@ -0,0 +1,9 @@
1
+classes/config.php
2
+_packages/**/.git/**
3
+
4
+static/styles/*.css
5
+static/styles/assets/fonts/**
6
+static/common/badges/**
7
+
8
+.DS_Store
9
+*.sw*

+ 4
- 0
ajax.php View File

@@ -0,0 +1,4 @@
1
+<?php
2
+declare(strict_types=1);
3
+
4
+require_once 'classes/script_start.php';

+ 1
- 0
announce.php View File

@@ -0,0 +1 @@
1
+d14:failure reason40:Invalid .torrent, try downloading again.e

+ 5
- 0
artist.php View File

@@ -0,0 +1,5 @@
1
+<?php
2
+declare(strict_types=1);
3
+
4
+define('ERROR_EXCEPTION', true);
5
+require_once 'classes/script_start.php';

+ 5
- 0
better.php View File

@@ -0,0 +1,5 @@
1
+<?php
2
+declare(strict_types=1);
3
+
4
+define('ERROR_EXCEPTION', true);
5
+require_once 'classes/script_start.php';

+ 4
- 0
blog.php View File

@@ -0,0 +1,4 @@
1
+<?php
2
+declare(strict_types=1);
3
+
4
+require_once 'classes/script_start.php';

+ 5
- 0
bookmarks.php View File

@@ -0,0 +1,5 @@
1
+<?php
2
+declare(strict_types=1);
3
+
4
+define('ERROR_EXCEPTION', true);
5
+require_once 'classes/script_start.php';

+ 4
- 0
browse.php View File

@@ -0,0 +1,4 @@
1
+<?php
2
+declare(strict_types=1);
3
+
4
+header('Location: torrents.php');

+ 286
- 0
classes/artists.class.php View File

@@ -0,0 +1,286 @@
1
+<?php
2
+
3
+class Artists
4
+{
5
+
6
+  /**
7
+   * Given an array of GroupIDs, return their associated artists.
8
+   *
9
+   * @param array $GroupIDs
10
+   * @return an array of the following form:
11
+   *  GroupID => {
12
+   *    [ArtistType] => {
13
+   *      id, name, aliasid
14
+   *    }
15
+   *  }
16
+   *
17
+   * ArtistType is an int. It can be:
18
+   * 1 => Main artist
19
+   * 2 => Guest artist
20
+   * 4 => Composer
21
+   * 5 => Conductor
22
+   * 6 => DJ
23
+   */
24
+    public static function get_artists($GroupIDs)
25
+    {
26
+        $Results = [];
27
+        $DBs = [];
28
+
29
+        foreach ($GroupIDs as $GroupID) {
30
+            if (!is_number($GroupID)) {
31
+                continue;
32
+            }
33
+
34
+            $Artists = G::$Cache->get_value('groups_artists_'.$GroupID);
35
+            if (is_array($Artists)) {
36
+                $Results[$GroupID] = $Artists;
37
+            } else {
38
+                $DBs[] = $GroupID;
39
+            }
40
+        }
41
+
42
+        if (count($DBs) > 0) {
43
+            $IDs = implode(',', $DBs);
44
+            if (empty($IDs)) {
45
+                $IDs = 'null';
46
+            }
47
+
48
+            $QueryID = G::$DB->get_query_id();
49
+            G::$DB->query("
50
+            SELECT
51
+              ta.`GroupID`,
52
+              ta.`ArtistID`,
53
+              ag.`Name`
54
+            FROM
55
+              `torrents_artists` AS ta
56
+            JOIN `artists_group` AS ag
57
+            ON
58
+              ta.`ArtistID` = ag.`ArtistID`
59
+            WHERE
60
+              ta.`GroupID` IN($IDs)
61
+            ORDER BY
62
+              ta.`GroupID` ASC,
63
+              ag.`Name` ASC;
64
+            ");
65
+
66
+            while (list($GroupID, $ArtistID, $ArtistName) = G::$DB->next_record(MYSQLI_BOTH, false)) {
67
+                $Results[$GroupID][] = array('id' => $ArtistID, 'name' => $ArtistName);
68
+                $New[$GroupID][] = array('id' => $ArtistID, 'name' => $ArtistName);
69
+            }
70
+
71
+            G::$DB->set_query_id($QueryID);
72
+            foreach ($DBs as $GroupID) {
73
+                if (isset($New[$GroupID])) {
74
+                    G::$Cache->cache_value("groups_artists_$GroupID", $New[$GroupID]);
75
+                } else {
76
+                    G::$Cache->cache_value("groups_artists_$GroupID", []);
77
+                }
78
+            }
79
+
80
+            $Missing = array_diff($GroupIDs, array_keys($Results));
81
+            if (!empty($Missing)) {
82
+                $Results += array_fill_keys($Missing, []);
83
+            }
84
+        }
85
+        return $Results;
86
+    }
87
+
88
+    /**
89
+     * Convenience function for get_artists, when you just need one group.
90
+     *
91
+     * @param int $GroupID
92
+     * @return array - see get_artists
93
+     */
94
+    public static function get_artist($GroupID)
95
+    {
96
+        $Results = Artists::get_artists(array($GroupID));
97
+        return $Results[$GroupID];
98
+    }
99
+
100
+    /**
101
+     * Format an array of artists for display.
102
+     * todo: Revisit the logic of this, see if we can helper-function the copypasta.
103
+     *
104
+     * @param array Artists an array of the form output by get_artists
105
+     * @param boolean $MakeLink if true, the artists will be links, if false, they will be text.
106
+     * @param boolean $IncludeHyphen if true, appends " - " to the end.
107
+     * @param $Escape if true, output will be escaped. Think carefully before setting it false.
108
+     */
109
+    public static function display_artists($Artists, $MakeLink = true, $IncludeHyphen = true, $Escape = true)
110
+    {
111
+        if (!empty($Artists)) {
112
+            $ampersand = ($Escape) ? ' &amp; ' : ' & ';
113
+            $link = ($IncludeHyphen? '🧑&nbsp;' : '');
114
+    
115
+            switch (count($Artists)) {
116
+            case 0:
117
+                break;
118
+
119
+            case 3:
120
+                $link .= Artists::display_artist($Artists[2], $MakeLink, $Escape). ", ";
121
+                // no break
122
+
123
+            case 2:
124
+                $link .= Artists::display_artist($Artists[1], $MakeLink, $Escape). ", ";
125
+                // no break
126
+
127
+            case 1:
128
+                $link .= Artists::display_artist($Artists[0], $MakeLink, $Escape);
129
+                break;
130
+
131
+            default:
132
+                $link = ($IncludeHyphen?'🧑&nbsp;':'').Artists::display_artist($Artists[0], $MakeLink, $Escape).' et al.';
133
+                #$link = 'Various'.($IncludeHyphen?' &ndash; ':'');
134
+        }
135
+            return $link;
136
+        } else {
137
+            return '';
138
+        }
139
+    }
140
+    
141
+    /**
142
+     * Formats a single artist name.
143
+     *
144
+     * @param array $Artist an array of the form ('id' => ID, 'name' => Name)
145
+     * @param boolean $MakeLink If true, links to the artist page.
146
+     * @param boolean $Escape If false and $MakeLink is false, returns the unescaped, unadorned artist name.
147
+     * @return string Formatted artist name.
148
+     */
149
+    public static function display_artist($Artist, $MakeLink = true, $Escape = true)
150
+    {
151
+        if ($MakeLink && !$Escape) {
152
+            error('Invalid parameters to Artists::display_artist()');
153
+        } elseif ($MakeLink) {
154
+            return '<a href="artist.php?id='.$Artist['id'].'">'.display_str($Artist['name']).'</a>';
155
+        } elseif ($Escape) {
156
+            return display_str($Artist['name']);
157
+        } else {
158
+            return $Artist['name'];
159
+        }
160
+    }
161
+
162
+    /**
163
+     * Deletes an artist and their requests, wiki, and tags.
164
+     * Does NOT delete their torrents.
165
+     *
166
+     * @param int $ArtistID
167
+     */
168
+    public static function delete_artist($ArtistID)
169
+    {
170
+        $QueryID = G::$DB->get_query_id();
171
+        G::$DB->query("
172
+        SELECT
173
+          `NAME`
174
+        FROM
175
+          `artists_group`
176
+        WHERE
177
+          `ArtistID` = $ArtistID
178
+        ");
179
+        list($Name) = G::$DB->next_record(MYSQLI_NUM, false);
180
+
181
+        // Delete requests
182
+        G::$DB->query("
183
+        SELECT
184
+          `RequestID`
185
+        FROM
186
+          `requests_artists`
187
+        WHERE
188
+          `ArtistID` = $ArtistID AND `ArtistID` != 0
189
+        ");
190
+
191
+        $Requests = G::$DB->to_array();
192
+        foreach ($Requests as $Request) {
193
+            list($RequestID) = $Request;
194
+            G::$DB->query("
195
+            DELETE
196
+            FROM
197
+              `requests`
198
+            WHERE
199
+              `ID` = $RequestID
200
+            ");
201
+
202
+            G::$DB->query("
203
+            DELETE
204
+            FROM
205
+              `requests_votes`
206
+            WHERE
207
+              `RequestID` = $RequestID
208
+            ");
209
+
210
+            G::$DB->query("
211
+            DELETE
212
+            FROM
213
+              `requests_tags`
214
+            WHERE
215
+              `RequestID` = $RequestID
216
+            ");
217
+
218
+            G::$DB->query("
219
+            DELETE
220
+            FROM
221
+              `requests_artists`
222
+            WHERE
223
+              `RequestID` = $RequestID
224
+            ");
225
+        }
226
+
227
+        // Delete artist
228
+        G::$DB->query("
229
+        DELETE
230
+        FROM
231
+          `artists_group`
232
+        WHERE
233
+          `ArtistID` = $ArtistID
234
+        ");
235
+        G::$Cache->decrement('stats_artist_count');
236
+
237
+        // Delete wiki revisions
238
+        G::$DB->query("
239
+        DELETE
240
+        FROM
241
+          `wiki_artists`
242
+        WHERE
243
+          `PageID` = $ArtistID
244
+        ");
245
+
246
+        // Delete tags
247
+        G::$DB->query("
248
+        DELETE
249
+        FROM
250
+          `artists_tags`
251
+        WHERE
252
+          `ArtistID` = $ ArtistID
253
+        ");
254
+
255
+        // Delete artist comments, subscriptions and quote notifications
256
+        Comments::delete_page('artist', $ArtistID);
257
+        G::$Cache->delete_value("artist_$ArtistID");
258
+        G::$Cache->delete_value("artist_groups_$ArtistID");
259
+
260
+        // Record in log
261
+        if (!empty(G::$LoggedUser['Username'])) {
262
+            $Username = G::$LoggedUser['Username'];
263
+        } else {
264
+            $Username = 'System';
265
+        }
266
+        
267
+        Misc::write_log("Artist $ArtistID ($Name) was deleted by $Username");
268
+        G::$DB->set_query_id($QueryID);
269
+    }
270
+
271
+    /**
272
+     * Remove LRM (left-right-marker) and trims, because people copypaste carelessly.
273
+     * If we don't do this, we get seemingly duplicate artist names.
274
+     * todo: make stricter, e.g., on all whitespace characters or Unicode normalisation
275
+     *
276
+     * @param string $ArtistName
277
+     */
278
+    public static function normalise_artist_name($ArtistName)
279
+    {
280
+        // \u200e is &lrm;
281
+        $ArtistName = trim($ArtistName);
282
+        $ArtistName = preg_replace('/^(\xE2\x80\x8E)+/', '', $ArtistName);
283
+        $ArtistName = preg_replace('/(\xE2\x80\x8E)+$/', '', $ArtistName);
284
+        return trim(preg_replace('/ +/', ' ', $ArtistName));
285
+    }
286
+}

+ 499
- 0
classes/autoenable.class.php View File

@@ -0,0 +1,499 @@
1
+<?php
2
+
3
+# todo: Check strict equality gently
4
+class AutoEnable
5
+{
6
+    // Constants for database values
7
+    const APPROVED = 1;
8
+    const DENIED = 2;
9
+    const DISCARDED = 3;
10
+
11
+    // Cache key to store the number of enable requests
12
+    const CACHE_KEY_NAME = 'num_enable_requests';
13
+
14
+    // The default request rejected message
15
+    const REJECTED_MESSAGE = <<<EOT
16
+    <p>
17
+      Your request to re-enable your account has been rejected.
18
+    </p>
19
+
20
+    <p>
21
+      This may be because a request is already pending for your username,
22
+      or because a recent request was denied.
23
+    </p>
24
+EOT;
25
+
26
+    // The default request received message
27
+    const RECEIVED_MESSAGE = <<<EOT
28
+    <p>
29
+      Your request to re-enable your account has been received.
30
+    </p>
31
+    
32
+    <p>
33
+      Most requests are responded to within minutes.
34
+      Remember to check your spam folder.
35
+    </p>
36
+EOT;
37
+
38
+    /**
39
+     * Handle a new enable request
40
+     *
41
+     * @param string $Username The user's username
42
+     * @param string $Email The user's email address
43
+     * @return string The output
44
+     */
45
+    public static function new_request($Username, $Email)
46
+    {
47
+        if (empty($Username)) {
48
+            header('Location: login.php');
49
+            error();
50
+        }
51
+
52
+        // Get the user's ID
53
+        G::$DB->query("
54
+        SELECT
55
+          um.`ID`,
56
+          ui.`BanReason`
57
+        FROM
58
+          `users_main` AS um
59
+        JOIN `users_info` ui ON
60
+          ui.`UserID` = um.`ID`
61
+        WHERE
62
+          um.`Username` = '$Username'
63
+          AND um.`Enabled` = '2'
64
+        ");
65
+
66
+        if (G::$DB->has_results()) {
67
+            // Make sure the user can make another request
68
+            list($UserID, $BanReason) = G::$DB->next_record();
69
+            G::$DB->query("
70
+            SELECT
71
+              1
72
+            FROM
73
+              `users_enable_requests`
74
+            WHERE
75
+              `UserID` = '$UserID' AND(
76
+                (
77
+                  `Timestamp` > NOW() - INTERVAL 1 WEEK
78
+                  AND `HandledTimestamp` IS NULL)
79
+                  OR(
80
+                    `Timestamp` > NOW() - INTERVAL 2 MONTH
81
+                    AND Outcome = '".self::DENIED."')
82
+                )
83
+            ");
84
+        }
85
+
86
+        $IP = $_SERVER['REMOTE_ADDR'];
87
+        if (G::$DB->has_results() || !isset($UserID)) {
88
+            // User already has/had a pending activation request or username is invalid
89
+            $Output = sprintf(self::REJECTED_MESSAGE, DISABLED_CHAN, BOT_SERVER);
90
+            if (isset($UserID)) {
91
+                Tools::update_user_notes(
92
+                    $UserID,
93
+                    sqltime() . " - Enable request rejected from $IP\n\n"
94
+                );
95
+            }
96
+        } else {
97
+            // New disable activation request
98
+            $UserAgent = db_string($_SERVER['HTTP_USER_AGENT']);
99
+            G::$DB->query(
100
+                "
101
+                INSERT INTO `users_enable_requests`(
102
+                  `UserID`,
103
+                  `Email`,
104
+                  `IP`,
105
+                  `UserAgent`,
106
+                  `Timestamp`
107
+                )
108
+                VALUES(?, ?, ?, ?, NOW())",
109
+                $UserID,
110
+                Crypto::encrypt($Email),
111
+                Crypto::encrypt($IP),
112
+                $UserAgent
113
+            );
114
+            $RequestID = G::$DB->inserted_id();
115
+
116
+            // Cache the number of requests for the modbar
117
+            G::$Cache->increment_value(self::CACHE_KEY_NAME);
118
+            setcookie('username', '', time() - 60 * 60, '/', '', false);
119
+            $Output = self::RECEIVED_MESSAGE;
120
+
121
+            Tools::update_user_notes(
122
+                $UserID,
123
+                sqltime() . " - Enable request " . G::$DB->inserted_id() . " received from $IP\n\n"
124
+            );
125
+
126
+            if ($BanReason === 3) {
127
+                self::handle_requests(
128
+                    [$RequestID],
129
+                    self::APPROVED,
130
+                    'Automatically approved (inactivity)'
131
+                );
132
+            }
133
+        }
134
+        return $Output;
135
+    }
136
+
137
+    /*
138
+     * Handle requests
139
+     *
140
+     * @param int|int[] $IDs An array of IDs, or a single ID
141
+     * @param int $Status The status to mark the requests as
142
+     * @param string $Comment The staff member comment
143
+     */
144
+    public static function handle_requests($IDs, $Status, $Comment)
145
+    {
146
+        # Error checking
147
+        if ($Status !== self::APPROVED && $Status !== self::DENIED && $Status !== self::DISCARDED) {
148
+            error(404);
149
+        }
150
+
151
+        $ENV = ENV::go();
152
+        $UserInfo = [];
153
+        $IDs = (!is_array($IDs)) ? [$IDs] : $IDs;
154
+
155
+        if (count($IDs) === 0) {
156
+            error(404);
157
+        }
158
+
159
+        foreach ($IDs as $ID) {
160
+            if (!is_number($ID)) {
161
+                error(404);
162
+            }
163
+        }
164
+
165
+        G::$DB->query("
166
+        SELECT
167
+          `Email`,
168
+          `ID`,
169
+          `UserID`
170
+        FROM
171
+          `users_enable_requests`
172
+        WHERE
173
+          `ID` IN(".implode(',', $IDs).")
174
+          AND `Outcome` IS NULL
175
+        ");
176
+        $Results = G::$DB->to_array(false, MYSQLI_NUM);
177
+
178
+        if ($Status !== self::DISCARDED) {
179
+            // Prepare email
180
+            require_once SERVER_ROOT.'/classes/templates.class.php';
181
+            $TPL = new TEMPLATE;
182
+
183
+            if ($Status === self::APPROVED) {
184
+                $TPL->open(SERVER_ROOT.'/templates/enable_request_accepted.tpl');
185
+                $TPL->set('SITE_DOMAIN', SITE_DOMAIN);
186
+            } else {
187
+                $TPL->open(SERVER_ROOT.'/templates/enable_request_denied.tpl');
188
+            }
189
+            $TPL->set('SITE_NAME', $ENV->SITE_NAME);
190
+
191
+            foreach ($Results as $Result) {
192
+                list($Email, $ID, $UserID) = $Result;
193
+                $Email = Crypto::decrypt($Email);
194
+                $UserInfo[] = array($ID, $UserID);
195
+
196
+                if ($Status === self::APPROVED) {
197
+                    // Generate token
198
+                    $Token = db_string(Users::make_secret());
199
+                    G::$DB->query("
200
+                    UPDATE
201
+                      `users_enable_requests`
202
+                    SET
203
+                      `Token` = ?
204
+                    WHERE
205
+                      `ID` = ?,
206
+                      $Token,
207
+                      $ID
208
+                    ");
209
+                    $TPL->set('TOKEN', $Token);
210
+                }
211
+
212
+                // Send email
213
+                $Subject = "Your enable request for $ENV->SITE_NAME has been ";
214
+                $Subject .= ($Status === self::APPROVED) ? 'approved' : 'denied';
215
+                Misc::send_email($Email, $Subject, $TPL->get(), 'noreply');
216
+            }
217
+        } else {
218
+            foreach ($Results as $Result) {
219
+                list(, $ID, $UserID) = $Result;
220
+                $UserInfo[] = array($ID, $UserID);
221
+            }
222
+        }
223
+
224
+        // User notes stuff
225
+        $StaffID = G::$LoggedUser['ID'] ?? 0;
226
+        G::$DB->query("
227
+        SELECT
228
+          `Username`
229
+        FROM
230
+          `users_main`
231
+        WHERE
232
+          `ID` = ?,
233
+          $StaffID
234
+        ");
235
+
236
+        if (G::$DB->has_results()) {
237
+            list($StaffUser) = G::$DB->next_record();
238
+        } else {
239
+            $StaffUser = "System";
240
+            $StaffID = 0;
241
+        }
242
+
243
+        foreach ($UserInfo as $User) {
244
+            list($ID, $UserID) = $User;
245
+            $BaseComment = sqltime()." - Enable request $ID ".strtolower(self::get_outcome_string($Status)).' by [user]'.$StaffUser.'[/user]';
246
+            $BaseComment .= (!empty($Comment)) ? "\nReason: $Comment\n\n" : "\n\n";
247
+            Tools::update_user_notes($UserID, $BaseComment);
248
+        }
249
+
250
+        // Update database values and decrement cache
251
+        G::$DB->query("
252
+        UPDATE
253
+          `users_enable_requests`
254
+        SET
255
+          `HandledTimestamp` = NOW(), `CheckedBy` = ?, `Outcome` = ?
256
+        WHERE
257
+          `ID` IN(".implode(',', $IDs)."),
258
+          $StaffID,
259
+          $Status
260
+        ");
261
+        G::$Cache->decrement_value(self::CACHE_KEY_NAME, count($IDs));
262
+    }
263
+
264
+    /**
265
+     * Unresolve a discarded request
266
+     *
267
+     * @param int $ID The request ID
268
+     */
269
+    public static function unresolve_request($ID)
270
+    {
271
+        $ID = (int) $ID;
272
+        if (empty($ID)) {
273
+            error(404);
274
+        }
275
+
276
+        G::$DB->query("
277
+        SELECT
278
+          `UserID`
279
+        FROM
280
+          `users_enable_requests`
281
+        WHERE
282
+          `Outcome` = '" . self::DISCARDED . "' AND `ID` = '$ID'
283
+        ");
284
+
285
+        if (!G::$DB->has_results()) {
286
+            error(404);
287
+        } else {
288
+            list($UserID) = G::$DB->next_record();
289
+        }
290
+
291
+        G::$DB->query("
292
+        SELECT
293
+          `Username`
294
+        FROM
295
+          `users_main`
296
+        WHERE
297
+          `ID` = '".G::$LoggedUser['ID']."'
298
+        ");
299
+        list($StaffUser) = G::$DB->next_record();
300
+
301
+        Tools::update_user_notes($UserID, sqltime()." - Enable request $ID unresolved by [user]".$StaffUser.'[/user]'."\n\n");
302
+        G::$DB->query("
303
+        UPDATE
304
+          `users_enable_requests`
305
+        SET
306
+          `Outcome` = NULL,
307
+          `HandledTimestamp` = NULL,
308
+          `CheckedBy` = NULL
309
+        WHERE
310
+          `ID` = '$ID'
311
+        ");
312
+        G::$Cache->increment_value(self::CACHE_KEY_NAME);
313
+    }
314
+
315
+    /**
316
+     * Get the corresponding outcome string for a numerical value
317
+     *
318
+     * @param int $Outcome The outcome integer
319
+     * @return string The formatted output string
320
+     */
321
+    public static function get_outcome_string($Outcome)
322
+    {
323
+        if ($Outcome === self::APPROVED) {
324
+            $String = 'Approved';
325
+        } elseif ($Outcome === self::DENIED) {
326
+            $String = 'Rejected';
327
+        } elseif ($Outcome === self::DISCARDED) {
328
+            $String = 'Discarded';
329
+        } else {
330
+            $String = '---';
331
+        }
332
+        return $String;
333
+    }
334
+
335
+    /**
336
+     * Handle a user's request to enable an account
337
+     *
338
+     * @param string $Token The token
339
+     * @return string The error output, or an empty string
340
+     */
341
+    public static function handle_token($Token)
342
+    {
343
+        $Token = db_string($Token);
344
+        G::$DB->query("
345
+        SELECT
346
+          uer.`UserID`,
347
+          uer.`HandledTimestamp`,
348
+          um.`torrent_pass`,
349
+          um.`Visible`,
350
+          um.`IP`
351
+        FROM
352
+          `users_enable_requests` AS uer
353
+        LEFT JOIN `users_main` AS um
354
+        ON
355
+          uer.`UserID` = um.`ID`
356
+        WHERE
357
+          `Token` = '$Token'
358
+        ");
359
+
360
+        if (G::$DB->has_results()) {
361
+            list($UserID, $Timestamp, $TorrentPass, $Visible, $IP) = G::$DB->next_record();
362
+            G::$DB->query("
363
+            UPDATE
364
+              `users_enable_requests`
365
+            SET
366
+              `Token` = NULL
367
+            WHERE
368
+              `Token` = '$Token'
369
+            ");
370
+
371
+            if ($Timestamp < time_minus(3600 * 48)) {
372
+                // Old request
373
+                Tools::update_user_notes($UserID, sqltime()." - Tried to use an expired enable token from ".$_SERVER['REMOTE_ADDR']."\n\n");
374
+                $Err = "Token has expired. Please visit ".DISABLED_CHAN." on ".BOT_SERVER." to discuss this with staff.";
375
+            } else {
376
+                // Good request, decrement cache value and enable account
377
+                G::$Cache->decrement_value(AutoEnable::CACHE_KEY_NAME);
378
+                $VisibleTrIP = ($Visible && Crypto::decrypt($IP) !== '127.0.0.1') ? '1' : '0';
379
+                Tracker::update_tracker('add_user', array('id' => $UserID, 'passkey' => $TorrentPass, 'visible' => $VisibleTrIP));
380
+
381
+                G::$DB->query("
382
+                UPDATE
383
+                  `users_main`
384
+                SET
385
+                  `Enabled` = '1',
386
+                  `can_leech` = '1'
387
+                WHERE
388
+                  `ID` = '$UserID'
389
+                ");
390
+                
391
+                G::$DB->query("
392
+                UPDATE
393
+                  `users_info`
394
+                SET
395
+                  `BanReason` = '0'
396
+                WHERE
397
+                  `UserID` = '$UserID'
398
+                ");
399
+
400
+                G::$Cache->delete_value("user_info_$UserID");
401
+                $Err = "Your account has been enabled. You may now log in.";
402
+            }
403
+        } else {
404
+            $Err = "Invalid token.";
405
+        }
406
+        return $Err;
407
+    }
408
+
409
+    /**
410
+     * Build the search query, from the searchbox inputs
411
+     *
412
+     * @param int $UserID The user ID
413
+     * @param string $IP The IP
414
+     * @param string $SubmittedTimestamp The timestamp representing when the request was submitted
415
+     * @param int $HandledUserID The ID of the user that handled the request
416
+     * @param string $HandledTimestamp The timestamp representing when the request was handled
417
+     * @param int $OutcomeSearch The outcome of the request
418
+     * @param boolean $Checked Should checked requests be included?
419
+     * @return array The WHERE conditions for the query
420
+     */
421
+    public static function build_search_query($Username, $IP, $SubmittedBetween, $SubmittedTimestamp1, $SubmittedTimestamp2, $HandledUsername, $HandledBetween, $HandledTimestamp1, $HandledTimestamp2, $OutcomeSearch, $Checked)
422
+    {
423
+        $Where = [];
424
+
425
+        if (!empty($Username)) {
426
+            $Where[] = "um1.`Username` = '$Username'";
427
+        }
428
+
429
+        if (!empty($IP)) {
430
+            // todo: Make this work with encrypted IPs
431
+            $Where[] = "uer.`IP` = '$IP'";
432
+        }
433
+
434
+        if (!empty($SubmittedTimestamp1)) {
435
+            switch ($SubmittedBetween) {
436
+                case 'on':
437
+                    $Where[] = "DATE(uer.`Timestamp`) = DATE('$SubmittedTimestamp1')";
438
+                    break;
439
+
440
+                case 'before':
441
+                    $Where[] = "DATE(uer.`Timestamp`) < DATE('$SubmittedTimestamp1')";
442
+                    break;
443
+
444
+                case 'after':
445
+                    $Where[] = "DATE(uer.`Timestamp`) > DATE('$SubmittedTimestamp1')";
446
+                    break;
447
+
448
+                case 'between':
449
+                    if (!empty($SubmittedTimestamp2)) {
450
+                        $Where[] = "DATE(uer.`Timestamp`) BETWEEN DATE('$SubmittedTimestamp1') AND DATE('$SubmittedTimestamp2')";
451
+                    }
452
+                    break;
453
+
454
+                default:
455
+                    break;
456
+            }
457
+        }
458
+
459
+        if (!empty($HandledTimestamp1)) {
460
+            switch ($HandledBetween) {
461
+                case 'on':
462
+                    $Where[] = "DATE(uer.`HandledTimestamp`) = DATE('$HandledTimestamp1')";
463
+                    break;
464
+
465
+                case 'before':
466
+                    $Where[] = "DATE(uer.`HandledTimestamp`) < DATE('$HandledTimestamp1')";
467
+                    break;
468
+
469
+                case 'after':
470
+                    $Where[] = "DATE(uer.`HandledTimestamp`) > DATE('$HandledTimestamp1')";
471
+                    break;
472
+
473
+                case 'between':
474
+                    if (!empty($HandledTimestamp2)) {
475
+                        $Where[] = "DATE(uer.`HandledTimestamp`) BETWEEN DATE('$HandledTimestamp1') AND DATE('$HandledTimestamp2')";
476
+                    }
477
+                    break;
478
+
479
+                default:
480
+                    break;
481
+            }
482
+        }
483
+
484
+        if (!empty($HandledUsername)) {
485
+            $Where[] = "um2.`Username` = '$HandledUsername'";
486
+        }
487
+
488
+        if (!empty($OutcomeSearch)) {
489
+            $Where[] = "uer.`Outcome` = '$OutcomeSearch'";
490
+        }
491
+
492
+        if ($Checked) {
493
+            // This is to skip the if statement in enable_requests.php
494
+            $Where[] = "(uer.`Outcome` IS NULL OR uer.`Outcome` IS NOT NULL)";
495
+        }
496
+
497
+        return $Where;
498
+    }
499
+}

+ 67
- 0
classes/autoload.php View File

@@ -0,0 +1,67 @@
1
+<?php
2
+declare(strict_types=1);
3
+
4
+/**
5
+ * Autoload
6
+ *
7
+ * Load classes automatically when they're needed.
8
+ * The Gazelle convention is classes/lowercase_name.class.php.
9
+ *
10
+ * @param string $ClassName class name
11
+ * @see https://www.php.net/manual/en/language.oop5.autoload.php
12
+ */
13
+spl_autoload_register(function ($ClassName) {
14
+    $FilePath = SERVER_ROOT . '/classes/' . strtolower($ClassName) . '.class.php';
15
+    #$FilePath = $_SERVER['DOCUMENT_ROOT'] . '/classes/' . strtolower($ClassName) . '.class.php';
16
+
17
+    if (!file_exists($FilePath)) {
18
+        // todo: Rename the following classes to conform with the code guidelines
19
+        switch ($ClassName) {
20
+        case 'MASS_USER_BOOKMARKS_EDITOR':
21
+          $FileName = 'mass_user_bookmarks_editor.class';
22
+          break;
23
+
24
+        case 'MASS_USER_TORRENTS_EDITOR':
25
+          $FileName = 'mass_user_torrents_editor.class';
26
+          break;
27
+
28
+        case 'MASS_USER_TORRENTS_TABLE_VIEW':
29
+          $FileName = 'mass_user_torrents_table_view.class';
30
+          break;
31
+
32
+        case 'TEXTAREA_PREVIEW':
33
+          $FileName = 'textarea_preview.class';
34
+          break;
35
+
36
+        case 'TORRENT':
37
+        case 'BENCODE_DICT':
38
+        case 'BENCODE_LIST':
39
+          $FileName = 'torrent.class';
40
+          break;
41
+
42
+        case 'RecursiveArrayObject':
43
+          $FileName = 'env.class';
44
+          break;
45
+
46
+        case 'Parsedown':
47
+          $FileName = 'vendor/Parsedown';
48
+          break;
49
+
50
+        case 'ParsedownExtra':
51
+          $FileName = 'vendor/ParsedownExtra';
52
+          break;
53
+
54
+        case 'TwitterAPIExchange':
55
+          $FileName = 'vendor/TwitterAPIExchange';
56
+          break;
57
+
58
+        default:
59
+          error("Couldn't import class $ClassName");
60
+    }
61
+
62
+        $FilePath = SERVER_ROOT . "/classes/$FileName.php";
63
+        #$FilePath = $_SERVER['DOCUMENT_ROOT'] . "/classes/$FileName.php";
64
+    }
65
+
66
+    require_once $FilePath;
67
+});

+ 163
- 0
classes/badges.class.php View File

@@ -0,0 +1,163 @@
1
+<?php
2
+declare(strict_types = 1);
3
+
4
+class Badges
5
+{
6
+    /**
7
+     * Given a UserID, returns that user's badges
8
+     *
9
+     * @param int $UserID
10
+     * @return array of BadgeIDs
11
+     */
12
+    public static function get_badges($UserID)
13
+    {
14
+        return Users::user_info($UserID)['Badges'];
15
+    }
16
+
17
+
18
+    /**
19
+     * Awards UserID the given BadgeID
20
+     *
21
+     * @param int $UserID
22
+     * @param int $BadgeID
23
+     * @return bool success?
24
+     */
25
+    public static function award_badge($UserID, $BadgeID)
26
+    {
27
+        if (self::has_badge($UserID, $BadgeID)) {
28
+            return false;
29
+        } else {
30
+            $QueryID = G::$DB->get_query_id();
31
+            G::$DB->query("
32
+            INSERT INTO `users_badges`(`UserID`, `BadgeID`)
33
+            VALUES($UserID, $BadgeID)
34
+            ");
35
+
36
+            G::$DB->set_query_id($QueryID);
37
+            G::$Cache->delete_value("user_info_$UserID");
38
+            return true;
39
+        }
40
+    }
41
+
42
+
43
+    /**
44
+     * Given a UserID, return that user's displayed badges
45
+     *
46
+     * @param int $UserID
47
+     * @return array of BadgeIDs
48
+     */
49
+    public static function get_displayed_badges($UserID)
50
+    {
51
+        $Result = [];
52
+        $Badges = self::get_badges($UserID);
53
+
54
+        foreach ($Badges as $Badge => $Displayed) {
55
+            if ($Displayed) {
56
+                $Result[] = $Badge;
57
+            }
58
+        }
59
+        return $Result;
60
+    }
61
+
62
+
63
+    /**
64
+     * Returns true if the given user owns the given badge
65
+     *
66
+     * @param int $UserID
67
+     * @param int $BadgeID
68
+     * @return bool
69
+     */
70
+    public static function has_badge($UserID, $BadgeID)
71
+    {
72
+        $Badges = self::get_badges($UserID);
73
+        return (array_key_exists($BadgeID, $Badges)) ?: false;
74
+    }
75
+
76
+
77
+    /**
78
+     * Creates HTML for displaying a badge.
79
+     *
80
+     * @param int $BadgeID
81
+     * @param bool $Tooltip Should HTML contain a tooltip?
82
+     * @return string HTML
83
+     */
84
+    public static function display_badge($BadgeID, $Tooltip = false)
85
+    {
86
+        $html = '';
87
+        if (($Badges = G::$Cache->get_value('badges')) && array_key_exists($BadgeID, $Badges)) {
88
+            extract($Badges[$BadgeID]);
89
+        } else {
90
+            self::update_badge_cache();
91
+            if (($Badges = G::$Cache->get_value('badges')) && array_key_exists($BadgeID, $Badges)) {
92
+                extract($Badges[$BadgeID]);
93
+            } else {
94
+                global $Debug;
95
+                $Debug->analysis("Invalid BadgeID $BadgeID requested.");
96
+            }
97
+        }
98
+
99
+        if ($Tooltip) {
100
+            $html .= "<a class='badge_icon'><img class='badge tooltip' alt='$Name' title='$Name: $Description' src='$Icon' /></a>";
101
+        } else {
102
+            $html .= "<a class='badge_icon'><img class='badge' alt='$Name' title='$Name' src='$Icon' /></a>";
103
+        }
104
+
105
+        return $html;
106
+    }
107
+
108
+
109
+    /**
110
+     * display_badges()
111
+     */
112
+    public static function display_badges($BadgeIDs, $Tooltip = false)
113
+    {
114
+        $html = '';
115
+        foreach ($BadgeIDs as $BadgeID) {
116
+            $html .= self::display_badge($BadgeID, $Tooltip);
117
+        }
118
+        return $html;
119
+    }
120
+
121
+
122
+    /**
123
+     * update_badge_cache()
124
+     */
125
+    private static function update_badge_cache()
126
+    {
127
+        $QueryID = G::$DB->get_query_id();
128
+
129
+        G::$DB->query("
130
+        SELECT
131
+          `ID`,
132
+          `Icon`,
133
+          `Name`,
134
+          `Description`
135
+        FROM
136
+          `badges`
137
+        ");
138
+
139
+        $badges = [];
140
+        if (G::$DB->has_results()) {
141
+            while (list($id, $icon, $name, $description) = G::$DB->next_record()) {
142
+                $badges[$id] = array('Icon' => $icon, 'Name' => $name, 'Description' => $description);
143
+            }
144
+            G::$Cache->cache_value('badges', $badges);
145
+        }
146
+
147
+        G::$DB->set_query_id($QueryID);
148
+    }
149
+
150
+
151
+    /**
152
+     * get_all_badges()
153
+     */
154
+    public static function get_all_badges()
155
+    {
156
+        if (($Badges = G::$Cache->get_value('badges'))) {
157
+            return $Badges;
158
+        } else {
159
+            self::update_badge_cache();
160
+            return G::$Cache->get_value('badges');
161
+        }
162
+    }
163
+}

+ 111
- 0
classes/bencode.class.php View File

@@ -0,0 +1,111 @@
1
+<?php
2
+
3
+/**
4
+ * If we're running a 32bit PHP version, we use small objects to store ints.
5
+ * Overhead from the function calls is small enough to not worry about
6
+ */
7
+class Int64
8
+{
9
+    private $Num;
10
+
11
+    public function __construct($Val)
12
+    {
13
+        $this->Num = $Val;
14
+    }
15
+
16
+    public static function make($Val)
17
+    {
18
+        return PHP_INT_SIZE === 4 ? new Int64($Val) : (int)$Val;
19
+    }
20
+
21
+    public static function get($Val)
22
+    {
23
+        return PHP_INT_SIZE === 4 ? $Val->Num : $Val;
24
+    }
25
+
26
+    public static function is_int($Val)
27
+    {
28
+        return is_int($Val) || (is_object($Val) && get_class($Val) === 'Int64');
29
+    }
30
+}
31
+
32
+/**
33
+ * The encode class is simple and straightforward. The only thing to
34
+ * note is that empty dictionaries are represented by boolean trues
35
+ */
36
+class Bencode
37
+{
38
+    private $DefaultKeys = array( // Get rid of everything except these keys to save some space
39
+      'created by', 'creation date', 'encoding', 'info', 'comment');
40
+    private $Data;
41
+    public $Enc;
42
+
43
+    /**
44
+     * Encode an arbitrary array (usually one that's just been decoded)
45
+     *
46
+     * @param array $Arg the thing to encode
47
+     * @param mixed $Keys string or array with keys in the input array to encode or true to encode everything
48
+     * @return bencoded string representing the content of the input array
49
+     */
50
+    public function encode($Arg = false, $Keys = false)
51
+    {
52
+        if ($Arg === false) {
53
+            $Data =& $this->Dec;
54
+        } else {
55
+            $Data =& $Arg;
56
+        }
57
+
58
+        if ($Keys === true) {
59
+            $this->Data = $Data;
60
+        } elseif ($Keys === false) {
61
+            $this->Data = array_intersect_key($Data, array_flip($this->DefaultKeys));
62
+        } elseif (is_array($Keys)) {
63
+            $this->Data = array_intersect_key($Data, array_flip($Keys));
64
+        } else {
65
+            $this->Data = isset($Data[$Keys]) ? $Data[$Keys] : false;
66
+        }
67
+
68
+        if (!$this->Data) {
69
+            return false;
70
+        }
71
+
72
+        $this->Enc = $this->_benc();
73
+        return $this->Enc;
74
+    }
75
+
76
+    /**
77
+     * Internal encoding function that does the actual job
78
+     *
79
+     * @return bencoded string
80
+     */
81
+    private function _benc()
82
+    {
83
+        if (!is_array($this->Data)) {
84
+            if (Int64::is_int($this->Data)) { // Integer
85
+                return 'i'.Int64::get($this->Data).'e';
86
+            }
87
+
88
+            if ($this->Data === true) { // Empty dictionary
89
+                return 'de';
90
+            }
91
+            return strlen($this->Data).':'.$this->Data; // String
92
+        }
93
+
94
+        if (empty($this->Data) || Int64::is_int(key($this->Data))) {
95
+            $IsDict = false;
96
+        } else {
97
+            $IsDict = true;
98
+            ksort($this->Data); // Dictionaries must be sorted
99
+        }
100
+        
101
+        $Ret = $IsDict ? 'd' : 'l';
102
+        foreach ($this->Data as $Key => $Value) {
103
+            if ($IsDict) {
104
+                $Ret .= strlen($Key).':'.$Key;
105
+            }
106
+            $this->Data = $Value;
107
+            $Ret .= $this->_benc();
108
+        }
109
+        return $Ret.'e';
110
+    }
111
+}

+ 208
- 0
classes/bencodedecode.class.php View File

@@ -0,0 +1,208 @@
1
+<?php
2
+
3
+/**
4
+ * The decode class is simple and straightforward. The only thing to
5
+ * note is that empty dictionaries are represented by boolean trues
6
+ */
7
+class BencodeDecode extends Bencode
8
+{
9
+    private $Data;
10
+    private $Length;
11
+    private $Pos = 0;
12
+    public $Dec = [];
13
+    public $ExitOnError = true;
14
+    const SnipLength = 40;
15
+
16
+    /**
17
+     * Decode prepararations
18
+     *
19
+     * @param string $Arg bencoded string or path to bencoded file to decode
20
+     * @param bool $IsPath needs to be true if $Arg is a path
21
+     * @return decoded data with a suitable structure
22
+     */
23
+    public function __construct($Arg = false, $IsPath = false, $Strict = true)
24
+    {
25
+        if (!$Strict) {
26
+            $this->ExitOnError = false;
27
+        }
28
+
29
+        if ($Arg === false) {
30
+            if (empty($this->Enc)) {
31
+                return false;
32
+            }
33
+        } else {
34
+            if ($IsPath === true) {
35
+                return $this->bdec_file($Arg);
36
+            }
37
+            $this->Data = $Arg;
38
+        }
39
+        return $this->decode();
40
+    }
41
+
42
+    /**
43
+     * Decodes a bencoded file
44
+     *
45
+     * @param $Path path to bencoded file to decode
46
+     * @return decoded data with a suitable structure
47
+     */
48
+    public function bdec_file($Path = false)
49
+    {
50
+        if (empty($Path)) {
51
+            return false;
52
+        }
53
+
54
+        if (!$this->Data = @file_get_contents($Path, FILE_BINARY)) {
55
+            return $this->error("Error: file '$Path' could not be opened.\n");
56
+        }
57
+        return $this->decode();
58
+    }
59
+
60
+    /**
61
+     * Decodes a string with bencoded data
62
+     *
63
+     * @param mixed $Arg bencoded data or false to decode the content of $this->Data
64
+     * @return decoded data with a suitable structure
65
+     */
66
+    public function decode($Arg = false)
67
+    {
68
+        if ($Arg !== false) {
69
+            $this->Data = $Arg;
70
+        } elseif (!$this->Data) {
71
+            $this->Data = $this->Enc;
72
+        }
73
+
74
+        if (!$this->Data) {
75
+            return false;
76
+        }
77
+
78
+        $this->Length = strlen($this->Data);
79
+        $this->Pos = 0;
80
+        $this->Dec = $this->_bdec();
81
+
82
+        if ($this->Pos < $this->Length) {
83
+            // Not really necessary, but if the torrent is invalid, it's better to warn than to silently truncate it
84
+            return $this->error();
85
+        }
86
+        return $this->Dec;
87
+    }
88
+
89
+    /**
90
+     * Internal decoding function that does the actual job
91
+     *
92
+     * @return decoded data with a suitable structure
93
+     */
94
+    private function _bdec()
95
+    {
96
+        switch ($this->Data[$this->Pos]) {
97
+
98
+      case 'i':
99
+        $this->Pos++;
100
+        $Value = substr($this->Data, $this->Pos, strpos($this->Data, 'e', $this->Pos) - $this->Pos);
101
+        if (!ctype_digit($Value) && !($Value[0] == '-' && ctype_digit(substr($Value, 1)))) {
102
+            return $this->error();
103
+        }
104
+        $this->Pos += strlen($Value) + 1;
105
+        return Int64::make($Value);
106
+
107
+      case 'l':
108
+        $Value = [];
109
+        $this->Pos++;
110
+        while ($this->Data[$this->Pos] != 'e') {
111
+            if ($this->Pos >= $this->Length) {
112
+                return $this->error();
113
+            }
114
+            $Value[] = $this->_bdec();
115
+        }
116
+        $this->Pos++;
117
+        return $Value;
118
+
119
+      case 'd':
120
+        $Value = [];
121
+        $this->Pos++;
122
+        while ($this->Data[$this->Pos] != 'e') {
123
+            $Length = substr($this->Data, $this->Pos, strpos($this->Data, ':', $this->Pos) - $this->Pos);
124
+            if (!ctype_digit($Length)) {
125
+                return $this->error();
126
+            }
127
+            $this->Pos += strlen($Length) + $Length + 1;
128
+            $Key = substr($this->Data, $this->Pos - $Length, $Length);
129
+            if ($this->Pos >= $this->Length) {
130
+                return $this->error();
131
+            }
132
+            $Value[$Key] = $this->_bdec();
133
+        }
134
+        $this->Pos++;
135
+        // Use boolean true to keep track of empty dictionaries
136
+        return empty($Value) ? true : $Value;
137
+
138
+      default:
139
+        $Length = substr($this->Data, $this->Pos, strpos($this->Data, ':', $this->Pos) - $this->Pos);
140
+        if (!ctype_digit($Length)) {
141
+            return $this->error(); // Even if the string is likely to be decoded correctly without this check, it's malformed
142
+        }
143
+        $this->Pos += strlen($Length) + $Length + 1;
144
+        return substr($this->Data, $this->Pos - $Length, $Length);
145
+    }
146
+    }
147
+
148
+    /**
149
+     * Convert everything to the correct data types and optionally escape strings
150
+     *
151
+     * @param bool $Escape whether to escape the textual data
152
+     * @param mixed $Data decoded data or false to use the $Dec property
153
+     * @return decoded data with more useful data types
154
+     */
155
+    public function dump($Escape = true, $Data = false)
156
+    {
157
+        if ($Data === false) {
158
+            $Data = $this->Dec;
159
+        }
160
+
161
+        if (Int64::is_int($Data)) {
162
+            return Int64::get($Data);
163
+        }
164
+
165
+        if (is_bool($Data)) {
166
+            return [];
167
+        }
168
+
169
+        if (is_array($Data)) {
170
+            $Output = [];
171
+            foreach ($Data as $Key => $Val) {
172
+                $Output[$Key] = $this->dump($Escape, $Val);
173
+            }
174
+            return $Output;
175
+        }
176
+        return $Escape ? htmlentities($Data) : $Data;
177
+    }
178
+
179
+    /**
180
+     * Display an error and halt the operation unless the $ExitOnError property is false
181
+     *
182
+     * @param string $ErrMsg the error message to display
183
+     */
184
+    private function error($ErrMsg = false)
185
+    {
186
+        static $ErrorPos;
187
+        if ($this->Pos === $ErrorPos) {
188
+            // The recursive nature of the class requires this to avoid duplicate error messages
189
+            return false;
190
+        }
191
+
192
+        if ($this->ExitOnError) {
193
+            if ($ErrMsg === false) {
194
+                printf(
195
+                    "Malformed string. Invalid character at pos 0x%X: %s\n",
196
+                    $this->Pos,
197
+                    str_replace(array("\r","\n"), array('',' '), htmlentities(substr($this->Data, $this->Pos, self::SnipLength)))
198
+                );
199
+            } else {
200
+                echo $ErrMsg;
201
+            }
202
+            exit();
203
+        }
204
+        
205
+        $ErrorPos = $this->Pos;
206
+        return false;
207
+    }
208
+}

+ 175
- 0
classes/bencodetorrent.class.php View File

@@ -0,0 +1,175 @@
1
+<?php
2
+
3
+# todo: Replace this with https://github.com/OPSnet/bencode-torrent
4
+
5
+/**
6
+ * Torrent class that contains some convenient functions related to torrent meta data
7
+ */
8
+class BencodeTorrent extends BencodeDecode
9
+{
10
+    private $PathKey = 'path';
11
+    public $Files = [];
12
+    public $Size = 0;
13
+
14
+    /**
15
+     * Create a list of the files in the torrent and their sizes as well as the total torrent size
16
+     *
17
+     * @return array with a list of files and file sizes
18
+     */
19
+    public function file_list()
20
+    {
21
+        if (empty($this->Dec)) {
22
+            return false;
23
+        }
24
+
25
+        $InfoDict =& $this->Dec['info'];
26
+        if (!isset($InfoDict['files'])) {
27
+            // Single-file torrent
28
+            $this->Size = (Int64::is_int($InfoDict['length'])
29
+        ? Int64::get($InfoDict['length'])
30
+        : $InfoDict['length']);
31
+            $Name = (isset($InfoDict['name.utf-8'])
32
+        ? $InfoDict['name.utf-8']
33
+        : $InfoDict['name']);
34
+            $this->Files[] = array($this->Size, $Name);
35
+        } else {
36
+            if (isset($InfoDict['path.utf-8']['files'][0])) {
37
+                $this->PathKey = 'path.utf-8';
38
+            }
39
+
40
+            foreach ($InfoDict['files'] as $File) {
41
+                $TmpPath = [];
42
+                foreach ($File[$this->PathKey] as $SubPath) {
43
+                    $TmpPath[] = $SubPath;
44
+                }
45
+                $CurSize = (Int64::is_int($File['length'])
46
+          ? Int64::get($File['length'])
47
+          : $File['length']);
48
+                $this->Files[] = array($CurSize, implode('/', $TmpPath));
49
+                $this->Size += $CurSize;
50
+            }
51
+            uasort($this->Files, function ($a, $b) {
52
+                return strnatcasecmp($a[1], $b[1]);
53
+            });
54
+        }
55
+        return array($this->Size, $this->Files);
56
+    }
57
+
58
+    /**
59
+     * Find out the name of the torrent
60
+     *
61
+     * @return string torrent name
62
+     */
63
+    public function get_name()
64
+    {
65
+        if (empty($this->Dec)) {
66
+            return false;
67
+        }
68
+        if (isset($this->Dec['info']['name.utf-8'])) {
69
+            return $this->Dec['info']['name.utf-8'];
70
+        }
71
+        return $this->Dec['info']['name'];
72
+    }
73
+
74
+    /**
75
+     * Find out the total size of the torrent
76
+     *
77
+     * @return string torrent size
78
+     */
79
+    public function get_size()
80
+    {
81
+        if (empty($this->Files)) {
82
+            if (empty($this->Dec)) {
83
+                return false;
84
+            }
85
+            $FileList = $this->file_list();
86
+        }
87
+        return $FileList[0];
88
+    }
89
+
90
+    /**
91
+     * Checks if the "private" flag is present in the torrent
92
+     *
93
+     * @return true if the "private" flag is set
94
+     */
95
+    public function is_private()
96
+    {
97
+        if (empty($this->Dec)) {
98
+            return false;
99
+        }
100
+        return isset($this->Dec['info']['private']) && Int64::get($this->Dec['info']['private']) == 1;
101
+    }
102
+    /**
103
+     * Add the "private" flag to the torrent
104
+     *
105
+     * @return true if a change was required
106
+     */
107
+    public function make_private()
108
+    {
109
+        if (empty($this->Dec)) {
110
+            return false;
111
+        }
112
+        if ($this->is_private()) {
113
+            return false;
114
+        }
115
+        $this->Dec['info']['private'] = Int64::make(1);
116
+        ksort($this->Dec['info']);
117
+        return true;
118
+    }
119
+
120
+    /**
121
+     * Add the "source" field to the torrent
122
+     *
123
+     * @return true if a change was required
124
+     */
125
+    public function make_sourced()
126
+    {
127
+        $Sources = Users::get_upload_sources();
128
+        if (empty($this->Dec)) {
129
+            return false;
130
+        }
131
+        if (isset($this->Dec['info']['source']) && ($this->Dec['info']['source'] == $Sources[0] || $this->Dec['info']['source'] == $Sources[1])) {
132
+            return false;
133
+        }
134
+        $this->Dec['info']['source'] = $Sources[0];
135
+        ksort($this->Dec['info']);
136
+        return true;
137
+    }
138
+
139
+    /**
140
+     * Calculate the torrent's info hash
141
+     *
142
+     * @return info hash in hexadecimal form
143
+     */
144
+    public function info_hash()
145
+    {
146
+        if (empty($this->Dec) || !isset($this->Dec['info'])) {
147
+            return false;
148
+        }
149
+        return sha1($this->encode(false, 'info'));
150
+    }
151
+
152
+    /**
153
+     * Add the announce URL to a torrent
154
+     */
155
+    public static function add_announce_url($Data, $Url)
156
+    {
157
+        return 'd8:announce'.strlen($Url).':'.$Url.substr($Data, 1);
158
+    }
159
+
160
+    /**
161
+     * Add list of announce URLs to a torrent
162
+     */
163
+    public static function add_announce_list($Data, $Urls)
164
+    {
165
+        $r = 'd13:announce-listl';
166
+        for ($i = 0; $i < count($Urls); $i++) {
167
+            $r .= 'l';
168
+            for ($j = 0; $j < count($Urls[$i]); $j++) {
169
+                $r .= strlen($Urls[$i][$j]).':'.$Urls[$i][$j];
170
+            }
171
+            $r .= 'e';
172
+        }
173
+        return $r.'e'.substr($Data, 1);
174
+    }
175
+}

+ 39
- 0
classes/bitcoinrpc.class.php View File

@@ -0,0 +1,39 @@
1
+<?php
2
+
3
+class BitcoinRpc
4
+{
5
+    public static function __callStatic($Method, $Args)
6
+    {
7
+        if (!defined('BITCOIN_RPC_URL')) {
8
+            return false;
9
+        }
10
+
11
+        $MessageID = mt_rand();
12
+        $Params = json_encode(
13
+            array(
14
+                'method' => $Method,
15
+                'params' => $Args,
16
+                'id' => $MessageID
17
+            )
18
+        );
19
+
20
+        $Request = array(
21
+            'http' => array(
22
+                'method' => 'POST',
23
+                'header' => 'Content-type: application/json',
24
+                'content' => $Params
25
+            )
26
+        );
27
+
28
+        if (!$Response = file_get_contents(BITCOIN_RPC_URL, false, stream_context_create($Request))) {
29
+            return false;
30
+        }
31
+
32
+        $Response = json_decode($Response);
33
+        if ($Response->id != $MessageID || !empty($Response->error) || empty($Response->result)) {
34
+            return false;
35
+        }
36
+        
37
+        return $Response->result;
38
+    }
39
+}

+ 110
- 0
classes/bookmarks.class.php View File

@@ -0,0 +1,110 @@
1
+<?php
2
+
3
+class Bookmarks
4
+{
5
+    /**
6
+     * Check if can bookmark
7
+     *
8
+     * @param string $Type
9
+     * @return boolean
10
+     */
11
+    public static function can_bookmark($Type)
12
+    {
13
+        return in_array($Type, array(
14
+            'torrent',
15
+            'artist',
16
+            'collage',
17
+            'request'
18
+        ));
19
+    }
20
+
21
+    /**
22
+     * Get the bookmark schema.
23
+     * Recommended usage:
24
+     * list($Table, $Col) = bookmark_schema('torrent');
25
+     *
26
+     * @param string $Type the type to get the schema for
27
+     */
28
+    public static function bookmark_schema($Type)
29
+    {
30
+        switch ($Type) {
31
+          case 'torrent':
32
+              return array(
33
+                  'bookmarks_torrents',
34
+                  'GroupID'
35
+              );
36
+              break;
37
+
38
+          case 'artist':
39
+              return array(
40
+                  'bookmarks_artists',
41
+                  'ArtistID'
42
+              );
43
+              break;
44
+
45
+          case 'collage':
46
+              return array(
47
+                  'bookmarks_collages',
48
+                  'CollageID'
49
+               );
50
+              break;
51
+
52
+          case 'request':
53
+              return array(
54
+                  'bookmarks_requests',
55
+                  'RequestID'
56
+              );
57
+              break;
58
+
59
+          default:
60
+              error('h4x');
61
+        }
62
+    }
63
+
64
+    /**
65
+     * Check if something is bookmarked
66
+     *
67
+     * @param string $Type
68
+     *          type of bookmarks to check
69
+     * @param int $ID
70
+     *          bookmark's id
71
+     * @return boolean
72
+     */
73
+    public static function has_bookmarked($Type, $ID)
74
+    {
75
+        return in_array($ID, self::all_bookmarks($Type));
76
+    }
77
+
78
+    /**
79
+     * Fetch all bookmarks of a certain type for a user.
80
+     * If UserID is false than defaults to G::$LoggedUser['ID']
81
+     *
82
+     * @param string $Type
83
+     *          type of bookmarks to fetch
84
+     * @param int $UserID
85
+     *          userid whose bookmarks to get
86
+     * @return array the bookmarks
87
+     */
88
+    public static function all_bookmarks($Type, $UserID = false)
89
+    {
90
+        if ($UserID === false) {
91
+            $UserID = G::$LoggedUser['ID'];
92
+        }
93
+
94
+        $CacheKey = "bookmarks_$Type".'_'.$UserID;
95
+        if (($Bookmarks = G::$Cache->get_value($CacheKey)) === false) {
96
+            list($Table, $Col) = self::bookmark_schema($Type);
97
+            $QueryID = G::$DB->get_query_id();
98
+
99
+            G::$DB->query("
100
+            SELECT `$Col`
101
+            FROM `$Table`
102
+              WHERE UserID = '$UserID'");
103
+
104
+            $Bookmarks = G::$DB->collect($Col);
105
+            G::$DB->set_query_id($QueryID);
106
+            G::$Cache->cache_value($CacheKey, $Bookmarks, 0);
107
+        }
108
+        return $Bookmarks;
109
+    }
110
+}

+ 5
- 0
classes/browse.class.php View File

@@ -0,0 +1,5 @@
1
+<?php
2
+
3
+class Browse
4
+{
5
+}

+ 476
- 0
classes/cache.class.php View File

@@ -0,0 +1,476 @@
1
+<?php
2
+#declare(strict_types = 1);
3
+
4
+/*************************************************************************|
5
+|--------------- Caching class -------------------------------------------|
6
+|*************************************************************************|
7
+
8
+This class is a wrapper for the Memcache class, and it's been written in
9
+order to better handle the caching of full pages with bits of dynamic
10
+content that are different for every user.
11
+
12
+As this inherits memcache, all of the default memcache methods work -
13
+however, this class has page caching functions superior to those of
14
+memcache.
15
+
16
+Also, Memcache::get and Memcache::set have been wrapped by
17
+Cache::get_value and Cache::cache_value. get_value uses the same argument
18
+as get, but cache_value only takes the key, the value, and the duration
19
+(no zlib).
20
+
21
+// Unix sockets
22
+memcached -d -m 5120 -s /var/run/memcached.sock -a 0777 -t16 -C -u root
23
+
24
+// TCP bind
25
+memcached -d -m 8192 -l 10.10.0.1 -t8 -C
26
+
27
+|*************************************************************************/
28
+
29
+if (!extension_loaded('memcache') && !extension_loaded('memcached')) {
30
+    error('Memcache extension not loaded');
31
+}
32
+
33
+if (class_exists('Memcached')) {
34
+    class MemcacheCompat extends Memcached
35
+    {
36
+    }
37
+} else {
38
+    class MemcacheCompat extends Memcache
39
+    {
40
+    }
41
+}
42
+
43
+class Cache extends MemcacheCompat
44
+{
45
+    // Torrent Group cache version
46
+    const GROUP_VERSION = 5;
47
+
48
+    public $CacheHits = [];
49
+    public $MemcacheDBArray = [];
50
+    public $MemcacheDBKey = '';
51
+    protected $InTransaction = false;
52
+    public $Time = 0;
53
+    private $Servers = [];
54
+    private $PersistentKeys = [
55
+        'ajax_requests_*',
56
+        'query_lock_*',
57
+        'stats_*',
58
+        'top10tor_*',
59
+        'users_snatched_*',
60
+
61
+            // Cache-based features
62
+            'global_notification',
63
+            'notifications_one_reads_*',
64
+    ];
65
+    private $ClearedKeys = [];
66
+    public $CanClear = false;
67
+    public $InternalCache = true;
68
+
69
+    public function __construct($Servers)
70
+    {
71
+        if (is_subclass_of($this, 'Memcached')) {
72
+            parent::__construct();
73
+        }
74
+
75
+        $this->Servers = $Servers;
76
+        foreach ($Servers as $Server) {
77
+            if (is_subclass_of($this, 'Memcache')) {
78
+                $this->addServer($Server['host'], $Server['port'], true, $Server['buckets']);
79
+            } else {
80
+                $this->addServer(str_replace('unix://', '', $Server['host']), $Server['port'], $Server['buckets']);
81
+            }
82
+        }
83
+    }
84
+
85
+    //---------- Caching functions ----------//
86
+
87
+    // Allows us to set an expiration on otherwise perminantly cache'd values
88
+    // Useful for disabled users, locked threads, basically reducing ram usage
89
+    public function expire_value($Key, $Duration = 2592000)
90
+    {
91
+        $StartTime = microtime(true);
92
+        $this->set($Key, $this->get($Key), $Duration);
93
+        $this->Time += (microtime(true) - $StartTime) * 1000;
94
+    }
95
+
96
+    // Wrapper for Memcache::set, with the zlib option removed and default duration of 30 days
97
+    public function cache_value($Key, $Value, $Duration = 2592000)
98
+    {
99
+        $StartTime = microtime(true);
100
+        if (empty($Key)) {
101
+            trigger_error('Cache insert failed for empty key');
102
+        }
103
+
104
+        $SetParams = [$Key, $Value, 0, $Duration];
105
+        if (is_subclass_of($this, 'Memcached')) {
106
+            unset($SetParams[2]);
107
+        }
108
+
109
+        if (!$this->set(...$SetParams)) {
110
+            trigger_error("Cache insert failed for key $Key");
111
+        }
112
+
113
+        if ($this->InternalCache && array_key_exists($Key, $this->CacheHits)) {
114
+            $this->CacheHits[$Key] = $Value;
115
+        }
116
+        $this->Time += (microtime(true) - $StartTime) * 1000;
117
+    }
118
+
119
+    // Wrapper for Memcache::add, with the zlib option removed and default duration of 30 days
120
+    public function add_value($Key, $Value, $Duration = 2592000)
121
+    {
122
+        $StartTime = microtime(true);
123
+        $Added = $this->add($Key, $Value, 0, $Duration);
124
+        $this->Time += (microtime(true) - $StartTime) * 1000;
125
+        return $Added;
126
+    }
127
+
128
+    public function replace_value($Key, $Value, $Duration = 2592000)
129
+    {
130
+        $StartTime = microtime(true);
131
+        $ReplaceParams = [$Key, $Value, false, $Duration];
132
+
133
+        if (is_subclass_of($this, 'Memcached')) {
134
+            unset($ReplaceParams[2]);
135
+        }
136
+        $this->replace(...$ReplaceParams);
137
+
138
+        if ($this->InternalCache && array_key_exists($Key, $this->CacheHits)) {
139
+            $this->CacheHits[$Key] = $Value;
140
+        }
141
+        $this->Time += (microtime(true) - $StartTime) * 1000;
142
+    }
143
+
144
+    public function get_value($Key, $NoCache = false)
145
+    {
146
+        if (!$this->InternalCache) {
147
+            $NoCache = true;
148
+        }
149
+
150
+        $StartTime = microtime(true);
151
+        if (empty($Key)) {
152
+            trigger_error('Cache retrieval failed for empty key');
153
+        }
154
+
155
+        if (!empty($_GET['clearcache']) && $this->CanClear && !isset($this->ClearedKeys[$Key]) && !Misc::in_array_partial($Key, $this->PersistentKeys)) {
156
+            if ($_GET['clearcache'] === '1') {
157
+                // Because check_perms() isn't true until LoggedUser is pulled from the cache, we have to remove the entries loaded before the LoggedUser data
158
+                // Because of this, not user cache data will require a secondary pageload following the clearcache to update
159
+                if (count($this->CacheHits) > 0) {
160
+                    foreach (array_keys($this->CacheHits) as $HitKey) {
161
+                        if (!isset($this->ClearedKeys[$HitKey]) && !Misc::in_array_partial($HitKey, $this->PersistentKeys)) {
162
+                            $this->delete($HitKey);
163
+                            unset($this->CacheHits[$HitKey]);
164
+                            $this->ClearedKeys[$HitKey] = true;
165
+                        }
166
+                    }
167
+                }
168
+
169
+                $this->delete($Key);
170
+                $this->Time += (microtime(true) - $StartTime) * 1000;
171
+                return false;
172
+            } elseif ($_GET['clearcache'] === $Key) {
173
+                $this->delete($Key);
174
+                $this->Time += (microtime(true) - $StartTime) * 1000;
175
+                return false;
176
+            } elseif (substr($_GET['clearcache'], -1) === '*') {
177
+                $Prefix = substr($_GET['clearcache'], 0, -1);
178
+                if ($Prefix === '' || $Prefix === substr($Key, 0, strlen($Prefix))) {
179
+                    $this->delete($Key);
180
+                    $this->Time += (microtime(true) - $StartTime) * 1000;
181
+                    return false;
182
+                }
183
+            }
184
+            $this->ClearedKeys[$Key] = true;
185
+        }
186
+
187
+        // For cases like the forums, if a key is already loaded, grab the existing pointer
188
+        if (isset($this->CacheHits[$Key]) && !$NoCache) {
189
+            $this->Time += (microtime(true) - $StartTime) * 1000;
190
+            return $this->CacheHits[$Key];
191
+        }
192
+
193
+        $Return = $this->get($Key);
194
+        if ($Return !== false) {
195
+            $this->CacheHits[$Key] = $NoCache ? null : $Return;
196
+        }
197
+
198
+        $this->Time += (microtime(true) - $StartTime) * 1000;
199
+        return $Return;
200
+    }
201
+
202
+    // Wrapper for Memcache::delete. For a reason, see above.
203
+    public function delete_value($Key)
204
+    {
205
+        $StartTime = microtime(true);
206
+        if (empty($Key)) {
207
+            trigger_error('Cache deletion failed for empty key');
208
+        }
209
+
210
+        if (!$this->delete($Key)) {
211
+            //trigger_error("Cache delete failed for key $Key");
212
+        }
213
+
214
+        unset($this->CacheHits[$Key]);
215
+        $this->Time += (microtime(true) - $StartTime) * 1000;
216
+    }
217
+
218
+    public function increment_value($Key, $Value = 1)
219
+    {
220
+        $StartTime = microtime(true);
221
+        $NewVal = $this->increment($Key, $Value);
222
+
223
+        if (isset($this->CacheHits[$Key])) {
224
+            $this->CacheHits[$Key] = $NewVal;
225
+        }
226
+        $this->Time += (microtime(true) - $StartTime) * 1000;
227
+    }
228
+
229
+    public function decrement_value($Key, $Value = 1)
230
+    {
231
+        $StartTime = microtime(true);
232
+        $NewVal = $this->decrement($Key, $Value);
233
+
234
+        if (isset($this->CacheHits[$Key])) {
235
+            $this->CacheHits[$Key] = $NewVal;
236
+        }
237
+        $this->Time += (microtime(true) - $StartTime) * 1000;
238
+    }
239
+
240
+    //---------- memcachedb functions ----------//
241
+
242
+    public function begin_transaction($Key)
243
+    {
244
+        $Value = $this->get($Key);
245
+        if (!is_array($Value)) {
246
+            $this->InTransaction = false;
247
+            $this->MemcacheDBKey = [];
248
+            $this->MemcacheDBKey = '';
249
+            return false;
250
+        }
251
+
252
+        $this->MemcacheDBArray = $Value;
253
+        $this->MemcacheDBKey = $Key;
254
+        $this->InTransaction = true;
255
+        return true;
256
+    }
257
+
258
+    public function cancel_transaction()
259
+    {
260
+        $this->InTransaction = false;
261
+        $this->MemcacheDBKey = [];
262
+        $this->MemcacheDBKey = '';
263
+    }
264
+
265
+    public function commit_transaction($Time = 2592000)
266
+    {
267
+        if (!$this->InTransaction) {
268
+            return false;
269
+        }
270
+
271
+        $this->cache_value($this->MemcacheDBKey, $this->MemcacheDBArray, $Time);
272
+        $this->InTransaction = false;
273
+    }
274
+
275
+    // Updates multiple rows in an array
276
+    public function update_transaction($Rows, $Values)
277
+    {
278
+        if (!$this->InTransaction) {
279
+            return false;
280
+        }
281
+
282
+        $Array = $this->MemcacheDBArray;
283
+        if (is_array($Rows)) {
284
+            $i = 0;
285
+            $Keys = $Rows[0];
286
+            $Property = $Rows[1];
287
+            foreach ($Keys as $Row) {
288
+                $Array[$Row][$Property] = $Values[$i];
289
+                $i++;
290
+            }
291
+        } else {
292
+            $Array[$Rows] = $Values;
293
+        }
294
+        $this->MemcacheDBArray = $Array;
295
+    }
296
+
297
+    // Updates multiple values in a single row in an array
298
+    // $Values must be an associative array with key:value pairs like in the array we're updating
299
+    public function update_row($Row, $Values)
300
+    {
301
+        if (!$this->InTransaction) {
302
+            return false;
303
+        }
304
+
305
+        if ($Row === false) {
306
+            $UpdateArray = $this->MemcacheDBArray;
307
+        } else {
308
+            $UpdateArray = $this->MemcacheDBArray[$Row];
309
+        }
310
+
311
+        foreach ($Values as $Key => $Value) {
312
+            if (!array_key_exists($Key, $UpdateArray)) {
313
+                trigger_error('Bad transaction key ('.$Key.') for cache '.$this->MemcacheDBKey);
314
+            }
315
+
316
+            if ($Value === '+1') {
317
+                if (!is_number($UpdateArray[$Key])) {
318
+                    trigger_error('Tried to increment non-number ('.$Key.') for cache '.$this->MemcacheDBKey);
319
+                }
320
+                ++$UpdateArray[$Key]; // Increment value
321
+            } elseif ($Value === '-1') {
322
+                if (!is_number($UpdateArray[$Key])) {
323
+                    trigger_error('Tried to decrement non-number ('.$Key.') for cache '.$this->MemcacheDBKey);
324
+                }
325
+                --$UpdateArray[$Key]; // Decrement value
326
+            } else {
327
+                $UpdateArray[$Key] = $Value; // Otherwise, just alter value
328
+            }
329
+        }
330
+
331
+        if ($Row === false) {
332
+            $this->MemcacheDBArray = $UpdateArray;
333
+        } else {
334
+            $this->MemcacheDBArray[$Row] = $UpdateArray;
335
+        }
336
+    }
337
+
338
+    // Increments multiple values in a single row in an array
339
+    // $Values must be an associative array with key:value pairs like in the array we're updating
340
+    public function increment_row($Row, $Values)
341
+    {
342
+        if (!$this->InTransaction) {
343
+            return false;
344
+        }
345
+
346
+        if ($Row === false) {
347
+            $UpdateArray = $this->MemcacheDBArray;
348
+        } else {
349
+            $UpdateArray = $this->MemcacheDBArray[$Row];
350
+        }
351
+
352
+        foreach ($Values as $Key => $Value) {
353
+            if (!array_key_exists($Key, $UpdateArray)) {
354
+                trigger_error("Bad transaction key ($Key) for cache ".$this->MemcacheDBKey);
355
+            }
356
+
357
+            if (!is_number($Value)) {
358
+                trigger_error("Tried to increment with non-number ($Key) for cache ".$this->MemcacheDBKey);
359
+            }
360
+            $UpdateArray[$Key] += $Value; // Increment value
361
+        }
362
+
363
+        if ($Row === false) {
364
+            $this->MemcacheDBArray = $UpdateArray;
365
+        } else {
366
+            $this->MemcacheDBArray[$Row] = $UpdateArray;
367
+        }
368
+    }
369
+
370
+    // Insert a value at the beginning of the array
371
+    public function insert_front($Key, $Value)
372
+    {
373
+        if (!$this->InTransaction) {
374
+            return false;
375
+        }
376
+
377
+        if ($Key === '') {
378
+            array_unshift($this->MemcacheDBArray, $Value);
379
+        } else {
380
+            $this->MemcacheDBArray = array($Key=>$Value) + $this->MemcacheDBArray;
381
+        }
382
+    }
383
+
384
+    // Insert a value at the end of the array
385
+    public function insert_back($Key, $Value)
386
+    {
387
+        if (!$this->InTransaction) {
388
+            return false;
389
+        }
390
+
391
+        if ($Key === '') {
392
+            array_push($this->MemcacheDBArray, $Value);
393
+        } else {
394
+            $this->MemcacheDBArray = $this->MemcacheDBArray + array($Key=>$Value);
395
+        }
396
+    }
397
+
398
+    public function insert($Key, $Value)
399
+    {
400
+        if (!$this->InTransaction) {
401
+            return false;
402
+        }
403
+
404
+        if ($Key === '') {
405
+            $this->MemcacheDBArray[] = $Value;
406
+        } else {
407
+            $this->MemcacheDBArray[$Key] = $Value;
408
+        }
409
+    }
410
+
411
+    public function delete_row($Row)
412
+    {
413
+        if (!$this->InTransaction) {
414
+            return false;
415
+        }
416
+
417
+        if (!isset($this->MemcacheDBArray[$Row])) {
418
+            trigger_error("Tried to delete non-existent row ($Row) for cache ".$this->MemcacheDBKey);
419
+        }
420
+        unset($this->MemcacheDBArray[$Row]);
421
+    }
422
+
423
+    public function update($Key, $Rows, $Values, $Time = 2592000)
424
+    {
425
+        if (!$this->InTransaction) {
426
+            $this->begin_transaction($Key);
427
+            $this->update_transaction($Rows, $Values);
428
+            $this->commit_transaction($Time);
429
+        } else {
430
+            $this->update_transaction($Rows, $Values);
431
+        }
432
+    }
433
+
434
+    /**
435
+     * Tries to set a lock. Expiry time is one hour to avoid indefinite locks
436
+     *
437
+     * @param string $LockName name on the lock
438
+     * @return true if lock was acquired
439
+     */
440
+    public function get_query_lock($LockName)
441
+    {
442
+        return $this->add_value('query_lock_'.$LockName, 1, 3600);
443
+    }
444
+
445
+    /**
446
+     * Remove lock
447
+     *
448
+     * @param string $LockName name on the lock
449
+     */
450
+    public function clear_query_lock($LockName)
451
+    {
452
+        $this->delete_value('query_lock_'.$LockName);
453
+    }
454
+
455
+    /**
456
+     * Get cache server status
457
+     *
458
+     * @return array (host => int status, ...)
459
+     */
460
+    public function server_status()
461
+    {
462
+        $Status = [];
463
+        if (is_subclass_of($this, 'Memcached')) {
464
+            $MemcachedStats = $this->getStats();
465
+        }
466
+        
467
+        foreach ($this->Servers as $Server) {
468
+            if (is_subclass_of($this, 'Memcached')) {
469
+                $Status["$Server[host]:$Server[port]"] = gettype($MemcachedStats["$Server[host]:$Server[port]"]) === 'array' ? 1 : 0;
470
+            } else {
471
+                $Status["$Server[host]:$Server[port]"] = $this->getServerStatus($Server['host'], $Server['port']);
472
+            }
473
+        }
474
+        return $Status;
475
+    }
476
+}

+ 232
- 0
classes/charts.class.php View File

@@ -0,0 +1,232 @@
1
+<?php
2
+
3
+class GOOGLE_CHARTS
4
+{
5
+    protected $URL = 'https://chart.googleapis.com/chart';
6
+    protected $Labels = [];
7
+    protected $Data = [];
8
+    protected $Options = [];
9
+
10
+    public function __construct($Type, $Width, $Height, $Options)
11
+    {
12
+        if ($Width * $Height > 300000 || $Height > 1000 || $Width > 1000) {
13
+            trigger_error('Tried to make chart too large.');
14
+        }
15
+
16
+        $this->URL .= "?cht=$Type&amp;chs={$Width}x$Height";
17
+        $this->Options = $Options;
18
+    }
19
+
20
+    protected function encode($Number)
21
+    {
22
+        if ($Number === -1) {
23
+            return '__';
24
+        }
25
+
26
+        $CharKey = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.';
27
+        return $CharKey[floor($Number / 64)].$CharKey[floor($Number % 64)];
28
+    }
29
+
30
+    public function color($Colors)
31
+    {
32
+        $this->URL .= '&amp;chco='.$Colors;
33
+    }
34
+
35
+    public function lines($Thickness, $Solid = 1, $Blank = 0)
36
+    {
37
+        $this->URL .= "&amp;chls=$Thickness,$Solid,$Blank";
38
+    }
39
+
40
+    public function title($Title, $Color = '', $Size = '')
41
+    {
42
+        $this->URL .= '&amp;chtt='.str_replace(array(' ', "\n"), array('+', '|'), $Title);
43
+        if (!empty($Color)) {
44
+            $this->URL .= '&amp;chts='.$Color;
45
+        }
46
+
47
+        if (!empty($Size)) {
48
+            $this->URL .= ','.$Size;
49
+        }
50
+    }
51
+
52
+    public function legend($Items, $Placement = '')
53
+    {
54
+        $this->URL .= '&amp;chdl='.str_replace(' ', '+', implode('|', $Items));
55
+        if (!empty($Placement)) {
56
+            if (!in_array($Placement, array('b', 't', 'r', 'l', 'bv', 'tv'))) {
57
+                trigger_error('Invalid legend placement.');
58
+            }
59
+            $this->URL .= '&amp;chdlp='.$Placement;
60
+        }
61
+    }
62
+
63
+    public function add($Label, $Data)
64
+    {
65
+        if ($Label !== false) {
66
+            $this->Labels[] = $Label;
67
+        }
68
+        $this->Data[] = $Data;
69
+    }
70
+
71
+    public function grid_lines($SpacingX = 0, $SpacingY = -1, $Solid = 1, $Blank = 1)
72
+    {
73
+        // Can take 2 more parameters for offset, but we're not bothering with that right now
74
+        $this->URL .= "&amp;chg=$SpacingX,$SpacingY,$Solid,$Blank";
75
+    }
76
+
77
+    public function transparent()
78
+    {
79
+        $this->URL .= '&amp;chf=bg,s,FFFFFF00';
80
+    }
81
+
82
+
83
+    public function url()
84
+    {
85
+        return $this->URL;
86
+    }
87
+}
88
+
89
+class AREA_GRAPH extends GOOGLE_CHARTS
90
+{
91
+    public function __construct($Width, $Height, $Options = [])
92
+    {
93
+        parent::__construct('lc', $Width, $Height, $Options);
94
+    }
95
+
96
+    public function color($Color)
97
+    {
98
+        $this->URL .= '&amp;chco='.$Color.'&amp;chm=B,'.$Color.'50,0,0,0';
99
+    }
100
+
101
+    public function generate()
102
+    {
103
+        $Max = max($this->Data);
104
+        $Min = ((isset($this->Options['Break'])) ? $Min = min($this->Data) : 0);
105
+        $Data = [];
106
+
107
+        foreach ($this->Data as $Value) {
108
+            $Data[] = $this->encode((($Value - $Min) / ($Max - $Min)) * 4095);
109
+        }
110
+        $this->URL .= "&amp;chxt=y,x&amp;chxs=0,h&amp;chxl=1:|".implode('|', $this->Labels).'&amp;chxr=0,'.$Min.','.($Max - $Min).'&amp;chd=e:'.implode('', $Data);
111
+    }
112
+}
113
+
114
+class PIE_CHART extends GOOGLE_CHARTS
115
+{
116
+    public function __construct($Width, $Height, $Options = [])
117
+    {
118
+        $Type = ((isset($this->Options['3D'])) ? 'p3' : 'p');
119
+        parent::__construct($Type, $Width, $Height, $Options);
120
+    }
121
+
122
+    public function generate()
123
+    {
124
+        $Sum = array_sum($this->Data);
125
+        $Other = isset($this->Options['Other']);
126
+        $Sort = isset($this->Options['Sort']);
127
+        $LabelPercent = isset($this->Options['Percentage']);
128
+
129
+        if ($Sort && !empty($this->Labels)) {
130
+            array_multisort($this->Data, SORT_DESC, $this->Labels);
131
+        } elseif ($Sort) {
132
+            sort($this->Data);
133
+            $this->Data = array_reverse($this->Data);
134
+        }
135
+
136
+        $Data = [];
137
+        $Labels = $this->Labels;
138
+        $OtherPercentage = 0.00;
139
+        $OtherData = 0;
140
+
141
+        foreach ($this->Data as $Key => $Value) {
142
+            $ThisPercentage = number_format(($Value / $Sum) * 100, 2);
143
+            $ThisData = ($Value / $Sum) * 4095;
144
+
145
+            if ($Other && $ThisPercentage < 1) {
146
+                $OtherPercentage += $ThisPercentage;
147
+                $OtherData += $ThisData;
148
+                unset($Data[$Key]);
149
+                unset($Labels[$Key]);
150
+                continue;
151
+            }
152
+
153
+            if ($LabelPercent) {
154
+                $Labels[$Key] .= ' ('.$ThisPercentage.'%)';
155
+            }
156
+            $Data[] = $this->encode($ThisData);
157
+        }
158
+
159
+        if ($OtherPercentage > 0) {
160
+            $OtherLabel = 'Other';
161
+            if ($LabelPercent) {
162
+                $OtherLabel .= ' ('.$OtherPercentage.'%)';
163
+            }
164
+            $Labels[] = $OtherLabel;
165
+            $Data[] = $this->encode($OtherData);
166
+        }
167
+        $this->URL .= "&amp;chl=".implode('|', $Labels).'&amp;chd=e:'.implode('', $Data);
168
+    }
169
+}
170
+
171
+/*
172
+class LOG_BAR_GRAPH extends GOOGLE_CHARTS
173
+{
174
+    // todo: Finish
175
+    public function __construct($Base, $Width, $Height, $Options = [])
176
+    {
177
+        parent::__construct('lc', $Width, $Height, $Options);
178
+    }
179
+
180
+    public function color($Color)
181
+    {
182
+        $this->URL .= '&amp;chco='.$Color.'&amp;chm=B,'.$Color.'50,0,0,0';
183
+    }
184
+
185
+    public function generate()
186
+    {
187
+        $Max = max($this->Data);
188
+        $Min = ((isset($this->Options['Break'])) ? $Min = min($this->Data) : 0);
189
+        $Data = [];
190
+
191
+        foreach ($this->Data as $Value) {
192
+            $Data[] = $this->encode((($Value - $Min) / ($Max - $Min)) * 4095);
193
+        }
194
+        $this->URL .= "&amp;chxt=y,x&amp;chxs=0,h&amp;chxl=1:|".implode('|', $this->Labels).'&amp;chxr=0,'.$Min.','.($Max-$Min).'&amp;chd=e:'.implode('', $Data);
195
+    }
196
+}
197
+*/
198
+
199
+/*
200
+class POLL_GRAPH extends GOOGLE_CHARTS
201
+{
202
+    public function __construct()
203
+    {
204
+        $this->URL .= '?cht=bhg';
205
+    }
206
+
207
+    public function add($Label, $Data)
208
+    {
209
+        if ($Label !== false) {
210
+            $this->Labels[] = Format::cut_string($Label, 35);
211
+        }
212
+        $this->Data[] = $Data;
213
+    }
214
+
215
+    public function generate()
216
+    {
217
+        $Count = count($this->Data);
218
+        $Height = (30 * $Count) + 20;
219
+        $Max = max($this->Data);
220
+        $Sum = array_sum($this->Data);
221
+        $Increment = ($Max / $Sum) * 25; // * 100% / 4divisions
222
+        $Data = [];
223
+        $Labels = [];
224
+
225
+        foreach ($this->Data as $Key => $Value) {
226
+            $Data[] = $this->encode(($Value / $Max) * 4095);
227
+            $Labels[] = '@t'.str_replace(array(' ', ','), array('+', '\,'), $this->Labels[$Key]).',000000,1,'.round((($Key + 1) / $Count) - (12 / $Height), 2).':0,12';
228
+        }
229
+        $this->URL .= "&amp;chbh=25,0,5&amp;chs=214x$Height&amp;chl=0%|".round($Increment, 1)."%|".round($Increment * 2, 1)."%|".round($Increment * 3, 1)."%|".round($Increment * 4, 1)."%&amp;chm=".implode('|', $Labels).'&amp;chd=e:'.implode('', $Data);
230
+    }
231
+}
232
+*/

+ 78
- 0
classes/collages.class.php View File

@@ -0,0 +1,78 @@
1
+<?php
2
+
3
+class Collages
4
+{
5
+    public static function increase_subscriptions($CollageID)
6
+    {
7
+        $QueryID = G::$DB->get_query_id();
8
+        G::$DB->query("
9
+        UPDATE
10
+          `collages`
11
+        SET
12
+          `Subscribers` = `Subscribers` + 1
13
+        WHERE
14
+          `ID` = '$CollageID'
15
+        ");
16
+        G::$DB->set_query_id($QueryID);
17
+    }
18
+
19
+    public static function decrease_subscriptions($CollageID)
20
+    {
21
+        $QueryID = G::$DB->get_query_id();
22
+        G::$DB->query("
23
+        UPDATE
24
+          `collages`
25
+        SET
26
+          `Subscribers` = IF(
27
+            `Subscribers` < 1,
28
+            0,
29
+            `Subscribers` - 1
30
+          )
31
+        WHERE
32
+          `ID` = '$CollageID'
33
+        ");
34
+        G::$DB->set_query_id($QueryID);
35
+    }
36
+
37
+    public static function create_personal_collage()
38
+    {
39
+        G::$DB->query("
40
+        SELECT
41
+          COUNT(`ID`)
42
+        FROM
43
+          `collages`
44
+        WHERE
45
+          `UserID` = '".G::$LoggedUser['ID']."' AND `CategoryID` = '0' AND `Deleted` = '0'
46
+        ");
47
+        list($CollageCount) = G::$DB->next_record();
48
+
49
+        if ($CollageCount >= G::$LoggedUser['Permissions']['MaxCollages']) {
50
+            // todo: Fix this, the query was for COUNT(ID), so I highly doubt that this works... - Y
51
+            list($CollageID) = G::$DB->next_record();
52
+            header("Location: collage.php?id=$CollageID");
53
+            error();
54
+        }
55
+
56
+        $NameStr = db_string(G::$LoggedUser['Username']."'s personal collage".($CollageCount > 0 ? ' no. '.($CollageCount + 1) : ''));
57
+        $Description = db_string('Personal collage for '.G::$LoggedUser['Username'].'. The first 5 albums will appear on his or her [url='.site_url().'user.php?id= '.G::$LoggedUser['ID'].']profile[/url].');
58
+
59
+        G::$DB->query("
60
+        INSERT INTO `collages`(
61
+          `Name`,
62
+          `Description`,
63
+          `CategoryID`,
64
+          `UserID`
65
+        )
66
+        VALUES(
67
+          '$NameStr',
68
+          '$Description',
69
+          '0',
70
+          ".G::$LoggedUser['ID']."
71
+        )
72
+        ");
73
+          
74
+        $CollageID = G::$DB->inserted_id();
75
+        header("Location: collage.php?id=$CollageID");
76
+        error();
77
+    }
78
+}

+ 573
- 0
classes/comments.class.php View File

@@ -0,0 +1,573 @@
1
+<?php
2
+
3
+class Comments
4
+{
5
+    /*
6
+     * For all functions:
7
+     * $Page = 'artist', 'collages', 'requests' or 'torrents'
8
+     * $PageID = ArtistID, CollageID, RequestID or GroupID, respectively
9
+     */
10
+
11
+    /**
12
+     * Post a comment on an artist, request or torrent page.
13
+     * @param string $Page
14
+     * @param int $PageID
15
+     * @param string $Body
16
+     * @return int ID of the new comment
17
+     */
18
+    public static function post($Page, $PageID, $Body)
19
+    {
20
+        $QueryID = G::$DB->get_query_id();
21
+        G::$DB->query("
22
+        SELECT
23
+        CEIL(
24
+          (
25
+          SELECT
26
+            COUNT(`ID`) + 1
27
+          FROM
28
+            `comments`
29
+          WHERE
30
+            `Page` = '$Page' AND `PageID` = $PageID
31
+          ) / ".TORRENT_COMMENTS_PER_PAGE."
32
+        ) AS Pages
33
+        ");
34
+        list($Pages) = G::$DB->next_record();
35
+
36
+        G::$DB->query("
37
+        INSERT INTO `comments`(
38
+          `Page`,
39
+          `PageID`,
40
+          `AuthorID`,
41
+          `AddedTime`,
42
+          `Body`
43
+        )
44
+        VALUES(
45
+          '$Page',
46
+          $PageID,
47
+          ".G::$LoggedUser['ID'].",
48
+          NOW(), '".db_string($Body)."')
49
+        ");
50
+        $PostID = G::$DB->inserted_id();
51
+
52
+        $CatalogueID = floor((TORRENT_COMMENTS_PER_PAGE * $Pages - TORRENT_COMMENTS_PER_PAGE) / THREAD_CATALOGUE);
53
+        G::$Cache->delete_value($Page.'_comments_'.$PageID.'_catalogue_'.$CatalogueID);
54
+        G::$Cache->delete_value($Page.'_comments_'.$PageID);
55
+
56
+        Subscriptions::flush_subscriptions($Page, $PageID);
57
+        Subscriptions::quote_notify($Body, $PostID, $Page, $PageID);
58
+
59
+        G::$DB->set_query_id($QueryID);
60
+        return $PostID;
61
+    }
62
+
63
+    /**
64
+     * Edit a comment
65
+     * @param int $PostID
66
+     * @param string $NewBody
67
+     * @param bool $SendPM If true, send a PM to the author of the comment informing him about the edit
68
+     *
69
+     * todo: Move permission check out of here/remove hardcoded error(404)
70
+     */
71
+    public static function edit($PostID, $NewBody, $SendPM = false)
72
+    {
73
+        $QueryID = G::$DB->get_query_id();
74
+        G::$DB->query("
75
+        SELECT
76
+          `Body`,
77
+          `AuthorID`,
78
+          `Page`,
79
+          `PageID`,
80
+          `AddedTime`
81
+        FROM
82
+          `comments`
83
+        WHERE
84
+          `ID` = $PostID
85
+        ");
86
+
87
+        if (!G::$DB->has_results()) {
88
+            return false;
89
+        }
90
+        list($OldBody, $AuthorID, $Page, $PageID, $AddedTime) = G::$DB->next_record();
91
+
92
+        if (G::$LoggedUser['ID'] != $AuthorID && !check_perms('site_moderate_forums')) {
93
+            return false;
94
+        }
95
+
96
+        G::$DB->query("
97
+        SELECT
98
+        CEIL(
99
+          COUNT(`ID`) / ".TORRENT_COMMENTS_PER_PAGE."
100
+        ) AS Page
101
+        FROM
102
+          `comments`
103
+        WHERE
104
+          `Page` = '$Page' AND `PageID` = $PageID AND `ID` <= $PostID
105
+        ");
106
+        list($CommPage) = G::$DB->next_record();
107
+
108
+        // Perform the update
109
+        G::$DB->query("
110
+        UPDATE
111
+          `comments`
112
+        SET
113
+          `Body` = '".db_string($NewBody)."',
114
+          `EditedUserID` = ".G::$LoggedUser['ID'].",
115
+          `EditedTime` = NOW()
116
+        WHERE
117
+          `ID` = $PostID
118
+        ");
119
+
120
+        // Update the cache
121
+        $CatalogueID = floor((TORRENT_COMMENTS_PER_PAGE * $CommPage - TORRENT_COMMENTS_PER_PAGE) / THREAD_CATALOGUE);
122
+        G::$Cache->delete_value($Page . '_comments_' . $PageID . '_catalogue_' . $CatalogueID);
123
+
124
+        if ($Page === 'collages') {
125
+            // On collages, we also need to clear the collage key (collage_$CollageID), because it has the comments in it... (why??)
126
+            G::$Cache->delete_value("collage_$PageID");
127
+        }
128
+
129
+        G::$DB->query("
130
+        INSERT INTO `comments_edits`(
131
+          `Page`,
132
+          `PostID`,
133
+          `EditUser`,
134
+          `EditTime`,
135
+          `Body`
136
+        )
137
+        VALUES(
138
+          '$Page',
139
+          $PostID,
140
+          ".G::$LoggedUser['ID'].",
141
+          NOW(), '".db_string($OldBody)."')
142
+        ");
143
+        G::$DB->set_query_id($QueryID);
144
+
145
+        if ($SendPM && G::$LoggedUser['ID'] !== $AuthorID) {
146
+            // Send a PM to the user to notify them of the edit
147
+            $PMSubject = "Your comment #$PostID has been edited";
148
+            $PMurl = site_url()."comments.php?action=jump&postid=$PostID";
149
+            $ProfLink = '[url='.site_url().'user.php?id='.G::$LoggedUser['ID'].']'.G::$LoggedUser['Username'].'[/url]';
150
+            $PMBody = "One of your comments has been edited by $ProfLink: [url]{$PMurl}[/url]";
151
+            Misc::send_pm($AuthorID, 0, $PMSubject, $PMBody);
152
+        }
153
+
154
+        return true; // todo: This should reflect whether or not the update was actually successful, e.g., by checking G::$DB->affected_rows after the UPDATE query
155
+    }
156
+
157
+    /**
158
+     * Delete a comment
159
+     * @param int $PostID
160
+     */
161
+    public static function delete($PostID)
162
+    {
163
+        $QueryID = G::$DB->get_query_id();
164
+        // Get page, pageid
165
+        G::$DB->query("
166
+        SELECT
167
+          `Page`,
168
+          `PageID`
169
+        FROM
170
+          `comments`
171
+        WHERE
172
+          `ID` = $PostID
173
+        ");
174
+
175
+        if (!G::$DB->has_results()) {
176
+            // no such comment?
177
+            G::$DB->set_query_id($QueryID);
178
+            return false;
179
+        }
180
+        list($Page, $PageID) = G::$DB->next_record();
181
+
182
+        // Get number of pages
183
+        G::$DB->query("
184
+        SELECT
185
+        CEIL(
186
+          COUNT(`ID`) / ".TORRENT_COMMENTS_PER_PAGE."
187
+        ) AS Pages,
188
+        CEIL(
189
+          SUM(IF(`ID` <= $PostID, 1, 0)) / ".TORRENT_COMMENTS_PER_PAGE."
190
+        ) AS Page
191
+        FROM
192
+          `comments`
193
+        WHERE
194
+          `Page` = '$Page' AND `PageID` = $PageID
195
+        GROUP BY
196
+          `PageID`
197
+        ");
198
+
199
+        if (!G::$DB->has_results()) {
200
+            // The comment $PostID was probably not posted on $Page
201
+            G::$DB->set_query_id($QueryID);
202
+            return false;
203
+        }
204
+        list($CommPages, $CommPage) = G::$DB->next_record();
205
+
206
+        // $CommPages = number of pages in the thread
207
+        // $CommPage = which page the post is on
208
+        // These are set for cache clearing.
209
+        G::$DB->query("
210
+        DELETE
211
+        FROM
212
+          `comments`
213
+        WHERE
214
+          `ID` = $PostID
215
+        ");
216
+
217
+        G::$DB->query("
218
+        DELETE
219
+        FROM
220
+          `comments_edits`
221
+        WHERE
222
+          `Page` = '$Page' AND `PostID` = $PostID
223
+        ");
224
+
225
+        G::$DB->query("
226
+        DELETE
227
+        FROM
228
+          `users_notify_quoted`
229
+        WHERE
230
+          `Page` = '$Page' AND `PostID` = $PostID
231
+        ");
232
+
233
+        Subscriptions::flush_subscriptions($Page, $PageID);
234
+        Subscriptions::flush_quote_notifications($Page, $PageID);
235
+
236
+        // We need to clear all subsequential catalogues as they've all been bumped with the absence of this post
237
+        $ThisCatalogue = floor((TORRENT_COMMENTS_PER_PAGE * $CommPage - TORRENT_COMMENTS_PER_PAGE) / THREAD_CATALOGUE);
238
+        $LastCatalogue = floor((TORRENT_COMMENTS_PER_PAGE * $CommPages - TORRENT_COMMENTS_PER_PAGE) / THREAD_CATALOGUE);
239
+
240
+        for ($i = $ThisCatalogue; $i <= $LastCatalogue; ++$i) {
241
+            G::$Cache->delete_value($Page . '_comments_' . $PageID . '_catalogue_' . $i);
242
+        }
243
+
244
+        G::$Cache->delete_value($Page . '_comments_' . $PageID);
245
+        if ($Page === 'collages') {
246
+            // On collages, we also need to clear the collage key (collage_$CollageID), because it has the comments in it... (why??)
247
+            G::$Cache->delete_value("collage_$PageID");
248
+        }
249
+
250
+        G::$DB->set_query_id($QueryID);
251
+        return true;
252
+    }
253
+
254
+    /**
255
+     * Get the URL to a comment, already knowing the Page and PostID
256
+     * @param string $Page
257
+     * @param int $PageID
258
+     * @param int $PostID
259
+     * @return string|bool The URL to the comment or false on error
260
+     */
261
+    public static function get_url($Page, $PageID, $PostID = null)
262
+    {
263
+        $Post = (!empty($PostID) ? "&postid=$PostID#post$PostID" : '');
264
+        switch ($Page) {
265
+        case 'artist':
266
+            return "artist.php?id=$PageID$Post";
267
+
268
+        case 'collages':
269
+            return "collages.php?action=comments&collageid=$PageID$Post";
270
+
271
+        case 'requests':
272
+            return "requests.php?action=view&id=$PageID$Post";
273
+
274
+        case 'torrents':
275
+            return "torrents.php?id=$PageID$Post";
276
+
277
+        default:
278
+            return false;
279
+        }
280
+    }
281
+
282
+    /**
283
+     * Get the URL to a comment
284
+     * @param int $PostID
285
+     * @return string|bool The URL to the comment or false on error
286
+     */
287
+    public static function get_url_query($PostID)
288
+    {
289
+        $QueryID = G::$DB->get_query_id();
290
+        G::$DB->query("
291
+        SELECT
292
+          `Page`,
293
+          `PageID`
294
+        FROM
295
+          `comments`
296
+        WHERE
297
+          `ID` = $PostID
298
+        ");
299
+
300
+        if (!G::$DB->has_results()) {
301
+            error(404);
302
+        }
303
+
304
+        list($Page, $PageID) = G::$DB->next_record();
305
+        G::$DB->set_query_id($QueryID);
306
+
307
+        return self::get_url($Page, $PageID, $PostID);
308
+    }
309
+
310
+    /**
311
+     * Load a page's comments. This takes care of `postid` and (indirectly) `page` parameters passed in $_GET.
312
+     * Quote notifications and last read are also handled here, unless $HandleSubscriptions = false is passed.
313
+     * @param string $Page
314
+     * @param int $PageID
315
+     * @param bool $HandleSubscriptions Whether or not to handle subscriptions (last read & quote notifications)
316
+     * @return array ($NumComments, $Page, $Thread, $LastRead)
317
+     *     $NumComments: the total number of comments on this artist/request/torrent group
318
+     *     $Page: the page we're currently on
319
+     *     $Thread: an array of all posts on this page
320
+     *     $LastRead: ID of the last comment read by the current user in this thread;
321
+     *                will be false if $HandleSubscriptions == false or if there are no comments on this page
322
+     */
323
+    public static function load($Page, $PageID, $HandleSubscriptions = true)
324
+    {
325
+        $QueryID = G::$DB->get_query_id();
326
+
327
+        // Get the total number of comments
328
+        $NumComments = G::$Cache->get_value($Page . "_comments_$PageID");
329
+        if ($NumComments === false) {
330
+            G::$DB->query("
331
+            SELECT
332
+              COUNT(`ID`)
333
+            FROM
334
+              `comments`
335
+            WHERE
336
+              `Page` = '$Page' AND `PageID` = $PageID
337
+            ");
338
+            list($NumComments) = G::$DB->next_record();
339
+            G::$Cache->cache_value($Page."_comments_$PageID", $NumComments, 0);
340
+        }
341
+
342
+        // If a postid was passed, we need to determine which page that comment is on.
343
+        // Format::page_limit handles a potential $_GET['page']
344
+        if (isset($_GET['postid']) && is_number($_GET['postid']) && $NumComments > TORRENT_COMMENTS_PER_PAGE) {
345
+            G::$DB->query("
346
+            SELECT
347
+              COUNT(`ID`)
348
+            FROM
349
+              `comments`
350
+            WHERE
351
+              `Page` = '$Page' AND `PageID` = $PageID AND `ID` <= $_GET[postid]
352
+            ");
353
+
354
+            list($PostNum) = G::$DB->next_record();
355
+            list($CommPage, $Limit) = Format::page_limit(TORRENT_COMMENTS_PER_PAGE, $PostNum);
356
+        } else {
357
+            list($CommPage, $Limit) = Format::page_limit(TORRENT_COMMENTS_PER_PAGE, $NumComments);
358
+        }
359
+
360
+        // Get the cache catalogue
361
+        $CatalogueID = floor((TORRENT_COMMENTS_PER_PAGE * $CommPage - TORRENT_COMMENTS_PER_PAGE) / THREAD_CATALOGUE);
362
+
363
+        // Cache catalogue from which the page is selected, allows block caches and future ability to specify posts per page
364
+        $Catalogue = G::$Cache->get_value($Page.'_comments_'.$PageID.'_catalogue_'.$CatalogueID);
365
+        if ($Catalogue === false) {
366
+            $CatalogueLimit = $CatalogueID * THREAD_CATALOGUE . ', ' . THREAD_CATALOGUE;
367
+            G::$DB->query("
368
+            SELECT
369
+              c.`ID`, c.`AuthorID`,
370
+              c.`AddedTime`,
371
+              c.`Body`,
372
+              c.`EditedUserID`,
373
+              c.`EditedTime`,
374
+              u.`Username`
375
+            FROM
376
+              `comments` AS c
377
+            LEFT JOIN `users_main` AS u
378
+            ON
379
+              u.`ID` = c.`EditedUserID`
380
+            WHERE
381
+              c.`Page` = '$Page' AND c.`PageID` = $PageID
382
+            ORDER BY
383
+              c.`ID`
384
+            LIMIT $CatalogueLimit
385
+            ");
386
+
387
+            $Catalogue = G::$DB->to_array(false, MYSQLI_ASSOC);
388
+            G::$Cache->cache_value($Page.'_comments_'.$PageID.'_catalogue_'.$CatalogueID, $Catalogue, 0);
389
+        }
390
+
391
+        // This is a hybrid to reduce the catalogue down to the page elements: We use the page limit % catalogue
392
+        $Thread = array_slice($Catalogue, ((TORRENT_COMMENTS_PER_PAGE * $CommPage - TORRENT_COMMENTS_PER_PAGE) % THREAD_CATALOGUE), TORRENT_COMMENTS_PER_PAGE, true);
393
+
394
+        if ($HandleSubscriptions && count($Thread) > 0) {
395
+            // Quote notifications
396
+            $LastPost = end($Thread);
397
+            $LastPost = $LastPost['ID'];
398
+            $FirstPost = reset($Thread);
399
+            $FirstPost = $FirstPost['ID'];
400
+
401
+            G::$DB->query("
402
+            UPDATE
403
+              `users_notify_quoted`
404
+            SET
405
+              `UnRead` = FALSE
406
+            WHERE
407
+              `UserID` = ".G::$LoggedUser['ID']."
408
+              AND `Page` = '$Page'
409
+              AND `PageID` = $PageID
410
+              AND `PostID` >= $FirstPost
411
+              AND `PostID` <= $LastPost
412
+            ");
413
+
414
+            if (G::$DB->affected_rows()) {
415
+                G::$Cache->delete_value('notify_quoted_' . G::$LoggedUser['ID']);
416
+            }
417
+
418
+            // Last read
419
+            G::$DB->query("
420
+            SELECT
421
+              `PostID`
422
+            FROM
423
+              `users_comments_last_read`
424
+            WHERE
425
+              `UserID` = ".G::$LoggedUser['ID']."
426
+              AND `Page` = '$Page'
427
+              AND `PageID` = $PageID
428
+            ");
429
+
430
+            list($LastRead) = G::$DB->next_record();
431
+            if ($LastRead < $LastPost) {
432
+                G::$DB->query("
433
+                INSERT INTO `users_comments_last_read`(`UserID`, `Page`, `PageID`, `PostID`)
434
+                VALUES(
435
+                  ".G::$LoggedUser['ID'].",
436
+                  '$Page',
437
+                  $PageID,
438
+                  $LastPost
439
+                )
440
+                ON DUPLICATE KEY
441
+                UPDATE
442
+                  `PostID` = $LastPost
443
+                ");
444
+                G::$Cache->delete_value('subscriptions_user_new_' . G::$LoggedUser['ID']);
445
+            }
446
+        } else {
447
+            $LastRead = false;
448
+        }
449
+
450
+        G::$DB->set_query_id($QueryID);
451
+        return array($NumComments, $CommPage, $Thread, $LastRead);
452
+    }
453
+
454
+    /**
455
+     * Merges all comments from $Page/$PageID into $Page/$TargetPageID. This also takes care of quote notifications, subscriptions and cache.
456
+     * @param type $Page
457
+     * @param type $PageID
458
+     * @param type $TargetPageID
459
+     */
460
+    public static function merge($Page, $PageID, $TargetPageID)
461
+    {
462
+        $QueryID = G::$DB->get_query_id();
463
+
464
+        G::$DB->query("
465
+        UPDATE
466
+          `comments`
467
+        SET
468
+          `PageID` = $TargetPageID
469
+        WHERE
470
+          `Page` = '$Page' AND `PageID` = $PageID
471
+        ");
472
+
473
+        // Quote notifications
474
+        G::$DB->query("
475
+        UPDATE
476
+          `users_notify_quoted`
477
+        SET
478
+          `PageID` = $TargetPageID
479
+        WHERE
480
+          `Page` = '$Page' AND `PageID` = $PageID
481
+        ");
482
+
483
+        // Comment subscriptions
484
+        Subscriptions::move_subscriptions($Page, $PageID, $TargetPageID);
485
+
486
+        // Cache (we need to clear all comment catalogues)
487
+        G::$DB->query("
488
+        SELECT
489
+          CEIL(
490
+            COUNT(`ID`) / ".TORRENT_COMMENTS_PER_PAGE."
491
+          ) AS Pages
492
+        FROM
493
+          `comments`
494
+        WHERE
495
+          `Page` = '$Page' AND `PageID` = $TargetPageID
496
+        GROUP BY
497
+          `PageID`
498
+        ");
499
+
500
+        list($CommPages) = G::$DB->next_record();
501
+        $LastCatalogue = floor((TORRENT_COMMENTS_PER_PAGE * $CommPages - TORRENT_COMMENTS_PER_PAGE) / THREAD_CATALOGUE);
502
+        
503
+        for ($i = 0; $i <= $LastCatalogue; ++$i) {
504
+            G::$Cache->delete_value($Page . "_comments_$TargetPageID" . "_catalogue_$i");
505
+        }
506
+
507
+        G::$Cache->delete_value($Page . "_comments_$TargetPageID");
508
+        G::$DB->set_query_id($QueryID);
509
+    }
510
+
511
+    /**
512
+     * Delete all comments on $Page/$PageID (deals with quote notifications and subscriptions as well)
513
+     * @param string $Page
514
+     * @param int $PageID
515
+     * @return boolean
516
+     */
517
+    public static function delete_page($Page, $PageID)
518
+    {
519
+        $QueryID = G::$DB->get_query_id();
520
+
521
+        // get number of pages
522
+        G::$DB->query("
523
+        SELECT
524
+          CEIL(
525
+            COUNT(`ID`) / ".TORRENT_COMMENTS_PER_PAGE."
526
+          ) AS Pages
527
+        FROM
528
+          `comments`
529
+        WHERE
530
+          `Page` = '$Page' AND `PageID` = $PageID
531
+        GROUP BY
532
+          `PageID`
533
+        ");
534
+
535
+        if (!G::$DB->has_results()) {
536
+            return false;
537
+        }
538
+        list($CommPages) = G::$DB->next_record();
539
+
540
+        // Delete comments
541
+        G::$DB->query("
542
+        DELETE
543
+        FROM
544
+          `comments`
545
+        WHERE
546
+          `Page` = '$Page' AND `PageID` = $PageID
547
+        ");
548
+
549
+        // Delete quote notifications
550
+        Subscriptions::flush_quote_notifications($Page, $PageID);
551
+        G::$DB->query("
552
+        DELETE
553
+        FROM
554
+          `users_notify_quoted`
555
+        WHERE
556
+          `Page` = '$Page' AND `PageID` = $PageID
557
+        ");
558
+
559
+        // Deal with subscriptions
560
+        Subscriptions::move_subscriptions($Page, $PageID, null);
561
+
562
+        // Clear cache
563
+        $LastCatalogue = floor((TORRENT_COMMENTS_PER_PAGE * $CommPages - TORRENT_COMMENTS_PER_PAGE) / THREAD_CATALOGUE);
564
+        for ($i = 0; $i <= $LastCatalogue; ++$i) {
565
+            G::$Cache->delete_value($Page."_comments_$PageID"."_catalogue_$i");
566
+        }
567
+
568
+        G::$Cache->delete_value($Page."_comments_$PageID");
569
+        G::$DB->set_query_id($QueryID);
570
+
571
+        return true;
572
+    }
573
+}

+ 116
- 0
classes/commentsview.class.php View File

@@ -0,0 +1,116 @@
1
+<?php
2
+
3
+class CommentsView
4
+{
5
+    /**
6
+     * Render a thread of comments
7
+     * @param array $Thread An array as returned by Comments::load
8
+     * @param int $LastRead PostID of the last read post
9
+     * @param string $Baselink Link to the site these comments are on
10
+     */
11
+    public static function render_comments($Thread, $LastRead, $Baselink)
12
+    {
13
+        foreach ($Thread as $Post) {
14
+            list($PostID, $AuthorID, $AddedTime, $CommentBody, $EditedUserID, $EditedTime, $EditedUsername) = array_values($Post);
15
+            self::render_comment($AuthorID, $PostID, $CommentBody, $AddedTime, $EditedUserID, $EditedTime, $Baselink . "&amp;postid=$PostID#post$PostID", ($PostID > $LastRead));
16
+        }
17
+    }
18
+
19
+    /**
20
+     * Render one comment
21
+     * @param int $AuthorID
22
+     * @param int $PostID
23
+     * @param string $Body
24
+     * @param string $AddedTime
25
+     * @param int $EditedUserID
26
+     * @param string $EditedTime
27
+     * @param string $Link The link to the post elsewhere on the site
28
+     * @param string $Header The header used in the post
29
+     * @param bool $Tools Whether or not to show [Edit], [Report] etc.
30
+     *
31
+     * todo: Find a better way to pass the page (artist, collages, requests, torrents) to this function than extracting it from $Link
32
+     */
33
+    public static function render_comment($AuthorID, $PostID, $Body, $AddedTime, $EditedUserID, $EditedTime, $Link, $Unread = false, $Header = '', $Tools = true)
34
+    {
35
+        $UserInfo = Users::user_info($AuthorID);
36
+        $Header = Users::format_username($AuthorID, true, true, true, true, true) . time_diff($AddedTime) . $Header; ?>
37
+<table
38
+  class="forum_post box vertical_margin<?=(!Users::has_avatars_enabled() ? ' noavatar' : '') . ($Unread ? ' forum_unread' : '')?>"
39
+  id="post<?=$PostID?>">
40
+  <colgroup>
41
+    <?php if (Users::has_avatars_enabled()) { ?>
42
+    <col class="col_avatar" />
43
+    <?php } ?>
44
+    <col class="col_post_body" />
45
+  </colgroup>
46
+  <tr class="colhead_dark">
47
+    <td colspan="<?=(Users::has_avatars_enabled() ? 2 : 1)?>">
48
+      <div class="float_left"><a class="post_id"
49
+          href="<?=$Link?>">#<?=$PostID?></a>
50
+        <?=$Header?>
51
+        <?php if ($Tools) { ?>
52
+        - <a href="#quickpost"
53
+          onclick="Quote('<?=$PostID?>','<?=$UserInfo['Username']?>', true);"
54
+          class="brackets">Quote</a>
55
+        <?php if ($AuthorID == G::$LoggedUser['ID'] || check_perms('site_moderate_forums')) { ?>
56
+        - <a href="#post<?=$PostID?>"
57
+          onclick="Edit_Form('<?=$PostID?>','');"
58
+          class="brackets">Edit</a>
59
+        <?php }
60
+      if (check_perms('site_moderate_forums')) { ?>
61
+        - <a href="#post<?=$PostID?>"
62
+          onclick="Delete('<?=$PostID?>');"
63
+          class="brackets">Delete</a>
64
+        <?php } ?>
65
+      </div>
66
+      <div id="bar<?=$PostID?>" class="float_right">
67
+        <a href="reports.php?action=report&amp;type=comment&amp;id=<?=$PostID?>"
68
+          class="brackets">Report</a>
69
+        <?php
70
+      if (check_perms('users_warn') && $AuthorID != G::$LoggedUser['ID'] && G::$LoggedUser['Class'] >= $UserInfo['Class']) {
71
+          ?>
72
+        <form class="manage_form hidden" name="user"
73
+          id="warn<?=$PostID?>" action="comments.php" method="post">
74
+          <input type="hidden" name="action" value="warn" />
75
+          <input type="hidden" name="postid" value="<?=$PostID?>" />
76
+        </form>
77
+        - <a href="#"
78
+          onclick="$('#warn<?=$PostID?>').raw().submit(); return false;"
79
+          class="brackets">Warn</a>
80
+        <?php
81
+      } ?>
82
+        &nbsp;
83
+        <a href="#">&uarr;</a>
84
+        <?php } ?>
85
+      </div>
86
+    </td>
87
+  </tr>
88
+  <tr>
89
+    <?php if (Users::has_avatars_enabled()) { ?>
90
+    <td class="avatar" valign="top">
91
+      <?=Users::show_avatar($UserInfo['Avatar'], $AuthorID, $UserInfo['Username'], G::$LoggedUser['DisableAvatars'])?>
92
+    </td>
93
+    <?php } ?>
94
+    <td class="body" valign="top">
95
+      <div id="content<?=$PostID?>">
96
+        <?=Text::full_format($Body)?>
97
+        <?php if ($EditedUserID) { ?>
98
+        <br />
99
+        <br />
100
+        <div class="last_edited">
101
+          <?php if (check_perms('site_admin_forums')) { ?>
102
+          <a href="#content<?=$PostID?>"
103
+            onclick="LoadEdit('<?=substr($Link, 0, strcspn($Link, '.'))?>', <?=$PostID?>, 1); return false;">&laquo;</a>
104
+          <?php } ?>
105
+          Last edited by
106
+          <?=Users::format_username($EditedUserID, false, false, false) ?>
107
+          <?=time_diff($EditedTime, 2, true, true)?>
108
+          <?php } ?>
109
+        </div>
110
+      </div>
111
+    </td>
112
+  </tr>
113
+</table>
114
+<?php
115
+    }
116
+}

+ 1316
- 0
classes/config.template
File diff suppressed because it is too large
View File


+ 54
- 0
classes/cookie.class.php View File

@@ -0,0 +1,54 @@
1
+<?php
2
+
3
+/*************************************************************************|
4
+|--------------- Cookie class --------------------------------------------|
5
+|*************************************************************************|
6
+
7
+This class handles cookies.
8
+
9
+$Cookie->get(); is user provided and untrustworthy
10
+
11
+|*************************************************************************/
12
+
13
+/*
14
+interface COOKIE_INTERFACE {
15
+  public function get($Key);
16
+  public function set($Key, $Value, $Seconds, $LimitAccess);
17
+  public function del($Key);
18
+
19
+  public function flush();
20
+}
21
+*/
22
+
23
+class COOKIE /*implements COOKIE_INTERFACE*/
24
+{
25
+    const LIMIT_ACCESS = true; // If true, blocks JS cookie API access by default (can be overridden case by case)
26
+    const PREFIX = ''; // In some cases you may desire to prefix your cookies
27
+
28
+    public function get($Key)
29
+    {
30
+        if (!isset($_COOKIE[SELF::PREFIX.$Key])) {
31
+            return false;
32
+        }
33
+        return $_COOKIE[SELF::PREFIX.$Key];
34
+    }
35
+
36
+    // Pass the 4th optional param as false to allow JS access to the cookie
37
+    public function set($Key, $Value, $Seconds = 86400, $LimitAccess = SELF::LIMIT_ACCESS)
38
+    {
39
+        setcookie(SELF::PREFIX.$Key, $Value, time() + $Seconds, '/', SITE_DOMAIN, $_SERVER['SERVER_PORT'] === '443', $LimitAccess, false);
40
+    }
41
+
42
+    public function del($Key)
43
+    {
44
+        setcookie(SELF::PREFIX.$Key, '', time() - 24 * 3600); //3600 vs 1 second to account for potential clock desyncs
45
+    }
46
+
47
+    public function flush()
48
+    {
49
+        $Cookies = array_keys($_COOKIE);
50
+        foreach ($Cookies as $Cookie) {
51
+            $this->del($Cookie);
52
+        }
53
+    }
54
+}

+ 41
- 0
classes/crypto.class.php View File

@@ -0,0 +1,41 @@
1
+<?php
2
+declare(strict_types=1);
3
+
4
+class Crypto
5
+{
6
+    /**
7
+     * Encrypts input text for use in database
8
+     *
9
+     * @param string $plaintext
10
+     * @return encrypted string or false if DB key not accessible
11
+     */
12
+    public static function encrypt($plaintext)
13
+    {
14
+        if (apcu_exists('DBKEY')) {
15
+            $iv_size = openssl_cipher_iv_length('aes-256-cbc');
16
+            $iv = openssl_random_pseudo_bytes($iv_size);
17
+            $ret = base64_encode($iv.openssl_encrypt($plaintext, 'aes-256-cbc', apcu_fetch('DBKEY'), OPENSSL_RAW_DATA, $iv));
18
+            return $ret;
19
+        } else {
20
+            return false;
21
+        }
22
+    }
23
+
24
+    /**
25
+     * Decrypts input text from database
26
+     *
27
+     * @param string $ciphertext
28
+     * @return decrypted string string or false if DB key not accessible
29
+     */
30
+    public static function decrypt($ciphertext)
31
+    {
32
+        if (apcu_exists('DBKEY')) {
33
+            $iv_size = openssl_cipher_iv_length('aes-256-cbc');
34
+            $iv = substr(base64_decode($ciphertext), 0, $iv_size);
35
+            $ciphertext = substr(base64_decode($ciphertext), $iv_size);
36
+            return openssl_decrypt($ciphertext, 'aes-256-cbc', apcu_fetch('DBKEY'), OPENSSL_RAW_DATA, $iv);
37
+        } else {
38
+            return false;
39
+        }
40
+    }
41
+}

+ 827
- 0
classes/debug.class.php View File

@@ -0,0 +1,827 @@
1
+<?php
2
+
3
+// Debug info for developers
4
+ini_set('max_execution_time', 600);
5
+define('MAX_TIME', 20000); //Maximum execution time in ms
6
+define('MAX_ERRORS', 0); //Maxmimum errors, warnings, notices we will allow in a page
7
+define('MAX_MEMORY', 80 * 1024 * 1024); //Maximum memory used per pageload
8
+define('MAX_QUERIES', 30); //Maxmimum queries
9
+
10
+class DEBUG
11
+{
12
+    public $Errors = [];
13
+    public $Flags = [];
14
+    public $Perf = [];
15
+    private $LoggedVars = [];
16
+
17
+    public function profile($Automatic = '')
18
+    {
19
+        global $ScriptStartTime;
20
+        $Reason = [];
21
+
22
+        if (!empty($Automatic)) {
23
+            $Reason[] = $Automatic;
24
+        }
25
+
26
+        $Micro = (microtime(true) - $ScriptStartTime) * 1000;
27
+        if ($Micro > MAX_TIME && !defined('TIME_EXCEPTION')) {
28
+            $Reason[] = number_format($Micro, 3).' ms';
29
+        }
30
+
31
+        $Errors = count($this->get_errors());
32
+        if ($Errors > MAX_ERRORS && !defined('ERROR_EXCEPTION')) {
33
+            $Reason[] = $Errors.' PHP errors';
34
+        }
35
+
36
+        /*
37
+        $Queries = count($this->get_queries());
38
+        if ($Queries > MAX_QUERIES && !defined('QUERY_EXCEPTION')) {
39
+          $Reason[] = $Queries.' Queries';
40
+        }
41
+        */
42
+
43
+        $Ram = memory_get_usage(true);
44
+        if ($Ram > MAX_MEMORY && !defined('MEMORY_EXCEPTION')) {
45
+            $Reason[] = Format::get_size($Ram).' RAM used';
46
+        }
47
+
48
+        G::$DB->warnings(); // See comment in MYSQL::query
49
+        /*
50
+        $Queries = $this->get_queries();
51
+        $DBWarningCount = 0;
52
+        foreach ($Queries as $Query) {
53
+          if (!empty($Query[2])) {
54
+            $DBWarningCount += count($Query[2]);
55
+          }
56
+        }
57
+        if ($DBWarningCount) {
58
+          $Reason[] = $DBWarningCount . ' DB warning(s)';
59
+        }
60
+        */
61
+
62
+        $CacheStatus = G::$Cache->server_status();
63
+        if (in_array(0, $CacheStatus) && !G::$Cache->get_value('cache_fail_reported')) {
64
+            // Limit to max one report every 15 minutes to avoid massive debug spam
65
+            G::$Cache->cache_value('cache_fail_reported', true, 900);
66
+            $Reason[] = "Cache server error";
67
+        }
68
+
69
+        if (isset($_REQUEST['profile'])) {
70
+            $Reason[] = 'Requested by ' . G::$LoggedUser['Username'];
71
+        }
72
+
73
+        $this->Perf['Memory usage'] = (($Ram>>10) / 1024).' MB';
74
+        $this->Perf['Page process time'] = number_format($Micro / 1000, 3).' s';
75
+        $this->Perf['CPU time'] = number_format($this->get_cpu_time() / 1000000, 3).' s';
76
+
77
+        if (isset($Reason[0])) {
78
+            $this->log_var($CacheStatus, 'Cache server status');
79
+            $this->analysis(implode(', ', $Reason));
80
+            return true;
81
+        }
82
+        return false;
83
+    }
84
+
85
+    public function analysis($Message, $Report = '', $Time = 43200)
86
+    {
87
+        global $Document;
88
+        if (empty($Report)) {
89
+            $Report = $Message;
90
+        }
91
+        $Identifier = Users::make_secret(5);
92
+        G::$Cache->cache_value(
93
+            'analysis_'.$Identifier,
94
+            array(
95
+                'url' => $_SERVER['REQUEST_URI'],
96
+                'message' => $Report,
97
+                'errors' => $this->get_errors(true),
98
+                'queries' => $this->get_queries(),
99
+                'flags' => $this->get_flags(),
100
+                'includes' => $this->get_includes(),
101
+                'cache' => $this->get_cache_keys(),
102
+                'vars' => $this->get_logged_vars(),
103
+                'perf' => $this->get_perf(),
104
+                'ocelot' => $this->get_ocelot_requests()
105
+            ),
106
+            $Time
107
+        );
108
+
109
+        $RequestURI = !empty($_SERVER['REQUEST_URI']) ? substr($_SERVER['REQUEST_URI'], 1) : '';
110
+        send_irc(DEBUG_CHAN, "$Message $Document ".site_url()."tools.php?action=analysis&case=$Identifier ".site_url().$RequestURI);
111
+    }
112
+
113
+    public function get_cpu_time()
114
+    {
115
+        if (!defined('PHP_WINDOWS_VERSION_MAJOR')) {
116
+            global $CPUTimeStart;
117
+            $RUsage = getrusage();
118
+            $CPUTime = $RUsage['ru_utime.tv_sec'] * 1000000 + $RUsage['ru_utime.tv_usec'] - $CPUTimeStart;
119
+            return $CPUTime;
120
+        }
121
+        return false;
122
+    }
123
+
124
+    public function log_var($Var, $VarName = false)
125
+    {
126
+        $BackTrace = debug_backtrace();
127
+        $ID = Users::make_secret(5);
128
+
129
+        if (!$VarName) {
130
+            $VarName = $ID;
131
+        }
132
+
133
+        $File = array('path' => substr($BackTrace[0]['file'], strlen(SERVER_ROOT)), 'line' => $BackTrace[0]['line']);
134
+        $this->LoggedVars[$ID] = array($VarName => array('bt' => $File, 'data' => $Var));
135
+    }
136
+
137
+    public function set_flag($Event)
138
+    {
139
+        global $ScriptStartTime;
140
+        $this->Flags[] = array($Event, (microtime(true) - $ScriptStartTime) * 1000, memory_get_usage(true), $this->get_cpu_time());
141
+    }
142
+
143
+    // This isn't in the constructor because $this is not available, and the function cannot be made static
144
+    public function handle_errors()
145
+    {
146
+        //error_reporting(E_ALL ^ E_STRICT | E_WARNING | E_DEPRECATED | E_ERROR | E_PARSE); //E_STRICT disabled
147
+        error_reporting(E_WARNING | E_ERROR | E_PARSE);
148
+        set_error_handler(array($this, 'php_error_handler'));
149
+    }
150
+
151
+    protected function format_args($Array)
152
+    {
153
+        $LastKey = -1;
154
+        $Return = [];
155
+        foreach ($Array as $Key => $Val) {
156
+            $Return[$Key] = '';
157
+            if (!is_int($Key) || !is_int($LastKey) || $Key != $LastKey + 1) {
158
+                $Return[$Key] .= "'$Key' => ";
159
+            }
160
+
161
+            if ($Val === true) {
162
+                $Return[$Key] .= 'true';
163
+            } elseif ($Val === false) {
164
+                $Return[$Key] .= 'false';
165
+            } elseif (is_string($Val)) {
166
+                $Return[$Key] .= "'$Val'";
167
+            } elseif (is_int($Val)) {
168
+                $Return[$Key] .= $Val;
169
+            } elseif (is_object($Val)) {
170
+                $Return[$Key] .= get_class($Val);
171
+            } elseif (is_array($Val)) {
172
+                $Return[$Key] .= 'array('.$this->format_args($Val).')';
173
+            }
174
+
175
+            $LastKey = $Key;
176
+        }
177
+        return implode(', ', $Return);
178
+    }
179
+
180
+    public function php_error_handler($Level, $Error, $File, $Line)
181
+    {
182
+        // Who added this, it's still something to pay attention to...
183
+        if (stripos('Undefined index', $Error) !== false) {
184
+            //return true;
185
+        }
186
+
187
+        $Steps = 1; // Steps to go up in backtrace, default one
188
+        $Call = '';
189
+        $Args = '';
190
+        $Tracer = debug_backtrace();
191
+
192
+        // This is in case something in this function goes wrong and we get stuck with an infinite loop
193
+        if (isset($Tracer[$Steps]['function'], $Tracer[$Steps]['class']) && $Tracer[$Steps]['function'] == 'php_error_handler' && $Tracer[$Steps]['class'] == 'DEBUG') {
194
+            return true;
195
+        }
196
+
197
+        // If this error was thrown, we return the function which threw it
198
+        if (isset($Tracer[$Steps]['function']) && $Tracer[$Steps]['function'] == 'trigger_error') {
199
+            $Steps++;
200
+            $File = $Tracer[$Steps]['file'];
201
+            $Line = $Tracer[$Steps]['line'];
202
+        }
203
+
204
+        // At this time ONLY Array strict typing is fully supported.
205
+        // Allow us to abuse strict typing (IE: function test(Array))
206
+        if (preg_match('/^Argument (\d+) passed to \S+ must be an (array), (array|string|integer|double|object) given, called in (\S+) on line (\d+) and defined$/', $Error, $Matches)) {
207
+            $Error = 'Type hinting failed on arg '.$Matches[1]. ', expected '.$Matches[2].' but found '.$Matches[3];
208
+            $File = $Matches[4];
209
+            $Line = $Matches[5];
210
+        }
211
+
212
+        // Let's not be repetative
213
+        if (($Tracer[$Steps]['function'] == 'include' || $Tracer[$Steps]['function'] == 'require') && isset($Tracer[$Steps]['args'][0]) && $Tracer[$Steps]['args'][0] == $File) {
214
+            unset($Tracer[$Steps]['args']);
215
+        }
216
+
217
+        // Class
218
+        if (isset($Tracer[$Steps]['class'])) {
219
+            $Call .= $Tracer[$Steps]['class'].'::';
220
+        }
221
+
222
+        // Function & args
223
+        if (isset($Tracer[$Steps]['function'])) {
224
+            $Call .= $Tracer[$Steps]['function'];
225
+            if (isset($Tracer[$Steps]['args'][0])) {
226
+                $Args = $this->format_args($Tracer[$Steps]['args']);
227
+            }
228
+        }
229
+
230
+        // Shorten the path & we're done
231
+        $File = str_replace(SERVER_ROOT, '', $File);
232
+        $Error = str_replace(SERVER_ROOT, '', $Error);
233
+
234
+        if (DEBUG_MODE) {
235
+            $this->Errors[] = array($Error, $File.':'.$Line, $Call, $Args);
236
+        }
237
+        return true;
238
+    }
239
+
240
+    /* Data wrappers */
241
+
242
+    public function get_perf()
243
+    {
244
+        if (empty($this->Perf)) {
245
+            global $ScriptStartTime;
246
+            $PageTime = (microtime(true) - $ScriptStartTime);
247
+            $CPUTime = $this->get_cpu_time();
248
+            $Perf = array(
249
+                'Memory usage' => Format::get_size(memory_get_usage(true)),
250
+                'Page process time' => number_format($PageTime, 3).' s');
251
+
252
+            if ($CPUTime) {
253
+                $Perf['CPU time'] = number_format($CPUTime / 1000000, 3).' s';
254
+            }
255
+            return $Perf;
256
+        }
257
+        return $this->Perf;
258
+    }
259
+
260
+    public function get_flags()
261
+    {
262
+        return $this->Flags;
263
+    }
264
+
265
+    public function get_errors($Light = false)
266
+    {
267
+        // Because the cache can't take some of these variables
268
+        if ($Light) {
269
+            foreach ($this->Errors as $Key => $Value) {
270
+                $this->Errors[$Key][3] = '';
271
+            }
272
+        }
273
+        return $this->Errors;
274
+    }
275
+
276
+    public function get_constants()
277
+    {
278
+        return get_defined_constants(true);
279
+    }
280
+
281
+    public function get_classes()
282
+    {
283
+        foreach (get_declared_classes() as $Class) {
284
+            $Classes[$Class]['Vars'] = get_class_vars($Class);
285
+            $Classes[$Class]['Functions'] = get_class_methods($Class);
286
+        }
287
+        return $Classes;
288
+    }
289
+
290
+    public function get_extensions()
291
+    {
292
+        foreach (get_loaded_extensions() as $Extension) {
293
+            $Extensions[$Extension]['Functions'] = get_extension_funcs($Extension);
294
+        }
295
+        return $Extensions;
296
+    }
297
+
298
+    public function get_includes()
299
+    {
300
+        return get_included_files();
301
+    }
302
+
303
+    public function get_cache_time()
304
+    {
305
+        return G::$Cache->Time;
306
+    }
307
+
308
+    public function get_cache_keys()
309
+    {
310
+        return array_keys(G::$Cache->CacheHits);
311
+    }
312
+
313
+    public function get_sphinxql_queries()
314
+    {
315
+        if (class_exists('Sphinxql')) {
316
+            return Sphinxql::$Queries;
317
+        }
318
+    }
319
+
320
+    public function get_sphinxql_time()
321
+    {
322
+        if (class_exists('Sphinxql')) {
323
+            return Sphinxql::$Time;
324
+        }
325
+    }
326
+
327
+    public function get_queries()
328
+    {
329
+        return G::$DB->Queries;
330
+    }
331
+
332
+    public function get_query_time()
333
+    {
334
+        return G::$DB->Time;
335
+    }
336
+
337
+    public function get_logged_vars()
338
+    {
339
+        return $this->LoggedVars;
340
+    }
341
+
342
+    public function get_ocelot_requests()
343
+    {
344
+        if (class_exists('Tracker')) {
345
+            return Tracker::$Requests;
346
+        }
347
+    }
348
+
349
+    /* Output Formatting */
350
+
351
+    public function perf_table($Perf = false)
352
+    {
353
+        if (!is_array($Perf)) {
354
+            $Perf = $this->get_perf();
355
+        }
356
+        if (empty($Perf)) {
357
+            return;
358
+        } ?>
359
+
360
+<table class="layout">
361
+  <tr>
362
+    <td><strong><a href="#" onclick="$(this).parents('.layout').next('#debug_perf').gtoggle(); return false;"
363
+          class="brackets">View</a> Performance Statistics:</strong></td>
364
+  </tr>
365
+</table>
366
+<table id="debug_perf" class="debug_table hidden">
367
+  <?php
368
+    foreach ($Perf as $Stat => $Value) {
369
+        ?>
370
+  <tr class="valign_top">
371
+    <td class="debug_perf_stat"><?=$Stat?>
372
+    </td>
373
+    <td class="debug_perf_data"><?=$Value?>
374
+    </td>
375
+  </tr>
376
+  <?php
377
+    } ?>
378
+</table>
379
+<?php
380
+    }
381
+
382
+    public function include_table($Includes = false)
383
+    {
384
+        if (!is_array($Includes)) {
385
+            $Includes = $this->get_includes();
386
+        } ?>
387
+
388
+<table class="layout">
389
+  <tr>
390
+    <td>
391
+      <strong>
392
+        <a href="#" onclick="$(this).parents('.layout').next('#debug_include').gtoggle(); return false;"
393
+          class="brackets">View</a>
394
+        <?=number_format(count($Includes))?>
395
+        Includes:
396
+      </strong>
397
+    </td>
398
+  </tr>
399
+</table>
400
+
401
+<table id="debug_include" class="debug_table hidden">
402
+  <?php
403
+    foreach ($Includes as $File) {
404
+        ?>
405
+  <tr class="valign_top">
406
+    <td><?=$File?>
407
+    </td>
408
+  </tr>
409
+  <?php
410
+    } ?>
411
+</table>
412
+<?php
413
+    }
414
+
415
+    public function class_table($Classes = false)
416
+    {
417
+        if (!is_array($Classes)) {
418
+            $Classes = $this->get_classes();
419
+        } ?>
420
+<table class="layout">
421
+  <tr>
422
+    <td>
423
+      <strong>
424
+        <a href="#" onclick="$(this).parents('.layout').next('#debug_classes').gtoggle(); return false;"
425
+          class="brackets">View</a>
426
+        Classes:
427
+      </strong>
428
+    </td>
429
+  </tr>
430
+</table>
431
+
432
+<table id="debug_classes" class="debug_table hidden">
433
+  <tr>
434
+    <td>
435
+      <pre>
436
+<?php
437
+print_r($Classes);
438
+        echo "\n"; ?>
439
+        </pre>
440
+    </td>
441
+  </tr>
442
+</table>
443
+<?php
444
+    }
445
+
446
+    public function extension_table()
447
+    {
448
+        ?>
449
+<table class="layout">
450
+  <tr>
451
+    <td>
452
+      <strong>
453
+        <a href="#" onclick="$(this).parents('.layout').next('#debug_extensions').gtoggle(); return false;"
454
+          class="brackets">View</a>
455
+        Extensions:
456
+      </strong>
457
+    </td>
458
+  </tr>
459
+</table>
460
+<table id="debug_extensions" class="debug_table hidden">
461
+  <tr>
462
+    <td>
463
+      <pre>
464
+<?php
465
+print_r($this->get_extensions());
466
+        echo "\n"; ?>
467
+        </pre>
468
+    </td>
469
+  </tr>
470
+</table>
471
+<?php
472
+    }
473
+
474
+    public function flag_table($Flags = false)
475
+    {
476
+        if (!is_array($Flags)) {
477
+            $Flags = $this->get_flags();
478
+        }
479
+
480
+        if (empty($Flags)) {
481
+            return;
482
+        } ?>
483
+
484
+<table class="layout">
485
+  <tr>
486
+    <td>
487
+      <strong>
488
+        <a href="#" onclick="$(this).parents('.layout').next('#debug_flags').gtoggle(); return false;"
489
+          class="brackets">View</a>
490
+        Flags:
491
+      </strong>
492
+    </td>
493
+  </tr>
494
+</table>
495
+
496
+<table id="debug_flags" class="debug_table hidden">
497
+  <tr class="valign_top">
498
+    <td class="debug_flags_event"><strong>Event</strong></td>
499
+    <td class="debug_flags_time"><strong>Page time</strong></td>
500
+    <?php if ($Flags[0][3] !== false) { ?>
501
+    <td class="debug_flags_time"><strong>CPU time</strong></td>
502
+    <?php } ?>
503
+    <td class="debug_flags_memory"><strong>Memory</strong></td>
504
+  </tr>
505
+
506
+  <?php
507
+    foreach ($Flags as $Flag) {
508
+        list($Event, $MicroTime, $Memory, $CPUTime) = $Flag; ?>
509
+  <tr class="valign_top">
510
+    <td><?=$Event?>
511
+    </td>
512
+    <td><?=number_format($MicroTime, 3)?> ms</td>
513
+    <?php if ($CPUTime !== false) { ?>
514
+    <td><?=number_format($CPUTime / 1000, 3)?> ms</td>
515
+    <?php } ?>
516
+    <td><?=Format::get_size($Memory)?>
517
+    </td>
518
+  </tr>
519
+  <?php
520
+    } ?>
521
+</table>
522
+<?php
523
+    }
524
+
525
+    public function constant_table($Constants = false)
526
+    {
527
+        if (!is_array($Constants)) {
528
+            $Constants = $this->get_constants();
529
+        } ?>
530
+
531
+<table class="layout">
532
+  <tr>
533
+    <td>
534
+      <strong>
535
+        <a href="#" onclick="$(this).parents('.layout').next('#debug_constants').gtoggle(); return false;"
536
+          class="brackets">View</a>
537
+        Constants:
538
+      </strong>
539
+    </td>
540
+  </tr>
541
+</table>
542
+
543
+<table id="debug_constants" class="debug_table hidden">
544
+  <tr>
545
+    <td class="debug_data debug_constants_data">
546
+      <pre>
547
+<?=display_str(print_r($Constants, true))?>
548
+        </pre>
549
+    </td>
550
+  </tr>
551
+</table>
552
+<?php
553
+    }
554
+
555
+    public function ocelot_table($OcelotRequests = false)
556
+    {
557
+        if (!is_array($OcelotRequests)) {
558
+            $OcelotRequests = $this->get_ocelot_requests();
559
+        }
560
+
561
+        if (empty($OcelotRequests)) {
562
+            return;
563
+        } ?>
564
+
565
+<table class="layout">
566
+  <tr>
567
+    <td>
568
+      <strong>
569
+        <a data-toggle-target="#debug_ocelot" class="brackets">View</a>
570
+        <?=number_format(count($OcelotRequests))?>
571
+        Ocelot requests:
572
+      </strong>
573
+    </td>
574
+  </tr>
575
+</table>
576
+
577
+<table id="debug_ocelot" class="debug_table hidden">
578
+  <?php foreach ($OcelotRequests as $i => $Request) { ?>
579
+  <tr>
580
+    <td class="debug_data debug_ocelot_data">
581
+      <a data-toggle-target="#debug_ocelot_<?=$i?>"><?=display_str($Request['path'])?></a>
582
+      <pre id="debug_ocelot_<?=$i?>"
583
+        class="hidden"><?=display_str($Request['response'])?></pre>
584
+    </td>
585
+
586
+    <td class="debug_info" style="width: 100px;">
587
+      <?=display_str($Request['status'])?>
588
+    </td>
589
+    <td class="debug_info debug_timing" style="width: 100px;">
590
+      <?=number_format($Request['time'], 5)?> ms
591
+    </td>
592
+  </tr>
593
+  <?php } ?>
594
+</table>
595
+<?php
596
+    }
597
+
598
+    public function cache_table($CacheKeys = false)
599
+    {
600
+        $Header = 'Cache Keys';
601
+        if (!is_array($CacheKeys)) {
602
+            $CacheKeys = $this->get_cache_keys();
603
+            $Header .= ' ('.number_format($this->get_cache_time(), 5).' ms)';
604
+        }
605
+
606
+        if (empty($CacheKeys)) {
607
+            return;
608
+        }
609
+        $Header = ' '.number_format(count($CacheKeys))." $Header:"; ?>
610
+
611
+<table class="layout">
612
+  <tr>
613
+    <td>
614
+      <strong>
615
+        <a href="#" onclick="$(this).parents('.layout').next('#debug_cache').gtoggle(); return false;"
616
+          class="brackets">View</a>
617
+        <?=$Header?>
618
+      </strong>
619
+    </td>
620
+  </tr>
621
+</table>
622
+
623
+<table id="debug_cache" class="debug_table hidden">
624
+  <?php foreach ($CacheKeys as $Key) { ?>
625
+  <tr>
626
+    <td class="label nobr debug_info debug_cache_key">
627
+      <a href="#"
628
+        onclick="$('#debug_cache_<?=$Key?>').gtoggle(); return false;"><?=display_str($Key)?></a>
629
+      <a href="tools.php?action=clear_cache&amp;key=<?=$Key?>&amp;type=clear"
630
+        target="_blank" class="brackets tooltip" title="Clear this cache key">Clear</a>
631
+    </td>
632
+    <td class="debug_data debug_cache_data">
633
+      <pre id="debug_cache_<?=$Key?>" class="hidden">
634
+<?=display_str(print_r(G::$Cache->get_value($Key, true), true))?>
635
+        </pre>
636
+    </td>
637
+  </tr>
638
+  <?php } ?>
639
+</table>
640
+<?php
641
+    }
642
+
643
+    public function error_table($Errors = false)
644
+    {
645
+        if (!is_array($Errors)) {
646
+            $Errors = $this->get_errors();
647
+        }
648
+
649
+        if (empty($Errors)) {
650
+            return;
651
+        } ?>
652
+
653
+<table class="layout">
654
+  <tr>
655
+    <td>
656
+      <strong>
657
+        <a href="#" onclick="$(this).parents('.layout').next('#debug_error').gtoggle(); return false;"
658
+          class="brackets">View</a>
659
+        <?=number_format(count($Errors))?>
660
+        Errors:
661
+      </strong>
662
+    </td>
663
+  </tr>
664
+</table>
665
+<table id="debug_error" class="debug_table hidden">
666
+  <?php
667
+    foreach ($Errors as $Error) {
668
+        list($Error, $Location, $Call, $Args) = $Error; ?>
669
+  <tr class="valign_top">
670
+    <td class="debug_info debug_error_call">
671
+      <?=display_str($Call)?>(<?=display_str($Args)?>)
672
+    </td>
673
+    <td class="debug_data debug_error_data">
674
+      <?=display_str($Error)?>
675
+    </td>
676
+    <td>
677
+      <?=display_str($Location)?>
678
+    </td>
679
+  </tr>
680
+  <?php
681
+    } ?>
682
+</table>
683
+<?php
684
+    }
685
+
686
+    public function query_table($Queries=false)
687
+    {
688
+        $Header = 'Queries';
689
+        if (!is_array($Queries)) {
690
+            $Queries = $this->get_queries();
691
+            $Header .= ' ('.number_format($this->get_query_time(), 5).' ms)';
692
+        }
693
+
694
+        if (empty($Queries)) {
695
+            return;
696
+        }
697
+        $Header = ' '.number_format(count($Queries))." $Header:"; ?>
698
+
699
+<table class="layout">
700
+  <tr>
701
+    <td>
702
+      <strong>
703
+        <a href="#" onclick="$(this).parents('.layout').next('#debug_database').gtoggle(); return false;"
704
+          class="brackets">View</a>
705
+        <?=$Header?>
706
+      </strong>
707
+    </td>
708
+  </tr>
709
+</table>
710
+
711
+<table id="debug_database" class="debug_table hidden">
712
+  <?php
713
+    foreach ($Queries as $Query) {
714
+        $SQL = $Query[0] ?? null;
715
+        $Time = $Query[1] ?? null;
716
+        $Warnings = $Query[2] ?? null;
717
+
718
+        if ($Warnings !== null) {
719
+            $Warnings = implode('<br />', $Warnings);
720
+        } ?>
721
+
722
+  <tr class="valign_top">
723
+    <td class="debug_data debug_query_data">
724
+      <div><?=str_replace("\t", '&nbsp;&nbsp;', nl2br(display_str(trim($SQL))))?>
725
+      </div>
726
+    </td>
727
+
728
+    <td class="debug_info debug_query_time" style="width: 130px;"><?=number_format($Time, 5)?> ms</td>
729
+    <td class="debug_info debug_query_warnings"><?=$Warnings?>
730
+    </td>
731
+  </tr>
732
+  <?php
733
+    } ?>
734
+</table>
735
+<?php
736
+    }
737
+
738
+    public function sphinx_table($Queries = false)
739
+    {
740
+        $Header = 'Searches';
741
+        if (!is_array($Queries)) {
742
+            $Queries = $this->get_sphinxql_queries();
743
+            $Header .= ' ('.number_format($this->get_sphinxql_time(), 5).' ms)';
744
+        }
745
+
746
+        if (empty($Queries)) {
747
+            return;
748
+        }
749
+        $Header = ' '.number_format(count($Queries))." $Header:"; ?>
750
+
751
+<table class="layout">
752
+  <tr>
753
+    <td>
754
+      <strong>
755
+        <a href="#" onclick="$(this).parents('.layout').next('#debug_sphinx').gtoggle(); return false;"
756
+          class="brackets">View</a>
757
+        <?=$Header?>
758
+      </strong>
759
+    </td>
760
+  </tr>
761
+</table>
762
+<table id="debug_sphinx" class="debug_table hidden">
763
+  <?php
764
+    foreach ($Queries as $Query) {
765
+        list($Params, $Time) = $Query; ?>
766
+  <tr class="valign_top">
767
+    <td class="debug_data debug_sphinx_data">
768
+      <pre><?=str_replace("\t", '  ', $Params)?></pre>
769
+    </td>
770
+    <td class="debug_info debug_sphinx_time" style="width: 130px;"><?=number_format($Time, 5)?> ms</td>
771
+  </tr>
772
+  <?php
773
+    } ?>
774
+</table>
775
+<?php
776
+    }
777
+
778
+    public function vars_table($Vars = false)
779
+    {
780
+        $Header = 'Logged Variables';
781
+        if (empty($Vars)) {
782
+            if (empty($this->LoggedVars)) {
783
+                return;
784
+            }
785
+            $Vars = $this->LoggedVars;
786
+        }
787
+        $Header = ' '.number_format(count($Vars))." $Header:"; ?>
788
+
789
+<table class="layout">
790
+  <tr>
791
+    <td>
792
+      <strong>
793
+        <a href="#" onclick="$(this).parents('.layout').next('#debug_loggedvars').gtoggle(); return false;"
794
+          class="brackets">View</a>
795
+        <?=$Header?>
796
+      </strong>
797
+    </td>
798
+  </tr>
799
+</table>
800
+
801
+<table id="debug_loggedvars" class="debug_table hidden">
802
+  <?php
803
+    foreach ($Vars as $ID => $Var) {
804
+        $Key = key($Var);
805
+        $Data = current($Var);
806
+        $Size = count($Data['data']); ?>
807
+  <tr>
808
+    <td class="debug_info debug_loggedvars_name">
809
+      <a href="#"
810
+        onclick="$('#debug_loggedvars_<?=$ID?>').gtoggle(); return false;"><?=display_str($Key)?></a>
811
+      (<?=$Size . ($Size == 1 ? ' element' : ' elements')?>)
812
+      <div>
813
+        <?=$Data['bt']['path'].':'.$Data['bt']['line']; ?>
814
+      </div>
815
+    </td>
816
+    <td class="debug_data debug_loggedvars_data">
817
+      <pre id="debug_loggedvars_<?=$ID?>" class="hidden">
818
+<?=display_str(print_r($Data['data'], true))?>
819
+        </pre>
820
+    </td>
821
+  </tr>
822
+  <?php
823
+    } ?>
824
+</table>
825
+<?php
826
+    }
827
+}

+ 881
- 0
classes/donations.class.php View File

@@ -0,0 +1,881 @@
1
+<?php
2
+declare(strict_types=1);
3
+
4
+define('BTC_API_URL', 'https://api.bitcoinaverage.com/ticker/global/EUR/');
5
+define('USD_API_URL', 'http://www.google.com/ig/calculator?hl=en&q=1USD=?EUR');
6
+
7
+class Donations
8
+{
9
+    private static $IsSchedule = false;
10
+
11
+    public static function regular_donate($UserID, $DonationAmount, $Source, $Reason, $Currency = 'EUR')
12
+    {
13
+        self::donate(
14
+            $UserID,
15
+            array(
16
+              'Source' => $Source,
17
+              'Price' => $DonationAmount,
18
+              'Currency' => $Currency,
19
+              'Source' => $Source,
20
+              'Reason' => $Reason,
21
+              'SendPM' => true
22
+            )
23
+        );
24
+    }
25
+
26
+    public static function donate($UserID, $Args)
27
+    {
28
+        $UserID = (int) $UserID;
29
+        $QueryID = G::$DB->get_query_id();
30
+
31
+        G::$DB->query("
32
+        SELECT
33
+          1
34
+        FROM
35
+          `users_main`
36
+        WHERE
37
+          `ID` = '$UserID'
38
+        LIMIT 1
39
+        ");
40
+
41
+        if (G::$DB->has_results()) {
42
+            G::$Cache->InternalCache = false;
43
+            foreach ($Args as &$Arg) {
44
+                $Arg = db_string($Arg);
45
+            }
46
+            extract($Args);
47
+
48
+            // We don't always get a date passed in
49
+            if (empty($Date)) {
50
+                $Date = sqltime();
51
+            }
52
+
53
+            // Get the ID of the staff member making the edit
54
+            $AddedBy = 0;
55
+            if (!self::$IsSchedule) {
56
+                $AddedBy = G::$LoggedUser['ID'];
57
+            }
58
+
59
+            // Give them the extra invite
60
+            $ExtraInvite = G::$DB->affected_rows();
61
+
62
+            // A staff member is directly manipulating donor points
63
+            if (isset($Manipulation) && $Manipulation === 'Direct') {
64
+                $DonorPoints = $Rank;
65
+                $AdjustedRank = $Rank >= MAX_EXTRA_RANK ? MAX_EXTRA_RANK : $Rank;
66
+
67
+                G::$DB->query("
68
+                INSERT INTO users_donor_ranks(
69
+                  `UserID`,
70
+                  `Rank`,
71
+                  `TotalRank`,
72
+                  `DonationTime`,
73
+                  `RankExpirationTime`
74
+                )
75
+                VALUES(
76
+                  '$UserID',
77
+                  '$AdjustedRank',
78
+                  '$TotalRank',
79
+                  '$Date',
80
+                  NOW()
81
+                )
82
+                ON DUPLICATE KEY
83
+                UPDATE
84
+                  `Rank` = '$AdjustedRank',
85
+                  `TotalRank` = '$TotalRank',
86
+                  `DonationTime` = '$Date',
87
+                  `RankExpirationTime` = NOW()
88
+                ");
89
+            } else {
90
+                // Donations from the store get donor points directly, no need to calculate them
91
+                if ($Source === 'Store Parser') {
92
+                    $ConvertedPrice = self::currency_exchange($Amount * $Price, $Currency);
93
+                } else {
94
+                    $ConvertedPrice = self::currency_exchange($Price, $Currency);
95
+                    $DonorPoints = self::calculate_rank($ConvertedPrice);
96
+                }
97
+
98
+                // Rank is the same thing as DonorPoints
99
+                $IncreaseRank = $DonorPoints;
100
+                $CurrentRank = self::get_rank($UserID);
101
+
102
+                // A user's donor rank can never exceed MAX_EXTRA_RANK
103
+                // If the amount they donated causes it to overflow, chnage it to MAX_EXTRA_RANK
104
+                // The total rank isn't affected by this, so their original donor point value is added to it
105
+                if (($CurrentRank + $DonorPoints) >= MAX_EXTRA_RANK) {
106
+                    $AdjustedRank = MAX_EXTRA_RANK;
107
+                } else {
108
+                    $AdjustedRank = $CurrentRank + $DonorPoints;
109
+                }
110
+
111
+                G::$DB->query("
112
+                INSERT INTO users_donor_ranks(
113
+                  `UserID`,
114
+                  `Rank`,
115
+                  `TotalRank`,
116
+                  `DonationTime`,
117
+                  `RankExpirationTime`
118
+                )
119
+                VALUES(
120
+                  '$UserID',
121
+                  '$AdjustedRank',
122
+                  '$DonorPoints',
123
+                  '$Date',
124
+                  NOW()
125
+                )
126
+                ON DUPLICATE KEY
127
+                UPDATE
128
+                  `Rank` = '$AdjustedRank',
129
+                  `TotalRank` = TotalRank + '$DonorPoints',
130
+                  `DonationTime` = '$Date',
131
+                  `RankExpirationTime` = NOW()
132
+                ");
133
+            }
134
+
135
+            // Donor cache key is outdated
136
+            G::$Cache->delete_value("donor_info_$UserID");
137
+
138
+            // Get their rank
139
+            $Rank = self::get_rank($UserID);
140
+            $TotalRank = self::get_total_rank($UserID);
141
+
142
+            // Now that their rank and total rank has been set, we can calculate their special rank
143
+            self::calculate_special_rank($UserID);
144
+
145
+            // Hand out invites
146
+            G::$DB->query("
147
+            SELECT
148
+              `InvitesRecievedRank`
149
+            FROM
150
+              `users_donor_ranks`
151
+            WHERE
152
+              `UserID` = '$UserID'
153
+            ");
154
+
155
+            list($InvitesRecievedRank) = G::$DB->next_record();
156
+            $AdjustedRank = $Rank >= MAX_RANK ? (MAX_RANK - 1) : $Rank;
157
+            $InviteRank = $AdjustedRank - $InvitesRecievedRank;
158
+
159
+            if ($InviteRank > 0) {
160
+                $Invites = $ExtraInvite ? ($InviteRank + 1) : $InviteRank;
161
+
162
+                G::$DB->query("
163
+                UPDATE
164
+                  `users_main`
165
+                SET
166
+                  `Invites` = Invites + '$Invites'
167
+                WHERE
168
+                  `ID` = '$UserID'
169
+                ");
170
+
171
+                G::$DB->query("
172
+                UPDATE
173
+                  `users_donor_ranks`
174
+                SET
175
+                  `InvitesRecievedRank` = '$AdjustedRank'
176
+                WHERE
177
+                  `UserID` = '$UserID'
178
+                ");
179
+            }
180
+
181
+            // Send them a thank you PM
182
+            if ($SendPM) {
183
+                $Subject = "Thank you for your donation";
184
+                Misc::send_pm($UserID, 0, $Subject, '');
185
+            }
186
+
187
+            // Lastly, add this donation to our history
188
+            G::$DB->query("
189
+            INSERT INTO `donations`(
190
+                `UserID`,
191
+                `Amount`,
192
+                `Source`,
193
+                `Reason`,
194
+                `Currency`,
195
+                `Email`,
196
+                `Time`,
197
+                `AddedBy`,
198
+                `Rank`,
199
+                `TotalRank`
200
+            )
201
+            VALUES(
202
+                '$UserID',
203
+                '$ConvertedPrice',
204
+                '$Source',
205
+                '$Reason',
206
+                '$Currency',
207
+                '',
208
+                '$Date',
209
+                '$AddedBy',
210
+                '$DonorPoints',
211
+                '$TotalRank'
212
+            )
213
+            ");
214
+
215
+            // Clear their user cache keys because the users_info values has been modified
216
+            G::$Cache->delete_value("user_info_$UserID");
217
+            G::$Cache->delete_value("user_info_heavy_$UserID");
218
+            G::$Cache->delete_value("donor_info_$UserID");
219
+        }
220
+        G::$DB->set_query_id($QueryID);
221
+    }
222
+
223
+    private static function calculate_special_rank($UserID)
224
+    {
225
+        $UserID = (int) $UserID;
226
+        $QueryID = G::$DB->get_query_id();
227
+
228
+        // Are they are special?
229
+        G::$DB->query("
230
+        SELECT
231
+          `TotalRank`,
232
+          `SpecialRank`
233
+        FROM
234
+          `users_donor_ranks`
235
+        WHERE
236
+          `UserID` = '$UserID'
237
+        ");
238
+
239
+        if (G::$DB->has_results()) {
240
+            // Adjust their special rank depending on the total rank.
241
+            list($TotalRank, $SpecialRank) = G::$DB->next_record();
242
+            if ($TotalRank < 10) {
243
+                $SpecialRank = 0;
244
+            }
245
+
246
+            if ($SpecialRank < 1 && $TotalRank >= 10) {
247
+                Misc::send_pm($UserID, 0, 'Special Donor Rank 1', 'Thank You');
248
+                $SpecialRank = 1;
249
+            }
250
+
251
+            if ($SpecialRank < 2 && $TotalRank >= 20) {
252
+                Misc::send_pm($UserID, 0, 'Special Donor Rank 2', 'Thank You');
253
+                $SpecialRank = 2;
254
+            }
255
+
256
+            if ($SpecialRank < 3 && $TotalRank >= 50) {
257
+                Misc::send_pm($UserID, 0, 'Special Donor Rank 3', 'Thank You');
258
+                $SpecialRank = 3;
259
+            }
260
+
261
+            // Make them special
262
+            G::$DB->query("
263
+            UPDATE
264
+              `users_donor_ranks`
265
+            SET
266
+              `SpecialRank` = '$SpecialRank'
267
+            WHERE
268
+              `UserID` = '$UserID'
269
+            ");
270
+            G::$Cache->delete_value("donor_info_$UserID");
271
+        }
272
+        G::$DB->set_query_id($QueryID);
273
+    }
274
+
275
+    public static function schedule()
276
+    {
277
+        self::$IsSchedule = true;
278
+        DonationsBitcoin::find_new_donations();
279
+        self::expire_ranks();
280
+    }
281
+
282
+    public static function expire_ranks()
283
+    {
284
+        $QueryID = G::$DB->get_query_id();
285
+        G::$DB->query("
286
+        SELECT
287
+          `UserID`,
288
+          `Rank`
289
+        FROM
290
+          `users_donor_ranks`
291
+        WHERE
292
+          `Rank` > 1 AND `SpecialRank` != 3 AND `RankExpirationTime` < NOW() - INTERVAL 766 HOUR
293
+        ");
294
+
295
+        // 2 hours less than 32 days to account for schedule run times
296
+        if (G::$DB->record_count() > 0) {
297
+            $UserIDs = [];
298
+            while (list($UserID, $Rank) = G::$DB->next_record()) {
299
+                G::$Cache->delete_value("donor_info_$UserID");
300
+                G::$Cache->delete_value("donor_title_$UserID");
301
+                G::$Cache->delete_value("donor_profile_rewards_$UserID");
302
+                $UserIDs[] = $UserID;
303
+            }
304
+            $In = implode(',', $UserIDs);
305
+
306
+            G::$DB->query("
307
+            UPDATE
308
+              `users_donor_ranks`
309
+            SET
310
+              `Rank` = Rank - IF(Rank = " . MAX_RANK . ", 2, 1),
311
+              `RankExpirationTime` = NOW()
312
+            WHERE
313
+              `UserID` IN($In)
314
+            ");
315
+        }
316
+        G::$DB->set_query_id($QueryID);
317
+    }
318
+
319
+    private static function calculate_rank($Amount)
320
+    {
321
+        return floor($Amount / 5);
322
+    }
323
+
324
+    public static function update_rank($UserID, $Rank, $TotalRank, $Reason)
325
+    {
326
+        $Rank = (int) $Rank;
327
+        $TotalRank = (int) $TotalRank;
328
+
329
+        self::donate(
330
+            $UserID,
331
+            array(
332
+              'Manipulation' => 'Direct',
333
+              'Rank' => $Rank,
334
+              'TotalRank' => $TotalRank,
335
+              'Reason' => $Reason,
336
+              'Source' => 'Modify Values',
337
+              'Currency' => 'EUR'
338
+            )
339
+        );
340
+    }
341
+
342
+    public static function hide_stats($UserID)
343
+    {
344
+        $QueryID = G::$DB->get_query_id();
345
+        G::$DB->query("
346
+        INSERT INTO `users_donor_ranks`(`UserID`, `Hidden`)
347
+        VALUES('$UserID', '1')
348
+        ON DUPLICATE KEY
349
+        UPDATE
350
+          `Hidden` = '1'
351
+        ");
352
+        G::$DB->set_query_id($QueryID);
353
+    }
354
+
355
+    public static function show_stats($UserID)
356
+    {
357
+        $QueryID = G::$DB->get_query_id();
358
+        G::$DB->query("
359
+        INSERT INTO `users_donor_ranks`(`UserID`, `Hidden`)
360
+        VALUES('$UserID', '0')
361
+        ON DUPLICATE KEY
362
+        UPDATE
363
+          `Hidden` = '0'
364
+        ");
365
+        G::$DB->set_query_id($QueryID);
366
+    }
367
+
368
+    public static function is_visible($UserID)
369
+    {
370
+        $QueryID = G::$DB->get_query_id();
371
+        G::$DB->query("
372
+        SELECT
373
+          `Hidden`
374
+        FROM
375
+          `users_donor_ranks`
376
+        WHERE
377
+          `Hidden` = '0' AND `UserID` = '$UserID'
378
+        ");
379
+
380
+        $HasResults = G::$DB->has_results();
381
+        G::$DB->set_query_id($QueryID);
382
+        return $HasResults;
383
+    }
384
+
385
+    public static function has_donor_forum($UserID)
386
+    {
387
+        $ENV = ENV::go();
388
+        return self::get_rank($UserID) >= $ENV->DONOR_FORUM_RANK || self::get_special_rank($UserID) >= MAX_SPECIAL_RANK;
389
+    }
390
+
391
+    /**
392
+     * Put all the common donor info in the same cache key to save some cache calls
393
+     */
394
+    public static function get_donor_info($UserID)
395
+    {
396
+        // Our cache class should prevent identical memcached requests
397
+        $DonorInfo = G::$Cache->get_value("donor_info_$UserID");
398
+        if ($DonorInfo === false) {
399
+            # todo: Investigate Rank in donations table
400
+            $QueryID = G::$DB->get_query_id();
401
+            G::$DB->query("
402
+            SELECT
403
+              `Rank`,
404
+              `SpecialRank`,
405
+              `TotalRank`,
406
+              `DonationTime`,
407
+              `RankExpirationTime` + INTERVAL 766 HOUR
408
+            FROM
409
+              `users_donor_ranks`
410
+            WHERE
411
+              `UserID` = '$UserID'
412
+            ");
413
+
414
+            // 2 hours less than 32 days to account for schedule run times
415
+            if (G::$DB->has_results()) {
416
+                list($Rank, $SpecialRank, $TotalRank, $DonationTime, $ExpireTime) = G::$DB->next_record(MYSQLI_NUM, false);
417
+                if ($DonationTime === null) {
418
+                    $DonationTime = 0;
419
+                }
420
+
421
+                if ($ExpireTime === null) {
422
+                    $ExpireTime = 0;
423
+                }
424
+            } else {
425
+                $Rank = $SpecialRank = $TotalRank = $DonationTime = $ExpireTime = 0;
426
+            }
427
+
428
+            if (Permissions::is_mod($UserID)) {
429
+                $Rank = MAX_EXTRA_RANK;
430
+                $SpecialRank = MAX_SPECIAL_RANK;
431
+            }
432
+
433
+            G::$DB->query("
434
+            SELECT
435
+              `IconMouseOverText`,
436
+              `AvatarMouseOverText`,
437
+              `CustomIcon`,
438
+              `CustomIconLink`,
439
+              `SecondAvatar`
440
+            FROM
441
+              `donor_rewards`
442
+            WHERE
443
+              `UserID` = '$UserID'
444
+            ");
445
+
446
+            $Rewards = G::$DB->next_record(MYSQLI_ASSOC);
447
+            G::$DB->set_query_id($QueryID);
448
+
449
+            $DonorInfo = array(
450
+                'Rank' => (int) $Rank,
451
+                'SRank' => (int) $SpecialRank,
452
+                'TotRank' => (int) $TotalRank,
453
+                'Time' => $DonationTime,
454
+                'ExpireTime' => $ExpireTime,
455
+                'Rewards' => $Rewards
456
+            );
457
+            G::$Cache->cache_value("donor_info_$UserID", $DonorInfo, 0);
458
+        }
459
+        return $DonorInfo;
460
+    }
461
+
462
+    public static function get_rank($UserID)
463
+    {
464
+        return self::get_donor_info($UserID)['Rank'];
465
+    }
466
+
467
+    public static function get_special_rank($UserID)
468
+    {
469
+        return self::get_donor_info($UserID)['SRank'];
470
+    }
471
+
472
+    public static function get_total_rank($UserID)
473
+    {
474
+        return self::get_donor_info($UserID)['TotRank'];
475
+    }
476
+
477
+    public static function get_donation_time($UserID)
478
+    {
479
+        return self::get_donor_info($UserID)['Time'];
480
+    }
481
+
482
+    public static function get_personal_collages($UserID)
483
+    {
484
+        $DonorInfo = self::get_donor_info($UserID);
485
+        if ($DonorInfo['SRank'] === MAX_SPECIAL_RANK) {
486
+            $Collages = 5;
487
+        } else {
488
+            $Collages = min($DonorInfo['Rank'], 5); // One extra collage per donor rank up to 5
489
+        }
490
+        return $Collages;
491
+    }
492
+
493
+    public static function get_titles($UserID)
494
+    {
495
+        $Results = G::$Cache->get_value("donor_title_$UserID");
496
+        if ($Results === false) {
497
+            $QueryID = G::$DB->get_query_id();
498
+
499
+            G::$DB->query("
500
+            SELECT
501
+              `Prefix`,
502
+              `Suffix`,
503
+              `UseComma`
504
+            FROM
505
+              `donor_forum_usernames`
506
+            WHERE
507
+              `UserID` = '$UserID'
508
+            ");
509
+
510
+            $Results = G::$DB->next_record();
511
+            G::$DB->set_query_id($QueryID);
512
+            G::$Cache->cache_value("donor_title_$UserID", $Results, 0);
513
+        }
514
+        return $Results;
515
+    }
516
+
517
+    public static function get_enabled_rewards($UserID)
518
+    {
519
+        $Rewards = [];
520
+        $Rank = self::get_rank($UserID);
521
+        $SpecialRank = self::get_special_rank($UserID);
522
+        $HasAll = $SpecialRank === 3;
523
+
524
+        $Rewards = array(
525
+            'HasAvatarMouseOverText' => false,
526
+            'HasCustomDonorIcon' => false,
527
+            'HasDonorForum' => false,
528
+            'HasDonorIconLink' => false,
529
+            'HasDonorIconMouseOverText' => false,
530
+            'HasProfileInfo1' => false,
531
+            'HasProfileInfo2' => false,
532
+            'HasProfileInfo3' => false,
533
+            'HasProfileInfo4' => false,
534
+            'HasSecondAvatar' => false);
535
+
536
+        if ($Rank >= 2 || $HasAll) {
537
+            $Rewards["HasDonorIconMouseOverText"] = true;
538
+            $Rewards["HasProfileInfo1"] = true;
539
+        }
540
+
541
+        if ($Rank >= 3 || $HasAll) {
542
+            $Rewards["HasAvatarMouseOverText"] = true;
543
+            $Rewards["HasProfileInfo2"] = true;
544
+        }
545
+
546
+        if ($Rank >= 4 || $HasAll) {
547
+            $Rewards["HasDonorIconLink"] = true;
548
+            $Rewards["HasProfileInfo3"] = true;
549
+        }
550
+
551
+        if ($Rank >= MAX_RANK || $HasAll) {
552
+            $Rewards["HasCustomDonorIcon"] = true;
553
+            $Rewards["HasDonorForum"] = true;
554
+            $Rewards["HasProfileInfo4"] = true;
555
+        }
556
+
557
+        if ($SpecialRank >= 2) {
558
+            $Rewards["HasSecondAvatar"] = true;
559
+        }
560
+        return $Rewards;
561
+    }
562
+
563
+    public static function get_rewards($UserID)
564
+    {
565
+        return self::get_donor_info($UserID)['Rewards'];
566
+    }
567
+
568
+    public static function get_profile_rewards($UserID)
569
+    {
570
+        $Results = G::$Cache->get_value("donor_profile_rewards_$UserID");
571
+        if ($Results === false) {
572
+            $QueryID = G::$DB->get_query_id();
573
+
574
+            G::$DB->query("
575
+            SELECT
576
+              `ProfileInfo1`,
577
+              `ProfileInfoTitle1`,
578
+              `ProfileInfo2`,
579
+              `ProfileInfoTitle2`,
580
+              `ProfileInfo3`,
581
+              `ProfileInfoTitle3`,
582
+              `ProfileInfo4`,
583
+              `ProfileInfoTitle4`
584
+            FROM
585
+              `donor_rewards`
586
+            WHERE
587
+              `UserID` = '$UserID'
588
+            ");
589
+
590
+            $Results = G::$DB->next_record();
591
+            G::$DB->set_query_id($QueryID);
592
+            G::$Cache->cache_value("donor_profile_rewards_$UserID", $Results, 0);
593
+        }
594
+        return $Results;
595
+    }
596
+
597
+    private static function add_profile_info_reward($Counter, &$Insert, &$Values, &$Update)
598
+    {
599
+        if (isset($_POST["profile_title_" . $Counter]) && isset($_POST["profile_info_" . $Counter])) {
600
+            $ProfileTitle = db_string($_POST["profile_title_" . $Counter]);
601
+            $ProfileInfo = db_string($_POST["profile_info_" . $Counter]);
602
+            $ProfileInfoTitleSQL = "ProfileInfoTitle" . $Counter;
603
+            $ProfileInfoSQL = "ProfileInfo" . $Counter;
604
+            $Insert[] = "$ProfileInfoTitleSQL";
605
+            $Values[] = "'$ProfileInfoTitle'";
606
+            $Update[] = "$ProfileInfoTitleSQL = '$ProfileTitle'";
607
+            $Insert[] = "$ProfileInfoSQL";
608
+            $Values[] = "'$ProfileInfo'";
609
+            $Update[] = "$ProfileInfoSQL = '$ProfileInfo'";
610
+        }
611
+    }
612
+
613
+    public static function update_rewards($UserID)
614
+    {
615
+        $Rank = self::get_rank($UserID);
616
+        $SpecialRank = self::get_special_rank($UserID);
617
+        $HasAll = $SpecialRank === 3;
618
+        $Counter = 0;
619
+        $Insert = [];
620
+        $Values = [];
621
+        $Update = [];
622
+        $Insert[] = "UserID";
623
+        $Values[] = "'$UserID'";
624
+
625
+        if ($Rank >= 1 || $HasAll) {
626
+        }
627
+
628
+        if ($Rank >= 2 || $HasAll) {
629
+            if (isset($_POST['donor_icon_mouse_over_text'])) {
630
+                $IconMouseOverText = db_string($_POST['donor_icon_mouse_over_text']);
631
+                $Insert[] = "IconMouseOverText";
632
+                $Values[] = "'$IconMouseOverText'";
633
+                $Update[] = "IconMouseOverText = '$IconMouseOverText'";
634
+            }
635
+            $Counter++;
636
+        }
637
+
638
+        if ($Rank >= 3 || $HasAll) {
639
+            if (isset($_POST['avatar_mouse_over_text'])) {
640
+                $AvatarMouseOverText = db_string($_POST['avatar_mouse_over_text']);
641
+                $Insert[] = "AvatarMouseOverText";
642
+                $Values[] = "'$AvatarMouseOverText'";
643
+                $Update[] = "AvatarMouseOverText = '$AvatarMouseOverText'";
644
+            }
645
+            $Counter++;
646
+        }
647
+
648
+        if ($Rank >= 4 || $HasAll) {
649
+            if (isset($_POST['donor_icon_link'])) {
650
+                $CustomIconLink = db_string($_POST['donor_icon_link']);
651
+                if (!Misc::is_valid_url($CustomIconLink)) {
652
+                    $CustomIconLink = '';
653
+                }
654
+
655
+                $Insert[] = "CustomIconLink";
656
+                $Values[] = "'$CustomIconLink'";
657
+                $Update[] = "CustomIconLink = '$CustomIconLink'";
658
+            }
659
+            $Counter++;
660
+        }
661
+
662
+        if ($Rank >= MAX_RANK || $HasAll) {
663
+            if (isset($_POST['donor_icon_custom_url'])) {
664
+                $CustomIcon = db_string($_POST['donor_icon_custom_url']);
665
+                if (!Misc::is_valid_url($CustomIcon)) {
666
+                    $CustomIcon = '';
667
+                }
668
+
669
+                $Insert[] = "CustomIcon";
670
+                $Values[] = "'$CustomIcon'";
671
+                $Update[] = "CustomIcon = '$CustomIcon'";
672
+            }
673
+            self::update_titles($UserID, $_POST['donor_title_prefix'], $_POST['donor_title_suffix'], $_POST['donor_title_comma']);
674
+            $Counter++;
675
+        }
676
+
677
+        for ($i = 1; $i <= $Counter; $i++) {
678
+            self::add_profile_info_reward($i, $Insert, $Values, $Update);
679
+        }
680
+
681
+        if ($SpecialRank >= 2) {
682
+            if (isset($_POST['second_avatar'])) {
683
+                $SecondAvatar = db_string($_POST['second_avatar']);
684
+                if (!Misc::is_valid_url($SecondAvatar)) {
685
+                    $SecondAvatar = '';
686
+                }
687
+
688
+                $Insert[] = "SecondAvatar";
689
+                $Values[] = "'$SecondAvatar'";
690
+                $Update[] = "SecondAvatar = '$SecondAvatar'";
691
+            }
692
+        }
693
+
694
+        $Insert = implode(', ', $Insert);
695
+        $Values = implode(', ', $Values);
696
+        $Update = implode(', ', $Update);
697
+
698
+        if ($Counter > 0) {
699
+            $QueryID = G::$DB->get_query_id();
700
+
701
+            G::$DB->query("
702
+            INSERT INTO `donor_rewards`($Insert)
703
+            VALUES($Values)
704
+            ON DUPLICATE KEY
705
+            UPDATE
706
+              $Update
707
+            ");
708
+            G::$DB->set_query_id($QueryID);
709
+        }
710
+
711
+        G::$Cache->delete_value("donor_profile_rewards_$UserID");
712
+        G::$Cache->delete_value("donor_info_$UserID");
713
+    }
714
+
715
+    public static function update_titles($UserID, $Prefix, $Suffix, $UseComma)
716
+    {
717
+        $QueryID = G::$DB->get_query_id();
718
+        $Prefix = trim(db_string($Prefix));
719
+        $Suffix = trim(db_string($Suffix));
720
+        $UseComma = empty($UseComma);
721
+
722
+        G::$DB->query("
723
+        INSERT INTO `donor_forum_usernames`(
724
+          `UserID`,
725
+          `Prefix`,
726
+          `Suffix`,
727
+          `UseComma`
728
+        )
729
+        VALUES(
730
+          '$UserID',
731
+          '$Prefix',
732
+          '$Suffix',
733
+          '$UseComma'
734
+        )
735
+        ON DUPLICATE KEY
736
+        UPDATE
737
+          `Prefix` = '$Prefix',
738
+          `Suffix` = '$Suffix',
739
+          `UseComma` = '$UseComma'
740
+        ");
741
+
742
+        G::$Cache->delete_value("donor_title_$UserID");
743
+        G::$DB->set_query_id($QueryID);
744
+    }
745
+
746
+    public static function get_donation_history($UserID)
747
+    {
748
+        $UserID = (int) $UserID;
749
+        if (empty($UserID)) {
750
+            error(404);
751
+        }
752
+
753
+        # todo: Investigate Rank in donations table
754
+        $QueryID = G::$DB->get_query_id();
755
+        G::$DB->query("
756
+        SELECT
757
+          `Amount`,
758
+          `Email`,
759
+          `Time`,
760
+          `Currency`,
761
+          `Reason`,
762
+          `Source`,
763
+          `AddedBy`,
764
+          `Rank`,
765
+          `TotalRank`
766
+        FROM
767
+          `donations`
768
+        WHERE
769
+          `UserID` = '$UserID'
770
+        ORDER BY
771
+          `Time`
772
+        DESC
773
+        ");
774
+
775
+        $DonationHistory = G::$DB->to_array(false, MYSQLI_ASSOC, false);
776
+        G::$DB->set_query_id($QueryID);
777
+        return $DonationHistory;
778
+    }
779
+
780
+    public static function get_rank_expiration($UserID)
781
+    {
782
+        $DonorInfo = self::get_donor_info($UserID);
783
+        if ($DonorInfo['SRank'] === MAX_SPECIAL_RANK || $DonorInfo['Rank'] === 1) {
784
+            $Return = 'Never';
785
+        } elseif ($DonorInfo['ExpireTime']) {
786
+            $ExpireTime = strtotime($DonorInfo['ExpireTime']);
787
+            if ($ExpireTime - time() < 60) {
788
+                $Return = 'Soon';
789
+            } else {
790
+                $Expiration = time_diff($ExpireTime); // 32 days
791
+                $Return = "in $Expiration";
792
+            }
793
+        } else {
794
+            $Return = '';
795
+        }
796
+        return $Return;
797
+    }
798
+
799
+    public static function get_leaderboard_position($UserID)
800
+    {
801
+        $UserID = (int) $UserID;
802
+        $QueryID = G::$DB->get_query_id();
803
+        G::$DB->query("SET @RowNum := 0");
804
+
805
+        G::$DB->query("
806
+        SELECT
807
+          `Position`
808
+        FROM
809
+          (
810
+          SELECT
811
+            d.UserID,
812
+            @RowNum := @RowNum + 1 AS POSITION
813
+          FROM
814
+            `users_donor_ranks` AS d
815
+          ORDER BY
816
+            `TotalRank`
817
+          DESC
818
+          ) l
819
+        WHERE
820
+          `UserID` = '$UserID'
821
+        ");
822
+
823
+        if (G::$DB->has_results()) {
824
+            list($Position) = G::$DB->next_record();
825
+        } else {
826
+            $Position = 0;
827
+        }
828
+
829
+        G::$DB->set_query_id($QueryID);
830
+        return $Position;
831
+    }
832
+
833
+    public static function is_donor($UserID)
834
+    {
835
+        return self::get_rank($UserID) > 0;
836
+    }
837
+
838
+    public static function currency_exchange($Amount, $Currency)
839
+    {
840
+        if (!self::is_valid_currency($Currency)) {
841
+            error("$Currency is not valid currency");
842
+        }
843
+        
844
+        switch ($Currency) {
845
+        case 'USD':
846
+          $Amount = self::usd_to_euro($Amount);
847
+          break;
848
+
849
+        case 'BTC':
850
+          $Amount = self::btc_to_euro($Amount);
851
+          break;
852
+
853
+        default:
854
+          break;
855
+        }
856
+        return round($Amount, 2);
857
+    }
858
+
859
+    public static function is_valid_currency($Currency)
860
+    {
861
+        return $Currency === 'EUR' || $Currency === 'BTC' || $Currency === 'USD';
862
+    }
863
+
864
+    public static function btc_to_euro($Amount)
865
+    {
866
+        $Rate = G::$Cache->get_value('btc_rate');
867
+        if (empty($Rate)) {
868
+            G::$Cache->cache_value('btc_rate', $Rate, 86400);
869
+        }
870
+        return $Rate * $Amount;
871
+    }
872
+
873
+    public static function usd_to_euro($Amount)
874
+    {
875
+        $Rate = G::$Cache->get_value('usd_rate');
876
+        if (empty($Rate)) {
877
+            G::$Cache->cache_value('usd_rate', $Rate, 86400);
878
+        }
879
+        return $Rate * $Amount;
880
+    }
881
+}

+ 214
- 0
classes/donationsbitcoin.class.php View File

@@ -0,0 +1,214 @@
1
+<?php
2
+
3
+class DonationsBitcoin
4
+{
5
+    /**
6
+     * Ask bitcoind for a list of all addresses that have received bitcoins
7
+     *
8
+     * @return array (BitcoinAddress => Amount, ...)
9
+     */
10
+    public static function get_received()
11
+    {
12
+        if (defined('BITCOIN_RPC_URL')) {
13
+            $Donations = BitcoinRpc::listreceivedbyaddress();
14
+        }
15
+
16
+        if (empty($Donations)) {
17
+            return [];
18
+        }
19
+
20
+        $BTCUsers = [];
21
+        foreach ($Donations as $Account) {
22
+            $BTCUsers[$Account->address] = $Account->amount;
23
+        }
24
+        return $BTCUsers;
25
+    }
26
+
27
+    /**
28
+     * Ask bitcoind for the current account balance
29
+     *
30
+     * @return float balance
31
+     */
32
+    public static function get_balance()
33
+    {
34
+        if (defined('BITCOIN_RPC_URL')) {
35
+            return BitcoinRpc::getbalance();
36
+        }
37
+    }
38
+
39
+    /**
40
+     * Get a user's existing bitcoin address or generate a new one
41
+     *
42
+     * @param int $UserID
43
+     * @param bool $GenAddress whether to create a new address if it doesn't exist
44
+     * @return false if no address exists and $GenAddress is false
45
+     *         string bitcoin address otherwise
46
+     */
47
+    public static function get_address($UserID, $GenAddress = false)
48
+    {
49
+        $UserID = (int)$UserID;
50
+        $QueryID = G::$DB->get_query_id();
51
+        G::$DB->query("
52
+        SELECT BitcoinAddress
53
+        FROM users_info
54
+          WHERE UserID = '$UserID'");
55
+
56
+        list($Addr) = G::$DB->next_record();
57
+        G::$DB->set_query_id($QueryID);
58
+
59
+        if (!empty($Addr)) {
60
+            return $Addr;
61
+        } elseif ($GenAddress) {
62
+            if (defined('BITCOIN_RPC_URL')) {
63
+                $NewAddr = BitcoinRpc::getnewaddress();
64
+            }
65
+
66
+            if (empty($NewAddr)) {
67
+                error(0);
68
+            }
69
+
70
+            $QueryID = G::$DB->get_query_id();
71
+            G::$DB->query("
72
+            UPDATE users_info
73
+            SET BitcoinAddress = '".db_string($NewAddr)."'
74
+              WHERE UserID = '$UserID'
75
+              AND BitcoinAddress IS NULL");
76
+
77
+            G::$DB->set_query_id($QueryID);
78
+            return $NewAddr;
79
+        } else {
80
+            return false;
81
+        }
82
+    }
83
+
84
+    /**
85
+     * Ask bitcoind for the total amount of bitcoins received
86
+     *
87
+     * @return float amount
88
+     */
89
+    public static function get_total_received()
90
+    {
91
+        if (defined('BITCOIN_RPC_URL')) {
92
+            $Accounts = BitcoinRpc::listreceivedbyaccount();
93
+        }
94
+
95
+        if (empty($Accounts)) {
96
+            return 0.0;
97
+        }
98
+
99
+        foreach ($Accounts as $Account) {
100
+            if ($Account->account === '') {
101
+                return $Account->amount;
102
+            }
103
+        }
104
+        return 0.0;
105
+    }
106
+
107
+    /**
108
+     * Translate bitcoin addresses to user IDs
109
+     *
110
+     * @param array $Addresses list of bitcoin addresses
111
+     * @return array (BitcoinAddress => UserID, ...)
112
+     */
113
+    public static function get_userids($Addresses)
114
+    {
115
+        if (!is_array($Addresses) || empty($Addresses)) {
116
+            return false;
117
+        }
118
+
119
+        $QueryID = G::$DB->get_query_id();
120
+        G::$DB->query("
121
+        SELECT BitcoinAddress, UserID
122
+        FROM users_info
123
+          WHERE BitcoinAddress IN ('" . implode("', '", $Addresses) . "')");
124
+
125
+        if (G::$DB->has_results()) {
126
+            $UserIDs = G::$DB->to_pair(0, 1);
127
+        } else {
128
+            $UserIDs = [];
129
+        }
130
+
131
+        G::$DB->set_query_id($QueryID);
132
+        return $UserIDs;
133
+    }
134
+
135
+    /**
136
+     * Find and process new donations since the last time this function was called.
137
+     */
138
+    public static function find_new_donations()
139
+    {
140
+        global $Debug;
141
+        if (($OldAmount = G::$Cache->get_value('btc_total_received')) === false) {
142
+            $QueryID = G::$DB->get_query_id();
143
+            G::$DB->query("
144
+            SELECT IFNULL(SUM(Amount), 0)
145
+            FROM donations_bitcoin");
146
+
147
+            list($OldAmount) = G::$DB->next_record(MYSQLI_NUM, false);
148
+            G::$DB->set_query_id($QueryID);
149
+        }
150
+
151
+        $NewAmount = self::get_total_received();
152
+        if ($NewAmount < $OldAmount) {
153
+            // This shouldn't happen. Perhaps bitcoind was restarted recently
154
+            // or the block index was removed. Either way, try again later
155
+            send_irc(DEBUG_CHAN, "Bad bitcoin donation data (is $NewAmount, was $OldAmount). If this persists, something is probably wrong.");
156
+            return false;
157
+        }
158
+
159
+        if ($NewAmount > $OldAmount) {
160
+            // I really wish we didn't have to do it like this
161
+            $QueryID = G::$DB->get_query_id();
162
+            G::$DB->query("
163
+            SELECT BitcoinAddress, SUM(Amount)
164
+            FROM donations_bitcoin
165
+              GROUP BY BitcoinAddress");
166
+
167
+            $OldDonations = G::$DB->to_pair(0, 1, false);
168
+            G::$DB->set_query_id($QueryID);
169
+            $NewDonations = self::get_received();
170
+
171
+            foreach ($NewDonations as $Address => &$Amount) {
172
+                if (isset($OldDonations[$Address])) {
173
+                    if ($Amount == $OldDonations[$Address]) { // Direct comparison should be fine as everything comes from bitcoind
174
+                        unset($NewDonations[$Address]);
175
+                        continue;
176
+                    }
177
+
178
+                    $Debug->log_var(array('old' => $OldDonations[$Address], 'new' => $Amount), "New donations from $Address");
179
+                    // PHP doesn't do fixed-point math, and json_decode has already botched the precision
180
+                    // so let's just round this off to satoshis and pray that we're on a 64 bit system
181
+                    $Amount = round($Amount - $OldDonations[$Address], 8);
182
+                }
183
+                $NewDonations[$Address] = $Amount;
184
+            }
185
+
186
+            $Debug->log_var($NewDonations, '$NewDonations');
187
+            foreach (self::get_userids(array_keys($NewDonations)) as $Address => $UserID) {
188
+                Donations::regular_donate($UserID, $NewDonations[$Address], 'Bitcoin Parser', '', 'BTC');
189
+                self::store_donation($Address, $NewDonations[$Address]);
190
+            }
191
+            G::$Cache->cache_value('btc_total_received', $NewAmount, 0);
192
+        }
193
+    }
194
+
195
+    /**
196
+     * Record a donation in the database
197
+     *
198
+     * @param string $Address bitcoin address
199
+     * @param double $Amount amount of bitcoins transferred
200
+     */
201
+    public static function store_donation($Address, $Amount)
202
+    {
203
+        if (!is_numeric($Amount) || $Amount <= 0) {
204
+            // Panic!
205
+            return false;
206
+        }
207
+
208
+        G::$DB->query("
209
+        INSERT INTO donations_bitcoin
210
+          (BitcoinAddress, Amount)
211
+        VALUES
212
+          ('$Address', $Amount)");
213
+    }
214
+}

+ 227
- 0
classes/donationsview.class.php View File

@@ -0,0 +1,227 @@
1
+<?php
2
+
3
+class DonationsView
4
+{
5
+    public static function render_mod_donations($UserID)
6
+    {
7
+        ?>
8
+<table class="layout box" id="donation_box">
9
+  <tr class="colhead">
10
+    <td colspan="2">
11
+      Donor System (add points)
12
+    </td>
13
+  </tr>
14
+  <tr>
15
+    <td class="label">Value:</td>
16
+    <td>
17
+      <input type="text" name="donation_value" onkeypress="return isNumberKey(event);" />
18
+      <select name="donation_currency">
19
+        <option value="EUR">EUR</option>
20
+        <option value="USD">USD</option>
21
+        <option value="BTC">BTC</option>
22
+      </select>
23
+    </td>
24
+  </tr>
25
+  <tr>
26
+    <td class="label">Reason:</td>
27
+    <td><input type="text" class="wide_input_text" name="donation_reason" /></td>
28
+  </tr>
29
+  <tr>
30
+    <td align="right" colspan="2">
31
+      <input type="submit" name="donor_points_submit" value="Add donor points" />
32
+    </td>
33
+  </tr>
34
+</table>
35
+
36
+<table class="layout box" id="donor_points_box">
37
+  <tr class="colhead">
38
+    <td colspan="3" class="tooltip"
39
+      title='Use this tool only when manually correcting values. If crediting donations normally, use the "Donor System (add points)" tool'>
40
+      Donor System (modify values)
41
+    </td>
42
+  </tr>
43
+  <tr>
44
+    <td class="label tooltip" title="Active points determine a user's Donor Rank and do expire.">Active points:</td>
45
+    <td><input type="text" name="donor_rank" onkeypress="return isNumberKey(event);"
46
+        value="<?=Donations::get_rank($UserID)?>" /></td>
47
+  </tr>
48
+  <tr>
49
+    <td class="label tooltip"
50
+      title="Total points represent a user's overall total and never expire. Total points determines a user's Special Rank and Donor Leaderboard placement.">
51
+      Total points:</td>
52
+    <td><input type="text" name="total_donor_rank" onkeypress="return isNumberKey(event);"
53
+        value="<?=Donations::get_total_rank($UserID)?>" /></td>
54
+  </tr>
55
+  <tr>
56
+    <td class="label">Reason:</td>
57
+    <td><input type="text" class="wide_input_text" name="reason" /></td>
58
+  </tr>
59
+  <tr>
60
+    <td align="right" colspan="2">
61
+      <input type="submit" name="donor_values_submit" value="Change point values" />
62
+    </td>
63
+  </tr>
64
+</table>
65
+<?php
66
+    }
67
+
68
+    public static function render_donor_stats($UserID)
69
+    {
70
+        $OwnProfile = G::$LoggedUser['ID'] === $UserID;
71
+        if (check_perms("users_mod") || $OwnProfile || Donations::is_visible($UserID)) {
72
+            ?>
73
+<div class="box box_info box_userinfo_donor_stats">
74
+  <div class="head colhead_dark">Donor Statistics</div>
75
+  <ul class="stats nobullet">
76
+    <?php
77
+      if (Donations::is_donor($UserID)) {
78
+          if (check_perms('users_mod') || $OwnProfile) {
79
+              ?>
80
+    <li>
81
+      Total donor points: <?=Donations::get_total_rank($UserID)?>
82
+    </li>
83
+    <?php
84
+          } ?>
85
+    <li>
86
+      Current donor rank: <?=self::render_rank(Donations::get_rank($UserID), Donations::get_special_rank($UserID), true)?>
87
+    </li>
88
+    <li>
89
+      Leaderboard position: <?=Donations::get_leaderboard_position($UserID)?>
90
+    </li>
91
+    <li>
92
+      Last donated: <?=time_diff(Donations::get_donation_time($UserID))?>
93
+    </li>
94
+    <li>
95
+      Rank expires: <?=(Donations::get_rank_expiration($UserID))?>
96
+    </li>
97
+    <?php
98
+      } else { ?>
99
+    <li>
100
+      This user hasn't donated.
101
+    </li>
102
+    <?php } ?>
103
+  </ul>
104
+</div>
105
+<?php
106
+        }
107
+    }
108
+
109
+    public static function render_profile_rewards($EnabledRewards, $ProfileRewards)
110
+    {
111
+        for ($i = 1; $i <= 4; $i++) {
112
+            if ($EnabledRewards['HasProfileInfo' . $i] && $ProfileRewards['ProfileInfo' . $i]) {
113
+                ?>
114
+<div class="box">
115
+  <div class="head">
116
+    <span><?=!empty($ProfileRewards['ProfileInfoTitle' . $i]) ? display_str($ProfileRewards['ProfileInfoTitle' . $i]) : "Extra Profile " . ($i + 1)?></span>
117
+    <span class="float_right"><a
118
+        data-toggle-target="#profilediv_<?=$i?>"
119
+        data-toggle-replace="Show" class="brackets">Hide</a></span>
120
+  </div>
121
+  <div class="pad profileinfo" id="profilediv_<?=$i?>">
122
+    <?= Text::full_format($ProfileRewards['ProfileInfo' . $i]); ?>
123
+  </div>
124
+</div>
125
+<?php
126
+            }
127
+        }
128
+    }
129
+
130
+    public static function render_donation_history($DonationHistory)
131
+    {
132
+        if (empty($DonationHistory)) {
133
+            return;
134
+        } ?>
135
+<div class="box" id="donation_history_box">
136
+  <div class="head">
137
+    Donation History <a data-toggle-target="#donation_history" class="brackets" style="float_right">Toggle</a>
138
+  </div>
139
+  <div class="hidden" id="donation_history">
140
+    <table cellpadding="6" cellspacing="1" border="0" class="border" width="100%">
141
+      <tbody>
142
+        <tr class="colhead_dark">
143
+          <td>
144
+            <strong>Source</strong>
145
+          </td>
146
+          <td>
147
+            <strong>Date</strong>
148
+          </td>
149
+          <td>
150
+            <strong>Amount (EUR)</strong>
151
+          </td>
152
+          <td>
153
+            <strong>Added Points</strong>
154
+          </td>
155
+          <td>
156
+            <strong>Total Points</strong>
157
+          </td>
158
+          <td>
159
+            <strong>Email</strong>
160
+          </td>
161
+          <td style="width: 30%;">
162
+            <strong>Reason</strong>
163
+          </td>
164
+        </tr>
165
+        <?php foreach ($DonationHistory as $Donation) { ?>
166
+        <tr class="row">
167
+          <td>
168
+            <?=display_str($Donation['Source'])?>
169
+            (<?=Users::format_username($Donation['AddedBy'])?>)
170
+          </td>
171
+          <td>
172
+            <?=$Donation['Time']?>
173
+          </td>
174
+          <td>
175
+            <?=$Donation['Amount']?>
176
+          </td>
177
+          <td>
178
+            <?=$Donation['Rank']?>
179
+          </td>
180
+          <td>
181
+            <?=$Donation['TotalRank']?>
182
+          </td>
183
+          <td>
184
+            <?=display_str($Donation['Email'])?>
185
+          </td>
186
+          <td>
187
+            <?=display_str($Donation['Reason'])?>
188
+          </td>
189
+        </tr>
190
+        <?php
191
+    } ?>
192
+      </tbody>
193
+    </table>
194
+  </div>
195
+</div>
196
+<?php
197
+    }
198
+
199
+    public static function render_rank($Rank, $SpecialRank, $ShowOverflow = false)
200
+    {
201
+        if ($SpecialRank === 3) {
202
+            $Display = '∞ [Diamond]';
203
+        } else {
204
+            $CurrentRank = $Rank >= MAX_RANK ? MAX_RANK : $Rank;
205
+            $Overflow = $Rank - $CurrentRank;
206
+            $Display = $CurrentRank;
207
+            if ($Display === 5 || $Display === 6) {
208
+                $Display--;
209
+            }
210
+            if ($ShowOverflow && $Overflow) {
211
+                $Display .= " (+$Overflow)";
212
+            }
213
+            if ($Rank >= 6) {
214
+                $Display .= ' [Gold]';
215
+            } elseif ($Rank >= 4) {
216
+                $Display .= ' [Silver]';
217
+            } elseif ($Rank >= 3) {
218
+                $Display .= ' [Bronze]';
219
+            } elseif ($Rank >= 2) {
220
+                $Display .= ' [Copper]';
221
+            } elseif ($Rank >= 1) {
222
+                $Display .= ' [Red]';
223
+            }
224
+        }
225
+        echo $Display;
226
+    }
227
+}

+ 302
- 0
classes/env.class.php View File

@@ -0,0 +1,302 @@
1
+<?php
2
+
3
+/**
4
+ * ENV
5
+ *
6
+ * The PHP singleton is considered bad design for nebulous reasons,
7
+ * but for securely loading a site config it does exactly what we need:
8
+ *
9
+ *  - Ensure that only one instance of itself can ever exist
10
+ *  - Load the instance everywhere we need to do $ENV->VALUE
11
+ *  - No memory penalty because of multiple $ENV instances
12
+ *  - Static values in classes/config.php are immutable
13
+ *  - Site configs don't exist in the constants table
14
+ *  - Separate public and private config values
15
+ *
16
+ * @see https://stackoverflow.com/a/3724689
17
+ * @see https://phpenthusiast.com/blog/the-singleton-design-pattern-in-php
18
+ */
19
+
20
+class ENV
21
+{
22
+    # Disinstantiates itself
23
+    private static $ENV = null;
24
+
25
+    # Config options receptacles
26
+    private static $Priv = []; # Passwords, app keys, database, etc.
27
+    private static $Pub = []; # Site meta, options, resources, etc.
28
+
29
+
30
+    /**
31
+     * __functions()
32
+     */
33
+
34
+    # Prevents outside construction
35
+    private function __construct()
36
+    {
37
+        # Would be expensive, e.g.,
38
+        #   $ENV = new ENV();
39
+        return;
40
+    }
41
+
42
+    # Prevents multiple instances
43
+    public function __clone()
44
+    {
45
+        return trigger_error(
46
+            'clone() not allowed',
47
+            E_USER_ERROR
48
+        );
49
+    }
50
+
51
+    # $this->key returns public->key
52
+    public function __get($key)
53
+    {
54
+        return isset(self::$Pub[$key])
55
+            ? self::$Pub[$key]
56
+            : false;
57
+    }
58
+    
59
+    # isset()
60
+    public function __isset($key)
61
+    {
62
+        return isset(self::$Pub[$key]);
63
+    }
64
+
65
+
66
+    /**
67
+     * Gets n' Sets
68
+     */
69
+
70
+    # Calls its self's creation or returns itself
71
+    public static function go()
72
+    {
73
+        return (self::$ENV === null)
74
+            ? self::$ENV = new ENV()
75
+            : self::$ENV;
76
+    }
77
+
78
+    # get
79
+    public static function getPriv($key)
80
+    {
81
+        return isset(self::$Priv[$key])
82
+            ? self::$Priv[$key]
83
+            : false;
84
+    }
85
+
86
+    public static function getPub($key)
87
+    {
88
+        return isset(self::$Pub[$key])
89
+            ? self::$Pub[$key]
90
+            : false;
91
+    }
92
+
93
+    # set
94
+    public static function setPriv($key, $value)
95
+    {
96
+        return self::$Priv[$key] = $value;
97
+    }
98
+
99
+    public static function setPub($key, $value)
100
+    {
101
+        return self::$Pub[$key] = $value;
102
+    }
103
+
104
+
105
+    /**
106
+     * toArray
107
+     * @see https://ben.lobaugh.net/blog/567/php-recursively-convert-an-object-to-an-array
108
+     */
109
+    public function toArray($obj)
110
+    {
111
+        if (is_object($obj)) {
112
+            $obj = (array) $obj;
113
+        }
114
+
115
+        if (is_array($obj)) {
116
+            $new = array();
117
+
118
+            foreach ($obj as $key => $value) {
119
+                $new[$key] = $this->toArray($value);
120
+            }
121
+        } else {
122
+            $new = $obj;
123
+        }
124
+        return $new;
125
+    }
126
+
127
+
128
+    /**
129
+     * fromJson
130
+     *
131
+     * @param string $JSON Valid JavaScript object string
132
+     * @return RecursiveArrayObject Not stdClass as in json_decode()
133
+     */
134
+
135
+    public function fromJson($str)
136
+    {
137
+        if (!is_string($str) || is_empty($str)) {
138
+            error('$ENV->fromJson() expects a string.');
139
+        }
140
+
141
+        # Decode to array and construct RAO
142
+        return $RAO = new RecursiveArrayObject(
143
+            json_decode($str, true)
144
+        );
145
+    }
146
+
147
+
148
+    /**
149
+     * dedupe
150
+     *
151
+     * Takes a collection (usually an array) of various jumbled $ENV slices.
152
+     * Returns a once-deduplicated RecursiveArrayObject with original nesting intact.
153
+     * Simple and handy if you need to populate a form with arbitrary collections of metadata.
154
+     */
155
+    public function dedupe($obj)
156
+    {
157
+        if (is_object($obj)) {
158
+            $obj = (array) $obj;
159
+        }
160
+
161
+        return $RAO = new RecursiveArrayObject(
162
+            array_unique($this->toArray($obj))
163
+        );
164
+    }
165
+
166
+
167
+    /**
168
+     * map
169
+     *
170
+     * Simple array_map() object wrapper.
171
+     * Maps a callback (or default) to an object.
172
+     *
173
+     * Example output:
174
+     * $Hashes = $ENV->map('md5', $ENV->CATS->SEQ);
175
+     *
176
+     * var_dump($Hashes);
177
+     * object(RecursiveArrayObject)#324 (1) {
178
+     *   ["storage":"ArrayObject":private]=>
179
+     *   array(6) {
180
+     *     ["ID"]=>
181
+     *     string(32) "28c8edde3d61a0411511d3b1866f0636"
182
+     *     ["Name"]=>
183
+     *     string(32) "fe83ccb5dc96dbc0658b3c4672c7d5fe"
184
+     *     ["Icon"]=>
185
+     *     string(32) "52963afccc006d2bce3c890ad9e8f73a"
186
+     *     ["Platforms"]=>
187
+     *     string(32) "d41d8cd98f00b204e9800998ecf8427e"
188
+     *     ["Formats"]=>
189
+     *     string(32) "d41d8cd98f00b204e9800998ecf8427e"
190
+     *     ["Description"]=>
191
+     *     string(32) "ca6628e8c13411c800d1d9d0eaccd849"
192
+     *   }
193
+     * }
194
+     *
195
+     * var_dump($Hashes->Icon);
196
+     * string(32) "52963afccc006d2bce3c890ad9e8f73a"
197
+     *
198
+     * @param string $fn Callback function
199
+     * @param object $obj Object to operate on
200
+     * @return object $RAO Mapped RecursiveArrayObject
201
+     */
202
+    public function map($fn = '', $obj = null)
203
+    {
204
+        # Set a default function if desired
205
+        if (empty($fn) && !is_object($fn)) {
206
+            $fn = 'array_filter';
207
+        }
208
+
209
+        # Quick sanity check
210
+        if ($fn === 'array_map') {
211
+            error("map() can't invoke the function it wraps.");
212
+        }
213
+        
214
+        /**
215
+         * $fn not a closure
216
+         *
217
+         * var_dump(
218
+         *   gettype(
219
+         *     (function() { return; })
220
+         * ));
221
+         * string(6) "object"
222
+         */
223
+        if (is_string($fn) && !is_object($fn)) {
224
+            $fn = trim(strtok($fn, ' '));
225
+        }
226
+
227
+        # Map the sanitized function name
228
+        # to a mapped array conversion
229
+        return $RAO = new RecursiveArrayObject(
230
+            array_map(
231
+                $fn,
232
+                array_map(
233
+                    $fn,
234
+                    $this->toArray($obj)
235
+                )
236
+            )
237
+        );
238
+    }
239
+}
240
+
241
+
242
+/**
243
+ * @author: etconsilium@github
244
+ * @license: BSDLv2
245
+ * @see https://github.com/etconsilium/php-recursive-array-object
246
+ */
247
+
248
+class RecursiveArrayObject extends \ArrayObject
249
+{
250
+    /**
251
+     * __construct
252
+     */
253
+    public function __construct($input = null, $flags = self::ARRAY_AS_PROPS, $iterator_class = "ArrayIterator")
254
+    {
255
+        foreach ($input as $key => $value) {
256
+            $this->__set($key, $value);
257
+        }
258
+        return $this;
259
+    }
260
+
261
+    /**
262
+     * __set
263
+     */
264
+    public function __set($name, $value)
265
+    {
266
+        if (is_array($value) || is_object($value)) {
267
+            $this->offsetSet($name, (new self($value)));
268
+        } else {
269
+            $this->offsetSet($name, $value);
270
+        }
271
+    }
272
+
273
+    /**
274
+     * __get
275
+     */
276
+    public function __get($name)
277
+    {
278
+        if ($this->offsetExists($name)) {
279
+            return $this->offsetGet($name);
280
+        } elseif (array_key_exists($name, $this)) {
281
+            return $this[$name];
282
+        } else {
283
+            throw new \InvalidArgumentException(sprintf('$this have not prop `%s`', $name));
284
+        }
285
+    }
286
+
287
+    /**
288
+     * __isset
289
+     */
290
+    public function __isset($name)
291
+    {
292
+        return array_key_exists($name, $this);
293
+    }
294
+
295
+    /**
296
+     * __unset
297
+     */
298
+    public function __unset($name)
299
+    {
300
+        unset($this[$name]);
301
+    }
302
+}

+ 114
- 0
classes/feed.class.php View File

@@ -0,0 +1,114 @@
1
+<?php
2
+declare(strict_types = 1);
3
+
4
+class Feed
5
+{
6
+    /**
7
+     * open_feed
8
+     */
9
+    public function open_feed()
10
+    {
11
+        header('Content-Type: application/xml; charset=utf-8');
12
+        echo "<?xml version='1.0' encoding='utf-8'?>",
13
+             "<rss xmlns:dc='http://purl.org/dc/elements/1.1/' version='2.0'><channel>";
14
+    }
15
+
16
+    /**
17
+     * close_feed
18
+     */
19
+    public function close_feed()
20
+    {
21
+        echo "</channel></rss>";
22
+    }
23
+
24
+    /**
25
+     * channel
26
+     */
27
+    public function channel($Title, $Description, $Section = '')
28
+    {
29
+        $ENV = ENV::go();
30
+        $Site = site_url();
31
+
32
+        # echo commas because <<<XML would copy whitespace
33
+        echo "<title>$Title $ENV->SEP $ENV->SITE_NAME</title>",
34
+             "<link>$Site$Section</link>",
35
+             "<description>$Description</description>",
36
+             "<language>en-us</language>",
37
+             "<lastBuildDate>".date('r')."</lastBuildDate>",
38
+             "<docs>http://blogs.law.harvard.edu/tech/rss</docs>",
39
+             "<generator>Gazelle Feed Class</generator>";
40
+    }
41
+
42
+    /**
43
+     * item
44
+     */
45
+    public function item($Title, $Description, $Page, $Creator, $Comments = '', $Category = '', $Date = '')
46
+    {
47
+        $Site = site_url();
48
+        if ($Date === '') {
49
+            $Date = date('r');
50
+        } else {
51
+            $Date = date('r', strtotime($Date));
52
+        }
53
+
54
+        // Escape with CDATA, otherwise the feed breaks.
55
+        $Item  = "<item>";
56
+        $Item .= "<title><![CDATA[$Title]]></title>";
57
+        $Item .= "<description><![CDATA[$Description]]></description>";
58
+        $Item .= "<pubDate>$Date</pubDate>";
59
+        $Item .= "<link>$Site$Page</link>";
60
+        $Item .= "<guid>$Site$Page</guid>";
61
+
62
+        if ($Comments !== '') {
63
+            $Item .= "<comments>$Site$Comments</comments>";
64
+        }
65
+
66
+        if ($Category !== '') {
67
+            $Item .= "<category><![CDATA[$Category]]></category>";
68
+        }
69
+
70
+        $Item .= "<dc:creator>$Creator</dc:creator></item>";
71
+        return $Item;
72
+    }
73
+
74
+    /**
75
+     * retrieve
76
+     */
77
+    public function retrieve($CacheKey, $AuthKey, $PassKey)
78
+    {
79
+        global $Cache;
80
+        $Entries = $Cache->get_value($CacheKey);
81
+
82
+        if (!$Entries) {
83
+            $Entries = [];
84
+        } else {
85
+            foreach ($Entries as $Item) {
86
+                echo str_replace(
87
+                    array('[[PASSKEY]]', '[[AUTHKEY]]'),
88
+                    array(display_str($PassKey), display_str($AuthKey)),
89
+                    $Item
90
+                );
91
+            }
92
+        }
93
+    }
94
+
95
+    /**
96
+     * populate
97
+     */
98
+    public function populate($CacheKey, $Item)
99
+    {
100
+        global $Cache;
101
+        $Entries = $Cache->get_value($CacheKey, true);
102
+
103
+        if (!$Entries) {
104
+            $Entries = [];
105
+        } else {
106
+            if (count($Entries) >= 50) {
107
+                array_pop($Entries);
108
+            }
109
+        }
110
+        
111
+        array_unshift($Entries, $Item);
112
+        $Cache->cache_value($CacheKey, $Entries, 0); // inf cache
113
+    }
114
+}

+ 699
- 0
classes/format.class.php View File

@@ -0,0 +1,699 @@
1
+<?php
2
+
3
+class Format
4
+{
5
+    /**
6
+     * Torrent Labels
7
+     * Map a common display string to a CSS class
8
+     * Indexes are lower case
9
+     * Note the "tl_" prefix for "torrent label"
10
+     *
11
+     * There are five basic types:
12
+     *  - tl_free (leech status)
13
+     *  - tl_snatched
14
+     *  - tl_reported
15
+     *  - tl_approved
16
+     *  - tl_notice (default)
17
+     *
18
+     * @var array Strings
19
+     */
20
+    private static $TorrentLabels = array(
21
+        'default'  => 'tl_notice',
22
+        'snatched' => 'tl_snatched',
23
+        'seeding'  => 'tl_seeding',
24
+        'leeching' => 'tl_leeching',
25
+
26
+        'freeleech'     => 'tl_free',
27
+        'neutral leech' => 'tl_free tl_neutral',
28
+        'personal freeleech' => 'tl_free tl_personal',
29
+
30
+        'reported'    => 'tl_reported',
31
+        'bad tags'    => 'tl_reported tl_bad_tags',
32
+        'bad folders' => 'tl_reported tl_bad_folders',
33
+        'bad file names' => 'tl_reported tl_bad_file_names',
34
+
35
+        'uncensored' => 'tl_notice'
36
+    );
37
+
38
+
39
+    /**
40
+     * Shorten a string
41
+     *
42
+     * @param $Str string to cut
43
+     * @param $Length cut at length
44
+     * @param $Hard force cut at length instead of at closest word
45
+     * @param $ShowDots Show dots at the end
46
+     * @return string formatted string
47
+     */
48
+    public static function cut_string($Str, $Length, $Hard = false, $ShowDots = true)
49
+    {
50
+        if (mb_strlen($Str, 'UTF-8') > $Length) {
51
+            if ($Hard === 0) {
52
+                // Not hard, cut at closest word
53
+                $CutDesc = mb_substr($Str, 0, $Length, 'UTF-8');
54
+                $DescArr = explode(' ', $CutDesc);
55
+
56
+                if (count($DescArr) > 1) {
57
+                    array_pop($DescArr);
58
+                    $CutDesc = implode(' ', $DescArr);
59
+                }
60
+                
61
+                if ($ShowDots) {
62
+                    $CutDesc .= '…';
63
+                }
64
+            } else {
65
+                $CutDesc = mb_substr($Str, 0, $Length, 'UTF-8');
66
+                if ($ShowDots) {
67
+                    $CutDesc .= '…';
68
+                }
69
+            }
70
+
71
+            return $CutDesc;
72
+        } else {
73
+            return $Str;
74
+        }
75
+    }
76
+
77
+
78
+    /**
79
+     * Gets the CSS class corresponding to a ratio
80
+     *
81
+     * @param $Ratio ratio to get the css class for
82
+     * @return string the CSS class corresponding to the ratio range
83
+     */
84
+    public static function get_ratio_color($Ratio)
85
+    {
86
+        if ($Ratio < 0.1) {
87
+            return 'r00';
88
+        }
89
+
90
+        if ($Ratio < 0.2) {
91
+            return 'r01';
92
+        }
93
+
94
+        if ($Ratio < 0.3) {
95
+            return 'r02';
96
+        }
97
+
98
+        if ($Ratio < 0.4) {
99
+            return 'r03';
100
+        }
101
+
102
+        if ($Ratio < 0.5) {
103
+            return 'r04';
104
+        }
105
+
106
+        if ($Ratio < 0.6) {
107
+            return 'r05';
108
+        }
109
+
110
+        if ($Ratio < 0.7) {
111
+            return 'r06';
112
+        }
113
+
114
+        if ($Ratio < 0.8) {
115
+            return 'r07';
116
+        }
117
+
118
+        if ($Ratio < 0.9) {
119
+            return 'r08';
120
+        }
121
+
122
+        if ($Ratio < 1) {
123
+            return 'r09';
124
+        }
125
+
126
+        if ($Ratio < 2) {
127
+            return 'r10';
128
+        }
129
+
130
+        if ($Ratio < 5) {
131
+            return 'r20';
132
+        }
133
+
134
+        return 'r50';
135
+    }
136
+
137
+
138
+    /**
139
+     * Calculates and formats a ratio.
140
+     *
141
+     * @param int $Dividend AKA numerator
142
+     * @param int $Divisor
143
+     * @param boolean $Color if true, ratio will be coloured.
144
+     * @return string formatted ratio HTML
145
+     */
146
+    public static function get_ratio_html($Dividend, $Divisor, $Color = true)
147
+    {
148
+        $Ratio = self::get_ratio($Dividend, $Divisor);
149
+
150
+        if ($Ratio === false) {
151
+            return '&ndash;';
152
+        }
153
+
154
+        if ($Ratio === '∞') {
155
+            return '<span class="tooltip r99" title="Infinite">∞</span>';
156
+        }
157
+
158
+        if ($Color) {
159
+            $Ratio = sprintf(
160
+                '<span class="tooltip %s" title="%s">%s</span>',
161
+                self::get_ratio_color($Ratio),
162
+                self::get_ratio($Dividend, $Divisor, 5),
163
+                $Ratio
164
+            );
165
+        }
166
+
167
+        return $Ratio;
168
+    }
169
+
170
+
171
+    /**
172
+     * Returns ratio
173
+     * @param int $Dividend
174
+     * @param int $Divisor
175
+     * @param int $Decimal floor to n decimals (e.g. subtract .005 to floor to 2 decimals)
176
+     * @return boolean|string
177
+     */
178
+    public static function get_ratio($Dividend, $Divisor, $Decimal = 2)
179
+    {
180
+        if ((int) $Divisor === 0 && (int) $Dividend === 0) {
181
+            return false;
182
+        }
183
+
184
+        if ((int) $Divisor === 0) {
185
+            return '∞';
186
+        }
187
+
188
+        return number_format(max($Dividend / $Divisor - (0.5 / pow(10, $Decimal)), 0), $Decimal);
189
+    }
190
+
191
+
192
+    /**
193
+     * Gets the query string of the current page, minus the parameters in $Exclude
194
+     *
195
+     * @param array $Exclude Query string parameters to leave out, or blank to include all parameters.
196
+     * @param bool $Escape Whether to return a string prepared for HTML output
197
+     * @param bool $Sort Whether to sort the parameters by key
198
+     * @return An optionally HTML sanatized query string
199
+     */
200
+    public static function get_url($Exclude = false, $Escape = true, $Sort = false)
201
+    {
202
+        if ($Exclude !== false) {
203
+            $Separator = $Escape ? '&amp;' : '&';
204
+            $QueryItems = null;
205
+            parse_str($_SERVER['QUERY_STRING'], $QueryItems);
206
+
207
+            foreach ($Exclude as $Key) {
208
+                unset($QueryItems[$Key]);
209
+            }
210
+
211
+            if ($Sort) {
212
+                ksort($QueryItems);
213
+            }
214
+
215
+            return http_build_query($QueryItems, '', $Separator);
216
+        } else {
217
+            return $Escape ? display_str($_SERVER['QUERY_STRING']) : $_SERVER['QUERY_STRING'];
218
+        }
219
+    }
220
+
221
+
222
+    /**
223
+     * Finds what page we're on and gives it to us, as well as the LIMIT clause for SQL
224
+     * Takes in $_GET['page'] as an additional input
225
+     *
226
+     * @param $PerPage Results to show per page
227
+     * @param $DefaultResult Optional, which result's page we want if no page is specified
228
+     * If this parameter is not specified, we will default to page 1
229
+     *
230
+     * @return array(int, string) What page we are on, and what to use in the LIMIT section of a query
231
+     * e.g. "SELECT […] LIMIT $Limit;"
232
+     */
233
+    public static function page_limit($PerPage, $DefaultResult = 1)
234
+    {
235
+        if (!isset($_GET['page'])) {
236
+            $Page = ceil($DefaultResult / $PerPage);
237
+            # todo: Strict equality breaks comment fetching
238
+            if ($Page == 0) {
239
+                $Page = 1;
240
+            }
241
+            $Limit = $PerPage;
242
+        } else {
243
+            if (!is_number($_GET['page'])) {
244
+                error(0);
245
+            }
246
+            $Page = $_GET['page'];
247
+
248
+            if ($Page <= 0) {
249
+                $Page = 1;
250
+            }
251
+            $Limit = $PerPage * $Page - $PerPage . ", $PerPage";
252
+        }
253
+        return array($Page, $Limit);
254
+    }
255
+
256
+
257
+    /**
258
+     * catalogue_limit()
259
+     *
260
+     * A9 magic. Some other poor soul can write the phpdoc.
261
+     * For data stored in memcached catalogues (giant arrays), e.g., forum threads
262
+     */
263
+    public static function catalogue_limit($Page, $PerPage, $CatalogueSize = 500)
264
+    {
265
+        $CatalogueID = floor(($PerPage * $Page - $PerPage) / $CatalogueSize);
266
+        $CatalogueLimit = ($CatalogueID * $CatalogueSize).", $CatalogueSize";
267
+        return array($CatalogueID, $CatalogueLimit);
268
+    }
269
+
270
+
271
+    /**
272
+     * catalogue_select()
273
+     */
274
+    public static function catalogue_select($Catalogue, $Page, $PerPage, $CatalogueSize = 500)
275
+    {
276
+        return array_slice($Catalogue, (($PerPage * $Page - $PerPage) % $CatalogueSize), $PerPage, true);
277
+    }
278
+
279
+
280
+    /*
281
+     * Get pages
282
+     * Returns a page list, given certain information about the pages.
283
+     *
284
+     * @param int $StartPage: The current record the page you're on starts with.
285
+     *    e.g. if you're on page 2 of a forum thread with 25 posts per page, $StartPage is 25.
286
+     *    If you're on page 1, $StartPage is 0.
287
+     * @param int $TotalRecords: The total number of records in the result set.
288
+     *    e.g. if you're on a forum thread with 152 posts, $TotalRecords is 152.
289
+     * @param int $ItemsPerPage: Self-explanatory. The number of records shown on each page
290
+     *    e.g. if there are 25 posts per forum page, $ItemsPerPage is 25.
291
+     * @param int $ShowPages: The number of page links that are shown.
292
+     *    e.g. If there are 20 pages that exist, but $ShowPages is only 11, only 11 links will be shown.
293
+     * @param string $Anchor A URL fragment to attach to the links.
294
+     *    e.g. '#comment12'
295
+     * @return A sanitized HTML page listing.
296
+     */
297
+    public static function get_pages($StartPage, $TotalRecords, $ItemsPerPage, $ShowPages = 11, $Anchor = '')
298
+    {
299
+        global $Document, $Method, $Mobile;
300
+        $Location = "$Document.php";
301
+        $StartPage = ceil($StartPage);
302
+        $TotalPages = 0;
303
+
304
+        if ($TotalRecords > 0) {
305
+            $StartPage = min($StartPage, ceil($TotalRecords / $ItemsPerPage));
306
+
307
+            $ShowPages--;
308
+            $TotalPages = ceil($TotalRecords / $ItemsPerPage);
309
+
310
+            if ($TotalPages > $ShowPages) {
311
+                $StartPosition = $StartPage - round($ShowPages / 2);
312
+
313
+                if ($StartPosition <= 0) {
314
+                    $StartPosition = 1;
315
+                } else {
316
+                    if ($StartPosition >= ($TotalPages - $ShowPages)) {
317
+                        $StartPosition = $TotalPages - $ShowPages;
318
+                    }
319
+                }
320
+
321
+                $StopPage = $ShowPages + $StartPosition;
322
+            } else {
323
+                $StopPage = $TotalPages;
324
+                $StartPosition = 1;
325
+            }
326
+
327
+            $StartPosition = max($StartPosition, 1);
328
+
329
+            $QueryString = self::get_url(array('page', 'post'));
330
+            if ($QueryString !== '') {
331
+                $QueryString = "&amp;$QueryString";
332
+            }
333
+
334
+            $Pages = '';
335
+
336
+            if ($StartPage > 1) {
337
+                $Pages .= "<a href='$Location?page=1$QueryString$Anchor'><strong>&laquo; First</strong></a> ";
338
+                $Pages .= "<a href='$Location?page=".($StartPage - 1).$QueryString.$Anchor."' class='pager_prev'><strong>&lsaquo; Prev</strong></a> | ";
339
+            }
340
+            // End change
341
+
342
+            if (!$Mobile) {
343
+                for ($i = $StartPosition; $i <= $StopPage; $i++) {
344
+                    if ($i !== $StartPage) {
345
+                        $Pages .= "<a href='$Location?page=$i$QueryString$Anchor'>";
346
+                    }
347
+
348
+                    $Pages .= '<strong>';
349
+                    if ($i * $ItemsPerPage > $TotalRecords) {
350
+                        $Pages .= ((($i - 1) * $ItemsPerPage) + 1)."-$TotalRecords";
351
+                    } else {
352
+                        $Pages .= ((($i - 1) * $ItemsPerPage) + 1).'-'.($i * $ItemsPerPage);
353
+                    }
354
+
355
+                    $Pages .= '</strong>';
356
+                    if ($i !== $StartPage) {
357
+                        $Pages .= '</a>';
358
+                    }
359
+
360
+                    if ($i < $StopPage) {
361
+                        $Pages .= ' | ';
362
+                    }
363
+                }
364
+            } else {
365
+                $Pages .= $StartPage;
366
+            }
367
+
368
+            if ($StartPage && $StartPage < $TotalPages) {
369
+                $Pages .= " | <a href='$Location?page=".($StartPage + 1).$QueryString.$Anchor."' class='pager_next'><strong>Next &rsaquo;</strong></a> ";
370
+                $Pages .= "<a href='$Location?page=$TotalPages$QueryString$Anchor'><strong> Last&nbsp;&raquo;</strong></a>";
371
+            }
372
+        }
373
+
374
+        if ($TotalPages > 1) {
375
+            return $Pages;
376
+        }
377
+    }
378
+
379
+
380
+    /**
381
+     * Format a size in bytes as a human readable string in KiB/MiB/…
382
+     * Note: KiB, MiB, etc. are the IEC units, which are in base 2.
383
+     * KB, MB are the SI units, which are in base 10.
384
+     *
385
+     * @param int $Size
386
+     * @param int $Levels Number of decimal places. Defaults to 2, unless the size >= 1TB, in which case it defaults to 4.
387
+     * @return string formatted number.
388
+     */
389
+    public static function get_size($Size, $Levels = 2)
390
+    {
391
+        $Units = array('B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB');
392
+        $Size = (double) $Size;
393
+
394
+        for ($Steps = 0; abs($Size) >= 1024 && $Steps < count($Units); $Size /= 1024, $Steps++) {
395
+        }
396
+
397
+        if (func_num_args() === 1 && $Steps >= 4) {
398
+            $Levels++;
399
+        }
400
+        return number_format($Size, $Levels) . ' ' . $Units[$Steps];
401
+    }
402
+
403
+
404
+    /**
405
+     * Format a number as a multiple of its highest power of 1000 (e.g. 10035 -> '10.04k')
406
+     *
407
+     * @param int $Number
408
+     * @return string formatted number.
409
+     */
410
+    public static function human_format($Number)
411
+    {
412
+        $Steps = 0;
413
+        while ($Number >= 1000) {
414
+            $Steps++;
415
+            $Number = $Number / 1000;
416
+        }
417
+
418
+        switch ($Steps) {
419
+          case 0: return round($Number); break;
420
+          case 1: return round($Number, 2).'k'; break;
421
+          case 2: return round($Number, 2).'M'; break;
422
+          case 3: return round($Number, 2).'G'; break;
423
+          case 4: return round($Number, 2).'T'; break;
424
+          case 5: return round($Number, 2).'P'; break;
425
+          default: return round($Number, 2).'E + '.$Steps * 3;
426
+        }
427
+    }
428
+
429
+
430
+    /**
431
+     * Given a formatted string of a size, get the number of bytes it represents.
432
+     *
433
+     * @param string $Size formatted size string, e.g. 123.45k
434
+     * @return Number of bytes it represents, e.g. (123.45 * 1024)
435
+     */
436
+    public static function get_bytes($Size)
437
+    {
438
+        list($Value, $Unit) = sscanf($Size, "%f%s");
439
+        $Unit = ltrim($Unit);
440
+
441
+        if (empty($Unit)) {
442
+            return $Value ? round($Value) : 0;
443
+        }
444
+
445
+        switch (strtolower($Unit[0])) {
446
+          case 'k': return round($Value * 1024);
447
+          case 'm': return round($Value * 1048576);
448
+          case 'g': return round($Value * 1073741824);
449
+          case 't': return round($Value * 1099511627776);
450
+          default: return 0;
451
+        }
452
+    }
453
+
454
+
455
+    /**
456
+     * Reverse the effects of display_str - un-sanitize HTML.
457
+     * Use sparingly.
458
+     *
459
+     * @param string $Str the string to unsanitize
460
+     * @return unsanitized string
461
+     */
462
+    public static function undisplay_str($Str)
463
+    {
464
+        return mb_convert_encoding($Str, 'UTF-8', 'HTML-ENTITIES');
465
+    }
466
+
467
+
468
+    /**
469
+     * Echo data sent in a GET form field, useful for text areas.
470
+     *
471
+     * @param string $Index the name of the form field
472
+     * @param boolean $Return if set to true, value is returned instead of echoed.
473
+     * @return Sanitized value of field index if $Return == true
474
+     */
475
+    public static function form($Index, $Return = false)
476
+    {
477
+        if (!empty($_GET[$Index])) {
478
+            if ($Return) {
479
+                return display_str($_GET[$Index]);
480
+            } else {
481
+                echo display_str($_GET[$Index]);
482
+            }
483
+        }
484
+    }
485
+
486
+
487
+    /**
488
+     * Convenience function to echo out selected="selected" and checked="checked" so you don't have to.
489
+     *
490
+     * @param string $Name the name of the option in the select (or field in $Array)
491
+     * @param mixed $Value the value that the option must be for the option to be marked as selected or checked
492
+     * @param string $Attribute The value returned/echoed is $Attribute="$Attribute" with a leading space
493
+     * @param array $Array The array the option is in, defaults to GET.
494
+     * @return void
495
+     */
496
+    public static function selected($Name, $Value, $Attribute = 'selected', $Array = [])
497
+    {
498
+        if (empty($Array)) {
499
+            $Array = $_GET;
500
+        }
501
+
502
+        if (isset($Array[$Name]) && $Array[$Name] !== '') {
503
+            if ($Array[$Name] === $Value) {
504
+                echo " $Attribute='$Attribute'";
505
+            }
506
+        }
507
+    }
508
+
509
+
510
+    /**
511
+     * Return a CSS class name if certain conditions are met. Mainly useful to mark links as 'active'
512
+     *
513
+     * @param mixed $Target The variable to compare all values against
514
+     *
515
+     * @param mixed $Tests The condition values. Type and dimension determines test type
516
+     *  - Scalar: $Tests must be equal to $Target for a match
517
+     *  - Vector: All elements in $Tests must correspond to equal values in $Target
518
+     *  - 2-dimensional array: At least one array must be identical to $Target
519
+     *
520
+     * @param string $ClassName CSS class name to return
521
+     * @param bool $AddAttribute Whether to include the "class" attribute in the output
522
+     * @param string $UserIDKey Key in _REQUEST for a user ID parameter, which if given will be compared to G::$LoggedUser[ID]
523
+     *
524
+     * @return class name on match, otherwise an empty string
525
+     */
526
+    public static function add_class($Target, $Tests, $ClassName, $AddAttribute, $UserIDKey = false)
527
+    {
528
+        if ($UserIDKey && isset($_REQUEST[$UserIDKey]) && G::$LoggedUser['ID'] !== $_REQUEST[$UserIDKey]) {
529
+            return '';
530
+        }
531
+
532
+        $Pass = true;
533
+        if (!is_array($Tests)) {
534
+            // Scalars are nice and easy
535
+            $Pass = $Tests === $Target;
536
+        } elseif (!is_array($Tests[0])) {
537
+            // Test all values in vectors
538
+            foreach ($Tests as $Type => $Part) {
539
+                if (!isset($Target[$Type]) || $Target[$Type] !== $Part) {
540
+                    $Pass = false;
541
+                    break;
542
+                }
543
+            }
544
+        } else {
545
+            // Loop to the end of the array or until we find a matching test
546
+            foreach ($Tests as $Test) {
547
+                $Pass = true;
548
+
549
+                // If $Pass remains true after this test, it's a match
550
+                foreach ($Test as $Type => $Part) {
551
+                    if (!isset($Target[$Type]) || $Target[$Type] !== $Part) {
552
+                        $Pass = false;
553
+                        break;
554
+                    }
555
+                }
556
+
557
+                if ($Pass) {
558
+                    break;
559
+                }
560
+            }
561
+        }
562
+
563
+        if (!$Pass) {
564
+            return '';
565
+        }
566
+
567
+        if ($AddAttribute) {
568
+            return " class='$ClassName'";
569
+        }
570
+        return " $ClassName";
571
+    }
572
+
573
+
574
+    /**
575
+     * Detect the encoding of a string and transform it to UTF-8.
576
+     *
577
+     * @param string $Str
578
+     * @return UTF-8 encoded version of $Str
579
+     */
580
+    public static function make_utf8($Str)
581
+    {
582
+        if ($Str !== '') {
583
+            if (self::is_utf8($Str)) {
584
+                $Encoding = 'UTF-8';
585
+            }
586
+
587
+            if (empty($Encoding)) {
588
+                $Encoding = mb_detect_encoding($Str, 'UTF-8, ISO-8859-1');
589
+            }
590
+
591
+            if (empty($Encoding)) {
592
+                $Encoding = 'ISO-8859-1';
593
+            }
594
+
595
+            if ($Encoding === 'UTF-8') {
596
+                return $Str;
597
+            } else {
598
+                return @mb_convert_encoding($Str, 'UTF-8', $Encoding);
599
+            }
600
+        }
601
+    }
602
+
603
+
604
+    /**
605
+     * Magical function.
606
+     *
607
+     * @param string $Str function to detect encoding on.
608
+     * @return true if the string is in UTF-8.
609
+     */
610
+    public static function is_utf8($Str)
611
+    {
612
+        return preg_match(
613
+            '%^(?:
614
+            [\x09\x0A\x0D\x20-\x7E]            // ASCII
615
+          | [\xC2-\xDF][\x80-\xBF]             // Non-overlong 2-byte
616
+          | \xE0[\xA0-\xBF][\x80-\xBF]         // Excluding overlongs
617
+          | [\xE1-\xEC\xEE\xEF][\x80-\xBF]{2}  // Straight 3-byte
618
+          | \xED[\x80-\x9F][\x80-\xBF]         // Excluding surrogates
619
+          | \xF0[\x90-\xBF][\x80-\xBF]{2}      // Planes 1-3
620
+          | [\xF1-\xF3][\x80-\xBF]{3}          // Planes 4-15
621
+          | \xF4[\x80-\x8F][\x80-\xBF]{2}      // Plane 16
622
+            )*$%xs',
623
+            $Str
624
+        );
625
+    }
626
+
627
+
628
+    /**
629
+     * Modified accessor for the $TorrentLabels array
630
+     *
631
+     * Converts $Text to lowercase and strips non-word characters
632
+     *
633
+     * @param string $Text Search string
634
+     * @return string CSS class(es)
635
+     */
636
+    public static function find_torrent_label_class($Text)
637
+    {
638
+        $Index = mb_eregi_replace('(?:[^\w\d\s]+)', '', strtolower($Text));
639
+        if (isset(self::$TorrentLabels[$Index])) {
640
+            return self::$TorrentLabels[$Index];
641
+        } else {
642
+            return self::$TorrentLabels['default'];
643
+        }
644
+    }
645
+
646
+
647
+    /**
648
+     * Creates a strong element that notes the torrent's state.
649
+     * E.g.: snatched/freeleech/neutral leech/reported
650
+     *
651
+     * The CSS class is inferred using find_torrent_label_class($Text)
652
+     *
653
+     * @param string $Text Display text
654
+     * @param string $Class Custom CSS class
655
+     * @return string <strong> element
656
+     */
657
+    public static function torrent_label($Text, $Class = '')
658
+    {
659
+        if (empty($Class)) {
660
+            $Class = self::find_torrent_label_class($Text);
661
+        }
662
+        
663
+        return sprintf(
664
+            '<strong class="torrent_label tooltip %1$s" title="%2$s" style="white-space: nowrap;">%2$s</strong>',
665
+            display_str($Class),
666
+            display_str($Text)
667
+        );
668
+    }
669
+
670
+
671
+    /**
672
+     * Formats a CSS class name from a Category ID
673
+     * @global array $Categories
674
+     * @param int|string $CategoryID This number will be subtracted by one
675
+     * @return string
676
+     */
677
+    public static function css_category($CategoryID = 1)
678
+    {
679
+        global $Categories;
680
+        return 'cats_' . strtolower(str_replace(
681
+            array('-', ' '),
682
+            '',
683
+            $Categories[$CategoryID - 1]
684
+        ));
685
+    }
686
+
687
+
688
+    /**
689
+     * Formats a CSS class name from a Category ID
690
+     * @global array $Categories
691
+     * @param int|string $CategoryID This number will be subtracted by one
692
+     * @return string
693
+     */
694
+    public static function pretty_category($CategoryID = 1)
695
+    {
696
+        global $Categories;
697
+        return ucwords(str_replace('-', ' ', $Categories[$CategoryID - 1]));
698
+    }
699
+}

+ 417
- 0
classes/forums.class.php View File

@@ -0,0 +1,417 @@
1
+<?php
2
+
3
+class Forums
4
+{
5
+    /**
6
+     * Get information on a thread.
7
+     *
8
+     * @param int $ThreadID the thread ID.
9
+     * @param boolean $Return indicates whether thread info should be returned.
10
+     * @param Boolean $SelectiveCache cache thread info.
11
+     * @return array holding thread information.
12
+     */
13
+    public static function get_thread_info($ThreadID, $Return = true, $SelectiveCache = false)
14
+    {
15
+        if ((!$ThreadInfo = G::$Cache->get_value('thread_' . $ThreadID . '_info')) || !isset($ThreadInfo['Ranking'])) {
16
+            $QueryID = G::$DB->get_query_id();
17
+            G::$DB->query(
18
+                "
19
+            SELECT
20
+              t.`Title`,
21
+              t.`ForumID`,
22
+              t.`IsLocked`,
23
+              t.`IsSticky`,
24
+              COUNT(fp.`id`) AS Posts,
25
+              t.`LastPostAuthorID`,
26
+              ISNULL(p.`TopicID`) AS NoPoll,
27
+              t.`StickyPostID`,
28
+              t.`AuthorID` AS OP,
29
+              t.`Ranking`
30
+            FROM
31
+              `forums_topics` AS t
32
+            JOIN `forums_posts` AS fp
33
+            ON
34
+              fp.`TopicID` = t.`ID`
35
+            LEFT JOIN `forums_polls` AS p
36
+            ON
37
+              p.`TopicID` = t.`ID`
38
+            WHERE
39
+              t.`ID` = ?
40
+            GROUP BY
41
+              fp.TopicID
42
+              ",
43
+                $ThreadID
44
+            );
45
+
46
+            if (!G::$DB->has_results()) {
47
+                G::$DB->set_query_id($QueryID);
48
+                return null;
49
+            }
50
+
51
+            $ThreadInfo = G::$DB->next_record(MYSQLI_ASSOC, false);
52
+            if ($ThreadInfo['StickyPostID']) {
53
+                $ThreadInfo['Posts']--;
54
+                G::$DB->query(
55
+                    "
56
+                SELECT
57
+                  p.`ID`,
58
+                  p.`AuthorID`,
59
+                  p.`AddedTime`,
60
+                  p.`Body`,
61
+                  p.`EditedUserID`,
62
+                  p.`EditedTime`,
63
+                  ed.`Username`
64
+                FROM
65
+                  `forums_posts` AS p
66
+                LEFT JOIN `users_main` AS ed
67
+                ON
68
+                  ed.`ID` = p.`EditedUserID`
69
+                WHERE
70
+                  p.`TopicID` = ? AND p.`ID` = ? ",
71
+                    $ThreadID,
72
+                    $ThreadInfo['StickyPostID']
73
+                );
74
+                list($ThreadInfo['StickyPost']) = G::$DB->to_array(false, MYSQLI_ASSOC);
75
+            }
76
+
77
+            G::$DB->set_query_id($QueryID);
78
+            if (!$SelectiveCache || !$ThreadInfo['IsLocked'] || $ThreadInfo['IsSticky']) {
79
+                G::$Cache->cache_value('thread_'.$ThreadID.'_info', $ThreadInfo, 0);
80
+            }
81
+        }
82
+        
83
+        if ($Return) {
84
+            return $ThreadInfo;
85
+        }
86
+    }
87
+
88
+    /**
89
+     * Checks whether user has permissions on a forum.
90
+     *
91
+     * @param int $ForumID the forum ID.
92
+     * @param string $Perm the permissision to check, defaults to 'Read'
93
+     * @return boolean true if user has permission
94
+     */
95
+    public static function check_forumperm($ForumID, $Perm = 'Read')
96
+    {
97
+        $Forums = self::get_forums();
98
+        if (isset(G::$LoggedUser['CustomForums'][$ForumID]) && G::$LoggedUser['CustomForums'][$ForumID] === 1) {
99
+            return true;
100
+        }
101
+
102
+        if ($ForumID === DONOR_FORUM && Donations::has_donor_forum(G::$LoggedUser['ID'])) {
103
+            return true;
104
+        }
105
+
106
+        if ($Forums[$ForumID]['MinClass' . $Perm] > G::$LoggedUser['Class'] && (!isset(G::$LoggedUser['CustomForums'][$ForumID]) || G::$LoggedUser['CustomForums'][$ForumID] == 0)) {
107
+            return false;
108
+        }
109
+        
110
+        if (isset(G::$LoggedUser['CustomForums'][$ForumID]) && G::$LoggedUser['CustomForums'][$ForumID] === 0) {
111
+            return false;
112
+        }
113
+
114
+        return true;
115
+    }
116
+
117
+    /**
118
+     * Gets basic info on a forum.
119
+     *
120
+     * @param int $ForumID the forum ID.
121
+     */
122
+    public static function get_forum_info($ForumID)
123
+    {
124
+        $Forum = G::$Cache->get_value("ForumInfo_$ForumID");
125
+        if (!$Forum) {
126
+            $QueryID = G::$DB->get_query_id();
127
+            G::$DB->query(
128
+                "
129
+            SELECT
130
+              `Name`,
131
+              `MinClassRead`,
132
+              `MinClassWrite`,
133
+              `MinClassCreate`,
134
+            COUNT(`forums_topics`.`ID`) AS Topics
135
+            FROM
136
+              `forums`
137
+            LEFT JOIN `forums_topics` ON `forums_topics`.`ForumID` = `forums`.`ID`
138
+            WHERE
139
+              `forums`.`ID` = ?
140
+            GROUP BY
141
+              `ForumID`
142
+            ",
143
+                $ForumID
144
+            );
145
+
146
+            if (!G::$DB->has_results()) {
147
+                return false;
148
+            }
149
+
150
+            // Makes an array, with $Forum['Name'], etc.
151
+            $Forum = G::$DB->next_record(MYSQLI_ASSOC);
152
+            G::$DB->set_query_id($QueryID);
153
+            G::$Cache->cache_value("ForumInfo_$ForumID", $Forum, 86400);
154
+        }
155
+        return $Forum;
156
+    }
157
+
158
+    /**
159
+     * Get the forum categories
160
+     * @return array ForumCategoryID => Name
161
+     */
162
+    public static function get_forum_categories()
163
+    {
164
+        $ForumCats = G::$Cache->get_value('forums_categories');
165
+        if ($ForumCats === false) {
166
+            $QueryID = G::$DB->get_query_id();
167
+            G::$DB->query("
168
+            SELECT
169
+              `ID`,
170
+              `Name`
171
+            FROM
172
+              `forums_categories`
173
+            ");
174
+
175
+            $ForumCats = [];
176
+            while (list($ID, $Name) = G::$DB->next_record()) {
177
+                $ForumCats[$ID] = $Name;
178
+            }
179
+
180
+            G::$DB->set_query_id($QueryID);
181
+            G::$Cache->cache_value('forums_categories', $ForumCats, 0);
182
+        }
183
+        return $ForumCats;
184
+    }
185
+
186
+    /**
187
+     * Get the forums
188
+     * @return array ForumID => (various information about the forum)
189
+     */
190
+    public static function get_forums()
191
+    {
192
+        if (!$Forums = G::$Cache->get_value('forums_list')) {
193
+            $QueryID = G::$DB->get_query_id();
194
+            G::$DB->query("
195
+            SELECT
196
+              f.`ID`,
197
+              f.`CategoryID`,
198
+              f.`Name`,
199
+              f.`Description`,
200
+              f.`MinClassRead` AS MinClassRead,
201
+              f.`MinClassWrite` AS MinClassWrite,
202
+              f.`MinClassCreate` AS MinClassCreate,
203
+              f.`NumTopics`,
204
+              f.`NumPosts`,
205
+              f.`LastPostID`,
206
+              f.`LastPostAuthorID`,
207
+              f.`LastPostTopicID`,
208
+              f.`LastPostTime`,
209
+              0 AS SpecificRules,
210
+              t.`Title`,
211
+              t.`IsLocked` AS Locked,
212
+              t.`IsSticky` AS Sticky
213
+            FROM
214
+              `forums` AS f
215
+            JOIN `forums_categories` AS fc
216
+            ON
217
+              fc.`ID` = f.`CategoryID`
218
+            LEFT JOIN `forums_topics` AS t
219
+            ON
220
+              t.`ID` = f.`LastPostTopicID`
221
+            GROUP BY
222
+              f.`ID`
223
+            ORDER BY
224
+              fc.`Sort`,
225
+              fc.`Name`,
226
+              f.`CategoryID`,
227
+              f.`Sort`
228
+            ");
229
+            $Forums = G::$DB->to_array('ID', MYSQLI_ASSOC, false);
230
+
231
+            G::$DB->query("
232
+            SELECT
233
+              `ForumID`,
234
+              `ThreadID`
235
+            FROM
236
+              `forums_specific_rules`
237
+            ");
238
+
239
+            $SpecificRules = [];
240
+            while (list($ForumID, $ThreadID) = G::$DB->next_record(MYSQLI_NUM, false)) {
241
+                $SpecificRules[$ForumID][] = $ThreadID;
242
+            }
243
+
244
+            G::$DB->set_query_id($QueryID);
245
+            foreach ($Forums as $ForumID => &$Forum) {
246
+                if (isset($SpecificRules[$ForumID])) {
247
+                    $Forum['SpecificRules'] = $SpecificRules[$ForumID];
248
+                } else {
249
+                    $Forum['SpecificRules'] = [];
250
+                }
251
+            }
252
+            G::$Cache->cache_value('forums_list', $Forums, 0);
253
+        }
254
+        return $Forums;
255
+    }
256
+
257
+    /**
258
+     * Get all forums that the current user has special access to ("Extra forums" in the profile)
259
+     * @return array Array of ForumIDs
260
+     */
261
+    public static function get_permitted_forums()
262
+    {
263
+        if (isset(G::$LoggedUser['CustomForums'])) {
264
+            return (array)array_keys(G::$LoggedUser['CustomForums'], 1);
265
+        } else {
266
+            return [];
267
+        }
268
+    }
269
+
270
+    /**
271
+     * Get all forums that the current user does not have access to ("Restricted forums" in the profile)
272
+     * @return array Array of ForumIDs
273
+     */
274
+    public static function get_restricted_forums()
275
+    {
276
+        if (isset(G::$LoggedUser['CustomForums'])) {
277
+            return (array)array_keys(G::$LoggedUser['CustomForums'], 0);
278
+        } else {
279
+            return [];
280
+        }
281
+    }
282
+
283
+    /**
284
+     * Get the last read posts for the current user
285
+     * @param array $Forums Array of forums as returned by self::get_forums()
286
+     * @return array TopicID => array(TopicID, PostID, Page) where PostID is the ID of the last read post and Page is the page on which that post is
287
+     */
288
+    public static function get_last_read($Forums)
289
+    {
290
+        if (isset(G::$LoggedUser['PostsPerPage'])) {
291
+            $PerPage = G::$LoggedUser['PostsPerPage'];
292
+        } else {
293
+            $PerPage = POSTS_PER_PAGE;
294
+        }
295
+
296
+        $TopicIDs = [];
297
+        foreach ($Forums as $Forum) {
298
+            if (!empty($Forum['LastPostTopicID'])) {
299
+                $TopicIDs[] = $Forum['LastPostTopicID'];
300
+            }
301
+        }
302
+
303
+        if (!empty($TopicIDs)) {
304
+            $QueryID = G::$DB->get_query_id();
305
+            G::$DB->query(
306
+                "
307
+            SELECT
308
+              l.`TopicID`,
309
+              l.`PostID`,
310
+              CEIL(
311
+                (
312
+                SELECT
313
+                  COUNT(p.`ID`)
314
+                FROM
315
+                  `forums_posts` AS p
316
+                WHERE
317
+                  p.`TopicID` = l.`TopicID` AND p.`ID` <= l.`PostID`
318
+                ) / ?
319
+              ) AS Page
320
+            FROM
321
+              `forums_last_read_topics` AS l
322
+            WHERE
323
+              l.`TopicID` IN(".implode(',', $TopicIDs).") AND l.`UserID` = ? ",
324
+                $PerPage,
325
+                G::$LoggedUser['ID']
326
+            );
327
+
328
+            $LastRead = G::$DB->to_array('TopicID', MYSQLI_ASSOC);
329
+            G::$DB->set_query_id($QueryID);
330
+        } else {
331
+            $LastRead = [];
332
+        }
333
+        return $LastRead;
334
+    }
335
+
336
+    /**
337
+     * Add a note to a topic.
338
+     * @param int $TopicID
339
+     * @param string $Note
340
+     * @param int|null $UserID
341
+     * @return boolean
342
+     */
343
+    public static function add_topic_note($TopicID, $Note, $UserID = null)
344
+    {
345
+        if ($UserID === null) {
346
+            $UserID = G::$LoggedUser['ID'];
347
+        }
348
+
349
+        $QueryID = G::$DB->get_query_id();
350
+        G::$DB->query("
351
+        INSERT INTO `forums_topic_notes`(
352
+          `TopicID`,
353
+          `AuthorID`,
354
+          `AddedTime`,
355
+          `Body`
356
+        )
357
+        VALUES(
358
+          '$TopicID',
359
+          '$UserID',
360
+          NOW(),
361
+          '$Note'
362
+        )
363
+        ");
364
+
365
+        G::$DB->set_query_id($QueryID);
366
+        return (bool) G::$DB->affected_rows();
367
+    }
368
+
369
+    /**
370
+     * Determine if a thread is unread
371
+     * @param bool $Locked
372
+     * @param bool $Sticky
373
+     * @param int $LastPostID
374
+     * @param array $LastRead An array as returned by self::get_last_read
375
+     * @param int $LastTopicID TopicID of the thread where the most recent post was made
376
+     * @param string $LastTime Datetime of the last post
377
+     * @return bool
378
+     */
379
+    public static function is_unread($Locked, $Sticky, $LastPostID, $LastRead, $LastTopicID, $LastTime)
380
+    {
381
+        return (!$Locked || $Sticky)
382
+            && $LastPostID !== 0
383
+            && (
384
+                (empty($LastRead[$LastTopicID]) || $LastRead[$LastTopicID]['PostID'] < $LastPostID)
385
+                && strtotime($LastTime) > G::$LoggedUser['CatchupTime']
386
+            );
387
+    }
388
+
389
+    /**
390
+     * Create the part of WHERE in the sql queries used to filter forums for a
391
+     * specific user (MinClassRead, restricted and permitted forums).
392
+     * @return string
393
+     */
394
+    public static function user_forums_sql()
395
+    {
396
+        // I couldn't come up with a good name, please rename this if you can. -- Y
397
+        $RestrictedForums = self::get_restricted_forums();
398
+        $PermittedForums = self::get_permitted_forums();
399
+
400
+        if (Donations::has_donor_forum(G::$LoggedUser['ID']) && !in_array(DONOR_FORUM, $PermittedForums)) {
401
+            $PermittedForums[] = DONOR_FORUM;
402
+        }
403
+
404
+        $SQL = "((f.`MinClassRead` <= '" . G::$LoggedUser['Class'] . "'";
405
+        if (count($RestrictedForums)) {
406
+            $SQL .= " AND f.`ID` NOT IN ('" . implode("', '", $RestrictedForums) . "')";
407
+        }
408
+
409
+        $SQL .= ')';
410
+        if (count($PermittedForums)) {
411
+            $SQL .= " OR f.`ID` IN ('" . implode("', '", $PermittedForums) . "')";
412
+        }
413
+        
414
+        $SQL .= ')';
415
+        return $SQL;
416
+    }
417
+}

+ 17
- 0
classes/g.class.php View File

@@ -0,0 +1,17 @@
1
+<?php
2
+declare(strict_types = 1);
3
+
4
+class G
5
+{
6
+    public static $DB;
7
+    public static $Cache;
8
+    public static $LoggedUser;
9
+
10
+    public static function initialize()
11
+    {
12
+        global $DB, $Cache, $LoggedUser;
13
+        self::$DB = $DB;
14
+        self::$Cache = $Cache;
15
+        self::$LoggedUser =& $LoggedUser;
16
+    }
17
+}

+ 67
- 0
classes/image.class.php View File

@@ -0,0 +1,67 @@
1
+<?php
2
+
3
+if (!extension_loaded('gd')) {
4
+    error('GD Extension not loaded.');
5
+}
6
+
7
+class IMAGE
8
+{
9
+    public $Image = false;
10
+    public $FontSize = 10;
11
+    public $Font = '';
12
+    public $TextAngle = 0;
13
+
14
+    public function create($Width, $Height)
15
+    {
16
+        $this->Image = imagecreate($Width, $Height);
17
+        $this->Font = SERVER_ROOT.'/classes/fonts/VERDANA.TTF';
18
+
19
+        if (function_exists('imageantialias')) {
20
+            imageantialias($this->Image, true);
21
+        }
22
+    }
23
+
24
+    public function color($Red, $Green, $Blue, $Alpha = 0)
25
+    {
26
+        return imagecolorallocatealpha($this->Image, $Red, $Green, $Blue, $Alpha);
27
+    }
28
+
29
+    public function line($x1, $y1, $x2, $y2, $Color, $Thickness = 1)
30
+    {
31
+        if ($Thickness === 1) {
32
+            return imageline($this->Image, $x1, $y1, $x2, $y2, $Color);
33
+        }
34
+        $t = $Thickness / 2 - 0.5;
35
+
36
+        if ($x1 === $x2 || $y1 === $y2) {
37
+            return imagefilledrectangle($this->Image, round(min($x1, $x2) - $t), round(min($y1, $y2) - $t), round(max($x1, $x2) + $t), round(max($y1, $y2) + $t), $color);
38
+        }
39
+        $k = ($y2 - $y1) / ($x2 - $x1); //y = kx + q
40
+        $a = $t / sqrt(1 + pow($k, 2));
41
+
42
+        $Points = array(
43
+            round($x1 - (1 + $k) * $a), round($y1 + (1 - $k) * $a),
44
+            round($x1 - (1 - $k) * $a), round($y1 - (1 + $k) * $a),
45
+            round($x2 + (1 + $k) * $a), round($y2 - (1 - $k) * $a),
46
+            round($x2 + (1 - $k) * $a), round($y2 + (1 + $k) * $a),
47
+        );
48
+
49
+        imagefilledpolygon($this->Image, $Points, 4, $Color);
50
+        return imagepolygon($this->Image, $Points, 4, $Color);
51
+    }
52
+
53
+    public function ellipse($x, $y, $Width, $Height, $Color)
54
+    {
55
+        return imageEllipse($this->Image, $x, $y, $Width, $Height, $Color);
56
+    }
57
+
58
+    public function text($x, $y, $Color, $Text)
59
+    {
60
+        return imagettftext($this->Image, $this->FontSize, $this->TextAngle, $x, $y, $Color, $this->Font, $Text);
61
+    }
62
+
63
+    public function make_png($FileName = null)
64
+    {
65
+        return imagepng($this->Image, $FileName);
66
+    }
67
+}

+ 58
- 0
classes/imagetools.class.php View File

@@ -0,0 +1,58 @@
1
+<?php
2
+declare(strict_types=1);
3
+
4
+/**
5
+ * ImageTools Class
6
+ * Thumbnail aide, mostly
7
+ */
8
+class ImageTools
9
+{
10
+    /**
11
+     * Determine the image URL. This takes care of the image proxy and thumbnailing.
12
+     * @param string $Url
13
+     * @param string $Thumb image proxy scale profile to use
14
+     * @return string
15
+     */
16
+    public static function process($Url = '', $Thumb = false)
17
+    {
18
+        $ENV = ENV::go();
19
+
20
+        if (!$Url) {
21
+            return '';
22
+        }
23
+        
24
+        if (preg_match('/^https:\/\/('.SITE_DOMAIN.'|'.$ENV->IMAGE_DOMAIN.')\//', $Url) || $Url[0] === '/') {
25
+            if (strpos($Url, '?') === false) {
26
+                $Url .= '?';
27
+            }
28
+            return $Url;
29
+        } else {
30
+            return 'https://'
31
+            . $ENV->IMAGE_DOMAIN
32
+            . ($Thumb?"/$Thumb/":'/')
33
+            . '?h='
34
+            . rawurlencode(base64_encode(hash_hmac('sha256', $Url, IMAGE_PSK, true)))
35
+            . '&i='
36
+            . urlencode($Url);
37
+        }
38
+    }
39
+
40
+    /**
41
+     * Checks if a link's host is (not) good, otherwise displays an error.
42
+     * @param string $Url Link to an image
43
+     * @return boolean
44
+     */
45
+    public static function blacklisted($Url, $ShowError = true)
46
+    {
47
+        $Blacklist = ['tinypic.com'];
48
+        foreach ($Blacklist as $Value) {
49
+            if (stripos($Url, $Value) !== false) {
50
+                if ($ShowError) {
51
+                    error($Value . ' is not an allowed image host. Please use a different host.');
52
+                }
53
+                return true;
54
+            }
55
+        }
56
+        return false;
57
+    }
58
+}

+ 31
- 0
classes/inbox.class.php View File

@@ -0,0 +1,31 @@
1
+<?php
2
+
3
+class Inbox
4
+{
5
+    /*
6
+     * Get the link to a user's inbox.
7
+     * This is what handles the ListUnreadPMsFirst setting
8
+     *
9
+     * @param string - whether the inbox or sentbox should be loaded
10
+     * @return string - the URL to a user's inbox
11
+     */
12
+    public static function get_inbox_link($WhichBox = 'inbox')
13
+    {
14
+        $ListFirst = isset(G::$LoggedUser['ListUnreadPMsFirst']) ? G::$LoggedUser['ListUnreadPMsFirst'] : false;
15
+
16
+        if ($WhichBox === 'inbox') {
17
+            if ($ListFirst) {
18
+                $InboxURL = 'inbox.php?sort=unread';
19
+            } else {
20
+                $InboxURL = 'inbox.php';
21
+            }
22
+        } else {
23
+            if ($ListFirst) {
24
+                $InboxURL = 'inbox.php?action=sentbox&amp;sort=unread';
25
+            } else {
26
+                $InboxURL = 'inbox.php?action=sentbox';
27
+            }
28
+        }
29
+        return $InboxURL;
30
+    }
31
+}

+ 270
- 0
classes/invite_tree.class.php View File

@@ -0,0 +1,270 @@
1
+<?php
2
+
3
+class INVITE_TREE
4
+{
5
+    public $UserID = 0;
6
+    public $Visible = true;
7
+
8
+    // Set things up
9
+    public function INVITE_TREE($UserID, $Options = [])
10
+    {
11
+        $this->UserID = $UserID;
12
+        if ($Options['visible'] === false) {
13
+            $this->Visible = false;
14
+        }
15
+    }
16
+
17
+    public function make_tree()
18
+    {
19
+        $QueryID = G::$DB->get_query_id();
20
+        $UserID = $this->UserID; ?>
21
+
22
+<div class="invitetree pad">
23
+    <?php
24
+    G::$DB->query("
25
+    SELECT
26
+      `TreePosition`,
27
+      `TreeID`,
28
+      `TreeLevel`
29
+    FROM
30
+      `invite_tree`
31
+    WHERE
32
+      `UserID` = $UserID
33
+    ");
34
+
35
+        list($TreePosition, $TreeID, $TreeLevel) = G::$DB->next_record(MYSQLI_NUM, false);
36
+        if (!$TreeID) {
37
+            return;
38
+        }
39
+
40
+        G::$DB->query("
41
+        SELECT
42
+          `TreePosition`
43
+        FROM
44
+          `invite_tree`
45
+        WHERE
46
+          `TreeID` = $TreeID AND `TreeLevel` = $TreeLevel AND `TreePosition` > $TreePosition
47
+        ORDER BY
48
+          `TreePosition` ASC
49
+        LIMIT 1
50
+        ");
51
+
52
+        if (G::$DB->has_results()) {
53
+            list($MaxPosition) = G::$DB->next_record(MYSQLI_NUM, false);
54
+        } else {
55
+            $MaxPosition = false;
56
+        }
57
+
58
+        $TreeQuery = G::$DB->query("
59
+        SELECT
60
+          it.`UserID`,
61
+          `Enabled`,
62
+          `PermissionID`,
63
+          `Donor`,
64
+          `Uploaded`,
65
+          `Downloaded`,
66
+          `Paranoia`,
67
+          `TreePosition`,
68
+          `TreeLevel`
69
+        FROM
70
+          `invite_tree` AS it
71
+        JOIN `users_main` AS um
72
+        ON
73
+          um.`ID` = it.`UserID`
74
+        JOIN `users_info` AS ui
75
+        ON
76
+          ui.`UserID` = it.`UserID`
77
+        WHERE
78
+          `TreeID` = $TreeID AND `TreePosition` > $TreePosition ".
79
+          ($MaxPosition ? " AND `TreePosition` < $MaxPosition " : '')."
80
+          AND `TreeLevel` > $TreeLevel
81
+        ORDER BY
82
+          `TreePosition`
83
+        ");
84
+        $PreviousTreeLevel = $TreeLevel;
85
+
86
+        // Stats for the summary
87
+        $MaxTreeLevel = $TreeLevel; // The deepest level (this changes)
88
+        $OriginalTreeLevel = $TreeLevel; // The level of the user we're viewing
89
+        $BaseTreeLevel = $TreeLevel + 1; // The level of users invited by our user
90
+        $Count = 0;
91
+        $Branches = 0;
92
+        $DisabledCount = 0;
93
+        $DonorCount = 0;
94
+        $ParanoidCount = 0;
95
+        $TotalUpload = 0;
96
+        $TotalDownload = 0;
97
+        $TopLevelUpload = 0;
98
+        $TopLevelDownload = 0;
99
+
100
+        $ClassSummary = [];
101
+        global $Classes;
102
+        foreach ($Classes as $ClassID => $Val) {
103
+            $ClassSummary[$ClassID] = 0;
104
+        }
105
+
106
+        // We store this in an output buffer, so we can show the summary at the top without having to loop through twice
107
+        ob_start();
108
+        while (list($ID, $Enabled, $Class, $Donor, $Uploaded, $Downloaded, $Paranoia, $TreePosition, $TreeLevel) = G::$DB->next_record(MYSQLI_NUM, false)) {
109
+
110
+            // Do stats
111
+            $Count++;
112
+
113
+            if ($TreeLevel > $MaxTreeLevel) {
114
+                $MaxTreeLevel = $TreeLevel;
115
+            }
116
+
117
+            if ($TreeLevel === $BaseTreeLevel) {
118
+                $Branches++;
119
+                $TopLevelUpload += $Uploaded;
120
+                $TopLevelDownload += $Downloaded;
121
+            }
122
+
123
+            $ClassSummary[$Class]++;
124
+            if ($Enabled === 2) {
125
+                $DisabledCount++;
126
+            }
127
+
128
+            if ($Donor) {
129
+                $DonorCount++;
130
+            }
131
+
132
+            // Manage tree depth
133
+            if ($TreeLevel > $PreviousTreeLevel) {
134
+                for ($i = 0; $i < $TreeLevel - $PreviousTreeLevel; $i++) {
135
+                    echo "\n\n<ul class=\"invitetree\">\n\t<li>\n";
136
+                }
137
+            } elseif ($TreeLevel < $PreviousTreeLevel) {
138
+                for ($i = 0; $i < $PreviousTreeLevel - $TreeLevel; $i++) {
139
+                    echo "\t</li>\n</ul>\n";
140
+                }
141
+                echo "\t</li>\n\t<li>\n";
142
+            } else {
143
+                echo "\t</li>\n\t<li>\n";
144
+            }
145
+            $UserClass = $Classes[$Class]['Level']; ?>
146
+
147
+    <strong>
148
+        <?=Users::format_username($ID, true, true, ($Enabled !== 2 ? false : true), true)?>
149
+    </strong>
150
+
151
+    <?php
152
+      if (check_paranoia(array('uploaded', 'downloaded'), $Paranoia, $UserClass)) {
153
+          $TotalUpload += $Uploaded;
154
+          $TotalDownload += $Downloaded; ?>
155
+
156
+    &nbsp;Uploaded: <strong><?=Format::get_size($Uploaded)?></strong>
157
+    &nbsp;Downloaded: <strong><?=Format::get_size($Downloaded)?></strong>
158
+    &nbsp;Ratio: <strong><?=Format::get_ratio_html($Uploaded, $Downloaded)?></strong>
159
+    <?php
160
+      } else {
161
+          $ParanoidCount++; ?>
162
+    &nbsp;Hidden
163
+    <?php
164
+      } ?>
165
+
166
+    <?php
167
+      $PreviousTreeLevel = $TreeLevel;
168
+            G::$DB->set_query_id($TreeQuery);
169
+        }
170
+
171
+        $Tree = ob_get_clean();
172
+        for ($i = 0; $i < $PreviousTreeLevel - $OriginalTreeLevel; $i++) {
173
+            $Tree .= "\t</li>\n</ul>\n";
174
+        }
175
+
176
+        if ($Count) {
177
+            ?>
178
+    <p style="font-weight: bold;">
179
+        This tree has <?=number_format($Count)?> entries,
180
+        <?=number_format($Branches)?> branches,
181
+        and a depth of <?=number_format($MaxTreeLevel - $OriginalTreeLevel)?>.
182
+        It has
183
+        <?php
184
+      $ClassStrings = [];
185
+            foreach ($ClassSummary as $ClassID => $ClassCount) {
186
+                if ($ClassCount == 0) {
187
+                    continue;
188
+                }
189
+
190
+                $LastClass = Users::make_class_string($ClassID);
191
+                if ($ClassCount > 1) {
192
+                    if ($LastClass === 'Torrent Celebrity') {
193
+                        $LastClass = 'Torrent Celebrities';
194
+                    } else {
195
+                        // Prevent duplicate letterss
196
+                        if (substr($LastClass, -1) !== 's') {
197
+                            $LastClass.='s';
198
+                        }
199
+                    }
200
+                }
201
+
202
+                $LastClass = "$ClassCount $LastClass (" . number_format(($ClassCount / $Count) * 100) . '%)';
203
+                $ClassStrings[] = $LastClass;
204
+            }
205
+
206
+            if (count($ClassStrings) > 1) {
207
+                array_pop($ClassStrings);
208
+                echo implode(', ', $ClassStrings);
209
+                echo ' and '.$LastClass;
210
+            } else {
211
+                echo $LastClass;
212
+            }
213
+
214
+            echo '. ';
215
+            echo $DisabledCount;
216
+            echo ($DisabledCount === 1) ? ' user is' : ' users are';
217
+            echo ' disabled (';
218
+
219
+            if ($DisabledCount === 0) {
220
+                echo '0%)';
221
+            } else {
222
+                echo number_format(($DisabledCount / $Count) * 100) . '%)';
223
+            }
224
+
225
+            echo ', and ';
226
+            echo $DonorCount;
227
+            echo ($DonorCount === 1) ? ' user has' : ' users have';
228
+            echo ' donated (';
229
+
230
+            if ($DonorCount === 0) {
231
+                echo '0%)';
232
+            } else {
233
+                echo number_format(($DonorCount / $Count) * 100) . '%)';
234
+            }
235
+            echo '. </p>';
236
+
237
+            echo '<p style="font-weight: bold;">';
238
+            echo 'The total amount uploaded by the entire tree was '.Format::get_size($TotalUpload);
239
+            echo '; the total amount downloaded was '.Format::get_size($TotalDownload);
240
+            echo '; and the total ratio is '.Format::get_ratio_html($TotalUpload, $TotalDownload).'. ';
241
+            echo '</p>';
242
+
243
+            echo '<p style="font-weight: bold;">';
244
+            echo 'The total amount uploaded by direct invitees (the top level) was '.Format::get_size($TopLevelUpload);
245
+            echo '; the total amount downloaded was '.Format::get_size($TopLevelDownload);
246
+            echo '; and the total ratio is '.Format::get_ratio_html($TopLevelUpload, $TopLevelDownload).'. ';
247
+
248
+            echo "These numbers include the stats of paranoid users and will be factored into the invitation giving script.\n\t\t</p>\n";
249
+
250
+            if ($ParanoidCount) {
251
+                echo '<p style="font-weight: bold;">';
252
+                echo $ParanoidCount;
253
+                echo ($ParanoidCount === 1) ? ' user (' : ' users (';
254
+                echo number_format(($ParanoidCount / $Count) * 100);
255
+                echo '%) ';
256
+                echo ($ParanoidCount === 1) ? ' is' : ' are';
257
+                echo ' too paranoid to have their stats shown here, and ';
258
+                echo ($ParanoidCount === 1) ? ' was' : ' were';
259
+                echo ' not factored into the stats for the total tree.';
260
+                echo '</p>';
261
+            }
262
+        } ?>
263
+        <br />
264
+        <?=$Tree?>
265
+</div>
266
+
267
+<?php
268
+    G::$DB->set_query_id($QueryID);
269
+    }
270
+}

+ 153
- 0
classes/ipv4.class.php View File

@@ -0,0 +1,153 @@
1
+<?php
2
+declare(strict_types = 1);
3
+
4
+/**
5
+ * Adapted from
6
+ * https://github.com/OPSnet/Gazelle/blob/master/app/Manager/IPv4.php
7
+ *
8
+ * Not working as of 2020-12-12
9
+ */
10
+
11
+class IPv4
12
+{
13
+    const CACHE_KEY = 'ipv4_bans_';
14
+
15
+    /**
16
+     * Returns the unsigned 32bit form of an IPv4 address
17
+     *
18
+     * @param string $ipv4 The IP address x.x.x.x
19
+     * @return string the long it represents.
20
+     */
21
+    public function ip2ulong(string $ipv4)
22
+    {
23
+        return sprintf('%u', ip2long($ipv4));
24
+    }
25
+
26
+    /**
27
+     * Returns true if given IP is banned.
28
+     * TODO: This looks really braindead. Why not compare the 32bit address
29
+     *       directly BETWEEN FromIP AND ToIP? Apart from dubious merits of
30
+     *       caching?
31
+     *
32
+     * @param string $IP
33
+     * @return bool True if banned
34
+     */
35
+    public function isBanned(string $IP)
36
+    {
37
+        $A = substr($IP, 0, strcspn($IP, '.'));
38
+        $key = self::CACHE_KEY . $A;
39
+        $IPBans = G::$Cache->get_value($key);
40
+
41
+        if (!is_array($IPBans)) {
42
+            G::$DB->prepare_query("
43
+            SELECT 
44
+              `FromIP`,
45
+              `ToIP`,
46
+              `ID`
47
+            FROM
48
+              `ip_bans`
49
+            WHERE
50
+              `FromIP` BETWEEN $A << 24 AND (($A+1) << 24) - 1
51
+            ");
52
+
53
+            G::$DB->exec_prepared_query();
54
+            $IPBans = G::$DB->to_array(0, MYSQLI_NUM);
55
+            G::$Cache->cache_value($key, $IPBans, 0);
56
+        }
57
+
58
+        $IPNum = IPv4::ip2ulong($IP);
59
+        foreach ($IPBans as $IPBan) {
60
+            list($FromIP, $ToIP) = $IPBan;
61
+
62
+            if ($IPNum >= $FromIP && $IPNum <= $ToIP) {
63
+                return true;
64
+            }
65
+        }
66
+
67
+        return false;
68
+    }
69
+
70
+    /**
71
+     * Create an ip address ban over a range of addresses. Will append
72
+     * the given reason to an existing ban.
73
+     *
74
+     * @param int $userId The person doing the band (0 for system)
75
+     * @param string $from The first address (dotted quad a.b.c.d)
76
+     * @param string $to The last adddress in the range (may equal $from)
77
+     * @param string $reason Why ban?
78
+     */
79
+    public function createBan(int $userId, $ipv4From, string $ipv4To, string $reason)
80
+    {
81
+        $from = $this->ip2ulong($ipv4From);
82
+        $to   = $this->ip2ulong($ipv4To);
83
+
84
+        $current = G::$DB->scalar("
85
+        SELECT
86
+          `Reason`
87
+        FROM
88
+          `ip_bans`
89
+        WHERE
90
+          '$from' BETWEEN `FromIP` AND `ToIP`
91
+        ");
92
+
93
+        if ($current) {
94
+            if ($current !== $reason) {
95
+                G::$DB->prepare_query("
96
+                UPDATE
97
+                  `ip_bans`
98
+                SET
99
+                  `Reason` = CONCAT('$reason', ' AND ', `Reason`),
100
+                  `UserID` = '$userId',
101
+                  `Created` = NOW()
102
+                WHERE
103
+                  `FromIP` = '$from' AND `ToIP` = '$to'
104
+                ");
105
+                G::$DB->exec_prepared_query();
106
+            }
107
+        } else { // Not yet banned
108
+            G::$DB->prepare_query("
109
+            INSERT INTO `ip_bans`(`Reason`, `FromIP`, `ToIP`, `UserID`)
110
+            VALUES('$reason', '$from', '$to', '$userId')
111
+            ");
112
+
113
+            G::$DB->exec_prepared_query();
114
+            G::$Cache->delete_value(
115
+                self::CACHE_KEY . substr($ipv4From, 0, strcspn($ipv4From, '.'))
116
+            );
117
+        }
118
+    }
119
+
120
+    /**
121
+     * Remove an ip ban
122
+     *
123
+     * param int $id Row to remove
124
+     */
125
+    public function removeBan(int $id)
126
+    {
127
+        $fromClassA = G::$DB->scalar("
128
+        SELECT
129
+          `FromIP` >> 24
130
+        FROM
131
+          `ip_bans`
132
+        WHERE
133
+          `ID` = '$id'
134
+        ");
135
+
136
+        if (is_null($fromClassA)) {
137
+            return;
138
+        }
139
+
140
+        G::$DB->prepare_query("
141
+        DELETE
142
+        FROM
143
+          `ip_bans`
144
+        WHERE
145
+          `ID` = '$id'
146
+        ");
147
+
148
+        if (G::$DB->affected_rows()) {
149
+            G::$DB->exec_prepared_query();
150
+            G::$Cache->delete_value(self::CACHE_KEY . $fromClassA);
151
+        }
152
+    }
153
+}

+ 131
- 0
classes/json.class.php View File

@@ -0,0 +1,131 @@
1
+<?php
2
+declare(strict_types = 1);
3
+
4
+/**
5
+ * Adapted from
6
+ * https://github.com/OPSnet/Gazelle/blob/master/app/Json.php
7
+ *
8
+ * Unused as of 2020-12-12
9
+ */
10
+
11
+abstract class Json
12
+{
13
+    protected $version;
14
+    protected $source;
15
+    protected $mode;
16
+
17
+    /**
18
+     * __construct
19
+     */
20
+    public function __construct()
21
+    {
22
+        parent::__construct();
23
+        $this->source = SITE_NAME;
24
+        $this->mode = 0;
25
+        $this->version = 1;
26
+    }
27
+
28
+    /**
29
+     * The payload of a valid JSON response, implemented in the child class.
30
+     * @return array Payload to be passed to json_encode()
31
+     *         null if the payload cannot be produced (permissions, id not found, ...).
32
+     */
33
+    abstract public function payload(): ?array;
34
+
35
+    /**
36
+     * Configure JSON printing (any of the json_encode  JSON_* constants)
37
+     *
38
+     * @param int $mode the bit-or'ed values to confgure encoding results
39
+     */
40
+    public function setMode(string $mode)
41
+    {
42
+        $this->mode = $mode;
43
+        return $this;
44
+    }
45
+
46
+    /**
47
+     * set the version of the Json payload. Increment the
48
+     * value when there is significant change in the payload.
49
+     * If not called, the version defaults to 1.
50
+     *
51
+     * @param int version
52
+     */
53
+    public function setVersion(int $version)
54
+    {
55
+        $this->version = $version;
56
+        return $this;
57
+    }
58
+
59
+    /**
60
+     * General failure routine for when bad things happen.
61
+     *
62
+     * @param string $message The error set in the JSON response
63
+     */
64
+    public function failure(string $message)
65
+    {
66
+        print json_encode(
67
+            array_merge(
68
+                [
69
+                    'status' => 'failure',
70
+                    'response' => [],
71
+                    'error' => $message,
72
+                ],
73
+                $this->info(),
74
+                $this->debug(),
75
+            ),
76
+            $this->mode
77
+        );
78
+    }
79
+
80
+    /**
81
+     * emit
82
+     */
83
+    public function emit()
84
+    {
85
+        $payload = $this->payload();
86
+        if (!$payload) {
87
+            return;
88
+        }
89
+        print json_encode(
90
+            array_merge(
91
+                [
92
+                    'status' => 'success',
93
+                    'response' => $payload,
94
+                ],
95
+                $this->info(),
96
+                $this->debug()
97
+            ),
98
+            $this->mode
99
+        );
100
+    }
101
+
102
+    /**
103
+     * debug
104
+     */
105
+    protected function debug()
106
+    {
107
+        if (!check_perms('site_debug')) {
108
+            return [];
109
+        }
110
+        global $Debug;
111
+        return [
112
+            'debug' => [
113
+                'queries'  => $Debug->get_queries(),
114
+                'searches' => $Debug->get_sphinxql_queries(),
115
+            ],
116
+        ];
117
+    }
118
+
119
+    /**
120
+     * info
121
+     */
122
+    protected function info()
123
+    {
124
+        return [
125
+            'info' => [
126
+                'source' => $this->source,
127
+                'version' => $this->version,
128
+            ]
129
+        ];
130
+    }
131
+}

+ 60
- 0
classes/lockedaccounts.class.php View File

@@ -0,0 +1,60 @@
1
+<?php
2
+
3
+/**
4
+ * Class to manage locked accounts
5
+ */
6
+class LockedAccounts
7
+{
8
+
9
+    /**
10
+     * Lock an account
11
+     *
12
+     * @param int $UserID The ID of the user to lock
13
+     * @param int $Type The lock type, should be a constant value
14
+     * @param string $Message The message to write to user notes
15
+     * @param string $Reason The reason for the lock
16
+     * @param int $LockedByUserID The ID of the staff member that locked $UserID's account. 0 for system
17
+     */
18
+    public static function lock_account($UserID, $Type, $Message, $Reason, $LockedByUserID)
19
+    {
20
+        if ($LockedByUserID === 0) {
21
+            $Username = "System";
22
+        } else {
23
+            G::$DB->query("SELECT Username FROM users_main WHERE ID = '" . $LockedByUserID . "'");
24
+            list($Username) = G::$DB->next_record();
25
+        }
26
+
27
+        G::$DB->query("
28
+        INSERT INTO locked_accounts (UserID, Type)
29
+          VALUES ('" . $UserID . "', " . $Type . ")");
30
+          
31
+        Tools::update_user_notes($UserID, sqltime() . " - " . db_string($Message) . " by $Username\nReason: " . db_string($Reason) . "\n\n");
32
+        G::$Cache->delete_value("user_info_$UserID");
33
+    }
34
+
35
+    /**
36
+     * Unlock an account
37
+     *
38
+     * @param int $UserID The ID of the user to unlock
39
+     * @param int $Type The lock type, should be a constant value. Used for database verification
40
+     *                  to avoid deleting the wrong lock type
41
+     * @param string $Reason The reason for unlock
42
+     * @param int $UnlockedByUserID The ID of the staff member unlocking $UserID's account. 0 for system
43
+     */
44
+    public static function unlock_account($UserID, $Type, $Message, $Reason, $UnlockedByUserID)
45
+    {
46
+        if ($UnlockedByUserID === 0) {
47
+            $Username = "System";
48
+        } else {
49
+            G::$DB->query("SELECT Username FROM users_main WHERE ID = '" . $UnlockedByUserID . "'");
50
+            list($Username) = G::$DB->next_record();
51
+        }
52
+
53
+        G::$DB->query("DELETE FROM locked_accounts WHERE UserID = '$UserID' AND Type = '". $Type ."'");
54
+
55
+        if (G::$DB->affected_rows() === 1) {
56
+            G::$Cache->delete_value("user_info_$UserID");
57
+            Tools::update_user_notes($UserID, sqltime() . " - " . db_string($Message) . " by $Username\nReason: " . db_string($Reason) . "\n\n");
58
+        }
59
+    }
60
+}

+ 305
- 0
classes/loginwatch.class.php View File

@@ -0,0 +1,305 @@
1
+<?php
2
+declare(strict_types = 1);
3
+
4
+/**
5
+ * Adapted from
6
+ * https://github.com/OPSnet/Gazelle/blob/master/app/LoginWatch.php
7
+ *
8
+ * Unknown status as of 2020-12-12
9
+ */
10
+
11
+class LoginWatch
12
+{
13
+    protected $watchId;
14
+
15
+    /**
16
+     * Set the context of a watched IP address (to save passing it in to each method call).
17
+     * On a virgin login with no previous errors there may not even be a watch yet
18
+     * @param int ID of the watch
19
+     */
20
+    public function setWatch($watchId)
21
+    {
22
+        if (!is_null($watchId)) {
23
+            $this->watchId = $watchId;
24
+        }
25
+        return $this;
26
+    }
27
+
28
+    /**
29
+     * Find a login watch by IP address
30
+     * @param string IPv4 address
31
+     * @return array [watchId, nrAttemtps, nrBans, bannedUntil]
32
+     */
33
+    public function findByIp(string $ipaddr): ?array
34
+    {
35
+        # Default: remote host
36
+        if (empty($ipaddr)) {
37
+            $ipaddr = $_SERVER['REMOTE_ADDR'];
38
+        }
39
+
40
+        return G::$DB->row("
41
+        SELECT
42
+          `ID`,
43
+          `Attempts`,
44
+          `Bans`,
45
+          `BannedUntil`
46
+        FROM
47
+          `login_attempts`
48
+        WHERE
49
+          `IP` = '$ipaddr'
50
+        ");
51
+    }
52
+
53
+    /**
54
+     * Create a new login watch on an userid/username/ipaddress
55
+     * @param string IPv4 address
56
+     * @param string|null $capture The username captured on the form
57
+     * @param int $userId
58
+     * @return int ID of watch
59
+     */
60
+    public function create(string $ipaddr, ?string $capture, int $userId = 0)
61
+    {
62
+        G::$DB->prepare_query("
63
+        INSERT INTO `login_attempts`(`IP`, `Capture`, `UserID`)
64
+        VALUES('$ipaddr', '$capture', '$userId')
65
+        ");
66
+
67
+        G::$DB->exec_prepared_query();
68
+        return ($this->watchId = G::$DB->inserted_id());
69
+    }
70
+
71
+    /**
72
+     * Record another failure attempt on this watch. If the user has not
73
+     * logged in recently from this IP address then subsequent logins
74
+     * will be blocked for increasingly longer times, otherwise 1 minute.
75
+     *
76
+     * @param int $userId The ID of the user
77
+     * @param string $ipaddr The IP the user is coming from
78
+     * @param string $capture The username captured on the form
79
+     * @return int 1 if the watch was updated
80
+     */
81
+    public function increment(int $userId, string $ipaddr, ?string $capture): int
82
+    {
83
+        $seen = G::$DB->query("
84
+        SELECT
85
+          1
86
+        FROM
87
+          `users_history_ips`
88
+        WHERE
89
+          (
90
+            `EndTime` IS NULL
91
+            OR `EndTime` > NOW() - INTERVAL 1 WEEK
92
+          )
93
+          AND `UserID` = '$userId'
94
+          AND `IP` = '$ipaddr'
95
+        ");
96
+
97
+        $delay = $seen ? 60 : LOGIN_ATTEMPT_BACKOFF[min($this->nrAttempts(), count(LOGIN_ATTEMPT_BACKOFF)-1)];
98
+        G::$DB->prepare_query("
99
+            UPDATE `login_attempts` SET
100
+                `Attempts` = `Attempts` + 1,
101
+                `LastAttempt` = now(),
102
+                `BannedUntil` = now() + INTERVAL '$delay' SECOND,
103
+                `UserID` = '$userId',
104
+                `Capture` ='$capture' 
105
+            WHERE `ID` = '$this->watchId' 
106
+            ");
107
+        return G::$DB->affected_rows();
108
+    }
109
+
110
+    /**
111
+     * Ban subsequent attempts to login from this watched IP address for 6 hours
112
+     * @param int $attempts How many attempts so far?
113
+     * @param string the username captured on the form (which may not even be a valid user)
114
+     * @param int $userId user ID of a valid user (or 0 if invalid username)
115
+     * @return int 1 if the watch was banned
116
+     */
117
+    public function ban(int $attempts, ?string $capture, int $userId = 0): int
118
+    {
119
+        G::$DB->prepare_query("
120
+        UPDATE
121
+          `login_attempts`
122
+        SET
123
+          `Bans` = `Bans` + 1,
124
+          `LastAttempt` = NOW(),
125
+          `BannedUntil` = NOW() + INTERVAL 6 HOUR,
126
+          `Attempts` = '$attempts',
127
+          `Capture` = '$capture',
128
+          `UserID` = '$userId'
129
+        WHERE
130
+          `ID` = '$this->watchId'
131
+        ");
132
+
133
+        G::$DB->exec_prepared_query();
134
+        return G::$DB->affected_rows();
135
+    }
136
+
137
+    /**
138
+     * When does the login ban expire?
139
+     * @return string datestamp of expiry
140
+     */
141
+    public function bannedUntil(): ?string
142
+    {
143
+        return G::$DB->scalar("
144
+        SELECT
145
+          `BannedUntil`
146
+        FROM
147
+          `login_attempts`
148
+        WHERE
149
+          `ID` = '$this->watchId'
150
+        ");
151
+    }
152
+
153
+    /**
154
+     * If the login ban was in the past then they get 6 more shots
155
+     * @return int 1 if a prior ban was cleared
156
+     */
157
+    public function clearPriorBan(): int
158
+    {
159
+        G::$DB->prepare_query("
160
+        UPDATE
161
+          `login_attempts`
162
+        SET
163
+          `BannedUntil` = NULL,
164
+          `Attempts` = 0
165
+        WHERE
166
+          `BannedUntil` < NOW()
167
+          AND `ID` = '$this->watchId'
168
+        ");
169
+
170
+        G::$DB->exec_prepared_query();
171
+        return G::$DB->affected_rows();
172
+    }
173
+
174
+    /**
175
+     * If the login was successful, clear prior attempts
176
+     * @return int 1 if an update was made
177
+     */
178
+    public function clearAttempts(): int
179
+    {
180
+        G::$DB->prepare_query("
181
+        UPDATE
182
+          `login_attempts`
183
+        SET
184
+          `Attempts` = 0
185
+        WHERE
186
+          `ID` = '$this->watchId'
187
+        ");
188
+
189
+        G::$DB->exec_prepared_query();
190
+        return $this->db->affected_rows();
191
+    }
192
+
193
+    /**
194
+     * How many attempts have been made on this watch?
195
+     * @return int Number of attempts
196
+     */
197
+    public function nrAttempts(): int
198
+    {
199
+        return (int) G::$DB->scalar("
200
+        SELECT
201
+          `Attempts`
202
+        FROM
203
+          `login_attempts`
204
+        WHERE
205
+          `ID` = '$this->watchId'
206
+        ") ?? 0;
207
+    }
208
+
209
+    /**
210
+     * Get the list of login failures
211
+     * @return array list [ID, ipaddr, userid, LastAttempt (datetime), Attempts, BannedUntil (datetime), Bans]
212
+     */
213
+    public function activeList(string $orderBy, string $orderWay): array
214
+    {
215
+        G::$DB->prepare_query("
216
+        SELECT
217
+          w.`ID` AS id,
218
+          w.`IP` AS ipaddr,
219
+          w.`UserID` AS user_id,
220
+          w.`LastAttempt` AS last_attempt,
221
+          w.`Attempts` AS attempts,
222
+          w.`BannedUntil` AS banned_until,
223
+          w.`Bans` AS bans,
224
+          w.`Capture`,
225
+          um.`Username` AS username,
226
+          (ip.`FromIP` IS NOT NULL) AS banned
227
+        FROM
228
+          `login_attempts` w
229
+        LEFT JOIN `users_main` um ON
230
+          (um.`ID` = w.`UserID`)
231
+        LEFT JOIN `ip_bans` ip ON
232
+          (ip.`FromIP` = INET_ATON(w.`IP`))
233
+        WHERE
234
+          (
235
+            w.`BannedUntil` > NOW()
236
+            OR w.`LastAttempt` > NOW() - INTERVAL 6 HOUR
237
+          )
238
+        ORDER BY
239
+          '$orderBy' '$orderWay'
240
+        ");
241
+
242
+        G::$DB->exec_prepared_query();
243
+        return G::$DB->to_array('id', MYSQLI_ASSOC, false);
244
+    }
245
+
246
+    /**
247
+     * Ban the IP addresses pointed to by the IDs that are on login watch.
248
+     * @param array list of IDs to ban.
249
+     * @return number of addresses banned
250
+     */
251
+    public function setBan(int $userId, string $reason, array $list): int
252
+    {
253
+        if (!$list) {
254
+            return 0;
255
+        }
256
+
257
+        $reason = trim($reason);
258
+        $n = 0;
259
+
260
+        foreach ($list as $id) {
261
+            $ipv4 = G::$DB->scalar("
262
+            SELECT
263
+              INET_ATON(`IP`)
264
+            FROM
265
+              `login_attempts`
266
+            WHERE
267
+              `ID` = '$id'
268
+            ");
269
+
270
+            G::$DB->prepared_query("
271
+            INSERT IGNORE
272
+            INTO `ip_bans`(`UserID`, `Reason`, `FromIP`, `ToIP`)
273
+            VALUES('$userId', '$reason', '$ipv4', '$ipv4')
274
+            ");
275
+
276
+            G::$DB->exec_prepared_query();
277
+            $n += $this->db->affected_rows();
278
+        }
279
+
280
+        return $n;
281
+    }
282
+
283
+    /**
284
+     * Clear the list of IDs that are on login watch.
285
+     * @param array list of IDs to clear.
286
+     * @return number of rows removed
287
+     */
288
+    public function setClear(array $list): int
289
+    {
290
+        if (!$list) {
291
+            return 0;
292
+        }
293
+
294
+        G::$DB->prepare_query("
295
+        DELETE
296
+        FROM
297
+          `login_attempts`
298
+        WHERE
299
+          `ID` IN(".placeholders($list).")
300
+        ", ...$list);
301
+
302
+        G::$DB->exec_prepared_query();
303
+        return G::$DB->affected_rows();
304
+    }
305
+}

+ 89
- 0
classes/mass_user_bookmarks_editor.class.php View File

@@ -0,0 +1,89 @@
1
+<?php
2
+
3
+//require_once 'mass_user_torrents_editor.class.php';
4
+
5
+/**
6
+ * This class helps with mass-editing bookmarked torrents.
7
+ *
8
+ * It can later be used for other bookmark tables.
9
+ *
10
+ */
11
+class MASS_USER_BOOKMARKS_EDITOR extends MASS_USER_TORRENTS_EDITOR
12
+{
13
+    public function __construct($Table = 'bookmarks_torrents')
14
+    {
15
+        $this->set_table($Table);
16
+    }
17
+
18
+    /**
19
+     * Runs a SQL query and clears the Cache key
20
+     *
21
+     * G::$Cache->delete_value didn't always work, but setting the key to null, did. (?)
22
+     *
23
+     * @param string $sql
24
+     */
25
+    protected function query_and_clear_cache($sql)
26
+    {
27
+        $QueryID = G::$DB->get_query_id();
28
+        if (is_string($sql) && G::$DB->query($sql)) {
29
+            G::$Cache->delete_value('bookmarks_group_ids_' . G::$LoggedUser['ID']);
30
+        }
31
+        G::$DB->set_query_id($QueryID);
32
+    }
33
+
34
+    /**
35
+     * Uses (checkboxes) $_POST['remove'] to delete entries.
36
+     *
37
+     * Uses an IN() to match multiple items in one query.
38
+     */
39
+    public function mass_remove()
40
+    {
41
+        $SQL = [];
42
+        foreach ($_POST['remove'] as $GroupID => $K) {
43
+            if (is_number($GroupID)) {
44
+                $SQL[] = sprintf('%d', $GroupID);
45
+            }
46
+        }
47
+
48
+        if (!empty($SQL)) {
49
+            $SQL = sprintf(
50
+                '
51
+            DELETE FROM %s
52
+              WHERE UserID = %d
53
+              AND GroupID IN (%s)',
54
+                $this->Table,
55
+                G::$LoggedUser['ID'],
56
+                implode(', ', $SQL)
57
+            );
58
+            $this->query_and_clear_cache($SQL);
59
+        }
60
+    }
61
+
62
+    /**
63
+     * Uses $_POST['sort'] values to update the DB.
64
+     */
65
+    public function mass_update()
66
+    {
67
+        $SQL = [];
68
+        foreach ($_POST['sort'] as $GroupID => $Sort) {
69
+            if (is_number($Sort) && is_number($GroupID)) {
70
+                $SQL[] = sprintf('(%d, %d, %d)', $GroupID, $Sort, G::$LoggedUser['ID']);
71
+            }
72
+        }
73
+
74
+        if (!empty($SQL)) {
75
+            $SQL = sprintf(
76
+                '
77
+            INSERT INTO %s
78
+              (GroupID, Sort, UserID)
79
+            VALUES
80
+              %s
81
+            ON DUPLICATE KEY UPDATE
82
+              Sort = VALUES (Sort)',
83
+                $this->Table,
84
+                implode(', ', $SQL)
85
+            );
86
+            $this->query_and_clear_cache($SQL);
87
+        }
88
+    }
89
+}

+ 66
- 0
classes/mass_user_torrents_editor.class.php View File

@@ -0,0 +1,66 @@
1
+<?php
2
+
3
+/**
4
+ * Abstract class
5
+ * Mass User-Torrents Editor
6
+ *
7
+ * A class that deals with mass editing a user's torrents.
8
+ *
9
+ * This abstract class is used by sub-classes as a way to access the Cache/DB.
10
+ *
11
+ * It is intended to streamline the process of processing data sent by the
12
+ * MASS_USER_TORRENT_TABLE_VIEW class.
13
+ *
14
+ * It could also be used for other types like collages.
15
+ */
16
+abstract class MASS_USER_TORRENTS_EDITOR
17
+{
18
+    /**
19
+     * The affected DB table
20
+     * @var string $Table
21
+     */
22
+    protected $Table;
23
+
24
+    /**
25
+     * Set the Table
26
+     * @param string $Table
27
+     */
28
+    final public function set_table($Table)
29
+    {
30
+        $this->Table = db_string($Table);
31
+    }
32
+
33
+    /**
34
+     * Get the Table
35
+     * @return string $Table
36
+     */
37
+    final public function get_table()
38
+    {
39
+        return $this->Table;
40
+    }
41
+
42
+    /**
43
+     * The extending class must provide a method to send a query and clear the cache
44
+     */
45
+    abstract protected function query_and_clear_cache($sql);
46
+
47
+    /**
48
+     * A method to insert many rows into a single table
49
+     * Not required in subsequent classes
50
+     */
51
+    public function mass_add()
52
+    {
53
+    }
54
+
55
+    /**
56
+     * A method to remove many rows from a table
57
+     * The extending class must have a mass_remove method
58
+     */
59
+    abstract public function mass_remove();
60
+
61
+    /**
62
+     * A method to update many rows in a table
63
+     * The extending class must have a mass_update method
64
+     */
65
+    abstract public function mass_update();
66
+}

+ 300
- 0
classes/mass_user_torrents_table_view.class.php View File

@@ -0,0 +1,300 @@
1
+<?php
2
+declare(strict_types = 1);
3
+
4
+/**
5
+ * This class outputs a table that can be used to sort torrents through a drag/drop
6
+ * interface, an automatic column sorter, or manual imput.
7
+ *
8
+ * It places checkboxes to delete items.
9
+ *
10
+ * (It creates a div#thin.)
11
+ *
12
+ * It can be used for Bookmarks, Collages, or anywhere where torrents are managed.
13
+ */
14
+class MASS_USER_TORRENTS_TABLE_VIEW
15
+{
16
+    /**
17
+     * Used to set text the page heading (h2 tag)
18
+     * @var string $Heading
19
+     */
20
+    private $Heading = 'Manage Torrents';
21
+
22
+    /**
23
+     * Sets the value of the input name="type"
24
+     * Later to be used as $_POST['type'] in a form processor
25
+     * @var string $EditType
26
+     */
27
+    private $EditType;
28
+
29
+    /**
30
+     * Flag for empty $TorrentList
31
+     * @var bool $HasTorrentList
32
+     */
33
+    private $HasTorrents;
34
+
35
+    /**
36
+     * Internal reference to the TorrentList
37
+     * @var array $TorrentList
38
+     */
39
+    private $TorrentList;
40
+
41
+    /**
42
+     * Ref. to $CollageDataList
43
+     * @var array $CollageDataList
44
+     */
45
+    private $CollageDataList;
46
+
47
+    /**
48
+     * Counter for number of groups
49
+     * @var in $NumGroups
50
+     */
51
+    private $NumGroups = 0;
52
+
53
+    /**
54
+     * When creating a new instance of this class, TorrentList and
55
+     * CollageDataList must be passed. Additionally, a heading can be added.
56
+     *
57
+     * @param array $TorrentList
58
+     * @param array $CollageDataList
59
+     * @param string $EditType
60
+     * @param string $Heading
61
+     */
62
+    public function __construct(array &$TorrentList, array &$CollageDataList, $EditType, $Heading = null)
63
+    {
64
+        $this->set_heading($Heading);
65
+        $this->set_edit_type($EditType);
66
+
67
+        $this->TorrentList = $TorrentList;
68
+        $this->CollageDataList = $CollageDataList;
69
+
70
+        $this->HasTorrents = !empty($TorrentList);
71
+        if (!$this->HasTorrents) {
72
+            $this->no_torrents();
73
+        }
74
+    }
75
+
76
+    private function no_torrents()
77
+    { ?>
78
+<div>
79
+  <div class="header">
80
+    <h2>No torrents found</h2>
81
+  </div>
82
+  <div class="box pad" align="center">
83
+    <p>Add some torrents and come back later</p>
84
+  </div>
85
+</div>
86
+<?php
87
+    }
88
+
89
+    /**
90
+     * Renders a complete page and table
91
+     */
92
+    public function render_all()
93
+    {
94
+        $this->header();
95
+        $this->body();
96
+        $this->footer();
97
+    }
98
+
99
+    /**
100
+     * Renders a comptele page/table header: div#thin, h2, scripts, notes,
101
+     * form, table, etc.
102
+     */
103
+    public function header()
104
+    {
105
+        if ($this->HasTorrents) { ?>
106
+<div>
107
+  <div class="header">
108
+    <h2><?=display_str($this->Heading)?>
109
+    </h2>
110
+  </div>
111
+
112
+  <table width="100%" class="layout box">
113
+    <tr class="colhead">
114
+      <td id="sorting_head">Sorting</td>
115
+    </tr>
116
+
117
+    <tr>
118
+      <td id="drag_drop_textnote">
119
+        <ul>
120
+          <li>Click on the headings to organize columns automatically.</li>
121
+          <li>Sort multiple columns simultaneously by holding down the shift key and clicking other column headers.</li>
122
+          <li>Click and drag any row to change its order.</li>
123
+          <li>Double-click on a row to check it.</li>
124
+        </ul>
125
+      </td>
126
+    </tr>
127
+  </table>
128
+
129
+  <form action="bookmarks.php" method="post" id="drag_drop_collage_form">
130
+
131
+    <?php $this->buttons(); ?>
132
+
133
+    <table id="manage_collage_table" class="box">
134
+      <thead>
135
+        <tr class="colhead">
136
+          <th style="width: 7%;" data-sorter="false">Order</th>
137
+          <th style="width: 1%;"><span><abbr class="tooltip" title="Current order">#</abbr></span></th>
138
+          <th style="width: 1%;"><span>Year</span></th>
139
+          <th style="width: 15%;" data-sorter="ignoreArticles"><span>Artist</span></th>
140
+          <th data-sorter="ignoreArticles"><span>Torrent</span></th>
141
+          <th style="width: 5%;" data-sorter="relativeTime"><span>Bookmarked</span></th>
142
+          <th style="width: 1%;" id="check_all" data-sorter="false"><span>Remove</span></th>
143
+        </tr>
144
+      </thead>
145
+      <tbody>
146
+        <?php
147
+        }
148
+    }
149
+
150
+    /**
151
+     * Closes header code
152
+     */
153
+    public function footer()
154
+    {
155
+        if ($this->HasTorrents) { ?>
156
+      </tbody>
157
+    </table>
158
+
159
+    <?php $this->buttons(); ?>
160
+
161
+    <div>
162
+      <input type="hidden" name="action" value="mass_edit" />
163
+      <input type="hidden" name="type"
164
+        value="<?=display_str($this->EditType)?>" />
165
+      <input type="hidden" name="auth"
166
+        value="<?=G::$LoggedUser['AuthKey']?>" />
167
+    </div>
168
+  </form>
169
+</div>
170
+<?php
171
+        }
172
+    }
173
+
174
+    /**
175
+     * Formats data for use in row
176
+     *
177
+     */
178
+    public function body()
179
+    {
180
+        if ($this->HasTorrents) {
181
+            foreach ($this->TorrentList as $GroupID => $Group) {
182
+                $Artists = [];
183
+
184
+                extract($Group);
185
+                extract($this->CollageDataList[$GroupID]);
186
+
187
+                $this->NumGroups++;
188
+
189
+                $DisplayName = self::display_name($ExtendedArtists, $Artists, $VanityHouse);
190
+                $TorrentLink = '<a href="torrents.php?id='.$GroupID.'" class="tooltip" title="View torrent">'.$Name.'</a>';
191
+                $Year = $Year > 0 ? $Year : '';
192
+                $DateAdded = date($Time);
193
+
194
+                $this->row($Sort, $GroupID, $Year, $DisplayName, $TorrentLink, $DateAdded);
195
+            }
196
+        }
197
+    }
198
+
199
+    /**
200
+     * Outputs a single row
201
+     *
202
+     * @param string|int $Sort
203
+     * @param string|int $GroupID
204
+     * @param string|int $GroupYear
205
+     * @param string $DisplayName
206
+     * @param string $TorrentLink
207
+     */
208
+    public function row($Sort, $GroupID, $GroupYear, $DisplayName, $TorrentLink, $DateAdded)
209
+    { ?>
210
+<tr class="drag row" id="li_<?=$GroupID?>">
211
+  <td>
212
+    <input class="sort_numbers" type="text"
213
+      name="sort[<?=$GroupID?>]"
214
+      value="<?=$Sort?>"
215
+      id="sort_<?=$GroupID?>" size="4" />
216
+  </td>
217
+
218
+  <td>
219
+    <?=$this->NumGroups?>
220
+  </td>
221
+
222
+  <td>
223
+    <?=$GroupYear ? trim($GroupYear) : ' '?>
224
+  </td>
225
+
226
+  <td>
227
+    <?=$DisplayName ? trim($DisplayName) : ' '?>
228
+  </td>
229
+
230
+  <td>
231
+    <?=$TorrentLink ? trim($TorrentLink) : ' '?>
232
+  </td>
233
+
234
+  <td class="nobr tooltip" title="<?=$DateAdded?>">
235
+    <?=$DateAdded ? time_diff($DateAdded) : ' '?>
236
+  </td>
237
+
238
+  <td class="center">
239
+    <input type="checkbox" name="remove[<?=$GroupID?>]" value="" />
240
+  </td>
241
+</tr>
242
+<?php
243
+    }
244
+
245
+    /**
246
+     * Parses a simple display name
247
+     *
248
+     * @param array $ExtendedArtists
249
+     * @param array $Artists
250
+     * @param string $VanityHouse
251
+     * @return string $DisplayName
252
+     */
253
+    public static function display_name(array &$ExtendedArtists, array &$Artists, $VanityHouse)
254
+    {
255
+        $DisplayName = '';
256
+        if (!empty($ExtendedArtists[1]) || !empty($ExtendedArtists[4])
257
+         || !empty($ExtendedArtists[5]) || !empty($ExtendedArtists[6])) {
258
+            unset($ExtendedArtists[2], $ExtendedArtists[3]);
259
+            $DisplayName = Artists::display_artists($ExtendedArtists, true, false);
260
+        } elseif (count($Artists) > 0) {
261
+            $DisplayName = Artists::display_artists(array('1'=>$Artists), true, false);
262
+        }
263
+
264
+        if ($VanityHouse) {
265
+            $DisplayName .= ' [<abbr class="tooltip" title="This is a Vanity House release">VH</abbr>]';
266
+        }
267
+        return $DisplayName;
268
+    }
269
+
270
+    /**
271
+     * Renders buttons used at the top and bottom of the table
272
+     */
273
+    public function buttons()
274
+    { ?>
275
+<div class="drag_drop_save">
276
+  <input type="submit" name="update" value="Update ranking" title="Save your rank"
277
+    class="tooltip save_sortable_collage" />
278
+  <input type="submit" name="delete" value="Delete checked" title="Remove items"
279
+    class="tooltip save_sortable_collage" />
280
+</div>
281
+<?php
282
+    }
283
+
284
+    /**
285
+     * @param string $EditType
286
+     */
287
+    public function set_edit_type($EditType)
288
+    {
289
+        $this->EditType = $EditType;
290
+    }
291
+
292
+    /**
293
+     * Set's the current page's heading
294
+     * @param string $Heading
295
+     */
296
+    public function set_heading($Heading)
297
+    {
298
+        $this->Heading = $Heading;
299
+    }
300
+}

+ 580
- 0
classes/misc.class.php View File

@@ -0,0 +1,580 @@
1
+<?php
2
+
3
+class Misc
4
+{
5
+    /**
6
+     * Send an email.
7
+     *
8
+     * @param string $To the email address to send it to.
9
+     * @param string $Subject
10
+     * @param string $Body
11
+     * @param string $From The user part of the user@$ENV->SITE_DOMAIN email address.
12
+     * @param string $ContentType text/plain or text/html
13
+     */
14
+    public static function send_email($To, $Subject, $Body, $From = 'noreply', $ContentType = 'text/plain')
15
+    {
16
+        $ENV = ENV::go();
17
+
18
+        # todo: <<<EOT
19
+        $Headers  = "MIME-Version: 1.0\r\n";
20
+        $Headers .= "Content-type: $ContentType; charset=utf-8\r\n";
21
+        $Headers .= "From: $ENV->SITE_NAME <$From@$ENV->SITE_DOMAIN>\r\n";
22
+        $Headers .= "Reply-To: $From@$ENV->SITE_DOMAIN\r\n";
23
+        $Headers .= "X-Mailer: Project Gazelle\r\n";
24
+        $Headers .= "Message-Id: <".Users::make_secret()."@$ENV->SITE_DOMAIN>\r\n";
25
+        $Headers .= "X-Priority: 3\r\n";
26
+
27
+        // Check if email is enabled
28
+        if ($ENV->FEATURE_SEND_EMAIL) {
29
+            mail($To, $Subject, $Body, $Headers, "-f $From@$ENV->SITE_DOMAIN");
30
+        }
31
+    }
32
+
33
+    /**
34
+     * Sanitize a string to be allowed as a filename.
35
+     *
36
+     * @param string $EscapeStr the string to escape
37
+     * @return the string with all banned characters removed.
38
+     */
39
+    public static function file_string($EscapeStr)
40
+    {
41
+        $ENV = ENV::go();
42
+        return str_replace($ENV->BAD_CHARS, '', $EscapeStr);
43
+    }
44
+
45
+    /**
46
+     * Sends a PM from $FromId to $ToId.
47
+     *
48
+     * @param string $ToID ID of user to send PM to. If $ToID is an array and $ConvID is empty, a message will be sent to multiple users.
49
+     * @param string $FromID ID of user to send PM from, 0 to send from system
50
+     * @param string $Subject
51
+     * @param string $Body
52
+     * @param int $ConvID The conversation the message goes in. Leave blank to start a new conversation.
53
+     * @return
54
+     */
55
+    public static function send_pm($ToID, $FromID, $Subject, $Body, $ConvID = '')
56
+    {
57
+        global $Time;
58
+        $UnescapedSubject = $Subject;
59
+        $UnescapedBody = $Body;
60
+        $Subject = db_string($Subject);
61
+        $Body = Crypto::encrypt(substr($Body, 0, 49135)); // 49135 -> encryption -> 65536 (max length in mysql)
62
+
63
+        if ($ToID === 0) {
64
+            // Don't allow users to send messages to the system
65
+            return;
66
+        }
67
+
68
+        $QueryID = G::$DB->get_query_id();
69
+
70
+        if ($ConvID === '') {
71
+            // Create a new conversation.
72
+            G::$DB->query("
73
+            INSERT INTO pm_conversations (Subject)
74
+            VALUES ('$Subject')");
75
+            $ConvID = G::$DB->inserted_id();
76
+
77
+            G::$DB->query("
78
+            INSERT INTO pm_conversations_users
79
+              (UserID, ConvID, InInbox, InSentbox, SentDate, ReceivedDate, UnRead)
80
+            VALUES
81
+              ('$ToID', '$ConvID', '1','0', NOW(), NOW(), '1')");
82
+
83
+            if ($FromID === $ToID) {
84
+                G::$DB->query(
85
+                    "
86
+                UPDATE pm_conversations_users
87
+                SET InSentbox = '1'
88
+                  WHERE ConvID = '$ConvID'"
89
+                );
90
+            } elseif ($FromID !== 0) {
91
+                G::$DB->query("
92
+                INSERT INTO pm_conversations_users
93
+                  (UserID, ConvID, InInbox, InSentbox, SentDate, ReceivedDate, UnRead)
94
+                VALUES
95
+                  ('$FromID', '$ConvID', '0','1', NOW(), NOW(), '0')");
96
+            }
97
+            $ToID = array($ToID);
98
+        } else {
99
+            // Update the pre-existing conversations
100
+            G::$DB->query("
101
+            UPDATE pm_conversations_users
102
+            SET
103
+              InInbox = '1',
104
+              UnRead = '1',
105
+              ReceivedDate = NOW()
106
+            WHERE UserID IN (".implode(',', $ToID).")
107
+              AND ConvID = '$ConvID'");
108
+
109
+            G::$DB->query("
110
+            UPDATE pm_conversations_users
111
+            SET
112
+              InSentbox = '1',
113
+              SentDate = NOW()
114
+            WHERE UserID = '$FromID'
115
+              AND ConvID = '$ConvID'");
116
+        }
117
+
118
+        // Now that we have a $ConvID for sure, send the message
119
+        G::$DB->query("
120
+      INSERT INTO pm_messages
121
+        (SenderID, ConvID, SentDate, Body)
122
+      VALUES
123
+        ('$FromID', '$ConvID', NOW(), '$Body')");
124
+
125
+        // Update the cached new message count
126
+        foreach ($ToID as $ID) {
127
+            G::$DB->query("
128
+            SELECT COUNT(ConvID)
129
+            FROM pm_conversations_users
130
+              WHERE UnRead = '1'
131
+              AND UserID = '$ID'
132
+              AND InInbox = '1'");
133
+
134
+            list($UnRead) = G::$DB->next_record();
135
+            G::$Cache->cache_value("inbox_new_$ID", $UnRead);
136
+        }
137
+
138
+        G::$DB->query("
139
+        SELECT Username
140
+        FROM users_main
141
+          WHERE ID = '$FromID'");
142
+
143
+        list($SenderName) = G::$DB->next_record();
144
+        foreach ($ToID as $ID) {
145
+            G::$DB->query("
146
+            SELECT COUNT(ConvID)
147
+            FROM pm_conversations_users
148
+              WHERE UnRead = '1'
149
+              AND UserID = '$ID'
150
+              AND InInbox = '1'");
151
+                  
152
+            list($UnRead) = G::$DB->next_record();
153
+            G::$Cache->cache_value("inbox_new_$ID", $UnRead);
154
+        }
155
+
156
+        G::$DB->set_query_id($QueryID);
157
+        return $ConvID;
158
+    }
159
+
160
+    /**
161
+     * Create thread function, things should already be escaped when sent here.
162
+     *
163
+     * @param int $ForumID
164
+     * @param int $AuthorID ID of the user creating the post.
165
+     * @param string $Title
166
+     * @param string $PostBody
167
+     * @return -1 on error, -2 on user not existing, thread id on success.
168
+     */
169
+    public static function create_thread($ForumID, $AuthorID, $Title, $PostBody)
170
+    {
171
+        global $Time;
172
+        if (!$ForumID || !$AuthorID || !is_number($AuthorID) || !$Title || !$PostBody) {
173
+            return -1;
174
+        }
175
+
176
+        $QueryID = G::$DB->get_query_id();
177
+        G::$DB->query("
178
+        SELECT Username
179
+        FROM users_main
180
+          WHERE ID = $AuthorID");
181
+
182
+        if (!G::$DB->has_results()) {
183
+            G::$DB->set_query_id($QueryID);
184
+            return -2;
185
+        }
186
+        list($AuthorName) = G::$DB->next_record();
187
+
188
+        $ThreadInfo = [];
189
+        $ThreadInfo['IsLocked'] = 0;
190
+        $ThreadInfo['IsSticky'] = 0;
191
+
192
+        G::$DB->query("
193
+        INSERT INTO forums_topics
194
+          (Title, AuthorID, ForumID, LastPostTime, LastPostAuthorID, CreatedTime)
195
+        VALUES
196
+          ('$Title', '$AuthorID', '$ForumID', NOW(), '$AuthorID', NOW())");
197
+
198
+        $TopicID = G::$DB->inserted_id();
199
+        $Posts = 1;
200
+
201
+        G::$DB->query("
202
+        INSERT INTO forums_posts
203
+          (TopicID, AuthorID, AddedTime, Body)
204
+        VALUES
205
+          ('$TopicID', '$AuthorID', NOW(), '$PostBody')");
206
+        $PostID = G::$DB->inserted_id();
207
+
208
+        G::$DB->query("
209
+        UPDATE forums
210
+        SET
211
+          NumPosts  = NumPosts + 1,
212
+          NumTopics = NumTopics + 1,
213
+          LastPostID = '$PostID',
214
+          LastPostAuthorID = '$AuthorID',
215
+          LastPostTopicID = '$TopicID',
216
+          LastPostTime = NOW()
217
+        WHERE ID = '$ForumID'");
218
+
219
+        G::$DB->query("
220
+        UPDATE forums_topics
221
+        SET
222
+          NumPosts = NumPosts + 1,
223
+          LastPostID = '$PostID',
224
+          LastPostAuthorID = '$AuthorID',
225
+          LastPostTime = NOW()
226
+        WHERE ID = '$TopicID'");
227
+
228
+        // Bump this topic to head of the cache
229
+        list($Forum, , , $Stickies) = G::$Cache->get_value("forums_$ForumID");
230
+        if (!empty($Forum)) {
231
+            if (count($Forum) === TOPICS_PER_PAGE && $Stickies < TOPICS_PER_PAGE) {
232
+                array_pop($Forum);
233
+            }
234
+
235
+            G::$DB->query("
236
+            SELECT IsLocked, IsSticky, NumPosts
237
+            FROM forums_topics
238
+              WHERE ID ='$TopicID'");
239
+
240
+            list($IsLocked, $IsSticky, $NumPosts) = G::$DB->next_record();
241
+            $Part1 = array_slice($Forum, 0, $Stickies, true); // Stickies
242
+
243
+            $Part2 = array(
244
+                $TopicID => array(
245
+                    'ID' => $TopicID,
246
+                    'Title' => $Title,
247
+                    'AuthorID' => $AuthorID,
248
+                    'IsLocked' => $IsLocked,
249
+                    'IsSticky' => $IsSticky,
250
+                    'NumPosts' => $NumPosts,
251
+                    'LastPostID' => $PostID,
252
+                    'LastPostTime' => sqltime(),
253
+                    'LastPostAuthorID' => $AuthorID,
254
+                )
255
+            ); // Bumped thread
256
+
257
+            $Part3 = array_slice($Forum, $Stickies, TOPICS_PER_PAGE, true); //Rest of page
258
+            if ($Stickies > 0) {
259
+                $Part1 = array_slice($Forum, 0, $Stickies, true); //Stickies
260
+                $Part3 = array_slice($Forum, $Stickies, TOPICS_PER_PAGE - $Stickies - 1, true); //Rest of page
261
+            } else {
262
+                $Part1 = [];
263
+                $Part3 = $Forum;
264
+            }
265
+
266
+            if (is_null($Part1)) {
267
+                $Part1 = [];
268
+            }
269
+
270
+            if (is_null($Part3)) {
271
+                $Part3 = [];
272
+            }
273
+
274
+            $Forum = $Part1 + $Part2 + $Part3;
275
+            G::$Cache->cache_value("forums_$ForumID", array($Forum, '', 0, $Stickies), 0);
276
+        }
277
+
278
+        // Update the forum root
279
+        G::$Cache->begin_transaction('forums_list');
280
+        $UpdateArray = array(
281
+            'NumPosts' => '+1',
282
+            'NumTopics' => '+1',
283
+            'LastPostID' => $PostID,
284
+            'LastPostAuthorID' => $AuthorID,
285
+            'LastPostTopicID' => $TopicID,
286
+            'LastPostTime' => sqltime(),
287
+            'Title' => $Title,
288
+            'IsLocked' => $ThreadInfo['IsLocked'],
289
+            'IsSticky' => $ThreadInfo['IsSticky']
290
+        );
291
+
292
+        $UpdateArray['NumTopics'] = '+1';
293
+
294
+        G::$Cache->update_row($ForumID, $UpdateArray);
295
+        G::$Cache->commit_transaction(0);
296
+
297
+        $CatalogueID = floor((POSTS_PER_PAGE * ceil($Posts / POSTS_PER_PAGE) - POSTS_PER_PAGE) / THREAD_CATALOGUE);
298
+        G::$Cache->begin_transaction('thread_'.$TopicID.'_catalogue_'.$CatalogueID);
299
+
300
+        $Post = array(
301
+            'ID' => $PostID,
302
+            'AuthorID' => G::$LoggedUser['ID'],
303
+            'AddedTime' => sqltime(),
304
+            'Body' => $PostBody,
305
+            'EditedUserID' => 0,
306
+            'EditedTime' => null,
307
+            'Username' => ''
308
+        );
309
+        
310
+        G::$Cache->insert('', $Post);
311
+        G::$Cache->commit_transaction(0);
312
+
313
+        G::$Cache->begin_transaction('thread_'.$TopicID.'_info');
314
+        G::$Cache->update_row(false, array('Posts' => '+1', 'LastPostAuthorID' => $AuthorID));
315
+        G::$Cache->commit_transaction(0);
316
+
317
+        G::$DB->set_query_id($QueryID);
318
+        return $TopicID;
319
+    }
320
+
321
+    /**
322
+     * If the suffix of $Haystack is $Needle
323
+     *
324
+     * @param string $Haystack String to search in
325
+     * @param string $Needle String to search for
326
+     * @return boolean True if $Needle is a suffix of $Haystack
327
+     */
328
+    public static function ends_with($Haystack, $Needle)
329
+    {
330
+        return substr($Haystack, strlen($Needle) * -1) === $Needle;
331
+    }
332
+
333
+    /**
334
+     * If the prefix of $Haystack is $Needle
335
+     *
336
+     * @param string $Haystack String to search in
337
+     * @param string $Needle String to search for
338
+     * @return boolean True if $Needle is a prefix of $Haystack
339
+     */
340
+    public static function starts_with($Haystack, $Needle)
341
+    {
342
+        return strpos($Haystack, $Needle) === 0;
343
+    }
344
+
345
+    /**
346
+     * Variant of in_array() with trailing wildcard support
347
+     *
348
+     * @param string $Needle, array $Haystack
349
+     * @return boolean true if (substring of) $Needle exists in $Haystack
350
+     */
351
+    public static function in_array_partial($Needle, $Haystack)
352
+    {
353
+        static $Searches = [];
354
+        if (array_key_exists($Needle, $Searches)) {
355
+            return $Searches[$Needle];
356
+        }
357
+
358
+        foreach ($Haystack as $String) {
359
+            if (substr($String, -1) === '*') {
360
+                if (!strncmp($Needle, $String, strlen($String) - 1)) {
361
+                    $Searches[$Needle] = true;
362
+                    return true;
363
+                }
364
+            } elseif (!strcmp($Needle, $String)) {
365
+                $Searches[$Needle] = true;
366
+                return true;
367
+            }
368
+        }
369
+
370
+        $Searches[$Needle] = false;
371
+        return false;
372
+    }
373
+
374
+    /**
375
+     * Used to check if keys in $_POST and $_GET are all set, and throws an error if not.
376
+     * This reduces 'if' statement redundancy for a lot of variables
377
+     *
378
+     * @param array $Request Either $_POST or $_GET, or whatever other array you want to check.
379
+     * @param array $Keys The keys to ensure are set.
380
+     * @param boolean $AllowEmpty If set to true, a key that is in the request but blank will not throw an error.
381
+     * @param int $Error The error code to throw if one of the keys isn't in the array.
382
+     */
383
+    public static function assert_isset_request($Request, $Keys = null, $AllowEmpty = false, $Error = 0)
384
+    {
385
+        if (isset($Keys)) {
386
+            foreach ($Keys as $K) {
387
+                if (!isset($Request[$K]) || ($AllowEmpty === false && $Request[$K] === '')) {
388
+                    error($Error);
389
+                    break;
390
+                }
391
+            }
392
+        } else {
393
+            foreach ($Request as $R) {
394
+                if (!isset($R) || ($AllowEmpty === false && $R === '')) {
395
+                    error($Error);
396
+                    break;
397
+                }
398
+            }
399
+        }
400
+    }
401
+
402
+    /**
403
+     * Given an array of tags, return an array of their IDs.
404
+     *
405
+     * @param array $TagNames
406
+     * @return array IDs
407
+     */
408
+    public static function get_tags($TagNames)
409
+    {
410
+        $TagIDs = [];
411
+        foreach ($TagNames as $Index => $TagName) {
412
+            $Tag = G::$Cache->get_value("tag_id_$TagName");
413
+            if (is_array($Tag)) {
414
+                unset($TagNames[$Index]);
415
+                $TagIDs[$Tag['ID']] = $Tag['Name'];
416
+            }
417
+        }
418
+
419
+        if (count($TagNames) > 0) {
420
+            $QueryID = G::$DB->get_query_id();
421
+            G::$DB->query("
422
+            SELECT ID, Name
423
+            FROM tags
424
+              WHERE Name IN ('".implode("', '", $TagNames)."')");
425
+
426
+            $SQLTagIDs = G::$DB->to_array();
427
+            G::$DB->set_query_id($QueryID);
428
+
429
+            foreach ($SQLTagIDs as $Tag) {
430
+                $TagIDs[$Tag['ID']] = $Tag['Name'];
431
+                G::$Cache->cache_value('tag_id_'.$Tag['Name'], $Tag, 0);
432
+            }
433
+        }
434
+        return($TagIDs);
435
+    }
436
+
437
+    /**
438
+     * Gets the alias of the tag; if there is no alias, silently returns the original tag.
439
+     *
440
+     * @param string $BadTag the tag we want to alias
441
+     * @return string The aliased tag.
442
+     */
443
+    public static function get_alias_tag($BadTag)
444
+    {
445
+        $QueryID = G::$DB->get_query_id();
446
+        G::$DB->query("
447
+        SELECT AliasTag
448
+        FROM tag_aliases
449
+          WHERE BadTag = '$BadTag'
450
+          LIMIT 1");
451
+
452
+        if (G::$DB->has_results()) {
453
+            list($AliasTag) = G::$DB->next_record();
454
+        } else {
455
+            $AliasTag = $BadTag;
456
+        }
457
+        G::$DB->set_query_id($QueryID);
458
+        return $AliasTag;
459
+    }
460
+
461
+    /*
462
+     * Write a message to the system log.
463
+     *
464
+     * @param string $Message the message to write.
465
+     */
466
+    public static function write_log($Message)
467
+    {
468
+        global $Time;
469
+        $QueryID = G::$DB->get_query_id();
470
+        G::$DB->query("
471
+        INSERT INTO log (Message, Time)
472
+          VALUES (?, NOW())", $Message);
473
+        G::$DB->set_query_id($QueryID);
474
+    }
475
+
476
+    /**
477
+     * Get a tag ready for database input and display.
478
+     *
479
+     * @param string $Str
480
+     * @return sanitized version of $Str
481
+     */
482
+    public static function sanitize_tag($Str)
483
+    {
484
+        $Str = strtolower($Str);
485
+        $Str = preg_replace('/[^a-z0-9:.]/', '', $Str);
486
+        $Str = preg_replace('/(^[.,]*)|([.,]*$)/', '', $Str);
487
+        $Str = htmlspecialchars($Str);
488
+        $Str = db_string(trim($Str));
489
+        return $Str;
490
+    }
491
+
492
+    /**
493
+     * HTML escape an entire array for output.
494
+     * @param array $Array, what we want to escape
495
+     * @param boolean/array $Escape
496
+     *  if true, all keys escaped
497
+     *  if false, no escaping.
498
+     *  If array, it's a list of array keys not to escape.
499
+     * @return mutated version of $Array with values escaped.
500
+     */
501
+    public static function display_array($Array, $Escape = [])
502
+    {
503
+        foreach ($Array as $Key => $Val) {
504
+            if ((!is_array($Escape) && $Escape === true) || !in_array($Key, $Escape)) {
505
+                $Array[$Key] = display_str($Val);
506
+            }
507
+        }
508
+        return $Array;
509
+    }
510
+
511
+    /**
512
+     * Searches for a key/value pair in an array.
513
+     *
514
+     * @return array of results
515
+     */
516
+    public static function search_array($Array, $Key, $Value)
517
+    {
518
+        $Results = [];
519
+        if (is_array($Array)) {
520
+            if (isset($Array[$Key]) && $Array[$Key] === $Value) {
521
+                $Results[] = $Array;
522
+            }
523
+
524
+            foreach ($Array as $subarray) {
525
+                $Results = array_merge($Results, self::search_array($subarray, $Key, $Value));
526
+            }
527
+        }
528
+        return $Results;
529
+    }
530
+
531
+    /**
532
+     * Search for $Needle in the string $Haystack which is a list of values separated by $Separator.
533
+     * @param string $Haystack
534
+     * @param string $Needle
535
+     * @param string $Separator
536
+     * @param boolean $Strict
537
+     * @return boolean
538
+     */
539
+    public static function search_joined_string($Haystack, $Needle, $Separator = '|', $Strict = true)
540
+    {
541
+        return (array_search($Needle, explode($Separator, $Haystack), $Strict) !== false);
542
+    }
543
+
544
+    /**
545
+     * Check for a ":" in the beginning of a torrent meta data string
546
+     * to see if it's stored in the old base64-encoded format
547
+     *
548
+     * @param string $Torrent the torrent data
549
+     * @return true if the torrent is stored in binary format
550
+     */
551
+    public static function is_new_torrent(&$Data)
552
+    {
553
+        return strpos(substr($Data, 0, 10), ':') !== false;
554
+    }
555
+
556
+    public static function display_recommend($ID, $Type, $Hide = true)
557
+    {
558
+        if ($Hide) {
559
+            $Hide = ' style="display: none;"';
560
+        } ?>
561
+<div id="recommendation_div" data-id="<?=$ID?>"
562
+    data-type="<?=$Type?>" <?=$Hide?> class="center">
563
+    <div style="display: inline-block;">
564
+        <strong>Recommend to:</strong>
565
+        <select id="friend" name="friend">
566
+            <option value="0" selected="selected">Choose friend</option>
567
+        </select>
568
+        <input type="text" id="recommendation_note" placeholder="Add note..." />
569
+        <button id="send_recommendation" disabled="disabled">Send</button>
570
+    </div>
571
+    <div class="new" id="recommendation_status"><br /></div>
572
+</div>
573
+<?php
574
+    }
575
+
576
+    public static function is_valid_url($URL)
577
+    {
578
+        return preg_match('|^http(s)?://[a-z0-9-]+(.[a-z0-9-]+)*(:[0-9]+)?(/.*)?$|i', $URL);
579
+    }
580
+}

+ 656
- 0
classes/mysql.class.php View File

@@ -0,0 +1,656 @@
1
+<?php
2
+#declare(strict_types = 1);
3
+
4
+//-----------------------------------------------------------------------------------
5
+/////////////////////////////////////////////////////////////////////////////////////
6
+/*//-- MySQL wrapper class ----------------------------------------------------------
7
+
8
+This class provides an interface to mysqli. You should always use this class instead
9
+of the mysql/mysqli functions, because this class provides debugging features and a
10
+bunch of other cool stuff.
11
+
12
+Everything returned by this class is automatically escaped for output. This can be
13
+turned off by setting $Escape to false in next_record or to_array.
14
+
15
+//--------- Basic usage -------------------------------------------------------------
16
+
17
+* Creating the object.
18
+
19
+require(SERVER_ROOT.'/classes/mysql.class.php');
20
+$DB = NEW DB_MYSQL;
21
+-----
22
+
23
+* Making a query
24
+
25
+$DB->query("
26
+  SELECT *
27
+  FROM table...");
28
+
29
+  Is functionally equivalent to using mysqli_query("SELECT * FROM table...")
30
+  Stores the result set in $this->QueryID
31
+  Returns the result set, so you can save it for later (see set_query_id())
32
+-----
33
+
34
+* Getting data from a query
35
+
36
+$array = $DB->next_record();
37
+  Is functionally equivalent to using mysqli_fetch_array($ResultSet)
38
+  You do not need to specify a result set - it uses $this-QueryID
39
+-----
40
+
41
+* Escaping a string
42
+
43
+db_string($str);
44
+  Is a wrapper for mysqli_real_escape_string().
45
+  USE THIS FUNCTION EVERY TIME YOU QUERY USER-SUPPLIED INPUT!
46
+
47
+//--------- Advanced usage ---------------------------------------------------------
48
+
49
+* The conventional way of retrieving a row from a result set is as follows:
50
+
51
+list($All, $Columns, $That, $You, $Select) = $DB->next_record();
52
+-----
53
+
54
+* This is how you loop over the result set:
55
+
56
+while (list($All, $Columns, $That, $You, $Select) = $DB->next_record()) {
57
+  echo "Do stuff with $All of the ".$Columns.$That.$You.$Select;
58
+}
59
+-----
60
+
61
+* There are also a couple more mysqli functions that have been wrapped. They are:
62
+
63
+record_count()
64
+  Wrapper to mysqli_num_rows()
65
+
66
+affected_rows()
67
+  Wrapper to mysqli_affected_rows()
68
+
69
+inserted_id()
70
+  Wrapper to mysqli_insert_id()
71
+
72
+close
73
+  Wrapper to mysqli_close()
74
+-----
75
+
76
+* And, of course, a few handy custom functions.
77
+
78
+to_array($Key = false)
79
+  Transforms an entire result set into an array (useful in situations where you
80
+  can't order the rows properly in the query).
81
+
82
+  If $Key is set, the function uses $Key as the index (good for looking up a
83
+  field). Otherwise, it uses an iterator.
84
+
85
+  For an example of this function in action, check out forum.php.
86
+
87
+collect($Key)
88
+  Loops over the result set, creating an array from one of the fields ($Key).
89
+  For an example, see forum.php.
90
+
91
+set_query_id($ResultSet)
92
+  This class can only hold one result set at a time. Using set_query_id allows
93
+  you to set the result set that the class is using to the result set in
94
+  $ResultSet. This result set should have been obtained earlier by using
95
+  $DB->query().
96
+
97
+  Example:
98
+
99
+  $FoodRS = $DB->query("
100
+      SELECT *
101
+      FROM food");
102
+  $DB->query("
103
+    SELECT *
104
+    FROM drink");
105
+  $Drinks = $DB->next_record();
106
+  $DB->set_query_id($FoodRS);
107
+  $Food = $DB->next_record();
108
+
109
+  Of course, this example is contrived, but you get the point.
110
+
111
+-------------------------------------------------------------------------------------
112
+*///---------------------------------------------------------------------------------
113
+
114
+if (!extension_loaded('mysqli')) {
115
+    error('Mysqli Extension not loaded.');
116
+}
117
+
118
+/**
119
+ * db_string
120
+ * Handles escaping
121
+ */
122
+function db_string($String, $DisableWildcards = false)
123
+{
124
+    global $DB;
125
+    $DB->connect(0);
126
+
127
+    # Connect and mysqli_real_escape_string()
128
+    # Previously called $DB->escape_str, now below
129
+    # todo: Fix the bad escapes everywhere; see below
130
+    #if (!is_string($String)) { # This is the correct way,
131
+    if (is_array($String)) { # but this prevents errors
132
+        error('Attempted to escape non-string.', $NoHTML = true);
133
+        $String = '';
134
+    } else {
135
+        $String = mysqli_real_escape_string($DB->LinkID, $String);
136
+    }
137
+
138
+    // Remove user input wildcards
139
+    if ($DisableWildcards) {
140
+        $String = str_replace(array('%','_'), array('\%','\_'), $String);
141
+    }
142
+
143
+    return $String;
144
+}
145
+
146
+/**
147
+ * db_array
148
+ */
149
+function db_array($Array, $DontEscape = [], $Quote = false)
150
+{
151
+    foreach ($Array as $Key => $Val) {
152
+        if (!in_array($Key, $DontEscape)) {
153
+            if ($Quote) {
154
+                $Array[$Key] = '\''.db_string(trim($Val)).'\'';
155
+            } else {
156
+                $Array[$Key] = db_string(trim($Val));
157
+            }
158
+        }
159
+    }
160
+    return $Array;
161
+}
162
+
163
+// todo: Revisit access levels once Drone is replaced by ZeRobot
164
+class DB_MYSQL
165
+{
166
+    public $LinkID = false;
167
+    protected $QueryID = false;
168
+    protected $StatementID = false;
169
+    protected $PreparedQuery = false;
170
+    protected $Record = [];
171
+    protected $Row;
172
+    protected $Errno = 0;
173
+    protected $Error = '';
174
+
175
+    public $Queries = [];
176
+    public $Time = 0.0;
177
+
178
+    protected $Database = '';
179
+    protected $Server = '';
180
+    protected $User = '';
181
+    protected $Pass = '';
182
+    protected $Port = 0;
183
+    protected $Socket = '';
184
+
185
+    /**
186
+     * __construct
187
+     */
188
+    public function __construct($Database = null, $User = null, $Pass = null, $Server = null, $Port = null, $Socket = null)
189
+    {
190
+        $ENV = ENV::go();
191
+        $this->Database = $ENV->getPriv('SQLDB');
192
+        $this->User = $ENV->getPriv('SQLLOGIN');
193
+        $this->Pass = $ENV->getPriv('SQLPASS');
194
+        $this->Server = $ENV->getPriv('SQLHOST');
195
+        $this->Port = $ENV->getPriv('SQLPORT');
196
+        $this->Socket = $ENV->getPriv('SQLSOCK');
197
+    }
198
+
199
+    /**
200
+     * halt
201
+     */
202
+    public function halt($Msg)
203
+    {
204
+        global $Debug, $argv;
205
+        $DBError = 'MySQL: '.strval($Msg).' SQL error: '.strval($this->Errno).' ('.strval($this->Error).')';
206
+
207
+        if ($this->Errno === 1194) {
208
+            send_irc(ADMIN_CHAN, $this->Error);
209
+        }
210
+
211
+        $Debug->analysis('!dev DB Error', $DBError, 3600 * 24);
212
+        if (DEBUG_MODE || check_perms('site_debug') || isset($argv[1])) {
213
+            echo '<pre>'.display_str($DBError).'</pre>';
214
+            if (DEBUG_MODE || check_perms('site_debug')) {
215
+                print_r($this->Queries);
216
+            }
217
+            error(400, $NoHTML = true);
218
+        } else {
219
+            error(-1, $NoHTML = true);
220
+        }
221
+    }
222
+
223
+    /**
224
+     * connect
225
+     */
226
+    public function connect()
227
+    {
228
+        if (!$this->LinkID) {
229
+            $this->LinkID = mysqli_connect($this->Server, $this->User, $this->Pass, $this->Database, $this->Port, $this->Socket); // defined in config.php
230
+            if (!$this->LinkID) {
231
+                $this->Errno = mysqli_connect_errno();
232
+                $this->Error = mysqli_connect_error();
233
+                $this->halt('Connection failed (host:'.$this->Server.':'.$this->Port.')');
234
+            }
235
+        }
236
+        mysqli_set_charset($this->LinkID, "utf8mb4");
237
+    }
238
+     
239
+    /**
240
+     * prepare_query
241
+     */
242
+    public function prepare_query($Query, &...$BindVars)
243
+    {
244
+        $this->connect();
245
+
246
+        $this->StatementID = mysqli_prepare($this->LinkID, $Query);
247
+        if (!empty($BindVars)) {
248
+            $Types = '';
249
+            $TypeMap = ['string'=>'s', 'double'=>'d', 'integer'=>'i', 'boolean'=>'i'];
250
+
251
+            foreach ($BindVars as $BindVar) {
252
+                $Types .= $TypeMap[gettype($BindVar)] ?? 'b';
253
+            }
254
+            mysqli_stmt_bind_param($this->StatementID, $Types, ...$BindVars);
255
+        }
256
+
257
+        $this->PreparedQuery = $Query;
258
+        return $this->StatementID;
259
+    }
260
+
261
+    /**
262
+     * exec_prepared_query
263
+     */
264
+    public function exec_prepared_query()
265
+    {
266
+        $QueryStartTime = microtime(true);
267
+        mysqli_stmt_execute($this->StatementID);
268
+        $this->QueryID = mysqli_stmt_get_result($this->StatementID);
269
+        $QueryRunTime = (microtime(true) - $QueryStartTime) * 1000;
270
+        $this->Queries[] = [$this->PreppedQuery, $QueryRunTime, null];
271
+        $this->Time += $QueryRunTime;
272
+    }
273
+
274
+    /**
275
+     * Runs a raw query assuming pre-sanitized input. However, attempting to self sanitize (such
276
+     * as via db_string) is still not as safe for using prepared statements so for queries
277
+     * involving user input, you really should not use this function (instead opting for
278
+     * prepared_query) {@See DB_MYSQL::prepared_query}
279
+     *
280
+     * When running a batch of queries using the same statement
281
+     * with a variety of inputs, it's more performant to reuse the statement
282
+     * with {@see DB_MYSQL::prepare} and {@see DB_MYSQL::execute}
283
+     *
284
+     * @return mysqli_result|bool Returns a mysqli_result object
285
+     *                            for successful SELECT queries,
286
+     *                            or TRUE for other successful DML queries
287
+     *                            or FALSE on failure.
288
+     *
289
+     * @param $Query
290
+     * @param int $AutoHandle
291
+     * @return mysqli_result|bool
292
+     */
293
+    public function query($Query, &...$BindVars)
294
+    {
295
+        /**
296
+         * If there was a previous query, we store the warnings. We cannot do
297
+         * this immediately after mysqli_query because mysqli_insert_id will
298
+         * break otherwise due to mysqli_get_warnings sending a SHOW WARNINGS;
299
+         * query. When sending a query, however, we're sure that we won't call
300
+         * mysqli_insert_id (or any similar function, for that matter) later on,
301
+         * so we can safely get the warnings without breaking things.
302
+         * Note that this means that we have to call $this->warnings manually
303
+         * for the last query!
304
+         */
305
+        global $Debug;
306
+        if ($this->QueryID) {
307
+            $this->warnings();
308
+        }
309
+
310
+        $QueryStartTime = microtime(true);
311
+        $this->connect();
312
+
313
+        // In the event of a MySQL deadlock, we sleep allowing MySQL time to unlock, then attempt again for a maximum of 5 tries
314
+        for ($i = 1; $i < 6; $i++) {
315
+            $this->StatementID = mysqli_prepare($this->LinkID, $Query);
316
+            if (!empty($BindVars)) {
317
+                $Types = '';
318
+                $TypeMap = ['string'=>'s', 'double'=>'d', 'integer'=>'i', 'boolean'=>'i'];
319
+
320
+                foreach ($BindVars as $BindVar) {
321
+                    $Types .= $TypeMap[gettype($BindVar)] ?? 'b';
322
+                }
323
+                mysqli_stmt_bind_param($this->StatementID, $Types, ...$BindVars);
324
+            }
325
+
326
+            mysqli_stmt_execute($this->StatementID);
327
+            $this->QueryID = mysqli_stmt_get_result($this->StatementID);
328
+
329
+            if (DEBUG_MODE) {
330
+                // In DEBUG_MODE, return the full trace on a SQL error (super useful
331
+                // For debugging). do not attempt to retry to query
332
+                if (!$this->QueryID) {
333
+                    echo '<pre>' . mysqli_error($this->LinkID) . '<br><br>';
334
+                    debug_print_backtrace();
335
+                    echo '</pre>';
336
+                    error();
337
+                }
338
+            }
339
+
340
+            if (!in_array(mysqli_errno($this->LinkID), array(1213, 1205))) {
341
+                break;
342
+            }
343
+
344
+            $Debug->analysis('Non-Fatal Deadlock:', $Query, 3600 * 24);
345
+            trigger_error("Database deadlock, attempt $i");
346
+            sleep($i * rand(2, 5)); // Wait longer as attempts increase
347
+        }
348
+
349
+        $QueryEndTime = microtime(true);
350
+        $this->Queries[] = array($Query, ($QueryEndTime - $QueryStartTime) * 1000, null);
351
+        $this->Time += ($QueryEndTime - $QueryStartTime) * 1000;
352
+
353
+        if (!$this->QueryID && !$this->StatementID) {
354
+            $this->Errno = mysqli_errno($this->LinkID);
355
+            $this->Error = mysqli_error($this->LinkID);
356
+            $this->halt("Invalid Query: $Query");
357
+        }
358
+
359
+        $this->Row = 0;
360
+        return $this->QueryID;
361
+    }
362
+
363
+    /**
364
+     * inserted_id
365
+     */
366
+    public function inserted_id()
367
+    {
368
+        if ($this->LinkID) {
369
+            return mysqli_insert_id($this->LinkID);
370
+        }
371
+    }
372
+
373
+    /**
374
+     * next_record
375
+     */
376
+    public function next_record($Type = MYSQLI_BOTH, $Escape = true)
377
+    { // $Escape can be true, false, or an array of keys to not escape
378
+        if ($this->LinkID) {
379
+            $this->Record = mysqli_fetch_array($this->QueryID, $Type);
380
+            $this->Row++;
381
+
382
+            if (!is_array($this->Record)) {
383
+                $this->QueryID = false;
384
+            } elseif ($Escape !== false) {
385
+                $this->Record = Misc::display_array($this->Record, $Escape);
386
+            }
387
+            return $this->Record;
388
+        }
389
+    }
390
+
391
+    /**
392
+     * close
393
+     */
394
+    public function close()
395
+    {
396
+        if ($this->LinkID) {
397
+            if (!mysqli_close($this->LinkID)) {
398
+                $this->halt('Cannot close connection or connection did not open.');
399
+            }
400
+            $this->LinkID = false;
401
+        }
402
+    }
403
+
404
+    /*
405
+     * Returns an integer with the number of rows found
406
+     * Returns a string if the number of rows found exceeds MAXINT
407
+     */
408
+    public function record_count()
409
+    {
410
+        if ($this->QueryID) {
411
+            return mysqli_num_rows($this->QueryID);
412
+        }
413
+    }
414
+
415
+    /*
416
+     * Returns true if the query exists and there were records found
417
+     * Returns false if the query does not exist or if there were 0 records returned
418
+     */
419
+    public function has_results()
420
+    {
421
+        return ($this->QueryID && $this->record_count() !== 0);
422
+    }
423
+
424
+    /**
425
+     * affected_rows
426
+     */
427
+    public function affected_rows()
428
+    {
429
+        if ($this->LinkID) {
430
+            return mysqli_affected_rows($this->LinkID);
431
+        }
432
+    }
433
+
434
+    /**
435
+     * info
436
+     */
437
+    public function info()
438
+    {
439
+        return mysqli_get_host_info($this->LinkID);
440
+    }
441
+
442
+    // Creates an array from a result set
443
+    // If $Key is set, use the $Key column in the result set as the array key
444
+    // Otherwise, use an integer
445
+    public function to_array($Key = false, $Type = MYSQLI_BOTH, $Escape = true)
446
+    {
447
+        $Return = [];
448
+        while ($Row = mysqli_fetch_array($this->QueryID, $Type)) {
449
+            if ($Escape !== false) {
450
+                $Row = Misc::display_array($Row, $Escape);
451
+            }
452
+
453
+            if ($Key !== false) {
454
+                $Return[$Row[$Key]] = $Row;
455
+            } else {
456
+                $Return[] = $Row;
457
+            }
458
+        }
459
+
460
+        mysqli_data_seek($this->QueryID, 0);
461
+        return $Return;
462
+    }
463
+
464
+    //  Loops through the result set, collecting the $ValField column into an array with $KeyField as keys
465
+    public function to_pair($KeyField, $ValField, $Escape = true)
466
+    {
467
+        $Return = [];
468
+        while ($Row = mysqli_fetch_array($this->QueryID)) {
469
+            if ($Escape) {
470
+                $Key = display_str($Row[$KeyField]);
471
+                $Val = display_str($Row[$ValField]);
472
+            } else {
473
+                $Key = $Row[$KeyField];
474
+                $Val = $Row[$ValField];
475
+            }
476
+            $Return[$Key] = $Val;
477
+        }
478
+
479
+        mysqli_data_seek($this->QueryID, 0);
480
+        return $Return;
481
+    }
482
+
483
+    //  Loops through the result set, collecting the $Key column into an array
484
+    public function collect($Key, $Escape = true)
485
+    {
486
+        $Return = [];
487
+        while ($Row = mysqli_fetch_array($this->QueryID)) {
488
+            $Return[] = $Escape ? display_str($Row[$Key]) : $Row[$Key];
489
+        }
490
+        
491
+        mysqli_data_seek($this->QueryID, 0);
492
+        return $Return;
493
+    }
494
+
495
+
496
+    /**
497
+     * Useful extras from OPS
498
+     */
499
+
500
+    /**
501
+     * Runs a prepared_query using placeholders and returns the matched row.
502
+     * Stashes the current query id so that this can be used within a block
503
+     * that is looping over an active resultset.
504
+     *
505
+     * @param string  $sql The parameterized query to run
506
+     * @param mixed   $args  The values of the placeholders
507
+     * @return array  resultset or null
508
+     */
509
+    public function row($Query, &...$BindVars)
510
+    {
511
+        $qid = $this->get_query_id();
512
+        $this->query($Query, ...$BindVars);
513
+        $result = $this->next_record(MYSQLI_NUM, false);
514
+        $this->set_query_id($qid);
515
+        return $result;
516
+    }
517
+
518
+    /**
519
+     * Runs a prepared_query using placeholders and returns the first element
520
+     * of the first row.
521
+     * Stashes the current query id so that this can be used within a block
522
+     * that is looping over an active resultset.
523
+     *
524
+     * @param string  $sql The parameterized query to run
525
+     * @param mixed   $args  The values of the placeholders
526
+     * @return mixed  value or null
527
+     */
528
+    public function scalar($Query, &...$BindVars)
529
+    {
530
+        $qid = $this->get_query_id();
531
+        $this->query($Query, ...$BindVars);
532
+        $result = $this->has_results() ? $this->next_record(MYSQLI_NUM, false) : [null];
533
+        $this->set_query_id($qid);
534
+        return $result[0];
535
+    }
536
+    # End OPS additions
537
+
538
+
539
+    /**
540
+     * set_query_id
541
+     */
542
+    public function set_query_id(&$ResultSet)
543
+    {
544
+        $this->QueryID = $ResultSet;
545
+        $this->Row = 0;
546
+    }
547
+
548
+    /**
549
+     * get_query_id
550
+     */
551
+    public function get_query_id()
552
+    {
553
+        return $this->QueryID;
554
+    }
555
+
556
+    /**
557
+     * beginning
558
+     */
559
+    public function beginning()
560
+    {
561
+        mysqli_data_seek($this->QueryID, 0);
562
+        $this->Row = 0;
563
+    }
564
+
565
+    /**
566
+     * This function determines whether the last query caused warning messages
567
+     * and stores them in $this->Queries
568
+     */
569
+    public function warnings()
570
+    {
571
+        $Warnings = [];
572
+        if (!is_bool($this->LinkID) && mysqli_warning_count($this->LinkID)) {
573
+            $e = mysqli_get_warnings($this->LinkID);
574
+            do {
575
+                if ($e->errno === 1592) {
576
+                    // 1592: Unsafe statement written to the binary log using statement format since BINLOG_FORMAT = STATEMENT
577
+                    continue;
578
+                }
579
+                $Warnings[] = 'Code ' . $e->errno . ': ' . display_str($e->message);
580
+            } while ($e->next());
581
+        }
582
+        $this->Queries[count($this->Queries) - 1][2] = $Warnings;
583
+    }
584
+
585
+
586
+    /**
587
+     * todo: Work this into Bio Gazelle
588
+     * @see https://github.com/OPSnet/Gazelle/blob/master/app/DB.php
589
+     */
590
+
591
+    /**
592
+     * Soft delete a row from a table <t> by inserting it into deleted_<t> and then delete from <t>
593
+     * @param string $schema the schema name
594
+     * @param string $table the table name
595
+     * @param array $condition Must be an array of arrays, e.g. [[column_name, column_value]] or [[col1, val1], [col2, val2]]
596
+     *                         Will be used to identify the row (or rows) to delete
597
+     * @param boolean $delete whether to delete the matched rows
598
+     * @return array 2 elements, true/false and message if false
599
+     * /
600
+    public function softDelete($schema, $table, array $condition, $delete = true)
601
+    {
602
+        $sql = 'SELECT column_name, column_type FROM information_schema.columns WHERE table_schema = ? AND table_name = ? ORDER BY 1';
603
+        $this->db->prepared_query($sql, $schema, $table);
604
+        $t1 = $this->db->to_array();
605
+        $n1 = count($t1);
606
+
607
+        $softDeleteTable = 'deleted_' . $table;
608
+        $this->db->prepared_query($sql, $schema, $softDeleteTable);
609
+        $t2 = $this->db->to_array();
610
+        $n2 = count($t2);
611
+
612
+        if (!$n1) {
613
+            return [false, "No such table $table"];
614
+        } elseif (!$n2) {
615
+            return [false, "No such table $softDeleteTable"];
616
+        } elseif ($n1 != $n2) {
617
+            // tables do not have the same number of columns
618
+            return [false, "$table and $softDeleteTable column count mismatch ($n1 != $n2)"];
619
+        }
620
+
621
+        $column = [];
622
+        for ($i = 0; $i < $n1; ++$i) {
623
+            // a column does not have the same name or datatype
624
+            if (strtolower($t1[$i][0]) != strtolower($t2[$i][0]) || $t1[$i][1] != $t2[$i][1]) {
625
+                return [false, "{$table}: column {$t1[$i][0]} name or datatype mismatch {$t1[$i][0]}:{$t2[$i][0]} {$t1[$i][1]}:{$t2[$i][1]}"];
626
+            }
627
+            $column[] = $t1[$i][0];
628
+        }
629
+        $columnList = implode(', ', $column);
630
+        $conditionList = implode(' AND ', array_map(function ($c) {
631
+            return "{$c[0]} = ?";
632
+        }, $condition));
633
+        $argList = array_map(function ($c) {
634
+            return $c[1];
635
+        }, $condition);
636
+
637
+        $sql = "INSERT INTO $softDeleteTable
638
+                  ($columnList)
639
+            SELECT $columnList
640
+            FROM $table
641
+            WHERE $conditionList";
642
+        $this->db->prepared_query($sql, ...$argList);
643
+        if ($this->db->affected_rows() == 0) {
644
+            return [false, "condition selected 0 rows"];
645
+        }
646
+
647
+        if (!$delete) {
648
+            return [true, "rows affected: " . $this->db->affected_rows()];
649
+        }
650
+
651
+        $sql = "DELETE FROM $table WHERE $conditionList";
652
+        $this->db->prepared_query($sql, ...$argList);
653
+        return [true, "rows deleted: " . $this->db->affected_rows()];
654
+    }
655
+    */
656
+}

+ 729
- 0
classes/notificationsmanager.class.php View File

@@ -0,0 +1,729 @@
1
+<?php
2
+declare(strict_types = 1);
3
+
4
+class NotificationsManager
5
+{
6
+    // Option types
7
+    const OPT_DISABLED = 0;
8
+    const OPT_POPUP = 1;
9
+    const OPT_TRADITIONAL = 2;
10
+
11
+    // Importances
12
+    const IMPORTANT = 'information';
13
+    const CRITICAL = 'error';
14
+    const WARNING = 'warning';
15
+    const INFO = 'confirmation';
16
+
17
+    public static $Importances = array(
18
+        'important' => self::IMPORTANT,
19
+        'critical' => self::CRITICAL,
20
+        'warning' => self::WARNING,
21
+        'info' => self::INFO
22
+    );
23
+
24
+    // Types. These names must correspond to column names in users_notifications_settings
25
+    const NEWS = 'News';
26
+    const BLOG = 'Blog';
27
+    const STAFFBLOG = 'StaffBlog';
28
+    const STAFFPM = 'StaffPM';
29
+    const INBOX = 'Inbox';
30
+    const QUOTES = 'Quotes';
31
+    const SUBSCRIPTIONS = 'Subscriptions';
32
+    const TORRENTS = 'Torrents';
33
+    const COLLAGES = 'Collages';
34
+    const SITEALERTS = 'SiteAlerts';
35
+    const FORUMALERTS = 'ForumAlerts';
36
+    const REQUESTALERTS = 'RequestAlerts';
37
+    const COLLAGEALERTS = 'CollageAlerts';
38
+    const TORRENTALERTS = 'TorrentAlerts';
39
+    const GLOBALNOTICE = 'Global';
40
+
41
+    public static $Types = array(
42
+        'News',
43
+        'Blog',
44
+        'StaffPM',
45
+        'Inbox',
46
+        'Quotes',
47
+        'Subscriptions',
48
+        'Torrents',
49
+        'Collages',
50
+        'SiteAlerts',
51
+        'ForumAlerts',
52
+        'RequestAlerts',
53
+        'CollageAlerts',
54
+        'TorrentAlerts'
55
+    );
56
+
57
+    private $UserID;
58
+    private $Notifications;
59
+    private $Settings;
60
+    private $Skipped;
61
+
62
+    public function __construct($UserID, $Skip = [], $Load = true, $AutoSkip = true)
63
+    {
64
+        $this->UserID = $UserID;
65
+        $this->Notifications = [];
66
+        $this->Settings = self::get_settings($UserID);
67
+        $this->Skipped = $Skip;
68
+
69
+        if ($AutoSkip) {
70
+            foreach ($this->Settings as $Key => $Value) {
71
+                // Skip disabled and traditional settings
72
+                if ($Value === self::OPT_DISABLED || $this->is_traditional($Key)) {
73
+                    $this->Skipped[$Key] = true;
74
+                }
75
+            }
76
+        }
77
+
78
+        if ($Load) {
79
+            $this->load_global_notification();
80
+            if (!isset($this->Skipped[self::NEWS])) {
81
+                $this->load_news();
82
+            }
83
+
84
+            if (!isset($this->Skipped[self::BLOG])) {
85
+                $this->load_blog();
86
+            }
87
+
88
+            // if (!isset($this->Skipped[self::STAFFBLOG])) {
89
+            //   $this->load_staff_blog();
90
+            // }
91
+
92
+            if (!isset($this->Skipped[self::STAFFPM])) {
93
+                $this->load_staff_pms();
94
+            }
95
+
96
+            if (!isset($this->Skipped[self::INBOX])) {
97
+                $this->load_inbox();
98
+            }
99
+
100
+            if (!isset($this->Skipped[self::TORRENTS])) {
101
+                $this->load_torrent_notifications();
102
+            }
103
+
104
+            if (!isset($this->Skipped[self::COLLAGES])) {
105
+                $this->load_collage_subscriptions();
106
+            }
107
+
108
+            if (!isset($this->Skipped[self::QUOTES])) {
109
+                $this->load_quote_notifications();
110
+            }
111
+            
112
+            if (!isset($this->Skipped[self::SUBSCRIPTIONS])) {
113
+                $this->load_subscriptions();
114
+            }
115
+            
116
+            // $this->load_one_reads(); // The code that sets these notices is commented out.
117
+        }
118
+    }
119
+
120
+    public function get_notifications()
121
+    {
122
+        return $this->Notifications;
123
+    }
124
+
125
+    public function clear_notifications_array()
126
+    {
127
+        unset($this->Notifications);
128
+        $this->Notifications = [];
129
+    }
130
+
131
+    private function create_notification($Type, $ID, $Message, $URL, $Importance)
132
+    {
133
+        $this->Notifications[$Type] = array(
134
+            'id' => (int) $ID,
135
+            'message' => $Message,
136
+            'url' => $URL,
137
+            'importance' => $Importance
138
+        );
139
+    }
140
+
141
+    public static function notify_user($UserID, $Type, $Message, $URL, $Importance)
142
+    {
143
+        self::notify_users(array($UserID), $Type, $Message, $URL, $Importance);
144
+    }
145
+
146
+    public static function notify_users($UserIDs, $Type, $Message, $URL, $Importance)
147
+    {
148
+        /*
149
+        if (!isset($Importance)) {
150
+          $Importance = self::INFO;
151
+        }
152
+        $Type = db_string($Type);
153
+        if (!empty($UserIDs)) {
154
+          $UserIDs = implode(',', $UserIDs);
155
+          $QueryID = G::$DB->get_query_id();
156
+          G::$DB->query("
157
+            SELECT UserID
158
+            FROM users_notifications_settings
159
+            WHERE $Type != 0
160
+              AND UserID IN ($UserIDs)");
161
+          $UserIDs = [];
162
+          while (list($ID) = G::$DB->next_record()) {
163
+            $UserIDs[] = $ID;
164
+          }
165
+          G::$DB->set_query_id($QueryID);
166
+          foreach ($UserIDs as $UserID) {
167
+            $OneReads = G::$Cache->get_value("notifications_one_reads_$UserID");
168
+            if (!$OneReads) {
169
+              $OneReads = [];
170
+            }
171
+            array_unshift($OneReads, $this->create_notification($OneReads, "oneread_" . uniqid(), null, $Message, $URL, $Importance));
172
+            $OneReads = array_filter($OneReads);
173
+            G::$Cache->cache_value("notifications_one_reads_$UserID", $OneReads, 0);
174
+          }
175
+        }
176
+        */
177
+    }
178
+
179
+    public static function get_notification_enabled_users($Type, $UserID)
180
+    {
181
+        $Type = db_string($Type);
182
+        $UserWhere = '';
183
+        if (isset($UserID)) {
184
+            $UserID = (int)$UserID;
185
+            $UserWhere = " AND UserID = '$UserID'";
186
+        }
187
+        $QueryID = G::$DB->get_query_id();
188
+        G::$DB->query("
189
+        SELECT UserID
190
+        FROM users_notifications_settings
191
+          WHERE $Type != 0
192
+        $UserWhere");
193
+        $IDs = [];
194
+        while (list($ID) = G::$DB->next_record()) {
195
+            $IDs[] = $ID;
196
+        }
197
+        G::$DB->set_query_id($QueryID);
198
+        return $IDs;
199
+    }
200
+
201
+    public function load_one_reads()
202
+    {
203
+        $OneReads = G::$Cache->get_value('notifications_one_reads_' . G::$LoggedUser['ID']);
204
+        if (is_array($OneReads)) {
205
+            $this->Notifications = $this->Notifications + $OneReads;
206
+        }
207
+    }
208
+
209
+    public static function clear_one_read($ID)
210
+    {
211
+        $OneReads = G::$Cache->get_value('notifications_one_reads_' . G::$LoggedUser['ID']);
212
+        if ($OneReads) {
213
+            unset($OneReads[$ID]);
214
+            if (count($OneReads) > 0) {
215
+                G::$Cache->cache_value('notifications_one_reads_' . G::$LoggedUser['ID'], $OneReads, 0);
216
+            } else {
217
+                G::$Cache->delete_value('notifications_one_reads_' . G::$LoggedUser['ID']);
218
+            }
219
+        }
220
+    }
221
+
222
+    public function load_global_notification()
223
+    {
224
+        $GlobalNotification = G::$Cache->get_value('global_notification');
225
+        if ($GlobalNotification) {
226
+            $Read = G::$Cache->get_value('user_read_global_' . G::$LoggedUser['ID']);
227
+            if (!$Read) {
228
+                $this->create_notification(self::GLOBALNOTICE, 0, $GlobalNotification['Message'], $GlobalNotification['URL'], $GlobalNotification['Importance']);
229
+            }
230
+        }
231
+    }
232
+
233
+    public static function get_global_notification()
234
+    {
235
+        return G::$Cache->get_value('global_notification');
236
+    }
237
+
238
+    public static function set_global_notification($Message, $URL, $Importance, $Expiration)
239
+    {
240
+        if (empty($Message) || empty($Expiration)) {
241
+            error('Error setting notification');
242
+        }
243
+        G::$Cache->cache_value('global_notification', array("Message" => $Message, "URL" => $URL, "Importance" => $Importance, "Expiration" => $Expiration), $Expiration);
244
+    }
245
+
246
+    public static function delete_global_notification()
247
+    {
248
+        G::$Cache->delete_value('global_notification');
249
+    }
250
+
251
+    public static function clear_global_notification()
252
+    {
253
+        $GlobalNotification = G::$Cache->get_value('global_notification');
254
+        if ($GlobalNotification) {
255
+            // This is some trickery
256
+            // since we can't know which users have the read cache key set
257
+            // we set the expiration time of their cache key to that of the length of the notification
258
+            // this gaurantees that their cache key will expire after the notification expires
259
+            G::$Cache->cache_value('user_read_global_' . G::$LoggedUser['ID'], true, $GlobalNotification['Expiration']);
260
+        }
261
+    }
262
+
263
+    public function load_news()
264
+    {
265
+        $MyNews = G::$LoggedUser['LastReadNews'];
266
+        $CurrentNews = G::$Cache->get_value('news_latest_id');
267
+        $Title = G::$Cache->get_value('news_latest_title');
268
+        if ($CurrentNews === false || $Title === false) {
269
+            $QueryID = G::$DB->get_query_id();
270
+            G::$DB->query('
271
+            SELECT ID, Title
272
+            FROM news
273
+              ORDER BY Time DESC
274
+              LIMIT 1');
275
+            if (G::$DB->has_results()) {
276
+                list($CurrentNews, $Title) = G::$DB->next_record();
277
+            } else {
278
+                $CurrentNews = -1;
279
+            }
280
+            G::$DB->set_query_id($QueryID);
281
+            G::$Cache->cache_value('news_latest_id', $CurrentNews, 0);
282
+            G::$Cache->cache_value('news_latest_title', $Title, 0);
283
+        }
284
+        if ($MyNews < $CurrentNews) {
285
+            $this->create_notification(self::NEWS, $CurrentNews, "Announcement: $Title", "index.php#news$CurrentNews", self::IMPORTANT);
286
+        }
287
+    }
288
+
289
+    public function load_blog()
290
+    {
291
+        $MyBlog = G::$LoggedUser['LastReadBlog'];
292
+        $CurrentBlog = G::$Cache->get_value('blog_latest_id');
293
+        $Title = G::$Cache->get_value('blog_latest_title');
294
+        if ($CurrentBlog === false) {
295
+            $QueryID = G::$DB->get_query_id();
296
+            G::$DB->query('
297
+            SELECT ID, Title
298
+            FROM blog
299
+              WHERE Important = 1
300
+              ORDER BY Time DESC
301
+              LIMIT 1');
302
+            if (G::$DB->has_results()) {
303
+                list($CurrentBlog, $Title) = G::$DB->next_record();
304
+            } else {
305
+                $CurrentBlog = -1;
306
+            }
307
+            G::$DB->set_query_id($QueryID);
308
+            G::$Cache->cache_value('blog_latest_id', $CurrentBlog, 0);
309
+            G::$Cache->cache_value('blog_latest_title', $Title, 0);
310
+        }
311
+        if ($MyBlog < $CurrentBlog) {
312
+            $this->create_notification(self::BLOG, $CurrentBlog, "Blog: $Title", "blog.php#blog$CurrentBlog", self::IMPORTANT);
313
+        }
314
+    }
315
+
316
+    public function load_staff_blog()
317
+    {
318
+        if (check_perms('users_mod')) {
319
+            global $SBlogReadTime, $LatestSBlogTime;
320
+            if (!$SBlogReadTime && ($SBlogReadTime = G::$Cache->get_value('staff_blog_read_' . G::$LoggedUser['ID'])) === false) {
321
+                $QueryID = G::$DB->get_query_id();
322
+                G::$DB->query("
323
+                SELECT Time
324
+                FROM staff_blog_visits
325
+                  WHERE UserID = " . G::$LoggedUser['ID']);
326
+                if (list($SBlogReadTime) = G::$DB->next_record()) {
327
+                    $SBlogReadTime = strtotime($SBlogReadTime);
328
+                } else {
329
+                    $SBlogReadTime = 0;
330
+                }
331
+                G::$DB->set_query_id($QueryID);
332
+                G::$Cache->cache_value('staff_blog_read_' . G::$LoggedUser['ID'], $SBlogReadTime, 1209600);
333
+            }
334
+            if (!$LatestSBlogTime && ($LatestSBlogTime = G::$Cache->get_value('staff_blog_latest_time')) === false) {
335
+                $QueryID = G::$DB->get_query_id();
336
+                G::$DB->query('
337
+                SELECT MAX(Time)
338
+                FROM staff_blog');
339
+                if (list($LatestSBlogTime) = G::$DB->next_record()) {
340
+                    $LatestSBlogTime = strtotime($LatestSBlogTime);
341
+                } else {
342
+                    $LatestSBlogTime = 0;
343
+                }
344
+                G::$DB->set_query_id($QueryID);
345
+                G::$Cache->cache_value('staff_blog_latest_time', $LatestSBlogTime, 1209600);
346
+            }
347
+            if ($SBlogReadTime < $LatestSBlogTime) {
348
+                $this->create_notification(self::STAFFBLOG, 0, 'New Staff Blog Post!', 'staffblog.php', self::IMPORTANT);
349
+            }
350
+        }
351
+    }
352
+
353
+    public function load_staff_pms()
354
+    {
355
+        $NewStaffPMs = G::$Cache->get_value('staff_pm_new_' . G::$LoggedUser['ID']);
356
+        if ($NewStaffPMs === false) {
357
+            $QueryID = G::$DB->get_query_id();
358
+            G::$DB->query("
359
+            SELECT COUNT(ID)
360
+            FROM staff_pm_conversations
361
+              WHERE UserID = '" . G::$LoggedUser['ID'] . "'
362
+              AND Unread = '1'");
363
+            list($NewStaffPMs) = G::$DB->next_record();
364
+            G::$DB->set_query_id($QueryID);
365
+            G::$Cache->cache_value('staff_pm_new_' . G::$LoggedUser['ID'], $NewStaffPMs, 0);
366
+        }
367
+
368
+        if ($NewStaffPMs > 0) {
369
+            $Title = 'You have ' . ($NewStaffPMs === 1 ? 'a' : $NewStaffPMs) . ' new Staff PM' . ($NewStaffPMs > 1 ? 's' : '');
370
+            $this->create_notification(self::STAFFPM, 0, $Title, 'staffpm.php', self::INFO);
371
+        }
372
+    }
373
+
374
+    public function load_inbox()
375
+    {
376
+        $NewMessages = G::$Cache->get_value('inbox_new_' . G::$LoggedUser['ID']);
377
+        if ($NewMessages === false) {
378
+            $QueryID = G::$DB->get_query_id();
379
+            G::$DB->query("
380
+            SELECT COUNT(UnRead)
381
+            FROM pm_conversations_users
382
+              WHERE UserID = '" . G::$LoggedUser['ID'] . "'
383
+              AND UnRead = '1'
384
+              AND InInbox = '1'");
385
+            list($NewMessages) = G::$DB->next_record();
386
+            G::$DB->set_query_id($QueryID);
387
+            G::$Cache->cache_value('inbox_new_' . G::$LoggedUser['ID'], $NewMessages, 0);
388
+        }
389
+
390
+        if ($NewMessages > 0) {
391
+            $Title = 'You have ' . ($NewMessages === 1 ? 'a' : $NewMessages) . ' new message' . ($NewMessages > 1 ? 's' : '');
392
+            $this->create_notification(self::INBOX, 0, $Title, Inbox::get_inbox_link(), self::INFO);
393
+        }
394
+    }
395
+
396
+    public function load_torrent_notifications()
397
+    {
398
+        if (check_perms('site_torrents_notify')) {
399
+            $NewNotifications = G::$Cache->get_value('notifications_new_' . G::$LoggedUser['ID']);
400
+            if ($NewNotifications === false) {
401
+                $QueryID = G::$DB->get_query_id();
402
+                G::$DB->query("
403
+                SELECT COUNT(UserID)
404
+                FROM users_notify_torrents
405
+                  WHERE UserID = ' " . G::$LoggedUser['ID'] . "'
406
+                  AND UnRead = '1'");
407
+                list($NewNotifications) = G::$DB->next_record();
408
+                G::$DB->set_query_id($QueryID);
409
+                G::$Cache->cache_value('notifications_new_' . G::$LoggedUser['ID'], $NewNotifications, 0);
410
+            }
411
+        }
412
+        if (isset($NewNotifications) && $NewNotifications > 0) {
413
+            $Title = 'You have ' . ($NewNotifications === 1 ? 'a' : $NewNotifications) . ' new torrent notification' . ($NewNotifications > 1 ? 's' : '');
414
+            $this->create_notification(self::TORRENTS, 0, $Title, 'torrents.php?action=notify', self::INFO);
415
+        }
416
+    }
417
+
418
+    public function load_collage_subscriptions()
419
+    {
420
+        if (check_perms('site_collages_subscribe')) {
421
+            $NewCollages = G::$Cache->get_value('collage_subs_user_new_' . G::$LoggedUser['ID']);
422
+            if ($NewCollages === false) {
423
+                $QueryID = G::$DB->get_query_id();
424
+                G::$DB->query("
425
+                SELECT COUNT(DISTINCT s.CollageID)
426
+                FROM users_collage_subs AS s
427
+                  JOIN collages AS c ON s.CollageID = c.ID
428
+                  JOIN collages_torrents AS ct ON ct.CollageID = c.ID
429
+                WHERE s.UserID = " . G::$LoggedUser['ID'] . "
430
+                  AND ct.AddedOn > s.LastVisit
431
+                  AND c.Deleted = '0'");
432
+                list($NewCollages) = G::$DB->next_record();
433
+                G::$DB->set_query_id($QueryID);
434
+                G::$Cache->cache_value('collage_subs_user_new_' . G::$LoggedUser['ID'], $NewCollages, 0);
435
+            }
436
+            if ($NewCollages > 0) {
437
+                $Title = 'You have ' . ($NewCollages === 1 ? 'a' : $NewCollages) . ' new collage update' . ($NewCollages > 1 ? 's' : '');
438
+                $this->create_notification(self::COLLAGES, 0, $Title, 'userhistory.php?action=subscribed_collages', self::INFO);
439
+            }
440
+        }
441
+    }
442
+
443
+    public function load_quote_notifications()
444
+    {
445
+        if (isset(G::$LoggedUser['NotifyOnQuote']) && G::$LoggedUser['NotifyOnQuote']) {
446
+            $QuoteNotificationsCount = Subscriptions::has_new_quote_notifications();
447
+            if ($QuoteNotificationsCount > 0) {
448
+                $Title = 'New quote' . ($QuoteNotificationsCount > 1 ? 's' : '');
449
+                $this->create_notification(self::QUOTES, 0, $Title, 'userhistory.php?action=quote_notifications', self::INFO);
450
+            }
451
+        }
452
+    }
453
+
454
+    public function load_subscriptions()
455
+    {
456
+        $SubscriptionsCount = Subscriptions::has_new_subscriptions();
457
+        if ($SubscriptionsCount > 0) {
458
+            $Title = 'New subscription' . ($SubscriptionsCount > 1 ? 's' : '');
459
+            $this->create_notification(self::SUBSCRIPTIONS, 0, $Title, 'userhistory.php?action=subscriptions', self::INFO);
460
+        }
461
+    }
462
+
463
+    public static function clear_news($News)
464
+    {
465
+        $QueryID = G::$DB->get_query_id();
466
+        if (!$News) {
467
+            if (!$News = G::$Cache->get_value('news')) {
468
+                G::$DB->query('
469
+                SELECT
470
+                  ID,
471
+                  Title,
472
+                  Body,
473
+                  Time
474
+                FROM news
475
+                  ORDER BY Time DESC
476
+                  LIMIT 1');
477
+                $News = G::$DB->to_array(false, MYSQLI_NUM, false);
478
+                G::$Cache->cache_value('news_latest_id', $News[0][0], 0);
479
+            }
480
+        }
481
+
482
+        if (G::$LoggedUser['LastReadNews'] !== $News[0][0]) {
483
+            G::$Cache->begin_transaction('user_info_heavy_' . G::$LoggedUser['ID']);
484
+            G::$Cache->update_row(false, array('LastReadNews' => $News[0][0]));
485
+            G::$Cache->commit_transaction(0);
486
+            G::$DB->query("
487
+            UPDATE users_info
488
+            SET LastReadNews = '".$News[0][0]."'
489
+              WHERE UserID = " . G::$LoggedUser['ID']);
490
+            G::$LoggedUser['LastReadNews'] = $News[0][0];
491
+        }
492
+        G::$DB->set_query_id($QueryID);
493
+    }
494
+
495
+    public static function clear_blog($Blog)
496
+    {
497
+        $QueryID = G::$DB->get_query_id();
498
+        if (!isset($Blog) || !$Blog) {
499
+            if (!$Blog = G::$Cache->get_value('blog')) {
500
+                G::$DB->query("
501
+                SELECT
502
+                  b.ID,
503
+                  um.Username,
504
+                  b.UserID,
505
+                  b.Title,
506
+                  b.Body,
507
+                  b.Time,
508
+                  b.ThreadID
509
+                FROM blog AS b
510
+                  LEFT JOIN users_main AS um ON b.UserID = um.ID
511
+                  ORDER BY Time DESC
512
+                  LIMIT 1");
513
+                $Blog = G::$DB->to_array();
514
+            }
515
+        }
516
+        if (G::$LoggedUser['LastReadBlog'] < $Blog[0][0]) {
517
+            G::$Cache->begin_transaction('user_info_heavy_' . G::$LoggedUser['ID']);
518
+            G::$Cache->update_row(false, array('LastReadBlog' => $Blog[0][0]));
519
+            G::$Cache->commit_transaction(0);
520
+            G::$DB->query("
521
+            UPDATE users_info
522
+            SET LastReadBlog = '". $Blog[0][0]."'
523
+              WHERE UserID = " . G::$LoggedUser['ID']);
524
+            G::$LoggedUser['LastReadBlog'] = $Blog[0][0];
525
+        }
526
+        G::$DB->set_query_id($QueryID);
527
+    }
528
+
529
+    public static function clear_staff_pms()
530
+    {
531
+        $QueryID = G::$DB->get_query_id();
532
+        G::$DB->query("
533
+        SELECT ID
534
+        FROM staff_pm_conversations
535
+          WHERE Unread = true
536
+          AND UserID = " . G::$LoggedUser['ID']);
537
+        $IDs = [];
538
+        while (list($ID) = G::$DB->next_record()) {
539
+            $IDs[] = $ID;
540
+        }
541
+        $IDs = implode(',', $IDs);
542
+        if (!empty($IDs)) {
543
+            G::$DB->query("
544
+            UPDATE staff_pm_conversations
545
+            SET Unread = false
546
+              WHERE ID IN ($IDs)");
547
+        }
548
+        G::$Cache->delete_value('staff_pm_new_' . G::$LoggedUser['ID']);
549
+        G::$DB->set_query_id($QueryID);
550
+    }
551
+
552
+    public static function clear_inbox()
553
+    {
554
+        $QueryID = G::$DB->get_query_id();
555
+        G::$DB->query("
556
+        SELECT ConvID
557
+        FROM pm_conversations_users
558
+          WHERE Unread = '1'
559
+          AND UserID = " . G::$LoggedUser['ID']);
560
+        $IDs = [];
561
+        while (list($ID) = G::$DB->next_record()) {
562
+            $IDs[] = $ID;
563
+        }
564
+        $IDs = implode(',', $IDs);
565
+        if (!empty($IDs)) {
566
+            G::$DB->query("
567
+            UPDATE pm_conversations_users
568
+            SET Unread = '0'
569
+              WHERE ConvID IN ($IDs)
570
+              AND UserID = " . G::$LoggedUser['ID']);
571
+        }
572
+        G::$Cache->delete_value('inbox_new_' . G::$LoggedUser['ID']);
573
+        G::$DB->set_query_id($QueryID);
574
+    }
575
+
576
+    public static function clear_torrents()
577
+    {
578
+        $QueryID = G::$DB->get_query_id();
579
+        G::$DB->query("
580
+        SELECT TorrentID
581
+        FROM users_notify_torrents
582
+          WHERE UserID = ' " . G::$LoggedUser['ID'] . "'
583
+          AND UnRead = '1'");
584
+        $IDs = [];
585
+        while (list($ID) = G::$DB->next_record()) {
586
+            $IDs[] = $ID;
587
+        }
588
+        $IDs = implode(',', $IDs);
589
+        if (!empty($IDs)) {
590
+            G::$DB->query("
591
+            UPDATE users_notify_torrents
592
+            SET Unread = '0'
593
+              WHERE TorrentID IN ($IDs)
594
+              AND UserID = " . G::$LoggedUser['ID']);
595
+        }
596
+        G::$Cache->delete_value('notifications_new_' . G::$LoggedUser['ID']);
597
+        G::$DB->set_query_id($QueryID);
598
+    }
599
+
600
+    public static function clear_collages()
601
+    {
602
+        $QueryID = G::$DB->get_query_id();
603
+        G::$DB->query("
604
+        UPDATE users_collage_subs
605
+        SET LastVisit = NOW()
606
+          WHERE UserID = " . G::$LoggedUser['ID']);
607
+        G::$Cache->delete_value('collage_subs_user_new_' . G::$LoggedUser['ID']);
608
+        G::$DB->set_query_id($QueryID);
609
+    }
610
+
611
+    public static function clear_quotes()
612
+    {
613
+        $QueryID = G::$DB->get_query_id();
614
+        G::$DB->query("
615
+        UPDATE users_notify_quoted
616
+        SET UnRead = '0'
617
+          WHERE UserID = " . G::$LoggedUser['ID']);
618
+        G::$Cache->delete_value('notify_quoted_' . G::$LoggedUser['ID']);
619
+        G::$DB->set_query_id($QueryID);
620
+    }
621
+
622
+    public static function clear_subscriptions()
623
+    {
624
+        $QueryID = G::$DB->get_query_id();
625
+        if (($UserSubscriptions = G::$Cache->get_value('subscriptions_user_' . G::$LoggedUser['ID'])) === false) {
626
+            G::$DB->query("
627
+            SELECT TopicID
628
+            FROM users_subscriptions
629
+              WHERE UserID = " . G::$LoggedUser['ID']);
630
+            if ($UserSubscriptions = G::$DB->collect(0)) {
631
+                G::$Cache->cache_value('subscriptions_user_' . G::$LoggedUser['ID'], $UserSubscriptions, 0);
632
+            }
633
+        }
634
+        if (!empty($UserSubscriptions)) {
635
+            G::$DB->query("
636
+            INSERT INTO forums_last_read_topics (UserID, TopicID, PostID)
637
+            SELECT '" . G::$LoggedUser['ID'] . "', ID, LastPostID
638
+            FROM forums_topics
639
+              WHERE ID IN (".implode(',', $UserSubscriptions).')
640
+            ON DUPLICATE KEY UPDATE
641
+              PostID = LastPostID');
642
+        }
643
+        G::$Cache->delete_value('subscriptions_user_new_' . G::$LoggedUser['ID']);
644
+        G::$DB->set_query_id($QueryID);
645
+    }
646
+
647
+    /*
648
+      // todo: Figure out what these functions are supposed to do and fix them
649
+
650
+      public static function send_notification($UserID, $ID, $Type, $Message, $URL, $Importance = 'alert', $AutoExpire = false) {
651
+        $Notifications = G::$Cache->get_value("user_cache_notifications_$UserID");
652
+        if (empty($Notifications)) {
653
+          $Notifications = [];
654
+        }
655
+        array_unshift($Notifications, $this->create_notification($Type, $ID, $Message, $URL, $Importance, $AutoExpire));
656
+        G::$Cache->cache_value("user_cache_notifications_$UserID", $Notifications, 0);
657
+      }
658
+
659
+      public static function clear_notification($UserID, $Index) {
660
+        $Notifications = G::$Cache->get_value("user_cache_notifications_$UserID");
661
+        if (count($Notifications)) {
662
+          unset($Notifications[$Index]);
663
+          $Notifications = array_values($Notifications);
664
+          G::$Cache->cache_value("user_cache_notifications_$UserID", $Notifications, 0);
665
+        }
666
+      }
667
+    */
668
+
669
+    public static function get_settings($UserID)
670
+    {
671
+        $Results = G::$Cache->get_value("users_notifications_settings_$UserID");
672
+        if (!$Results) {
673
+            $QueryID = G::$DB->get_query_id();
674
+            G::$DB->query("
675
+            SELECT *
676
+            FROM users_notifications_settings
677
+              WHERE UserID = ?", $UserID);
678
+            $Results = G::$DB->next_record(MYSQLI_ASSOC, false);
679
+            G::$DB->set_query_id($QueryID);
680
+            G::$Cache->cache_value("users_notifications_settings_$UserID", $Results, 0);
681
+        }
682
+        return $Results;
683
+    }
684
+
685
+    public static function save_settings($UserID, $Settings = false)
686
+    {
687
+        if (!is_array($Settings)) {
688
+            // A little cheat technique, gets all keys in the $_POST array starting with 'notifications_'
689
+            $Settings = array_intersect_key($_POST, array_flip(preg_grep('/^notifications_/', array_keys($_POST))));
690
+        }
691
+        $Update = [];
692
+        foreach (self::$Types as $Type) {
693
+            $Popup = array_key_exists("notifications_{$Type}_popup", $Settings);
694
+            $Traditional = array_key_exists("notifications_{$Type}_traditional", $Settings);
695
+            $Result = self::OPT_DISABLED;
696
+            if ($Popup) {
697
+                $Result = self::OPT_POPUP;
698
+            } elseif ($Traditional) {
699
+                $Result = self::OPT_TRADITIONAL;
700
+            }
701
+            $Update[] = "$Type = $Result";
702
+        }
703
+        $Update = implode(',', $Update);
704
+
705
+        $QueryID = G::$DB->get_query_id();
706
+        G::$DB->query("
707
+        UPDATE users_notifications_settings
708
+        SET $Update
709
+          WHERE UserID = ?", $UserID);
710
+
711
+        G::$DB->set_query_id($QueryID);
712
+        G::$Cache->delete_value("users_notifications_settings_$UserID");
713
+    }
714
+
715
+    public function is_traditional($Type)
716
+    {
717
+        return $this->Settings[$Type] === self::OPT_TRADITIONAL;
718
+    }
719
+
720
+    public function is_skipped($Type)
721
+    {
722
+        return isset($this->Skipped[$Type]);
723
+    }
724
+
725
+    public function use_noty()
726
+    {
727
+        return in_array(self::OPT_POPUP, $this->Settings);
728
+    }
729
+}

+ 140
- 0
classes/notificationsmanagerview.class.php View File

@@ -0,0 +1,140 @@
1
+<?php
2
+declare(strict_types=1);
3
+
4
+class NotificationsManagerView
5
+{
6
+    private static $Settings;
7
+
8
+    public static function load_js()
9
+    {
10
+        $ENV = ENV::go();
11
+
12
+        # Adapted from design/privateheader.php
13
+        $Scripts = [
14
+          'vendor/noty/packaged/jquery.noty.packaged.min',
15
+          'vendor/noty/layouts/bottomRight',
16
+          'vendor/noty/themes/relax',
17
+          'user_notifications'
18
+        ];
19
+
20
+        foreach ($Scripts as $Script) {
21
+            echo View::pushAsset(
22
+                "$ENV->STATIC_SERVER/functions/$Script.js",
23
+                'script'
24
+            );
25
+        }
26
+    }
27
+
28
+    public static function render_settings($Settings)
29
+    {
30
+        $ENV = ENV::go();
31
+        self::$Settings = $Settings; ?>
32
+<tr>
33
+  <td class="label">
34
+    <strong>News Announcements</strong>
35
+  </td>
36
+
37
+  <td>
38
+    <?php self::render_checkbox(NotificationsManager::NEWS); ?>
39
+  </td>
40
+</tr>
41
+
42
+<tr>
43
+  <td class="label">
44
+    <strong>Blog Announcements</strong>
45
+  </td>
46
+
47
+  <td>
48
+    <?php self::render_checkbox(NotificationsManager::BLOG); ?>
49
+  </td>
50
+</tr>
51
+
52
+<tr>
53
+  <td class="label">
54
+    <strong>Inbox Messages</strong>
55
+  </td>
56
+
57
+  <td>
58
+    <?php self::render_checkbox(NotificationsManager::INBOX, true); ?>
59
+  </td>
60
+</tr>
61
+
62
+<tr>
63
+  <td class="label tooltip"
64
+    title="Notify when you receive a new private message from <?= $ENV->SITE_NAME ?> staff">
65
+    <strong>Staff Messages</strong>
66
+  </td>
67
+
68
+  <td>
69
+    <?php self::render_checkbox(NotificationsManager::STAFFPM, false, false); ?>
70
+  </td>
71
+</tr>
72
+
73
+<tr>
74
+  <td class="label">
75
+    <strong>Thread Subscriptions</strong>
76
+  </td>
77
+
78
+  <td>
79
+    <?php self::render_checkbox(NotificationsManager::SUBSCRIPTIONS, false, false); ?>
80
+  </td>
81
+</tr>
82
+
83
+<tr>
84
+  <td class="label tooltip" title="Notify whenever someone quotes you in the forums">
85
+    <strong>Quote Notifications</strong>
86
+  </td>
87
+  <td>
88
+    <?php self::render_checkbox(NotificationsManager::QUOTES); ?>
89
+  </td>
90
+</tr>
91
+
92
+<?php if (check_perms('site_torrents_notify')) { ?>
93
+<tr>
94
+  <td class="label tooltip" title="Notify when your torrent notification filters are triggered">
95
+    <strong>Torrent Notifications</strong>
96
+  </td>
97
+
98
+  <td>
99
+    <?php self::render_checkbox(NotificationsManager::TORRENTS, true, false); ?>
100
+  </td>
101
+</tr>
102
+<?php } ?>
103
+
104
+<tr>
105
+  <td class="label tooltip" title="Notify when a torrent is added to a subscribed collage">
106
+    <strong>Collage Subscriptions</strong>
107
+  </td>
108
+
109
+  <td>
110
+    <?php self::render_checkbox(NotificationsManager::COLLAGES. false, false); ?>
111
+  </td>
112
+</tr>
113
+<?php
114
+    }
115
+
116
+    private static function render_checkbox($Name, $Traditional = false)
117
+    {
118
+        $Checked = self::$Settings[$Name];
119
+        $PopupChecked = $Checked === NotificationsManager::OPT_POPUP || !isset($Checked) ? ' checked="checked"' : '';
120
+        $TraditionalChecked = $Checked === NotificationsManager::OPT_TRADITIONAL ? ' checked="checked"' : ''; ?>
121
+<label>
122
+  <input type="checkbox" name="notifications_<?=$Name?>_popup"
123
+    id="notifications_<?=$Name?>_popup" <?=$PopupChecked?> />
124
+  Pop-up
125
+</label>
126
+<?php if ($Traditional) { ?>
127
+<label>
128
+  <input type="checkbox" name="notifications_<?=$Name?>_traditional"
129
+    id="notifications_<?=$Name?>_traditional" <?=$TraditionalChecked?> />
130
+  Traditional
131
+</label>
132
+<?php
133
+      }
134
+    }
135
+
136
+    public static function format_traditional($Contents)
137
+    {
138
+        return "<a href=\"$Contents[url]\">$Contents[message]</a>";
139
+    }
140
+}

+ 115
- 0
classes/paranoia.class.php View File

@@ -0,0 +1,115 @@
1
+<?php
2
+
3
+// Note: at the time this file is loaded, check_perms is not defined.
4
+// Don't call check_paranoia in /classes/script_start.php without ensuring check_perms has been defined
5
+
6
+// The following are used throughout the site:
7
+// uploaded, ratio, downloaded: stats
8
+// lastseen: approximate time the user last used the site
9
+// uploads: the full list of the user's uploads
10
+// uploads+: just how many torrents the user has uploaded
11
+// snatched, seeding, leeching: the list of the user's snatched torrents, seeding torrents, and leeching torrents respectively
12
+// snatched+, seeding+, leeching+: the length of those lists respectively
13
+// uniquegroups, perfectflacs: the list of the user's uploads satisfying a particular criterion
14
+// uniquegroups+, perfectflacs+: the length of those lists
15
+// If "uploads+" is disallowed, so is "uploads". So if "uploads" is in the array, the user is a little paranoid, "uploads+", very paranoid.
16
+
17
+// The following are almost only used in /sections/user/user.php:
18
+// requiredratio
19
+// requestsfilled_count: the number of requests the user has filled
20
+//   requestsfilled_bounty: the bounty thus earned
21
+//   requestsfilled_list: the actual list of requests the user has filled
22
+// requestsvoted_...: similar
23
+// artistsadded: the number of artists the user has added
24
+// torrentcomments: the list of comments the user has added to torrents
25
+//   +
26
+// collages: the list of collages the user has created
27
+//   +
28
+// collagecontribs: the list of collages the user has contributed to
29
+//   +
30
+// invitedcount: the number of users this user has directly invited
31
+
32
+/**
33
+ * Return whether currently logged in user can see $Property on a user with $Paranoia, $UserClass and (optionally) $UserID
34
+ * If $Property is an array of properties, returns whether currently logged in user can see *all* $Property ...
35
+ *
36
+ * @param $Property The property to check, or an array of properties.
37
+ * @param $Paranoia The paranoia level to check against.
38
+ * @param $UserClass The user class to check against (Staff can see through paranoia of lower classed staff)
39
+ * @param $UserID Optional. The user ID of the person being viewed
40
+ * @return mixed 1 representing the user has normal access
41
+ *               2 representing that the paranoia was overridden,
42
+ *               false representing access denied.
43
+ */
44
+define("PARANOIA_ALLOWED", 1);
45
+define("PARANOIA_OVERRIDDEN", 2);
46
+
47
+function check_paranoia($Property, $Paranoia = false, $UserClass = false, $UserID = false)
48
+{
49
+    global $Classes;
50
+    if ($Property == false) {
51
+        return false;
52
+    }
53
+
54
+    if (!is_array($Paranoia)) {
55
+        $Paranoia = json_decode($Paranoia, true);
56
+    }
57
+
58
+    if (!is_array($Paranoia)) {
59
+        $Paranoia = [];
60
+    }
61
+
62
+    if (is_array($Property)) {
63
+        $all = true;
64
+        foreach ($Property as $P) {
65
+            $all = $all && check_paranoia($P, $Paranoia, $UserClass, $UserID);
66
+        }
67
+        return $all;
68
+    } else {
69
+        if (($UserID !== false) && (G::$LoggedUser['ID'] == $UserID)) {
70
+            return PARANOIA_ALLOWED;
71
+        }
72
+
73
+        $May = !in_array($Property, $Paranoia) && !in_array($Property . '+', $Paranoia);
74
+        if ($May) {
75
+            return PARANOIA_ALLOWED;
76
+        }
77
+
78
+        if (check_perms('users_override_paranoia', $UserClass)) {
79
+            return PARANOIA_OVERRIDDEN;
80
+        }
81
+
82
+        $Override=false;
83
+        switch ($Property) {
84
+          case 'downloaded':
85
+          case 'ratio':
86
+          case 'uploaded':
87
+          case 'lastseen':
88
+            if (check_perms('users_mod', $UserClass)) {
89
+                return PARANOIA_OVERRIDDEN;
90
+            }
91
+            break;
92
+
93
+          case 'snatched': case 'snatched+':
94
+            if (check_perms('users_view_torrents_snatchlist', $UserClass)) {
95
+                return PARANOIA_OVERRIDDEN;
96
+            }
97
+            break;
98
+
99
+          case 'uploads': case 'uploads+':
100
+          case 'seeding': case 'seeding+':
101
+          case 'leeching': case 'leeching+':
102
+            if (check_perms('users_view_seedleech', $UserClass)) {
103
+                return PARANOIA_OVERRIDDEN;
104
+            }
105
+            break;
106
+            
107
+          case 'invitedcount':
108
+            if (check_perms('users_view_invites', $UserClass)) {
109
+                return PARANOIA_OVERRIDDEN;
110
+            }
111
+            break;
112
+        }
113
+        return false;
114
+    }
115
+}

+ 112
- 0
classes/permissions.class.php View File

@@ -0,0 +1,112 @@
1
+<?php
2
+
3
+class Permissions
4
+{
5
+    /* Check to see if a user has the permission to perform an action
6
+     * This is called by check_perms in util.php, for convenience.
7
+     *
8
+     * @param string PermissionName
9
+     * @param string $MinClass Return false if the user's class level is below this.
10
+     */
11
+    public static function check_perms($PermissionName, $MinClass = 0)
12
+    {
13
+        if (G::$LoggedUser['EffectiveClass'] >= 1000) {
14
+            return true;
15
+        } // Sysops can do anything
16
+
17
+        if (G::$LoggedUser['EffectiveClass'] < $MinClass) {
18
+            return false;
19
+        } // MinClass failure
20
+    return G::$LoggedUser['Permissions'][$PermissionName] ?? false; // Return actual permission
21
+    }
22
+
23
+    /**
24
+     * Gets the permissions associated with a certain permissionid
25
+     *
26
+     * @param int $PermissionID the kind of permissions to fetch
27
+     * @return array permissions
28
+     */
29
+    public static function get_permissions($PermissionID)
30
+    {
31
+        $Permission = G::$Cache->get_value("perm_$PermissionID");
32
+        if (empty($Permission)) {
33
+            $QueryID = G::$DB->get_query_id();
34
+            G::$DB->query("
35
+            SELECT Level AS Class, `Values` AS Permissions, Secondary, PermittedForums
36
+            FROM permissions
37
+              WHERE ID = '$PermissionID'");
38
+
39
+            $Permission = G::$DB->next_record(MYSQLI_ASSOC, ['Permissions']);
40
+            G::$DB->set_query_id($QueryID);
41
+            $Permission['Permissions'] = unserialize($Permission['Permissions']);
42
+            G::$Cache->cache_value("perm_$PermissionID", $Permission, 2592000);
43
+        }
44
+        return $Permission;
45
+    }
46
+
47
+    /**
48
+     * Get a user's permissions.
49
+     *
50
+     * @param $UserID
51
+     * @param array|false $CustomPermissions
52
+     *  Pass in the user's custom permissions if you already have them.
53
+     *  Leave false if you don't have their permissions. The function will fetch them.
54
+     * @return array Mapping of PermissionName=>bool/int
55
+     */
56
+    public static function get_permissions_for_user($UserID, $CustomPermissions = false)
57
+    {
58
+        $UserInfo = Users::user_info($UserID);
59
+
60
+        // Fetch custom permissions if they weren't passed in.
61
+        if ($CustomPermissions === false) {
62
+            $QueryID = G::$DB->get_query_id();
63
+            G::$DB->query('
64
+            SELECT CustomPermissions
65
+            FROM users_main
66
+              WHERE ID = ' . (int)$UserID);
67
+
68
+            list($CustomPermissions) = G::$DB->next_record(MYSQLI_NUM, false);
69
+            G::$DB->set_query_id($QueryID);
70
+        }
71
+
72
+        if (!empty($CustomPermissions) && !is_array($CustomPermissions)) {
73
+            $CustomPermissions = unserialize($CustomPermissions);
74
+        }
75
+
76
+        $Permissions = self::get_permissions($UserInfo['PermissionID']);
77
+
78
+        // Manage 'special' inherited permissions
79
+        $BonusPerms = [];
80
+        $BonusCollages = 0;
81
+
82
+        foreach ($UserInfo['ExtraClasses'] as $PermID => $Value) {
83
+            $ClassPerms = self::get_permissions($PermID);
84
+            $BonusCollages += $ClassPerms['Permissions']['MaxCollages'];
85
+            unset($ClassPerms['Permissions']['MaxCollages']);
86
+            $BonusPerms = array_merge($BonusPerms, $ClassPerms['Permissions']);
87
+        }
88
+
89
+        if (empty($CustomPermissions)) {
90
+            $CustomPermissions = [];
91
+        }
92
+
93
+        $MaxCollages = ($Permissions['Permissions']['MaxCollages'] ?? 0) + $BonusCollages;
94
+        if (isset($CustomPermissions['MaxCollages'])) {
95
+            $MaxCollages += $CustomPermissions['MaxCollages'];
96
+            unset($CustomPermissions['MaxCollages']);
97
+        }
98
+        $Permissions['Permissions']['MaxCollages'] = $MaxCollages;
99
+
100
+        // Combine the permissions
101
+        return array_merge(
102
+            $Permissions['Permissions'],
103
+            $BonusPerms,
104
+            $CustomPermissions
105
+        );
106
+    }
107
+
108
+    public static function is_mod($UserID)
109
+    {
110
+        return self::get_permissions_for_user($UserID)['users_mod'] ?? false;
111
+    }
112
+}

+ 278
- 0
classes/permissions_form.php View File

@@ -0,0 +1,278 @@
1
+<?php
2
+
3
+/********************************************************************************
4
+ ************ Permissions form ********************** user.php and tools.php ****
5
+ ********************************************************************************
6
+ ** This function is used to create both the class permissions form, and the   **
7
+ ** user custom permissions form.                                              **
8
+ ********************************************************************************/
9
+
10
+$PermissionsArray = array(
11
+  'site_leech' => 'Can leech (Does this work?).',
12
+  'site_upload' => 'Upload torrent access.',
13
+  'site_vote' => 'Request vote access.',
14
+  'site_submit_requests' => 'Request create access.',
15
+  'site_advanced_search' => 'Advanced search access.',
16
+  'site_top10' => 'Top 10 access.',
17
+  'site_advanced_top10' => 'Advanced Top 10 access.',
18
+  'site_torrents_notify' => 'Notifications access.',
19
+  'site_collages_create' => 'Collage create access.',
20
+  'site_collages_manage' => 'Collage manage access.',
21
+  'site_collages_delete' => 'Collage delete access.',
22
+  'site_collages_subscribe' => 'Collage subscription access.',
23
+  'site_collages_personal' => 'Can have a personal collage.',
24
+  'site_collages_renamepersonal' => 'Can rename own personal collages.',
25
+  'site_make_bookmarks' => 'Bookmarks access.',
26
+  'site_edit_wiki' => 'Wiki edit access.',
27
+  'site_can_invite_always' => 'Can invite past user limit.',
28
+  'site_send_unlimited_invites' => 'Unlimited invites.',
29
+  'site_moderate_requests' => 'Request moderation access.',
30
+  'site_delete_artist' => 'Can delete artists (must be able to delete torrents+requests).',
31
+  'site_moderate_forums' => 'Forum moderation access.',
32
+  'site_admin_forums' => 'Forum administrator access.',
33
+  'site_forums_double_post' => 'Can double post in the forums.',
34
+  'site_view_flow' => 'Can view stats and data pools.',
35
+  'site_view_full_log' => 'Can view old log entries.',
36
+  'site_view_torrent_snatchlist' => 'Can view torrent snatch lists.',
37
+  'site_recommend_own' => 'Can recommend own torrents.',
38
+  'site_manage_recommendations' => 'Recommendations management access.',
39
+  'site_delete_tag' => 'Can delete tags.',
40
+  'site_disable_ip_history' => 'Disable IP history.',
41
+  'zip_downloader' => 'Download multiple torrents at once.',
42
+  'site_debug' => 'Developer access.',
43
+  'site_proxy_images' => 'Image proxy & anti-canary.',
44
+  'site_search_many' => 'Can go past low limit of search results.',
45
+  'site_ratio_watch_immunity' => 'Immune from being put on ratio watch.',
46
+  'users_edit_usernames' => 'Can edit usernames.',
47
+  'users_edit_ratio' => 'Can edit anyone\'s upload/download amounts.',
48
+  'users_edit_own_ratio' => 'Can edit own upload/download amounts.',
49
+  'users_edit_titles' => 'Can edit titles.',
50
+  'users_edit_avatars' => 'Can edit avatars.',
51
+  'users_edit_invites' => 'Can edit invite numbers and cancel sent invites.',
52
+  'users_edit_watch_hours' => 'Can edit contrib watch hours.',
53
+  'users_edit_reset_keys' => 'Can reset passkey/authkey.',
54
+  'users_edit_profiles' => 'Can edit anyone\'s profile.',
55
+  'users_view_friends' => 'Can view anyone\'s friends.',
56
+  'users_reset_own_keys' => 'Can reset own passkey/authkey.',
57
+  'users_edit_password' => 'Can change passwords.',
58
+  'users_promote_below' => 'Can promote users to below current level.',
59
+  'users_promote_to' => 'Can promote users up to current level.',
60
+  'users_give_donor' => 'Can give donor access.',
61
+  'users_warn' => 'Can warn users.',
62
+  'users_disable_users' => 'Can disable users.',
63
+  'users_disable_posts' => 'Can disable users\' posting privileges.',
64
+  'users_disable_any' => 'Can disable any users\' rights.',
65
+  'users_delete_users' => 'Can delete users.',
66
+  'users_view_invites' => 'Can view who user has invited.',
67
+  'users_view_seedleech' => 'Can view what a user is seeding or leeching.',
68
+  'users_view_uploaded' => 'Can view a user\'s uploads, regardless of privacy level.',
69
+  'users_view_keys' => 'Can view passkeys.',
70
+  'users_view_ips' => 'Can view IP addresses.',
71
+  'users_view_email' => 'Can view email addresses.',
72
+  'users_invite_notes' => 'Can add a staff note when inviting someone.',
73
+  'users_override_paranoia' => 'Can override paranoia.',
74
+  'users_logout' => 'Can log users out (old?).',
75
+  'users_make_invisible' => 'Can make users invisible.',
76
+  'users_mod' => 'Basic moderator tools.',
77
+  'torrents_edit' => 'Can edit any torrent.',
78
+  'torrents_delete' => 'Can delete torrents.',
79
+  'torrents_delete_fast' => 'Can delete more than 3 torrents at a time.',
80
+  'torrents_freeleech' => 'Can make torrents freeleech.',
81
+  'torrents_search_fast' => 'Rapid search (for scripts).',
82
+  'torrents_hide_dnu' => 'Hide the Do Not Upload list by default.',
83
+  'torrents_fix_ghosts' => 'Can fix "ghost" groups on artist pages.',
84
+  'screenshots_add' => 'Can add screenshots to any torrent and delete their own screenshots.',
85
+  'screenshots_delete' => 'Can delete any screenshot from any torrent.',
86
+  'admin_manage_news' => 'Can manage site news.',
87
+  'admin_manage_blog' => 'Can manage the site blog.',
88
+  'admin_manage_polls' => 'Can manage polls.',
89
+  'admin_manage_forums' => 'Can manage forums (add/edit/delete).',
90
+  'admin_manage_fls' => 'Can manage FLS.',
91
+  'admin_reports' => 'Can access reports system.',
92
+  'admin_advanced_user_search' => 'Can access advanced user search.',
93
+  'admin_create_users' => 'Can create users through an administrative form.',
94
+  'admin_donor_log' => 'Can view the donor log.',
95
+  'admin_manage_ipbans' => 'Can manage IP bans.',
96
+  'admin_dnu' => 'Can manage do not upload list.',
97
+  'admin_clear_cache' => 'Can clear cached.',
98
+  'admin_whitelist' => 'Can manage the list of allowed clients.',
99
+  'admin_manage_permissions' => 'Can edit permission classes/user permissions.',
100
+  'admin_schedule' => 'Can run the site schedule.',
101
+  'admin_login_watch' => 'Can manage login watch.',
102
+  'admin_manage_wiki' => 'Can manage wiki access.',
103
+  'admin_update_geoip' => 'Can update geoIP data.',
104
+  'site_collages_recover' => 'Can recover \'deleted\' collages.',
105
+  'torrents_add_artist' => 'Can add artists to any group.',
106
+  'edit_unknowns' => 'Can edit unknown release information.',
107
+  'forums_polls_create' => 'Can create polls in the forums.',
108
+  'forums_polls_moderate' => 'Can feature and close polls.',
109
+  'project_team' => 'Is part of the project team.',
110
+  'torrents_edit_vanityhouse' => 'Can mark groups as part of Vanity House.',
111
+  'artist_edit_vanityhouse' => 'Can mark artists as part of Vanity House.',
112
+  'site_tag_aliases_read' => 'Can view the list of tag aliases.'
113
+);
114
+
115
+function permissions_form()
116
+{
117
+    ?>
118
+<div class="permissions">
119
+  <div class="permission_container">
120
+    <table>
121
+      <tr class="colhead">
122
+        <td>Site</td>
123
+      </tr>
124
+      <tr>
125
+        <td>
126
+          <?php
127
+    display_perm('site_leech', 'Can leech.');
128
+    display_perm('site_upload', 'Can upload.');
129
+    display_perm('site_vote', 'Can vote on requests.');
130
+    display_perm('site_submit_requests', 'Can submit requests.');
131
+    display_perm('site_advanced_search', 'Can use advanced search.');
132
+    display_perm('site_top10', 'Can access top 10.');
133
+    display_perm('site_torrents_notify', 'Can access torrents notifications system.');
134
+    display_perm('site_collages_create', 'Can create collages.');
135
+    display_perm('site_collages_manage', 'Can manage collages (add torrents, sorting).');
136
+    display_perm('site_collages_delete', 'Can delete collages.');
137
+    display_perm('site_collages_subscribe', 'Can access collage subscriptions.');
138
+    display_perm('site_collages_personal', 'Can have a personal collage.');
139
+    display_perm('site_collages_renamepersonal', 'Can rename own personal collages.');
140
+    display_perm('site_advanced_top10', 'Can access advanced top 10.');
141
+    display_perm('site_make_bookmarks', 'Can make bookmarks.');
142
+    display_perm('site_edit_wiki', 'Can edit wiki pages.');
143
+    display_perm('site_can_invite_always', 'Can invite users even when invites are closed.');
144
+    display_perm('site_send_unlimited_invites', 'Can send unlimited invites.');
145
+    display_perm('site_moderate_requests', 'Can moderate any request.');
146
+    display_perm('site_delete_artist', 'Can delete artists (must be able to delete torrents+requests).');
147
+    display_perm('forums_polls_create', 'Can create polls in the forums.');
148
+    display_perm('forums_polls_moderate', 'Can feature and close polls.');
149
+    display_perm('site_moderate_forums', 'Can moderate the forums.');
150
+    display_perm('site_admin_forums', 'Can administrate the forums.');
151
+    display_perm('site_view_flow', 'Can view site stats and data pools.');
152
+    display_perm('site_view_full_log', 'Can view the full site log.');
153
+    display_perm('site_view_torrent_snatchlist', 'Can view torrent snatch lists.');
154
+    display_perm('site_recommend_own', 'Can add own torrents to recommendations list.');
155
+    display_perm('site_manage_recommendations', 'Can edit recommendations list.');
156
+    display_perm('site_delete_tag', 'Can delete tags.');
157
+    display_perm('site_disable_ip_history', 'Disable IP history.');
158
+    display_perm('zip_downloader', 'Download multiple torrents at once.');
159
+    display_perm('site_debug', 'View site debug tables.');
160
+    display_perm('site_proxy_images', 'Proxy images through the server.');
161
+    display_perm('site_search_many', 'Can go past low limit of search results.');
162
+    display_perm('site_collages_recover', 'Can recover \'deleted\' collages.');
163
+    display_perm('site_forums_double_post', 'Can double post in the forums.');
164
+    display_perm('project_team', 'Part of the project team.');
165
+    display_perm('site_tag_aliases_read', 'Can view the list of tag aliases.');
166
+    display_perm('site_ratio_watch_immunity', 'Immune from being put on ratio watch.'); ?>
167
+        </td>
168
+      </tr>
169
+    </table>
170
+  </div>
171
+
172
+  <div class="permission_container">
173
+    <table>
174
+      <tr class="colhead">
175
+        <td>Users</td>
176
+      </tr>
177
+      <tr>
178
+        <td>
179
+          <?php
180
+    display_perm('users_edit_usernames', 'Can edit usernames.');
181
+    display_perm('users_edit_ratio', 'Can edit anyone\'s upload/download amounts.');
182
+    display_perm('users_edit_own_ratio', 'Can edit own upload/download amounts.');
183
+    display_perm('users_edit_titles', 'Can edit titles.');
184
+    display_perm('users_edit_avatars', 'Can edit avatars.');
185
+    display_perm('users_edit_invites', 'Can edit invite numbers and cancel sent invites.');
186
+    display_perm('users_edit_watch_hours', 'Can edit contrib watch hours.');
187
+    display_perm('users_edit_reset_keys', 'Can reset any passkey/authkey.');
188
+    display_perm('users_edit_profiles', 'Can edit anyone\'s profile.');
189
+    display_perm('users_edit_badges', 'Can edit anyone\'s badges.');
190
+    display_perm('users_view_friends', 'Can view anyone\'s friends.');
191
+    display_perm('users_reset_own_keys', 'Can reset own passkey/authkey.');
192
+    display_perm('users_edit_password', 'Can change password.');
193
+    display_perm('users_promote_below', 'Can promote users to below current level.');
194
+    display_perm('users_promote_to', 'Can promote users up to current level.');
195
+    display_perm('users_give_donor', 'Can give donor access.');
196
+    display_perm('users_warn', 'Can warn users.');
197
+    display_perm('users_disable_users', 'Can disable users.');
198
+    display_perm('users_disable_posts', 'Can disable users\' posting privileges.');
199
+    display_perm('users_disable_any', 'Can disable any users\' rights.');
200
+    display_perm('users_delete_users', 'Can delete anyone\'s account');
201
+    display_perm('users_view_invites', 'Can view who user has invited');
202
+    display_perm('users_view_seedleech', 'Can view what a user is seeding or leeching');
203
+    display_perm('users_view_uploaded', 'Can view a user\'s uploads, regardless of privacy level');
204
+    display_perm('users_view_keys', 'Can view passkeys');
205
+    display_perm('users_view_ips', 'Can view IP addresses');
206
+    display_perm('users_view_email', 'Can view email addresses');
207
+    display_perm('users_invite_notes', 'Can add a staff note when inviting someone.');
208
+    display_perm('users_override_paranoia', 'Can override paranoia');
209
+    display_perm('users_make_invisible', 'Can make users invisible');
210
+    display_perm('users_logout', 'Can log users out');
211
+    display_perm('users_mod', 'Can access basic moderator tools (Admin comment)'); ?>
212
+          * Everything is only applicable to users with the same or lower class level
213
+        </td>
214
+      </tr>
215
+    </table>
216
+  </div>
217
+
218
+  <div class="permission_container">
219
+    <table>
220
+      <tr class="colhead">
221
+        <td>Torrents</td>
222
+      </tr>
223
+      <tr>
224
+        <td>
225
+          <?php
226
+    display_perm('torrents_edit', 'Can edit any torrent');
227
+    display_perm('torrents_delete', 'Can delete torrents');
228
+    display_perm('torrents_delete_fast', 'Can delete more than 3 torrents at a time.');
229
+    display_perm('torrents_freeleech', 'Can make torrents freeleech');
230
+    display_perm('torrents_search_fast', 'Unlimit search frequency (for scripts).');
231
+    display_perm('torrents_add_artist', 'Can add artists to any group.');
232
+    display_perm('edit_unknowns', 'Can edit unknown release information.');
233
+    display_perm('torrents_edit_vanityhouse', 'Can mark groups as part of Vanity House.');
234
+    display_perm('artist_edit_vanityhouse', 'Can mark artists as part of Vanity House.');
235
+    display_perm('torrents_hide_dnu', 'Hide the Do Not Upload list by default.');
236
+    display_perm('torrents_fix_ghosts', 'Can fix ghost groups on artist pages.');
237
+    display_perm('screenshots_add', 'Can add screenshots to any torrent and delete their own screenshots.');
238
+    display_perm('screenshots_delete', 'Can delete any screenshot from any torrent.'); ?>
239
+        </td>
240
+      </tr>
241
+    </table>
242
+  </div>
243
+
244
+  <div class="permission_container">
245
+    <table>
246
+      <tr class="colhead">
247
+        <td>Administrative</td>
248
+      </tr>
249
+      <tr>
250
+        <td>
251
+          <?php
252
+    display_perm('admin_manage_news', 'Can manage site news');
253
+    display_perm('admin_manage_blog', 'Can manage the site blog');
254
+    display_perm('admin_manage_polls', 'Can manage polls');
255
+    display_perm('admin_manage_forums', 'Can manage forums (add/edit/delete)');
256
+    display_perm('admin_manage_fls', 'Can manage FLS');
257
+    display_perm('admin_reports', 'Can access reports system');
258
+    display_perm('admin_advanced_user_search', 'Can access advanced user search');
259
+    display_perm('admin_create_users', 'Can create users through an administrative form');
260
+    display_perm('admin_donor_log', 'Can view the donor log');
261
+    display_perm('admin_manage_ipbans', 'Can manage IP bans');
262
+    display_perm('admin_dnu', 'Can manage do not upload list');
263
+    display_perm('admin_clear_cache', 'Can clear cached pages');
264
+    display_perm('admin_whitelist', 'Can manage the list of allowed clients.');
265
+    display_perm('admin_manage_permissions', 'Can edit permission classes/user permissions.');
266
+    display_perm('admin_schedule', 'Can run the site schedule.');
267
+    display_perm('admin_login_watch', 'Can manage login watch.');
268
+    display_perm('admin_manage_wiki', 'Can manage wiki access.');
269
+    display_perm('admin_update_geoip', 'Can update geoIP data.'); ?>
270
+        </td>
271
+      </tr>
272
+    </table>
273
+  </div>
274
+
275
+  <div class="submit_container"><input type="submit" name="submit" value="Save Permission Class" /></div>
276
+</div>
277
+<?php
278
+}

+ 45
- 0
classes/proxies.class.php View File

@@ -0,0 +1,45 @@
1
+<?php
2
+
3
+// Useful: http://www.robtex.com/cnet/
4
+$AllowedProxies = array(
5
+  // Opera Turbo (may include Opera-owned IP addresses that aren't used for Turbo, but shouldn't run much risk of exploitation)
6
+  '64.255.180.*', // Norway
7
+  '64.255.164.*', // Norway
8
+  '80.239.242.*', // Poland
9
+  '80.239.243.*', // Poland
10
+  '91.203.96.*', // Norway
11
+  '94.246.126.*', // Norway
12
+  '94.246.127.*', // Norway
13
+  '195.189.142.*', // Norway
14
+  '195.189.143.*', // Norway
15
+);
16
+
17
+function proxyCheck($IP)
18
+{
19
+    global $AllowedProxies;
20
+    for ($i = 0, $il = count($AllowedProxies); $i < $il; ++$i) {
21
+        // Based on the wildcard principle it should never be shorter
22
+        if (strlen($IP) < strlen($AllowedProxies[$i])) {
23
+            continue;
24
+        }
25
+
26
+        // Since we're matching bit for bit iterating from the start
27
+        for ($j = 0, $jl = strlen($IP); $j < $jl; ++$j) {
28
+            // Completed iteration and no inequality
29
+            if ($j == $jl - 1 && $IP[$j] === $AllowedProxies[$i][$j]) {
30
+                return true;
31
+            }
32
+
33
+            // Wildcard
34
+            if ($AllowedProxies[$i][$j] === '*') {
35
+                return true;
36
+            }
37
+
38
+            // Inequality found
39
+            if ($IP[$j] !== $AllowedProxies[$i][$j]) {
40
+                break;
41
+            }
42
+        }
43
+    }
44
+    return false;
45
+}

+ 30
- 0
classes/reports.class.php View File

@@ -0,0 +1,30 @@
1
+<?php
2
+
3
+class Reports
4
+{
5
+    /**
6
+     * This function formats a string containing a torrent's remaster information
7
+     * to be used in Reports v2.
8
+     *
9
+     * @param boolean  $Remastered - whether the torrent contains remaster information
10
+     * @param string   $RemasterTitle - the title of the remaster information
11
+     * @param string   $RemasterYear - the year of the remaster information
12
+     */
13
+    public static function format_reports_remaster_info($Remastered, $RemasterTitle, $RemasterYear)
14
+    {
15
+        if ($Remastered) {
16
+            $RemasterDisplayString = ' &lt;';
17
+            if ($RemasterTitle !== '' && $RemasterYear !== '') {
18
+                $RemasterDisplayString .= "$RemasterTitle - $RemasterYear";
19
+            } elseif ($RemasterTitle !== '' && $RemasterYear === '') {
20
+                $RemasterDisplayString .= $RemasterTitle;
21
+            } elseif ($RemasterTitle === '' && $RemasterYear !== '') {
22
+                $RemasterDisplayString .= $RemasterYear;
23
+            }
24
+            $RemasterDisplayString .= '&gt;';
25
+        } else {
26
+            $RemasterDisplayString = '';
27
+        }
28
+        return $RemasterDisplayString;
29
+    }
30
+}

+ 264
- 0
classes/requests.class.php View File

@@ -0,0 +1,264 @@
1
+<?php
2
+#declare(strict_types=1);
3
+
4
+class Requests
5
+{
6
+    /**
7
+     * Update the sphinx requests delta table for a request.
8
+     *
9
+     * @param $RequestID
10
+     */
11
+    public static function update_sphinx_requests($RequestID)
12
+    {
13
+        $QueryID = G::$DB->get_query_id();
14
+        G::$DB->query("
15
+        SELECT REPLACE(t.Name, '.', '_')
16
+        FROM tags AS t
17
+          JOIN requests_tags AS rt ON t.ID = rt.TagID
18
+          WHERE rt.RequestID = $RequestID");
19
+
20
+        $TagList = G::$DB->collect(0, false);
21
+        $TagList = db_string(implode(' ', $TagList));
22
+
23
+        G::$DB->query("
24
+        REPLACE INTO sphinx_requests_delta (
25
+          ID, UserID, TimeAdded, LastVote, CategoryID, Title, TagList,
26
+          CatalogueNumber, FillerID, TorrentID,
27
+          TimeFilled, Visible, Votes, Bounty)
28
+        SELECT
29
+          ID, r.UserID, UNIX_TIMESTAMP(TimeAdded) AS TimeAdded,
30
+          UNIX_TIMESTAMP(LastVote) AS LastVote, CategoryID, Title, '$TagList',
31
+          CatalogueNumber, FillerID, TorrentID,
32
+          UNIX_TIMESTAMP(TimeFilled) AS TimeFilled, Visible,
33
+          COUNT(rv.UserID) AS Votes, SUM(rv.Bounty) >> 10 AS Bounty
34
+        FROM requests AS r
35
+          LEFT JOIN requests_votes AS rv ON rv.RequestID = r.ID
36
+        WHERE ID = $RequestID
37
+          GROUP BY r.ID");
38
+
39
+        G::$DB->query("
40
+        UPDATE sphinx_requests_delta
41
+        SET ArtistList = (
42
+          SELECT GROUP_CONCAT(ag.Name SEPARATOR ' ')
43
+          FROM requests_artists AS ra
44
+            JOIN artists_group AS ag ON ag.ArtistID = ra.ArtistID
45
+          WHERE ra.RequestID = $RequestID
46
+            GROUP BY NULL
47
+        )
48
+          WHERE ID = $RequestID");
49
+
50
+        G::$DB->set_query_id($QueryID);
51
+        G::$Cache->delete_value("request_$RequestID");
52
+    }
53
+
54
+    /**
55
+     * Function to get data from an array of $RequestIDs. Order of keys doesn't matter (let's keep it that way).
56
+     *
57
+     * @param array $RequestIDs
58
+     * @param boolean $Return if set to false, data won't be returned (ie. if we just want to prime the cache.)
59
+     * @return The array of requests.
60
+     * Format: array(RequestID => Associative array)
61
+     * To see what's exactly inside each associate array, peek inside the function. It won't bite.
62
+     */
63
+    // In places where the output from this is merged with sphinx filters, it will be in a different order.
64
+    public static function get_requests($RequestIDs, $Return = true)
65
+    {
66
+        $Found = $NotFound = array_fill_keys($RequestIDs, false);
67
+        // Try to fetch the requests from the cache first.
68
+        foreach ($RequestIDs as $i => $RequestID) {
69
+            if (!is_number($RequestID)) {
70
+                unset($RequestIDs[$i], $Found[$GroupID], $NotFound[$GroupID]);
71
+                continue;
72
+            }
73
+
74
+            $Data = G::$Cache->get_value("request_$RequestID");
75
+            if (!empty($Data)) {
76
+                unset($NotFound[$RequestID]);
77
+                $Found[$RequestID] = $Data;
78
+            }
79
+        }
80
+
81
+        // Make sure there's something in $RequestIDs, otherwise the SQL will break
82
+        if (count($RequestIDs) === 0) {
83
+            return [];
84
+        }
85
+        $IDs = implode(',', array_keys($NotFound));
86
+
87
+        /*
88
+         * Don't change without ensuring you change everything else that uses get_requests()
89
+         */
90
+
91
+        if (count($NotFound) > 0) {
92
+            $QueryID = G::$DB->get_query_id();
93
+            G::$DB->query("
94
+            SELECT
95
+              ID,
96
+              UserID,
97
+              TimeAdded,
98
+              LastVote,
99
+              CategoryID,
100
+              Title,
101
+              Title2,
102
+              TitleJP,
103
+              Image,
104
+              Description,
105
+              CatalogueNumber,
106
+              FillerID,
107
+              TorrentID,
108
+              TimeFilled,
109
+              GroupID
110
+            FROM requests
111
+              WHERE ID IN ($IDs)
112
+              ORDER BY ID");
113
+
114
+            $Requests = G::$DB->to_array(false, MYSQLI_ASSOC, true);
115
+            $Tags = self::get_tags(G::$DB->collect('ID', false));
116
+
117
+            foreach ($Requests as $Request) {
118
+                $Request['AnonymousFill'] = false;
119
+                if ($Request['FillerID']) {
120
+                    G::$DB->query("
121
+                    SELECT Anonymous
122
+                    FROM torrents
123
+                      WHERE ID = ".$Request['TorrentID']);
124
+
125
+                    list($Anonymous) = G::$DB->next_record();
126
+                    if ($Anonymous) {
127
+                        $Request['AnonymousFill'] = true;
128
+                    }
129
+                }
130
+
131
+                unset($NotFound[$Request['ID']]);
132
+                $Request['Tags'] = isset($Tags[$Request['ID']]) ? $Tags[$Request['ID']] : [];
133
+                $Found[$Request['ID']] = $Request;
134
+                G::$Cache->cache_value('request_'.$Request['ID'], $Request, 0);
135
+            }
136
+            G::$DB->set_query_id($QueryID);
137
+
138
+            // Orphan requests. There shouldn't ever be any
139
+            if (count($NotFound) > 0) {
140
+                foreach (array_keys($NotFound) as $GroupID) {
141
+                    unset($Found[$GroupID]);
142
+                }
143
+            }
144
+        }
145
+
146
+        if ($Return) { // If we're interested in the data, and not just caching it
147
+            return $Found;
148
+        }
149
+    }
150
+
151
+    /**
152
+     * Return a single request. Wrapper for get_requests
153
+     *
154
+     * @param int $RequestID
155
+     * @return request array or false if request doesn't exist. See get_requests for a description of the format
156
+     */
157
+    public static function get_request($RequestID)
158
+    {
159
+        $Request = self::get_requests(array($RequestID));
160
+        if (isset($Request[$RequestID])) {
161
+            return $Request[$RequestID];
162
+        }
163
+        return false;
164
+    }
165
+
166
+    public static function get_artists($RequestID)
167
+    {
168
+        $Artists = G::$Cache->get_value("request_artists_$RequestID");
169
+        if (is_array($Artists)) {
170
+            $Results = $Artists;
171
+        } else {
172
+            $Results = [];
173
+            $QueryID = G::$DB->get_query_id();
174
+            G::$DB->query("
175
+            SELECT
176
+              ra.ArtistID,
177
+              ag.Name
178
+            FROM requests_artists AS ra
179
+              JOIN artists_group AS ag ON ra.ArtistID = ag.ArtistID
180
+            WHERE ra.RequestID = $RequestID
181
+              ORDER BY ag.Name ASC;");
182
+
183
+            $ArtistRaw = G::$DB->to_array();
184
+            G::$DB->set_query_id($QueryID);
185
+
186
+            foreach ($ArtistRaw as $ArtistRow) {
187
+                list($ArtistID, $ArtistName) = $ArtistRow;
188
+                $Results[] = array('id' => $ArtistID, 'name' => $ArtistName);
189
+            }
190
+            G::$Cache->cache_value("request_artists_$RequestID", $Results);
191
+        }
192
+        return $Results;
193
+    }
194
+
195
+    public static function get_tags($RequestIDs)
196
+    {
197
+        if (empty($RequestIDs)) {
198
+            return [];
199
+        }
200
+
201
+        if (is_array($RequestIDs)) {
202
+            $RequestIDs = implode(',', $RequestIDs);
203
+        }
204
+
205
+        $QueryID = G::$DB->get_query_id();
206
+        G::$DB->query("
207
+        SELECT
208
+          rt.RequestID,
209
+          rt.TagID,
210
+          t.Name
211
+        FROM requests_tags AS rt
212
+          JOIN tags AS t ON rt.TagID = t.ID
213
+        WHERE rt.RequestID IN ($RequestIDs)
214
+          ORDER BY rt.TagID ASC");
215
+
216
+        $Tags = G::$DB->to_array(false, MYSQLI_NUM, false);
217
+        G::$DB->set_query_id($QueryID);
218
+
219
+        $Results = [];
220
+        foreach ($Tags as $TagsRow) {
221
+            list($RequestID, $TagID, $TagName) = $TagsRow;
222
+            $Results[$RequestID][$TagID] = $TagName;
223
+        }
224
+        return $Results;
225
+    }
226
+
227
+    public static function get_votes_array($RequestID)
228
+    {
229
+        $RequestVotes = G::$Cache->get_value("request_votes_$RequestID");
230
+        if (!is_array($RequestVotes)) {
231
+            $QueryID = G::$DB->get_query_id();
232
+            G::$DB->query("
233
+            SELECT
234
+              rv.UserID,
235
+              rv.Bounty,
236
+              u.Username
237
+            FROM requests_votes AS rv
238
+              LEFT JOIN users_main AS u ON u.ID = rv.UserID
239
+            WHERE rv.RequestID = $RequestID
240
+              ORDER BY rv.Bounty DESC");
241
+
242
+            if (!G::$DB->has_results()) {
243
+                return array(
244
+                    'TotalBounty' => 0,
245
+                    'Voters' => []);
246
+            }
247
+            $Votes = G::$DB->to_array();
248
+
249
+            $RequestVotes = [];
250
+            $RequestVotes['TotalBounty'] = array_sum(G::$DB->collect('Bounty'));
251
+
252
+            foreach ($Votes as $Vote) {
253
+                list($UserID, $Bounty, $Username) = $Vote;
254
+                $VoteArray = [];
255
+                $VotesArray[] = array('UserID' => $UserID, 'Username' => $Username, 'Bounty' => $Bounty);
256
+            }
257
+
258
+            $RequestVotes['Voters'] = $VotesArray;
259
+            G::$Cache->cache_value("request_votes_$RequestID", $RequestVotes);
260
+            G::$DB->set_query_id($QueryID);
261
+        }
262
+        return $RequestVotes;
263
+    }
264
+}

+ 30
- 0
classes/revisionhistory.class.php View File

@@ -0,0 +1,30 @@
1
+<?php
2
+
3
+class RevisionHistory
4
+{
5
+    /**
6
+     * Read the revision history of an artist or torrent page
7
+     * @param string $Page artists or torrents
8
+     * @param in $PageID
9
+     * @return array
10
+     */
11
+    public static function get_revision_history($Page, $PageID)
12
+    {
13
+        $Table = ($Page == 'artists') ? 'wiki_artists' : 'wiki_torrents';
14
+        $QueryID = G::$DB->get_query_id();
15
+
16
+        G::$DB->query("
17
+        SELECT
18
+          RevisionID,
19
+          Summary,
20
+          Time,
21
+          UserID
22
+        FROM $Table
23
+          WHERE PageID = $PageID
24
+          ORDER BY RevisionID DESC");
25
+
26
+        $Ret = G::$DB->to_array();
27
+        G::$DB->set_query_id($QueryID);
28
+        return $Ret;
29
+    }
30
+}

+ 46
- 0
classes/revisionhistoryview.class.php View File

@@ -0,0 +1,46 @@
1
+<?php
2
+
3
+class RevisionHistoryView
4
+{
5
+    /**
6
+     * Render the revision history
7
+     * @param array $RevisionHistory see RevisionHistory::get_revision_history
8
+     * @param string $BaseURL
9
+     */
10
+    public static function render_revision_history($RevisionHistory, $BaseURL)
11
+    {
12
+        ?>
13
+<table cellpadding="6" cellspacing="1" border="0" width="100%" class="box">
14
+  <tr class="colhead">
15
+    <td>Revision</td>
16
+    <td>Date</td>
17
+    <?php if (check_perms('users_mod')) { ?>
18
+    <td>User</td>
19
+    <?php } ?>
20
+    <td>Summary</td>
21
+  </tr>
22
+  <?php
23
+    foreach ($RevisionHistory as $Entry) {
24
+        list($RevisionID, $Summary, $Time, $UserID) = $Entry; ?>
25
+  <tr class="row">
26
+    <td>
27
+      <?= "<a href=\"$BaseURL&amp;revisionid=$RevisionID\">#$RevisionID</a>" ?>
28
+    </td>
29
+    <td>
30
+      <?=$Time?>
31
+    </td>
32
+    <?php if (check_perms('users_mod')) { ?>
33
+    <td>
34
+      <?=Users::format_username($UserID, false, false, false)?>
35
+    </td>
36
+    <?php } ?>
37
+    <td>
38
+      <?=($Summary ? $Summary : '(empty)')?>
39
+    </td>
40
+  </tr>
41
+  <?php
42
+    } ?>
43
+</table>
44
+<?php
45
+    }
46
+}

+ 572
- 0
classes/script_start.php View File

@@ -0,0 +1,572 @@
1
+<?php
2
+#declare(strict_types=1);
3
+
4
+# https://www.php.net/manual/en/language.oop5.autoload.php
5
+require_once 'config.php';
6
+require_once 'security.class.php';
7
+
8
+# Initialize
9
+$ENV = ENV::go();
10
+$Security = new Security();
11
+$Security->setupPitfalls();
12
+
13
+
14
+/*-- Script Start Class --------------------------------*/
15
+/*------------------------------------------------------*/
16
+/* This isnt really a class but a way to tie other      */
17
+/* classes and functions used all over the site to the  */
18
+/* page currently being displayed.                      */
19
+/*------------------------------------------------------*/
20
+/* The code that includes the main php files and    */
21
+/* generates the page are at the bottom.        */
22
+/*------------------------------------------------------*/
23
+/********************************************************/
24
+
25
+require SERVER_ROOT.'/classes/proxies.class.php';
26
+
27
+// Get the user's actual IP address if they're proxied.
28
+// Or if cloudflare is used
29
+if (isset($_SERVER['HTTP_CF_CONNECTING_IP'])) {
30
+    $_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_CF_CONNECTING_IP'];
31
+}
32
+
33
+if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])
34
+    && proxyCheck($_SERVER['REMOTE_ADDR'])
35
+    && filter_var(
36
+        $_SERVER['HTTP_X_FORWARDED_FOR'],
37
+        FILTER_VALIDATE_IP,
38
+        FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
39
+    )) {
40
+    $_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_X_FORWARDED_FOR'];
41
+}
42
+
43
+if (!isset($argv) && !empty($_SERVER['HTTP_HOST'])) {
44
+    // Skip this block if running from cli or if the browser is old and shitty
45
+    // This should really be done in nginx config
46
+    // todo: Remove
47
+    if ($_SERVER['HTTP_HOST'] == 'www.'.SITE_DOMAIN) {
48
+        header('Location: https://'.SITE_DOMAIN.$_SERVER['REQUEST_URI']);
49
+        error();
50
+    }
51
+}
52
+
53
+$ScriptStartTime = microtime(true); // To track how long a page takes to create
54
+if (!defined('PHP_WINDOWS_VERSION_MAJOR')) {
55
+    $RUsage = getrusage();
56
+    $CPUTimeStart = $RUsage['ru_utime.tv_sec'] * 1000000 + $RUsage['ru_utime.tv_usec'];
57
+}
58
+ob_start(); // Start a buffer, mainly in case there is a mysql error
59
+
60
+require SERVER_ROOT.'/classes/debug.class.php'; // Require the debug class
61
+require SERVER_ROOT.'/classes/mysql.class.php'; // Require the database wrapper
62
+require SERVER_ROOT.'/classes/cache.class.php'; // Require the caching class
63
+require SERVER_ROOT.'/classes/time.class.php'; // Require the time class
64
+require SERVER_ROOT.'/classes/paranoia.class.php'; // Require the paranoia check_paranoia function
65
+require SERVER_ROOT.'/classes/util.php';
66
+
67
+$Debug = new DEBUG;
68
+$Debug->handle_errors();
69
+$Debug->set_flag('Debug constructed');
70
+
71
+$DB = new DB_MYSQL;
72
+$Cache = new Cache($ENV->getPriv('MEMCACHED_SERVERS'));
73
+
74
+// Autoload classes.
75
+require SERVER_ROOT.'/classes/autoload.php';
76
+
77
+// Note: G::initialize is called twice.
78
+// This is necessary as the code inbetween (initialization of $LoggedUser) makes use of G::$DB and G::$Cache.
79
+// todo: Remove one of the calls once we're moving everything into that class
80
+G::initialize();
81
+
82
+// Begin browser identification
83
+$Browser = UserAgent::browser($_SERVER['HTTP_USER_AGENT']);
84
+$OperatingSystem = UserAgent::operating_system($_SERVER['HTTP_USER_AGENT']);
85
+
86
+$Debug->set_flag('start user handling');
87
+
88
+// Get classes
89
+// todo: Remove these globals, replace by calls into Users
90
+list($Classes, $ClassLevels) = Users::get_classes();
91
+
92
+
93
+//-- Load user information
94
+// User info is broken up into many sections
95
+// Heavy - Things that the site never has to look at if the user isn't logged in (as opposed to things like the class, donor status, etc)
96
+// Light - Things that appear in format_user
97
+// Stats - Uploaded and downloaded - can be updated by a script if you want super speed
98
+// Session data - Information about the specific session
99
+// Enabled - if the user's enabled or not
100
+// Permissions
101
+
102
+
103
+/**
104
+ * JSON API token support
105
+ * @see https://github.com/OPSnet/Gazelle/commit/7c208fc4c396a16c77289ef886d0015db65f2af1#diff-2ea09cbf36b1d20fec7a6d7fc50780723b9f804c4e857003aa9a9c359dc9fd49
106
+ */
107
+
108
+// Set the document we are loading
109
+$Document = basename(parse_url($_SERVER['SCRIPT_NAME'], PHP_URL_PATH), '.php');
110
+
111
+$LoggedUser = [];
112
+$SessionID = false;
113
+$FullToken = null;
114
+
115
+// Only allow using the Authorization header for ajax endpoint
116
+if (!empty($_SERVER['HTTP_AUTHORIZATION']) && $Document === 'ajax') {
117
+    # Banned IP address
118
+    if (IPv4::isBanned($_SERVER['REMOTE_ADDR'])) {
119
+        header('Content-Type: application/json');
120
+        json_die('failure', 'your ip address has been banned');
121
+    }
122
+
123
+    # Invalid auth header type
124
+    # Bearer is correct according to RFC 6750
125
+    # https://tools.ietf.org/html/rfc6750
126
+    $AuthorizationHeader = explode(" ", (string) $_SERVER['HTTP_AUTHORIZATION']);
127
+    if (count($AuthorizationHeader) === 2) {
128
+        if ($AuthorizationHeader[0] !== 'Bearer') {
129
+            header('Content-Type: application/json');
130
+            json_die('failure', 'authorization type must be Bearer');
131
+        }
132
+        $FullToken = $AuthorizationHeader[1];
133
+    } else {
134
+        header('Content-Type: application/json');
135
+        json_die('failure', 'authorization type must be Bearer');
136
+    }
137
+
138
+    $Revoked = 1;
139
+    $UserID = (int) substr(Crypto::decrypt(base64UrlDecode($FullToken), $ENV->getPriv('ENCKEY')), 32);
140
+
141
+    if (!empty($UserID)) {
142
+        [$LoggedUser['ID'], $Revoked] =
143
+        G::$DB->row("
144
+        SELECT
145
+          `UserID`,
146
+          `Revoked`
147
+        FROM
148
+          `api_user_tokens`
149
+        WHERE
150
+          `UserID` = '$UserID'
151
+        ");
152
+    # AND `Token` = '$FullToken'
153
+    } else {
154
+        header('Content-Type: application/json');
155
+        json_die('failure', 'invalid token format');
156
+    }
157
+
158
+    # No user or revoked API token
159
+    if (empty($LoggedUser['ID']) || $Revoked === 1) {
160
+        log_token_attempt(G::$DB);
161
+        header('Content-Type: application/json');
162
+        json_die('failure', 'token user mismatch');
163
+    }
164
+
165
+    # Checks if a user exists
166
+    if (isset($LoggedUser['ID'])) {
167
+        #$UserID = (int) $LoggedUser['ID'];
168
+        #$Session = new Gazelle\Session($LoggedUser['ID']);
169
+    
170
+        # User doesn't own that token
171
+        if (!is_null($FullToken) && !Users::hasApiToken($UserID, $FullToken)) {
172
+            log_token_attempt(G::$DB, $LoggedUser['ID']);
173
+            header('Content-type: application/json');
174
+            json_die('failure', 'token revoked');
175
+        }
176
+    
177
+        # User is disabled
178
+        if (Users::isDisabled($UserID)) {
179
+            if (is_null($FullToken)) {
180
+                logout($LoggedUser['ID'], $SessionID);
181
+            } else {
182
+                log_token_attempt(G::$DB, $LoggedUser['ID']);
183
+                header('Content-type: application/json');
184
+                json_die('failure', 'user disabled');
185
+            }
186
+        }
187
+    }
188
+}
189
+
190
+/*
191
+# OPS pleasantly rewrote session handling
192
+$UserSessions = [];
193
+if (isset($_COOKIE['session'])) {
194
+    $LoginCookie = Crypto::decrypt($_COOKIE['session'], $ENV->getPriv('ENCKEY'));
195
+    if ($LoginCookie !== false) {
196
+        [$SessionID, $LoggedUser['ID']] = explode('|~|', Crypto::decrypt($LoginCookie, $ENV->getPriv('ENCKEY')));
197
+        $LoggedUser['ID'] = (int)$LoggedUser['ID'];
198
+
199
+        if (!$LoggedUser['ID'] || !$SessionID) {
200
+            logout($LoggedUser['ID'], $SessionID);
201
+        }
202
+
203
+        $Session = new Gazelle\Session($LoggedUser['ID']);
204
+        $UserSessions = $Session->sessions();
205
+        if (!array_key_exists($SessionID, $UserSessions)) {
206
+            logout($LoggedUser['ID'], $SessionID);
207
+        }
208
+    }
209
+}
210
+*/
211
+# End OPS API token additions
212
+
213
+
214
+/**
215
+ * Session handling and cookies
216
+ */
217
+if (isset($_COOKIE['session']) && isset($_COOKIE['userid'])) {
218
+    $SessionID = $_COOKIE['session'];
219
+    $LoggedUser['ID'] = (int) $_COOKIE['userid'];
220
+
221
+    $UserID = $LoggedUser['ID']; // todo: UserID should not be LoggedUser
222
+    if (!$LoggedUser['ID'] || !$SessionID) {
223
+        logout();
224
+    }
225
+
226
+    $UserSessions = $Cache->get_value("users_sessions_$UserID");
227
+    if (!is_array($UserSessions)) {
228
+        $DB->query(
229
+            "
230
+        SELECT
231
+          SessionID,
232
+          Browser,
233
+          OperatingSystem,
234
+          IP,
235
+          LastUpdate
236
+        FROM users_sessions
237
+          WHERE UserID = '$UserID'
238
+          AND Active = 1
239
+        ORDER BY LastUpdate DESC"
240
+        );
241
+
242
+        $UserSessions = $DB->to_array('SessionID', MYSQLI_ASSOC);
243
+        $Cache->cache_value("users_sessions_$UserID", $UserSessions, 0);
244
+    }
245
+
246
+    if (!array_key_exists($SessionID, $UserSessions)) {
247
+        logout();
248
+    }
249
+
250
+    // Check if user is enabled
251
+    $Enabled = $Cache->get_value('enabled_'.$LoggedUser['ID']);
252
+    if ($Enabled === false) {
253
+        $DB->query("
254
+        SELECT Enabled
255
+          FROM users_main
256
+          WHERE ID = '$LoggedUser[ID]'");
257
+
258
+        list($Enabled) = $DB->next_record();
259
+        $Cache->cache_value('enabled_'.$LoggedUser['ID'], $Enabled, 0);
260
+    }
261
+
262
+    # todo: Check strict equality
263
+    if ($Enabled == 2) {
264
+        logout();
265
+    }
266
+
267
+    // Up/Down stats
268
+    $UserStats = $Cache->get_value('user_stats_'.$LoggedUser['ID']);
269
+    if (!is_array($UserStats)) {
270
+        $DB->query("
271
+        SELECT Uploaded AS BytesUploaded, Downloaded AS BytesDownloaded, RequiredRatio
272
+        FROM users_main
273
+          WHERE ID = '$LoggedUser[ID]'");
274
+
275
+        $UserStats = $DB->next_record(MYSQLI_ASSOC);
276
+        $Cache->cache_value('user_stats_'.$LoggedUser['ID'], $UserStats, 3600);
277
+    }
278
+
279
+    // Get info such as username
280
+    $LightInfo = Users::user_info($LoggedUser['ID']);
281
+    $HeavyInfo = Users::user_heavy_info($LoggedUser['ID']);
282
+
283
+
284
+    /**
285
+     * OPS API tokens
286
+     * @see https://github.com/OPSnet/Gazelle/commit/7c208fc4c396a16c77289ef886d0015db65f2af1#diff-2ea09cbf36b1d20fec7a6d7fc50780723b9f804c4e857003aa9a9c359dc9fd49
287
+     */
288
+    // TODO: These globals need to die, and just use $LoggedUser
289
+    // TODO: And then instantiate $LoggedUser from Gazelle\Session when needed
290
+    if (empty($LightInfo['Username'])) { // Ghost
291
+        logout($LoggedUser['ID'], $SessionID);
292
+        if (!is_null($FullToken)) {
293
+            #$UserID->flushCache();
294
+            log_token_attempt(G::$DB, $LoggedUser['ID']);
295
+            header('Content-type: application/json');
296
+            json_die('error', 'invalid token');
297
+        } else {
298
+            logout($LoggedUser['ID'], $SessionID);
299
+        }
300
+    }
301
+    # End OPS API token additions
302
+
303
+
304
+    // Create LoggedUser array
305
+    $LoggedUser = array_merge($HeavyInfo, $LightInfo, $UserStats);
306
+    $LoggedUser['RSS_Auth'] = md5($LoggedUser['ID'] . RSS_HASH . $LoggedUser['torrent_pass']);
307
+
308
+    // $LoggedUser['RatioWatch'] as a bool to disable things for users on Ratio Watch
309
+    $LoggedUser['RatioWatch'] = (
310
+        $LoggedUser['RatioWatchEnds']
311
+     && time() < strtotime($LoggedUser['RatioWatchEnds'])
312
+     && ($LoggedUser['BytesDownloaded'] * $LoggedUser['RequiredRatio']) > $LoggedUser['BytesUploaded']
313
+    );
314
+
315
+    // Load in the permissions
316
+    $LoggedUser['Permissions'] = Permissions::get_permissions_for_user($LoggedUser['ID'], $LoggedUser['CustomPermissions']);
317
+    $LoggedUser['Permissions']['MaxCollages'] += Donations::get_personal_collages($LoggedUser['ID']);
318
+
319
+    // Change necessary triggers in external components
320
+    $Cache->CanClear = check_perms('admin_clear_cache');
321
+
322
+    // Because we <3 our staff
323
+    if (check_perms('site_disable_ip_history')) {
324
+        $_SERVER['REMOTE_ADDR'] = '127.0.0.1';
325
+    }
326
+
327
+    // Update LastUpdate every 10 minutes
328
+    if (strtotime($UserSessions[$SessionID]['LastUpdate']) + 600 < time()) {
329
+        $DB->query("
330
+        UPDATE users_main
331
+        SET LastAccess = NOW()
332
+        WHERE ID = '$LoggedUser[ID]'
333
+        ");
334
+
335
+        $SessionQuery =
336
+       "UPDATE users_sessions
337
+          SET ";
338
+
339
+        // Only update IP if we have an encryption key in memory
340
+        if (apcu_exists('DBKEY')) {
341
+            $SessionQuery .= "IP = '".Crypto::encrypt($_SERVER['REMOTE_ADDR'])."', ";
342
+        }
343
+
344
+        $SessionQuery .=
345
+       "Browser = '$Browser',
346
+        OperatingSystem = '$OperatingSystem',
347
+        LastUpdate = NOW()
348
+        WHERE UserID = '$LoggedUser[ID]'
349
+        AND SessionID = '".db_string($SessionID)."'";
350
+
351
+        $DB->query($SessionQuery);
352
+        $Cache->begin_transaction("users_sessions_$UserID");
353
+        $Cache->delete_row($SessionID);
354
+
355
+        $UsersSessionCache = array(
356
+        'SessionID' => $SessionID,
357
+        'Browser' => $Browser,
358
+        'OperatingSystem' => $OperatingSystem,
359
+        'IP' => (apcu_exists('DBKEY') ? Crypto::encrypt($_SERVER['REMOTE_ADDR']) : $UserSessions[$SessionID]['IP']),
360
+        'LastUpdate' => sqltime() );
361
+
362
+        $Cache->insert_front($SessionID, $UsersSessionCache);
363
+        $Cache->commit_transaction(0);
364
+    }
365
+
366
+    // Notifications
367
+    if (isset($LoggedUser['Permissions']['site_torrents_notify'])) {
368
+        $LoggedUser['Notify'] = $Cache->get_value('notify_filters_'.$LoggedUser['ID']);
369
+        if (!is_array($LoggedUser['Notify'])) {
370
+            $DB->query("
371
+            SELECT ID, Label
372
+            FROM users_notify_filters
373
+              WHERE UserID = '$LoggedUser[ID]'");
374
+
375
+            $LoggedUser['Notify'] = $DB->to_array('ID');
376
+            $Cache->cache_value('notify_filters_'.$LoggedUser['ID'], $LoggedUser['Notify'], 2592000);
377
+        }
378
+    }
379
+
380
+    // We've never had to disable the wiki privs of anyone.
381
+    if ($LoggedUser['DisableWiki']) {
382
+        unset($LoggedUser['Permissions']['site_edit_wiki']);
383
+    }
384
+
385
+    // IP changed
386
+    if (apcu_exists('DBKEY') && Crypto::decrypt($LoggedUser['IP']) != $_SERVER['REMOTE_ADDR'] && !check_perms('site_disable_ip_history')) {
387
+        if (Tools::site_ban_ip($_SERVER['REMOTE_ADDR'])) {
388
+            error('Your IP address has been banned.');
389
+        }
390
+
391
+        $CurIP = db_string($LoggedUser['IP']);
392
+        $NewIP = db_string($_SERVER['REMOTE_ADDR']);
393
+        $DB->query("
394
+        SELECT IP
395
+        FROM users_history_ips
396
+          WHERE EndTime IS NULL
397
+          AND UserID = '$LoggedUser[ID]'");
398
+
399
+        while (list($EncIP) = $DB->next_record()) {
400
+            if (Crypto::decrypt($EncIP) == $CurIP) {
401
+                $CurIP = $EncIP;
402
+                // CurIP is now the encrypted IP that was already in the database (for matching)
403
+                break;
404
+            }
405
+        }
406
+
407
+        $DB->query("
408
+        UPDATE users_history_ips
409
+        SET EndTime = NOW()
410
+          WHERE EndTime IS NULL
411
+          AND UserID = '$LoggedUser[ID]'
412
+          AND IP = '$CurIP'");
413
+
414
+        $DB->query("
415
+        INSERT IGNORE INTO users_history_ips
416
+          (UserID, IP, StartTime)
417
+        VALUES
418
+          ('$LoggedUser[ID]', '".Crypto::encrypt($NewIP)."', NOW())");
419
+
420
+        $ipcc = Tools::geoip($NewIP);
421
+        $DB->query("
422
+        UPDATE users_main
423
+        SET IP = '".Crypto::encrypt($NewIP)."', ipcc = '$ipcc'
424
+          WHERE ID = '$LoggedUser[ID]'");
425
+
426
+        $Cache->begin_transaction('user_info_heavy_'.$LoggedUser['ID']);
427
+        $Cache->update_row(false, array('IP' => Crypto::encrypt($_SERVER['REMOTE_ADDR'])));
428
+        $Cache->commit_transaction(0);
429
+    }
430
+
431
+    // Get stylesheets
432
+    $Stylesheets = $Cache->get_value('stylesheets');
433
+    if (!is_array($Stylesheets)) {
434
+        $DB->query('
435
+        SELECT
436
+          ID,
437
+          LOWER(REPLACE(Name, " ", "_")) AS Name,
438
+          Name AS ProperName,
439
+          LOWER(REPLACE(Additions, " ", "_")) AS Additions,
440
+          Additions AS ProperAdditions
441
+        FROM stylesheets');
442
+
443
+        $Stylesheets = $DB->to_array('ID', MYSQLI_BOTH);
444
+        $Cache->cache_value('stylesheets', $Stylesheets, 0);
445
+    }
446
+
447
+    // todo: Clean up this messy solution
448
+    $LoggedUser['StyleName'] = $Stylesheets[$LoggedUser['StyleID']]['Name'];
449
+    if (empty($LoggedUser['Username'])) {
450
+        logout(); // Ghost
451
+    }
452
+}
453
+
454
+G::initialize();
455
+$Debug->set_flag('end user handling');
456
+$Debug->set_flag('start function definitions');
457
+
458
+/**
459
+ * Log out the current session
460
+ */
461
+function logout()
462
+{
463
+    global $SessionID;
464
+    setcookie('session', '', time() - 60 * 60 * 24 * 365, '/', '', false);
465
+    setcookie('userid', '', time() - 60 * 60 * 24 * 365, '/', '', false);
466
+    setcookie('keeplogged', '', time() - 60 * 60 * 24 * 365, '/', '', false);
467
+
468
+    if ($SessionID) {
469
+        G::$DB->query("
470
+        DELETE FROM users_sessions
471
+          WHERE UserID = '" . G::$LoggedUser['ID'] . "'
472
+          AND SessionID = '".db_string($SessionID)."'");
473
+
474
+        G::$Cache->begin_transaction('users_sessions_' . G::$LoggedUser['ID']);
475
+        G::$Cache->delete_row($SessionID);
476
+        G::$Cache->commit_transaction(0);
477
+    }
478
+
479
+    G::$Cache->delete_value('user_info_' . G::$LoggedUser['ID']);
480
+    G::$Cache->delete_value('user_stats_' . G::$LoggedUser['ID']);
481
+    G::$Cache->delete_value('user_info_heavy_' . G::$LoggedUser['ID']);
482
+
483
+    header('Location: login.php');
484
+    error();
485
+}
486
+
487
+function logout_all_sessions()
488
+{
489
+    $UserID = G::$LoggedUser['ID'];
490
+
491
+    G::$DB->query("
492
+    DELETE FROM users_sessions
493
+      WHERE UserID = '$UserID'");
494
+
495
+    G::$Cache->delete_value('users_sessions_' . $UserID);
496
+    logout();
497
+}
498
+
499
+function enforce_login()
500
+{
501
+    global $SessionID;
502
+    if (!$SessionID || !G::$LoggedUser) {
503
+        setcookie('redirect', $_SERVER['REQUEST_URI'], time() + 60 * 30, '/', '', false);
504
+        logout();
505
+    }
506
+}
507
+
508
+/**
509
+ * Make sure $_GET['auth'] is the same as the user's authorization key
510
+ * Should be used for any user action that relies solely on GET.
511
+ *
512
+ * @param Are we using ajax?
513
+ * @return authorisation status. Prints an error message to DEBUG_CHAN on IRC on failure.
514
+ */
515
+function authorize($Ajax = false)
516
+{
517
+    # Ugly workaround for API tokens
518
+    if (!empty($_SERVER['HTTP_AUTHORIZATION']) && $Document === 'ajax') {
519
+        return true;
520
+    } else {
521
+        if (empty($_REQUEST['auth']) || $_REQUEST['auth'] !== G::$LoggedUser['AuthKey']) {
522
+            send_irc(DEBUG_CHAN, G::$LoggedUser['Username']." just failed authorize on ".$_SERVER['REQUEST_URI'].(!empty($_SERVER['HTTP_REFERER']) ? " coming from ".$_SERVER['HTTP_REFERER'] : ""));
523
+            error('Invalid authorization key. Go back, refresh, and try again.', $NoHTML = true);
524
+            return false;
525
+        }
526
+    }
527
+}
528
+
529
+$Debug->set_flag('ending function definitions');
530
+$Document = basename(parse_url($_SERVER['SCRIPT_FILENAME'], PHP_URL_PATH), '.php');
531
+
532
+if (!preg_match('/^[a-z0-9]+$/i', $Document)) {
533
+    error(404);
534
+}
535
+
536
+$StripPostKeys = array_fill_keys(array('password', 'cur_pass', 'new_pass_1', 'new_pass_2', 'verifypassword', 'confirm_password', 'ChangePassword', 'Password'), true);
537
+$Cache->cache_value('php_' . getmypid(), array(
538
+  'start' => sqltime(),
539
+  'document' => $Document,
540
+  'query' => $_SERVER['QUERY_STRING'],
541
+  'get' => $_GET,
542
+  'post' => array_diff_key($_POST, $StripPostKeys)), 600);
543
+
544
+// Locked account constant
545
+define('STAFF_LOCKED', 1);
546
+
547
+$AllowedPages = ['staffpm', 'ajax', 'locked', 'logout', 'login'];
548
+if (isset(G::$LoggedUser['LockedAccount']) && !in_array($Document, $AllowedPages)) {
549
+    require(SERVER_ROOT . '/sections/locked/index.php');
550
+} else {
551
+    require(SERVER_ROOT . '/sections/' . $Document . '/index.php');
552
+}
553
+
554
+$Debug->set_flag('completed module execution');
555
+
556
+/* Required in the absence of session_start() for providing that pages will change
557
+upon hit rather than being browser cached for changing content.
558
+
559
+Old versions of Internet Explorer choke when downloading binary files over HTTPS with disabled cache.
560
+Define the following constant in files that handle file downloads */
561
+if (!defined('SKIP_NO_CACHE_HEADERS')) {
562
+    header('Cache-Control: no-cache, must-revalidate, post-check=0, pre-check=0');
563
+    header('Pragma: no-cache');
564
+}
565
+
566
+// Flush to user
567
+ob_end_flush();
568
+
569
+$Debug->set_flag('set headers and send to user');
570
+
571
+// Attribute profiling
572
+$Debug->profile();

+ 86
- 0
classes/security.class.php View File

@@ -0,0 +1,86 @@
1
+<?php
2
+declare(strict_types = 1);
3
+
4
+/**
5
+ * Security
6
+ *
7
+ * Designed to hold common authentication functions from various sources:
8
+ *  - classes/script_start.php
9
+ */
10
+
11
+class Security
12
+{
13
+    /**
14
+     * Setup pitfalls
15
+     *
16
+     * A series of quick sanity checks during app init.
17
+     * Previously in classes/script_start.php.
18
+     */
19
+    public function setupPitfalls()
20
+    {
21
+        # short_open_tag
22
+        if (!ini_get('short_open_tag')) {
23
+            error('short_open_tag != On in php.ini');
24
+        }
25
+
26
+        # apcu
27
+        if (!extension_loaded('apcu')) {
28
+            error('APCu extension not loaded');
29
+        }
30
+
31
+        # Deal with dumbasses
32
+        if (isset($_REQUEST['info_hash']) && isset($_REQUEST['peer_id'])) {
33
+            error(
34
+                'd14:failure reason40:Invalid .torrent, try downloading again.e',
35
+                $NoHTML = true,
36
+                $Debug = false
37
+            );
38
+        }
39
+
40
+        return;
41
+    }
42
+
43
+    /**
44
+     * UserID checks
45
+     *
46
+     * @param array $Permissions Permission string
47
+     * @param int $UserID Defaults to $_GET['userid'] if none supplied.
48
+     * @return int $UserID The working $UserID.
49
+     */
50
+    public function checkUser($Permissions = [], $UserID = null)
51
+    {
52
+        /*
53
+        if (!$UserID) {
54
+            error('$UserID is required.');
55
+        }
56
+        */
57
+
58
+        # No Gazelle args passed
59
+        if ($_GET['userid'] && empty($UserID)) {
60
+            $UserID = $_GET['userid'];
61
+        } else {
62
+            $UserID = G::$LoggedUser['ID'];
63
+        }
64
+
65
+        # NaN
66
+        if (!is_int($UserID) && not_null($UserID)) {
67
+            error('$UserID must be an integer.');
68
+        }
69
+
70
+        # $Permissions: string fallback as in View::show_header()
71
+        if (is_string($Permissions) && !empty($Permissions)) {
72
+            $Permissions = explode(',', $Permissions);
73
+        }
74
+
75
+        # Check each permission and error out if necessary
76
+        foreach ($Permissions as $Permission) {
77
+            if (!check_perms($Permissions)) {
78
+                error(403);
79
+                break;
80
+            }
81
+        }
82
+
83
+        # If all tests pass
84
+        return (int) $UserID;
85
+    }
86
+}

+ 304
- 0
classes/sitehistory.class.php View File

@@ -0,0 +1,304 @@
1
+<?php
2
+
3
+class SiteHistory
4
+{
5
+    private static $Categories = array(1 => "Code", "Event", "Milestone", "Policy", "Release", "Staff Change");
6
+    private static $SubCategories = array(1 => "Announcement", "Blog Post", "Forum Post", "Wiki", "Other", "External Source");
7
+    private static $Tags = array(
8
+        "api",
9
+        "celebration",
10
+        "class.primary",
11
+        "class.secondary",
12
+        "collage",
13
+        "community",
14
+        "conclusion",
15
+        "contest",
16
+        "design",
17
+        "donate",
18
+        "editing",
19
+        "editorial",
20
+        "feature",
21
+        "featured.article",
22
+        "featured.album",
23
+        "featured.product",
24
+        "finances",
25
+        "format",
26
+        "forum",
27
+        "freeleech",
28
+        "freeleech.tokens",
29
+        "gazelle",
30
+        "hierarchy",
31
+        "inbox",
32
+        "infrastructure",
33
+        "interview",
34
+        "irc",
35
+        "log",
36
+        "neutral.leech",
37
+        "notifications",
38
+        "ocelot",
39
+        "paranoia",
40
+        "picks.guest",
41
+        "picks.staff",
42
+        "promotion",
43
+        "ratio",
44
+        "record",
45
+        "report",
46
+        "request",
47
+        "requirement",
48
+        "retirement",
49
+        "rippy",
50
+        "search",
51
+        "settings",
52
+        "start",
53
+        "stats",
54
+        "store",
55
+        "stylesheet",
56
+        "tagging",
57
+        "transcode",
58
+        "toolbox",
59
+        "top.10",
60
+        "torrent",
61
+        "torrent.group",
62
+        "upload",
63
+        "vanity.house",
64
+        "voting",
65
+        "whitelist",
66
+        "wiki"
67
+    );
68
+
69
+    public static function get_months()
70
+    {
71
+        $Results = G::$Cache->get_value("site_history_months");
72
+        if (!$Results) {
73
+            $QueryID = G::$DB->get_query_id();
74
+            G::$DB->query("
75
+            SELECT DISTINCT
76
+              YEAR(DATE) AS Year, MONTH(Date) AS Month, MONTHNAME(Date) AS MonthName
77
+            FROM site_history
78
+              ORDER BY Date DESC");
79
+
80
+            $Results = G::$DB->to_array();
81
+            G::$DB->set_query_id($QueryID);
82
+            G::$Cache->cache_value("site_history_months", $Results, 0);
83
+        }
84
+        return $Results;
85
+    }
86
+
87
+    public static function get_event($ID)
88
+    {
89
+        if (!empty($ID)) {
90
+            $QueryID = G::$DB->get_query_id();
91
+            G::$DB->query("
92
+            SELECT
93
+              ID, Title, Url, Category, SubCategory, Tags, Body, AddedBy, Date
94
+            FROM site_history
95
+              WHERE ID = '$ID'
96
+              ORDER BY Date DESC");
97
+
98
+            $Event = G::$DB->next_record();
99
+            G::$DB->set_query_id($QueryID);
100
+            return $Event;
101
+        }
102
+    }
103
+
104
+    public static function get_latest_events($Limit)
105
+    {
106
+        self::get_events(null, null, null, null, null, null, $Limit);
107
+    }
108
+
109
+    public static function get_events($Month, $Year, $Title, $Category, $SubCategory, $Tags, $Limit)
110
+    {
111
+        $Month = (int)$Month;
112
+        $Year = (int)$Year;
113
+        $Title = db_string($Title);
114
+        $Category = (int)$Category;
115
+        $SubCategory = (int)$SubCategory;
116
+        $Tags = db_string($Tags);
117
+        $Limit = (int)$Limit;
118
+        $Where = [];
119
+
120
+        if (!empty($Month)) {
121
+            $Where[] = " MONTH(Date) = '$Month' ";
122
+        }
123
+
124
+        if (!empty($Year)) {
125
+            $Where[] = " YEAR(Date) = '$Year' ";
126
+        }
127
+
128
+        if (!empty($Title)) {
129
+            $Where[] = " Title LIKE '%$Title%' ";
130
+        }
131
+
132
+        if (!empty($Category)) {
133
+            $Where[] = " Category = '$Category '";
134
+        }
135
+
136
+        if (!empty($SubCategory)) {
137
+            $Where[] = " SubCategory = '$SubCategory '";
138
+        }
139
+
140
+        if (!empty($Tags)) {
141
+            $Tags = explode(',', $Tags);
142
+            $Or = '(';
143
+
144
+            foreach ($Tags as $Tag) {
145
+                $Tag = trim($Tag);
146
+                $Or .= " Tags LIKE '%$Tag%' OR ";
147
+            }
148
+
149
+            if (strlen($Or) > 1) {
150
+                $Or = rtrim($Or, 'OR ');
151
+                $Or .= ')';
152
+                $Where[] = $Or;
153
+            }
154
+        }
155
+
156
+        if (!empty($Limit)) {
157
+            $Limit = " LIMIT $Limit";
158
+        } else {
159
+            $Limit = '';
160
+        }
161
+
162
+        if (count($Where) > 0) {
163
+            $Query = ' WHERE ' . implode('AND', $Where);
164
+        } else {
165
+            $Query = '';
166
+        }
167
+
168
+        $QueryID = G::$DB->get_query_id();
169
+        G::$DB->query("
170
+        SELECT
171
+          ID, Title, Url, Category, SubCategory, Tags, Body, AddedBy, Date
172
+        FROM site_history
173
+          $Query
174
+          ORDER BY Date DESC
175
+          $Limit");
176
+
177
+        $Events = G::$DB->to_array();
178
+        G::$DB->set_query_id($QueryID);
179
+        return $Events;
180
+    }
181
+
182
+    public static function add_event($Date, $Title, $Link, $Category, $SubCategory, $Tags, $Body, $UserID)
183
+    {
184
+        if (empty($Date)) {
185
+            $Date = sqltime();
186
+        } else {
187
+            list($Y, $M, $D) = explode('-', $Date);
188
+            if (!checkdate($M, $D, $Y)) {
189
+                error("Error");
190
+            }
191
+        }
192
+
193
+        $Title = db_string($Title);
194
+        $Link = db_string($Link);
195
+        $Category = (int)$Category;
196
+        $SubCategory = (int)$SubCategory;
197
+        $Tags = db_string(strtolower((preg_replace('/\s+/', '', $Tags))));
198
+        $ExplodedTags = explode(',', $Tags);
199
+
200
+        foreach ($ExplodedTags as $Tag) {
201
+            if (!in_array($Tag, self::get_tags())) {
202
+                error("Invalid tag");
203
+            }
204
+        }
205
+
206
+        $Body = db_string($Body);
207
+        $UserID = (int)$UserID;
208
+
209
+        if (empty($Title) || empty($Category) || empty($SubCategory)) {
210
+            error("Error");
211
+        }
212
+
213
+        $QueryID = G::$DB->get_query_id();
214
+        G::$DB->query("
215
+        INSERT INTO site_history
216
+          (Title, Url, Category, SubCategory, Tags, Body, AddedBy, Date)
217
+        VALUES
218
+          ('$Title', '$Link', '$Category', '$SubCategory', '$Tags', '$Body', '$UserID', '$Date')");
219
+
220
+        G::$DB->set_query_id($QueryID);
221
+        G::$Cache->delete_value("site_history_months");
222
+    }
223
+
224
+    public static function update_event($ID, $Date, $Title, $Link, $Category, $SubCategory, $Tags, $Body, $UserID)
225
+    {
226
+        if (empty($Date)) {
227
+            $Date = sqltime();
228
+        } else {
229
+            $Date = db_string($Date);
230
+            list($Y, $M, $D) = explode('-', $Date);
231
+            if (!checkdate($M, $D, $Y)) {
232
+                error("Error");
233
+            }
234
+        }
235
+
236
+        $ID = (int)$ID;
237
+        $Title = db_string($Title);
238
+        $Link = db_string($Link);
239
+        $Category = (int)$Category;
240
+        $SubCategory = (int)$SubCategory;
241
+        $Tags = db_string(strtolower((preg_replace('/\s+/', '', $Tags))));
242
+        $ExplodedTags = explode(",", $Tags);
243
+
244
+        foreach ($ExplodedTags as $Tag) {
245
+            if (!in_array($Tag, self::get_tags())) {
246
+                error("Invalid tag");
247
+            }
248
+        }
249
+
250
+        $Body = db_string($Body);
251
+        $UserID = (int)$UserID;
252
+
253
+        if (empty($ID) || empty($Title) || empty($Category) || empty($SubCategory)) {
254
+            error("Error");
255
+        }
256
+
257
+        $QueryID = G::$DB->get_query_id();
258
+        G::$DB->query("
259
+        UPDATE site_history
260
+        SET
261
+          Title = '$Title',
262
+          Url = '$Link',
263
+          Category = '$Category',
264
+          SubCategory = '$SubCategory',
265
+          Tags = '$Tags',
266
+          Body = '$Body',
267
+          AddedBy = '$UserID',
268
+          Date = '$Date'
269
+        WHERE ID = '$ID'");
270
+
271
+        G::$DB->set_query_id($QueryID);
272
+        G::$Cache->delete_value("site_history_months");
273
+    }
274
+
275
+    public static function delete_event($ID)
276
+    {
277
+        if (!is_numeric($ID)) {
278
+            error(404);
279
+        }
280
+
281
+        $QueryID = G::$DB->get_query_id();
282
+        G::$DB->query("
283
+        DELETE FROM site_history
284
+          WHERE ID = '$ID'");
285
+          
286
+        G::$DB->set_query_id($QueryID);
287
+        G::$Cache->delete_value("site_history_months");
288
+    }
289
+
290
+    public static function get_categories()
291
+    {
292
+        return self::$Categories;
293
+    }
294
+
295
+    public static function get_sub_categories()
296
+    {
297
+        return self::$SubCategories;
298
+    }
299
+
300
+    public static function get_tags()
301
+    {
302
+        return self::$Tags;
303
+    }
304
+}

+ 236
- 0
classes/sitehistoryview.class.php View File

@@ -0,0 +1,236 @@
1
+<?php
2
+
3
+class SiteHistoryView
4
+{
5
+    public static function render_linkbox()
6
+    {
7
+        if (check_perms('users_mod')
8
+      ) {
9
+            ?>
10
+<div class="linkbox">
11
+  <a href="sitehistory.php?action=edit" class="brackets">Create new event</a>
12
+</div>
13
+<?php
14
+        }
15
+    }
16
+
17
+    public static function render_events($Events)
18
+    {
19
+        $Categories = SiteHistory::get_categories();
20
+        $SubCategories = SiteHistory::get_sub_categories();
21
+        $CanEdit = check_perms('users_mod') ;
22
+        foreach ($Events as $Event) {
23
+            ?>
24
+<div class="box">
25
+  <div class="head colhead_dark">
26
+    <div class="title">
27
+      <?php if ($CanEdit) { ?>
28
+      <a class="brackets"
29
+        href="sitehistory.php?action=edit&amp;id=<?=$Event['ID']?>">Edit</a>
30
+      <?php } ?>
31
+
32
+      <?=date('F d, Y', strtotime($Event['Date'])); ?>
33
+      -
34
+      <a href="sitehistory.php?action=search&amp;category=<?=$Event['Category']?>"
35
+        class="brackets"><?=$Categories[$Event['Category']]?></a>
36
+      <a href="sitehistory.php?action=search&amp;subcategory=<?=$Event['SubCategory']?>"
37
+        class="brackets"><?=$SubCategories[$Event['SubCategory']]?></a>
38
+
39
+      <?php if (!empty($Event['Url'])) { ?>
40
+      <a href="<?=$Event['Url']?>"><?=$Event['Title']?></a>
41
+      <?php } else { ?>
42
+      <?=$Event['Title']?>
43
+      <?php } ?>
44
+    </div>
45
+    <div class="tags">
46
+      <?php self::render_tags($Event['Tags'])?>
47
+    </div>
48
+  </div>
49
+  <?php if (!empty($Event['Body'])) { ?>
50
+  <div class="body">
51
+    <?=Text::full_format($Event['Body'])?>
52
+  </div>
53
+  <?php } ?>
54
+</div>
55
+<?php
56
+        }
57
+    }
58
+
59
+    private static function render_tags($Tags)
60
+    {
61
+        $Tags = explode(',', $Tags);
62
+        natcasesort($Tags);
63
+        $FormattedTags = '';
64
+        foreach ($Tags as $Tag) {
65
+            $FormattedTags .= "<a href=\"sitehistory.php?action=search&amp;tags=$Tag\">$Tag" . "</a>, ";
66
+        }
67
+        echo rtrim($FormattedTags, ', ');
68
+    }
69
+
70
+    public static function render_search() { ?>
71
+<div class="box">
72
+  <div class="head">Search</div>
73
+  <div class="pad">
74
+    <form class="search_form" action="sitehistory.php" method="get">
75
+      <input type="hidden" name="action" value="search" />
76
+      <input type="text" id="title" name="title" size="20" placeholder="Title" />
77
+      <br /><br />
78
+      <input type="text" id="tags" name="tags" size="20" placeholder="Comma-separated tags" />
79
+      <br /><br />
80
+      <select name="category" id="category">
81
+        <option value="0">Choose a category</option>
82
+        <?php
83
+      $Categories = SiteHistory::get_categories();
84
+      foreach ($Categories as $Key => $Value) {
85
+          ?>
86
+        <option<?=$Key == $Event['Category'] ? ' selected="selected"' : ''?>
87
+          value="<?=$Key?>"><?=$Value?>
88
+          </option>
89
+          <?php
90
+      } ?>
91
+      </select>
92
+      <br /><br />
93
+      <select name="subcategory">
94
+        <option value="0">Choose a subcategory</option>
95
+        <?php
96
+      $SubCategories = SiteHistory::get_sub_categories();
97
+      foreach ($SubCategories as $Key => $Value) {
98
+          ?>
99
+        <option<?=$Key == $Event['SubCategory'] ? ' selected="selected"' : ''?>
100
+          value="<?=$Key?>"><?=$Value?>
101
+          </option>
102
+          <?php
103
+      } ?>
104
+      </select>
105
+      <br /><br />
106
+      <input value="Search" type="submit" />
107
+    </form>
108
+  </div>
109
+</div>
110
+<?php }
111
+
112
+    public static function render_edit_form($Event) { ?>
113
+<form id="event_form" method="post" action="">
114
+  <?php if ($Event) { ?>
115
+  <input type="hidden" name="action" value="take_edit" />
116
+  <input type="hidden" name="id"
117
+    value="<?=$Event['ID']?>" />
118
+  <?php } else { ?>
119
+  <input type="hidden" name="action" value="take_create" />
120
+  <?php } ?>
121
+  <input type="hidden" name="auth"
122
+    value="<?=G::$LoggedUser['AuthKey']?>" />
123
+  <table cellpadding="6" cellspacing="1" border="0" class="layout border" width="100%">
124
+    <tr>
125
+      <td class="label">Title:</td>
126
+      <td>
127
+        <input type="text" id="title" name="title" size="50" class="required"
128
+          value="<?=$Event['Title']?>" />
129
+      </td>
130
+    </tr>
131
+    <tr>
132
+      <td class="label">Link:</td>
133
+      <td>
134
+        <input type="text" id="url" name="url" size="50"
135
+          value="<?=$Event['Url']?>" />
136
+      </td>
137
+    </tr>
138
+    <tr>
139
+      <td class="label">Date:</td>
140
+      <td>
141
+        <input type="date" id="date" name="date" class="required" <?=$Event ? ' value="' . date('Y-m-d', strtotime($Event['Date'])) . '"' : ''?>
142
+        />
143
+      </td>
144
+    </tr>
145
+    <tr>
146
+      <td class="label">Category:</td>
147
+      <td>
148
+        <select id="category" name="category" class="required">
149
+          <option value="0">Choose a category</option>
150
+          <?php
151
+    $Categories = SiteHistory::get_categories();
152
+    foreach ($Categories as $Key => $Value) {
153
+        ?>
154
+          <option<?=$Key == $Event['Category'] ? ' selected="selected"' : ''?>
155
+            value="<?=$Key?>"><?=$Value?>
156
+            </option>
157
+            <?php
158
+    } ?>
159
+        </select>
160
+      </td>
161
+    </tr>
162
+    <tr>
163
+      <td class="label">Subcategory:</td>
164
+      <td>
165
+        <select id="category" name="sub_category" class="required">
166
+          <option value="0">Choose a subcategory</option>
167
+          <?php $SubCategories = SiteHistory::get_sub_categories();
168
+    foreach ($SubCategories as $Key => $Value) { ?>
169
+          <option<?=$Key == $Event['SubCategory'] ? ' selected="selected"' : ''?>
170
+            value="<?=$Key?>"><?=$Value?>
171
+            </option>
172
+            <?php } ?>
173
+        </select>
174
+      </td>
175
+    </tr>
176
+    <tr>
177
+      <td class="label">Tags:</td>
178
+      <td>
179
+        <input type="text" id="tags" name="tags" placeholder="Comma-separated tags; use periods/dots for spaces"
180
+          size="50"
181
+          value="<?=$Event['Tags']?>" />
182
+        <select id="tag_list">
183
+          <option>Choose tags</option>
184
+          <?php
185
+    $Tags = SiteHistory::get_tags();
186
+    foreach ($Tags as $Tag) {
187
+        ?>
188
+          <option><?=$Tag?>
189
+          </option>
190
+          <?php
191
+    } ?>
192
+        </select>
193
+      </td>
194
+    </tr>
195
+    <tr>
196
+      <td class="label">Body:</td>
197
+      <td>
198
+        <textarea id="body" name="body" cols="90" rows="8" tabindex="1"
199
+          onkeyup="resize('body');"><?=$Event['Body']?></textarea>
200
+      </td>
201
+    </tr>
202
+  </table>
203
+  <input type="submit" name="submit" value="Submit" />
204
+  <?php if ($Event) { ?>
205
+  <input type="submit" name="delete" value="Delete" />
206
+  <?php } ?>
207
+</form>
208
+<?php
209
+  }
210
+
211
+    public static function render_recent_sidebar($Events) { ?>
212
+<div class="box">
213
+  <div class="head colhead_dark">
214
+    <strong><a href="sitehistory.php">Latest site history</a></strong>
215
+  </div>
216
+  <ul class="stats nobullet">
217
+    <?php
218
+    $Categories = SiteHistory::get_categories();
219
+    foreach ($Events as $Event) {
220
+        ?>
221
+    <li>
222
+      <a href="sitehistory.php?action=search&amp;category=<?=$Event['Category']?>"
223
+        class="brackets"><?=$Categories[$Event['Category']]?></a>
224
+      <?php if (!empty($Event['Url'])) { ?>
225
+      <a href="<?=$Event['Url']?>"><?=Format::cut_string($Event['Title'], 20)?></a>
226
+      <?php } else { ?>
227
+      <?=Format::cut_string($Event['Title'], 20)?>
228
+      <?php } ?>
229
+    </li>
230
+    <?php
231
+    } ?>
232
+  </ul>
233
+</div>
234
+<?php
235
+  }
236
+}

+ 16
- 0
classes/slaves.class.php View File

@@ -0,0 +1,16 @@
1
+<?php
2
+
3
+class Slaves
4
+{
5
+    public static function get_level($SlaveID)
6
+    {
7
+        G::$DB->query("
8
+        SELECT u.Uploaded, u.Downloaded, u.BonusPoints, COUNT(t.UserID)
9
+        FROM users_main AS u
10
+          LEFT JOIN torrents AS t ON u.ID=t.UserID
11
+          WHERE u.ID = $SlaveID");
12
+          
13
+        list($Upload, $Download, $Points, $Uploads) = G::$DB->next_record();
14
+        return intval(((($Uploads**0.35)*1.5)+1) * max(($Upload+($Points*1000000)-$Download)/(1024**3), 1));
15
+    }
16
+};

+ 162
- 0
classes/sphinxql.class.php View File

@@ -0,0 +1,162 @@
1
+<?php
2
+
3
+if (!extension_loaded('mysqli')) {
4
+    error('Mysqli Extension not loaded.');
5
+}
6
+
7
+class Sphinxql extends mysqli
8
+{
9
+    private static $Connections = [];
10
+    private $Server;
11
+    private $Port;
12
+    private $Socket;
13
+    private $Ident;
14
+    private $Connected = false;
15
+
16
+    public static $Queries = [];
17
+    public static $Time = 0.0;
18
+
19
+    /**
20
+     * Initialize Sphinxql object
21
+     *
22
+     * @param string $Server server address or hostname
23
+     * @param int $Port listening port
24
+     * @param string $Socket Unix socket address, overrides $Server:$Port
25
+     */
26
+    public function __construct($Server, $Port, $Socket)
27
+    {
28
+        $this->Server = $Server;
29
+        $this->Port = $Port;
30
+        $this->Socket = $Socket;
31
+        $this->Ident = self::get_ident($Server, $Port, $Socket);
32
+    }
33
+
34
+    /**
35
+     * Create server ident based on connection information
36
+     *
37
+     * @param string $Server server address or hostname
38
+     * @param int $Port listening port
39
+     * @param string $Socket Unix socket address, overrides $Server:$Port
40
+     * @return identification string
41
+     */
42
+    private static function get_ident($Server, $Port, $Socket)
43
+    {
44
+        if ($Socket) {
45
+            return $Socket;
46
+        } else {
47
+            return "$Server:$Port";
48
+        }
49
+    }
50
+
51
+    /**
52
+     * Create Sphinxql object or return existing one
53
+     *
54
+     * @param string $Server server address or hostname
55
+     * @param int $Port listening port
56
+     * @param string $Socket Unix socket address, overrides $Server:$Port
57
+     * @return Sphinxql object
58
+     */
59
+    public static function init_connection($Server, $Port, $Socket)
60
+    {
61
+        $Ident = self::get_ident($Server, $Port, $Socket);
62
+        if (!isset(self::$Connections[$Ident])) {
63
+            self::$Connections[$Ident] = new Sphinxql($Server, $Port, $Socket);
64
+        }
65
+        return self::$Connections[$Ident];
66
+    }
67
+
68
+    /**
69
+     * Connect the Sphinxql object to the Sphinx server
70
+     */
71
+    public function sph_connect()
72
+    {
73
+        if ($this->Connected || $this->connect_errno) {
74
+            return;
75
+        }
76
+
77
+        global $Debug;
78
+        $Debug->set_flag("Connecting to Sphinx server $this->Ident");
79
+
80
+        for ($Attempt = 0; $Attempt < 3; $Attempt++) {
81
+            parent::__construct($this->Server, '', '', '', $this->Port, $this->Socket);
82
+            if (!$this->connect_errno) {
83
+                $this->Connected = true;
84
+                break;
85
+            }
86
+            sleep(1);
87
+        }
88
+
89
+        if ($this->connect_errno) {
90
+            $Errno = $this->connect_errno;
91
+            $Error = $this->connect_error;
92
+            $this->error("Connection failed. (".strval($Errno).": ".strval($Error).")");
93
+            $Debug->set_flag("Could not connect to Sphinx server $this->Ident. (".strval($Errno).": ".strval($Error).")");
94
+        } else {
95
+            $Debug->set_flag("Connected to Sphinx server $this->Ident");
96
+        }
97
+    }
98
+
99
+    /**
100
+     * Print a message to privileged users and optionally halt page processing
101
+     *
102
+     * @param string $Msg message to display
103
+     * @param bool $Halt halt page processing. Default is to continue processing the page
104
+     * @return Sphinxql object
105
+     */
106
+    public function error($Msg, $Halt = false)
107
+    {
108
+        global $Debug;
109
+        $ErrorMsg = 'SphinxQL ('.$this->Ident.'): '.strval($Msg);
110
+        $Debug->analysis('SphinxQL Error', $ErrorMsg, 3600*24);
111
+
112
+        if ($Halt === true && (DEBUG_MODE || check_perms('site_debug'))) {
113
+            echo '<pre>'.display_str($ErrorMsg).'</pre>';
114
+            error();
115
+        } elseif ($Halt === true) {
116
+            error(-1);
117
+        }
118
+    }
119
+
120
+    /**
121
+     * Escape special characters before sending them to the Sphinx server.
122
+     * Two escapes needed because the first one is eaten up by the mysql driver.
123
+     *
124
+     * @param string $String string to escape
125
+     * @return escaped string
126
+     */
127
+    public static function sph_escape_string($String)
128
+    {
129
+        return strtr(
130
+            strtolower($String),
131
+            array(
132
+            '('=>'\\\\(',
133
+            ')'=>'\\\\)',
134
+            '|'=>'\\\\|',
135
+            '-'=>'\\\\-',
136
+            '@'=>'\\\\@',
137
+            '~'=>'\\\\~',
138
+            '&'=>'\\\\&',
139
+            '\''=>'\\\'',
140
+            '<'=>'\\\\<',
141
+            '!'=>'\\\\!',
142
+            '"'=>'\\\\"',
143
+            '/'=>'\\\\/',
144
+            '*'=>'\\\\*',
145
+            '$'=>'\\\\$',
146
+            '^'=>'\\\\^',
147
+            '\\'=>'\\\\\\\\')
148
+        );
149
+    }
150
+
151
+    /**
152
+     * Register sent queries globally for later retrieval by debug functions
153
+     *
154
+     * @param string $QueryString query text
155
+     * @param param $QueryProcessTime time building and processing the query
156
+     */
157
+    public static function register_query($QueryString, $QueryProcessTime)
158
+    {
159
+        self::$Queries[] = array($QueryString, $QueryProcessTime);
160
+        self::$Time += $QueryProcessTime;
161
+    }
162
+}

+ 410
- 0
classes/sphinxqlquery.class.php View File

@@ -0,0 +1,410 @@
1
+<?php
2
+
3
+class SphinxqlQuery
4
+{
5
+    private $Sphinxql;
6
+    private $Errors;
7
+    private $Expressions;
8
+    private $Filters;
9
+    private $GroupBy;
10
+    private $Indexes;
11
+    private $Limits;
12
+    private $Options;
13
+    private $QueryString;
14
+    private $Select;
15
+    private $SortBy;
16
+    private $SortGroupBy;
17
+
18
+    /**
19
+     * Initialize Sphinxql object
20
+     *
21
+     * @param string $Server server address or hostname
22
+     * @param int $Port listening port
23
+     * @param string $Socket Unix socket address, overrides $Server:$Port
24
+     */
25
+    public function __construct($Server = SPHINXQL_HOST, $Port = SPHINXQL_PORT, $Socket = SPHINXQL_SOCK)
26
+    {
27
+        $this->Sphinxql = Sphinxql::init_connection($Server, $Port, $Socket);
28
+        $this->reset();
29
+    }
30
+
31
+    /**
32
+     * Specify what data the Sphinx query is supposed to return
33
+     *
34
+     * @param string $Fields Attributes and expressions
35
+     * @return current Sphinxql query object
36
+     */
37
+    public function select($Fields)
38
+    {
39
+        $this->Select = $Fields;
40
+        return $this;
41
+    }
42
+
43
+    /**
44
+     * Specify the indexes to use in the search
45
+     *
46
+     * @param string $Indexes comma-separated list of indexes
47
+     * @return current Sphinxql query object
48
+     */
49
+    public function from($Indexes)
50
+    {
51
+        $this->Indexes = $Indexes;
52
+        return $this;
53
+    }
54
+
55
+    /**
56
+     * Add attribute filter. Calling multiple filter functions results in boolean AND between each condition.
57
+     *
58
+     * @param string $Attribute attribute which the filter will apply to
59
+     * @param mixed $Values scalar or array of numerical values. Array uses boolean OR in query condition
60
+     * @param bool $Exclude whether to exclude or include matching documents. Default mode is to include matches
61
+     * @return current Sphinxql query object
62
+     */
63
+    public function where($Attribute, $Values, $Exclude = false)
64
+    {
65
+        if (empty($Attribute) || !isset($Values)) {
66
+            $this->error("Attribute name and filter value are required.");
67
+            return $this;
68
+        }
69
+        $Filters = [];
70
+        if (is_array($Values)) {
71
+            foreach ($Values as $Value) {
72
+                if (!is_number($Value)) {
73
+                    $this->error("Filters only support numeric values.");
74
+                    return $this;
75
+                }
76
+            }
77
+            if ($Exclude) {
78
+                $Filters[] = "$Attribute NOT IN (".implode(",", $Values).")";
79
+            } else {
80
+                $Filters[] = "$Attribute IN (".implode(",", $Values).")";
81
+            }
82
+        } else {
83
+            if (!is_number($Values)) {
84
+                $this->error("Filters only support numeric values.");
85
+                return $this;
86
+            }
87
+            if ($Exclude) {
88
+                $Filters[] = "$Attribute != $Values";
89
+            } else {
90
+                $Filters[] = "$Attribute = $Values";
91
+            }
92
+        }
93
+        $this->Filters[] = implode(" AND ", $Filters);
94
+        return $this;
95
+    }
96
+
97
+    /**
98
+     * Add attribute less-than filter. Calling multiple filter functions results in boolean AND between each condition.
99
+     *
100
+     * @param string $Attribute attribute which the filter will apply to
101
+     * @param array $Value upper limit for matches
102
+     * @param bool $Inclusive whether to use <= or <
103
+     * @return current Sphinxql query object
104
+     */
105
+    public function where_lt($Attribute, $Value, $Inclusive = false)
106
+    {
107
+        if (empty($Attribute) || !isset($Value) || !is_number($Value)) {
108
+            $this->error("Attribute name is required and only numeric filters are supported.");
109
+            return $this;
110
+        }
111
+        $this->Filters[] = $Inclusive ? "$Attribute <= $Value" : "$Attribute < $Value";
112
+        return $this;
113
+    }
114
+
115
+    /**
116
+     * Add attribute greater-than filter. Calling multiple filter functions results in boolean AND between each condition.
117
+     *
118
+     * @param string $Attribute attribute which the filter will apply to
119
+     * @param array $Value lower limit for matches
120
+     * @param bool $Inclusive whether to use >= or >
121
+     * @return current Sphinxql query object
122
+     */
123
+    public function where_gt($Attribute, $Value, $Inclusive = false)
124
+    {
125
+        if (empty($Attribute) || !isset($Value) || !is_number($Value)) {
126
+            $this->error("Attribute name is required and only numeric filters are supported.");
127
+            return $this;
128
+        }
129
+        $this->Filters[] = $Inclusive ? "$Attribute >= $Value" : "$Attribute > $Value";
130
+        return $this;
131
+    }
132
+
133
+    /**
134
+     * Add attribute range filter. Calling multiple filter functions results in boolean AND between each condition.
135
+     *
136
+     * @param string $Attribute attribute which the filter will apply to
137
+     * @param array $Values pair of numerical values that defines the filter range
138
+     * @return current Sphinxql query object
139
+     */
140
+    public function where_between($Attribute, $Values)
141
+    {
142
+        if (empty($Attribute) || empty($Values) || count($Values) != 2 || !is_number($Values[0]) || !is_number($Values[1])) {
143
+            $this->error("Filter range requires array of two numerical boundaries as values.");
144
+            return $this;
145
+        }
146
+        $this->Filters[] = "$Attribute BETWEEN $Values[0] AND $Values[1]";
147
+        return $this;
148
+    }
149
+
150
+    /**
151
+     * Add fulltext query expression. Calling multiple filter functions results in boolean AND between each condition.
152
+     * Query expression is escaped automatically
153
+     *
154
+     * @param string $Expr query expression
155
+     * @param string $Field field to match $Expr against. Default is *, which means all available fields
156
+     * @return current Sphinxql query object
157
+     */
158
+    public function where_match($Expr, $Field = '*', $Escape = true)
159
+    {
160
+        if (empty($Expr)) {
161
+            return $this;
162
+        }
163
+        if ($Field !== false) {
164
+            $Field = "@$Field ";
165
+        }
166
+        if ($Escape === true) {
167
+            $this->Expressions[] = "$Field".Sphinxql::sph_escape_string($Expr);
168
+        } else {
169
+            $this->Expressions[] = $Field.$Expr;
170
+        }
171
+        return $this;
172
+    }
173
+
174
+    /**
175
+     * Specify the order of the matches. Calling this function multiple times sets secondary priorities
176
+     *
177
+     * @param string $Attribute attribute to use for sorting.
178
+     *     Passing an empty attribute value will clear the current sort settings
179
+     * @param string $Mode sort method to apply to the selected attribute
180
+     * @return current Sphinxql query object
181
+     */
182
+    public function order_by($Attribute = false, $Mode = false)
183
+    {
184
+        if (empty($Attribute)) {
185
+            $this->SortBy = [];
186
+        } else {
187
+            $this->SortBy[] = "$Attribute $Mode";
188
+        }
189
+        return $this;
190
+    }
191
+
192
+    /**
193
+     * Specify how the results are grouped
194
+     *
195
+     * @param string $Attribute group matches with the same $Attribute value.
196
+     *     Passing an empty attribute value will clear the current group settings
197
+     * @return current Sphinxql query object
198
+     */
199
+    public function group_by($Attribute = false)
200
+    {
201
+        if (empty($Attribute)) {
202
+            $this->GroupBy = '';
203
+        } else {
204
+            $this->GroupBy = $Attribute;
205
+        }
206
+        return $this;
207
+    }
208
+
209
+    /**
210
+     * Specify the order of the results within groups
211
+     *
212
+     * @param string $Attribute attribute to use for sorting.
213
+     *     Passing an empty attribute will clear the current group sort settings
214
+     * @param string $Mode sort method to apply to the selected attribute
215
+     * @return current Sphinxql query object
216
+     */
217
+    public function order_group_by($Attribute = false, $Mode = false)
218
+    {
219
+        if (empty($Attribute)) {
220
+            $this->SortGroupBy = '';
221
+        } else {
222
+            $this->SortGroupBy = "$Attribute $Mode";
223
+        }
224
+        return $this;
225
+    }
226
+
227
+    /**
228
+     * Specify the offset and amount of matches to return
229
+     *
230
+     * @param int $Offset number of matches to discard
231
+     * @param int $Limit number of matches to return
232
+     * @param int $MaxMatches number of results to store in the Sphinx server's memory. Must be >= ($Offset+$Limit)
233
+     * @return current Sphinxql query object
234
+     */
235
+    public function limit($Offset, $Limit, $MaxMatches = SPHINX_MAX_MATCHES)
236
+    {
237
+        $this->Limits = "$Offset, $Limit";
238
+        $this->set('max_matches', $MaxMatches);
239
+        return $this;
240
+    }
241
+
242
+    /**
243
+     * Tweak the settings to use for the query. Sanity checking shouldn't be needed as Sphinx already does it
244
+     *
245
+     * @param string $Name setting name
246
+     * @param mixed $Value value
247
+     * @return current Sphinxql query object
248
+     */
249
+    public function set($Name, $Value)
250
+    {
251
+        $this->Options[$Name] = $Value;
252
+        return $this;
253
+    }
254
+
255
+    /**
256
+     * Combine the query options into a valid Sphinx query segment
257
+     *
258
+     * @return string of options
259
+     */
260
+    private function build_options()
261
+    {
262
+        $Options = [];
263
+        foreach ($this->Options as $Option => $Value) {
264
+            $Options[] = "$Option = $Value";
265
+        }
266
+        return implode(', ', $Options);
267
+    }
268
+
269
+    /**
270
+     * Combine the query conditions into a valid Sphinx query segment
271
+     */
272
+    private function build_query()
273
+    {
274
+        if (!$this->Indexes) {
275
+            $this->error('Index name is required.');
276
+            return false;
277
+        }
278
+        $this->QueryString = "SELECT $this->Select\nFROM $this->Indexes";
279
+        if (!empty($this->Expressions)) {
280
+            $this->Filters['expr'] = "MATCH('".implode(' ', $this->Expressions)."')";
281
+        }
282
+        if (!empty($this->Filters)) {
283
+            $this->QueryString .= "\nWHERE ".implode("\n\tAND ", $this->Filters);
284
+        }
285
+        if (!empty($this->GroupBy)) {
286
+            $this->QueryString .= "\nGROUP BY $this->GroupBy";
287
+        }
288
+        if (!empty($this->SortGroupBy)) {
289
+            $this->QueryString .= "\nWITHIN GROUP ORDER BY $this->SortGroupBy";
290
+        }
291
+        if (!empty($this->SortBy)) {
292
+            $this->QueryString .= "\nORDER BY ".implode(", ", $this->SortBy);
293
+        }
294
+        if (!empty($this->Limits)) {
295
+            $this->QueryString .= "\nLIMIT $this->Limits";
296
+        }
297
+        if (!empty($this->Options)) {
298
+            $Options = $this->build_options();
299
+            $this->QueryString .= "\nOPTION $Options";
300
+        }
301
+    }
302
+
303
+    /**
304
+     * Construct and send the query. Register the query in the global Sphinxql object
305
+     *
306
+     * @param bool GetMeta whether to fetch meta data for the executed query. Default is yes
307
+     * @return Sphinxql result object
308
+     */
309
+    public function query($GetMeta = true)
310
+    {
311
+        $QueryStartTime = microtime(true);
312
+        $this->build_query();
313
+        if (count($this->Errors) > 0) {
314
+            $ErrorMsg = implode("\n", $this->Errors);
315
+            $this->Sphinxql->error("Query builder found errors:\n$ErrorMsg");
316
+            return new SphinxqlResult(null, null, 1, $ErrorMsg);
317
+        }
318
+        $QueryString = $this->QueryString;
319
+        $Result = $this->send_query($GetMeta);
320
+        $QueryProcessTime = (microtime(true) - $QueryStartTime)*1000;
321
+        Sphinxql::register_query($QueryString, $QueryProcessTime);
322
+        return $Result;
323
+    }
324
+
325
+    /**
326
+     * Run a manually constructed query
327
+     *
328
+     * @param string Query query expression
329
+     * @param bool GetMeta whether to fetch meta data for the executed query. Default is yes
330
+     * @return Sphinxql result object
331
+     */
332
+    public function raw_query($Query, $GetMeta = true)
333
+    {
334
+        $this->QueryString = $Query;
335
+        return $this->send_query($GetMeta);
336
+    }
337
+
338
+    /**
339
+     * Run a pre-processed query. Only used internally
340
+     *
341
+     * @param bool GetMeta whether to fetch meta data for the executed query
342
+     * @return Sphinxql result object
343
+     */
344
+    private function send_query($GetMeta)
345
+    {
346
+        if (!$this->QueryString) {
347
+            return false;
348
+        }
349
+        $this->Sphinxql->sph_connect();
350
+        $Result = $this->Sphinxql->query($this->QueryString);
351
+        if ($Result === false) {
352
+            $Errno = $this->Sphinxql->errno;
353
+            $Error = $this->Sphinxql->error;
354
+            $this->Sphinxql->error("Query returned error $Errno ($Error).\n$this->QueryString");
355
+            $Meta = null;
356
+        } else {
357
+            $Errno = 0;
358
+            $Error = '';
359
+            $Meta = $GetMeta ? $this->get_meta() : null;
360
+        }
361
+        return new SphinxqlResult($Result, $Meta, $Errno, $Error);
362
+    }
363
+
364
+    /**
365
+     * Reset all query options and conditions
366
+     */
367
+    public function reset()
368
+    {
369
+        $this->Errors = [];
370
+        $this->Expressions = [];
371
+        $this->Filters = [];
372
+        $this->GroupBy = '';
373
+        $this->Indexes = '';
374
+        $this->Limits = [];
375
+        $this->Options = array('ranker' => 'none');
376
+        $this->QueryString = '';
377
+        $this->Select = '*';
378
+        $this->SortBy = [];
379
+        $this->SortGroupBy = '';
380
+    }
381
+
382
+    /**
383
+     * Fetch and store meta data for the last executed query
384
+     *
385
+     * @return meta data
386
+     */
387
+    private function get_meta()
388
+    {
389
+        return $this->raw_query("SHOW META", false)->to_pair(0, 1);
390
+    }
391
+
392
+    /**
393
+     * Copy attribute filters from another SphinxqlQuery object
394
+     *
395
+     * @param SphinxqlQuery $SphQLSource object to copy the filters from
396
+     * @return current SphinxqlQuery object
397
+     */
398
+    public function copy_attributes_from($SphQLSource)
399
+    {
400
+        $this->Filters = $SphQLSource->Filters;
401
+    }
402
+
403
+    /**
404
+     * Store error messages
405
+     */
406
+    private function error($Msg)
407
+    {
408
+        $this->Errors[] = $Msg;
409
+    }
410
+}

+ 156
- 0
classes/sphinxqlresult.class.php View File

@@ -0,0 +1,156 @@
1
+<?php
2
+
3
+class SphinxqlResult
4
+{
5
+    private $Result;
6
+    private $Meta;
7
+    public $Errno;
8
+    public $Error;
9
+
10
+    /**
11
+     * Create Sphinxql result object
12
+     *
13
+     * @param mysqli_result $Result query results
14
+     * @param array $Meta meta data for the query
15
+     * @param int $Errno error code returned by the query upon failure
16
+     * @param string $Error error message returned by the query upon failure
17
+     */
18
+    public function __construct($Result, $Meta, $Errno, $Error)
19
+    {
20
+        $this->Result = $Result;
21
+        $this->Meta = $Meta;
22
+        $this->Errno = $Errno;
23
+        $this->Error = $Error;
24
+    }
25
+
26
+    /**
27
+     * Redirect to the Mysqli result object if a nonexistent method is called
28
+     *
29
+     * @param string $Name method name
30
+     * @param array $Arguments arguments used in the function call
31
+     * @return whatever the parent function returns
32
+     */
33
+    public function __call($Name, $Arguments)
34
+    {
35
+        return call_user_func_array(array($this->Result, $Name), $Arguments);
36
+    }
37
+
38
+    /**
39
+     * Did the query find anything?
40
+     *
41
+     * @return bool results were found
42
+     */
43
+    public function has_results()
44
+    {
45
+        return $this->get_meta('total') > 0;
46
+    }
47
+
48
+    /**
49
+     * Collect and return the specified key of all results as a list
50
+     *
51
+     * @param string $Key key containing the desired data
52
+     * @return array with the $Key value of all results
53
+     */
54
+    public function collect($Key)
55
+    {
56
+        $Return = [];
57
+        while ($Row = $this->fetch_array()) {
58
+            $Return[] = $Row[$Key];
59
+        }
60
+
61
+        $this->data_seek(0);
62
+        return $Return;
63
+    }
64
+
65
+    /**
66
+     * Collect and return all available data for the matches optionally indexed by a specified key
67
+     *
68
+     * @param string $Key key to use as indexing value
69
+     * @param string $ResultType method to use when fetching data from the mysqli_result object. Default is MYSQLI_ASSOC
70
+     * @return array with all available data for the matches
71
+     */
72
+    public function to_array($Key, $ResultType = MYSQLI_ASSOC)
73
+    {
74
+        $Return = [];
75
+        while ($Row = $this->fetch_array($ResultType)) {
76
+            if ($Key !== false) {
77
+                $Return[$Row[$Key]] = $Row;
78
+            } else {
79
+                $Return[] = $Row;
80
+            }
81
+        }
82
+
83
+        $this->data_seek(0);
84
+        return $Return;
85
+    }
86
+
87
+    /**
88
+     * Collect pairs of keys for all matches
89
+     *
90
+     * @param string $Key1 key to use as indexing value
91
+     * @param string $Key2 key to use as value
92
+     * @return array with $Key1 => $Key2 pairs for matches
93
+     */
94
+    public function to_pair($Key1, $Key2)
95
+    {
96
+        $Return = [];
97
+        while ($Row = $this->fetch_array()) {
98
+            $Return[$Row[$Key1]] = $Row[$Key2];
99
+        }
100
+        
101
+        $this->data_seek(0);
102
+        return $Return;
103
+    }
104
+
105
+    /**
106
+     * Return specified portions of the current Sphinxql result object's meta data
107
+     *
108
+     * @param mixed $Keys scalar or array with keys to return. Default is false, which returns all meta data
109
+     * @return array with meta data
110
+     */
111
+    public function get_meta($Keys = false)
112
+    {
113
+        if ($Keys !== false) {
114
+            if (is_array($Keys)) {
115
+                $Return = [];
116
+                foreach ($Keys as $Key) {
117
+                    if (!isset($this->Meta[$Key])) {
118
+                        continue;
119
+                    }
120
+                    $Return[$Key] = $this->Meta[$Key];
121
+                }
122
+                return $Return;
123
+            } else {
124
+                return isset($this->Meta[$Keys]) ? $this->Meta[$Keys] : false;
125
+            }
126
+        } else {
127
+            return $this->Meta;
128
+        }
129
+    }
130
+
131
+    /**
132
+     * Return specified portions of the current Mysqli result object's information
133
+     *
134
+     * @param mixed $Keys scalar or array with keys to return. Default is false, which returns all available information
135
+     * @return array with result information
136
+     */
137
+    public function get_result_info($Keys = false)
138
+    {
139
+        if ($Keys !== false) {
140
+            if (is_array($Keys)) {
141
+                $Return = [];
142
+                foreach ($Keys as $Key) {
143
+                    if (!isset($this->Result->$Key)) {
144
+                        continue;
145
+                    }
146
+                    $Return[$Key] = $this->Result->$Key;
147
+                }
148
+                return $Return;
149
+            } else {
150
+                return isset($this->Result->$Keys) ? $this->Result->$Keys : false;
151
+            }
152
+        } else {
153
+            return $this->Result;
154
+        }
155
+    }
156
+}

+ 422
- 0
classes/subscriptions.class.php View File

@@ -0,0 +1,422 @@
1
+<?php
2
+
3
+class Subscriptions
4
+{
5
+    /**
6
+     * Parse a post/comment body for quotes and notify all quoted users that have quote notifications enabled.
7
+     * @param string $Body
8
+     * @param int $PostID
9
+     * @param string $Page
10
+     * @param int $PageID
11
+     */
12
+    public static function quote_notify($Body, $PostID, $Page, $PageID)
13
+    {
14
+        $QueryID = G::$DB->get_query_id();
15
+        /*
16
+         * Explanation of the parameters PageID and Page: Page contains where
17
+         * this quote comes from and can be forums, artist, collages, requests
18
+         * or torrents. The PageID contains the additional value that is
19
+         * necessary for the users_notify_quoted table. The PageIDs for the
20
+         * different Page are: forums: TopicID artist: ArtistID collages:
21
+         * CollageID requests: RequestID torrents: GroupID
22
+         */
23
+        $Matches = [];
24
+        preg_match_all('/\[quote(?:=(.*)(?:\|.*)?)?]|\[\/quote]/iU', $Body, $Matches, PREG_SET_ORDER);
25
+
26
+        if (count($Matches)) {
27
+            $Usernames = [];
28
+            $Level = 0;
29
+            foreach ($Matches as $M) {
30
+                if ($M[0] != '[/quote]') {
31
+                    if ($Level == 0 && isset($M[1]) && strlen($M[1]) > 0 && preg_match(USERNAME_REGEX, $M[1])) {
32
+                        $Usernames[] = preg_replace('/(^[.,]*)|([.,]*$)/', '', $M[1]); // wut?
33
+                    }
34
+                    ++$Level;
35
+                } else {
36
+                    --$Level;
37
+                }
38
+            }
39
+        }
40
+        // remove any dupes in the array (the fast way)
41
+        $Usernames = array_flip(array_flip($Usernames));
42
+
43
+        G::$DB->query("
44
+      SELECT m.ID
45
+      FROM users_main AS m
46
+        LEFT JOIN users_info AS i ON i.UserID = m.ID
47
+      WHERE m.Username IN ('" . implode("', '", $Usernames) . "')
48
+        AND i.NotifyOnQuote = '1'
49
+        AND i.UserID != " . G::$LoggedUser['ID']);
50
+
51
+        $Results = G::$DB->to_array();
52
+        foreach ($Results as $Result) {
53
+            $UserID = db_string($Result['ID']);
54
+            $QuoterID = db_string(G::$LoggedUser['ID']);
55
+            $Page = db_string($Page);
56
+            $PageID = db_string($PageID);
57
+            $PostID = db_string($PostID);
58
+
59
+            G::$DB->query(
60
+                "
61
+        INSERT IGNORE INTO users_notify_quoted
62
+          (UserID, QuoterID, Page, PageID, PostID, Date)
63
+        VALUES
64
+          (    ?,               ?,               ?,      ?,       ?,   NOW())",
65
+                $Result['ID'],
66
+                G::$LoggedUser['ID'],
67
+                $Page,
68
+                $PageID,
69
+                $PostID
70
+            );
71
+            G::$Cache->delete_value("notify_quoted_$UserID");
72
+            if ($Page == 'forums') {
73
+                $URL = site_url() . "forums.php?action=viewthread&postid=$PostID";
74
+            } else {
75
+                $URL = site_url() . "comments.php?action=jump&postid=$PostID";
76
+            }
77
+        }
78
+        G::$DB->set_query_id($QueryID);
79
+    }
80
+
81
+    /**
82
+     * (Un)subscribe from a forum thread.
83
+     * If UserID == 0, G::$LoggedUser[ID] is used
84
+     * @param int $TopicID
85
+     * @param int $UserID
86
+     */
87
+    public static function subscribe($TopicID, $UserID = 0)
88
+    {
89
+        if ($UserID == 0) {
90
+            $UserID = G::$LoggedUser['ID'];
91
+        }
92
+        $QueryID = G::$DB->get_query_id();
93
+        $UserSubscriptions = self::get_subscriptions();
94
+        $Key = self::has_subscribed($TopicID);
95
+        if ($Key !== false) {
96
+            G::$DB->query('
97
+        DELETE FROM users_subscriptions
98
+        WHERE UserID = ' . db_string($UserID) . '
99
+          AND TopicID = ' . db_string($TopicID));
100
+            unset($UserSubscriptions[$Key]);
101
+        } else {
102
+            G::$DB->query("
103
+        INSERT IGNORE INTO users_subscriptions (UserID, TopicID)
104
+        VALUES ($UserID, " . db_string($TopicID) . ")");
105
+            array_push($UserSubscriptions, $TopicID);
106
+        }
107
+        G::$Cache->replace_value("subscriptions_user_$UserID", $UserSubscriptions, 0);
108
+        G::$Cache->delete_value("subscriptions_user_new_$UserID");
109
+        G::$DB->set_query_id($QueryID);
110
+    }
111
+
112
+    /**
113
+     * (Un)subscribe from comments.
114
+     * If UserID == 0, G::$LoggedUser[ID] is used
115
+     * @param string $Page 'artist', 'collages', 'requests' or 'torrents'
116
+     * @param int $PageID ArtistID, CollageID, RequestID or GroupID
117
+     * @param int $UserID
118
+     */
119
+    public static function subscribe_comments($Page, $PageID, $UserID = 0)
120
+    {
121
+        if ($UserID == 0) {
122
+            $UserID = G::$LoggedUser['ID'];
123
+        }
124
+        $QueryID = G::$DB->get_query_id();
125
+        $UserCommentSubscriptions = self::get_comment_subscriptions();
126
+        $Key = self::has_subscribed_comments($Page, $PageID);
127
+        if ($Key !== false) {
128
+            G::$DB->query("
129
+        DELETE FROM users_subscriptions_comments
130
+        WHERE UserID = " . db_string($UserID) . "
131
+          AND Page = '" . db_string($Page) . "'
132
+          AND PageID = " . db_string($PageID));
133
+            unset($UserCommentSubscriptions[$Key]);
134
+        } else {
135
+            G::$DB->query("
136
+        INSERT IGNORE INTO users_subscriptions_comments
137
+          (UserID, Page, PageID)
138
+        VALUES
139
+          ($UserID, '" . db_string($Page) . "', " . db_string($PageID) . ")");
140
+            array_push($UserCommentSubscriptions, array($Page, $PageID));
141
+        }
142
+        G::$Cache->replace_value("subscriptions_comments_user_$UserID", $UserCommentSubscriptions, 0);
143
+        G::$Cache->delete_value("subscriptions_comments_user_new_$UserID");
144
+        G::$DB->set_query_id($QueryID);
145
+    }
146
+
147
+    /**
148
+     * Read $UserID's subscriptions. If the cache key isn't set, it gets filled.
149
+     * If UserID == 0, G::$LoggedUser[ID] is used
150
+     * @param int $UserID
151
+     * @return array Array of TopicIDs
152
+     */
153
+    public static function get_subscriptions($UserID = 0)
154
+    {
155
+        if ($UserID == 0) {
156
+            $UserID = G::$LoggedUser['ID'];
157
+        }
158
+        $QueryID = G::$DB->get_query_id();
159
+        $UserSubscriptions = G::$Cache->get_value("subscriptions_user_$UserID");
160
+        if ($UserSubscriptions === false) {
161
+            G::$DB->query('
162
+        SELECT TopicID
163
+        FROM users_subscriptions
164
+        WHERE UserID = ' . db_string($UserID));
165
+            $UserSubscriptions = G::$DB->collect(0);
166
+            G::$Cache->cache_value("subscriptions_user_$UserID", $UserSubscriptions, 0);
167
+        }
168
+        G::$DB->set_query_id($QueryID);
169
+        return $UserSubscriptions;
170
+    }
171
+
172
+    /**
173
+     * Same as self::get_subscriptions, but for comment subscriptions
174
+     * @param int $UserID
175
+     * @return array Array of ($Page, $PageID)
176
+     */
177
+    public static function get_comment_subscriptions($UserID = 0)
178
+    {
179
+        if ($UserID == 0) {
180
+            $UserID = G::$LoggedUser['ID'];
181
+        }
182
+        $QueryID = G::$DB->get_query_id();
183
+        $UserCommentSubscriptions = G::$Cache->get_value("subscriptions_comments_user_$UserID");
184
+        if ($UserCommentSubscriptions === false) {
185
+            G::$DB->query('
186
+        SELECT Page, PageID
187
+        FROM users_subscriptions_comments
188
+        WHERE UserID = ' . db_string($UserID));
189
+            $UserCommentSubscriptions = G::$DB->to_array(false, MYSQLI_NUM);
190
+            G::$Cache->cache_value("subscriptions_comments_user_$UserID", $UserCommentSubscriptions, 0);
191
+        }
192
+        G::$DB->set_query_id($QueryID);
193
+        return $UserCommentSubscriptions;
194
+    }
195
+
196
+    /**
197
+     * Returns whether or not the current user has new subscriptions. This handles both forum and comment subscriptions.
198
+     * @return int Number of unread subscribed threads/comments
199
+     */
200
+    public static function has_new_subscriptions()
201
+    {
202
+        $QueryID = G::$DB->get_query_id();
203
+
204
+        $NewSubscriptions = G::$Cache->get_value('subscriptions_user_new_' . G::$LoggedUser['ID']);
205
+        if ($NewSubscriptions === false) {
206
+            // forum subscriptions
207
+            G::$DB->query("
208
+          SELECT COUNT(1)
209
+          FROM users_subscriptions AS s
210
+            LEFT JOIN forums_last_read_topics AS l ON l.UserID = s.UserID AND l.TopicID = s.TopicID
211
+            JOIN forums_topics AS t ON t.ID = s.TopicID
212
+            JOIN forums AS f ON f.ID = t.ForumID
213
+          WHERE " . Forums::user_forums_sql() . "
214
+            AND IF(t.IsLocked = '1' AND t.IsSticky = '0'" . ", t.LastPostID, IF(l.PostID IS NULL, 0, l.PostID)) < t.LastPostID
215
+            AND s.UserID = " . G::$LoggedUser['ID']);
216
+            list($NewForumSubscriptions) = G::$DB->next_record();
217
+
218
+            // comment subscriptions
219
+            G::$DB->query("
220
+          SELECT COUNT(1)
221
+          FROM users_subscriptions_comments AS s
222
+            LEFT JOIN users_comments_last_read AS lr ON lr.UserID = s.UserID AND lr.Page = s.Page AND lr.PageID = s.PageID
223
+            LEFT JOIN comments AS c ON c.ID = (SELECT MAX(ID) FROM comments WHERE Page = s.Page AND PageID = s.PageID)
224
+            LEFT JOIN collages AS co ON s.Page = 'collages' AND co.ID = s.PageID
225
+          WHERE s.UserID = " . G::$LoggedUser['ID'] . "
226
+            AND (s.Page != 'collages' OR co.Deleted = '0')
227
+            AND IF(lr.PostID IS NULL, 0, lr.PostID) < c.ID");
228
+            list($NewCommentSubscriptions) = G::$DB->next_record();
229
+
230
+            $NewSubscriptions = $NewForumSubscriptions + $NewCommentSubscriptions;
231
+            G::$Cache->cache_value('subscriptions_user_new_' . G::$LoggedUser['ID'], $NewSubscriptions, 0);
232
+        }
233
+        G::$DB->set_query_id($QueryID);
234
+        return (int)$NewSubscriptions;
235
+    }
236
+
237
+    /**
238
+     * Returns whether or not the current user has new quote notifications.
239
+     * @return int Number of unread quote notifications
240
+     */
241
+    public static function has_new_quote_notifications()
242
+    {
243
+        $QuoteNotificationsCount = G::$Cache->get_value('notify_quoted_' . G::$LoggedUser['ID']);
244
+        if ($QuoteNotificationsCount === false) {
245
+            $sql = "
246
+        SELECT COUNT(1)
247
+        FROM users_notify_quoted AS q
248
+          LEFT JOIN forums_topics AS t ON t.ID = q.PageID
249
+          LEFT JOIN forums AS f ON f.ID = t.ForumID
250
+          LEFT JOIN collages AS c ON q.Page = 'collages' AND c.ID = q.PageID
251
+        WHERE q.UserID = " . G::$LoggedUser['ID'] . "
252
+          AND q.UnRead
253
+          AND (q.Page != 'forums' OR " . Forums::user_forums_sql() . ")
254
+          AND (q.Page != 'collages' OR c.Deleted = '0')";
255
+            $QueryID = G::$DB->get_query_id();
256
+            G::$DB->query($sql);
257
+            list($QuoteNotificationsCount) = G::$DB->next_record();
258
+            G::$DB->set_query_id($QueryID);
259
+            G::$Cache->cache_value('notify_quoted_' . G::$LoggedUser['ID'], $QuoteNotificationsCount, 0);
260
+        }
261
+        return (int)$QuoteNotificationsCount;
262
+    }
263
+
264
+    /**
265
+     * Returns the key which holds this $TopicID in the subscription array.
266
+     * Use type-aware comparison operators with this! (ie. if (self::has_subscribed($TopicID) !== false) { ... })
267
+     * @param int $TopicID
268
+     * @return bool|int
269
+     */
270
+    public static function has_subscribed($TopicID)
271
+    {
272
+        $UserSubscriptions = self::get_subscriptions();
273
+        return array_search($TopicID, $UserSubscriptions);
274
+    }
275
+
276
+    /**
277
+     * Same as has_subscribed, but for comment subscriptions.
278
+     * @param string $Page 'artist', 'collages', 'requests' or 'torrents'
279
+     * @param int $PageID
280
+     * @return bool|int
281
+     */
282
+    public static function has_subscribed_comments($Page, $PageID)
283
+    {
284
+        $UserCommentSubscriptions = self::get_comment_subscriptions();
285
+        return array_search(array($Page, $PageID), $UserCommentSubscriptions);
286
+    }
287
+
288
+    /**
289
+     * Clear the subscription cache for all subscribers of a forum thread or artist/collage/request/torrent comments.
290
+     * @param type $Page 'forums', 'artist', 'collages', 'requests' or 'torrents'
291
+     * @param type $PageID TopicID, ArtistID, CollageID, RequestID or GroupID, respectively
292
+     */
293
+    public static function flush_subscriptions($Page, $PageID)
294
+    {
295
+        $QueryID = G::$DB->get_query_id();
296
+        if ($Page == 'forums') {
297
+            G::$DB->query("
298
+        SELECT UserID
299
+        FROM users_subscriptions
300
+        WHERE TopicID = '$PageID'");
301
+        } else {
302
+            G::$DB->query("
303
+        SELECT UserID
304
+        FROM users_subscriptions_comments
305
+        WHERE Page = '$Page'
306
+          AND PageID = '$PageID'");
307
+        }
308
+        $Subscribers = G::$DB->collect('UserID');
309
+        foreach ($Subscribers as $Subscriber) {
310
+            G::$Cache->delete_value("subscriptions_user_new_$Subscriber");
311
+        }
312
+        G::$DB->set_query_id($QueryID);
313
+    }
314
+
315
+    /**
316
+     * Move all $Page subscriptions from $OldPageID to $NewPageID (for example when merging torrent groups).
317
+     * Passing $NewPageID = null will delete the subscriptions.
318
+     * @param string $Page 'forums', 'artist', 'collages', 'requests' or 'torrents'
319
+     * @param int $OldPageID TopicID, ArtistID, CollageID, RequestID or GroupID, respectively
320
+     * @param int|null $NewPageID As $OldPageID, or null to delete the subscriptions
321
+     */
322
+    public static function move_subscriptions($Page, $OldPageID, $NewPageID)
323
+    {
324
+        self::flush_subscriptions($Page, $OldPageID);
325
+        $QueryID = G::$DB->get_query_id();
326
+        if ($Page == 'forums') {
327
+            if ($NewPageID !== null) {
328
+                G::$DB->query("
329
+          UPDATE IGNORE users_subscriptions
330
+          SET TopicID = '$NewPageID'
331
+          WHERE TopicID = '$OldPageID'");
332
+                // explanation see below
333
+                G::$DB->query("
334
+          UPDATE IGNORE forums_last_read_topics
335
+          SET TopicID = $NewPageID
336
+          WHERE TopicID = $OldPageID");
337
+                G::$DB->query("
338
+          SELECT UserID, MIN(PostID)
339
+          FROM forums_last_read_topics
340
+          WHERE TopicID IN ($OldPageID, $NewPageID)
341
+          GROUP BY UserID
342
+          HAVING COUNT(1) = 2");
343
+                $Results = G::$DB->to_array(false, MYSQLI_NUM);
344
+                foreach ($Results as $Result) {
345
+                    G::$DB->query("
346
+            UPDATE forums_last_read_topics
347
+            SET PostID = $Result[1]
348
+            WHERE TopicID = $NewPageID
349
+              AND UserID = $Result[0]");
350
+                }
351
+            }
352
+            G::$DB->query("
353
+        DELETE FROM users_subscriptions
354
+        WHERE TopicID = '$OldPageID'");
355
+            G::$DB->query("
356
+        DELETE FROM forums_last_read_topics
357
+        WHERE TopicID = $OldPageID");
358
+        } else {
359
+            if ($NewPageID !== null) {
360
+                G::$DB->query("
361
+          UPDATE IGNORE users_subscriptions_comments
362
+          SET PageID = '$NewPageID'
363
+          WHERE Page = '$Page'
364
+            AND PageID = '$OldPageID'");
365
+                // last read handling
366
+                // 1) update all rows that have no key collisions (i.e. users that haven't previously read both pages or if there are only comments on one page)
367
+                G::$DB->query("
368
+          UPDATE IGNORE users_comments_last_read
369
+          SET PageID = '$NewPageID'
370
+          WHERE Page = '$Page'
371
+            AND PageID = $OldPageID");
372
+                // 2) get all last read records with key collisions (i.e. there are records for one user for both PageIDs)
373
+                G::$DB->query("
374
+          SELECT UserID, MIN(PostID)
375
+          FROM users_comments_last_read
376
+          WHERE Page = '$Page'
377
+            AND PageID IN ($OldPageID, $NewPageID)
378
+          GROUP BY UserID
379
+          HAVING COUNT(1) = 2");
380
+                $Results = G::$DB->to_array(false, MYSQLI_NUM);
381
+                // 3) update rows for those people found in 2) to the earlier post
382
+                foreach ($Results as $Result) {
383
+                    G::$DB->query("
384
+            UPDATE users_comments_last_read
385
+            SET PostID = $Result[1]
386
+            WHERE Page = '$Page'
387
+              AND PageID = $NewPageID
388
+              AND UserID = $Result[0]");
389
+                }
390
+            }
391
+            G::$DB->query("
392
+        DELETE FROM users_subscriptions_comments
393
+        WHERE Page = '$Page'
394
+          AND PageID = '$OldPageID'");
395
+            G::$DB->query("
396
+        DELETE FROM users_comments_last_read
397
+        WHERE Page = '$Page'
398
+          AND PageID = '$OldPageID'");
399
+        }
400
+        G::$DB->set_query_id($QueryID);
401
+    }
402
+
403
+    /**
404
+     * Clear the quote notification cache for all subscribers of a forum thread or artist/collage/request/torrent comments.
405
+     * @param string $Page 'forums', 'artist', 'collages', 'requests' or 'torrents'
406
+     * @param int $PageID TopicID, ArtistID, CollageID, RequestID or GroupID, respectively
407
+     */
408
+    public static function flush_quote_notifications($Page, $PageID)
409
+    {
410
+        $QueryID = G::$DB->get_query_id();
411
+        G::$DB->query("
412
+      SELECT UserID
413
+      FROM users_notify_quoted
414
+      WHERE Page = '$Page'
415
+        AND PageID = $PageID");
416
+        $Subscribers = G::$DB->collect('UserID');
417
+        foreach ($Subscribers as $Subscriber) {
418
+            G::$Cache->delete_value("notify_quoted_$Subscriber");
419
+        }
420
+        G::$DB->set_query_id($QueryID);
421
+    }
422
+}

+ 323
- 0
classes/tags.class.php View File

@@ -0,0 +1,323 @@
1
+<?php
2
+
3
+/**
4
+ * Tags Class
5
+ *
6
+ * Formatting and sorting methods for tags and tag lists
7
+ *
8
+ * Example:
9
+ * <pre>&lt;?php
10
+ * $Tags = new Tags('pop rock hip.hop');
11
+ * $Tags->Format(); // returns a tag link list
12
+ *
13
+ * $Tags2 = new Tags('pop rock indie');
14
+ *
15
+ * // returns a tag link list of tags ordered by amount
16
+ * Tags::format_top();
17
+ * ?></pre>
18
+ * e.g.:
19
+ *  pop (2)
20
+ *  rock (2)
21
+ *  hip.hop (1)
22
+ *  indie (1)
23
+ *
24
+ * Each time a new Tags object is instantiated, the tag list is merged with the
25
+ * overall total amount of tags to provide a Top Tags list. Merging is optional.
26
+ */
27
+class Tags
28
+{
29
+    /**
30
+     * Collects all tags processed by the Tags Class
31
+     * @static
32
+     * @var array $All Class Tags
33
+     */
34
+    private static $All = [];
35
+
36
+    /**
37
+     * All tags in the current instance
38
+     * @var array $Tags Instance Tags
39
+     */
40
+    private $Tags = null;
41
+
42
+    /**
43
+     * @var array $TagLink Tag link list
44
+     */
45
+    private $TagLink = [];
46
+
47
+    /**
48
+     * @var string $Primary The primary tag
49
+     */
50
+    private $Primary = '';
51
+
52
+    /**
53
+     * Filter tags array to remove empty spaces.
54
+     *
55
+     * @param string $TagList A string of tags separated by a space
56
+     * @param boolean $Merge Merge the tag list with the Class' tags
57
+     *        E.g., compilations and soundtracks are skipped, so false
58
+     */
59
+    public function __construct($TagList, $Merge = true)
60
+    {
61
+        if ($TagList) {
62
+            $this->Tags = array_filter(explode(' ', str_replace('_', '.', $TagList)));
63
+
64
+            if ($Merge) {
65
+                self::$All = array_merge(self::$All, $this->Tags);
66
+            }
67
+
68
+            $this->Primary = $this->Tags[0];
69
+            sort($this->Tags);
70
+        } else {
71
+            $this->Tags = [];
72
+        }
73
+    }
74
+
75
+    /**
76
+     * @return string Primary Tag
77
+     */
78
+    public function get_primary()
79
+    {
80
+        return $this->Primary;
81
+    }
82
+
83
+    /**
84
+     * Set the primary tag
85
+     * @param string $Primary
86
+     */
87
+    public function set_primary($Primary)
88
+    {
89
+        $this->Primary = (string)$Primary;
90
+    }
91
+
92
+    /**
93
+     * Formats primary tag as a title
94
+     * @return string Title
95
+     */
96
+    public function title()
97
+    {
98
+        return ucwords(str_replace('.', ' ', $this->Primary));
99
+    }
100
+
101
+    /**
102
+     * Formats primary tag as a CSS class
103
+     * @return string CSS Class Name
104
+     */
105
+    public function css_name()
106
+    {
107
+        return 'tags_' . str_replace('.', '_', $this->Primary);
108
+    }
109
+
110
+    /**
111
+     * @return array Tags
112
+     */
113
+    public function get_tags()
114
+    {
115
+        return $this->Tags;
116
+    }
117
+
118
+    /**
119
+     * @return array All tags
120
+     */
121
+    public static function all()
122
+    {
123
+        return self::$All;
124
+    }
125
+
126
+    /**
127
+     * Counts and sorts All tags
128
+     * @return array All tags sorted
129
+     */
130
+    public static function sorted()
131
+    {
132
+        $Sorted = array_count_values(self::$All);
133
+        arsort($Sorted);
134
+        return $Sorted;
135
+    }
136
+
137
+    /**
138
+     * Formats tags
139
+     * @param string $Link Link to a taglist page
140
+     * @param string $ArtistName Restrict tag search by this artist
141
+     * @return string List of tag links
142
+     */
143
+    public function format($Link = 'torrents.php?taglist=', $ArtistName = '')
144
+    {
145
+        if (!empty($ArtistName)) {
146
+            $ArtistName = "&amp;artistname=" . urlencode($ArtistName) . "&amp;action=advanced&amp;searchsubmit=1";
147
+        }
148
+
149
+        foreach ($this->Tags as $Tag) {
150
+            $Split = self::get_name_and_class($Tag);
151
+            $Name = $Split['name'];
152
+            $Class = $Split['class'];
153
+
154
+            if (empty($this->TagLink[$Tag])) {
155
+                $this->TagLink[$Tag] = '<a class="' . $Class . '" href="' . $Link . $Tag . $ArtistName . '">' . $Name . '</a>';
156
+            }
157
+        }
158
+        return implode(', ', $this->TagLink);
159
+    }
160
+
161
+    /**
162
+     * Format a list of top tags
163
+     * @param int $Max Max number of items to get
164
+     * @param string $Link  Page query where more items of this tag type can be found
165
+     * @param string $ArtistName Optional artist
166
+     */
167
+    public static function format_top($Max = 5, $Link = 'torrents.php?taglist=', $ArtistName = '')
168
+    {
169
+        if (empty(self::$All)) { ?>
170
+<li>No torrent tags</li>
171
+<?php
172
+      return;
173
+    }
174
+
175
+        if (!empty($ArtistName)) {
176
+            $ArtistName = '&amp;artistname=' . urlencode($ArtistName) . '&amp;action=advanced&amp;searchsubmit=1';
177
+        }
178
+
179
+        foreach (array_slice(self::sorted(), 0, $Max) as $Tag => $Total) {
180
+            $Split = self::get_name_and_class($Tag);
181
+            $Name = $Split['name'];
182
+            $Class = $Split['class']; ?>
183
+
184
+<li><a class="<?=$Class?>"
185
+    href="<?=$Link . display_str($Name) . $ArtistName?>"><?=display_str($Name)?></a> (<?=$Total?>)</li>
186
+<?php
187
+        }
188
+    }
189
+
190
+    /**
191
+     * General purpose method to get all tag aliases from the DB
192
+     * @return array
193
+     */
194
+    public static function get_aliases()
195
+    {
196
+        $TagAliases = G::$Cache->get_value('tag_aliases_search');
197
+        if ($TagAliases === false) {
198
+            G::$DB->query('
199
+            SELECT ID, BadTag, AliasTag
200
+            FROM tag_aliases
201
+              ORDER BY BadTag');
202
+      
203
+            $TagAliases = G::$DB->to_array(false, MYSQLI_ASSOC, false);
204
+            // Unify tag aliases to be in_this_format as tags not in.this.format
205
+            array_walk_recursive($TagAliases, function (&$val) {
206
+                $val = preg_replace("/\./", "_", $val);
207
+            });
208
+            // Clean up the array for smaller cache size
209
+            foreach ($TagAliases as &$TagAlias) {
210
+                foreach (array_keys($TagAlias) as $Key) {
211
+                    if (is_numeric($Key)) {
212
+                        unset($TagAlias[$Key]);
213
+                    }
214
+                }
215
+            }
216
+            G::$Cache->cache_value('tag_aliases_search', $TagAliases, 3600 * 24 * 7); // cache for 7 days
217
+        }
218
+        return $TagAliases;
219
+    }
220
+
221
+    /**
222
+     * Replace bad tags with tag aliases
223
+     * @param array $Tags Array with sub-arrays 'include' and 'exclude'
224
+     * @return array
225
+     */
226
+    public static function remove_aliases($Tags)
227
+    {
228
+        $TagAliases = self::get_aliases();
229
+
230
+        if (isset($Tags['include'])) {
231
+            $End = count($Tags['include']);
232
+            for ($i = 0; $i < $End; $i++) {
233
+                foreach ($TagAliases as $TagAlias) {
234
+                    if ($Tags['include'][$i] === $TagAlias['BadTag']) {
235
+                        $Tags['include'][$i] = $TagAlias['AliasTag'];
236
+                        break;
237
+                    }
238
+                }
239
+            }
240
+            // Only keep unique entries after unifying tag standard
241
+            $Tags['include'] = array_unique($Tags['include']);
242
+        }
243
+
244
+        if (isset($Tags['exclude'])) {
245
+            $End = count($Tags['exclude']);
246
+            for ($i = 0; $i < $End; $i++) {
247
+                foreach ($TagAliases as $TagAlias) {
248
+                    if (substr($Tags['exclude'][$i], 1) === $TagAlias['BadTag']) {
249
+                        $Tags['exclude'][$i] = '!'.$TagAlias['AliasTag'];
250
+                        break;
251
+                    }
252
+                }
253
+            }
254
+            // Only keep unique entries after unifying tag standard
255
+            $Tags['exclude'] = array_unique($Tags['exclude']);
256
+        }
257
+        return $Tags;
258
+    }
259
+
260
+    /**
261
+     * Filters a list of include and exclude tags to be used in a Sphinx search
262
+     * @param array $Tags An array of tags with sub-arrays 'include' and 'exclude'
263
+     * @param integer $TagType Search for Any or All of these tags.
264
+     * @return array Array keys predicate and input
265
+     *               Predicate for a Sphinx 'taglist' query
266
+     *               Input contains clean, aliased tags. Use it in a form instead of the user submitted string
267
+     */
268
+    public static function tag_filter_sph($Tags, $TagType)
269
+    {
270
+        $QueryParts = [];
271
+        $Tags = Tags::remove_aliases($Tags);
272
+        $TagList = str_replace('_', '.', implode(', ', array_merge($Tags['include'], $Tags['exclude'])));
273
+
274
+        foreach ($Tags['include'] as &$Tag) {
275
+            $Tag = Sphinxql::sph_escape_string($Tag);
276
+        }
277
+
278
+        if (!empty($Tags['exclude'])) {
279
+            foreach ($Tags['exclude'] as &$Tag) {
280
+                $Tag = '!' . Sphinxql::sph_escape_string(substr($Tag, 1));
281
+            }
282
+        }
283
+
284
+        // 'All' tags
285
+        if (!isset($TagType) || $TagType == 1) {
286
+            $SearchWords = array_merge($Tags['include'], $Tags['exclude']);
287
+            if (!empty($Tags)) {
288
+                $QueryParts[] = implode(' ', $SearchWords);
289
+            }
290
+        }
291
+        // 'Any' tags
292
+        else {
293
+            if (!empty($Tags['include'])) {
294
+                $QueryParts[] = '( ' . implode(' | ', $Tags['include']) . ' )';
295
+            }
296
+            if (!empty($Tags['exclude'])) {
297
+                $QueryParts[] = implode(' ', $Tags['exclude']);
298
+            }
299
+        }
300
+
301
+        return ['input' => $TagList, 'predicate' => implode(' ', $QueryParts)];
302
+    }
303
+
304
+    /**
305
+     * Breaks a tag down into name and namespace class
306
+     * @param string $Tag Tag of the form 'tag' or 'tag:namespace'
307
+     * @return array Array keys name and class
308
+     *               name is the name of the tag without a namespace
309
+     *               class is the HTML class that should be applied to the tag, empty string if the tag has no namespace
310
+     */
311
+    public static function get_name_and_class($Tag)
312
+    {
313
+        $Name = $Tag;
314
+        $Class = "";
315
+        $Split = explode(':', $Tag);
316
+        
317
+        if (count($Split) > 1 && in_array($Split[1], TAG_NAMESPACES)) {
318
+            $Name = $Split[0];
319
+            $Class = "tag_" . $Split[1];
320
+        }
321
+        return array("name" => display_str($Name), "class" => display_str($Class));
322
+    }
323
+}

+ 83
- 0
classes/templates.class.php View File

@@ -0,0 +1,83 @@
1
+<?php
2
+
3
+// Example :
4
+// $TPL = new TEMPLATE;
5
+// $TPL->open('inv.tpl');
6
+// $TPL->set('ADDRESS1', $TPL->str_align(57, $UADDRESS1, 'l', ' '));
7
+// $TPL->get();
8
+
9
+class TEMPLATE
10
+{
11
+    public $file = '';
12
+    public $vars = [];
13
+
14
+    public function open($file)
15
+    {
16
+        $this->file = file($file);
17
+    }
18
+
19
+    public function set($name, $var, $ifnone = '<span style="font-style: italic;">-None-</span>')
20
+    {
21
+        if ($name !== '') {
22
+            $this->vars[$name][0] = $var;
23
+            $this->vars[$name][1] = $ifnone;
24
+        }
25
+    }
26
+
27
+    public function show()
28
+    {
29
+        $TMPVAR = '';
30
+        for ($i = 0; $i < sizeof($this->file); $i++) {
31
+            $TMPVAR = $this->file[$i];
32
+            foreach ($this->vars as $k=>$v) {
33
+                if ($v[1] !== '' && $v[0] === '') {
34
+                    $v[0] = $v[1];
35
+                }
36
+                $TMPVAR = str_replace('{{'.$k.'}}', $v[0], $TMPVAR);
37
+            }
38
+            echo $TMPVAR;
39
+        }
40
+    }
41
+
42
+    public function get()
43
+    {
44
+        $RESULT = '';
45
+        $TMPVAR = '';
46
+        for ($i = 0; $i < sizeof($this->file); $i++) {
47
+            $TMPVAR = $this->file[$i];
48
+            foreach ($this->vars as $k=>$v) {
49
+                if ($v[1] !== '' && $v[0] === '') {
50
+                    $v[0] = $v[1];
51
+                }
52
+                $TMPVAR = str_replace('{{'.$k.'}}', $v[0], $TMPVAR);
53
+            }
54
+            $RESULT.= $TMPVAR;
55
+        }
56
+        return $RESULT;
57
+    }
58
+
59
+    public function str_align($len, $str, $align, $fill)
60
+    {
61
+        $strlen = strlen($str);
62
+        if ($strlen > $len) {
63
+            return substr($str, 0, $len);
64
+        } elseif (($strlen === 0) || ($len === 0)) {
65
+            return '';
66
+        } else {
67
+            if (($align === 'l') || ($align === 'left')) {
68
+                $result = $str.str_repeat($fill, ($len - $strlen));
69
+            } elseif (($align === 'r') || ($align === 'right')) {
70
+                $result = str_repeat($fill, ($len - $strlen)).$str;
71
+            } elseif (($align === 'c') || ($align === 'center')) {
72
+                $snm = intval(($len - $strlen) / 2);
73
+                if (($strlen + ($snm * 2)) === $len) {
74
+                    $result = str_repeat($fill, $snm).$str;
75
+                } else {
76
+                    $result = str_repeat($fill, $snm + 1).$str;
77
+                }
78
+                $result.= str_repeat($fill, $snm);
79
+            }
80
+            return $result;
81
+        }
82
+    }
83
+}

+ 169
- 0
classes/testing.class.php View File

@@ -0,0 +1,169 @@
1
+<?php
2
+
3
+class Testing
4
+{
5
+    private static $ClassDirectories = array("classes");
6
+    private static $Classes = [];
7
+
8
+    /**
9
+     * Initialize the testasble classes into a map keyed by class name
10
+     */
11
+    public static function init()
12
+    {
13
+        self::load_classes();
14
+    }
15
+
16
+    /**
17
+     * Gets the class
18
+     */
19
+    public static function get_classes()
20
+    {
21
+        return self::$Classes;
22
+    }
23
+
24
+    /**
25
+     * Loads all the classes within given directories
26
+     */
27
+    private static function load_classes()
28
+    {
29
+        foreach (self::$ClassDirectories as $Directory) {
30
+            $Directory = SERVER_ROOT . "/" . $Directory . "/";
31
+            foreach (glob($Directory . "*.php") as $FileName) {
32
+                self::get_class_name($FileName);
33
+            }
34
+        }
35
+    }
36
+
37
+    /**
38
+     * Gets the class and adds into the map
39
+     */
40
+    private static function get_class_name($FileName)
41
+    {
42
+        $Tokens = token_get_all(file_get_contents($FileName));
43
+        $IsTestable = false;
44
+        $IsClass = false;
45
+
46
+        foreach ($Tokens as $Token) {
47
+            if (is_array($Token)) {
48
+                if (!$IsTestable && $Token[0] == T_DOC_COMMENT && strpos($Token[1], "@TestClass")) {
49
+                    $IsTestable = true;
50
+                }
51
+
52
+                if ($IsTestable && $Token[0] == T_CLASS) {
53
+                    $IsClass = true;
54
+                } elseif ($IsClass && $Token[0] == T_STRING) {
55
+                    $ReflectionClass = new ReflectionClass($Token[1]);
56
+
57
+                    if (count(self::get_testable_methods($ReflectionClass))) {
58
+                        self::$Classes[$Token[1]] = new ReflectionClass($Token[1]);
59
+                    }
60
+
61
+                    $IsTestable = false;
62
+                    $IsClass = false;
63
+                }
64
+            }
65
+        }
66
+    }
67
+
68
+    /**
69
+     * Checks if class exists in the map
70
+     */
71
+    public static function has_class($Class)
72
+    {
73
+        return array_key_exists($Class, self::$Classes);
74
+    }
75
+
76
+    /**
77
+     * Checks if class has a given testable methood
78
+     */
79
+    public static function has_testable_method($Class, $Method)
80
+    {
81
+        $TestableMethods = self::get_testable_methods($Class);
82
+        foreach ($TestableMethods as $TestMethod) {
83
+            if ($TestMethod->getName() === $Method) {
84
+                return true;
85
+            }
86
+        }
87
+        return false;
88
+    }
89
+
90
+    /**
91
+     * Get testable methods in a class, a testable method has a @Test
92
+     */
93
+    public static function get_testable_methods($Class)
94
+    {
95
+        if (is_string($Class)) {
96
+            $ReflectionClass = self::$Classes[$Class];
97
+        } else {
98
+            $ReflectionClass = $Class;
99
+        }
100
+
101
+        $ReflectionMethods = $ReflectionClass->getMethods();
102
+        $TestableMethods = [];
103
+
104
+        foreach ($ReflectionMethods as $Method) {
105
+            if ($Method->isPublic() && $Method->isStatic() && strpos($Method->getDocComment(), "@Test")) {
106
+                $TestableMethods[] = $Method;
107
+            }
108
+        }
109
+        return $TestableMethods;
110
+    }
111
+
112
+
113
+    /**
114
+     * Get the class comment
115
+     */
116
+    public static function get_class_comment($Class)
117
+    {
118
+        $ReflectionClass = self::$Classes[$Class];
119
+        return trim(str_replace(array("@TestClass", "*", "/"), "", $ReflectionClass->getDocComment()));
120
+    }
121
+
122
+    /**
123
+     * Get the undocumented methods in a class
124
+     */
125
+    public static function get_undocumented_methods($Class)
126
+    {
127
+        $ReflectionClass = self::$Classes[$Class];
128
+        $Methods = [];
129
+
130
+        foreach ($ReflectionClass->getMethods() as $Method) {
131
+            if (!$Method->getDocComment()) {
132
+                $Methods[] = $Method;
133
+            }
134
+        }
135
+        return $Methods;
136
+    }
137
+
138
+    /**
139
+     * Get the documented methods
140
+     */
141
+    public static function get_documented_methods($Class)
142
+    {
143
+        $ReflectionClass = self::$Classes[$Class];
144
+        $Methods = [];
145
+
146
+        foreach ($ReflectionClass->getMethods() as $Method) {
147
+            if ($Method->getDocComment()) {
148
+                $Methods[] = $Method;
149
+            }
150
+        }
151
+        return $Methods;
152
+    }
153
+
154
+    /**
155
+     * Get all methods in a class
156
+     */
157
+    public static function get_methods($Class)
158
+    {
159
+        return self::$Classes[$Class]->getMethods();
160
+    }
161
+
162
+    /**
163
+     * Get a method  comment
164
+     */
165
+    public static function get_method_comment($Method)
166
+    {
167
+        return trim(str_replace(array("*", "/"), "", $Method->getDocComment()));
168
+    }
169
+}

+ 182
- 0
classes/testingview.class.php View File

@@ -0,0 +1,182 @@
1
+<?php
2
+
3
+class TestingView
4
+{
5
+    /**
6
+     * Render the linkbox
7
+     */
8
+    public static function render_linkbox($Page) { ?>
9
+<div class="linkbox">
10
+  <?php if ($Page != "classes") { ?>
11
+  <a href="testing.php" class="brackets">Classes</a>
12
+  <?php }
13
+      if ($Page != "comments") { ?>
14
+  <a href="testing.php?action=comments" class="brackets">Comments</a>
15
+  <?php } ?>
16
+</div>
17
+<?php }
18
+
19
+    /**
20
+     * Render a list of classes
21
+     */
22
+    public static function render_classes($Classes) { ?>
23
+<table>
24
+  <tr class="colhead">
25
+    <td>
26
+      Class
27
+    </td>
28
+    <td>
29
+      Testable functions
30
+    </td>
31
+  </tr>
32
+  <?php foreach ($Classes as $Key => $Value) {
33
+        $Doc = Testing::get_class_comment($Key);
34
+        $Methods = count(Testing::get_testable_methods($Key)); ?>
35
+  <tr>
36
+    <td>
37
+      <a href="testing.php?action=class&amp;name=<?=$Key?>"
38
+        class="tooltip" title="<?=$Doc?>"><?=$Key?></a>
39
+    </td>
40
+    <td>
41
+      <?=$Methods?>
42
+    </td>
43
+  </tr>
44
+  <?php
45
+    } ?>
46
+</table>
47
+<?php }
48
+
49
+    /**
50
+     * Render functions in a class
51
+     */
52
+    public static function render_functions($Methods)
53
+    {
54
+        foreach ($Methods as $Index => $Method) {
55
+            $ClassName = $Method->getDeclaringClass()->getName();
56
+            $MethodName = $Method->getName(); ?>
57
+<div class="box">
58
+  <div class="head">
59
+    <span><?=self::render_method_definition($Method)?></span>
60
+    <span class="float_right">
61
+      <a data-toggle-target="#method_params_<?=$Index?>"
62
+        class="brackets">Params</a>
63
+      <a href="#" class="brackets run" data-gazelle-id="<?=$Index?>"
64
+        data-gazelle-class="<?=$ClassName?>"
65
+        data-gazelle-method="<?=$MethodName?>">Run</a>
66
+    </span>
67
+  </div>
68
+  <div class="pad hidden" id="method_params_<?=$Index?>">
69
+    <?self::render_method_params($Method); ?>
70
+  </div>
71
+  <div class="pad hidden" id="method_results_<?=$Index?>">
72
+  </div>
73
+</div>
74
+<?php
75
+        }
76
+    }
77
+
78
+    /**
79
+     * Render method parameters
80
+     */
81
+    private static function render_method_params($Method) { ?>
82
+<table>
83
+  <?php foreach ($Method->getParameters() as $Parameter) {
84
+        $DefaultValue = $Parameter->isDefaultValueAvailable() ? $Parameter->getDefaultValue() : ""; ?>
85
+  <tr>
86
+    <td class="label">
87
+      <?=$Parameter->getName()?>
88
+    </td>
89
+    <td>
90
+      <input type="text" name="<?=$Parameter->getName()?>"
91
+        value="<?=$DefaultValue?>" />
92
+    </td>
93
+  </tr>
94
+  <?php
95
+    } ?>
96
+</table>
97
+<?php }
98
+
99
+    /**
100
+     * Render the method definition
101
+     */
102
+    private static function render_method_definition($Method)
103
+    {
104
+        $Title = "<span class='tooltip' title='" . Testing::get_method_comment($Method) . "'>" . $Method->getName() . "</span> (";
105
+        foreach ($Method->getParameters() as $Parameter) {
106
+            $Color = "red";
107
+            if ($Parameter->isDefaultValueAvailable()) {
108
+                $Color = "green";
109
+            }
110
+            $Title .= "<span style='color: $Color'>";
111
+            $Title .= "$" . $Parameter->getName();
112
+            if ($Parameter->isDefaultValueAvailable()) {
113
+                $Title .= " = " . $Parameter->getDefaultValue();
114
+            }
115
+            $Title .= "</span>";
116
+            $Title .= ", ";
117
+        }
118
+        $Title = rtrim($Title, ", ");
119
+        $Title .= ")";
120
+        return $Title;
121
+    }
122
+
123
+    /**
124
+     * Renders class documentation stats
125
+     */
126
+    public static function render_missing_documentation($Classes) { ?>
127
+<table>
128
+  <tr class="colhead">
129
+    <td>
130
+      Class
131
+    </td>
132
+    <td>
133
+      Class documented
134
+    </td>
135
+    <td>
136
+      Undocumented functions
137
+    </td>
138
+    <td>
139
+      Documented functions
140
+    </td>
141
+  </tr>
142
+  <?php foreach ($Classes as $Key => $Value) {
143
+        $ClassComment = Testing::get_class_comment($Key); ?>
144
+  <tr>
145
+    <td>
146
+      <?=$Key?>
147
+    </td>
148
+    <td>
149
+      <?=!empty($ClassComment) ? "Yes" : "No"?>
150
+    <td>
151
+      <?=count(Testing::get_undocumented_methods($Key))?>
152
+    </td>
153
+    <td>
154
+      <?=count(Testing::get_documented_methods($Key))?>
155
+    </td>
156
+  </tr>
157
+  <?php
158
+    } ?>
159
+</table>
160
+<?php }
161
+
162
+    /**
163
+     * Pretty print any data
164
+     */
165
+    public static function render_results($Data)
166
+    {
167
+        $Results = '<pre><ul style="list-style-type: none">';
168
+        if (is_array($Data)) {
169
+            foreach ($Data as $Key => $Value) {
170
+                if (is_array($Value)) {
171
+                    $Results .= '<li>' . $Key . ' => ' . self::render_results($Value) . '</li>';
172
+                } else {
173
+                    $Results .= '<li>' . $Key . ' => ' . $Value . '</li>';
174
+                }
175
+            }
176
+        } else {
177
+            $Results .= '<li>' . $Data . '</li>';
178
+        }
179
+        $Results .= '</ul></pre>';
180
+        echo $Results;
181
+    }
182
+}

+ 1197
- 0
classes/text.class.php
File diff suppressed because it is too large
View File


+ 244
- 0
classes/textarea_preview.class.php View File

@@ -0,0 +1,244 @@
1
+<?php
2
+declare(strict_types = 1);
3
+
4
+/**
5
+ * This super class is used to manage the ammount of textareas there are
6
+ * and to generate the required JavaScript that enables the previews to work.
7
+ */
8
+class TEXTAREA_PREVIEW_SUPER
9
+{
10
+    /**
11
+     * @static
12
+     * @var int $Textareas Total number of textareas created
13
+     */
14
+    protected static $Textareas = 0;
15
+
16
+    /**
17
+     * @static
18
+     * @var array $_ID Array of textarea IDs
19
+     */
20
+    protected static $_ID = [];
21
+
22
+    /**
23
+     * @static
24
+     * @var bool For use in JavaScript method
25
+     */
26
+    private static $Exectuted = false;
27
+
28
+    /**
29
+     * This method should only run once with $all as true and should be placed
30
+     * in the header or footer.
31
+     *
32
+     * If $all is true, it includes TextareaPreview and jQuery
33
+     *
34
+     * jQuery is required for this to work, include it in the headers.
35
+     *
36
+     * @static
37
+     * @param bool $all Output all required scripts, otherwise just do iterator()
38
+     * @example <pre><?php TEXT_PREVIEW::JavaScript(); ?></pre>
39
+     * @return void
40
+     */
41
+    public static function JavaScript($all = true)
42
+    {
43
+        if (self::$Textareas === 0) {
44
+            return;
45
+        }
46
+
47
+        if (self::$Exectuted === false && $all) {
48
+            View::parse('generic/textarea/script.phtml');
49
+        }
50
+
51
+        self::$Exectuted = true;
52
+        self::iterator();
53
+    }
54
+
55
+    /**
56
+     * This iterator generates JavaScript to initialize each JavaScript
57
+     * TextareaPreview object.
58
+     *
59
+     * It will generate a numeric or custom ID related to the textarea.
60
+     * @static
61
+     * @return void
62
+     */
63
+    private static function iterator()
64
+    {
65
+        $script = [];
66
+        for ($i = 0; $i < self::$Textareas; $i++) {
67
+            if (isset(self::$_ID[$i]) && is_string(self::$_ID[$i])) {
68
+                $a = sprintf('%d, "%s"', $i, self::$_ID[$i]);
69
+            } else {
70
+                $a = $i;
71
+            }
72
+            $script[] = sprintf('[%s]', $a);
73
+        }
74
+
75
+        if (!empty($script)) {
76
+            View::parse('generic/textarea/script_factory.phtml', array('script' => join(', ', $script)));
77
+        }
78
+    }
79
+}
80
+
81
+/**
82
+ * Textarea Preview Class
83
+ *
84
+ * This class generates a textarea that works with the JS preview script.
85
+ * Templates found in design/views/generic/textarea.
86
+ *
87
+ * @example <pre><?php
88
+ *  // Create a textarea with a name of content.
89
+ *  // Buttons and preview divs are generated automatically near the textarea.
90
+ *  new TEXTAREA_PREVIEW('content');
91
+ *
92
+ *  // Create a textarea with name and id body_text with default text and
93
+ *  // no buttons or wrap preview divs.
94
+ *  // Buttons and preview divs are generated manually
95
+ *  $text = new TEXTAREA_PREVIEW('body_text', 'body_text', 'default text',
96
+ *          50, 20, false, false, array('disabled="disabled"', 'class="text"'));
97
+ *
98
+ *  $text->buttons(); // output buttons
99
+ *  $text->preview(); // output preview div
100
+ *
101
+ * // Create a textarea with custom preview wrapper around a table
102
+ * // the table will be (in)visible depending on the toggle
103
+ * $text = new TEXTAREA_PREVIEW('body', '', '', 30, 10, false, false);
104
+ * $id = $text->getID();
105
+ *
106
+ * // some template
107
+ * <div id="preview_wrap_<?=$id?>">
108
+ *    <table>
109
+ *      <tr>
110
+ *        <td>
111
+ *          <div id="preview_<?=$id?>"></div>
112
+ *        </td>
113
+ *      </tr>
114
+ *    </table>
115
+ * </div>
116
+ * </pre>
117
+ */
118
+class TEXTAREA_PREVIEW extends TEXTAREA_PREVIEW_SUPER
119
+{
120
+
121
+  /**
122
+   * @var int Unique ID
123
+   */
124
+    private $id;
125
+
126
+    /**
127
+     * Flag for preview output
128
+     * @var bool $preview
129
+     */
130
+    private $preview = false;
131
+
132
+    /**
133
+     * String table
134
+     * @var string Buffer
135
+     */
136
+    private $buffer = null;
137
+
138
+    /**
139
+     * This method creates a textarea
140
+     *
141
+     * @param string $Name    name attribute
142
+     * @param string $ID      id attribute
143
+     * @param string $Value   default text attribute
144
+     * @param string $Cols    cols attribute
145
+     * @param string $Rows    rows attribute
146
+     * @param bool   $Preview add the preview divs near the textarea
147
+     * @param bool   $Buttons add the edit/preview buttons near the textarea
148
+     * @param bool   $Buffer  doesn't output the textarea, use getBuffer()
149
+     * @param array  $ExtraAttributesarray of attribute="value"
150
+     *
151
+     * If false for $Preview, $Buttons, or $Buffer, use the appropriate
152
+     * methods to add the those elements manually. Alternatively, use getID
153
+     * to create your own.
154
+     *
155
+     * It's important to have the right IDs as they make the JS function properly.
156
+     */
157
+    public function __construct(
158
+        $Name,
159
+        $ID = '',
160
+        $Value = '',
161
+        $Placeholder = '',
162
+        $Cols = 40,
163
+        $Rows = 20,
164
+        $Preview = true,
165
+        $Buttons = true,
166
+        $Buffer = false,
167
+        $ExtraAttributes = []
168
+    ) {
169
+        $this->id = parent::$Textareas;
170
+        parent::$Textareas += 1;
171
+        array_push(parent::$_ID, $ID);
172
+
173
+        if (empty($ID)) {
174
+            $ID = 'quickpost_' . $this->id;
175
+        }
176
+
177
+        if (!empty($ExtraAttributes)) {
178
+            $Attributes = ' ' . implode(' ', $ExtraAttributes);
179
+        } else {
180
+            $Attributes = '';
181
+        }
182
+
183
+        if ($Preview === true) {
184
+            $this->preview();
185
+        }
186
+
187
+        $this->buffer = View::parse(
188
+            'generic/textarea/textarea.phtml',
189
+            array(
190
+                'ID' => $ID,
191
+                'NID' => $this->id,
192
+                'Name' => &$Name,
193
+                'Value' => &$Value,
194
+                'Placeholder' => &$Placeholder,
195
+                'Cols' => &$Cols,
196
+                'Rows' => &$Rows,
197
+                'Attributes' => &$Attributes
198
+            ),
199
+            $Buffer
200
+        );
201
+
202
+        if ($Buttons === true) {
203
+            $this->buttons();
204
+        }
205
+    }
206
+
207
+    /**
208
+     * Outputs the divs required for previewing the AJAX content
209
+     * Will only output once
210
+     */
211
+    public function preview()
212
+    {
213
+        if (!$this->preview) {
214
+            View::parse('generic/textarea/preview.phtml', array('ID' => $this->id));
215
+        }
216
+        $this->preview = true;
217
+    }
218
+
219
+    /**
220
+     * Outputs the preview and edit buttons
221
+     * Can be called many times to place buttons in different areas
222
+     */
223
+    public function buttons()
224
+    {
225
+        View::parse('generic/textarea/buttons.phtml', array('ID' => $this->id));
226
+    }
227
+
228
+    /**
229
+     * Returns the textarea's numeric ID.
230
+     */
231
+    public function getID()
232
+    {
233
+        return $this->id;
234
+    }
235
+
236
+    /**
237
+     * Returns textarea string when buffer is enabled in the constructor
238
+     * @return string
239
+     */
240
+    public function getBuffer()
241
+    {
242
+        return $this->buffer;
243
+    }
244
+}

+ 183
- 0
classes/time.class.php View File

@@ -0,0 +1,183 @@
1
+<?php
2
+
3
+if (!extension_loaded('date')) {
4
+    error('Date extension not loaded.');
5
+}
6
+
7
+function time_ago($TimeStamp)
8
+{
9
+    if (!$TimeStamp) {
10
+        return false;
11
+    }
12
+    if (!is_number($TimeStamp)) { // Assume that $TimeStamp is SQL timestamp
13
+        $TimeStamp = strtotime($TimeStamp);
14
+    }
15
+    return time() - $TimeStamp;
16
+}
17
+
18
+/*
19
+ * Returns a <span> by default but can optionally return the raw time
20
+ * difference in text (e.g. "16 hours and 28 minutes", "1 day, 18 hours").
21
+ */
22
+function time_diff($TimeStamp, $Levels = 2, $Span = true, $Lowercase = false)
23
+{
24
+    if (!$TimeStamp) {
25
+        return 'Never';
26
+    }
27
+    if (!is_number($TimeStamp)) { // Assume that $TimeStamp is SQL timestamp
28
+        $TimeStamp = strtotime($TimeStamp);
29
+    }
30
+    $Time = time() - $TimeStamp;
31
+
32
+    // If the time is negative, then it expires in the future.
33
+    if ($Time < 0) {
34
+        $Time = -$Time;
35
+        $HideAgo = true;
36
+    }
37
+
38
+    $Years = floor($Time / 31556926); // seconds in one year
39
+    $Remain = $Time - $Years * 31556926;
40
+
41
+    $Months = floor($Remain / 2629744); // seconds in one month
42
+    $Remain = $Remain - $Months * 2629744;
43
+
44
+    $Weeks = floor($Remain / 604800); // seconds in one week
45
+    $Remain = $Remain - $Weeks * 604800;
46
+
47
+    $Days = floor($Remain / 86400); // seconds in one day
48
+    $Remain = $Remain - $Days * 86400;
49
+
50
+    $Hours=floor($Remain / 3600); // seconds in one hour
51
+    $Remain = $Remain - $Hours * 3600;
52
+
53
+    $Minutes = floor($Remain / 60); // seconds in one minute
54
+    $Remain = $Remain - $Minutes * 60;
55
+
56
+    $Seconds = $Remain;
57
+
58
+    $Return = '';
59
+
60
+    if ($Years > 0 && $Levels > 0) {
61
+        $Return .= "$Years year".(($Years > 1) ? 's' : '');
62
+        $Levels--;
63
+    }
64
+
65
+    if ($Months > 0 && $Levels > 0) {
66
+        $Return .= ($Return != '') ? ', ' : '';
67
+        $Return .= "$Months month".(($Months > 1) ? 's' : '');
68
+        $Levels--;
69
+    }
70
+
71
+    if ($Weeks > 0 && $Levels > 0) {
72
+        $Return .= ($Return != '') ? ', ' : '';
73
+        $Return .= "$Weeks week".(($Weeks > 1) ? 's' : '');
74
+        $Levels--;
75
+    }
76
+
77
+    if ($Days > 0 && $Levels > 0) {
78
+        $Return .= ($Return != '') ? ', ' : '';
79
+        $Return .= "$Days day".(($Days > 1) ? 's' : '');
80
+        $Levels--;
81
+    }
82
+
83
+    if ($Hours > 0 && $Levels > 0) {
84
+        $Return .= ($Return != '') ? ', ' : '';
85
+        $Return .= "$Hours hour".(($Hours > 1) ? 's' : '');
86
+        $Levels--;
87
+    }
88
+
89
+    if ($Minutes > 0 && $Levels > 0) {
90
+        $Return .= ($Return != '') ? ' and ' : '';
91
+        $Return .= "$Minutes min".(($Minutes > 1) ? 's' : '');
92
+    }
93
+
94
+    if ($Return == '') {
95
+        $Return = 'Just now';
96
+    } elseif (!isset($HideAgo)) {
97
+        $Return .= ' ago';
98
+    }
99
+
100
+    if ($Lowercase) {
101
+        $Return = strtolower($Return);
102
+    }
103
+
104
+    if ($Span) {
105
+        return '<span class="time tooltip" title="'.date('M d Y, H:i', $TimeStamp).'">'.$Return.'</span>';
106
+    } else {
107
+        return $Return;
108
+    }
109
+}
110
+
111
+/* SQL utility functions */
112
+
113
+function time_plus($Offset)
114
+{
115
+    return date('Y-m-d H:i:s', time() + $Offset);
116
+}
117
+
118
+function time_minus($Offset, $Fuzzy = false)
119
+{
120
+    if ($Fuzzy) {
121
+        return date('Y-m-d 00:00:00', time() - $Offset);
122
+    } else {
123
+        return date('Y-m-d H:i:s', time() - $Offset);
124
+    }
125
+}
126
+
127
+// This is never used anywhere with $timestamp set
128
+// todo: Why don't we just use NOW() in the sql queries?
129
+function sqltime($timestamp = null)
130
+{
131
+    return date('Y-m-d H:i:s', ($timestamp ?? time()));
132
+}
133
+
134
+function validDate($DateString)
135
+{
136
+    $DateTime = explode(' ', $DateString);
137
+    if (count($DateTime) != 2) {
138
+        return false;
139
+    }
140
+
141
+    list($Date, $Time) = $DateTime;
142
+    $SplitTime = explode(':', $Time);
143
+    if (count($SplitTime) != 3) {
144
+        return false;
145
+    }
146
+
147
+    list($H, $M, $S) = $SplitTime;
148
+    if ($H != 0 && !(is_number($H) && $H < 24 && $H >= 0)) {
149
+        return false;
150
+    }
151
+
152
+    if ($M != 0 && !(is_number($M) && $M < 60 && $M >= 0)) {
153
+        return false;
154
+    }
155
+
156
+    if ($S != 0 && !(is_number($S) && $S < 60 && $S >= 0)) {
157
+        return false;
158
+    }
159
+
160
+    $SplitDate = explode('-', $Date);
161
+    if (count($SplitDate) != 3) {
162
+        return false;
163
+    }
164
+
165
+    list($Y, $M, $D) = $SplitDate;
166
+    return checkDate($M, $D, $Y);
167
+}
168
+
169
+function is_valid_date($Date)
170
+{
171
+    return is_valid_datetime($Date, 'Y-m-d');
172
+}
173
+
174
+function is_valid_time($Time)
175
+{
176
+    return is_valid_datetime($Time, 'H:i');
177
+}
178
+
179
+function is_valid_datetime($DateTime, $Format = 'Y-m-d H:i')
180
+{
181
+    $FormattedDateTime = DateTime::createFromFormat($Format, $DateTime);
182
+    return $FormattedDateTime && $FormattedDateTime->format($Format) == $DateTime;
183
+}

+ 341
- 0
classes/tools.class.php View File

@@ -0,0 +1,341 @@
1
+<?php
2
+
3
+# todo: Check strick equality gently
4
+class Tools
5
+{
6
+    /**
7
+     * Returns true if given IP is banned.
8
+     *
9
+     * @param string $IP
10
+     */
11
+    public static function site_ban_ip($IP)
12
+    {
13
+        global $Debug;
14
+        $A = substr($IP, 0, strcspn($IP, '.:'));
15
+        $IPNum = Tools::ip_to_unsigned($IP);
16
+        $IPBans = G::$Cache->get_value('ip_bans_'.$A);
17
+
18
+        if (!is_array($IPBans)) {
19
+            $SQL = sprintf("
20
+            SELECT ID, FromIP, ToIP
21
+            FROM ip_bans
22
+              WHERE FromIP BETWEEN %d << 24 AND (%d << 24) - 1", $A, $A + 1);
23
+
24
+            $QueryID = G::$DB->get_query_id();
25
+            G::$DB->query($SQL);
26
+            $IPBans = G::$DB->to_array(0, MYSQLI_NUM);
27
+            G::$DB->set_query_id($QueryID);
28
+            G::$Cache->cache_value('ip_bans_'.$A, $IPBans, 0);
29
+        }
30
+
31
+        $Debug->log_var($IPBans, 'IP bans for class '.$A);
32
+        foreach ($IPBans as $Index => $IPBan) {
33
+            list($ID, $FromIP, $ToIP) = $IPBan;
34
+            if ($IPNum >= $FromIP && $IPNum <= $ToIP) {
35
+                return true;
36
+            }
37
+        }
38
+        return false;
39
+    }
40
+
41
+    /**
42
+     * Returns the unsigned form of an IP address.
43
+     *
44
+     * @param string $IP The IP address x.x.x.x
45
+     * @return string the long it represents.
46
+     */
47
+    public static function ip_to_unsigned($IP)
48
+    {
49
+        $IPnum = sprintf('%u', ip2long($IP));
50
+        if (!$IPnum) {
51
+            // Try to encode as IPv6 (stolen from stackoverflow)
52
+            // Note that this is *wrong* and because of PHP's wankery stops being accurate after the most significant 16 digits or so
53
+            // But since this is only used for geolocation and IPv6 blocks are allocated in huge numbers, it's still fine
54
+            $IPnum = '';
55
+            foreach (unpack('C*', inet_pton($IP)) as $byte) {
56
+                $IPnum .= str_pad(decbin($byte), 8, "0", STR_PAD_LEFT);
57
+            }
58
+            $IPnum = base_convert(ltrim($IPnum, '0'), 2, 10);
59
+        }
60
+        return $IPnum;
61
+    }
62
+
63
+    /**
64
+     * Geolocate an IP address using the database
65
+     *
66
+     * @param $IP the ip to fetch the country for
67
+     * @return the country of origin
68
+     */
69
+    public static function geoip($IP)
70
+    {
71
+        static $IPs = [];
72
+        if (isset($IPs[$IP])) {
73
+            return $IPs[$IP];
74
+        }
75
+
76
+        if (is_number($IP)) {
77
+            $Long = $IP;
78
+        } else {
79
+            $Long = Tools::ip_to_unsigned($IP);
80
+        }
81
+
82
+        if (!$Long || $Long == 2130706433) { // No need to check cc for 127.0.0.1
83
+            return false;
84
+        }
85
+
86
+        $QueryID = G::$DB->get_query_id();
87
+        G::$DB->query("
88
+        SELECT EndIP, Code
89
+        FROM geoip_country
90
+          WHERE $Long >= StartIP
91
+          ORDER BY StartIP DESC
92
+          LIMIT 1");
93
+
94
+        if ((!list($EndIP, $Country) = G::$DB->next_record()) || $EndIP < $Long) {
95
+            $Country = '?';
96
+        }
97
+
98
+        G::$DB->set_query_id($QueryID);
99
+        $IPs[$IP] = $Country;
100
+        return $Country;
101
+    }
102
+
103
+    /**
104
+     * Gets the hostname for an IP address
105
+     *
106
+     * @param $IP the IP to get the hostname for
107
+     * @return hostname fetched
108
+     */
109
+    public static function get_host_by_ip($IP)
110
+    {
111
+        $testar = explode('.', $IP);
112
+        if (count($testar) != 4) {
113
+            return $IP;
114
+        }
115
+
116
+        for ($i = 0; $i < 4; ++$i) {
117
+            if (!is_numeric($testar[$i])) {
118
+                return $IP;
119
+            }
120
+        }
121
+
122
+        $host = `host -W 1 $IP`;
123
+        return ($host ? end(explode(' ', $host)) : $IP);
124
+    }
125
+
126
+    /**
127
+     * Gets an hostname using AJAX
128
+     *
129
+     * @param $IP the IP to fetch
130
+     * @return a span with JavaScript code
131
+     */
132
+    public static function get_host_by_ajax($IP)
133
+    {
134
+        static $ID = 0;
135
+        ++$ID;
136
+        return '<span id="host_'.$ID.'">Resolving host...<script type="text/javascript">ajax.get(\'tools.php?action=get_host&ip='.$IP.'\',function(host) {$(\'#host_'.$ID.'\').raw().innerHTML=host;});</script></span>';
137
+    }
138
+
139
+    /**
140
+     * Looks up the full host of an IP address, by system call.
141
+     * Used as the server-side counterpart to get_host_by_ajax.
142
+     *
143
+     * @param string $IP The IP address to look up.
144
+     * @return string the host.
145
+     */
146
+    public static function lookup_ip($IP)
147
+    {
148
+        // todo: Use the G::$Cache
149
+        $Output = explode(' ', shell_exec('host -W 1 '.escapeshellarg($IP)));
150
+        if (count($Output) == 1 && empty($Output[0])) {
151
+            return '';
152
+        }
153
+
154
+        if (count($Output) != 5) {
155
+            return false;
156
+        }
157
+
158
+        if ($Output[2].' '.$Output[3] == 'not found:') {
159
+            return false;
160
+        }
161
+        return trim($Output[4]);
162
+    }
163
+
164
+    /**
165
+     * Format an IP address with links to IP history.
166
+     *
167
+     * @param string IP
168
+     * @return string The HTML
169
+     */
170
+    public static function display_ip($IP)
171
+    {
172
+        $Line = display_str($IP).' ('.Tools::get_country_code_by_ajax($IP).') ';
173
+        $Line .= '<a href="user.php?action=search&amp;ip_history=on&amp;ip='.display_str($IP).'&amp;matchtype=strict" title="Search" class="brackets tooltip">S</a>';
174
+        return $Line;
175
+    }
176
+
177
+    public static function get_country_code_by_ajax($IP)
178
+    {
179
+        static $ID = 0;
180
+        ++$ID;
181
+        return '<span id="cc_'.$ID.'">Resolving CC...<script type="text/javascript">ajax.get(\'tools.php?action=get_cc&ip='.$IP.'\', function(cc) {$(\'#cc_'.$ID.'\').raw().innerHTML = cc;});</script></span>';
182
+    }
183
+
184
+
185
+    /**
186
+     * Disable an array of users.
187
+     *
188
+     * @param array $UserIDs (You can also send it one ID as an int, because fuck types)
189
+     * @param BanReason 0 - Unknown, 1 - Manual, 2 - Ratio, 3 - Inactive, 4 - Unused.
190
+     */
191
+    public static function disable_users($UserIDs, $AdminComment, $BanReason = 1)
192
+    {
193
+        $QueryID = G::$DB->get_query_id();
194
+        if (!is_array($UserIDs)) {
195
+            $UserIDs = array($UserIDs);
196
+        }
197
+
198
+        G::$DB->query("
199
+        UPDATE users_info AS i
200
+          JOIN users_main AS m ON m.ID = i.UserID
201
+        SET m.Enabled = '2',
202
+          m.can_leech = '0',
203
+          i.AdminComment = CONCAT('".sqltime()." - ".($AdminComment ? $AdminComment : 'Disabled by system')."\n\n', i.AdminComment),
204
+          i.BanDate = NOW(),
205
+          i.BanReason = '$BanReason',
206
+          i.RatioWatchDownload = ".($BanReason == 2 ? 'm.Downloaded' : "'0'")."
207
+        WHERE m.ID IN(".implode(',', $UserIDs).') ');
208
+
209
+        G::$Cache->decrement('stats_user_count', G::$DB->affected_rows());
210
+        foreach ($UserIDs as $UserID) {
211
+            G::$Cache->delete_value("enabled_$UserID");
212
+            G::$Cache->delete_value("user_info_$UserID");
213
+            G::$Cache->delete_value("user_info_heavy_$UserID");
214
+            G::$Cache->delete_value("user_stats_$UserID");
215
+
216
+            G::$DB->query("
217
+            SELECT SessionID
218
+            FROM users_sessions
219
+              WHERE UserID = '$UserID'
220
+              AND Active = 1");
221
+
222
+            while (list($SessionID) = G::$DB->next_record()) {
223
+                G::$Cache->delete_value("session_$UserID"."_$SessionID");
224
+            }
225
+            G::$Cache->delete_value("users_sessions_$UserID");
226
+
227
+            G::$DB->query("
228
+            DELETE FROM users_sessions
229
+              WHERE UserID = '$UserID'");
230
+        }
231
+
232
+        // Remove the users from the tracker.
233
+        G::$DB->query('
234
+        SELECT torrent_pass
235
+        FROM users_main
236
+          WHERE ID in ('.implode(', ', $UserIDs).')');
237
+
238
+        $PassKeys = G::$DB->collect('torrent_pass');
239
+        $Concat = '';
240
+        foreach ($PassKeys as $PassKey) {
241
+            if (strlen($Concat) > 3950) { // Ocelot's read buffer is 4 KiB and anything exceeding it is truncated
242
+                Tracker::update_tracker('remove_users', array('passkeys' => $Concat));
243
+                $Concat = $PassKey;
244
+            } else {
245
+                $Concat .= $PassKey;
246
+            }
247
+        }
248
+
249
+        Tracker::update_tracker('remove_users', array('passkeys' => $Concat));
250
+        G::$DB->set_query_id($QueryID);
251
+    }
252
+
253
+    /**
254
+     * Warn a user.
255
+     *
256
+     * @param int $UserID
257
+     * @param int $Duration length of warning in seconds
258
+     * @param string $reason
259
+     */
260
+    public static function warn_user($UserID, $Duration, $Reason)
261
+    {
262
+        global $Time;
263
+
264
+        $QueryID = G::$DB->get_query_id();
265
+        G::$DB->query("
266
+        SELECT Warned
267
+        FROM users_info
268
+          WHERE UserID = $UserID
269
+          AND Warned IS NOT NULL");
270
+          
271
+        if (G::$DB->has_results()) {
272
+            //User was already warned, appending new warning to old.
273
+            list($OldDate) = G::$DB->next_record();
274
+            $NewExpDate = date('Y-m-d H:i:s', strtotime($OldDate) + $Duration);
275
+
276
+            Misc::send_pm(
277
+                $UserID,
278
+                0,
279
+                'You have received multiple warnings.',
280
+                "When you received your latest warning (set to expire on ".date('Y-m-d', (time() + $Duration)).'), you already had a different warning (set to expire on '.date('Y-m-d', strtotime($OldDate)).").\n\n Due to this collision, your warning status will now expire at $NewExpDate."
281
+            );
282
+
283
+            $AdminComment = date('Y-m-d')." - Warning (Clash) extended to expire at $NewExpDate by " . G::$LoggedUser['Username'] . "\nReason: $Reason\n\n";
284
+
285
+            G::$DB->query('
286
+            UPDATE users_info
287
+            SET
288
+              Warned = \''.db_string($NewExpDate).'\',
289
+              WarnedTimes = WarnedTimes + 1,
290
+              AdminComment = CONCAT(\''.db_string($AdminComment).'\', AdminComment)
291
+              WHERE UserID = \''.db_string($UserID).'\'');
292
+        } else {
293
+            //Not changing, user was not already warned
294
+            $WarnTime = time_plus($Duration);
295
+
296
+            G::$Cache->begin_transaction("user_info_$UserID");
297
+            G::$Cache->update_row(false, array('Warned' => $WarnTime));
298
+            G::$Cache->commit_transaction(0);
299
+
300
+            $AdminComment = date('Y-m-d')." - Warned until $WarnTime by " . G::$LoggedUser['Username'] . "\nReason: $Reason\n\n";
301
+
302
+            G::$DB->query('
303
+            UPDATE users_info
304
+            SET
305
+              Warned = \''.db_string($WarnTime).'\',
306
+              WarnedTimes = WarnedTimes + 1,
307
+              AdminComment = CONCAT(\''.db_string($AdminComment).'\', AdminComment)
308
+              WHERE UserID = \''.db_string($UserID).'\'');
309
+        }
310
+        G::$DB->set_query_id($QueryID);
311
+    }
312
+
313
+    /**
314
+     * Update the notes of a user
315
+     * @param unknown $UserID ID of user
316
+     * @param unknown $AdminComment Comment to update with
317
+     */
318
+    public static function update_user_notes($UserID, $AdminComment)
319
+    {
320
+        $QueryID = G::$DB->get_query_id();
321
+        G::$DB->query('
322
+        UPDATE users_info
323
+        SET AdminComment = CONCAT(\''.db_string($AdminComment).'\', AdminComment)
324
+          WHERE UserID = \''.db_string($UserID).'\'');
325
+        G::$DB->set_query_id($QueryID);
326
+    }
327
+
328
+    /**
329
+    * Check if an IP address is part of a given CIDR range.
330
+    * @param string $CheckIP the IP address to be looked up
331
+    * @param string $Subnet the CIDR subnet to be checked against
332
+    */
333
+    public static function check_cidr_range($CheckIP, $Subnet)
334
+    {
335
+        $IP = ip2long($CheckIP);
336
+        $CIDR = split('/', $Subnet);
337
+        $SubnetIP = ip2long($CIDR[0]);
338
+        $SubnetMaskBits = 32 - $CIDR[1];
339
+        return (($IP>>$SubnetMaskBits) == ($SubnetIP>>$SubnetMaskBits));
340
+    }
341
+}

+ 91
- 0
classes/top10view.class.php View File

@@ -0,0 +1,91 @@
1
+<?php
2
+
3
+class Top10View
4
+{
5
+    public static function render_linkbox($Selected)
6
+    {
7
+      $ENV = ENV::go();
8
+        ?>
9
+<div class="linkbox">
10
+  <a href="top10.php?type=torrents" class="brackets"><?=self::get_selected_link("Torrents", $Selected === "torrents")?></a>
11
+  <a href="top10.php?type=users" class="brackets"><?=self::get_selected_link("Users", $Selected === "users")?></a>
12
+  <a href="top10.php?type=tags" class="brackets"><?=self::get_selected_link("Tags", $Selected === "tags")?></a>
13
+  <?php if ($ENV->FEATURE_DONATE) { ?>
14
+  <a href="top10.php?type=donors" class="brackets"><?=self::get_selected_link("Donors", $Selected === "donors")?></a>
15
+  <?php } ?>
16
+</div>
17
+<?php
18
+    }
19
+
20
+    private static function get_selected_link($String, $Selected)
21
+    {
22
+        if ($Selected) {
23
+            return "<strong>$String</strong>";
24
+        } else {
25
+            return $String;
26
+        }
27
+    }
28
+
29
+    public static function render_artist_tile($Artist, $Category)
30
+    {
31
+        if (self::is_valid_artist($Artist)) {
32
+            switch ($Category) {
33
+              case 'weekly':
34
+              case 'hyped':
35
+                self::render_tile("artist.php?artistname=", $Artist['name'], $Artist['image'][3]['#text']);
36
+                break;
37
+              default:
38
+                break;
39
+            }
40
+        }
41
+    }
42
+
43
+    private static function render_tile($Url, $Name, $Image)
44
+    {
45
+        if (!empty($Image)) {
46
+            $Name = display_str($Name); ?>
47
+<li>
48
+  <a
49
+    href="<?=$Url?><?=$Name?>">
50
+    <img class="tooltip large_tile" alt="<?=$Name?>"
51
+      title="<?=$Name?>"
52
+      src="<?=ImageTools::process($Image)?>" />
53
+  </a>
54
+</li>
55
+<?php
56
+        }
57
+    }
58
+
59
+    public static function render_artist_list($Artist, $Category)
60
+    {
61
+        if (self::is_valid_artist($Artist)) {
62
+            switch ($Category) {
63
+              case 'weekly':
64
+              case 'hyped':
65
+                self::render_list("artist.php?artistname=", $Artist['name'], $Artist['image'][3]['#text']);
66
+                break;
67
+              default:
68
+                break;
69
+            }
70
+        }
71
+    }
72
+
73
+    private static function render_list($Url, $Name, $Image)
74
+    {
75
+        if (!empty($Image)) {
76
+            $Image = ImageTools::process($Image);
77
+            $Title = "title=\"&lt;img class=&quot;large_tile&quot; src=&quot;$Image&quot; alt=&quot;&quot; /&gt;\"";
78
+            $Name = display_str($Name); ?>
79
+
80
+<li>
81
+  <a class="tooltip_image" data-title-plain="<?=$Name?>" <?=$Title?> href="<?=$Url?><?=$Name?>"><?=$Name?></a>
82
+</li>
83
+<?php
84
+        }
85
+    }
86
+
87
+    private static function is_valid_artist($Artist)
88
+    {
89
+        return $Artist['name'] !== '[unknown]';
90
+    }
91
+}

+ 348
- 0
classes/torrent.class.php View File

@@ -0,0 +1,348 @@
1
+<?php
2
+declare(strict_types=1);
3
+
4
+# todo: Rplace with https://github.com/OPSnet/bencode-torrent
5
+
6
+/*******************************************************************************
7
+|~~~~ Gazelle bencode parser                         ~~~~|
8
+--------------------------------------------------------------------------------
9
+
10
+Welcome to the Gazelle bencode parser. bencoding is the way of encoding data
11
+that bittorrent uses in torrent files. When we read the torrent files, we get
12
+one long string that must be parsed into a format we can easily edit - that's
13
+where this file comes into play.
14
+
15
+There are 4 data types in bencode:
16
+* String
17
+* Int
18
+* List - array without keys
19
+  - like array('value', 'value 2', 'value 3', 'etc')
20
+* Dictionary - array with string keys
21
+  - like array['key 1'] = 'value 1'; array['key 2'] = 'value 2';
22
+
23
+Before you go any further, we recommend reading the sections on bencoding and
24
+metainfo file structure here: http://wiki.theory.org/BitTorrentSpecification
25
+
26
+//----- How we store the data -----//
27
+
28
+* Strings
29
+  - Stored as php strings. Not difficult to remember.
30
+
31
+* Integers
32
+  - Stored as php ints
33
+  - must be casted with (int)
34
+
35
+* Lists
36
+  - Stored as a BENCODE_LIST object.
37
+  - The actual list is in BENCODE_LIST::$Val, as an array with incrementing integer indices
38
+  - The list in BENCODE_LIST::$Val is populated by the BENCODE_LIST::dec() function
39
+
40
+* Dictionaries
41
+  - Stored as a BENCODE_DICT object.
42
+  - The actual list is in BENCODE_DICT::$Val, as an array with string indices
43
+  - The list in BENCODE_DICT::$Val is populated by the BENCODE_DICT::dec() function
44
+
45
+//----- BENCODE_* Objects -----//
46
+
47
+Lists and dictionaries are stored as objects. They each have the following
48
+functions:
49
+
50
+* decode(Type, $Key)
51
+  - Decodes ANY bencoded element, given the type and the key
52
+  - Gets the position and string from $this
53
+
54
+* encode($Val)
55
+  - Encodes ANY non-bencoded element, given the value
56
+
57
+* dec()
58
+  - Decodes either a dictionary or a list, depending on where it's called from
59
+  - Uses the decode() function quite a bit
60
+
61
+* enc()
62
+  - Encodes either a dictionary or a list, depending on where it's called from
63
+  - Relies mostly on the encode() function
64
+
65
+Finally, as all torrents are just large dictionaries, the TORRENT class extends
66
+the BENCODE_DICT class.
67
+
68
+
69
+*******************************************************************************/
70
+class BENCODE2
71
+{
72
+    public $Val; // Decoded array
73
+    public $Pos = 1; // Pointer that indicates our position in the string
74
+    public $Str = ''; // Torrent string
75
+
76
+    public function __construct($Val, $IsParsed = false)
77
+    {
78
+        if (!$IsParsed) {
79
+            $this->Str = $Val;
80
+            $this->dec();
81
+        } else {
82
+            $this->Val = $Val;
83
+        }
84
+    }
85
+
86
+    // Decode an element based on the type. The type is really just an indicator.
87
+    public function decode($Type, $Key)
88
+    {
89
+        if (is_number($Type)) { // Element is a string
90
+            // Get length of string
91
+            $StrLen = $Type;
92
+            while ($this->Str[$this->Pos + 1] !== ':') {
93
+                $this->Pos++;
94
+                $StrLen.=$this->Str[$this->Pos];
95
+            }
96
+            $this->Val[$Key] = substr($this->Str, $this->Pos + 2, $StrLen);
97
+
98
+            $this->Pos += $StrLen;
99
+            $this->Pos += 2;
100
+        } elseif ($Type === 'i') { // Element is an int
101
+            $this->Pos++;
102
+
103
+            // Find end of integer (first occurance of 'e' after position)
104
+            $End = strpos($this->Str, 'e', $this->Pos);
105
+
106
+            // Get the integer, and - IMPORTANT - cast it as an int, so we know later that it's an int and not a string
107
+            $this->Val[$Key] = (int)substr($this->Str, $this->Pos, $End-$this->Pos);
108
+            $this->Pos = $End + 1;
109
+        } elseif ($Type === 'l') { // Element is a list
110
+            $this->Val[$Key] = new BENCODE_LIST(substr($this->Str, $this->Pos));
111
+            $this->Pos += $this->Val[$Key]->Pos;
112
+        } elseif ($Type === 'd') { // Element is a dictionary
113
+            $this->Val[$Key] = new BENCODE_DICT(substr($this->Str, $this->Pos));
114
+            $this->Pos += $this->Val[$Key]->Pos;
115
+            // Sort by key to respect spec
116
+            if (!empty($this->Val[$Key]->Val)) {
117
+                ksort($this->Val[$Key]->Val);
118
+            }
119
+        } else {
120
+            error('Invalid torrent file');
121
+        }
122
+    }
123
+
124
+    public function encode($Val)
125
+    {
126
+        if (is_int($Val)) { // Integer
127
+            return 'i'.$Val.'e';
128
+        } elseif (is_string($Val)) {
129
+            return strlen($Val).':'.$Val;
130
+        } elseif (is_object($Val)) {
131
+            return $Val->enc();
132
+        } else {
133
+            return 'fail';
134
+        }
135
+    }
136
+}
137
+
138
+class BENCODE_LIST extends BENCODE2
139
+{
140
+    public function enc()
141
+    {
142
+        if (empty($this->Val)) {
143
+            return 'le';
144
+        }
145
+
146
+        $Str = 'l';
147
+        reset($this->Val);
148
+
149
+        foreach ($this->Val as $Value) {
150
+            $Str.=$this->encode($Value);
151
+        }
152
+        return $Str.'e';
153
+    }
154
+
155
+    // Decode a list
156
+    public function dec()
157
+    {
158
+        $Key = 0; // Array index
159
+        $Length = strlen($this->Str);
160
+
161
+        while ($this->Pos < $Length) {
162
+            $Type = $this->Str[$this->Pos];
163
+            // $Type now indicates what type of element we're dealing with
164
+            // It's either an integer (string), 'i' (an integer), 'l' (a list), 'd' (a dictionary), or 'e' (end of dictionary/list)
165
+
166
+            if ($Type === 'e') { // End of list
167
+                $this->Pos += 1;
168
+                unset($this->Str); // Since we're finished parsing the string, we don't need to store it anymore. Benchmarked - this makes the parser run way faster
169
+                return;
170
+            }
171
+
172
+            // Decode the bencoded element
173
+            // This function changes $this->Pos and $this->Val, so you don't have to
174
+            $this->decode($Type, $Key);
175
+            ++$Key;
176
+        }
177
+        return true;
178
+    }
179
+}
180
+
181
+class BENCODE_DICT extends BENCODE2
182
+{
183
+    public function enc()
184
+    {
185
+        if (empty($this->Val)) {
186
+            return 'de';
187
+        }
188
+
189
+        $Str = 'd';
190
+        reset($this->Val);
191
+
192
+        foreach ($this->Val as $Key => $Value) {
193
+            $Str.=strlen($Key).':'.$Key.$this->encode($Value);
194
+        }
195
+        return $Str.'e';
196
+    }
197
+
198
+    // Decode a dictionary
199
+    public function dec()
200
+    {
201
+        $Length = strlen($this->Str);
202
+        while ($this->Pos<$Length) {
203
+            if ($this->Str[$this->Pos] === 'e') { // End of dictionary
204
+                $this->Pos += 1;
205
+                unset($this->Str); // Since we're finished parsing the string, we don't need to store it anymore. Benchmarked - this makes the parser run way faster
206
+                return;
207
+            }
208
+
209
+            // Get the dictionary key
210
+            // Length of the key, in bytes
211
+            $KeyLen = $this->Str[$this->Pos];
212
+
213
+            // Allow for multi-digit lengths
214
+            while ($this->Str[$this->Pos + 1] !== ':' && $this->Pos + 1 < $Length) {
215
+                $this->Pos++;
216
+                $KeyLen.=$this->Str[$this->Pos];
217
+            }
218
+            // $this->Pos is now on the last letter of the key length
219
+            // Adding 2 brings it past that character and the ':' to the beginning of the string
220
+            $this->Pos += 2;
221
+
222
+            // Get the name of the key
223
+            $Key = substr($this->Str, $this->Pos, $KeyLen);
224
+
225
+            // Move the position past the key to the beginning of the element
226
+            $this->Pos += $KeyLen;
227
+            $Type = $this->Str[$this->Pos];
228
+            // $Type now indicates what type of element we're dealing with
229
+            // It's either an integer (string), 'i' (an integer), 'l' (a list), 'd' (a dictionary), or 'e' (end of dictionary/list)
230
+
231
+            // Decode the bencoded element
232
+            // This function changes $this->Pos and $this->Val, so you don't have to
233
+            $this->decode($Type, $Key);
234
+        }
235
+        return true;
236
+    }
237
+}
238
+
239
+class TORRENT extends BENCODE_DICT
240
+{
241
+    public function dump()
242
+    {
243
+        // Convenience function used for testing and figuring out how we store the data
244
+        print_r($this->Val);
245
+    }
246
+
247
+    public function dump_data()
248
+    {
249
+        // Function which serializes $this->Val for storage
250
+        return base64_encode(serialize($this->Val));
251
+    }
252
+
253
+    public function set_announce_list($UrlsList)
254
+    {
255
+        $AnnounceList = new BENCODE_LIST([], true);
256
+        foreach ($UrlsList as $Urls) {
257
+            $SubList = new BENCODE_LIST($Urls, true);
258
+            unset($SubList->Str);
259
+            $AnnounceList->Val[] = $SubList;
260
+        }
261
+        $this->Val['announce-list'] = $AnnounceList;
262
+    }
263
+
264
+    public function set_announce_url($Announce)
265
+    {
266
+        $this->Val['announce'] = $Announce;
267
+        ksort($this->Val);
268
+    }
269
+
270
+    // Returns an array of:
271
+    //  * the files in the torrent
272
+    //  * the total size of files described therein
273
+    public function file_list()
274
+    {
275
+        $FileList = [];
276
+        if (!isset($this->Val['info']->Val['files'])) { // Single file mode
277
+            $TotalSize = $this->Val['info']->Val['length'];
278
+            $FileList[] = array($TotalSize, $this->get_name());
279
+        } else { // Multiple file mode
280
+            $FileNames = [];
281
+            $FileSizes = [];
282
+            $TotalSize = 0;
283
+            $Files = $this->Val['info']->Val['files']->Val;
284
+
285
+            if (isset($Files[0]->Val['path.utf-8'])) {
286
+                $PathKey = 'path.utf-8';
287
+            } else {
288
+                $PathKey = 'path';
289
+            }
290
+
291
+            foreach ($Files as $File) {
292
+                $FileSize = $File->Val['length'];
293
+                $TotalSize += $FileSize;
294
+
295
+                $FileName = ltrim(implode('/', $File->Val[$PathKey]->Val), '/');
296
+                $FileSizes[] = $FileSize;
297
+                $FileNames[] = $FileName;
298
+            }
299
+
300
+            natcasesort($FileNames);
301
+            foreach ($FileNames as $Index => $FileName) {
302
+                $FileList[] = array($FileSizes[$Index], $FileName);
303
+            }
304
+        }
305
+        return array($TotalSize, $FileList);
306
+    }
307
+
308
+    public function get_name()
309
+    {
310
+        if (isset($this->Val['info']->Val['name.utf-8'])) {
311
+            return $this->Val['info']->Val['name.utf-8'];
312
+        } else {
313
+            return $this->Val['info']->Val['name'];
314
+        }
315
+    }
316
+
317
+    public function make_private()
318
+    {
319
+        //----- The following properties do not affect the infohash:
320
+
321
+        // anounce-list is an unofficial extension to the protocol
322
+        // that allows for multiple trackers per torrent
323
+        unset($this->Val['announce-list']);
324
+
325
+        // Bitcomet & Azureus cache peers in here
326
+        unset($this->Val['nodes']);
327
+
328
+        // Azureus stores the dht_backup_enable flag here
329
+        unset($this->Val['azureus_properties']);
330
+
331
+        // Remove web-seeds
332
+        unset($this->Val['url-list']);
333
+
334
+        // Remove libtorrent resume info
335
+        unset($this->Val['libtorrent_resume']);
336
+
337
+        //----- End properties that do not affect the infohash
338
+        if ($this->Val['info']->Val['private']) {
339
+            return true; // Torrent is private
340
+        } else {
341
+            // Torrent is not private!
342
+            // Add private tracker flag and sort info dictionary
343
+            $this->Val['info']->Val['private'] = 1;
344
+            ksort($this->Val['info']->Val);
345
+            return false;
346
+        }
347
+    }
348
+}

+ 1494
- 0
classes/torrent_form.class.NEW.2020-12-10.php
File diff suppressed because it is too large
View File


+ 1478
- 0
classes/torrent_form.class.php
File diff suppressed because it is too large
View File


+ 1288
- 0
classes/torrents.class.php
File diff suppressed because it is too large
View File


+ 261
- 0
classes/torrentsdl.class.php View File

@@ -0,0 +1,261 @@
1
+<?php
2
+
3
+/**
4
+ * Class for functions related to the features involving torrent downloads
5
+ */
6
+class TorrentsDL
7
+{
8
+    const ChunkSize = 100;
9
+    const MaxPathLength = 200;
10
+    private $QueryResult;
11
+    private $QueryRowNum = 0;
12
+    private $Zip;
13
+    private $IDBoundaries;
14
+    private $FailedFiles = [];
15
+    private $NumAdded = 0;
16
+    private $NumFound = 0;
17
+    private $Size = 0;
18
+    private $Title;
19
+    private $User;
20
+    private $AnnounceURL;
21
+    private $AnnounceList;
22
+
23
+    /**
24
+     * Create a Zip object and store the query results
25
+     *
26
+     * @param mysqli_result $QueryResult results from a query on the collector pages
27
+     * @param string $Title name of the collection that will be created
28
+     * @param string $AnnounceURL URL to add to the created torrents
29
+     */
30
+    public function __construct(&$QueryResult, $Title)
31
+    {
32
+        G::$Cache->InternalCache = false; // The internal cache is almost completely useless for this
33
+        Zip::unlimit(); // Need more memory and longer timeout
34
+        $this->QueryResult = $QueryResult;
35
+        $this->Title = $Title;
36
+        $this->User = G::$LoggedUser;
37
+        $this->AnnounceURL = ANNOUNCE_URLS[0][0]."/".G::$LoggedUser['torrent_pass']."/announce";
38
+
39
+        function add_passkey($Ann)
40
+        {
41
+            return (is_array($Ann)) ? array_map('add_passkey', $Ann) : $Ann."/".G::$LoggedUser['torrent_pass']."/announce";
42
+        }
43
+
44
+        # todo: Probably not working, but no need yet
45
+        $this->AnnounceList = (sizeof(ANNOUNCE_URLS[0]) === 1 && sizeof(ANNOUNCE_URLS[0][0]) === 1) ? [] : array_map('add_passkey', ANNOUNCE_URLS[0]);
46
+
47
+        # Tracker tiers (pending)
48
+        #$this->AnnounceList = (sizeof(ANNOUNCE_URLS) === 1 && sizeof(ANNOUNCE_URLS[0]) === 1) ? [] : array(array_map('add_passkey', ANNOUNCE_URLS[0]), ANNOUNCE_URLS[1]);
49
+        
50
+        # Original Oppaitime
51
+        #$this->AnnounceList = (sizeof(ANNOUNCE_URLS) == 1 && sizeof(ANNOUNCE_URLS[0]) == 1) ? [] : array_map('add_passkey', ANNOUNCE_URLS);
52
+
53
+        $this->Zip = new Zip(Misc::file_string($Title));
54
+    }
55
+
56
+    /**
57
+     * Store the results from a DB query in smaller chunks to save memory
58
+     *
59
+     * @param string $Key the key to use in the result hash map
60
+     * @return array with results and torrent group IDs or false if there are no results left
61
+     */
62
+    public function get_downloads($Key)
63
+    {
64
+        $GroupIDs = $Downloads = [];
65
+        $OldQuery = G::$DB->get_query_id();
66
+        G::$DB->set_query_id($this->QueryResult);
67
+
68
+        if (!isset($this->IDBoundaries)) {
69
+            if ($Key === 'TorrentID') {
70
+                $this->IDBoundaries = false;
71
+            } else {
72
+                $this->IDBoundaries = G::$DB->to_pair($Key, 'TorrentID', false);
73
+            }
74
+        }
75
+
76
+        $Found = 0;
77
+        while ($Download = G::$DB->next_record(MYSQLI_ASSOC, false)) {
78
+            if (!$this->IDBoundaries || $Download['TorrentID'] === $this->IDBoundaries[$Download[$Key]]) {
79
+                $Found++;
80
+                $Downloads[$Download[$Key]] = $Download;
81
+                $GroupIDs[$Download['TorrentID']] = $Download['GroupID'];
82
+
83
+                if ($Found >= self::ChunkSize) {
84
+                    break;
85
+                }
86
+            }
87
+        }
88
+
89
+        $this->NumFound += $Found;
90
+        G::$DB->set_query_id($OldQuery);
91
+
92
+        if (empty($Downloads)) {
93
+            return false;
94
+        }
95
+
96
+        return array($Downloads, $GroupIDs);
97
+    }
98
+
99
+    /**
100
+     * Add a file to the zip archive
101
+     *
102
+     * @param string $TorrentData bencoded torrent without announce url (new format) or TORRENT object (old format)
103
+     * @param array $Info file info stored as an array with at least the keys
104
+     *  Artist, Name, Year, Media, Format, Encoding and TorrentID
105
+     * @param string $FolderName folder name
106
+     */
107
+    public function add_file(&$TorrentData, $Info, $FolderName = '')
108
+    {
109
+        $FolderName = Misc::file_string($FolderName);
110
+        $MaxPathLength = $FolderName ? (self::MaxPathLength - strlen($FolderName) - 1) : self::MaxPathLength;
111
+        $FileName = self::construct_file_name($Info['TorrentID'], $MaxPathLength);
112
+
113
+        $this->Size += $Info['Size'];
114
+        $this->NumAdded++;
115
+        $this->Zip->add_file(self::get_file($TorrentData, $this->AnnounceURL, $this->AnnounceList), ($FolderName ? "$FolderName/" : "") . $FileName);
116
+
117
+        usleep(25000); // We don't want to send much faster than the client can receive
118
+        return null;
119
+    }
120
+
121
+    /**
122
+     * Add a file to the list of files that could not be downloaded
123
+     *
124
+     * @param array $Info file info stored as an array with at least the keys Artist, Name and Year
125
+     */
126
+    public function fail_file($Info)
127
+    {
128
+        $this->FailedFiles[] = $Info['Artist'] . ' - ' . $Info['Name'] . ' - ' . $Info['Year'];
129
+    }
130
+
131
+    /**
132
+     * Add a file to the list of files that did not match the user's format or quality requirements
133
+     *
134
+     * @param array $Info file info stored as an array with at least the keys Artist, Name and Year
135
+     */
136
+    public function skip_file($Info)
137
+    {
138
+        $this->SkippedFiles[] = $Info['Artist'] . ' - ' . $Info['Name'] . ' - ' . $Info['Year'];
139
+    }
140
+
141
+    /**
142
+     * Add a summary to the archive and include a list of files that could not be added. Close the zip archive
143
+     *
144
+     * @param bool $FilterStats whether to include filter stats in the report
145
+     */
146
+    public function finalize($FilterStats = true)
147
+    {
148
+        $this->Zip->add_file($this->summary($FilterStats), "Summary.txt");
149
+        if (!empty($this->FailedFiles)) {
150
+            $this->Zip->add_file($this->errors(), "Errors.txt");
151
+        }
152
+        $this->Zip->close_stream();
153
+    }
154
+
155
+    /**
156
+     * Produce a summary text over the collector results
157
+     *
158
+     * @param bool $FilterStats whether to include filter stats in the report
159
+     * @return summary text
160
+     */
161
+    public function summary($FilterStats)
162
+    {
163
+        $ENV = ENV::go;
164
+        global $ScriptStartTime;
165
+        
166
+        $Time = number_format(1000 * (microtime(true) - $ScriptStartTime), 2)." ms";
167
+        $Used = Format::get_size(memory_get_usage(true));
168
+        $Date = date("M d Y, H:i");
169
+        $NumSkipped = count($this->SkippedFiles);
170
+
171
+        # wtf
172
+        return "Collector Download Summary for $this->Title - $ENV->SITE_NAME\r\n"
173
+      . "\r\n"
174
+      . "User:    {$this->User[Username]}\r\n"
175
+      . "Passkey: {$this->User[torrent_pass]}\r\n"
176
+      . "\r\n"
177
+      . "Time:    $Time\r\n"
178
+      . "Used:    $Used\r\n"
179
+      . "Date:    $Date\r\n"
180
+      . "\r\n"
181
+
182
+      . ($FilterStats !== false
183
+        ? "Torrent groups analyzed: $this->NumFound\r\n"
184
+          . "Torrent groups filtered: $NumSkipped\r\n"
185
+        : "")
186
+
187
+      . "Torrents downloaded:   $this->NumAdded\r\n"
188
+      . "\r\n"
189
+      . "Total size of torrents (ratio hit): ".Format::get_size($this->Size)."\r\n"
190
+
191
+      . ($NumSkipped
192
+        ? "\r\n"
193
+          . "Albums unavailable within your criteria (consider making a request for your desired format):\r\n"
194
+          . implode("\r\n", $this->SkippedFiles) . "\r\n"
195
+        : "");
196
+    }
197
+
198
+    /**
199
+     * Compile a list of files that could not be added to the archive
200
+     *
201
+     * @return list of files
202
+     */
203
+    public function errors()
204
+    {
205
+        return "A server error occurred. Please try again at a later time.\r\n"
206
+      . "\r\n"
207
+      . "The following torrents could not be downloaded:\r\n"
208
+      . implode("\r\n", $this->FailedFiles) . "\r\n";
209
+    }
210
+
211
+    /**
212
+     * Combine a bunch of torrent info into a standardized file name
213
+     *
214
+     * @params most input variables are self-explanatory
215
+     * @param int $TorrentID if given, append "-TorrentID" to torrent name
216
+     * @param int $MaxLength maximum file name length
217
+     * @return file name with at most $MaxLength characters
218
+     */
219
+    public static function construct_file_name($TorrentID = false, $MaxLength = self::MaxPathLength)
220
+    {
221
+        $MaxLength -= 8; // ".torrent"
222
+        if ($TorrentID !== false) {
223
+            $MaxLength -= (strlen($TorrentID) + 1);
224
+        }
225
+        return "$TorrentID.torrent";
226
+
227
+        /*
228
+        $TorrentArtist = Misc::file_string($Artist);
229
+        $TorrentName = Misc::file_string($Album);
230
+        */
231
+    }
232
+
233
+    /**
234
+     * Convert a stored torrent into a binary file that can be loaded in a torrent client
235
+     *
236
+     * @param mixed $TorrentData bencoded torrent without announce URL (new format) or TORRENT object (old format)
237
+     * @return bencoded string
238
+     */
239
+    public static function get_file(&$TorrentData, $AnnounceURL, $AnnounceList = [])
240
+    {
241
+        if (Misc::is_new_torrent($TorrentData)) {
242
+            $Bencode = BencodeTorrent::add_announce_url($TorrentData, $AnnounceURL);
243
+            if (!empty($AnnounceList)) {
244
+                $Bencode = BencodeTorrent::add_announce_list($Bencode, $AnnounceList);
245
+            }
246
+            return $Bencode;
247
+        }
248
+
249
+        $Tor = new TORRENT(unserialize(base64_decode($TorrentData)), true);
250
+        $Tor->set_announce_url($AnnounceURL);
251
+
252
+        unset($Tor->Val['announce-list']);
253
+        if (!empty($AnnounceList)) {
254
+            $Tor->set_announce_list($AnnounceList);
255
+        }
256
+        
257
+        unset($Tor->Val['url-list']);
258
+        unset($Tor->Val['libtorrent_resume']);
259
+        return $Tor->enc();
260
+    }
261
+}

+ 737
- 0
classes/torrentsearch.class.php View File

@@ -0,0 +1,737 @@
1
+<?php
2
+#declare(strict_types=1);
3
+
4
+class TorrentSearch
5
+{
6
+    const TAGS_ANY = 0;
7
+    const TAGS_ALL = 1;
8
+    const SPH_BOOL_AND = ' ';
9
+    const SPH_BOOL_OR = ' | ';
10
+
11
+    // Map of sort mode => attribute name for ungrouped torrent page
12
+    public static $SortOrders = [
13
+        'year' => 'year',
14
+        'time' => 'id',
15
+        'size' => 'size',
16
+        'seeders' => 'seeders',
17
+        'leechers' => 'leechers',
18
+        'snatched' => 'snatched',
19
+        'cataloguenumber' => 'cataloguenumber',
20
+        'random' => 1
21
+    ];
22
+
23
+    // Map of sort mode => attribute name for grouped torrent page
24
+    private static $SortOrdersGrouped = [
25
+        'year' => 'year',
26
+        'time' => 'id',
27
+        'size' => 'maxsize',
28
+        'seeders' => 'sumseeders',
29
+        'leechers' => 'sumleechers',
30
+        'snatched' => 'sumsnatched',
31
+        'cataloguenumber' => 'cataloguenumber',
32
+        'random' => 1
33
+    ];
34
+
35
+    // Map of sort mode => aggregate expression required for some grouped sort orders
36
+    private static $AggregateExp = [
37
+        'size' => 'MAX(size) AS maxsize',
38
+        'seeders' => 'SUM(seeders) AS sumseeders',
39
+        'leechers' => 'SUM(leechers) AS sumleechers',
40
+        'snatched' => 'SUM(snatched) AS sumsnatched'
41
+    ];
42
+
43
+    // Map of attribute name => global variable name with list of values that can be used for filtering
44
+    private static $Attributes = [
45
+        'filter_cat' => false,
46
+        'releasetype' => 'ReleaseTypes',
47
+        'freetorrent' => false,
48
+        'censored' => false,
49
+        'size_unit' => false,
50
+        'year' => false
51
+    ];
52
+
53
+    // List of fields that can be used for fulltext searches
54
+    private static $Fields = [
55
+        'artistname' => 1, # Author
56
+        'audioformat' => 1, # Version
57
+        'cataloguenumber' => 1, # Accession Number
58
+        'numbers' => 1, # Combined &uarr;
59
+        'codec' => 1, # License
60
+        'container' => 1, # Format
61
+        'archive' => 0, # todo
62
+        'description' => 1, # Not group desc
63
+        'filelist' => 1,
64
+        'groupname' => 1, # Title
65
+        'GroupTitle2' => 1, # Organism
66
+        'Groupnamejp' => 1, # Strain
67
+        'advgroupname' => 1,
68
+        'media' => 1, # Platform
69
+        'resolution' => 1, # Scope
70
+        'searchstr' => 1,
71
+        'series' => 1, # Location
72
+        'studio' => 1, # Department/Lab
73
+        'location' => 1, # Combined &uarr;
74
+        'taglist' => 1
75
+  ];
76
+
77
+    // List of torrent-specific fields that can be used for filtering
78
+    private static $TorrentFields = [
79
+        'description' => 1,
80
+        'encoding' => 1,
81
+        'censored' => 1,
82
+        'filelist' => 1,
83
+        'format' => 1,
84
+        'media' => 1
85
+  ];
86
+
87
+    // Some form field names don't match the ones in the index
88
+    private static $FormsToFields = [
89
+        # todo: Keep testing the granularity of filter combos
90
+        'searchstr' => '*',
91
+        'advgroupname' => '*', # todo: Fix this ;)
92
+        'numbers' => '(cataloguenumber,audioformat)',
93
+        'location' => '(studio,series)',
94
+        #'searchstr' => '(groupname,GroupTitle2,groupnamejp,artistname,studio,series,cataloguenumber,yearfulltext)',
95
+        #'advgroupname' => '(groupname,GroupTitle2,groupnamejp)',
96
+  ];
97
+
98
+    // Specify the operator type to use for fields. Empty key sets the default
99
+    private static $FieldOperators = [
100
+        '' => self::SPH_BOOL_AND,
101
+        'encoding' => self::SPH_BOOL_OR,
102
+        'format' => self::SPH_BOOL_OR,
103
+        'media' => self::SPH_BOOL_OR
104
+    ];
105
+
106
+    // Specify the separator character to use for fields. Empty key sets the default
107
+    private static $FieldSeparators = [
108
+        '' => ' ',
109
+        'encoding' => '|',
110
+        'format' => '|',
111
+        'media' => '|',
112
+        'taglist' => ','
113
+    ];
114
+
115
+    // Primary SphinxqlQuery object used to get group IDs or torrent IDs for ungrouped searches
116
+    private $SphQL;
117
+    // Second SphinxqlQuery object used to get torrent IDs if torrent-specific fulltext filters are used
118
+    private $SphQLTor;
119
+    // Ordered result array or false if query resulted in an error
120
+    private $SphResults;
121
+    // Requested page
122
+    private $Page;
123
+    // Number of results per page
124
+    private $PageSize;
125
+    // Number of results
126
+    private $NumResults = 0;
127
+    // Array with info from all matching torrent groups
128
+    private $Groups = [];
129
+    // Whether any filters were used
130
+    private $Filtered = false;
131
+    // Whether the random sort order is selected
132
+    private $Random = false;
133
+
134
+    /*
135
+     * Storage for fulltext search terms
136
+     * ['Field name' => [
137
+     *     'include' => [],
138
+     *     'exclude' => [],
139
+     *     'operator' => self::SPH_BOOL_AND | self::SPH_BOOL_OR
140
+     * ]], ...
141
+     */
142
+    private $Terms = [];
143
+    // Unprocessed search terms for retrieval
144
+    private $RawTerms = [];
145
+    // Storage for used torrent-specific attribute filters
146
+    // ['Field name' => 'Search expression', ...]
147
+    private $UsedTorrentAttrs = [];
148
+    // Storage for used torrent-specific fulltext fields
149
+    // ['Field name' => 'Search expression', ...]
150
+    private $UsedTorrentFields = [];
151
+
152
+    /**
153
+     * Initialize and configure a TorrentSearch object
154
+     *
155
+     * @param bool $GroupResults whether results should be grouped by group id
156
+     * @param string $OrderBy attribute to use for sorting the results
157
+     * @param string $OrderWay Whether to use ascending or descending order
158
+     * @param int $Page Page number to display
159
+     * @param int $PageSize Number of results per page
160
+     */
161
+    public function __construct($GroupResults, $OrderBy, $OrderWay, $Page, $PageSize)
162
+    {
163
+        if ($GroupResults && !isset(self::$SortOrdersGrouped[$OrderBy])
164
+        || !$GroupResults && !isset(self::$SortOrders[$OrderBy])
165
+        || !in_array($OrderWay, ['asc', 'desc'])
166
+    ) {
167
+            global $Debug;
168
+            $ErrMsg = "TorrentSearch constructor arguments:\n" . print_r(func_get_args(), true);
169
+            $Debug->analysis('Bad arguments in TorrentSearch constructor', $ErrMsg, 3600*24);
170
+            error(-1);
171
+        }
172
+
173
+        if (!is_number($Page) || $Page < 1) {
174
+            $Page = 1;
175
+        }
176
+
177
+        if (check_perms('site_search_many')) {
178
+            $this->Page = $Page;
179
+        } else {
180
+            $this->Page = min($Page, SPHINX_MAX_MATCHES / $PageSize);
181
+        }
182
+
183
+        $ResultLimit = $PageSize;
184
+        $this->PageSize = $PageSize;
185
+        $this->GroupResults = $GroupResults;
186
+        $this->SphQL = new SphinxqlQuery();
187
+        $this->SphQL->where_match('_all', 'fake', false);
188
+
189
+        if ($OrderBy === 'random') {
190
+            $this->SphQL->select('id, groupid')
191
+        ->order_by('RAND()', '');
192
+            $this->Random = true;
193
+            $this->Page = 1;
194
+            if ($GroupResults) {
195
+                // Get more results because ORDER BY RAND() can't be used in GROUP BY queries
196
+                $ResultLimit *= 5;
197
+            }
198
+        } elseif ($GroupResults) {
199
+            $Select = 'groupid';
200
+            if (isset(self::$AggregateExp[$OrderBy])) {
201
+                $Select .= ', ' . self::$AggregateExp[$OrderBy];
202
+            }
203
+            $this->SphQL->select($Select)
204
+        ->group_by('groupid')
205
+        ->order_group_by(self::$SortOrdersGrouped[$OrderBy], $OrderWay)
206
+        ->order_by(self::$SortOrdersGrouped[$OrderBy], $OrderWay);
207
+        } else {
208
+            $this->SphQL->select('id, groupid')
209
+        ->order_by(self::$SortOrders[$OrderBy], $OrderWay);
210
+        }
211
+
212
+        $Offset = ($this->Page - 1) * $ResultLimit;
213
+        $MinMax = G::$Cache->get_value('sphinx_min_max_matches');
214
+        $MaxMatches = max($Offset + $ResultLimit, $MinMax ? $MinMax : 1000); # todo: Keep an eye on this
215
+        $this->SphQL->from('torrents, delta')
216
+      ->limit($Offset, $ResultLimit, $MaxMatches);
217
+    }
218
+
219
+    /**
220
+     * Process search terms and run the main query
221
+     *
222
+     * @param array $Terms Array containing all search terms (e.g. $_GET)
223
+     * @return array List of matching group IDs with torrent ID as key for ungrouped results
224
+     */
225
+    public function query($Terms = [])
226
+    {
227
+        $this->process_search_terms($Terms);
228
+        $this->build_query();
229
+        $this->run_query();
230
+        $this->process_results();
231
+        return $this->SphResults;
232
+    }
233
+
234
+    public function insert_hidden_tags($tags)
235
+    {
236
+        $this->SphQL->where_match($tags, 'taglist', false);
237
+    }
238
+
239
+    /**
240
+     * Internal function that runs the queries needed to get the desired results
241
+     */
242
+    private function run_query()
243
+    {
244
+        $SphQLResult = $this->SphQL->query();
245
+        if ($SphQLResult->Errno > 0) {
246
+            $this->SphResults = false;
247
+            return;
248
+        }
249
+
250
+        if ($this->Random && $this->GroupResults) {
251
+            $TotalCount = $SphQLResult->get_meta('total_found');
252
+            $this->SphResults = $SphQLResult->collect('groupid');
253
+            $GroupIDs = array_keys($this->SphResults);
254
+            $GroupCount = count($GroupIDs);
255
+            while ($SphQLResult->get_meta('total') < $TotalCount && $GroupCount < $this->PageSize) {
256
+                // Make sure we get $PageSize results, or all of them if there are less than $PageSize hits
257
+                $this->SphQL->where('groupid', $GroupIDs, true);
258
+                $SphQLResult = $this->SphQL->query();
259
+
260
+                if (!$SphQLResult->has_results()) {
261
+                    break;
262
+                }
263
+
264
+                $this->SphResults += $SphQLResult->collect('groupid');
265
+                $GroupIDs = array_keys($this->SphResults);
266
+                $GroupCount = count($GroupIDs);
267
+            }
268
+
269
+            if ($GroupCount > $this->PageSize) {
270
+                $this->SphResults = array_slice($this->SphResults, 0, $this->PageSize, true);
271
+            }
272
+            $this->NumResults = count($this->SphResults);
273
+        } else {
274
+            $this->NumResults = (int)$SphQLResult->get_meta('total_found');
275
+            if ($this->GroupResults) {
276
+                $this->SphResults = $SphQLResult->collect('groupid');
277
+            } else {
278
+                $this->SphResults = $SphQLResult->to_pair('id', 'groupid');
279
+            }
280
+        }
281
+    }
282
+
283
+    /**
284
+     * Process search terms and store the parts in appropriate arrays until we know if
285
+     * the NOT operator can be used
286
+     */
287
+    private function build_query()
288
+    {
289
+        foreach ($this->Terms as $Field => $Words) {
290
+            $SearchString = '';
291
+
292
+            if (isset(self::$FormsToFields[$Field])) {
293
+                $Field = self::$FormsToFields[$Field];
294
+            }
295
+
296
+            $QueryParts = ['include' => [], 'exclude' => []];
297
+            if (!empty($Words['include'])) {
298
+                foreach ($Words['include'] as $Word) {
299
+                    $QueryParts['include'][] = Sphinxql::sph_escape_string($Word);
300
+                }
301
+            }
302
+
303
+            if (!empty($Words['exclude'])) {
304
+                foreach ($Words['exclude'] as $Word) {
305
+                    $QueryParts['exclude'][] = '!' . Sphinxql::sph_escape_string(substr($Word, 1));
306
+                }
307
+            }
308
+
309
+            if (!empty($QueryParts)) {
310
+                if (isset($Words['operator'])) {
311
+                    // Is the operator already specified?
312
+                    $Operator = $Words['operator'];
313
+                } elseif (isset(self::$FieldOperators[$Field])) {
314
+                    // Does this field have a non-standard operator?
315
+                    $Operator = self::$FieldOperators[$Field];
316
+                } else {
317
+                    // Go for the default operator
318
+                    $Operator = self::$FieldOperators[''];
319
+                }
320
+
321
+                if (!empty($QueryParts['include'])) {
322
+                    if ($Field === 'taglist') {
323
+                        foreach ($QueryParts['include'] as $key => $Tag) {
324
+                            $QueryParts['include'][$key] = '( '.$Tag.' | '.$Tag.':* )';
325
+                        }
326
+                    }
327
+                    $SearchString .= '( ' . implode($Operator, $QueryParts['include']) . ' ) ';
328
+                }
329
+
330
+                if (!empty($QueryParts['exclude'])) {
331
+                    $SearchString .= implode(' ', $QueryParts['exclude']);
332
+                }
333
+
334
+                $this->SphQL->where_match($SearchString, $Field, false);
335
+                if (isset(self::$TorrentFields[$Field])) {
336
+                    $this->UsedTorrentFields[$Field] = $SearchString;
337
+                }
338
+                $this->Filtered = true;
339
+            }
340
+        }
341
+    }
342
+
343
+    /**
344
+     * Look at each search term and figure out what to do with it
345
+     *
346
+     * @param array $Terms Array with search terms from query()
347
+     */
348
+    private function process_search_terms($Terms)
349
+    {
350
+        foreach ($Terms as $Key => $Term) {
351
+            if (isset(self::$Fields[$Key])) {
352
+                $this->process_field($Key, $Term);
353
+            } elseif (isset(self::$Attributes[$Key])) {
354
+                $this->process_attribute($Key, $Term);
355
+            }
356
+            $this->RawTerms[$Key] = $Term;
357
+        }
358
+        $this->post_process();
359
+    }
360
+
361
+    /**
362
+     * Process attribute filters and store them in case we need to post-process grouped results
363
+     *
364
+     * @param string $Attribute Name of the attribute to filter against
365
+     * @param mixed $Value The filter's condition for a match
366
+     */
367
+    private function process_attribute($Attribute, $Value)
368
+    {
369
+        if ($Value === '') {
370
+            return;
371
+        }
372
+
373
+        if ($Attribute === 'year') {
374
+            $this->search_year($Value);
375
+        } elseif ($Attribute === 'size_unit') {
376
+            // For the record, size_unit must appear in the GET parameters after size_min and size_max for this to work. Sorry.
377
+            if (is_numeric($this->RawTerms['size_min']) || is_numeric($this->RawTerms['size_max'])) {
378
+                $this->SphQL->where_between('size', [intval(($this->RawTerms['size_min'] ?? 0)*(1024**$Value)), intval(min(PHP_INT_MAX, ($this->RawTerms['size_max'] ?? INF)*(1024**$Value)))]);
379
+            }
380
+        } elseif ($Attribute === 'freetorrent') {
381
+            if ($Value === 3) {
382
+                $this->SphQL->where('freetorrent', 0, true);
383
+                $this->UsedTorrentAttrs['freetorrent'] = 3;
384
+            } elseif ($Value >= 0 && $Value < 3) {
385
+                $this->SphQL->where('freetorrent', $Value);
386
+                $this->UsedTorrentAttrs[$Attribute] = $Value;
387
+            }
388
+        } elseif ($Attribute === 'filter_cat') {
389
+            if (!is_array($Value)) {
390
+                $Value = array_fill_keys(explode('|', $Value), 1);
391
+            }
392
+            $CategoryFilter = [];
393
+            foreach (array_keys($Value) as $Category) {
394
+                if (is_number($Category)) {
395
+                    $CategoryFilter[] = $Category;
396
+                } else {
397
+                    global $Categories;
398
+                    $ValidValues = array_map('strtolower', $Categories);
399
+                    if (($CategoryID = array_search(strtolower($Category), $ValidValues)) !== false) {
400
+                        $CategoryFilter[] = $CategoryID + 1;
401
+                    }
402
+                }
403
+            }
404
+            $this->SphQL->where('categoryid', ($CategoryFilter ?? 0));
405
+        } else {
406
+            if (!is_number($Value) && self::$Attributes[$Attribute] !== false) {
407
+                // Check if the submitted value can be converted to a valid one
408
+                $ValidValuesVarname = self::$Attributes[$Attribute];
409
+                global $$ValidValuesVarname;
410
+                $ValidValues = array_map('strtolower', $$ValidValuesVarname);
411
+                if (($Value = array_search(strtolower($Value), $ValidValues)) === false) {
412
+                    // Force the query to return 0 results if value is still invalid
413
+                    $Value = max(array_keys($ValidValues)) + 1;
414
+                }
415
+            }
416
+            $this->SphQL->where($Attribute, $Value);
417
+            $this->UsedTorrentAttrs[$Attribute] = $Value;
418
+        }
419
+
420
+        $this->Filtered = true;
421
+    }
422
+
423
+    /**
424
+     * Look at a fulltext search term and figure out if it needs special treatment
425
+     *
426
+     * @param string $Field Name of the search field
427
+     * @param string $Term Search expression for the field
428
+     */
429
+    private function process_field($Field, $Term)
430
+    {
431
+        $Term = trim($Term);
432
+        if ($Term === '') {
433
+            return;
434
+        }
435
+
436
+        if ($Field === 'searchstr') {
437
+            $this->search_basic($Term);
438
+        } elseif ($Field === 'filelist') {
439
+            $this->search_filelist($Term);
440
+        } elseif ($Field === 'taglist') {
441
+            $this->search_taglist($Term);
442
+        } else {
443
+            $this->add_field($Field, $Term);
444
+        }
445
+    }
446
+
447
+    /**
448
+     * Some fields may require post-processing
449
+     */
450
+    private function post_process()
451
+    {
452
+        if (isset($this->Terms['taglist'])) {
453
+            // Replace bad tags with tag aliases
454
+            $this->Terms['taglist'] = Tags::remove_aliases($this->Terms['taglist']);
455
+            if (isset($this->RawTerms['tags_type']) && (int)$this->RawTerms['tags_type'] === self::TAGS_ANY) {
456
+                $this->Terms['taglist']['operator'] = self::SPH_BOOL_OR;
457
+            }
458
+
459
+            // Update the RawTerms array so get_terms() can return the corrected search terms
460
+            if (isset($this->Terms['taglist']['include'])) {
461
+                $AllTags = $this->Terms['taglist']['include'];
462
+            } else {
463
+                $AllTags = [];
464
+            }
465
+
466
+            if (isset($this->Terms['taglist']['exclude'])) {
467
+                $AllTags = array_merge($AllTags, $this->Terms['taglist']['exclude']);
468
+            }
469
+            $this->RawTerms['taglist'] = str_replace('_', '.', implode(', ', $AllTags));
470
+        }
471
+    }
472
+
473
+    /**
474
+     * Handle magic keywords in the basic torrent search
475
+     *
476
+     * @param string $Term Given search expression
477
+     */
478
+    private function search_basic($Term)
479
+    {
480
+        global $Bitrates, $Formats, $Media;
481
+        #$SearchBitrates = array_map('strtolower', $Bitrates);
482
+        #array_push($SearchBitrates, 'v0', 'v1', 'v2', '24bit');
483
+        #$SearchFormats = array_map('strtolower', $Formats);
484
+        $SearchMedia = array_map('strtolower', $Media);
485
+
486
+        foreach (explode(' ', $Term) as $Word) {
487
+            /*
488
+            if (in_array($Word, $SearchBitrates)) {
489
+              $this->add_word('encoding', $Word);
490
+            } elseif (in_array($Word, $SearchFormats)) {
491
+              $this->add_word('format', $Word);
492
+            }
493
+            */
494
+
495
+            if (in_array($Word, $SearchMedia)) {
496
+                $this->add_word('media', $Word);
497
+            } else {
498
+                $this->add_word('searchstr', $Word);
499
+            }
500
+        }
501
+    }
502
+
503
+    /**
504
+     * Use phrase boundary for file searches to make sure we don't count
505
+     * partial hits from multiple files
506
+     *
507
+     * @param string $Term Given search expression
508
+     */
509
+    private function search_filelist($Term)
510
+    {
511
+        $SearchString = '"' . Sphinxql::sph_escape_string($Term) . '"~20';
512
+        $this->SphQL->where_match($SearchString, 'filelist', false);
513
+        $this->UsedTorrentFields['filelist'] = $SearchString;
514
+        $this->Filtered = true;
515
+    }
516
+
517
+    /**
518
+     * Prepare tag searches before sending them to the normal treatment
519
+     *
520
+     * @param string $Term Given search expression
521
+     */
522
+    private function search_taglist($Term)
523
+    {
524
+        $Term = strtr($Term, '.', '_');
525
+        $this->add_field('taglist', $Term);
526
+    }
527
+
528
+    /**
529
+     * The year filter accepts a range. Figure out how to handle the filter value
530
+     *
531
+     * @param string $Term Filter condition. Can be an integer or a range with the format X-Y
532
+     * @return bool True if parameters are valid
533
+     */
534
+    private function search_year($Term)
535
+    {
536
+        $Years = explode('-', $Term);
537
+        if (count($Years) === 1 && is_number($Years[0])) {
538
+            // Exact year
539
+            $this->SphQL->where('year', $Years[0]);
540
+        } elseif (count($Years) === 2) {
541
+            if (empty($Years[0]) && is_number($Years[1])) {
542
+                // Range: 0 - 2005
543
+                $this->SphQL->where_lt('year', $Years[1], true);
544
+            } elseif (empty($Years[1]) && is_number($Years[0])) {
545
+                // Range: 2005 - 2^32-1
546
+                $this->SphQL->where_gt('year', $Years[0], true);
547
+            } elseif (is_number($Years[0]) && is_number($Years[1])) {
548
+                // Range: 2005 - 2009
549
+                $this->SphQL->where_between('year', [min($Years), max($Years)]);
550
+            } else {
551
+                // Invalid input
552
+                return false;
553
+            }
554
+        } else {
555
+            // Invalid input
556
+            return false;
557
+        }
558
+        return true;
559
+    }
560
+
561
+    /**
562
+     * Add a field filter that doesn't need special treatment
563
+     *
564
+     * @param string $Field Name of the search field
565
+     * @param string $Term Search expression for the field
566
+     */
567
+    private function add_field($Field, $Term)
568
+    {
569
+        if (isset(self::$FieldSeparators[$Field])) {
570
+            $Separator = self::$FieldSeparators[$Field];
571
+        } else {
572
+            $Separator = self::$FieldSeparators[''];
573
+        }
574
+
575
+        $Words = explode($Separator, $Term);
576
+        foreach ($Words as $Word) {
577
+            $this->add_word($Field, $Word);
578
+        }
579
+    }
580
+
581
+    /**
582
+     * Add a keyword to the array of search terms
583
+     *
584
+     * @param string $Field Name of the search field
585
+     * @param string $Word Keyword
586
+     */
587
+    private function add_word($Field, $Word)
588
+    {
589
+        $Word = trim($Word);
590
+        // Skip isolated hyphens to enable "Artist - Title" searches
591
+        if ($Word === '' || $Word === '-') {
592
+            return;
593
+        }
594
+
595
+        if ($Word[0] === '!' && strlen($Word) >= 2 && strpos($Word, '!', 1) === false) {
596
+            $this->Terms[$Field]['exclude'][] = $Word;
597
+        } else {
598
+            $this->Terms[$Field]['include'][] = $Word;
599
+        }
600
+    }
601
+
602
+    /**
603
+     * @return array Torrent group information for the matches from Torrents::get_groups
604
+     */
605
+    public function get_groups()
606
+    {
607
+        return $this->Groups;
608
+    }
609
+
610
+    /**
611
+     * @param string $Type Field or attribute name
612
+     * @return string Unprocessed search terms
613
+     */
614
+    public function get_terms($Type)
615
+    {
616
+        return $this->RawTerms[$Type] ?? '';
617
+    }
618
+
619
+    /**
620
+     * @return int Result count
621
+     */
622
+    public function record_count()
623
+    {
624
+        return $this->NumResults;
625
+    }
626
+
627
+    /**
628
+     * @return bool Whether any filters were used
629
+     */
630
+    public function has_filters()
631
+    {
632
+        return $this->Filtered;
633
+    }
634
+
635
+    /**
636
+     * @return bool Whether any torrent-specific fulltext filters were used
637
+     */
638
+    public function need_torrent_ft()
639
+    {
640
+        return $this->GroupResults && $this->NumResults > 0 && !empty($this->UsedTorrentFields);
641
+    }
642
+
643
+    /**
644
+     * Get torrent group info and remove any torrents that don't match
645
+     */
646
+    private function process_results()
647
+    {
648
+        if (count($this->SphResults) === 0) {
649
+            return;
650
+        }
651
+
652
+        $this->Groups = Torrents::get_groups($this->SphResults);
653
+        if ($this->need_torrent_ft()) {
654
+            // Query Sphinx for torrent IDs if torrent-specific fulltext filters were used
655
+            $this->filter_torrents_sph();
656
+        } elseif ($this->GroupResults) {
657
+            // Otherwise, let PHP discard unmatching torrents
658
+            $this->filter_torrents_internal();
659
+        }
660
+        // Ungrouped searches don't need any additional filtering
661
+    }
662
+
663
+    /**
664
+     * Build and run a query that gets torrent IDs from Sphinx when fulltext filters
665
+     * were used to get primary results and they are grouped
666
+     */
667
+    private function filter_torrents_sph()
668
+    {
669
+        $AllTorrents = [];
670
+        foreach ($this->Groups as $GroupID => $Group) {
671
+            if (!empty($Group['Torrents'])) {
672
+                $AllTorrents += array_fill_keys(array_keys($Group['Torrents']), $GroupID);
673
+            }
674
+        }
675
+
676
+        $TorrentCount = count($AllTorrents);
677
+        $this->SphQLTor = new SphinxqlQuery();
678
+        $this->SphQLTor->where_match('_all', 'fake', false);
679
+        $this->SphQLTor->select('id')->from('torrents, delta');
680
+
681
+        foreach ($this->UsedTorrentFields as $Field => $Term) {
682
+            $this->SphQLTor->where_match($Term, $Field, false);
683
+        }
684
+
685
+        $this->SphQLTor->copy_attributes_from($this->SphQL);
686
+        $this->SphQLTor->where('id', array_keys($AllTorrents))->limit(0, $TorrentCount, $TorrentCount);
687
+        $SphQLResultTor = $this->SphQLTor->query();
688
+        $MatchingTorrentIDs = $SphQLResultTor->to_pair('id', 'id');
689
+
690
+        foreach ($AllTorrents as $TorrentID => $GroupID) {
691
+            if (!isset($MatchingTorrentIDs[$TorrentID])) {
692
+                unset($this->Groups[$GroupID]['Torrents'][$TorrentID]);
693
+            }
694
+        }
695
+    }
696
+
697
+    /**
698
+     * Non-Sphinx method of collecting IDs of torrents that match any
699
+     * torrent-specific attribute filters that were used in the search query
700
+     */
701
+    private function filter_torrents_internal()
702
+    {
703
+        foreach ($this->Groups as $GroupID => $Group) {
704
+            if (empty($Group['Torrents'])) {
705
+                continue;
706
+            }
707
+            
708
+            foreach ($Group['Torrents'] as $TorrentID => $Torrent) {
709
+                if (!$this->filter_torrent_internal($Torrent)) {
710
+                    unset($this->Groups[$GroupID]['Torrents'][$TorrentID]);
711
+                }
712
+            }
713
+        }
714
+    }
715
+
716
+    /**
717
+     * Post-processing to determine if a torrent is a real hit or if it was
718
+     * returned because another torrent in the group matched. Only used if
719
+     * there are no torrent-specific fulltext conditions
720
+     *
721
+     * @param array $Torrent Torrent array, probably from Torrents::get_groups()
722
+     * @return bool True if it's a real hit
723
+     */
724
+    private function filter_torrent_internal($Torrent)
725
+    {
726
+        if (isset($this->UsedTorrentAttrs['freetorrent'])) {
727
+            $FilterValue = $this->UsedTorrentAttrs['freetorrent'];
728
+            if ($FilterValue === '3' && $Torrent['FreeTorrent'] === '0') {
729
+                // Either FL or NL is ok
730
+                return false;
731
+            } elseif ($FilterValue !== '3' && $FilterValue !== (int)$Torrent['FreeTorrent']) {
732
+                return false;
733
+            }
734
+        }
735
+        return true;
736
+    }
737
+}

+ 195
- 0
classes/tracker.class.php View File

@@ -0,0 +1,195 @@
1
+<?php
2
+
3
+// TODO: Turn this into a class with nice functions like update_user, delete_torrent, etc.
4
+class Tracker
5
+{
6
+    const STATS_MAIN = 0;
7
+    const STATS_USER = 1;
8
+
9
+    public static $Requests = [];
10
+
11
+    /**
12
+     * Send a GET request over a socket directly to the tracker
13
+     * For example, Tracker::update_tracker('change_passkey', array('oldpasskey' => OLD_PASSKEY, 'newpasskey' => NEW_PASSKEY)) will send the request:
14
+     * GET /tracker_32_char_secret_code/update?action=change_passkey&oldpasskey=OLD_PASSKEY&newpasskey=NEW_PASSKEY HTTP/1.1
15
+     *
16
+     * @param string $Action The action to send
17
+     * @param array $Updates An associative array of key->value pairs to send to the tracker
18
+     * @param boolean $ToIRC Sends a message to the channel #tracker with the GET URL.
19
+     */
20
+    public static function update_tracker($Action, $Updates, $ToIRC = false)
21
+    {
22
+        // Build request
23
+        $Get = TRACKER_SECRET . "/update?action=$Action";
24
+        foreach ($Updates as $Key => $Value) {
25
+            $Get .= "&$Key=$Value";
26
+        }
27
+
28
+        $MaxAttempts = 3;
29
+        // don't wait around if we're debugging
30
+        if (DEBUG_MODE) {
31
+            $MaxAttempts = 1;
32
+        }
33
+
34
+        $Err = false;
35
+        if (self::send_request($Get, $MaxAttempts, $Err) === false) {
36
+            send_irc(DEBUG_CHAN, "$MaxAttempts $Err $Get");
37
+            if (G::$Cache->get_value('ocelot_error_reported') === false) {
38
+                send_irc(ADMIN_CHAN, "Failed to update Ocelot: $Err $Get");
39
+                G::$Cache->cache_value('ocelot_error_reported', true, 3600);
40
+            }
41
+            return false;
42
+        }
43
+        return true;
44
+    }
45
+
46
+    /**
47
+     * Get global peer stats from the tracker
48
+     *
49
+     * @return array(0 => $Leeching, 1 => $Seeding) or false if request failed
50
+     */
51
+    public static function global_peer_count()
52
+    {
53
+        $Stats = self::get_stats(self::STATS_MAIN);
54
+        if (isset($Stats['leechers tracked']) && isset($Stats['seeders tracked'])) {
55
+            $Leechers = $Stats['leechers tracked'];
56
+            $Seeders = $Stats['seeders tracked'];
57
+        } else {
58
+            return false;
59
+        }
60
+        return array($Leechers, $Seeders);
61
+    }
62
+
63
+    /**
64
+     * Get peer stats for a user from the tracker
65
+     *
66
+     * @param string $TorrentPass The user's pass key
67
+     * @return array(0 => $Leeching, 1 => $Seeding) or false if the request failed
68
+     */
69
+    public static function user_peer_count($TorrentPass)
70
+    {
71
+        $Stats = self::get_stats(self::STATS_USER, array('key' => $TorrentPass));
72
+        if ($Stats === false) {
73
+            return false;
74
+        }
75
+        if (isset($Stats['leeching']) && isset($Stats['seeding'])) {
76
+            $Leeching = $Stats['leeching'];
77
+            $Seeding = $Stats['seeding'];
78
+        } else {
79
+            // User doesn't exist, but don't tell anyone
80
+            $Leeching = $Seeding = 0;
81
+        }
82
+        return array($Leeching, $Seeding);
83
+    }
84
+
85
+    /**
86
+     * Get whatever info the tracker has to report
87
+     *
88
+     * @return results from get_stats()
89
+     */
90
+    public static function info()
91
+    {
92
+        return self::get_stats(self::STATS_MAIN);
93
+    }
94
+
95
+    /**
96
+     * Send a stats request to the tracker and process the results
97
+     *
98
+     * @param int $Type Stats type to get
99
+     * @param array $Params Parameters required by stats type
100
+     * @return array with stats in named keys or false if the request failed
101
+     */
102
+    private static function get_stats($Type, $Params = false)
103
+    {
104
+        if (!defined('TRACKER_REPORTKEY')) {
105
+            return false;
106
+        }
107
+        $Get = TRACKER_REPORTKEY . '/report?';
108
+        if ($Type === self::STATS_MAIN) {
109
+            $Get .= 'get=stats';
110
+        } elseif ($Type === self::STATS_USER && !empty($Params['key'])) {
111
+            $Get .= "get=user&key=$Params[key]";
112
+        } else {
113
+            return false;
114
+        }
115
+        $Response = self::send_request($Get);
116
+        if ($Response === false) {
117
+            return false;
118
+        }
119
+        $Stats = [];
120
+        foreach (explode("\n", $Response) as $Stat) {
121
+            list($Val, $Key) = explode(" ", $Stat, 2);
122
+            $Stats[$Key] = $Val;
123
+        }
124
+        return $Stats;
125
+    }
126
+
127
+    /**
128
+     * Send a request to the tracker
129
+     *
130
+     * @param string $Path GET string to send to the tracker
131
+     * @param int $MaxAttempts Maximum number of failed attempts before giving up
132
+     * @param $Err Variable to use as storage for the error string if the request fails
133
+     * @return tracker response message or false if the request failed
134
+     */
135
+    private static function send_request($Get, $MaxAttempts = 1, &$Err = false)
136
+    {
137
+        $Header = "GET /$Get HTTP/1.1\r\nConnection: Close\r\n\r\n";
138
+        $Attempts = 0;
139
+        $Sleep = 0;
140
+        $Success = false;
141
+        $StartTime = microtime(true);
142
+        
143
+        while (!$Success && $Attempts++ < $MaxAttempts) {
144
+            if ($Sleep) {
145
+                sleep($Sleep);
146
+            }
147
+
148
+            // spend some time retrying if we're not in DEBUG_MODE
149
+            if (!DEBUG_MODE) {
150
+                $Sleep = 6;
151
+            }
152
+
153
+            // Send request
154
+            $File = fsockopen(TRACKER_HOST, TRACKER_PORT, $ErrorNum, $ErrorString);
155
+            if ($File) {
156
+                if (fwrite($File, $Header) === false) {
157
+                    $Err = "Failed to fwrite()";
158
+                    $Sleep = 3;
159
+                    continue;
160
+                }
161
+            } else {
162
+                $Err = "Failed to fsockopen() - $ErrorNum - $ErrorString";
163
+                continue;
164
+            }
165
+
166
+            // Check for response.
167
+            $Response = '';
168
+            while (!feof($File)) {
169
+                $Response .= fread($File, 1024);
170
+            }
171
+            $DataStart = strpos($Response, "\r\n\r\n") + 4;
172
+            $DataEnd = strrpos($Response, "\n");
173
+            if ($DataEnd > $DataStart) {
174
+                $Data = substr($Response, $DataStart, $DataEnd - $DataStart);
175
+            } else {
176
+                $Data = "";
177
+            }
178
+            $Status = substr($Response, $DataEnd + 1);
179
+            if ($Status == "success") {
180
+                $Success = true;
181
+            }
182
+        }
183
+        $Request = array(
184
+      'path' => substr($Get, strpos($Get, '/')),
185
+      'response' => ($Success ? $Data : $Response),
186
+      'status' => ($Success ? 'ok' : 'failed'),
187
+      'time' => 1000 * (microtime(true) - $StartTime)
188
+    );
189
+        self::$Requests[] = $Request;
190
+        if ($Success) {
191
+            return $Data;
192
+        }
193
+        return false;
194
+    }
195
+}

+ 138
- 0
classes/twofa.class.php View File

@@ -0,0 +1,138 @@
1
+<?php
2
+
3
+class TwoFactorAuth
4
+{
5
+    private $algorithm;
6
+    private $period;
7
+    private $digits;
8
+    private $issuer;
9
+    private static $_base32dict = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567=';
10
+    private static $_base32;
11
+    private static $_base32lookup = [];
12
+    private static $_supportedalgos = array('sha1', 'sha256', 'sha512', 'md5');
13
+
14
+    public function __construct($issuer = null, $digits = 6, $period = 30, $algorithm = 'sha1')
15
+    {
16
+        $this->issuer = $issuer;
17
+        $this->digits = $digits;
18
+        $this->period = $period;
19
+
20
+        $algorithm = strtolower(trim($algorithm));
21
+        if (!in_array($algorithm, self::$_supportedalgos)) {
22
+            $algorithm = 'sha1';
23
+        }
24
+        $this->algorithm = $algorithm;
25
+
26
+        self::$_base32 = str_split(self::$_base32dict);
27
+        self::$_base32lookup = array_flip(self::$_base32);
28
+    }
29
+
30
+    public function createSecret($bits = 210)
31
+    {
32
+        $secret = '';
33
+        $bytes = ceil($bits / 5); //We use 5 bits of each byte (since we have a 32-character 'alphabet' / BASE32)
34
+        $rnd = random_bytes($bytes);
35
+
36
+        for ($i = 0; $i < $bytes; $i++) {
37
+            $secret .= self::$_base32[ord($rnd[$i]) & 31]; //Mask out left 3 bits for 0-31 values
38
+        }
39
+        return $secret;
40
+    }
41
+
42
+    // Calculate the code with given secret and point in time
43
+    public function getCode($secret, $time = null)
44
+    {
45
+        $secretkey = $this->base32Decode($secret);
46
+
47
+        $timestamp = "\0\0\0\0" . pack('N*', $this->getTimeSlice($this->getTime($time)));  // Pack time into binary string
48
+        $hashhmac = hash_hmac($this->algorithm, $timestamp, $secretkey, true);             // Hash it with users secret key
49
+        $hashpart = substr($hashhmac, ord(substr($hashhmac, -1)) & 0x0F, 4);               // Use last nibble of result as index/offset and grab 4 bytes of the result
50
+        $value = unpack('N', $hashpart);                                                   // Unpack binary value
51
+        $value = $value[1] & 0x7FFFFFFF;                                                   // Drop MSB, keep only 31 bits
52
+
53
+        return str_pad($value % pow(10, $this->digits), $this->digits, '0', STR_PAD_LEFT);
54
+    }
55
+
56
+    // Check if the code is correct. This will accept codes starting from now +/- ($discrepancy * period) seconds
57
+    public function verifyCode($secret, $code, $discrepancy = 1, $time = null)
58
+    {
59
+        $result = false;
60
+        $timetamp = $this->getTime($time);
61
+
62
+        // Always iterate all possible codes to prevent timing-attacks
63
+        for ($i = -$discrepancy; $i <= $discrepancy; $i++) {
64
+            $result |= hash_equals($this->getCode($secret, $timetamp + ($i * $this->period)), $code);
65
+        }
66
+
67
+        return (bool)$result;
68
+    }
69
+
70
+    // Get data-uri of QRCode
71
+    public function getQRCodeImageAsDataUri($label, $secret, $size = 300)
72
+    {
73
+        if (exec('which qrencode')) {
74
+            $QRCodeImage = shell_exec("qrencode -s ".(int)($size/40)." -m 3 -o - '".$this->getQRText($label, $secret)."'");
75
+        } else {
76
+            $curlhandle = curl_init();
77
+
78
+            curl_setopt_array($curlhandle, array(
79
+            CURLOPT_URL => 'https://chart.googleapis.com/chart?cht=qr&chs='.$size.'x'.$size.'&chld=L|1&chl='.rawurlencode($this->getQRText($label, $secret)),
80
+            CURLOPT_RETURNTRANSFER => true,
81
+            CURLOPT_CONNECTTIMEOUT => 10,
82
+            CURLOPT_DNS_CACHE_TIMEOUT => 10,
83
+            CURLOPT_TIMEOUT => 10,
84
+            CURLOPT_SSL_VERIFYPEER => false,
85
+            CURLOPT_USERAGENT => 'TwoFactorAuth'
86
+            ));
87
+
88
+            $QRCodeImage = curl_exec($curlhandle);
89
+            curl_close($curlhandle);
90
+        }
91
+        return 'data:image/png;base64,'.base64_encode($QRCodeImage);
92
+    }
93
+
94
+    private function getTime($time)
95
+    {
96
+        return ($time === null) ? time() : $time;
97
+    }
98
+
99
+    private function getTimeSlice($time = null, $offset = 0)
100
+    {
101
+        return (int)floor($time / $this->period) + ($offset * $this->period);
102
+    }
103
+
104
+    // Builds a string to be encoded in a QR code
105
+    public function getQRText($label, $secret)
106
+    {
107
+        $QRText = 'otpauth://totp/'.rawurlencode($label).'?secret='.rawurlencode($secret);
108
+        $QRText .= ($this->issuer) ? '&issuer='.rawurlencode($this->issuer) : '';
109
+        $QRText .= ($this->period != 30) ? '&period='.intval($this->period) : '';
110
+        $QRText .= ($this->algorithm != 'sha1') ? '&algorithm='.rawurlencode(strtoupper($this->algorithm)) : '';
111
+        $QRText .= ($this->digits != 6) ? '&digits='.intval($this->digits) : '';
112
+        return $QRText;
113
+    }
114
+
115
+    private function base32Decode($value)
116
+    {
117
+        if (strlen($value)==0) {
118
+            return '';
119
+        }
120
+
121
+        $buffer = '';
122
+        foreach (str_split($value) as $char) {
123
+            if ($char !== '=') {
124
+                $buffer .= str_pad(decbin(self::$_base32lookup[$char]), 5, 0, STR_PAD_LEFT);
125
+            }
126
+        }
127
+        
128
+        $length = strlen($buffer);
129
+        $blocks = trim(chunk_split(substr($buffer, 0, $length - ($length % 8)), 8, ' '));
130
+
131
+        $output = '';
132
+        foreach (explode(' ', $blocks) as $block) {
133
+            $output .= chr(bindec(str_pad($block, 8, 0, STR_PAD_RIGHT)));
134
+        }
135
+
136
+        return $output;
137
+    }
138
+}

+ 576
- 0
classes/u2f.class.php View File

@@ -0,0 +1,576 @@
1
+<?php
2
+
3
+# https://github.com/Yubico/php-u2flib-server/blob/master/src/u2flib_server/U2F.php
4
+
5
+/* Copyright (c) 2014 Yubico AB
6
+ * All rights reserved.
7
+ *
8
+ * Redistribution and use in source and binary forms, with or without
9
+ * modification, are permitted provided that the following conditions are
10
+ * met:
11
+ *
12
+ *   * Redistributions of source code must retain the above copyright
13
+ *     notice, this list of conditions and the following disclaimer.
14
+ *
15
+ *   * Redistributions in binary form must reproduce the above
16
+ *     copyright notice, this list of conditions and the following
17
+ *     disclaimer in the documentation and/or other materials provided
18
+ *     with the distribution.
19
+ *
20
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
23
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
24
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
25
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
26
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
27
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
28
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
29
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31
+ */
32
+
33
+namespace u2f;
34
+#namespace u2flib_server;
35
+
36
+/** Constant for the version of the u2f protocol */
37
+const U2F_VERSION = "U2F_V2";
38
+
39
+/** Constant for the type value in registration clientData */
40
+const REQUEST_TYPE_REGISTER = "navigator.id.finishEnrollment";
41
+
42
+/** Constant for the type value in authentication clientData */
43
+const REQUEST_TYPE_AUTHENTICATE = "navigator.id.getAssertion";
44
+
45
+/** Error for the authentication message not matching any outstanding
46
+ * authentication request */
47
+const ERR_NO_MATCHING_REQUEST = 1;
48
+
49
+/** Error for the authentication message not matching any registration */
50
+const ERR_NO_MATCHING_REGISTRATION = 2;
51
+
52
+/** Error for the signature on the authentication message not verifying with
53
+ * the correct key */
54
+const ERR_AUTHENTICATION_FAILURE = 3;
55
+
56
+/** Error for the challenge in the registration message not matching the
57
+ * registration challenge */
58
+const ERR_UNMATCHED_CHALLENGE = 4;
59
+
60
+/** Error for the attestation signature on the registration message not
61
+ * verifying */
62
+const ERR_ATTESTATION_SIGNATURE = 5;
63
+
64
+/** Error for the attestation verification not verifying */
65
+const ERR_ATTESTATION_VERIFICATION = 6;
66
+
67
+/** Error for not getting good random from the system */
68
+const ERR_BAD_RANDOM = 7;
69
+
70
+/** Error when the counter is lower than expected */
71
+const ERR_COUNTER_TOO_LOW = 8;
72
+
73
+/** Error decoding public key */
74
+const ERR_PUBKEY_DECODE = 9;
75
+
76
+/** Error user-agent returned error */
77
+const ERR_BAD_UA_RETURNING = 10;
78
+
79
+/** Error old OpenSSL version */
80
+const ERR_OLD_OPENSSL = 11;
81
+
82
+/** Error for the origin not matching the appId */
83
+const ERR_NO_MATCHING_ORIGIN = 12;
84
+
85
+/** Error for the type in clientData being invalid */
86
+const ERR_BAD_TYPE = 13;
87
+
88
+/** Error for bad user presence byte value */
89
+const ERR_BAD_USER_PRESENCE = 14;
90
+
91
+/** @internal */
92
+const PUBKEY_LEN = 65;
93
+
94
+class U2F
95
+{
96
+    /** @var string  */
97
+    private $appId;
98
+
99
+    /** @var null|string */
100
+    private $attestDir;
101
+
102
+    /** @var array */
103
+    private $facetIds;
104
+
105
+    /** @internal */
106
+    private $FIXCERTS = array(
107
+        '349bca1031f8c82c4ceca38b9cebf1a69df9fb3b94eed99eb3fb9aa3822d26e8',
108
+        'dd574527df608e47ae45fbba75a2afdd5c20fd94a02419381813cd55a2a3398f',
109
+        '1d8764f0f7cd1352df6150045c8f638e517270e8b5dda1c63ade9c2280240cae',
110
+        'd0edc9a91a1677435a953390865d208c55b3183c6759c9b5a7ff494c322558eb',
111
+        '6073c436dcd064a48127ddbf6032ac1a66fd59a0c24434f070d4e564c124c897',
112
+        'ca993121846c464d666096d35f13bf44c1b05af205f9b4a1e00cf6cc10c5e511'
113
+    );
114
+
115
+    /**
116
+     * @param string $appId Application id for the running application
117
+     * @param string|null $attestDir Directory where trusted attestation roots may be found
118
+     * @param array|null $facetIds List of trusted Facet IDs
119
+     * @throws Error If OpenSSL older than 1.0.0 is used
120
+     */
121
+    public function __construct($appId, $attestDir = null, $facetIds = null)
122
+    {
123
+        if(OPENSSL_VERSION_NUMBER < 0x10000000) {
124
+            throw new Error('OpenSSL has to be at least version 1.0.0, this is ' . OPENSSL_VERSION_TEXT, ERR_OLD_OPENSSL);
125
+        }
126
+        $this->appId = $appId;
127
+        $this->attestDir = $attestDir;
128
+
129
+        if(!is_array($facetIds)) {
130
+            $facetIds = [$appId];
131
+        }
132
+        $this->facetIds = $facetIds;
133
+    }
134
+
135
+    /**
136
+     * Called to get a registration request to send to a user.
137
+     * Returns an array of one registration request and a array of sign requests.
138
+     *
139
+     * @param array $registrations List of current registrations for this
140
+     * user, to prevent the user from registering the same authenticator several
141
+     * times.
142
+     * @return array An array of two elements, the first containing a
143
+     * RegisterRequest the second being an array of SignRequest
144
+     * @throws Error
145
+     */
146
+    public function getRegisterData(array $registrations = array())
147
+    {
148
+        $challenge = $this->createChallenge();
149
+        $request = new RegisterRequest($challenge, $this->appId);
150
+        $signs = $this->getAuthenticateData($registrations);
151
+        return array($request, $signs);
152
+    }
153
+
154
+    /**
155
+     * Called to verify and unpack a registration message.
156
+     *
157
+     * @param RegisterRequest $request this is a reply to
158
+     * @param object $response response from a user
159
+     * @param bool $includeCert set to true if the attestation certificate should be
160
+     * included in the returned Registration object
161
+     * @return Registration
162
+     * @throws Error
163
+     */
164
+    public function doRegister($request, $response, $includeCert = true)
165
+    {
166
+        if( !is_object( $request ) ) {
167
+            throw new \InvalidArgumentException('$request of doRegister() method only accepts object.');
168
+        }
169
+
170
+        if( !is_object( $response ) ) {
171
+            throw new \InvalidArgumentException('$response of doRegister() method only accepts object.');
172
+        }
173
+
174
+        if( property_exists( $response, 'errorCode') && $response->errorCode !== 0 ) {
175
+            throw new Error('User-agent returned error. Error code: ' . $response->errorCode, ERR_BAD_UA_RETURNING );
176
+        }
177
+
178
+        if( !is_bool( $includeCert ) ) {
179
+            throw new \InvalidArgumentException('$include_cert of doRegister() method only accepts boolean.');
180
+        }
181
+
182
+        $rawReg = $this->base64u_decode($response->registrationData);
183
+        $regData = array_values(unpack('C*', $rawReg));
184
+        $clientData = $this->base64u_decode($response->clientData);
185
+        $cli = json_decode($clientData);
186
+
187
+        if($cli->challenge !== $request->challenge) {
188
+            throw new Error('Registration challenge does not match', ERR_UNMATCHED_CHALLENGE );
189
+        }
190
+
191
+        if(isset($cli->typ) && $cli->typ !== REQUEST_TYPE_REGISTER) {
192
+            throw new Error('ClientData type is invalid', ERR_BAD_TYPE);
193
+        }
194
+
195
+        if(isset($cli->origin) && !in_array($cli->origin, $this->facetIds, true)) {
196
+            throw new Error('App ID does not match the origin', ERR_NO_MATCHING_ORIGIN);
197
+        }
198
+
199
+        $registration = new Registration();
200
+        $offs = 1;
201
+        $pubKey = substr($rawReg, $offs, PUBKEY_LEN);
202
+        $offs += PUBKEY_LEN;
203
+        // decode the pubKey to make sure it's good
204
+        $tmpKey = $this->pubkey_to_pem($pubKey);
205
+        if($tmpKey === null) {
206
+            throw new Error('Decoding of public key failed', ERR_PUBKEY_DECODE );
207
+        }
208
+        $registration->publicKey = base64_encode($pubKey);
209
+        $khLen = $regData[$offs++];
210
+        $kh = substr($rawReg, $offs, $khLen);
211
+        $offs += $khLen;
212
+        $registration->keyHandle = $this->base64u_encode($kh);
213
+
214
+        // length of certificate is stored in byte 3 and 4 (excluding the first 4 bytes)
215
+        $certLen = 4;
216
+        $certLen += ($regData[$offs + 2] << 8);
217
+        $certLen += $regData[$offs + 3];
218
+
219
+        $rawCert = $this->fixSignatureUnusedBits(substr($rawReg, $offs, $certLen));
220
+        $offs += $certLen;
221
+        $pemCert  = "-----BEGIN CERTIFICATE-----\r\n";
222
+        $pemCert .= chunk_split(base64_encode($rawCert), 64);
223
+        $pemCert .= "-----END CERTIFICATE-----";
224
+        if($includeCert) {
225
+            $registration->certificate = base64_encode($rawCert);
226
+        }
227
+        if($this->attestDir) {
228
+            if(openssl_x509_checkpurpose($pemCert, -1, $this->get_certs()) !== true) {
229
+                throw new Error('Attestation certificate can not be validated', ERR_ATTESTATION_VERIFICATION );
230
+            }
231
+        }
232
+
233
+        if(!openssl_pkey_get_public($pemCert)) {
234
+            throw new Error('Decoding of public key failed', ERR_PUBKEY_DECODE );
235
+        }
236
+        $signature = substr($rawReg, $offs);
237
+
238
+        $dataToVerify  = pack('C', 0);
239
+        $dataToVerify .= hash('sha256', $request->appId, true);
240
+        $dataToVerify .= hash('sha256', $clientData, true);
241
+        $dataToVerify .= $kh;
242
+        $dataToVerify .= $pubKey;
243
+
244
+        if(openssl_verify($dataToVerify, $signature, $pemCert, 'sha256') === 1) {
245
+            return $registration;
246
+        } else {
247
+            throw new Error('Attestation signature does not match', ERR_ATTESTATION_SIGNATURE );
248
+        }
249
+    }
250
+
251
+    /**
252
+     * Called to get an authentication request.
253
+     *
254
+     * @param array $registrations An array of the registrations to create authentication requests for.
255
+     * @return array An array of SignRequest
256
+     * @throws Error
257
+     */
258
+    public function getAuthenticateData(array $registrations)
259
+    {
260
+        $sigs = array();
261
+        $challenge = $this->createChallenge();
262
+        foreach ($registrations as $reg) {
263
+            if( !is_object( $reg ) ) {
264
+                throw new \InvalidArgumentException('$registrations of getAuthenticateData() method only accepts array of object.');
265
+            }
266
+            /** @var Registration $reg */
267
+
268
+            $sig = new SignRequest();
269
+            $sig->appId = $this->appId;
270
+            $sig->keyHandle = $reg->keyHandle;
271
+            $sig->challenge = $challenge;
272
+            $sigs[] = $sig;
273
+        }
274
+        return $sigs;
275
+    }
276
+
277
+    /**
278
+     * Called to verify an authentication response
279
+     *
280
+     * @param array $requests An array of outstanding authentication requests
281
+     * @param array $registrations An array of current registrations
282
+     * @param object $response A response from the authenticator
283
+     * @return Registration
284
+     * @throws Error
285
+     *
286
+     * The Registration object returned on success contains an updated counter
287
+     * that should be saved for future authentications.
288
+     * If the Error returned is ERR_COUNTER_TOO_LOW this is an indication of
289
+     * token cloning or similar and appropriate action should be taken.
290
+     */
291
+    public function doAuthenticate(array $requests, array $registrations, $response)
292
+    {
293
+        if( !is_object( $response ) ) {
294
+            throw new \InvalidArgumentException('$response of doAuthenticate() method only accepts object.');
295
+        }
296
+
297
+        if( property_exists( $response, 'errorCode') && $response->errorCode !== 0 ) {
298
+            throw new Error('User-agent returned error. Error code: ' . $response->errorCode, ERR_BAD_UA_RETURNING );
299
+        }
300
+
301
+        /** @var object|null $req */
302
+        $req = null;
303
+
304
+        /** @var object|null $reg */
305
+        $reg = null;
306
+
307
+        $clientData = $this->base64u_decode($response->clientData);
308
+        $decodedClient = json_decode($clientData);
309
+
310
+        if(isset($decodedClient->typ) && $decodedClient->typ !== REQUEST_TYPE_AUTHENTICATE) {
311
+            throw new Error('ClientData type is invalid', ERR_BAD_TYPE);
312
+        }
313
+
314
+        foreach ($requests as $req) {
315
+            if( !is_object( $req ) ) {
316
+                throw new \InvalidArgumentException('$requests of doAuthenticate() method only accepts array of object.');
317
+            }
318
+
319
+            if($req->keyHandle === $response->keyHandle && $req->challenge === $decodedClient->challenge) {
320
+                break;
321
+            }
322
+
323
+            $req = null;
324
+        }
325
+        if($req === null) {
326
+            throw new Error('No matching request found', ERR_NO_MATCHING_REQUEST );
327
+        }
328
+        if(isset($decodedClient->origin) && !in_array($decodedClient->origin, $this->facetIds, true)) {
329
+            throw new Error('App ID does not match the origin', ERR_NO_MATCHING_ORIGIN);
330
+        }
331
+        foreach ($registrations as $reg) {
332
+            if( !is_object( $reg ) ) {
333
+                throw new \InvalidArgumentException('$registrations of doAuthenticate() method only accepts array of object.');
334
+            }
335
+
336
+            if($reg->keyHandle === $response->keyHandle) {
337
+                break;
338
+            }
339
+            $reg = null;
340
+        }
341
+        if($reg === null) {
342
+            throw new Error('No matching registration found', ERR_NO_MATCHING_REGISTRATION );
343
+        }
344
+        $pemKey = $this->pubkey_to_pem($this->base64u_decode($reg->publicKey));
345
+        if($pemKey === null) {
346
+            throw new Error('Decoding of public key failed', ERR_PUBKEY_DECODE );
347
+        }
348
+
349
+        $signData = $this->base64u_decode($response->signatureData);
350
+        $dataToVerify  = hash('sha256', $req->appId, true);
351
+        $dataToVerify .= substr($signData, 0, 5);
352
+        $dataToVerify .= hash('sha256', $clientData, true);
353
+        $signature = substr($signData, 5);
354
+
355
+        if(openssl_verify($dataToVerify, $signature, $pemKey, 'sha256') === 1) {
356
+            $upb = unpack("Cupb", substr($signData, 0, 1)); 
357
+            if($upb['upb'] !== 1) { 
358
+                throw new Error('User presence byte value is invalid', ERR_BAD_USER_PRESENCE );
359
+            }
360
+            $ctr = unpack("Nctr", substr($signData, 1, 4));
361
+            $counter = $ctr['ctr'];
362
+            /* TODO: wrap-around should be handled somehow.. */
363
+            if($counter > $reg->counter) {
364
+                $reg->counter = $counter;
365
+                return self::castObjectToRegistration($reg);
366
+            } else {
367
+                throw new Error('Counter too low.', ERR_COUNTER_TOO_LOW );
368
+            }
369
+        } else {
370
+            throw new Error('Authentication failed', ERR_AUTHENTICATION_FAILURE );
371
+        }
372
+    }
373
+
374
+    /**
375
+     * @param object $object
376
+     * @return Registration
377
+     */
378
+    protected static function castObjectToRegistration($object)
379
+    {
380
+        $reg = new Registration();
381
+        if (isset($object->publicKey)) {
382
+            $reg->publicKey = $object->publicKey;
383
+        }
384
+        if (isset($object->certificate)) {
385
+            $reg->certificate = $object->certificate;
386
+        }
387
+        if (isset($object->counter)) {
388
+            $reg->counter = $object->counter;
389
+        }
390
+        if (isset($object->keyHandle)) {
391
+            $reg->keyHandle = $object->keyHandle;
392
+        }
393
+        return $reg;
394
+    }
395
+
396
+    /**
397
+     * @return array
398
+     */
399
+    private function get_certs()
400
+    {
401
+        $files = array();
402
+        $dir = $this->attestDir;
403
+        if($dir !== null && is_dir($dir) && $handle = opendir($dir)) {
404
+            while(false !== ($entry = readdir($handle))) {
405
+                if(is_file("$dir/$entry")) {
406
+                    $files[] = "$dir/$entry";
407
+                }
408
+            }
409
+            closedir($handle);
410
+        } elseif (is_file("$dir")) {
411
+            $files[] = "$dir";
412
+        }
413
+        return $files;
414
+    }
415
+
416
+    /**
417
+     * @param string $data
418
+     * @return string
419
+     */
420
+    private function base64u_encode($data)
421
+    {
422
+        return trim(strtr(base64_encode($data), '+/', '-_'), '=');
423
+    }
424
+
425
+    /**
426
+     * @param string $data
427
+     * @return string
428
+     */
429
+    private function base64u_decode($data)
430
+    {
431
+        return base64_decode(strtr($data, '-_', '+/'));
432
+    }
433
+
434
+    /**
435
+     * @param string $key
436
+     * @return null|string
437
+     */
438
+    private function pubkey_to_pem($key)
439
+    {
440
+        if(strlen($key) !== PUBKEY_LEN || $key[0] !== "\x04") {
441
+            return null;
442
+        }
443
+
444
+        /*
445
+         * Convert the public key to binary DER format first
446
+         * Using the ECC SubjectPublicKeyInfo OIDs from RFC 5480
447
+         *
448
+         *  SEQUENCE(2 elem)                        30 59
449
+         *   SEQUENCE(2 elem)                       30 13
450
+         *    OID1.2.840.10045.2.1 (id-ecPublicKey) 06 07 2a 86 48 ce 3d 02 01
451
+         *    OID1.2.840.10045.3.1.7 (secp256r1)    06 08 2a 86 48 ce 3d 03 01 07
452
+         *   BIT STRING(520 bit)                    03 42 ..key..
453
+         */
454
+        $der  = "\x30\x59\x30\x13\x06\x07\x2a\x86\x48\xce\x3d\x02\x01";
455
+        $der .= "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07\x03\x42";
456
+        $der .= "\0".$key;
457
+
458
+        $pem  = "-----BEGIN PUBLIC KEY-----\r\n";
459
+        $pem .= chunk_split(base64_encode($der), 64);
460
+        $pem .= "-----END PUBLIC KEY-----";
461
+
462
+        return $pem;
463
+    }
464
+
465
+    /**
466
+     * @return string
467
+     * @throws Error
468
+     */
469
+    private function createChallenge()
470
+    {
471
+        $challenge = random_bytes(32);
472
+        $challenge = $this->base64u_encode( $challenge );
473
+
474
+        return $challenge;
475
+    }
476
+
477
+    /**
478
+     * Fixes a certificate where the signature contains unused bits.
479
+     *
480
+     * @param string $cert
481
+     * @return mixed
482
+     */
483
+    private function fixSignatureUnusedBits($cert)
484
+    {
485
+        if(in_array(hash('sha256', $cert), $this->FIXCERTS, true)) {
486
+            $cert[strlen($cert) - 257] = "\0";
487
+        }
488
+        return $cert;
489
+    }
490
+}
491
+
492
+/**
493
+ * Class for building a registration request
494
+ *
495
+ * @package u2flib_server
496
+ */
497
+class RegisterRequest
498
+{
499
+    /** @var string Protocol version */
500
+    public $version = U2F_VERSION;
501
+
502
+    /** @var string Registration challenge */
503
+    public $challenge;
504
+
505
+    /** @var string Application id */
506
+    public $appId;
507
+
508
+    /**
509
+     * @param string $challenge
510
+     * @param string $appId
511
+     * @internal
512
+     */
513
+    public function __construct($challenge, $appId)
514
+    {
515
+        $this->challenge = $challenge;
516
+        $this->appId = $appId;
517
+    }
518
+}
519
+
520
+/**
521
+ * Class for building up an authentication request
522
+ *
523
+ * @package u2flib_server
524
+ */
525
+class SignRequest
526
+{
527
+    /** @var string Protocol version */
528
+    public $version = U2F_VERSION;
529
+
530
+    /** @var string Authentication challenge */
531
+    public $challenge = '';
532
+
533
+    /** @var string Key handle of a registered authenticator */
534
+    public $keyHandle = '';
535
+
536
+    /** @var string Application id */
537
+    public $appId = '';
538
+}
539
+
540
+/**
541
+ * Class returned for successful registrations
542
+ *
543
+ * @package u2flib_server
544
+ */
545
+class Registration
546
+{
547
+    /** @var string The key handle of the registered authenticator */
548
+    public $keyHandle = '';
549
+
550
+    /** @var string The public key of the registered authenticator */
551
+    public $publicKey = '';
552
+
553
+    /** @var string The attestation certificate of the registered authenticator */
554
+    public $certificate = '';
555
+
556
+    /** @var int The counter associated with this registration */
557
+    public $counter = -1;
558
+}
559
+
560
+/**
561
+ * Error class, returned on errors
562
+ *
563
+ * @package u2flib_server
564
+ */
565
+class Error extends \Exception
566
+{
567
+    /**
568
+     * Override constructor and make message and code mandatory
569
+     * @param string $message
570
+     * @param int $code
571
+     * @param \Exception|null $previous
572
+     */
573
+    public function __construct($message, $code, \Exception $previous = null) {
574
+        parent::__construct($message, $code, $previous);
575
+    }
576
+}

+ 168
- 0
classes/useragent.class.php View File

@@ -0,0 +1,168 @@
1
+<?php
2
+
3
+class UserAgent
4
+{
5
+    private static $Browsers = array(
6
+    // Less popular
7
+    'Shiira'     => 'Shiira',
8
+    'Songbird'   => 'Songbird',
9
+    'SeaMonkey'  => 'SeaMonkey',
10
+    'OmniWeb'    => 'OmniWeb',
11
+    'Camino'     => 'Camino',
12
+    'Chimera'    => 'Chimera',
13
+    'Epiphany'   => 'Epiphany',
14
+    'Konqueror'  => 'Konqueror',
15
+    'Iceweasel'  => 'Iceweasel',
16
+    'Lynx'       => 'Lynx',
17
+    'Links'      => 'Links',
18
+    'libcurl'    => 'cURL',
19
+    'midori'     => 'Midori',
20
+    'Blackberry' => 'BlackBerry Browser',
21
+    // Big names
22
+    'Firefox' => 'Firefox',
23
+    'OPR'     => 'Opera Blink', # Opera 15+ (the version running Blink)
24
+    'Opera'   => 'Opera',
25
+    'Chrome'  => 'Chrome',
26
+    'Safari'  => 'Safari',
27
+    // Put Chrome Frame above IE
28
+    'chromeframe' => 'Chrome Frame',
29
+    'x-clock'     => 'Chrome Frame',
30
+    'MSIE'        => 'Internet Explorer',
31
+    'Trident'     => 'Internet Explorer',
32
+    // Firefox versions
33
+    'Shiretoko'    => 'Firefox (Experimental)',
34
+    'Minefield'    => 'Firefox (Experimental)',
35
+    'GranParadiso' => 'Firefox (Experimental)',
36
+    'Namoroka'     => 'Firefox (Experimental)',
37
+    'AppleWebKit'  => 'WebKit',
38
+    'Mozilla'      => 'Mozilla'
39
+    // Weird shit
40
+    /*
41
+    'WWW-Mechanize' => 'Perl',
42
+    'Wget'          => 'Wget',
43
+    'BTWebClient'   => 'µTorrent',
44
+    'Transmission'  => 'Transmission',
45
+    'Java'          => 'Java',
46
+    'RSS'           => 'RSS Downloader'
47
+    */
48
+  );
49
+
50
+    private static $OperatingSystems = array(
51
+    // Mobile
52
+    'SymbianOS'    => 'Symbian',
53
+    'blackberry'   => 'BlackBerry',
54
+    'iphone'       => 'iPhone',
55
+    'ipod'         => 'iPhone',
56
+    'android'      => 'Android',
57
+    'palm'         => 'Palm',
58
+    'mot-razr'     => 'Motorola Razr',
59
+  //'tablet PC'    => 'Windows RT',
60
+  //'ARM; Trident' => 'Windows RT',
61
+    // Windows
62
+    'Windows NT 10.0' => 'Windows 10',
63
+    'Windows NT 6.4'  => 'Windows 10',
64
+    'Windows NT 6.3'  => 'Windows 8.1',
65
+    'Windows 8.1'     => 'Windows 8.1',
66
+    'Windows NT 6.2'  => 'Windows 8',
67
+    'Windows 8'       => 'Windows 8',
68
+    'Windows NT 6.1'  => 'Windows 7',
69
+    'Windows 7'       => 'Windows 7',
70
+    'Windows NT 6.0'  => 'Windows Vista',
71
+    'Windows Vista'   => 'Windows Vista',
72
+    'windows nt 5.2'  => 'Windows 2003',
73
+    'windows 2003'    => 'Windows 2003',
74
+    'windows nt 5.0'  => 'Windows 2000',
75
+    'windows 2000'    => 'Windows 2000',
76
+    'windows nt 5.1'  => 'Windows XP',
77
+    'windows xp'      => 'Windows XP',
78
+    'Win 9x 4.90'     => 'Windows ME',
79
+    'Windows Me'      => 'Windows ME',
80
+    'windows nt'      => 'Windows NT',
81
+    'winnt'           => 'Windows NT',
82
+    'windows 98'      => 'Windows 98',
83
+    'windows ce'      => 'Windows CE',
84
+    'win98'           => 'Windows 98',
85
+    'windows 95'      => 'Windows 95',
86
+    'windows 95'      => 'Windows 95',
87
+    'win95'           => 'Windows 95',
88
+    'win16'           => 'Windows 3.1',
89
+  //'windows'         => 'Windows',
90
+    'cros'            => 'Chrome OS',
91
+    // OS X
92
+    'os x'      => 'Mac OS X',
93
+    'macintosh' => 'Mac OS X',
94
+    'darwin'    => 'Mac OS X',
95
+    // Less popular
96
+    'ubuntu'  => 'Ubuntu',
97
+    'debian'  => 'Debian',
98
+    'fedora'  => 'Fedora',
99
+    'freebsd' => 'FreeBSD',
100
+    'openbsd' => 'OpenBSD',
101
+    'bsd'     => 'BSD',
102
+    'x11'     => 'Linux',
103
+    'gnu'     => 'Linux',
104
+    'linux'   => 'Linux',
105
+    'unix'    => 'Unix',
106
+    'Sun OS'  => 'Sun',
107
+    'Sun'     => 'Sun',
108
+    // Weird shit
109
+    /*
110
+    'WWW-Mechanize' => 'Perl',
111
+    'Wget'          => 'Wget',
112
+    'BTWebClient'   => 'µTorrent',
113
+    'Transmission'  => 'Transmission',
114
+    'Java'          => 'Java',
115
+    'RSS'           => 'RSS Downloader',
116
+    */
117
+    // Catch-all
118
+    'win' => 'Windows',
119
+    'mac' => 'Mac OS X'
120
+  );
121
+
122
+    public static function operating_system(&$UserAgentString)
123
+    {
124
+        if (empty($UserAgentString)) {
125
+            return 'Hidden';
126
+        }
127
+
128
+        foreach (self::$OperatingSystems as $String => $OperatingSystem) {
129
+            if (stripos($UserAgentString, $String) !== false) {
130
+                return $OperatingSystem;
131
+            }
132
+        }
133
+        return 'Unknown';
134
+    }
135
+
136
+    public static function mobile(&$UserAgentString)
137
+    {
138
+        if (strpos($UserAgentString, 'iPad') !== false) {
139
+            return false;
140
+        }
141
+
142
+        // "Mobi" catches "Mobile" too
143
+        if (strpos($UserAgentString, 'Device') || strpos($UserAgentString, 'Mobi') || strpos($UserAgentString, 'Mini') || strpos($UserAgentString, 'webOS')) {
144
+            return true;
145
+        }
146
+        return false;
147
+    }
148
+
149
+    public static function browser(&$UserAgentString)
150
+    {
151
+        if (empty($UserAgentString)) {
152
+            return 'Hidden';
153
+        }
154
+
155
+        $Return = 'Unknown';
156
+        foreach (self::$Browsers as $String => $Browser) {
157
+            if (strpos($UserAgentString, $String) !== false) {
158
+                $Return = $Browser;
159
+                break;
160
+            }
161
+        }
162
+        
163
+        if (self::mobile($UserAgentString)) {
164
+            $Return .= ' Mobile';
165
+        }
166
+        return $Return;
167
+    }
168
+}

+ 173
- 0
classes/userrank.class.php View File

@@ -0,0 +1,173 @@
1
+<?php
2
+
3
+class UserRank
4
+{
5
+    const PREFIX = 'percentiles_'; // Prefix for memcache keys, to make life easier
6
+
7
+    // Returns a 101 row array (101 percentiles - 0 - 100), with the minimum value for that percentile as the value for each row
8
+    // BTW - ingenious
9
+    private static function build_table($MemKey, $Query)
10
+    {
11
+        $QueryID = G::$DB->get_query_id();
12
+
13
+        G::$DB->query("
14
+        DROP TEMPORARY TABLE IF EXISTS temp_stats");
15
+
16
+        G::$DB->query("
17
+        CREATE TEMPORARY TABLE temp_stats (
18
+          ID int(10) NOT NULL PRIMARY KEY AUTO_INCREMENT,
19
+          Val bigint(20) NOT NULL
20
+        );");
21
+
22
+        G::$DB->query("
23
+        INSERT INTO temp_stats (Val) ".
24
+        $Query);
25
+
26
+        G::$DB->query("
27
+        SELECT COUNT(ID)
28
+        FROM temp_stats");
29
+        list($UserCount) = G::$DB->next_record();
30
+
31
+        G::$DB->query("
32
+        SELECT MIN(Val)
33
+        FROM temp_stats
34
+          GROUP BY CEIL(ID / (".(int)$UserCount." / 100));");
35
+
36
+        $Table = G::$DB->to_array();
37
+        G::$DB->set_query_id($QueryID);
38
+
39
+        // Give a little variation to the cache length, so all the tables don't expire at the same time
40
+        G::$Cache->cache_value($MemKey, $Table, 3600 * 24 * rand(800, 1000) * 0.001);
41
+
42
+        return $Table;
43
+    }
44
+
45
+    private static function table_query($TableName)
46
+    {
47
+        switch ($TableName) {
48
+        case 'uploaded':
49
+          $Query =  "
50
+          SELECT Uploaded
51
+          FROM users_main
52
+          WHERE Enabled = '1'
53
+            AND Uploaded > 0
54
+          ORDER BY Uploaded;";
55
+          break;
56
+
57
+        case 'downloaded':
58
+          $Query =  "
59
+          SELECT Downloaded
60
+          FROM users_main
61
+          WHERE Enabled = '1'
62
+            AND Downloaded > 0
63
+          ORDER BY Downloaded;";
64
+          break;
65
+
66
+        case 'uploads':
67
+          $Query = "
68
+          SELECT COUNT(t.ID) AS Uploads
69
+          FROM users_main AS um
70
+            JOIN torrents AS t ON t.UserID = um.ID
71
+          WHERE um.Enabled = '1'
72
+          GROUP BY um.ID
73
+          ORDER BY Uploads;";
74
+          break;
75
+
76
+        case 'requests':
77
+          $Query = "
78
+          SELECT COUNT(r.ID) AS Requests
79
+          FROM users_main AS um
80
+            JOIN requests AS r ON r.FillerID = um.ID
81
+          WHERE um.Enabled = '1'
82
+          GROUP BY um.ID
83
+          ORDER BY Requests;";
84
+          break;
85
+
86
+        case 'posts':
87
+          $Query = "
88
+          SELECT COUNT(p.ID) AS Posts
89
+          FROM users_main AS um
90
+            JOIN forums_posts AS p ON p.AuthorID = um.ID
91
+          WHERE um.Enabled = '1'
92
+          GROUP BY um.ID
93
+          ORDER BY Posts;";
94
+          break;
95
+
96
+        case 'bounty':
97
+          $Query = "
98
+          SELECT SUM(rv.Bounty) AS Bounty
99
+          FROM users_main AS um
100
+            JOIN requests_votes AS rv ON rv.UserID = um.ID
101
+          WHERE um.Enabled = '1' " .
102
+          "GROUP BY um.ID
103
+          ORDER BY Bounty;";
104
+          break;
105
+
106
+        case 'artists':
107
+          $Query = "
108
+          SELECT COUNT(ta.ArtistID) AS Artists
109
+          FROM torrents_artists AS ta
110
+            JOIN torrents_group AS tg ON tg.ID = ta.GroupID
111
+            JOIN torrents AS t ON t.GroupID = tg.ID
112
+          WHERE t.UserID != ta.UserID
113
+          GROUP BY tg.ID
114
+          ORDER BY Artists ASC";
115
+          break;
116
+        }
117
+        return $Query;
118
+    }
119
+
120
+    public static function get_rank($TableName, $Value)
121
+    {
122
+        if ($Value == 0) {
123
+            return 0;
124
+        }
125
+
126
+        $Table = G::$Cache->get_value(self::PREFIX.$TableName);
127
+        if (!$Table) {
128
+            //Cache lock!
129
+            $Lock = G::$Cache->get_value(self::PREFIX.$TableName.'_lock');
130
+            if ($Lock) {
131
+                return false;
132
+            } else {
133
+                G::$Cache->cache_value(self::PREFIX.$TableName.'_lock', '1', 300);
134
+                $Table = self::build_table(self::PREFIX.$TableName, self::table_query($TableName));
135
+                G::$Cache->delete_value(self::PREFIX.$TableName.'_lock');
136
+            }
137
+        }
138
+
139
+        $LastPercentile = 0;
140
+        foreach ($Table as $Row) {
141
+            list($CurValue) = $Row;
142
+            if ($CurValue >= $Value) {
143
+                return $LastPercentile;
144
+            }
145
+            $LastPercentile++;
146
+        }
147
+        return 100; // 100th percentile
148
+    }
149
+
150
+    public static function overall_score($Uploaded, $Downloaded, $Uploads, $Requests, $Posts, $Bounty, $Artists, $Ratio)
151
+    {
152
+        // We can do this all in 1 line, but it's easier to read this way
153
+        if ($Ratio > 1) {
154
+            $Ratio = 1;
155
+        }
156
+
157
+        $TotalScore = 0;
158
+        if (in_array(false, func_get_args(), true)) {
159
+            return false;
160
+        }
161
+        
162
+        $TotalScore += $Uploaded * 15;
163
+        $TotalScore += $Downloaded * 8;
164
+        $TotalScore += $Uploads * 25;
165
+        $TotalScore += $Requests * 2;
166
+        $TotalScore += $Posts;
167
+        $TotalScore += $Bounty;
168
+        $TotalScore += $Artists;
169
+        $TotalScore /= (15 + 8 + 25 + 2 + 1 + 1 + 1);
170
+        $TotalScore *= $Ratio;
171
+        return $TotalScore;
172
+    }
173
+}

+ 1016
- 0
classes/users.class.php
File diff suppressed because it is too large
View File


+ 653
- 0
classes/util.php View File

@@ -0,0 +1,653 @@
1
+<?php
2
+#declare(strict_types = 1);
3
+
4
+// This is a file of miscellaneous functions that are called so damn often
5
+// that it'd just be annoying to stick them in namespaces.
6
+
7
+/**
8
+ * Return true if the given string is numeric.
9
+ *
10
+ * @param mixed $Str
11
+ * @return bool
12
+ */
13
+function is_number($Str)
14
+{
15
+    # todo: Strict equality breaks everything
16
+    return $Str == strval(intval($Str));
17
+}
18
+
19
+
20
+/**
21
+ * is_date()
22
+ */
23
+function is_date($Date)
24
+{
25
+    list($Y, $M, $D) = explode('-', $Date);
26
+
27
+    if (checkdate($M, $D, $Y)) {
28
+        return true;
29
+    }
30
+
31
+    return false;
32
+}
33
+
34
+
35
+/**
36
+ * Check that some given variables (usually in _GET or _POST) are numbers
37
+ *
38
+ * @param array $Base array that's supposed to contain all keys to check
39
+ * @param array $Keys list of keys to check
40
+ * @param mixed $Error error code or string to pass to the error() function if a key isn't numeric
41
+ */
42
+function assert_numbers(&$Base, $Keys, $Error = 0)
43
+{
44
+    // Make sure both arguments are arrays
45
+    if (!is_array($Base) || !is_array($Keys)) {
46
+        return;
47
+    }
48
+
49
+    foreach ($Keys as $Key) {
50
+        if (!isset($Base[$Key]) || !is_number($Base[$Key])) {
51
+            error($Error);
52
+        }
53
+    }
54
+}
55
+
56
+
57
+/**
58
+ * Return true, false or null, depending on the input value's "truthiness" or "non-truthiness"
59
+ *
60
+ * @param $Value the input value to check for truthiness
61
+ * @return true if $Value is "truthy", false if it is "non-truthy" or null if $Value was not
62
+ *         a bool-like value
63
+ */
64
+function is_bool_value($Value)
65
+{
66
+    if (is_bool($Value)) {
67
+        return $Value;
68
+    }
69
+
70
+    if (is_string($Value)) {
71
+        switch (strtolower($Value)) {
72
+            case 'true':
73
+            case 'yes':
74
+            case 'on':
75
+            case '1':
76
+                return true;
77
+
78
+            case 'false':
79
+            case 'no':
80
+            case 'off':
81
+            case '0':
82
+                return false;
83
+        }
84
+    }
85
+
86
+    if (is_numeric($Value)) {
87
+        if ($Value === 1) {
88
+            return true;
89
+        } elseif ($Value === 0) {
90
+            return false;
91
+        }
92
+    }
93
+
94
+    return;
95
+}
96
+
97
+
98
+/**
99
+ * HTML-escape a string for output.
100
+ * This is preferable to htmlspecialchars because it doesn't screw up upon a double escape.
101
+ *
102
+ * @param string $Str
103
+ * @return string escaped string.
104
+ */
105
+function display_str($Str)
106
+{
107
+    if ($Str === null || $Str === false || is_array($Str)) {
108
+        return '';
109
+    }
110
+
111
+    if ($Str !== '' && !is_number($Str)) {
112
+        $Str = Format::make_utf8($Str);
113
+        $Str = mb_convert_encoding($Str, 'HTML-ENTITIES', 'UTF-8');
114
+        $Str = preg_replace("/&(?![A-Za-z]{0,4}\w{2,3};|#[0-9]{2,6};)/m", '&amp;', $Str);
115
+
116
+        $Replace = array(
117
+            "'",'"',"<",">",
118
+            '&#128;','&#130;','&#131;','&#132;','&#133;','&#134;','&#135;','&#136;',
119
+            '&#137;','&#138;','&#139;','&#140;','&#142;','&#145;','&#146;','&#147;',
120
+            '&#148;','&#149;','&#150;','&#151;','&#152;','&#153;','&#154;','&#155;',
121
+            '&#156;','&#158;','&#159;'
122
+        );
123
+
124
+        $With = array(
125
+            '&#39;','&quot;','&lt;','&gt;',
126
+            '&#8364;','&#8218;','&#402;','&#8222;','&#8230;','&#8224;','&#8225;','&#710;',
127
+            '&#8240;','&#352;','&#8249;','&#338;','&#381;','&#8216;','&#8217;','&#8220;',
128
+            '&#8221;','&#8226;','&#8211;','&#8212;','&#732;','&#8482;','&#353;','&#8250;',
129
+            '&#339;','&#382;','&#376;'
130
+        );
131
+
132
+        $Str = str_replace($Replace, $With, $Str);
133
+    }
134
+
135
+    return $Str;
136
+}
137
+
138
+
139
+/**
140
+ * Send a message to an IRC bot listening on SOCKET_LISTEN_PORT
141
+ *
142
+ * @param string $Raw An IRC protocol snippet to send.
143
+ */
144
+function send_irc($Channels = null, $Message = '')
145
+{
146
+    $ENV = ENV::go();
147
+
148
+    // Check if IRC is enabled
149
+    if (!$ENV->FEATURE_IRC || !$Channels) {
150
+        return false;
151
+    }
152
+
153
+    # The fn takes an array or string
154
+    $Dest = [];
155
+
156
+    # Quick missed connection fix
157
+    if (is_string($Channels)) {
158
+        $Channels = explode(' ', $Channels);
159
+    }
160
+    
161
+    # Strip leading #channel hash
162
+    foreach ($Channels as $c) {
163
+        array_push($Dest, preg_replace('/^#/', '', $c));
164
+    }
165
+
166
+    # Specific to AB's kana bot
167
+    # https://github.com/anniemaybytes/kana
168
+    $Command =
169
+    implode('-', $Dest)
170
+    . '|%|'
171
+    . html_entity_decode(
172
+        display_str($Message),
173
+        ENT_QUOTES
174
+    );
175
+
176
+    # Original input sanitization
177
+    $Command = str_replace(array("\n", "\r"), '', $Command);
178
+
179
+    # Send the raw echo
180
+    $IRCSocket = fsockopen(SOCKET_LISTEN_ADDRESS, SOCKET_LISTEN_PORT);
181
+    fwrite($IRCSocket, $Command);
182
+    fclose($IRCSocket);
183
+}
184
+
185
+
186
+/**
187
+ * notify()
188
+ * Formerly in sections/error/index.php
189
+ */
190
+function notify($Channel, $Message)
191
+{
192
+    $ENV = ENV::go();
193
+    global $LoggedUser;
194
+
195
+    # Redirect dev messages to debug channel
196
+    if ($ENV->DEV) {
197
+        $Channel = $ENV->DEBUG_CHAN;
198
+    }
199
+
200
+    #
201
+    send_irc(
202
+        $Channel,
203
+        $Message
204
+        . " error by "
205
+        . (!empty($LoggedUser['ID']) ? site_url()
206
+            . "user.php?id=".$LoggedUser['ID']
207
+            . " ("
208
+            . $LoggedUser['Username']
209
+            . ")" : $_SERVER['REMOTE_ADDR']
210
+            . " ("
211
+            . Tools::geoip($_SERVER['REMOTE_ADDR'])
212
+        . ")")
213
+        
214
+        . " accessing https://"
215
+        . SITE_DOMAIN
216
+        . ""
217
+        . $_SERVER['REQUEST_URI']
218
+        . (!empty($_SERVER['HTTP_REFERER']) ? " from "
219
+            . $_SERVER['HTTP_REFERER'] : '')
220
+    );
221
+}
222
+
223
+
224
+/**
225
+ * Advanced error handling
226
+ *
227
+ * Displays an HTTP status code with description and triggers an error.
228
+ * If you use your own string for $Error, it becomes the error description.
229
+ *
230
+ * @param int|string $Error Error type
231
+ * The available HTTP status codes are
232
+ *  - Client:  [ 400, 403, 404, 405, 408, 413, 429 ]
233
+ *  - Server:  [ 500, 502, 504 ]
234
+ *  - Gazelle: [ -1, 0, !! ]
235
+ *
236
+ * @param boolean $NoHTML If true, the header/footer won't be shown, just the error.
237
+ * @param string $Log If true, the user is given a link to search $Log in the site log.
238
+ * @param boolean $Debug If true, print bug reporting instructions and a stack trace.
239
+ * @param boolean $JSON If true, print the error as a JSON response.
240
+ */
241
+function error($Error = 1, $NoHTML = false, $Log = false, $Debug = true) # , $JSON = false)
242
+{
243
+    $ENV = ENV::go();
244
+
245
+    # Error out on erroneous $Error
246
+    (!$Error || $Error === null)
247
+        ?? trigger_error('No $Error.', E_USER_ERROR);
248
+
249
+    (!is_int($Error) || !is_string($Error))
250
+        ?? trigger_error('$Error must be int or string.', E_USER_ERROR);
251
+
252
+    # Formerly in sections/error/index.php
253
+    if (!empty($_GET['e']) && is_int($_GET['e'])) {
254
+        # Request error, i.e., /nonexistent_page.php
255
+        $Error = $_GET['e'];
256
+    }
257
+
258
+    # https://en.wikipedia.org/wiki/List_of_HTTP_status_codes
259
+    switch ($Error) {
260
+        /**
261
+         * Client errors
262
+         */
263
+        case 400:
264
+        case 1: # Probably the user's fault
265
+            $Title = '400 Bad Request';
266
+            $Message = 'The server cannot or will not process the request due to an apparent client error
267
+                (e.g., malformed request syntax, size too large, invalid request message framing, or deceptive request routing).';
268
+            break;
269
+
270
+        case 403:
271
+            $Title = '403 Forbidden';
272
+            $Message = 'The request contained valid data and was understood by the server, but the server is refusing action.
273
+                This may be due to the user not having the necessary permissions for a resource or needing an account of some sort, or attempting a prohibited action
274
+                (e.g., creating a duplicate record where only one is allowed).
275
+                The request should not be repeated.';
276
+            if (substr($_SERVER['REQUEST_URI'], 0, 9) !== '/static/') {
277
+                notify($ENV->DEBUG_CHAN, $Title);
278
+            }
279
+            break;
280
+
281
+        case 404:
282
+            $Title = '404 Not Found';
283
+            $Message = 'The requested resource could not be found but may be available in the future.
284
+                Subsequent requests by the client are permissible.';
285
+            // Hide alerts for missing images and static requests
286
+            if (!preg_match(
287
+                "/\.(ico|jpg|jpeg|gif|png)$/",
288
+                $_SERVER['REQUEST_URI']
289
+            ) && substr($_SERVER['REQUEST_URI'], 0, 9) !== '/static/') {
290
+                notify($ENV->DEBUG_CHAN, $Title);
291
+            }
292
+            break;
293
+
294
+        case 405:
295
+            $Title = '405 Method Not Allowed';
296
+            $Message = 'A request method is not supported for the requested resource;
297
+                for example, a GET request on a form that requires data to be presented via POST,
298
+                or a PUT request on a read-only resource.';
299
+            notify($ENV->DEBUG_CHAN, $Title);
300
+            break;
301
+  
302
+        case 408:
303
+            $Title = '408 Request Timeout';
304
+            $Message = 'The server timed out waiting for the request.
305
+                According to HTTP specifications:
306
+                "The client did not produce a request within the time that the server was prepared to wait.
307
+                The client MAY repeat the request without modifications at any later time."';
308
+            break;
309
+
310
+        case 413:
311
+            $Title = '413 Payload Too Large';
312
+            $Message = 'The request is larger than the server is willing or able to process.';
313
+            notify($ENV->DEBUG_CHAN, $Title);
314
+            break;
315
+
316
+        case 429:
317
+            $Title = '429 Too Many Requests';
318
+            $Message = 'The user has sent too many requests in a given amount of time.';
319
+            notify($ENV->DEBUG_CHAN, $Title);
320
+            break;
321
+
322
+        /**
323
+         * Server errors
324
+         */
325
+        case 500:
326
+            $Title = '500 Internal Server Error';
327
+            $Message = 'A generic error message,
328
+                given when an unexpected condition was encountered and no more specific message is suitable.';
329
+        break;
330
+
331
+        case 502:
332
+            $Title = '502 Bad Gateway';
333
+            $Message = 'The server was acting as a gateway or proxy and received an invalid response from the upstream server.';
334
+            notify($ENV->DEBUG_CHAN, $Title);
335
+        break;
336
+
337
+        case 504:
338
+            $Title = '504 Gateway Timeout';
339
+            $Message = 'The server was acting as a gateway or proxy and did not receive a timely response from the upstream server.';
340
+            notify($ENV->DEBUG_CHAN, $Title);
341
+        break;
342
+
343
+        /**
344
+         * Gazelle errors
345
+         */
346
+        case -1:
347
+        #case 0: # Matches custom error strings
348
+            $Title = 'Invalid Input';
349
+            $Message = 'Something was wrong with the input provided with your request, and the server is refusing to fulfill it.';
350
+            notify($ENV->DEBUG_CHAN, 'PHP-0');
351
+        break;
352
+
353
+        case '!!':
354
+            $Title = 'Unexpected Error';
355
+            $Message = 'You have encountered an unexpected error.';
356
+            notify($ENV->DEBUG_CHAN, 'unexpected');
357
+        break;
358
+
359
+        default:
360
+            $Title = 'Other Error';
361
+            $Message = "A function supplied its own error message: $Error";
362
+            notify($ENV->DEBUG_CHAN, $Message);
363
+    }
364
+
365
+    # Normalize whitespace before adding features
366
+    $Message = preg_replace('/\s{2,}/', ' ', $Message);
367
+
368
+    /**
369
+     * JSON error output
370
+     */
371
+    /*
372
+    if ($JSON) {
373
+        print
374
+        json_encode(
375
+            array(
376
+              'status' => 'error',
377
+              'response' => $Message
378
+            )
379
+        );
380
+    }
381
+    */
382
+
383
+    /**
384
+     * Append $Log
385
+     * Formerly in sections/error/index.php
386
+     */
387
+    if ($Log ?? false) {
388
+        $Message .= " <a href='log.php?search=$Title'>Search Log</a>";
389
+    }
390
+
391
+    /**
392
+     * Append $Debug
393
+     */
394
+    if ($Debug ?? false) {
395
+        $DateTime = strftime('%c', $_SERVER['REQUEST_TIME']);
396
+        $BackTrace = debug_string_backtrace();
397
+
398
+        $Message .= ($NoHTML)
399
+            ? $BackTrace
400
+            : <<<HTML
401
+        <br /><br />
402
+        Please include the server response below,
403
+        as in a <a href="/staff.php">Staff PM</a>,
404
+        to help with debugging.
405
+
406
+<pre>
407
+```
408
+$DateTime
409
+{$_SERVER['SERVER_PROTOCOL']} {$_SERVER['REQUEST_METHOD']} $Title
410
+
411
+{$_SERVER['SCRIPT_FILENAME']}
412
+{$_SERVER['REQUEST_URI']}
413
+
414
+$BackTrace
415
+```
416
+</pre>
417
+HTML;
418
+    }
419
+
420
+    /**
421
+     * Display HTML
422
+     * Formerly in sections/error/index.php
423
+     */
424
+    if (empty($NoHTML)) {
425
+        View::show_header($Title);
426
+        echo $HTML = <<<HTML
427
+        <div>
428
+          <h2 class="header">$Title</h2>
429
+
430
+          <div class="box pad">
431
+            <p>$Message</p>
432
+          </div>
433
+        </div>
434
+HTML;
435
+        View::show_footer();
436
+    }
437
+
438
+    # Trigger the error
439
+    global $Debug;
440
+    $Debug->profile();
441
+    trigger_error("$Title - $Message", E_USER_ERROR);
442
+    throw new Exception("$Title - $Message");
443
+}
444
+
445
+
446
+/**
447
+ * debug_string_backtrace()
448
+ * https://stackoverflow.com/a/7039409
449
+ */
450
+function debug_string_backtrace()
451
+{
452
+    $e = new Exception;
453
+    return $e->getTraceAsString();
454
+}
455
+
456
+
457
+/**
458
+ * Convenience function. See doc in permissions.class.php
459
+ */
460
+function check_perms($PermissionName, $MinClass = 0)
461
+{
462
+    return Permissions::check_perms($PermissionName, $MinClass);
463
+}
464
+
465
+
466
+/**
467
+ * get_permissions_for_user()
468
+ */
469
+function get_permissions_for_user($UserID, $CustomPermissions = false)
470
+{
471
+    return Permissions::get_permissions_for_user($UserID, $CustomPermissions = false);
472
+}
473
+
474
+
475
+/**
476
+ * Print the site's URL including the appropriate URI scheme, including the trailing slash
477
+ */
478
+function site_url()
479
+{
480
+    return 'https://' . SITE_DOMAIN . '/';
481
+}
482
+# End OT/Bio Gazelle util.php
483
+
484
+
485
+    /**
486
+     * OPS JSON functions
487
+     * @see https://github.com/OPSnet/Gazelle/blob/master/classes/util.php
488
+     */
489
+
490
+/**
491
+ * Print JSON status result with an optional message and die.
492
+ */
493
+function json_die($Status, $Message = 'bad parameters')
494
+{
495
+    json_print($Status, $Message);
496
+    die();
497
+}
498
+
499
+/**
500
+ * Print JSON status result with an optional message.
501
+ */
502
+function json_print($Status, $Message)
503
+{
504
+    if ($Status === 'success' && $Message) {
505
+        $response = ['status' => $Status, 'response' => $Message];
506
+    } elseif ($Message) {
507
+        $response = ['status' => $Status, 'error' => $Message];
508
+    } else {
509
+        $response = ['status' => $Status, 'response' => []];
510
+    }
511
+
512
+    print(json_encode(add_json_info($response)));
513
+}
514
+
515
+/**
516
+ * json_error
517
+ */
518
+function json_error($Code)
519
+{
520
+    echo json_encode(add_json_info(['status' => 'failure', 'error' => $Code, 'response' => []]));
521
+    die();
522
+}
523
+
524
+/**
525
+ * json_or_error
526
+ */
527
+function json_or_error($JsonError, $Error = null, $NoHTML = false)
528
+{
529
+    if (defined('AJAX')) {
530
+        json_error($JsonError);
531
+    } else {
532
+        error($Error ?? $JsonError, $NoHTML);
533
+    }
534
+}
535
+
536
+/**
537
+ * add_json_info
538
+ */
539
+function add_json_info($Json)
540
+{
541
+    $ENV = ENV::go();
542
+
543
+    if (!isset($Json['info'])) {
544
+        $Json = array_merge($Json, [
545
+            'info' => [
546
+                'source' => $ENV->SITE_NAME,
547
+                'version' => 1,
548
+            ],
549
+        ]);
550
+    }
551
+    if (!isset($Json['debug']) && check_perms('site_debug')) {
552
+        /** @var DEBUG $Debug */
553
+        global $Debug;
554
+        $Json = array_merge($Json, [
555
+            'debug' => [
556
+                'queries' => $Debug->get_queries(),
557
+                'searches' => $Debug->get_sphinxql_queries()
558
+            ],
559
+        ]);
560
+    }
561
+    return $Json;
562
+}
563
+
564
+# End OPS JSON functions
565
+# Start OPS misc functions
566
+
567
+/**
568
+ * Hydrate an array from a query string (everything that follow '?')
569
+ * This reimplements parse_str() and side-steps the issue of max_input_vars limits.
570
+ *
571
+ * Example:
572
+ * in: li[]=14&li[]=31&li[]=58&li[]=68&li[]=69&li[]=54&li[]=5, param=li[]
573
+ * parsed: ['li[]' => ['14', '31, '58', '68', '69', '5']]
574
+ * out: ['14', '31, '58', '68', '69', '5']
575
+ *
576
+ * @param string query string from url
577
+ * @param string url param to extract
578
+ * @return array hydrated equivalent
579
+ */
580
+function parseUrlArgs(string $urlArgs, string $param): array
581
+{
582
+    $list = [];
583
+    $pairs = explode('&', $urlArgs);
584
+    foreach ($pairs as $p) {
585
+        [$name, $value] = explode('=', $p, 2);
586
+        if (!isset($list[$name])) {
587
+            $list[$name] = $value;
588
+        } else {
589
+            if (!is_array($list[$name])) {
590
+                $list[$name] = [$list[$name]];
591
+            }
592
+            $list[$name][] = $value;
593
+        }
594
+    }
595
+    return array_key_exists($param, $list) ? $list[$param] : [];
596
+}
597
+
598
+
599
+/**
600
+ * base64UrlEncode
601
+ * base64UrlDecode
602
+ * @see https://github.com/OPSnet/Gazelle/blob/master/app/Util/Text.php
603
+ */
604
+function base64UrlEncode($data)
605
+{
606
+    return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
607
+}
608
+
609
+function base64UrlDecode($data)
610
+{
611
+    return base64_decode(str_pad(
612
+        strtr($data, '-_', '+/'),
613
+        strlen($data) % 4,
614
+        '=',
615
+        STR_PAD_RIGHT
616
+    ));
617
+}
618
+
619
+/**
620
+ * log_token_attempt
621
+ * @see https://github.com/OPSnet/Gazelle/blob/master/classes/util.php
622
+ */
623
+// TODO: reconcile this with log_attempt in login/index.php
624
+function log_token_attempt(DB_MYSQL $db, int $userId = 0): void
625
+{
626
+    $watch = new LoginWatch;
627
+    $ipStr = $_SERVER['REMOTE_ADDR'];
628
+
629
+    [$attemptId, $attempts, $bans] = $db->row(
630
+        '
631
+        SELECT ID, Attempts, Bans
632
+        FROM login_attempts
633
+        WHERE IP = ?
634
+        ',
635
+        $_SERVER['REMOTE_ADDR']
636
+    );
637
+
638
+    if (!$attemptId) {
639
+        $watch->create($ipStr, null, $userId);
640
+        return;
641
+    }
642
+
643
+    $attempts++;
644
+    $watch->setWatch($attemptId);
645
+    if ($attempts < 6) {
646
+        $watch->increment($userId, $ipStr, null);
647
+        return;
648
+    }
649
+    $watch->ban($attempts, null, $userId);
650
+    if ($bans > 9) {
651
+        (new IPv4())->createBan(0, $ipStr, $ipStr, 'Automated ban per failed token usage');
652
+    }
653
+}

+ 566
- 0
classes/validate.class.php View File

@@ -0,0 +1,566 @@
1
+<?php
2
+
3
+/**
4
+ * Validate
5
+ * CURRENTLY UNTESTED
6
+ *
7
+ * WCD/OT Gazelle contains two functions by default:
8
+ *  - SetFields()
9
+ *  - ValidateForm()
10
+ *
11
+ * The first is responsible for initializing a number of internal variables.
12
+ * The second does the actual validation with rather hideous and clumsy logic.
13
+ *
14
+ * Bio Gazelle seeks to improve this class and centralize site validation.
15
+ * The class should serve these common and use-dependent functions:
16
+ *
17
+ *  - Ensure that no malicious data enters the DB
18
+ *   - Prevent XSS and SQL injection via $_GET and $_POST
19
+ *   - Escape all text inputs and HTML outputs
20
+ *   - Generate secure input elements and DB outputs
21
+ *   - Enforce $_POST except when $_GET is necessary (e.g., search → RSS)
22
+ *
23
+ *  - Ensure that no junk data enters the DB
24
+ *   - Enforce HTML form element limits (generated by this class)
25
+ *   - Prove that a date is valid and output DB-safe datetimes
26
+ *   - Limit acceptable input to classes/config.php constants
27
+ *   - Provide structured ways to support user data (e.g., $x Samples)
28
+ *   - Autocomplete everywhere
29
+ *   - Enforce DBKEY on all encryped input
30
+ *
31
+ *  - Some more stuff, a running list
32
+ *   - Check if a successful function returns null, e.g.,
33
+ *     php > function test() { return; }
34
+ *     php > var_dump(test());
35
+ *     NULL
36
+ *
37
+ *   - Check if a failed function returns false, e.g.,
38
+ *     php > function orwell() { return (2 + 2 === 5); }
39
+ *     php > var_dump(orwell());
40
+ *     bool(false)
41
+ *
42
+ * todo: Support form ID checks
43
+ * todo: Number and date validation
44
+ */
45
+
46
+class Validate
47
+{
48
+    /**
49
+     * title
50
+     *
51
+     * Check if a torrent title is valid.
52
+     * If so, return the sanitized title.
53
+     * If not, return an error.
54
+     */
55
+    public function textInput($String)
56
+    {
57
+        # Previously a constant
58
+        $MinLength = 10;
59
+        $MaxLength = 255;
60
+
61
+        # Does it exist and is it valid?
62
+        if (!$String || !is_string($String)) {
63
+            error('No or invalid $String parameter.');
64
+        }
65
+
66
+        # Is it too long or short?
67
+        if (count($String)) {
68
+        }
69
+    }
70
+
71
+
72
+    /**
73
+     * Torrent errors
74
+     *
75
+     * Responsible for the red error messages on bad upload attemps.
76
+     * todo: Test $this->TorrentError() on new file checker functions
77
+     */
78
+    public function TorrentError($Suspect)
79
+    {
80
+        global $Err;
81
+
82
+        if (!$Suspect) {
83
+            error('No error source :^)');
84
+        }
85
+
86
+        switch (false) {
87
+            case $this->HasExtensions($Suspect, 1):
88
+                return $Err = "The torrent has one or more files without extensions:\n" . display_str($Suspect);
89
+
90
+            case $this->CruftFree($Suspect):
91
+                return $Err = "The torrent has one or more junk files:\n" . display_str($Suspect);
92
+
93
+            case $this->SafeCharacters($Suspect):
94
+                $BadChars = $this->SafeCharacters('', true);
95
+                return $Err = "One or more files has the forbidden characters $BadChars:\n" . display_str($Suspect);
96
+            
97
+            default:
98
+                return null;
99
+        }
100
+
101
+        return null;
102
+    }
103
+
104
+    /**
105
+     * Check if a file has no extension and return false.
106
+     * Otherwise, return an array of the last $x extensions.
107
+     */
108
+    private function HasExtensions($FileName, $x)
109
+    {
110
+        if (!is_int($x) || $x <= 0) {
111
+            error('Requested number of extensions must be <= 0');
112
+        }
113
+
114
+        if (!strstr('.', $FileName)) {
115
+            return false;
116
+        }
117
+
118
+        $Extensions = array_slice(explode('.', strtolower($FileName)), -$x, $x);
119
+        return (!empty($Extensions)) ? $Extensions : false;
120
+    }
121
+
122
+    /**
123
+     * Check if a file is junk according to a filename blacklist.
124
+     * todo: Change $Keywords into an array of regexes
125
+     */
126
+    public function CruftFree($FileName)
127
+    {
128
+        $Keywords = [
129
+            'ahashare.com',
130
+            'demonoid.com',
131
+            'demonoid.me',
132
+            'djtunes.com',
133
+            'h33t',
134
+            'housexclusive.net',
135
+            'limetorrents.com',
136
+            'mixesdb.com',
137
+            'mixfiend.blogstop',
138
+            'mixtapetorrent.blogspot',
139
+            'plixid.com',
140
+            'reggaeme.com',
141
+            'scc.nfo',
142
+            'thepiratebay.org',
143
+            'torrentday',
144
+        ];
145
+        
146
+        # $Keywords match
147
+        foreach ($Keywords as &$Value) {
148
+            if (strpos(strtolower($FileName), $Value) !== false) {
149
+                return false;
150
+            }
151
+        }
152
+    
153
+        # Incomplete data
154
+        if (preg_match('/INCOMPLETE~\*/i', $FileName)) {
155
+            return false;
156
+        }
157
+
158
+        return true;
159
+    }
160
+      
161
+    /*
162
+     * These characters are invalid on Windows NTFS:
163
+     *   : ? / < > \ * | "
164
+     *
165
+     * If no $FileName, return the list of bad characters.
166
+     * If $FileName contains, a bad character, return false.
167
+     * Otherwise, return true.
168
+     *
169
+     * todo: Add "/" to the blacklist. This causes problems with nested dirs, apparently
170
+     * todo: Make possible preg_match($AllBlockedChars, $Name, $Matches)
171
+     */
172
+    public function SafeCharacters($FileName, $Pretty = false)
173
+    {
174
+        $InvalidChars = ':?<>\*|"';
175
+
176
+        if (empty($FileName)) {
177
+            return (!$Pretty) ? $InvalidChars : implode(' ', str_split($InvalidChars));
178
+        }
179
+
180
+        # todo: Regain functionality to return the invalid character
181
+        if (preg_match(implode('\\', str_split($InvalidChars)), $Name, $Matches)) {
182
+            return false;
183
+        }
184
+
185
+        return true;
186
+    }
187
+
188
+    /**
189
+     * Extension Parser
190
+     *
191
+     * Takes an associative array of file types and extension, e.g.,
192
+     * $Archives = [
193
+     *   '7z'     => ['7z'],
194
+     *   'bzip2'  => ['bz2', 'bzip2'],
195
+     *   'gzip'   => ['gz', 'gzip', 'tgz', 'tpz'],
196
+     *   ...
197
+     * ];
198
+     *
199
+     * Then it finds all the extensions in a torrent file list,
200
+     * organizes them by file size, and returns the heaviest match.
201
+     *
202
+     * That way, you can have, e.g., 5 GiB FASTQ sequence data in one file,
203
+     * and 100 other small files, and get the format of the actual data.
204
+     */
205
+    public function ParseExtensions($FileList, $Category, $FileTypes)
206
+    {
207
+        # Sort $Tor->file_list() output by size
208
+        $UnNested = array_values($FileList[1]);
209
+        $Sorted = (usort($UnNested, function ($a, $b) {
210
+            return $b <=> $a;
211
+        })) ? $UnNested : null;  # Ternary wrap because &uarr; returns true
212
+        
213
+        # Harvest the wheat
214
+        # todo: Entries seem duplicated here
215
+        $Heaviest = array_slice($Sorted, 0, 20);
216
+        $Matches = [];
217
+
218
+        # Distill the file format
219
+        $FileTypes = $FileTypes[$Category];
220
+        $FileTypeNames = array_keys($FileTypes);
221
+
222
+        foreach ($Heaviest as $Heaviest) {
223
+            # Collect the last 2 period-separated tokens
224
+            $Extensions = array_slice(explode('.', strtolower($Heaviest[1])), -2, 2);
225
+            $Matches = array_merge($Extensions);
226
+
227
+            # todo: Reduce nesting by one level
228
+            foreach ($Matches as $Match) {
229
+                $Match = strtolower($Match);
230
+        
231
+                foreach ($FileTypeNames as $FileTypeName) {
232
+                    $SearchMe = [ $FileTypeName, $FileTypes[$FileTypeName] ];
233
+        
234
+                    if (in_array($Match, $SearchMe[1])) {
235
+                        return $SearchMe[0];
236
+                        break;
237
+                    }
238
+                }
239
+
240
+                # Return the last element (Other or None)
241
+                return array_key_last($FileTypes);
242
+            }
243
+        }
244
+    }
245
+
246
+
247
+    /**
248
+     * Make input
249
+     * https://www.w3schools.com/html/html_form_input_types.asp
250
+     *
251
+     * Generate a secure HTML input element.
252
+     * Takes $Type, $Name (also used for id), and $Classes.
253
+     * Outputs a hardened input with the requested attributes.
254
+     *
255
+     * todo: See if this could work, one ugly switch for all forms in sections/
256
+     */
257
+
258
+    public function MakeInput($Type = 'text', $Name = '', $Classes = [])
259
+    {
260
+        if (!is_str($Type) || !is_str($Name)) {
261
+            error('$Type and $Name must be strings');
262
+        }
263
+
264
+        # Intentionally double quoted PHP constructing $HTML
265
+        # <input type='text' id='title_jp' name='title_jp' class='one two'
266
+        $HTML  = "";
267
+        $HTML .= "<input type='$Type' id='$Name' name='$Name'";
268
+        $HTML .= (!empty($Classes)) ? " class='" . implode(' ', $Classes) . "'" : "";
269
+
270
+        /*
271
+        'button'
272
+        'checkbox'
273
+        'color'
274
+        'date'
275
+        'datetime-local'
276
+        'email'
277
+        'file'
278
+        'hidden'
279
+        'image'
280
+        'month'
281
+        'number'
282
+        'password'
283
+        'radio'
284
+        'range'
285
+        'reset'
286
+        'search'
287
+        'submit'
288
+        'tel'
289
+        'text'
290
+        'time'
291
+        'url'
292
+        'week'
293
+        */
294
+                
295
+        return null;
296
+    }
297
+    
298
+
299
+    /**
300
+     * Legacy class
301
+     */
302
+    public $Fields = [];
303
+    public function SetFields($FieldName, $Required, $FieldType, $ErrorMessage, $Options = [])
304
+    {
305
+        $this->Fields[$FieldName]['Type'] = strtolower($FieldType);
306
+        $this->Fields[$FieldName]['Required'] = $Required;
307
+        $this->Fields[$FieldName]['ErrorMessage'] = $ErrorMessage;
308
+
309
+        if (!empty($Options['maxlength'])) {
310
+            $this->Fields[$FieldName]['MaxLength'] = $Options['maxlength'];
311
+        }
312
+
313
+        if (!empty($Options['minlength'])) {
314
+            $this->Fields[$FieldName]['MinLength'] = $Options['minlength'];
315
+        }
316
+
317
+        if (!empty($Options['comparefield'])) {
318
+            $this->Fields[$FieldName]['CompareField'] = $Options['comparefield'];
319
+        }
320
+
321
+        if (!empty($Options['allowperiod'])) {
322
+            $this->Fields[$FieldName]['AllowPeriod'] = $Options['allowperiod'];
323
+        }
324
+
325
+        if (!empty($Options['allowcomma'])) {
326
+            $this->Fields[$FieldName]['AllowComma'] = $Options['allowcomma'];
327
+        }
328
+
329
+        if (!empty($Options['inarray'])) {
330
+            $this->Fields[$FieldName]['InArray'] = $Options['inarray'];
331
+        }
332
+
333
+        if (!empty($Options['regex'])) {
334
+            $this->Fields[$FieldName]['Regex'] = $Options['regex'];
335
+        }
336
+    }
337
+
338
+    public function ValidateForm($ValidateArray)
339
+    {
340
+        reset($this->Fields);
341
+        foreach ($this->Fields as $FieldKey => $Field) {
342
+            $ValidateVar = $ValidateArray[$FieldKey];
343
+
344
+            # todo: Change this to a switch statement
345
+            if ($ValidateVar !== '' || !empty($Field['Required']) || $Field['Type'] === 'date') {
346
+                if ($Field['Type'] === 'string') {
347
+                    if (isset($Field['MaxLength'])) {
348
+                        $MaxLength = $Field['MaxLength'];
349
+                    } else {
350
+                        $MaxLength = 255;
351
+                    }
352
+
353
+                    if (isset($Field['MinLength'])) {
354
+                        $MinLength = $Field['MinLength'];
355
+                    } else {
356
+                        $MinLength = 1;
357
+                    }
358
+
359
+                    if (strlen($ValidateVar) > $MaxLength) {
360
+                        return $Field['ErrorMessage'];
361
+                    } elseif (strlen($ValidateVar) < $MinLength) {
362
+                        return $Field['ErrorMessage'];
363
+                    }
364
+                } elseif ($Field['Type'] === 'number') {
365
+                    if (isset($Field['MaxLength'])) {
366
+                        $MaxLength = $Field['MaxLength'];
367
+                    } else {
368
+                        $MaxLength = '';
369
+                    }
370
+
371
+                    if (isset($Field['MinLength'])) {
372
+                        $MinLength = $Field['MinLength'];
373
+                    } else {
374
+                        $MinLength = 0;
375
+                    }
376
+
377
+                    $Match = '0-9';
378
+                    if (isset($Field['AllowPeriod'])) {
379
+                        $Match .= '.';
380
+                    }
381
+
382
+                    if (isset($Field['AllowComma'])) {
383
+                        $Match .= ',';
384
+                    }
385
+
386
+                    if (preg_match('/[^'.$Match.']/', $ValidateVar) || strlen($ValidateVar) < 1) {
387
+                        return $Field['ErrorMessage'];
388
+                    } elseif ($MaxLength !== '' && $ValidateVar > $MaxLength) {
389
+                        return $Field['ErrorMessage'].'!!';
390
+                    } elseif ($ValidateVar < $MinLength) {
391
+                        return $Field['ErrorMessage']."$MinLength";
392
+                    }
393
+                } elseif ($Field['Type'] === 'email') {
394
+                    if (isset($Field['MaxLength'])) {
395
+                        $MaxLength = $Field['MaxLength'];
396
+                    } else {
397
+                        $MaxLength = 255;
398
+                    }
399
+
400
+                    if (isset($Field['MinLength'])) {
401
+                        $MinLength = $Field['MinLength'];
402
+                    } else {
403
+                        $MinLength = 6;
404
+                    }
405
+
406
+                    if (!preg_match("/^".EMAIL_REGEX."$/i", $ValidateVar)) {
407
+                        return $Field['ErrorMessage'];
408
+                    } elseif (strlen($ValidateVar) > $MaxLength) {
409
+                        return $Field['ErrorMessage'];
410
+                    } elseif (strlen($ValidateVar) < $MinLength) {
411
+                        return $Field['ErrorMessage'];
412
+                    }
413
+                } elseif ($Field['Type'] === 'link') {
414
+                    if (isset($Field['MaxLength'])) {
415
+                        $MaxLength = $Field['MaxLength'];
416
+                    } else {
417
+                        $MaxLength = 255;
418
+                    }
419
+
420
+                    if (isset($Field['MinLength'])) {
421
+                        $MinLength = $Field['MinLength'];
422
+                    } else {
423
+                        $MinLength = 10;
424
+                    }
425
+
426
+                    if (!preg_match('/^'.URL_REGEX.'$/i', $ValidateVar)) {
427
+                        return $Field['ErrorMessage'];
428
+                    } elseif (strlen($ValidateVar) > $MaxLength) {
429
+                        return $Field['ErrorMessage'];
430
+                    } elseif (strlen($ValidateVar) < $MinLength) {
431
+                        return $Field['ErrorMessage'];
432
+                    }
433
+                } elseif ($Field['Type'] === 'username') {
434
+                    if (isset($Field['MaxLength'])) {
435
+                        $MaxLength = $Field['MaxLength'];
436
+                    } else {
437
+                        $MaxLength = 20;
438
+                    }
439
+                    
440
+                    if (isset($Field['MinLength'])) {
441
+                        $MinLength = $Field['MinLength'];
442
+                    } else {
443
+                        $MinLength = 1;
444
+                    }
445
+
446
+                    if (!preg_match(USERNAME_REGEX, $ValidateVar)) {
447
+                        return $Field['ErrorMessage'];
448
+                    } elseif (strlen($ValidateVar) > $MaxLength) {
449
+                        return $Field['ErrorMessage'];
450
+                    } elseif (strlen($ValidateVar) < $MinLength) {
451
+                        return $Field['ErrorMessage'];
452
+                    }
453
+                } elseif ($Field['Type'] === 'checkbox') {
454
+                    if (!isset($ValidateArray[$FieldKey])) {
455
+                        return $Field['ErrorMessage'];
456
+                    }
457
+                } elseif ($Field['Type'] === 'compare') {
458
+                    if ($ValidateArray[$Field['CompareField']] !== $ValidateVar) {
459
+                        return $Field['ErrorMessage'];
460
+                    }
461
+                } elseif ($Field['Type'] === 'inarray') {
462
+                    if (array_search($ValidateVar, $Field['InArray']) === false) {
463
+                        return $Field['ErrorMessage'];
464
+                    }
465
+                } elseif ($Field['Type'] === 'regex') {
466
+                    if (!preg_match($Field['Regex'], $ValidateVar)) {
467
+                        return $Field['ErrorMessage'];
468
+                    }
469
+                }
470
+            }
471
+        } // while
472
+    } // function
473
+} // class
474
+
475
+
476
+/**
477
+ * File checker stub class
478
+ *
479
+ * Not technically part of the Validate class (yet).
480
+ * Useful torrent file functions such as finding disallowed characters.
481
+ * This will eventually move inside Validate for upload_handle.php.
482
+ */
483
+
484
+$Keywords = array(
485
+  'ahashare.com', 'demonoid.com', 'demonoid.me', 'djtunes.com', 'h33t', 'housexclusive.net',
486
+  'limetorrents.com', 'mixesdb.com', 'mixfiend.blogstop', 'mixtapetorrent.blogspot',
487
+  'plixid.com', 'reggaeme.com' , 'scc.nfo', 'thepiratebay.org', 'torrentday');
488
+
489
+function check_file($Type, $Name)
490
+{
491
+    check_name($Name);
492
+    check_extensions($Type, $Name);
493
+}
494
+
495
+function check_name($Name)
496
+{
497
+    global $Keywords;
498
+    $NameLC = strtolower($Name);
499
+
500
+    foreach ($Keywords as &$Value) {
501
+        if (strpos($NameLC, $Value) !== false) {
502
+            forbidden_error($Name);
503
+        }
504
+    }
505
+
506
+    if (preg_match('/INCOMPLETE~\*/i', $Name)) {
507
+        forbidden_error($Name);
508
+    }
509
+
510
+    /*
511
+     * These characters are invalid in NTFS on Windows systems:
512
+     *    : ? / < > \ * | "
513
+     *
514
+     * todo: Add "/" to the blacklist. This causes problems with nested dirs, apparently
515
+     * todo: Make possible preg_match($AllBlockedChars, $Name, $Matches)
516
+     *
517
+     * Only the following characters need to be escaped (see the link below):
518
+     *    \ - ^ ]
519
+     *
520
+     * http://www.php.net/manual/en/regexp.reference.character-classes.php
521
+     */
522
+    $AllBlockedChars = ' : ? < > \ * | " ';
523
+    if (preg_match('/[\\:?<>*|"]/', $Name, $Matches)) {
524
+        character_error($Matches[0], $AllBlockedChars);
525
+    }
526
+}
527
+
528
+function check_extensions($Type, $Name)
529
+{
530
+    # todo: Make generic or subsume into Validate->ParseExtensions()
531
+    /*
532
+    if (!isset($MusicExtensions[get_file_extension($Name)])) {
533
+        invalid_error($Name);
534
+    }
535
+    */
536
+}
537
+
538
+function get_file_extension($FileName)
539
+{
540
+    return strtolower(substr(strrchr($FileName, '.'), 1));
541
+}
542
+
543
+/**
544
+ * Error functions
545
+ *
546
+ * Responsible for the red error messages on bad upload attemps.
547
+ * todo: Make one function, e.g., Validate->error($type)
548
+ */
549
+
550
+function invalid_error($Name)
551
+{
552
+    global $Err;
553
+    $Err = 'The torrent contained one or more invalid files (' . display_str($Name) . ')';
554
+}
555
+
556
+function forbidden_error($Name)
557
+{
558
+    global $Err;
559
+    $Err = 'The torrent contained one or more forbidden files (' . display_str($Name) . ')';
560
+}
561
+
562
+function character_error($Character, $AllBlockedChars)
563
+{
564
+    global $Err;
565
+    $Err = "One or more of the files or folders in the torrent has a name that contains the forbidden character '$Character'. Please rename the files as necessary and recreate the torrent.<br /><br />\nNote: The complete list of characters that are disallowed are shown below:<br />\n\t\t$AllBlockedChars";
566
+}

+ 1994
- 0
classes/vendor/Parsedown.php
File diff suppressed because it is too large
View File


+ 686
- 0
classes/vendor/ParsedownExtra.php View File

@@ -0,0 +1,686 @@
1
+<?php
2
+
3
+#
4
+#
5
+# Parsedown Extra
6
+# https://github.com/erusev/parsedown-extra
7
+#
8
+# (c) Emanuil Rusev
9
+# http://erusev.com
10
+#
11
+# For the full license information, view the LICENSE file that was distributed
12
+# with this source code.
13
+#
14
+#
15
+
16
+class ParsedownExtra extends Parsedown
17
+{
18
+    # ~
19
+
20
+    const version = '0.8.0';
21
+
22
+    # ~
23
+
24
+    function __construct()
25
+    {
26
+        if (version_compare(parent::version, '1.7.1') < 0)
27
+        {
28
+            throw new Exception('ParsedownExtra requires a later version of Parsedown');
29
+        }
30
+
31
+        $this->BlockTypes[':'] []= 'DefinitionList';
32
+        $this->BlockTypes['*'] []= 'Abbreviation';
33
+
34
+        # identify footnote definitions before reference definitions
35
+        array_unshift($this->BlockTypes['['], 'Footnote');
36
+
37
+        # identify footnote markers before before links
38
+        array_unshift($this->InlineTypes['['], 'FootnoteMarker');
39
+    }
40
+
41
+    #
42
+    # ~
43
+
44
+    function text($text)
45
+    {
46
+        $Elements = $this->textElements($text);
47
+
48
+        # convert to markup
49
+        $markup = $this->elements($Elements);
50
+
51
+        # trim line breaks
52
+        $markup = trim($markup, "\n");
53
+
54
+        # merge consecutive dl elements
55
+
56
+        $markup = preg_replace('/<\/dl>\s+<dl>\s+/', '', $markup);
57
+
58
+        # add footnotes
59
+
60
+        if (isset($this->DefinitionData['Footnote']))
61
+        {
62
+            $Element = $this->buildFootnoteElement();
63
+
64
+            $markup .= "\n" . $this->element($Element);
65
+        }
66
+
67
+        return $markup;
68
+    }
69
+
70
+    #
71
+    # Blocks
72
+    #
73
+
74
+    #
75
+    # Abbreviation
76
+
77
+    protected function blockAbbreviation($Line)
78
+    {
79
+        if (preg_match('/^\*\[(.+?)\]:[ ]*(.+?)[ ]*$/', $Line['text'], $matches))
80
+        {
81
+            $this->DefinitionData['Abbreviation'][$matches[1]] = $matches[2];
82
+
83
+            $Block = array(
84
+                'hidden' => true,
85
+            );
86
+
87
+            return $Block;
88
+        }
89
+    }
90
+
91
+    #
92
+    # Footnote
93
+
94
+    protected function blockFootnote($Line)
95
+    {
96
+        if (preg_match('/^\[\^(.+?)\]:[ ]?(.*)$/', $Line['text'], $matches))
97
+        {
98
+            $Block = array(
99
+                'label' => $matches[1],
100
+                'text' => $matches[2],
101
+                'hidden' => true,
102
+            );
103
+
104
+            return $Block;
105
+        }
106
+    }
107
+
108
+    protected function blockFootnoteContinue($Line, $Block)
109
+    {
110
+        if ($Line['text'][0] === '[' and preg_match('/^\[\^(.+?)\]:/', $Line['text']))
111
+        {
112
+            return;
113
+        }
114
+
115
+        if (isset($Block['interrupted']))
116
+        {
117
+            if ($Line['indent'] >= 4)
118
+            {
119
+                $Block['text'] .= "\n\n" . $Line['text'];
120
+
121
+                return $Block;
122
+            }
123
+        }
124
+        else
125
+        {
126
+            $Block['text'] .= "\n" . $Line['text'];
127
+
128
+            return $Block;
129
+        }
130
+    }
131
+
132
+    protected function blockFootnoteComplete($Block)
133
+    {
134
+        $this->DefinitionData['Footnote'][$Block['label']] = array(
135
+            'text' => $Block['text'],
136
+            'count' => null,
137
+            'number' => null,
138
+        );
139
+
140
+        return $Block;
141
+    }
142
+
143
+    #
144
+    # Definition List
145
+
146
+    protected function blockDefinitionList($Line, $Block)
147
+    {
148
+        if ( ! isset($Block) or $Block['type'] !== 'Paragraph')
149
+        {
150
+            return;
151
+        }
152
+
153
+        $Element = array(
154
+            'name' => 'dl',
155
+            'elements' => array(),
156
+        );
157
+
158
+        $terms = explode("\n", $Block['element']['handler']['argument']);
159
+
160
+        foreach ($terms as $term)
161
+        {
162
+            $Element['elements'] []= array(
163
+                'name' => 'dt',
164
+                'handler' => array(
165
+                    'function' => 'lineElements',
166
+                    'argument' => $term,
167
+                    'destination' => 'elements'
168
+                ),
169
+            );
170
+        }
171
+
172
+        $Block['element'] = $Element;
173
+
174
+        $Block = $this->addDdElement($Line, $Block);
175
+
176
+        return $Block;
177
+    }
178
+
179
+    protected function blockDefinitionListContinue($Line, array $Block)
180
+    {
181
+        if ($Line['text'][0] === ':')
182
+        {
183
+            $Block = $this->addDdElement($Line, $Block);
184
+
185
+            return $Block;
186
+        }
187
+        else
188
+        {
189
+            if (isset($Block['interrupted']) and $Line['indent'] === 0)
190
+            {
191
+                return;
192
+            }
193
+
194
+            if (isset($Block['interrupted']))
195
+            {
196
+                $Block['dd']['handler']['function'] = 'textElements';
197
+                $Block['dd']['handler']['argument'] .= "\n\n";
198
+
199
+                $Block['dd']['handler']['destination'] = 'elements';
200
+
201
+                unset($Block['interrupted']);
202
+            }
203
+
204
+            $text = substr($Line['body'], min($Line['indent'], 4));
205
+
206
+            $Block['dd']['handler']['argument'] .= "\n" . $text;
207
+
208
+            return $Block;
209
+        }
210
+    }
211
+
212
+    #
213
+    # Header
214
+
215
+    protected function blockHeader($Line)
216
+    {
217
+        $Block = parent::blockHeader($Line);
218
+
219
+        if ($Block !== null && preg_match('/[ #]*{('.$this->regexAttribute.'+)}[ ]*$/', $Block['element']['handler']['argument'], $matches, PREG_OFFSET_CAPTURE))
220
+        {
221
+            $attributeString = $matches[1][0];
222
+
223
+            $Block['element']['attributes'] = $this->parseAttributeData($attributeString);
224
+
225
+            $Block['element']['handler']['argument'] = substr($Block['element']['handler']['argument'], 0, $matches[0][1]);
226
+        }
227
+
228
+        return $Block;
229
+    }
230
+
231
+    #
232
+    # Markup
233
+
234
+    protected function blockMarkup($Line)
235
+    {
236
+        if ($this->markupEscaped or $this->safeMode)
237
+        {
238
+            return;
239
+        }
240
+
241
+        if (preg_match('/^<(\w[\w-]*)(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*(\/)?>/', $Line['text'], $matches))
242
+        {
243
+            $element = strtolower($matches[1]);
244
+
245
+            if (in_array($element, $this->textLevelElements))
246
+            {
247
+                return;
248
+            }
249
+
250
+            $Block = array(
251
+                'name' => $matches[1],
252
+                'depth' => 0,
253
+                'element' => array(
254
+                    'rawHtml' => $Line['text'],
255
+                    'autobreak' => true,
256
+                ),
257
+            );
258
+
259
+            $length = strlen($matches[0]);
260
+            $remainder = substr($Line['text'], $length);
261
+
262
+            if (trim($remainder) === '')
263
+            {
264
+                if (isset($matches[2]) or in_array($matches[1], $this->voidElements))
265
+                {
266
+                    $Block['closed'] = true;
267
+                    $Block['void'] = true;
268
+                }
269
+            }
270
+            else
271
+            {
272
+                if (isset($matches[2]) or in_array($matches[1], $this->voidElements))
273
+                {
274
+                    return;
275
+                }
276
+                if (preg_match('/<\/'.$matches[1].'>[ ]*$/i', $remainder))
277
+                {
278
+                    $Block['closed'] = true;
279
+                }
280
+            }
281
+
282
+            return $Block;
283
+        }
284
+    }
285
+
286
+    protected function blockMarkupContinue($Line, array $Block)
287
+    {
288
+        if (isset($Block['closed']))
289
+        {
290
+            return;
291
+        }
292
+
293
+        if (preg_match('/^<'.$Block['name'].'(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*>/i', $Line['text'])) # open
294
+        {
295
+            $Block['depth'] ++;
296
+        }
297
+
298
+        if (preg_match('/(.*?)<\/'.$Block['name'].'>[ ]*$/i', $Line['text'], $matches)) # close
299
+        {
300
+            if ($Block['depth'] > 0)
301
+            {
302
+                $Block['depth'] --;
303
+            }
304
+            else
305
+            {
306
+                $Block['closed'] = true;
307
+            }
308
+        }
309
+
310
+        if (isset($Block['interrupted']))
311
+        {
312
+            $Block['element']['rawHtml'] .= "\n";
313
+            unset($Block['interrupted']);
314
+        }
315
+
316
+        $Block['element']['rawHtml'] .= "\n".$Line['body'];
317
+
318
+        return $Block;
319
+    }
320
+
321
+    protected function blockMarkupComplete($Block)
322
+    {
323
+        if ( ! isset($Block['void']))
324
+        {
325
+            $Block['element']['rawHtml'] = $this->processTag($Block['element']['rawHtml']);
326
+        }
327
+
328
+        return $Block;
329
+    }
330
+
331
+    #
332
+    # Setext
333
+
334
+    protected function blockSetextHeader($Line, array $Block = null)
335
+    {
336
+        $Block = parent::blockSetextHeader($Line, $Block);
337
+
338
+        if ($Block !== null && preg_match('/[ ]*{('.$this->regexAttribute.'+)}[ ]*$/', $Block['element']['handler']['argument'], $matches, PREG_OFFSET_CAPTURE))
339
+        {
340
+            $attributeString = $matches[1][0];
341
+
342
+            $Block['element']['attributes'] = $this->parseAttributeData($attributeString);
343
+
344
+            $Block['element']['handler']['argument'] = substr($Block['element']['handler']['argument'], 0, $matches[0][1]);
345
+        }
346
+
347
+        return $Block;
348
+    }
349
+
350
+    #
351
+    # Inline Elements
352
+    #
353
+
354
+    #
355
+    # Footnote Marker
356
+
357
+    protected function inlineFootnoteMarker($Excerpt)
358
+    {
359
+        if (preg_match('/^\[\^(.+?)\]/', $Excerpt['text'], $matches))
360
+        {
361
+            $name = $matches[1];
362
+
363
+            if ( ! isset($this->DefinitionData['Footnote'][$name]))
364
+            {
365
+                return;
366
+            }
367
+
368
+            $this->DefinitionData['Footnote'][$name]['count'] ++;
369
+
370
+            if ( ! isset($this->DefinitionData['Footnote'][$name]['number']))
371
+            {
372
+                $this->DefinitionData['Footnote'][$name]['number'] = ++ $this->footnoteCount; # &raquo; &
373
+            }
374
+
375
+            $Element = array(
376
+                'name' => 'sup',
377
+                'attributes' => array('id' => 'fnref'.$this->DefinitionData['Footnote'][$name]['count'].':'.$name),
378
+                'element' => array(
379
+                    'name' => 'a',
380
+                    'attributes' => array('href' => '#fn:'.$name, 'class' => 'footnote-ref'),
381
+                    'text' => $this->DefinitionData['Footnote'][$name]['number'],
382
+                ),
383
+            );
384
+
385
+            return array(
386
+                'extent' => strlen($matches[0]),
387
+                'element' => $Element,
388
+            );
389
+        }
390
+    }
391
+
392
+    private $footnoteCount = 0;
393
+
394
+    #
395
+    # Link
396
+
397
+    protected function inlineLink($Excerpt)
398
+    {
399
+        $Link = parent::inlineLink($Excerpt);
400
+
401
+        $remainder = $Link !== null ? substr($Excerpt['text'], $Link['extent']) : '';
402
+
403
+        if (preg_match('/^[ ]*{('.$this->regexAttribute.'+)}/', $remainder, $matches))
404
+        {
405
+            $Link['element']['attributes'] += $this->parseAttributeData($matches[1]);
406
+
407
+            $Link['extent'] += strlen($matches[0]);
408
+        }
409
+
410
+        return $Link;
411
+    }
412
+
413
+    #
414
+    # ~
415
+    #
416
+
417
+    private $currentAbreviation;
418
+    private $currentMeaning;
419
+
420
+    protected function insertAbreviation(array $Element)
421
+    {
422
+        if (isset($Element['text']))
423
+        {
424
+            $Element['elements'] = self::pregReplaceElements(
425
+                '/\b'.preg_quote($this->currentAbreviation, '/').'\b/',
426
+                array(
427
+                    array(
428
+                        'name' => 'abbr',
429
+                        'attributes' => array(
430
+                            'title' => $this->currentMeaning,
431
+                        ),
432
+                        'text' => $this->currentAbreviation,
433
+                    )
434
+                ),
435
+                $Element['text']
436
+            );
437
+
438
+            unset($Element['text']);
439
+        }
440
+
441
+        return $Element;
442
+    }
443
+
444
+    protected function inlineText($text)
445
+    {
446
+        $Inline = parent::inlineText($text);
447
+
448
+        if (isset($this->DefinitionData['Abbreviation']))
449
+        {
450
+            foreach ($this->DefinitionData['Abbreviation'] as $abbreviation => $meaning)
451
+            {
452
+                $this->currentAbreviation = $abbreviation;
453
+                $this->currentMeaning = $meaning;
454
+
455
+                $Inline['element'] = $this->elementApplyRecursiveDepthFirst(
456
+                    array($this, 'insertAbreviation'),
457
+                    $Inline['element']
458
+                );
459
+            }
460
+        }
461
+
462
+        return $Inline;
463
+    }
464
+
465
+    #
466
+    # Util Methods
467
+    #
468
+
469
+    protected function addDdElement(array $Line, array $Block)
470
+    {
471
+        $text = substr($Line['text'], 1);
472
+        $text = trim($text);
473
+
474
+        unset($Block['dd']);
475
+
476
+        $Block['dd'] = array(
477
+            'name' => 'dd',
478
+            'handler' => array(
479
+                'function' => 'lineElements',
480
+                'argument' => $text,
481
+                'destination' => 'elements'
482
+            ),
483
+        );
484
+
485
+        if (isset($Block['interrupted']))
486
+        {
487
+            $Block['dd']['handler']['function'] = 'textElements';
488
+
489
+            unset($Block['interrupted']);
490
+        }
491
+
492
+        $Block['element']['elements'] []= & $Block['dd'];
493
+
494
+        return $Block;
495
+    }
496
+
497
+    protected function buildFootnoteElement()
498
+    {
499
+        $Element = array(
500
+            'name' => 'div',
501
+            'attributes' => array('class' => 'footnotes'),
502
+            'elements' => array(
503
+                array('name' => 'hr'),
504
+                array(
505
+                    'name' => 'ol',
506
+                    'elements' => array(),
507
+                ),
508
+            ),
509
+        );
510
+
511
+        uasort($this->DefinitionData['Footnote'], 'self::sortFootnotes');
512
+
513
+        foreach ($this->DefinitionData['Footnote'] as $definitionId => $DefinitionData)
514
+        {
515
+            if ( ! isset($DefinitionData['number']))
516
+            {
517
+                continue;
518
+            }
519
+
520
+            $text = $DefinitionData['text'];
521
+
522
+            $textElements = parent::textElements($text);
523
+
524
+            $numbers = range(1, $DefinitionData['count']);
525
+
526
+            $backLinkElements = array();
527
+
528
+            foreach ($numbers as $number)
529
+            {
530
+                $backLinkElements[] = array('text' => ' ');
531
+                $backLinkElements[] = array(
532
+                    'name' => 'a',
533
+                    'attributes' => array(
534
+                        'href' => "#fnref$number:$definitionId",
535
+                        'rev' => 'footnote',
536
+                        'class' => 'footnote-backref',
537
+                    ),
538
+                    'rawHtml' => '&#8617;',
539
+                    'allowRawHtmlInSafeMode' => true,
540
+                    'autobreak' => false,
541
+                );
542
+            }
543
+
544
+            unset($backLinkElements[0]);
545
+
546
+            $n = count($textElements) -1;
547
+
548
+            if ($textElements[$n]['name'] === 'p')
549
+            {
550
+                $backLinkElements = array_merge(
551
+                    array(
552
+                        array(
553
+                            'rawHtml' => '&#160;',
554
+                            'allowRawHtmlInSafeMode' => true,
555
+                        ),
556
+                    ),
557
+                    $backLinkElements
558
+                );
559
+
560
+                unset($textElements[$n]['name']);
561
+
562
+                $textElements[$n] = array(
563
+                    'name' => 'p',
564
+                    'elements' => array_merge(
565
+                        array($textElements[$n]),
566
+                        $backLinkElements
567
+                    ),
568
+                );
569
+            }
570
+            else
571
+            {
572
+                $textElements[] = array(
573
+                    'name' => 'p',
574
+                    'elements' => $backLinkElements
575
+                );
576
+            }
577
+
578
+            $Element['elements'][1]['elements'] []= array(
579
+                'name' => 'li',
580
+                'attributes' => array('id' => 'fn:'.$definitionId),
581
+                'elements' => array_merge(
582
+                    $textElements
583
+                ),
584
+            );
585
+        }
586
+
587
+        return $Element;
588
+    }
589
+
590
+    # ~
591
+
592
+    protected function parseAttributeData($attributeString)
593
+    {
594
+        $Data = array();
595
+
596
+        $attributes = preg_split('/[ ]+/', $attributeString, - 1, PREG_SPLIT_NO_EMPTY);
597
+
598
+        foreach ($attributes as $attribute)
599
+        {
600
+            if ($attribute[0] === '#')
601
+            {
602
+                $Data['id'] = substr($attribute, 1);
603
+            }
604
+            else # "."
605
+            {
606
+                $classes []= substr($attribute, 1);
607
+            }
608
+        }
609
+
610
+        if (isset($classes))
611
+        {
612
+            $Data['class'] = implode(' ', $classes);
613
+        }
614
+
615
+        return $Data;
616
+    }
617
+
618
+    # ~
619
+
620
+    protected function processTag($elementMarkup) # recursive
621
+    {
622
+        # http://stackoverflow.com/q/1148928/200145
623
+        libxml_use_internal_errors(true);
624
+
625
+        $DOMDocument = new DOMDocument;
626
+
627
+        # http://stackoverflow.com/q/11309194/200145
628
+        $elementMarkup = mb_convert_encoding($elementMarkup, 'HTML-ENTITIES', 'UTF-8');
629
+
630
+        # http://stackoverflow.com/q/4879946/200145
631
+        $DOMDocument->loadHTML($elementMarkup);
632
+        $DOMDocument->removeChild($DOMDocument->doctype);
633
+        $DOMDocument->replaceChild($DOMDocument->firstChild->firstChild->firstChild, $DOMDocument->firstChild);
634
+
635
+        $elementText = '';
636
+
637
+        if ($DOMDocument->documentElement->getAttribute('markdown') === '1')
638
+        {
639
+            foreach ($DOMDocument->documentElement->childNodes as $Node)
640
+            {
641
+                $elementText .= $DOMDocument->saveHTML($Node);
642
+            }
643
+
644
+            $DOMDocument->documentElement->removeAttribute('markdown');
645
+
646
+            $elementText = "\n".$this->text($elementText)."\n";
647
+        }
648
+        else
649
+        {
650
+            foreach ($DOMDocument->documentElement->childNodes as $Node)
651
+            {
652
+                $nodeMarkup = $DOMDocument->saveHTML($Node);
653
+
654
+                if ($Node instanceof DOMElement and ! in_array($Node->nodeName, $this->textLevelElements))
655
+                {
656
+                    $elementText .= $this->processTag($nodeMarkup);
657
+                }
658
+                else
659
+                {
660
+                    $elementText .= $nodeMarkup;
661
+                }
662
+            }
663
+        }
664
+
665
+        # because we don't want for markup to get encoded
666
+        $DOMDocument->documentElement->nodeValue = 'placeholder\x1A';
667
+
668
+        $markup = $DOMDocument->saveHTML($DOMDocument->documentElement);
669
+        $markup = str_replace('placeholder\x1A', $elementText, $markup);
670
+
671
+        return $markup;
672
+    }
673
+
674
+    # ~
675
+
676
+    protected function sortFootnotes($A, $B) # callback
677
+    {
678
+        return $A['number'] - $B['number'];
679
+    }
680
+
681
+    #
682
+    # Fields
683
+    #
684
+
685
+    protected $regexAttribute = '(?:[#.][-\w]+[ ]*)';
686
+}

+ 410
- 0
classes/vendor/TwitterAPIExchange.php View File

@@ -0,0 +1,410 @@
1
+<?php
2
+
3
+/**
4
+ * Twitter-API-PHP : Simple PHP wrapper for the v1.1 API
5
+ *
6
+ * PHP version 5.3.10
7
+ *
8
+ * @category Awesomeness
9
+ * @package  Twitter-API-PHP
10
+ * @author   James Mallison <me@j7mbo.co.uk>
11
+ * @license  MIT License
12
+ * @version  1.0.4
13
+ * @link     http://github.com/j7mbo/twitter-api-php
14
+ */
15
+class TwitterAPIExchange
16
+{
17
+    /**
18
+     * @var string
19
+     */
20
+    private $oauth_access_token;
21
+
22
+    /**
23
+     * @var string
24
+     */
25
+    private $oauth_access_token_secret;
26
+
27
+    /**
28
+     * @var string
29
+     */
30
+    private $consumer_key;
31
+
32
+    /**
33
+     * @var string
34
+     */
35
+    private $consumer_secret;
36
+
37
+    /**
38
+     * @var array
39
+     */
40
+    private $postfields;
41
+
42
+    /**
43
+     * @var string
44
+     */
45
+    private $getfield;
46
+
47
+    /**
48
+     * @var mixed
49
+     */
50
+    protected $oauth;
51
+
52
+    /**
53
+     * @var string
54
+     */
55
+    public $url;
56
+
57
+    /**
58
+     * @var string
59
+     */
60
+    public $requestMethod;
61
+
62
+    /**
63
+     * The HTTP status code from the previous request
64
+     *
65
+     * @var int
66
+     */
67
+    protected $httpStatusCode;
68
+
69
+    /**
70
+     * Create the API access object. Requires an array of settings::
71
+     * oauth access token, oauth access token secret, consumer key, consumer secret
72
+     * These are all available by creating your own application on dev.twitter.com
73
+     * Requires the cURL library
74
+     *
75
+     * @throws \RuntimeException When cURL isn't loaded
76
+     * @throws \InvalidArgumentException When incomplete settings parameters are provided
77
+     *
78
+     * @param array $settings
79
+     */
80
+    public function __construct(array $settings)
81
+    {
82
+        if (!function_exists('curl_init'))
83
+        {
84
+            throw new RuntimeException('TwitterAPIExchange requires cURL extension to be loaded, see: http://curl.haxx.se/docs/install.html');
85
+        }
86
+
87
+        if (!isset($settings['oauth_access_token'])
88
+            || !isset($settings['oauth_access_token_secret'])
89
+            || !isset($settings['consumer_key'])
90
+            || !isset($settings['consumer_secret']))
91
+        {
92
+            throw new InvalidArgumentException('Incomplete settings passed to TwitterAPIExchange');
93
+        }
94
+
95
+        $this->oauth_access_token = $settings['oauth_access_token'];
96
+        $this->oauth_access_token_secret = $settings['oauth_access_token_secret'];
97
+        $this->consumer_key = $settings['consumer_key'];
98
+        $this->consumer_secret = $settings['consumer_secret'];
99
+    }
100
+
101
+    /**
102
+     * Set postfields array, example: array('screen_name' => 'J7mbo')
103
+     *
104
+     * @param array $array Array of parameters to send to API
105
+     *
106
+     * @throws \Exception When you are trying to set both get and post fields
107
+     *
108
+     * @return TwitterAPIExchange Instance of self for method chaining
109
+     */
110
+    public function setPostfields(array $array)
111
+    {
112
+        if (!is_null($this->getGetfield()))
113
+        {
114
+            throw new Exception('You can only choose get OR post fields (post fields include put).');
115
+        }
116
+
117
+        if (isset($array['status']) && substr($array['status'], 0, 1) === '@')
118
+        {
119
+            $array['status'] = sprintf("\0%s", $array['status']);
120
+        }
121
+
122
+        foreach ($array as $key => &$value)
123
+        {
124
+            if (is_bool($value))
125
+            {
126
+                $value = ($value === true) ? 'true' : 'false';
127
+            }
128
+        }
129
+
130
+        $this->postfields = $array;
131
+
132
+        // rebuild oAuth
133
+        if (isset($this->oauth['oauth_signature']))
134
+        {
135
+            $this->buildOauth($this->url, $this->requestMethod);
136
+        }
137
+
138
+        return $this;
139
+    }
140
+
141
+    /**
142
+     * Set getfield string, example: '?screen_name=J7mbo'
143
+     *
144
+     * @param string $string Get key and value pairs as string
145
+     *
146
+     * @throws \Exception
147
+     *
148
+     * @return \TwitterAPIExchange Instance of self for method chaining
149
+     */
150
+    public function setGetfield($string)
151
+    {
152
+        if (!is_null($this->getPostfields()))
153
+        {
154
+            throw new Exception('You can only choose get OR post / post fields.');
155
+        }
156
+
157
+        $getfields = preg_replace('/^\?/', '', explode('&', $string));
158
+        $params = array();
159
+
160
+        foreach ($getfields as $field)
161
+        {
162
+            if ($field !== '')
163
+            {
164
+                list($key, $value) = explode('=', $field);
165
+                $params[$key] = $value;
166
+            }
167
+        }
168
+
169
+        $this->getfield = '?' . http_build_query($params, '', '&');
170
+
171
+        return $this;
172
+    }
173
+
174
+    /**
175
+     * Get getfield string (simple getter)
176
+     *
177
+     * @return string $this->getfields
178
+     */
179
+    public function getGetfield()
180
+    {
181
+        return $this->getfield;
182
+    }
183
+
184
+    /**
185
+     * Get postfields array (simple getter)
186
+     *
187
+     * @return array $this->postfields
188
+     */
189
+    public function getPostfields()
190
+    {
191
+        return $this->postfields;
192
+    }
193
+
194
+    /**
195
+     * Build the Oauth object using params set in construct and additionals
196
+     * passed to this method. For v1.1, see: https://dev.twitter.com/docs/api/1.1
197
+     *
198
+     * @param string $url           The API url to use. Example: https://api.twitter.com/1.1/search/tweets.json
199
+     * @param string $requestMethod Either POST or GET
200
+     *
201
+     * @throws \Exception
202
+     *
203
+     * @return \TwitterAPIExchange Instance of self for method chaining
204
+     */
205
+    public function buildOauth($url, $requestMethod)
206
+    {
207
+        if (!in_array(strtolower($requestMethod), array('post', 'get', 'put', 'delete')))
208
+        {
209
+            throw new Exception('Request method must be either POST, GET or PUT or DELETE');
210
+        }
211
+
212
+        $consumer_key              = $this->consumer_key;
213
+        $consumer_secret           = $this->consumer_secret;
214
+        $oauth_access_token        = $this->oauth_access_token;
215
+        $oauth_access_token_secret = $this->oauth_access_token_secret;
216
+
217
+        $oauth = array(
218
+            'oauth_consumer_key' => $consumer_key,
219
+            'oauth_nonce' => time(),
220
+            'oauth_signature_method' => 'HMAC-SHA1',
221
+            'oauth_token' => $oauth_access_token,
222
+            'oauth_timestamp' => time(),
223
+            'oauth_version' => '1.0'
224
+        );
225
+
226
+        $getfield = $this->getGetfield();
227
+
228
+        if (!is_null($getfield))
229
+        {
230
+            $getfields = str_replace('?', '', explode('&', $getfield));
231
+
232
+            foreach ($getfields as $g)
233
+            {
234
+                $split = explode('=', $g);
235
+
236
+                /** In case a null is passed through **/
237
+                if (isset($split[1]))
238
+                {
239
+                    $oauth[$split[0]] = urldecode($split[1]);
240
+                }
241
+            }
242
+        }
243
+
244
+        $postfields = $this->getPostfields();
245
+
246
+        if (!is_null($postfields)) {
247
+            foreach ($postfields as $key => $value) {
248
+                $oauth[$key] = $value;
249
+            }
250
+        }
251
+
252
+        $base_info = $this->buildBaseString($url, $requestMethod, $oauth);
253
+        $composite_key = rawurlencode($consumer_secret) . '&' . rawurlencode($oauth_access_token_secret);
254
+        $oauth_signature = base64_encode(hash_hmac('sha1', $base_info, $composite_key, true));
255
+        $oauth['oauth_signature'] = $oauth_signature;
256
+
257
+        $this->url           = $url;
258
+        $this->requestMethod = $requestMethod;
259
+        $this->oauth         = $oauth;
260
+
261
+        return $this;
262
+    }
263
+
264
+    /**
265
+     * Perform the actual data retrieval from the API
266
+     *
267
+     * @param boolean $return      If true, returns data. This is left in for backward compatibility reasons
268
+     * @param array   $curlOptions Additional Curl options for this request
269
+     *
270
+     * @throws \Exception
271
+     *
272
+     * @return string json If $return param is true, returns json data.
273
+     */
274
+    public function performRequest($return = true, $curlOptions = array())
275
+    {
276
+        if (!is_bool($return))
277
+        {
278
+            throw new Exception('performRequest parameter must be true or false');
279
+        }
280
+
281
+        $header =  array($this->buildAuthorizationHeader($this->oauth), 'Expect:');
282
+
283
+        $getfield = $this->getGetfield();
284
+        $postfields = $this->getPostfields();
285
+
286
+        if (in_array(strtolower($this->requestMethod), array('put', 'delete')))
287
+        {
288
+            $curlOptions[CURLOPT_CUSTOMREQUEST] = $this->requestMethod;
289
+        }
290
+
291
+        $options = $curlOptions + array(
292
+            CURLOPT_HTTPHEADER => $header,
293
+            CURLOPT_HEADER => false,
294
+            CURLOPT_URL => $this->url,
295
+            CURLOPT_RETURNTRANSFER => true,
296
+            CURLOPT_TIMEOUT => 10,
297
+        );
298
+
299
+        if (!is_null($postfields))
300
+        {
301
+            $options[CURLOPT_POSTFIELDS] = http_build_query($postfields, '', '&');
302
+        }
303
+        else
304
+        {
305
+            if ($getfield !== '')
306
+            {
307
+                $options[CURLOPT_URL] .= $getfield;
308
+            }
309
+        }
310
+
311
+        $feed = curl_init();
312
+        curl_setopt_array($feed, $options);
313
+        $json = curl_exec($feed);
314
+
315
+        $this->httpStatusCode = curl_getinfo($feed, CURLINFO_HTTP_CODE);
316
+
317
+        if (($error = curl_error($feed)) !== '')
318
+        {
319
+            curl_close($feed);
320
+
321
+            throw new \Exception($error);
322
+        }
323
+
324
+        curl_close($feed);
325
+
326
+        return $json;
327
+    }
328
+
329
+    /**
330
+     * Private method to generate the base string used by cURL
331
+     *
332
+     * @param string $baseURI
333
+     * @param string $method
334
+     * @param array  $params
335
+     *
336
+     * @return string Built base string
337
+     */
338
+    private function buildBaseString($baseURI, $method, $params)
339
+    {
340
+        $return = array();
341
+        ksort($params);
342
+
343
+        foreach($params as $key => $value)
344
+        {
345
+            $return[] = rawurlencode($key) . '=' . rawurlencode($value);
346
+        }
347
+
348
+        return $method . "&" . rawurlencode($baseURI) . '&' . rawurlencode(implode('&', $return));
349
+    }
350
+
351
+    /**
352
+     * Private method to generate authorization header used by cURL
353
+     *
354
+     * @param array $oauth Array of oauth data generated by buildOauth()
355
+     *
356
+     * @return string $return Header used by cURL for request
357
+     */
358
+    private function buildAuthorizationHeader(array $oauth)
359
+    {
360
+        $return = 'Authorization: OAuth ';
361
+        $values = array();
362
+
363
+        foreach($oauth as $key => $value)
364
+        {
365
+            if (in_array($key, array('oauth_consumer_key', 'oauth_nonce', 'oauth_signature',
366
+                'oauth_signature_method', 'oauth_timestamp', 'oauth_token', 'oauth_version'))) {
367
+                $values[] = "$key=\"" . rawurlencode($value) . "\"";
368
+            }
369
+        }
370
+
371
+        $return .= implode(', ', $values);
372
+        return $return;
373
+    }
374
+
375
+    /**
376
+     * Helper method to perform our request
377
+     *
378
+     * @param string $url
379
+     * @param string $method
380
+     * @param string $data
381
+     * @param array  $curlOptions
382
+     *
383
+     * @throws \Exception
384
+     *
385
+     * @return string The json response from the server
386
+     */
387
+    public function request($url, $method = 'get', $data = null, $curlOptions = array())
388
+    {
389
+        if (strtolower($method) === 'get')
390
+        {
391
+            $this->setGetfield($data);
392
+        }
393
+        else
394
+        {
395
+            $this->setPostfields($data);
396
+        }
397
+
398
+        return $this->buildOauth($url, $method)->performRequest(true, $curlOptions);
399
+    }
400
+
401
+    /**
402
+     * Get the HTTP status code for the previous request
403
+     *
404
+     * @return integer
405
+     */
406
+    public function getHttpStatusCode()
407
+    {
408
+        return $this->httpStatusCode;
409
+    }
410
+}

+ 216
- 0
classes/view.class.php View File

@@ -0,0 +1,216 @@
1
+<?php
2
+declare(strict_types = 1);
3
+
4
+class View
5
+{
6
+    /**
7
+     * @var string Path relative to where (P)HTML templates reside
8
+     */
9
+    const IncludePath = './design/views/';
10
+
11
+
12
+    /**
13
+     * commonMeta
14
+     */
15
+    public function commonMeta()
16
+    {
17
+        $ENV = ENV::go();
18
+
19
+        return $HTML = <<<HTML
20
+        <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
21
+        <meta name=viewport content="width=device-width, initial-scale=1">
22
+        <!-- Default "index, follow" -->
23
+        <meta name="robots" content="none" />
24
+        <meta name="language" content="en-US" />
25
+        <meta name="description" content="$ENV->DESCRIPTION" />
26
+        <link rel="manifest" href="/manifest.php" />
27
+        <link rel="shortcut icon" href="/static/common/icon.png" />
28
+        <link rel="search" type="application/opensearchdescription+xml"
29
+          title="$ENV->SITE_NAME"
30
+          href="$ENV->STATIC_SERVER/opensearch.xml" />
31
+HTML;
32
+    }
33
+
34
+
35
+    /**
36
+     * HTTP/2 Server Push headers for Cloudflare
37
+     * @see https://blog.cloudflare.com/using-http-2-server-push-with-php/
38
+     */
39
+    public function pushAsset($uri, $type)
40
+    {
41
+        $ENV = ENV::go();
42
+
43
+        # Bad URI or type
44
+        if ((!$uri || !is_string($uri))
45
+          || (!$type || !is_string($type))) {
46
+            return error(404);
47
+        }
48
+
49
+        $filemtime = filemtime(SERVER_ROOT."/$uri");
50
+        $integrity = base64_encode(hash_file($ENV->SRI, SERVER_ROOT."/$uri", true));
51
+
52
+        # Send raw HTTP headers - preloading this way is bloat
53
+        # 26 requests, 1.58 MB, 584ms
54
+        #    vs.
55
+        # 10 requests, 730.8 KB, 460ms
56
+        #header("Link: <$uri?v=$filemtime>; rel=preload; as=$type", false);
57
+
58
+        switch ($type) {
59
+            case 'script':
60
+                $HTML = "<script src='$uri?v=$filemtime' integrity='$ENV->SRI-$integrity' crossorigin='anonymous'></script>";
61
+                break;
62
+
63
+            case 'style':
64
+                $HTML = "<link rel='stylesheet' href='$uri?v$filemtime' integrity='$integrity' crossorigin='anonymous' />";
65
+                break;
66
+
67
+            case 'font':
68
+                $HTML = "<link rel='preload' as='font' href='$uri?v$filemtime' integrity='$integrity' crossorigin='anonymous' />";
69
+                break;
70
+
71
+            default:
72
+                break;
73
+        }
74
+
75
+        # Needs to echo into the page
76
+        return $HTML;
77
+    }
78
+
79
+
80
+    /**
81
+     * This function is to include the header file on a page.
82
+     *
83
+     * @param $PageTitle the title of the page
84
+     * @param $JSIncludes is a comma-separated list of JS files to be included on
85
+     *                    the page. ONLY PUT THE RELATIVE LOCATION WITHOUT '.js'
86
+     *                    example: 'somefile,somedir/somefile'
87
+     */
88
+    public static function show_header($PageTitle = '', $JSIncludes = '', $CSSIncludes = '')
89
+    {
90
+        $ENV = ENV::go();
91
+        global $Document, $Mobile, $Classes;
92
+
93
+        if ($PageTitle !== '') {
94
+            $PageTitle .= " $ENV->SEP ";
95
+        }
96
+
97
+        $PageTitle .= $ENV->SITE_NAME;
98
+        $PageID = array(
99
+            $Document, // Document
100
+            empty($_REQUEST['action']) ? false : $_REQUEST['action'], // Action
101
+            empty($_REQUEST['type']) ? false : $_REQUEST['type'] // Type
102
+        );
103
+
104
+        if (!is_array(G::$LoggedUser)
105
+          || empty(G::$LoggedUser['ID'])
106
+          || (isset($Options['recover']) && $Options['recover'] === true)) {
107
+            require_once SERVER_ROOT.'/design/publicheader.php';
108
+        } else {
109
+            require_once SERVER_ROOT.'/design/privateheader.php';
110
+        }
111
+    }
112
+    
113
+
114
+    /**
115
+     * This function is to include the footer file on a page.
116
+     *
117
+     * @param $Options an optional array that you can pass information to the
118
+     *                 header through as well as setup certain limitations
119
+     *                 Here is a list of parameters that work in the $Options array:
120
+     *                 ['disclaimer'] = [boolean] (False) Displays the disclaimer in the footer
121
+     */
122
+    public static function show_footer($Options = [])
123
+    {
124
+        global $ScriptStartTime, $SessionID, $UserSessions, $Debug, $Time, $Mobile;
125
+        if (!is_array(G::$LoggedUser)
126
+          || empty(G::$LoggedUser['ID'])
127
+          || (isset($Options['recover']) && $Options['recover'] === true)) {
128
+            require_once SERVER_ROOT.'/design/publicfooter.php';
129
+        } else {
130
+            require_once SERVER_ROOT.'/design/privatefooter.php';
131
+        }
132
+    }
133
+
134
+
135
+    /**
136
+     * This is a generic function to load a template fromm /design and render it.
137
+     * The template should be in /design/my_template_name.php, and have a class
138
+     * in it called MyTemplateNameTemplate (my_template_name transformed to
139
+     * MixedCase, with the word 'Template' appended).
140
+     * This class should have a public static function render($Args), where
141
+     * $Args is an associative array of the template variables.
142
+     * You should note that by "Template", we mean "php file that outputs stuff".
143
+     *
144
+     * This function loads /design/$TemplateName.php, and then calls
145
+     * render($Args) on the class.
146
+     *
147
+     * @param string $TemplateName The name of the template, in underscore_format
148
+     * @param array $Args the arguments passed to the template.
149
+     */
150
+    public static function render_template($TemplateName, $Args)
151
+    {
152
+        static $LoadedTemplates; // Keep track of templates we've already loaded.
153
+        $ClassName = '';
154
+        if (isset($LoadedTemplates[$TemplateName])) {
155
+            $ClassName = $LoadedTemplates[$TemplateName];
156
+        } else {
157
+            include SERVER_ROOT.'/design/'.$TemplateName.'.php';
158
+
159
+            // Turn template_name into TemplateName
160
+            $ClassNameParts = explode('_', $TemplateName);
161
+            foreach ($ClassNameParts as $Index => $Part) {
162
+                $ClassNameParts[$Index] = ucfirst($Part);
163
+            }
164
+            
165
+            $ClassName = implode($ClassNameParts). 'Template';
166
+            $LoadedTemplates[$TemplateName] = $ClassName;
167
+        }
168
+        $ClassName::render($Args);
169
+    }
170
+
171
+
172
+    /**
173
+     * This method is similar to render_template, but does not require a
174
+     * template class.
175
+     *
176
+     * Instead, this method simply renders a PHP file (PHTML) with the supplied
177
+     * variables.
178
+     *
179
+     * All files must be placed within {self::IncludePath}. Create and organize
180
+     * new paths and files. (e.g.: /design/views/artist/, design/view/forums/, etc.)
181
+     *
182
+     * @static
183
+     * @param string  $TemplateFile A relative path to a PHTML file
184
+     * @param array   $Variables Assoc. array of variables to extract for the template
185
+     * @param boolean $Buffer enables Output Buffer
186
+     * @return boolean|string
187
+     *
188
+     * @example <pre><?php
189
+     *  // box.phtml
190
+     *  <p id="<?=$id?>">Data</p>
191
+     *
192
+     *  // The variable $id within box.phtml will be filled by $some_id
193
+     *  View::parse('section/box.phtml', array('id' => $some_id));
194
+     *
195
+     *  // Parse a template without outputing it
196
+     *  $SavedTemplate = View::parse('sec/tion/eg.php', $DataArray, true);
197
+     *  // later . . .
198
+     *  echo $SavedTemplate; // Output the buffer
199
+     * </pre>
200
+     */
201
+    public static function parse($TemplateFile, array $Variables = [], $Buffer = false)
202
+    {
203
+        $Template = self::IncludePath . $TemplateFile;
204
+        if (file_exists($Template)) {
205
+            extract($Variables);
206
+            if ($Buffer) {
207
+                ob_start();
208
+                include $Template;
209
+                $Content = ob_get_contents();
210
+                ob_end_clean();
211
+                return $Content;
212
+            }
213
+            return include $Template;
214
+        }
215
+    }
216
+}

+ 111
- 0
classes/wiki.class.php View File

@@ -0,0 +1,111 @@
1
+<?php
2
+declare(strict_types = 1);
3
+
4
+class Wiki
5
+{
6
+    /**
7
+     * Normalize an alias
8
+     * @param string $str
9
+     * @return string
10
+     */
11
+    public static function normalize_alias($str)
12
+    {
13
+        return trim(substr(preg_replace('/[^a-z0-9]/', '', strtolower(htmlentities($str))), 0, 50));
14
+    }
15
+
16
+    /**
17
+     * Get all aliases in an associative array of Alias => ArticleID
18
+     * @return array
19
+     */
20
+    public static function get_aliases()
21
+    {
22
+        $Aliases = G::$Cache->get_value('wiki_aliases');
23
+        if (!$Aliases) {
24
+            $QueryID = G::$DB->get_query_id();
25
+            G::$DB->query("
26
+            SELECT Alias, ArticleID
27
+            FROM wiki_aliases");
28
+            $Aliases = G::$DB->to_pair('Alias', 'ArticleID');
29
+            G::$DB->set_query_id($QueryID);
30
+            G::$Cache->cache_value('wiki_aliases', $Aliases, 3600 * 24 * 14); // 2 weeks
31
+        }
32
+        return $Aliases;
33
+    }
34
+
35
+    /**
36
+     * Flush the alias cache. Call this whenever you touch the wiki_aliases table.
37
+     */
38
+    public static function flush_aliases()
39
+    {
40
+        G::$Cache->delete_value('wiki_aliases');
41
+    }
42
+
43
+    /**
44
+     * Get the ArticleID corresponding to an alias
45
+     * @param string $Alias
46
+     * @return int
47
+     */
48
+    public static function alias_to_id($Alias)
49
+    {
50
+        $Aliases = self::get_aliases();
51
+        $Alias = self::normalize_alias($Alias);
52
+        if (!isset($Aliases[$Alias])) {
53
+            return false;
54
+        } else {
55
+            return (int)$Aliases[$Alias];
56
+        }
57
+    }
58
+
59
+    /**
60
+     * Get an article; returns false on error if $Error = false
61
+     * @param int $ArticleID
62
+     * @param bool $Error
63
+     * @return array|bool
64
+     */
65
+    public static function get_article($ArticleID, $Error = true)
66
+    {
67
+        $Contents = G::$Cache->get_value('wiki_article_'.$ArticleID);
68
+        if (!$Contents) {
69
+            $QueryID = G::$DB->get_query_id();
70
+            G::$DB->query("
71
+            SELECT
72
+              w.Revision,
73
+              w.Title,
74
+              w.Body,
75
+              w.MinClassRead,
76
+              w.MinClassEdit,
77
+              w.Date,
78
+              w.Author,
79
+              u.Username,
80
+            GROUP_CONCAT(a.Alias),
81
+            GROUP_CONCAT(a.UserID)
82
+            FROM wiki_articles AS w
83
+              LEFT JOIN wiki_aliases AS a ON w.ID=a.ArticleID
84
+              LEFT JOIN users_main AS u ON u.ID=w.Author
85
+              WHERE w.ID='$ArticleID'
86
+              GROUP BY w.ID");
87
+
88
+            if (!G::$DB->has_results()) {
89
+                if ($Error) {
90
+                    error(404);
91
+                } else {
92
+                    return false;
93
+                }
94
+            }
95
+            
96
+            $Contents = G::$DB->to_array();
97
+            G::$DB->set_query_id($QueryID);
98
+            G::$Cache->cache_value('wiki_article_'.$ArticleID, $Contents, 3600 * 24 * 14); // 2 weeks
99
+        }
100
+        return $Contents;
101
+    }
102
+
103
+    /**
104
+     * Flush an article's cache. Call this whenever you edited a wiki article or its aliases.
105
+     * @param int $ArticleID
106
+     */
107
+    public static function flush_article($ArticleID)
108
+    {
109
+        G::$Cache->delete_value('wiki_article_'.$ArticleID);
110
+    }
111
+}

+ 178
- 0
classes/zip.class.php View File

@@ -0,0 +1,178 @@
1
+<?php
2
+
3
+/*************************************************************************|
4
+|--------------- Zip class -----------------------------------------------|
5
+|*************************************************************************|
6
+
7
+This class provides a convenient way for us to generate and serve zip
8
+archives to our end users, both from physical files, cached
9
+or already parsed data (torrent files). It's all done on the fly, due to
10
+the high probability that a filesystem stored archive will never be
11
+downloaded twice.
12
+
13
+Utilizes gzcompress, based upon RFC 1950
14
+
15
+//------------- How it works --------------//
16
+
17
+Basic concept is construct archive, add files, and serve on the fly.
18
+
19
+//------------- How to use it --------------//
20
+
21
+* First, construct the archive:
22
+
23
+$Zip = new Zip('FileName');
24
+
25
+  Adds the headers so that add_file can stream and we don't need to create a massive buffer.
26
+  open_stream(); was integrated into the constructor to conform with Object-Oriented Standards.
27
+
28
+$Zip->unlimit();
29
+
30
+  A simple shortcut function for raising the basic PHP limits, time and memory for larger archives.
31
+
32
+-----
33
+
34
+* Then, add files and begin streaming to the user to avoid memory buffering:
35
+
36
+$Zip->add_file(file_get_contents("data/file.txt"), "File.txt");
37
+
38
+  Adds the contents of data/file.txt into File.txt in the archive root.
39
+
40
+$Zip->add_file($TorrentData, "Bookmarks/Artist - Album [2008].torrent");
41
+
42
+  Adds the parsed torrent to the archive in the Bookmarks folder (created simply by placing it in the path).
43
+
44
+-----
45
+
46
+* Then, close the archive to the user:
47
+
48
+$Zip->close_stream();
49
+
50
+  This collects everything put together thus far in the archive, and streams it to the user in the form of Test7.zip
51
+
52
+//------ Explanation of basic functions ------//
53
+
54
+add_file(Contents, Internal Path)
55
+  Adds the contents to the archive, where it will be extracted to Internal Path.
56
+
57
+close_stream();
58
+  Collect and stream to the user.
59
+
60
+//------------- Detailed example -------------//
61
+
62
+require('classes/zip.class.php');
63
+$Zip = new Zip('FileName');
64
+$Name = 'Ubuntu-8.10';
65
+$Zip->add_file($TorrentData, 'Torrents/'.Misc::file_string($Name).'.torrent');
66
+$Zip->add_file(file_get_contents('zip.php'), 'zip.php');
67
+$Zip->close_stream();
68
+
69
+
70
+//---------- Development reference -----------//
71
+http://www.pkware.com/documents/casestudies/APPNOTE.TXT - ZIP spec (this class)
72
+http://www.ietf.org/rfc/rfc1950.txt - ZLIB compression spec (gzcompress function)
73
+http://www.fileformat.info/tool/hexdump.htm - Useful for analyzing ZIP files
74
+
75
+|*************************************************************************/
76
+
77
+if (!extension_loaded('zlib')) {
78
+    error('Zlib Extension not loaded.');
79
+}
80
+
81
+class Zip
82
+{
83
+    public $ArchiveSize = 0; // Total size
84
+    public $ArchiveFiles = 0; // Total files
85
+    private $Structure = ''; // Structure saved to memory
86
+    private $FileOffset = 0; // Offset to write data
87
+    private $Data = ''; //An idea
88
+
89
+    public function __construct($ArchiveName = 'Archive')
90
+    {
91
+        header("Content-type: application/octet-stream"); // Stream download
92
+        header("Content-disposition: attachment; filename=\"$ArchiveName.zip\""); // Name the archive - Should not be urlencoded
93
+    }
94
+
95
+    public static function unlimit()
96
+    {
97
+        ob_end_clean();
98
+        set_time_limit(3600); // Limit 1 hour
99
+        ini_set('memory_limit', '1024M'); // Because the buffers can get extremely large
100
+    }
101
+
102
+    public function add_file($FileData, $ArchivePath, $TimeStamp = 0)
103
+    {
104
+        /* File header */
105
+        $this->Data = "\x50\x4b\x03\x04"; // PK signature
106
+        $this->Data .= "\x14\x00"; // Version requirements
107
+        $this->Data .= "\x00\x08"; // Bit flag - 0x8 = UTF-8 file names
108
+        $this->Data .= "\x08\x00"; // Compression
109
+        $this->Data .= "\x00\x00\x00\x00";
110
+
111
+        $DataLength = strlen($FileData); // Saved as variable to avoid wasting CPU calculating it multiple times
112
+        $CRC32 = crc32($FileData); // Ditto
113
+        $ZipData = gzcompress($FileData); // Ditto
114
+        $ZipData = substr($ZipData, 2, (strlen($ZipData) - 6)); // Checksum resolution
115
+        $ZipLength = strlen($ZipData); // Ditto
116
+
117
+        $this->Data .= pack('V', $CRC32); // CRC-32
118
+        $this->Data .= pack('V', $ZipLength); // Compressed file size
119
+        $this->Data .= pack('V', $DataLength); // Uncompressed file size
120
+        $this->Data .= pack('v', strlen($ArchivePath)); // Path name length
121
+        $this->Data .="\x00\x00"; // Extra field length (0'd so we can ignore this)
122
+        $this->Data .= $ArchivePath; // File name & Extra Field (length set to 0 so ignored)
123
+        /* END file header */
124
+
125
+        /* File data */
126
+        $this->Data .= $ZipData; // File data
127
+        /* END file data */
128
+
129
+        /* Data descriptor
130
+        Not needed (only needed when 3rd bitflag is set), causes problems with OS X archive utility
131
+        $this->Data .= pack('V', $CRC32); // CRC-32
132
+        $this->Data .= pack('V', $ZipLength); // Compressed file size
133
+        $this->Data .= pack('V', $DataLength); // Uncompressed file size
134
+        END data descriptor */
135
+
136
+        $FileDataLength = strlen($this->Data);
137
+        $this->ArchiveSize = $this->ArchiveSize + $FileDataLength; // All we really need is the size
138
+        $CurrentOffset = $this->ArchiveSize; // Update offsets
139
+        echo $this->Data; // Get this out to reduce our memory consumption
140
+
141
+        /* Central Directory Structure */
142
+        $CDS = "\x50\x4b\x01\x02"; // CDS signature
143
+        $CDS .="\x14\x00"; // Constructor version
144
+        $CDS .="\x14\x00"; // Version requirements
145
+        $CDS .="\x00\x08"; // Bit flag - 0x8 = UTF-8 file names
146
+        $CDS .="\x08\x00"; // Compression
147
+        $CDS .="\x00\x00\x00\x00"; // Last modified
148
+        $CDS .= pack('V', $CRC32); // CRC-32
149
+        $CDS .= pack('V', $ZipLength); // Compressed file size
150
+        $CDS .= pack('V', $DataLength); // Uncompressed file size
151
+        $CDS .= pack('v', strlen($ArchivePath)); // Path name length
152
+        $CDS .="\x00\x00"; // Extra field length (0'd so we can ignore this)
153
+        $CDS .="\x00\x00"; // File comment length  (no comment, 0'd)
154
+        $CDS .="\x00\x00"; // Disk number start (0 seems valid)
155
+        $CDS .="\x00\x00"; // Internal file attributes (again with the 0's)
156
+        $CDS .="\x20\x00\x00\x00"; // External file attributes
157
+        $CDS .= pack('V', $this->FileOffset); // Offsets
158
+        $CDS .= $ArchivePath; // File name & Extra Field (length set to 0 so ignored)
159
+        /* END central Directory Structure */
160
+
161
+        $this->FileOffset = $CurrentOffset; // Update offsets
162
+        $this->Structure .= $CDS; // Append to structure
163
+        $this->ArchiveFiles++; // Increment file count
164
+    }
165
+
166
+    public function close_stream()
167
+    {
168
+        echo $this->Structure; // Structure Root
169
+        echo "\x50\x4b\x05\x06"; // End of central directory signature
170
+        echo "\x00\x00"; // This disk
171
+        echo "\x00\x00"; // CDS start
172
+        echo pack('v', $this->ArchiveFiles); // Handle the number of entries
173
+        echo pack('v', $this->ArchiveFiles); // Ditto
174
+        echo pack('V', strlen($this->Structure)); //Size
175
+        echo pack('V', $this->ArchiveSize); // Offset
176
+        echo "\x00\x00"; // No comment, close it off
177
+    }
178
+}

+ 6
- 0
collage.php View File

@@ -0,0 +1,6 @@
1
+<?php
2
+declare(strict_types=1);
3
+
4
+$_SERVER['SCRIPT_FILENAME'] = 'collages.php'; // CLI fix
5
+define('ERROR_EXCEPTION', true);
6
+require_once 'classes/script_start.php';

+ 5
- 0
collages.php View File

@@ -0,0 +1,5 @@
1
+<?php
2
+declare(strict_types=1);
3
+
4
+define('ERROR_EXCEPTION', true);
5
+require_once 'classes/script_start.php';

+ 5
- 0
comments.php View File

@@ -0,0 +1,5 @@
1
+<?php
2
+declare(strict_types=1);
3
+
4
+define('ERROR_EXCEPTION', true);
5
+require_once 'classes/script_start.php';

+ 0
- 0
contest.php View File


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

Loading…
Cancel
Save