Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions db/143_migrate.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
USE `moddb`;

CREATE TABLE IF NOT EXISTS `modRelations` (
`relationId` INT NOT NULL AUTO_INCREMENT,
`releaseId` INT NOT NULL COMMENT 'Relations are always pinned to a specific release. A mod that hosts multiple identifiers (e.g. linux variant) declares relations per release independently.',
`targetIdentifier` VARCHAR(255) NOT NULL COMMENT 'Always populated. Free-form modid string (e.g. "carrycapacity"). Resolved to targetModId when possible.',
`targetModId` INT NULL COMMENT 'FK to mods.modId when the target is a mod hosted in this moddb. Cache, kept in sync by the relation upsert function.',
`relationType` ENUM('required','optional','incompatible','tested_with') NOT NULL,
`minVersion` BIGINT UNSIGNED NULL COMMENT 'Compiled semver, same encoding as modReleases.version. NULL = any version.',
`maxVersion` BIGINT UNSIGNED NULL COMMENT 'Compiled semver, inclusive upper bound. NULL = no upper bound.',
`origin` ENUM('auto','manual') NOT NULL DEFAULT 'manual'
COMMENT 'auto = derived from modPeekResults.rawDependencies, manual = declared via UI. Sync only touches auto rows.',
`createdByUserId` INT NOT NULL,
`created` DATETIME NOT NULL DEFAULT NOW(),
`lastModified` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`relationId`),
UNIQUE INDEX `uq_relation` (`releaseId`, `targetIdentifier`, `relationType`),
INDEX `targetIdentifier` (`targetIdentifier`),
INDEX `targetModId` (`targetModId`),
CONSTRAINT `FK_modRelations_targetModId` FOREIGN KEY (`targetModId`) REFERENCES `mods`(`modId`) ON UPDATE CASCADE ON DELETE SET NULL,
CONSTRAINT `FK_modRelations_releaseId` FOREIGN KEY (`releaseId`) REFERENCES `modReleases`(`releaseId`) ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT `FK_modRelations_userId` FOREIGN KEY (`createdByUserId`) REFERENCES `users`(`userId`) ON UPDATE CASCADE ON DELETE RESTRICT
)
ENGINE = InnoDB;
43 changes: 43 additions & 0 deletions db/144_migrate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

// Backfill: parse existing modPeekResults.rawDependencies into modRelations rows with origin='auto'.
// Skips identifier 'game' (already tracked by modReleaseCompatibleGameVersions) and wildcard '*'.
//
// Run inside the dev container:
// docker compose -f docker/docker-compose.yml exec php php db/144_migrate.php

$config = [];
$config["basepath"] = dirname(__DIR__).'/';
$_SERVER["SERVER_NAME"] = "mods.vintagestory.stage";
$_SERVER["REQUEST_URI"] = "/";
define('DEBUG', 1);
include($config["basepath"]."lib/config.php");
include($config["basepath"]."lib/core.php");
include_once($config["basepath"]."lib/relations.php");

global $con, $user;

$rows = $con->getAll(<<<SQL
SELECT mpr.fileId, mpr.rawDependencies, mr.releaseId
FROM modPeekResults mpr
JOIN files f ON f.fileId = mpr.fileId
JOIN modReleases mr ON mr.assetId = f.assetId
WHERE mpr.rawDependencies IS NOT NULL
SQL);

if (!$rows) {
echo "Backfilled 0 releases.\n";
exit(0);
}

// The sync function records createdByUserId; pick the first admin (roleId = 1) as a placeholder.
$adminUserId = (int)$con->getOne("SELECT userId FROM users WHERE roleId = 1 ORDER BY userId LIMIT 1");
if (!$adminUserId) { fwrite(STDERR, "No admin user found; aborting.\n"); exit(1); }
$user = ['userId' => $adminUserId];

