forked from anegostudios/vsmoddb
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathshow-mod.php
More file actions
479 lines (391 loc) · 20.3 KB
/
Copy pathshow-mod.php
File metadata and controls
479 lines (391 loc) · 20.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
<?php
include $config['basepath']. 'lib/recommend-release.php';
$assetId = $urlparts[2] ?? 0;
if (!$assetId) {
showErrorPage(HTTP_NOT_FOUND);
}
$asset = $con->getRow("
SELECT
a.*,
m.*,
logo.cdnPath AS logoUrl,
logo.created < '".SQL_MOD_CARD_TRANSITION_DATE."' AS hasLegacyLogo,
HEX(creator.hash) AS creatorHash,
creator.name AS creatorName,
s.code AS statusCode
FROM
assets a
JOIN mods m ON m.assetId = a.assetId
LEFT JOIN users creator ON creator.userId = a.createdByUserId
LEFT JOIN status s ON s.statusId = a.statusId
LEFT JOIN files AS logo ON logo.fileId = m.embedLogoFileId
WHERE
a.assetId = ?
", [$assetId]);
if (!$asset) showErrorPage(HTTP_NOT_FOUND);
$tags = $con->getAll(<<<SQL
SELECT t.tagId, t.name, CONCAT('#', LPAD(HEX(t.color), 8, '0')) AS color, t.text, mt.votes, mtv.vote
FROM modTags mt
LEFT JOIN tags t ON t.tagId = mt.tagId
LEFT JOIN modTagVotes mtv ON (mtv.modId, mtv.tagId, mtv.userId) = (mt.modId, mt.tagId, ?)
WHERE mt.modId = ? AND mt.votes > ?
ORDER BY mt.votes DESC
SQL, [$user['userId'] ?? 0, $asset['modId'], TAG_HIDE_THRESHOLD]);
$hiddenTagsCount = 0;
$i = 0;
foreach($tags as $tag) {
if($tag['votes'] < TAG_DOWNVOTED_THRESHOLD) $hiddenTagsCount++;
else {
$i++;
if($i > 8) $hiddenTagsCount++;
}
}
$view->assign("tags", $tags);
$view->assign("hiddenTagsCount", $hiddenTagsCount);
$teamMembers = $con->getAll(<<<SQL
SELECT u.userId, u.name, HEX(u.hash) AS userHash
FROM modTeamMembers t
JOIN users u ON u.userId = t.userId
WHERE t.modId = ?
SQL, [$asset['modId']]);
$view->assign('teamMembers', $teamMembers);
$files = $con->getAll('SELECT * FROM files WHERE assetId = ? AND fileId NOT IN (?, ?) ORDER BY `order`',
[$assetId, $asset['cardLogoFileId'] ?? 0, $asset['embedLogoFileId'] ?? 0]); /* sql cant compare against null */
//NOTE(Rennorb): There was a time where we rescaled images for logos. We no longer do that, but in ~140 cases there are still two images for the logo: the actual logo image, and the original one that was uploaded.
// Since we don't show the logo in the slideshow anymore, we also need to remove that second file that got uploaded, without removing it from the database so it stays downloadable for the mod author until they replace it.
// Here is a sql query to get a list of such mods:
/*
select modId, urlAlias, users.name from mods
join file f on f.fileId = mods.cardLogoFileId
join file f2 on f2.cdnPath = concat(substr(f.cdnPath, 1, length(f.cdnPath) - 12), substr(f.cdnPath, -4))
join assets on mods.assetId = asset.assetId
join users on users.userId = asset.createdByUserId;
*/
if($asset['hasLegacyLogo']) {
splitOffExtension($asset['logoUrl'], $base, $ext);
if(str_ends_with($base, '_480_320')) {
$legacyLogoPath = substr($base, 0, strlen($base) - 8).'.'.$ext;
foreach ($files as $k => $file) {
if($file['cdnPath'] === $legacyLogoPath) {
unset($files[$k]);
break;
}
}
}
}
if(!empty($asset['logoUrl'])) {
$asset['logoUrl'] = formatCdnUrlFromCdnPath($asset['logoUrl']);
}
foreach ($files as &$file) {
$file['created'] = date('M jS Y, H:i:s', strtotime($file['created']));
$file['ext'] = substr($file['name'], strrpos($file['name'], '.')+1); // no clue why pathinfo doesnt work here
$file['url'] = formatCdnUrl($file);
}
unset($file);
$view->assign('files', $files);
$outerSortOrder = ($_COOKIE['commentsort'] ?? '') !== 'oldestfirst' ? 'DESC' : 'ASC';
$comments = $con->getAll(<<<SQL
SELECT
c.commentId, c.text,
LEAST(c.responseDepth, 10) AS responseDepth, -- :MaxResponseDepth
mr.kind as lastModaction,
c.userId, u.name AS username, HEX(u.hash) AS userHash, IFNULL(u.banneduntil >= NOW(), 0) AS isBanned, r.code AS roleCode,
c.deleted, c.created, c.contentLastModified,
COALESCE(c.responseTo, c.commentId) AS responseTo,
COALESCE(c.conversationRoot, c.commentId) AS conversationRoot, -- cannot insert this initially for its own row, so lets coalesce with its own id here
parent.textShort AS parentText,
parentUser.name AS parentUserName
FROM
comments c
JOIN users u ON u.userId = c.userId
LEFT JOIN roles r ON r.roleId = u.roleid
LEFT JOIN moderationRecords mr ON mr.actionId = c.lastModaction
LEFT JOIN comments parent ON parent.commentId = c.responseTo
LEFT JOIN users parentUser ON parentUser.userId = parent.userId
WHERE c.assetId = ?
ORDER BY COALESCE(c.conversationRoot, c.commentId) $outerSortOrder, c.responseTo ASC, c.created ASC
SQL, [$assetId]);
if(count($comments) > 1) { // @perf: This could be better, but its not too bad honestly.
for($i = 0; $i < count($comments) - 1; ++$i) {
$a = $comments[$i];
$b = $comments[$i + 1];
if($a['conversationRoot'] === $a['commentId'] || $b['conversationRoot'] === $b['commentId']) { // dont touch the bottom layer
continue;
}
if($a['responseTo'] === $b['responseTo']) {
continue;
}
if($a['commentId'] !== $b['responseTo']) {
for($j = $i - 1; $j >= 0; --$j) {
if($comments[$j]['commentId'] === $b['responseTo'] || $comments[$j]['responseTo'] === $b['responseTo']) {
$el = array_splice($comments, $i + 1, 1);
array_splice($comments, $j + 1, 0, $el);
$change = true;
continue 2;
}
}
}
}
}
$commentIdToIndex = [];
foreach ($comments as $k => &$comment) {
if ($asset['createdByUserId'] == $comment['userId']) {
$comment['flairCode'] = 'author';
}
if ($comment['roleCode'] != 'player' && $comment['roleCode'] != 'player_nc') {
$comment['flairCode'] = $comment['roleCode'];
}
$comment['children'] = 0;
$commentIdToIndex[$comment['commentId']] = $k; // This can happen immediately before we do anything with the lookup because we only ever need back references.
if($comment['responseTo'] !== $comment['commentId']) {
$comments[$commentIdToIndex[$comment['responseTo']]]['children']++;
}
}
unset($comment);
$view->assign("comments", $comments, null, true);
$releases = $con->getAll(<<<SQL
SELECT
r.*,
rr.reason as retractionReason,
a.text,
GROUP_CONCAT(cgv.gameVersion ORDER BY cgv.gameVersion ASC SEPARATOR ',') AS compatibleGameVersions,
GROUP_CONCAT(gv.sortIndex ORDER BY cgv.gameVersion ASC SEPARATOR ',') AS compatibleGameVersionsIndices
FROM modReleases r
JOIN assets a ON a.assetId = r.assetId
LEFT JOIN modReleaseCompatibleGameVersions cgv ON cgv.releaseId = r.releaseId
LEFT JOIN gameVersions gv ON gv.version = cgv.gameVersion
LEFT JOIN modReleaseRetractions rr ON rr.releaseId = r.releaseId
WHERE modId = ?
GROUP BY r.releaseId
ORDER BY r.version DESC, MAX(cgv.gameVersion) DESC, r.created DESC
SQL, [$asset['modId']]);
$releaseFiles = [];
if(count($releases)) {
$foldedAssetIds = implode(',', array_map(fn($r) => $r['assetId'], $releases));
// @security: assetid's come from the database and are numeric, and are therefore sql inert.
//NOTE(Rennorb): Select the assetId for the first column so we can use getAssoc and use it as the key.
$releaseFiles = $con->getAssoc("SELECT assetId, f.* FROM files f WHERE assetId IN ($foldedAssetIds)");
}
foreach ($releases as &$release) {
// This catches the case where a release is missing its file because it got deleted retroactively.
//TODO(Rennorb) @cleanup @ux: make it impossible for a release to not have a file. Needs rework of the file upload system.
$release['file'] = $releaseFiles[$release['assetId']] ?? [];
if($release['compatibleGameVersions']) {
$compatibleGameVersions = array_map('intval', explode(',', $release['compatibleGameVersions'])); // sorted ascending
$compatibleGameVersionsIndices = array_map('intval', explode(',', $release['compatibleGameVersionsIndices'])); // sorted ascending
$release['maxCompatibleGameVersion'] = last($compatibleGameVersions);
$release['compatibleGameVersions'] = $compatibleGameVersions;
$release['compatibleGameVersionsFolded'] = foldSequentialVersionRanges($compatibleGameVersions, $compatibleGameVersionsIndices);
}
else {
// Set the value to "stable release", so the recommendation show it as "latest release", and not as "for testers" (because just 0 implies some kind of prerelease version).
$release['maxCompatibleGameVersion'] = 0x0000_0000_0000_f000;
$release['compatibleGameVersions'] = [];
$release['compatibleGameVersionsFolded'] = [];
}
}
unset($release);
/*
NOTE(Rennorb): The mod list/search should pass information about the currently searched for game versions to this script, so we can recommend the correct release when user is searching with a specific gv in mind.
This could be accomplished in one of three ways:
Post parameter:
pros:
- Invisible (does not pollute url)
cons:
- Causes the browser to query for "are you sure you want to resend information" when the page gets reloaded.
- Does not get transferred over if the link gets copy pasted to another user, potentially causing confusion because the site displays something different for the other user.
Get parameter:
pros:
- Does get copied over to other users, preserving that specific recommendation in the process.
cons:
- Pollutes the page link when it gets copy pasted to be presented on some social media outlet -> discord mod showcase links would likely get polluted.
- Recommendation is pinned with this link -> stored links 'degrade' since they won't recommend the latest release but the one for the specified game version.
Cookie:
pros:
- Invisible (does not pollute url)
cons:
- Potential misinterpretation if the cookie is not correctly reset before the user navigates to the mod page without a specific version search.
- Does not get transferred by copying the link, potentially causing confusion in a third party that will get a different recommendation.
- Likely does not persist between page reloads, so reloading the page will reset the recommendation to the general recommendation.
Referrer:
pros:
- Invisible (does not pollute url)
- Persistent across reloads, but resets on navigation.
- Does not require javascript.
cons:
- Does not get transferred by copying the link, potentially causing confusion in a third party that will get a different recommendation.
- Sending a correct referrer is up to the client.
I've decided that the referrer approach is best here, because;
- of all the cons between all the options the unwanted recommendation pinning of the 'get' approach is the worst offender and should be avoided,
- post navigation is complicated to implement, and
- cookies have to decay between reloads to avoid other staleness issues or be a lot very complicated.
*/
$allGameVersions = array_map('intval', $con->getCol('SELECT version FROM gameVersions ORDER BY version DESC'));
$mv = null;
$gvs = null;
if(!empty($_SERVER['HTTP_REFERER'])) {
parse_str(parse_url($_SERVER['HTTP_REFERER'], PHP_URL_QUERY), $refererQueryArgs);
if(!empty($refererQueryArgs['mv'])) $mv = compilePrimaryVersion($refererQueryArgs['mv']);
if(isset($refererQueryArgs['gv']) && is_array($refererQueryArgs['gv'])) {
$gvs = array_filter(array_map('compileSemanticVersion', $refererQueryArgs['gv']));
}
}
$recommendationIsInfluencedBySearch = selectDesiredVersions($allGameVersions, $mv, $gvs, $highestTargetVersion, $targetRecommendedGameVersionStable, $targetRecommendedGameVersionUnstable);
// Make sure to never recommend retracted releases:
$recommendationCandidates = array_filter($releases, fn($r) => !$r['retractionReason']);
recommendReleases($recommendationCandidates, $targetRecommendedGameVersionStable, $targetRecommendedGameVersionUnstable, $recommendedReleaseStable, $recommendedReleaseUnstable, $fallbackRelease);
$view->assign("releases", $releases, null, true);
$view->assign("recommendedReleaseStable", $recommendedReleaseStable, null, true);
$view->assign("recommendedReleaseUnstable", $recommendedReleaseUnstable, null, true);
$view->assign("fallbackRelease", $fallbackRelease, null, true);
$view->assign("recommendationIsInfluencedBySearch", $recommendationIsInfluencedBySearch, null, true);
$view->assign("highestTargetVersion", $highestTargetVersion, null, true);
$view->assign("asset", $asset);
$oneClickInstallWorks = !preg_match('/macintosh|mac os x|mac_powerpc|iphone|ipod|ipad|android|blackberry|webos|mobile/i', $_SERVER['HTTP_USER_AGENT']);
$view->assign("shouldShowOneClickInstall", $oneClickInstallWorks && ($asset['category'] & CATEGORY__MASK) === CATEGORY_GAME_MOD, null, false);
$view->assign("shouldListCompatibleGameVersion", ($asset['category'] & CATEGORY__MASK) === CATEGORY_GAME_MOD, null, false);
$view->assign("changelogColspan", 5 + (($asset['category'] & CATEGORY__MASK) === CATEGORY_GAME_MOD ? ($oneClickInstallWorks ? 3 : 2) : 0), null, false);
$view->assign("isFollowing", empty($user) ? 0 : $con->getOne('SELECT modId FROM userFollowedMods WHERE modId = ? AND userId = ?', [$asset['modId'], $user['userId']]));
if (!empty($user)) {
processTeamInvitation($asset, $user);
processOwnershipTransfer($asset, $user);
}
cspAllowTinyMceComment();
cspReplaceAllowedFetchSources("{$_SERVER['HTTP_HOST']}/api/v2/mods/{$asset['modId']}/ {$_SERVER['HTTP_HOST']}/api/v2/comments/ {$_SERVER['HTTP_HOST']}/api/v2/users/by-name/ {$_SERVER['HTTP_HOST']}/api/v2/tags/by-name/ {$_SERVER['HTTP_HOST']}/api/v2/notifications/settings/followed-mods/{$asset['modId']} {$_SERVER['HTTP_HOST']}/api/v2/notifications/settings/followed-mods/{$asset['modId']}/unfollow"); // create / edit comments, tag search, following //TODO(Rennorb): cleanup follow url
cspPushAllowedInlineHandlerHash('sha256-ro1cG9y3w13M1KSgaV9WpZDq3jSUi/S0hNEJ9yw3Uw4='); // location.hash = 'tab-description'
cspPushAllowedInlineHandlerHash('sha256-94NvHZFeRkm6w/lzsqG4nAxFmD5kBzGoK6eIsReP3v4='); // location.hash = 'tab-files'
cspAllowFotorama();
$view->assign('pagetitle', "{$asset['name']} - ");
$view->display("show-mod");
/** Fold several sequential version tags, e.g. 1.2.3, 1.2.4, 1.2.5 into '1.2.3 - 1.2.5' with a description containing the original versions.
* Folds accross all layers of versions.
* @param int[] $versions must be sorted for the algo to work
* @param int[] $versionIndices must be sorted for the algo to work
* @return string[]
*/
function foldSequentialVersionRanges($versions, $versionIndices)
{
assert(count($versions) == count($versionIndices));
$result = [];
$startSortIndex = $versionIndices[0];
$sequenceLength = 1;
for($i = 1; $i < count($versionIndices); $i++) {
if($versionIndices[$i] !== $startSortIndex + $sequenceLength) {
mergeAndPush($result, $versions, $i - $sequenceLength, $sequenceLength);
$startSortIndex = $versionIndices[$i];
$sequenceLength = 1;
}
else {
$sequenceLength++;
}
}
mergeAndPush($result, $versions, $i - $sequenceLength, $sequenceLength);
return $result;
}
function mergeAndPush(&$result, &$versions, $startIndex, $sequenceLength)
{
switch($sequenceLength) {
case 0: break;
case 1: $result[] = formatSemanticVersion($versions[$startIndex]); break;
default: $result[] = formatSemanticVersion($versions[$startIndex]).' - '.formatSemanticVersion($versions[$startIndex + $sequenceLength - 1]);
}
}
function processTeamInvitation($asset, $user)
{
global $con, $view;
$invite = $con->getRow("SELECT notificationId, recordId FROM notifications WHERE kind = ".NOTIFICATION_TEAM_INVITE." AND !`read` AND userId = ? AND (recordId & ((1 << 30) - 1)) = ?", [$user['userId'], $asset['modId']]); // :InviteEditBit
$pending = !empty($invite);
$view->assign("teaminvite", $pending);
if(!$pending) return;
if (!isset($_POST['acceptteaminvite'])) return;
if(DB_READONLY) showReadonlyPage();
switch ($_POST['acceptteaminvite']) {
case 1:
$canEdit = (intval($invite['recordId']) & (1 << 30)) ? 1 : 0; // :InviteEditBit
$con->Execute('INSERT INTO modTeamMembers (modId, userId, canEdit) values (?, ?, ?)', [$asset['modId'], $user['userId'], $canEdit]);
$con->Execute('UPDATE notifications SET `read` = 1 WHERE notificationId = ?', [$invite['notificationId']]);
logAssetChanges([$user['name'].' acepted team invitation'], $asset['assetId']);
forceRedirectAfterPOST();
exit();
case 0:
$con->Execute('UPDATE notifications SET `read` = 1 WHERE notificationId = ?', [$invite['notificationId']]);
logAssetChanges([$user['name'].' rejected team invitation'], $asset['assetId']);
forceRedirectAfterPOST();
exit();
}
}
function processOwnershipTransfer($asset, $user)
{
global $con, $view;
$pendingInvitationId = $con->getOne("SELECT notificationId FROM notifications WHERE kind = ? AND !`read` AND userId = ? AND recordId = ?", [NOTIFICATION_MOD_OWNERSHIP_TRANSFER_REQUEST, $user['userId'], $asset['modId']]);
$view->assign("transferownership", $pendingInvitationId);
if(!$pendingInvitationId) return;
if(!isset($_POST['acceptownershiptransfer'])) return;
if(DB_READONLY) showReadonlyPage();
switch ($_POST['acceptownershiptransfer']) {
case 1:
$con->startTrans();
$oldOwnerData = $con->getRow('SELECT createdByUserId, created FROM assets WHERE assetId = ?', [$asset['assetId']]); // @perf
// swap owner and teammember that accepted in the teammembers table
$con->execute(<<<SQL
UPDATE modTeamMembers
SET userId = ?, canEdit = 1, created = ?
WHERE modId = ? AND userId = ?
SQL, [$oldOwnerData['createdByUserId'], $oldOwnerData['created'], $asset['modId'], $user['userId']]);
$con->execute('UPDATE assets SET createdByUserId = ? WHERE assetId = ?', [$user['userId'], $asset['assetId']]);
$con->execute(<<<SQL
UPDATE assets a
JOIN mods m ON m.modId = ?
JOIN modReleases r ON r.modId = m.modId AND r.assetId = a.assetId
set a.createdByUserId = ?
SQL, [$asset['modId'], $user['userId']]);
$con->execute('UPDATE notifications SET `read` = 1 WHERE notificationId = ?', [$pendingInvitationId]);
// Send notification to the original author:
// Use the 31st bit of the modId to indicate success :PackedTransferSuccess
$con->execute('INSERT INTO notifications (kind, userId, recordId) VALUES ('.NOTIFICATION_MOD_OWNERSHIP_TRANSFER_RESOLVED.', ?, ?) ', [$oldOwnerData['createdByUserId'], $asset['modId'] | (1 << 30)]);
logAssetChanges(['Ownership migrated to '.$user['name']], $asset['assetId']);
$con->completeTrans();
forceRedirectAfterPOST();
exit();
case 0:
$con->startTrans();
$oldOwner = $con->getOne('SELECT createdByUserId FROM assets WHERE assetId = ?', [$asset['assetId']]); // @perf
$con->execute('UPDATE notifications SET `read` = 1 WHERE notificationId = ?', [$pendingInvitationId]);
// Send notification to the original author:
$con->execute('INSERT INTO notifications (kind, userId, recordId) VALUES ('.NOTIFICATION_MOD_OWNERSHIP_TRANSFER_RESOLVED.', ?, ?) ', [$oldOwner, $asset['modId'] | (0 << 30)]); // :PackedTransferSuccess
logAssetChanges(['Ownership migration rejected by '.$user['name']], $asset['assetId']);
$con->completeTrans();
forceRedirectAfterPOST();
exit();
}
}
/**
* @param array $release
* @param int $referenceVersion The version originally searched for.
* @return string
*/
function formatVersionsAndWarning($release, $referenceVersion)
{
$text = formatGrammaticallyCorrectEnumeration($release['compatibleGameVersionsFolded']);
if($release['maxCompatibleGameVersion'] >= $referenceVersion) return $text;
$ver = formatSemanticVersion($referenceVersion);
if((($release['maxCompatibleGameVersion'] ^ $referenceVersion) & VERSION_MASK_PRIMARY) === 0) {
$text .= ", <abbr title='While it is likely that this mod works with game version {$ver} it does not explicitly specify that it does.'>potentially outdated</abbr>";
}
else {
$text .= ", <abbr style='color:#b00;' title='This mod did not specify that is is compatible with gameversion {$ver}, nor with any patch of that major version in general.'><i class='ico alert'></i> outdated</abbr>";
}
return $text;
}
/**
* @param string $text
* @param bool $recommendationIsInfluencedBySearch
* @param int $referenceVersion
* @return string
*/
function formatRecommendationAdjustedHint($text, $recommendationIsInfluencedBySearch, $referenceVersion)
{
if(!$recommendationIsInfluencedBySearch) return $text;
$ver = formatSemanticVersion($referenceVersion);
return "<abbr title='Based on coming here from a search for game version {$ver}.
This is temporary and will reset on your next visit.'>{$text}*</abbr>";
}