$processed = 0;
foreach ($rows as $row) {
syncAutoRelationsForRelease((int)$row['releaseId'], $row['rawDependencies']);
$processed++;
}
echo "Backfilled $processed releases.\n";
18 changes: 18 additions & 0 deletions edit-release.php
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,15 @@
if($existingRelease) {
$ok = updateRelease($targetMod, $existingRelease, $newData, $newCompatibleGameVersions);
if($ok) {
if (!empty($_POST['removeRelations'])) {
foreach ($_POST['removeRelations'] as $rid) {
deleteManualRelation(intval($rid));
}
}
if (!empty($_POST['relations'])) {
persistManualRelationsFromForm(intval($existingRelease['releaseId']), $_POST['relations']);
}

if(!empty($_POST['saveandback'])) forceRedirect(formatModPath($targetMod).'#tab-files');
else forceRedirectAfterPOST();
exit();
Expand Down Expand Up @@ -355,4 +364,13 @@

$view->assign('auditLogs', $auditLogs, null, true);

if (!empty($existingRelease['releaseId'])) {
$relView = getRelationsForReleaseEditView(intval($existingRelease['releaseId']));
$view->assign('autoRelations', $relView['auto']);
$view->assign('manualRelations', $relView['manual']);
} else {
$view->assign('autoRelations', []);
$view->assign('manualRelations', []);
}

$view->display('edit-release');
61 changes: 52 additions & 9 deletions lib/api/public/mods.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

include_once $config['basepath'].'lib/relations.php';

const ERROR_SPEC_PARSE_FAILED = 4001;
const ERROR_MISSING_SPEC_VERSION_NO_GV = 4002;
const ERROR_FORBIDDEN_IN_HOSTED_MODE = 4031;
Expand Down Expand Up @@ -79,14 +81,19 @@ function canIgnoreRetraction($release)
// if crafted carefully. Note that we cannot use 'group by' in most cases, as it is up to the implementation to select any row
// for non aggregated columns, even different ones for each column, where we would need the first - or first by some other column.

// Tracks the release the caller explicitly asked for (or that we recommended in the unknown-version
// path). Used to seed the dep resolver below so the dependency tree reflects the exact release
// being installed, not whatever pickReleaseForIdentifier would re-pick.
$pickedRootReleases = [];

if($knownVersionQueryParams) {
$placeholders = substr(str_repeat('(?, ?),', count($knownVersionQueryParams) / 2), 0, -1);

if($gameVersion) {
// Complicated path with upgrade recommendations:
$releases = $con->execute(<<<SQL
SELECT
r0.identifier, f0.fileId, f0.name, rr0.reason AS retractionReason,
SELECT
r0.releaseId, r0.identifier, r0.version, f0.fileId, f0.name, rr0.reason AS retractionReason,
ru0.roleId AS retractedByRoleId, rr0.lastModifiedBy AS retractedByUserId, a0.createdByUserId,
r1.version AS recommendedUpgrade

Expand Down Expand Up @@ -141,14 +148,22 @@ function canIgnoreRetraction($release)

$r['fileName'] = $release['name'];
$r['fileUrl'] = formatDownloadTrackingUrl($release);

$pickedRootReleases[$release['identifier']] = [
'releaseId' => (int)$release['releaseId'],
'identifier' => $release['identifier'],
'version' => (int)$release['version'],
'fileName' => $release['name'],
'fileUrl' => $r['fileUrl'],
];
}
unset($r);
}
else {
// Simple path, no recommends:
$releases = $con->execute(<<<SQL
SELECT
r0.identifier, f0.fileId, f0.name, rr0.reason as retractionReason,
SELECT
r0.releaseId, r0.identifier, r0.version, f0.fileId, f0.name, rr0.reason as retractionReason,
ru0.roleId AS retractedByRoleId, rr0.lastModifiedBy AS retractedByUserId, a0.createdByUserId

FROM modReleases r0
Expand Down Expand Up @@ -179,19 +194,27 @@ function canIgnoreRetraction($release)

$r['fileName'] = $release['name'];
$r['fileUrl'] = formatDownloadTrackingUrl($release);

$pickedRootReleases[$release['identifier']] = [
'releaseId' => (int)$release['releaseId'],
'identifier' => $release['identifier'],
'version' => (int)$release['version'],
'fileName' => $release['name'],
'fileUrl' => $r['fileUrl'],
];
}
unset($r);
}
}

if($unknownVersionQueryParams && $gameVersion) {
// Complicated path. Can only exist if we have a gameversion to recommend any releases for.
// Never recommend retracted releases.
$placeholders = substr(str_repeat('?,', count($unknownVersionQueryParams)), 0, -1);

$releases = $con->execute(<<<SQL
SELECT
r1.identifier, f1.fileId, f1.name,
SELECT
r1.releaseId, r1.identifier, f1.fileId, f1.name,
r1.version as recommendedUpgrade

FROM (
Expand Down Expand Up @@ -222,6 +245,14 @@ function canIgnoreRetraction($release)
$r['fileName'] = $release['name'];
$r['fileUrl'] = formatDownloadTrackingUrl($release);
if($release['recommendedUpgrade']) $r['recommendedUpgrade'] = formatSemanticVersion(intval($release['recommendedUpgrade']));

$pickedRootReleases[$release['identifier']] = [
'releaseId' => (int)$release['releaseId'],
'identifier' => $release['identifier'],
'version' => (int)$release['recommendedUpgrade'],
'fileName' => $release['name'],
'fileUrl' => $r['fileUrl'],
];
}
unset($r);
}
Expand All @@ -233,9 +264,21 @@ function canIgnoreRetraction($release)
}
unset($r);

good(['data' => $result]);
$resolvePayload = [];
if (boolval($_GET['resolve-deps'] ?? false)) {
// Don't pass identifiers with already-known errors into the resolver.
// They are unavailable / malformed / retracted and should not surface dependency trees.
$rootIds = array_keys(array_filter($result, fn($r) => empty($r['errorCode'])));
// Seed the resolver with the releases picked above so the dep tree starts from the
// exact (identifier, version) requested in the URL instead of pickReleaseForIdentifier's
// "latest available" fallback. Transitive deps still use that fallback.
$rootMap = array_intersect_key($pickedRootReleases, array_flip($rootIds));
$deps = resolveTransitiveDeps($rootIds, $gameVersion ?: null, $rootMap);
$resolvePayload = ['resolved' => $deps['resolved'], 'installOrder' => $deps['installOrder'], 'warnings' => $deps['warnings']];
}
good(['data' => $result] + $resolvePayload);

default:
default:
// /mods/{modId}
$modId = filter_var($urlparts[0], FILTER_VALIDATE_INT);
if(!$modId) break; // fallthrough into the authenticated section.
Expand Down
15 changes: 15 additions & 0 deletions lib/edit-release.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

include_once $config['basepath'].'lib/relations.php';

/**
* @security: Does not perform validation!
* @param array{modId:int, type:int} $mod The mod the release is to be associated with.
Expand Down Expand Up @@ -28,6 +30,19 @@ function createNewRelease($mod, $newData, $newCompatibleGameVersions, $file)
$con->execute('UPDATE files SET assetId = ? WHERE fileId = ?', [$assetId, $file['fileId']]);
}

// Sync auto-derived relations from the parsed modinfo dependencies for this release.
$rawDependencies = $con->getOne('SELECT rawDependencies FROM modPeekResults WHERE fileId = ?', [$file['fileId']]);
syncAutoRelationsForRelease(intval($releaseId), $rawDependencies);

// Carry manual relations forward from the previous release of the same identifier (template autofill).
cloneManualRelationsFromPreviousRelease(intval($releaseId));

// Retro-link previously-orphan relations whose targetIdentifier matches this release's identifier.
$releaseIdentifier = $newData['identifier'] ?? null;
if ($releaseIdentifier) {
resolveDanglingTargets($releaseIdentifier, intval($mod['modId']));
}

$logInfo = 'v'.formatSemanticVersion($newData['version']);

if(($mod['category'] & CATEGORY__MASK) === CATEGORY_GAME_MOD) {
Expand Down
Loading