1 /** 2 Copyright: © 2013 rejectedsoftware e.K. 3 License: Subject to the terms of the GNU GPLv3 license, as written in the included LICENSE.txt file. 4 Authors: Sönke Ludwig 5 */ 6 module dubregistry.registry; 7 8 import dubregistry.cache : FileNotFoundException; 9 import dubregistry.dbcontroller; 10 import dubregistry.repositories.repository; 11 12 import dub.semver; 13 import dub.package_ : packageInfoFilenames; 14 import std.algorithm : canFind, countUntil, filter, map, sort, swap; 15 import std.array; 16 import std.datetime : Clock, UTC, hours, SysTime; 17 import std.encoding : sanitize; 18 import std.exception : enforce; 19 import std.range : chain, walkLength; 20 import std.string : format, startsWith, toLower; 21 import std.typecons; 22 import userman.db.controller; 23 import vibe.core.core; 24 import vibe.core.log; 25 import vibe.data.bson; 26 import vibe.data.json; 27 import vibe.stream.operations; 28 import vibe.utils.array : FixedRingBuffer; 29 30 31 /// Settings to configure the package registry. 32 class DubRegistrySettings { 33 string databaseName = "vpmreg"; 34 } 35 36 class DubRegistry { 37 private { 38 DubRegistrySettings m_settings; 39 DbController m_db; 40 41 // list of package names to check for updates 42 FixedRingBuffer!string m_updateQueue; 43 string m_currentUpdatePackage; 44 Task m_updateQueueTask; 45 TaskMutex m_updateQueueMutex; 46 TaskCondition m_updateQueueCondition; 47 SysTime m_lastSignOfLifeOfUpdateTask; 48 } 49 50 this(DubRegistrySettings settings) 51 { 52 m_settings = settings; 53 m_db = new DbController(settings.databaseName); 54 m_updateQueue.capacity = 10000; 55 m_updateQueueMutex = new TaskMutex; 56 m_updateQueueCondition = new TaskCondition(m_updateQueueMutex); 57 m_updateQueueTask = runTask(&processUpdateQueue); 58 } 59 60 @property DbController db() nothrow { return m_db; } 61 62 @property auto availablePackages() { return m_db.getAllPackages(); } 63 @property auto availablePackageIDs() { return m_db.getAllPackageIDs(); } 64 65 auto getPackageDump() 66 { 67 return m_db.getPackageDump(); 68 } 69 70 void triggerPackageUpdate(string pack_name) 71 { 72 synchronized (m_updateQueueMutex) { 73 if (!m_updateQueue[].canFind(pack_name)) 74 m_updateQueue.put(pack_name); 75 } 76 77 // watchdog for update task 78 if (Clock.currTime(UTC()) - m_lastSignOfLifeOfUpdateTask > 2.hours) { 79 logError("Update task has hung. Trying to interrupt."); 80 m_updateQueueTask.interrupt(); 81 } 82 83 if (!m_updateQueueTask.running) 84 m_updateQueueTask = runTask(&processUpdateQueue); 85 m_updateQueueCondition.notifyAll(); 86 } 87 88 bool isPackageScheduledForUpdate(string pack_name) 89 { 90 if (m_currentUpdatePackage == pack_name) return true; 91 synchronized (m_updateQueueMutex) 92 if (m_updateQueue[].canFind(pack_name)) return true; 93 return false; 94 } 95 96 /** Returns the current index of a given package in the update queue. 97 98 An index of zero indicates that the package is currently being updated. 99 A negative index is returned when the package is not in the update 100 queue. 101 */ 102 sizediff_t getUpdateQueuePosition(string pack_name) 103 { 104 if (m_currentUpdatePackage == pack_name) return 0; 105 synchronized (m_updateQueueMutex) { 106 auto idx = m_updateQueue[].countUntil(pack_name); 107 return idx >= 0 ? idx + 1 : -1; 108 } 109 } 110 111 auto searchPackages(string query) 112 { 113 static struct Info { string name; DbPackageVersion _base; alias _base this; } 114 return m_db.searchPackages(query).filter!(p => p.versions.length > 0).map!(p => 115 Info(p.name, m_db.getVersionInfo(p.name, p.versions[$ - 1].version_))); 116 } 117 118 RepositoryInfo getRepositoryInfo(DbRepository repository) 119 { 120 auto rep = getRepository(repository); 121 return rep.getInfo(); 122 } 123 124 void addPackage(DbRepository repository, User.ID user) 125 { 126 auto pack_name = validateRepository(repository); 127 128 DbPackage pack; 129 pack.owner = user.bsonObjectIDValue; 130 pack.name = pack_name; 131 pack.repository = repository; 132 m_db.addPackage(pack); 133 134 triggerPackageUpdate(pack.name); 135 } 136 137 void addOrSetPackage(DbPackage pack) 138 { 139 m_db.addOrSetPackage(pack); 140 } 141 142 void addDownload(BsonObjectID pack_id, string ver, string agent) 143 { 144 m_db.addDownload(pack_id, ver, agent); 145 } 146 147 void removePackage(string packname, User.ID user) 148 { 149 logInfo("Package %s: removing package owned by %s", packname, user); 150 m_db.removePackage(packname, user.bsonObjectIDValue); 151 } 152 153 auto getPackages(User.ID user) 154 { 155 return m_db.getUserPackages(user.bsonObjectIDValue); 156 } 157 158 bool isUserPackage(User.ID user, string package_name) 159 { 160 return m_db.isUserPackage(user.bsonObjectIDValue, package_name); 161 } 162 163 /// get stats (including downloads of all version) for a package 164 DbPackageStats getPackageStats(string packname) 165 { 166 auto cached = m_db.getPackageStats(packname); 167 if (cached.updatedAt > Clock.currTime(UTC()) - 24.hours) 168 return cached; 169 return updatePackageStats(packname); 170 } 171 172 private DbPackageStats updatePackageStats(string packname) 173 { 174 logDiagnostic("Updating stats for %s", packname); 175 176 DbPackageStats stats; 177 DbPackage pack = m_db.getPackage(packname); 178 stats.downloads = m_db.aggregateDownloadStats(pack._id); 179 180 try { 181 stats.repo = getRepositoryInfo(pack.repository).stats; 182 } catch (Exception e){ 183 logWarn("Failed to get repository info for %s: %s", packname, e.msg); 184 return typeof(return).init; 185 } 186 187 m_db.updatePackageStats(pack._id, stats); 188 return stats; 189 } 190 191 /// get downloads for a package version 192 DbDownloadStats getDownloadStats(string packname, string ver) 193 { 194 auto packid = m_db.getPackageID(packname); 195 if (ver == "latest") ver = getLatestVersion(packname); 196 enforce!RecordNotFound(m_db.hasVersion(packname, ver), "Unknown version for package."); 197 return m_db.aggregateDownloadStats(packid, ver); 198 } 199 200 Json getPackageVersionInfo(string packname, string ver) 201 { 202 if (ver == "latest") ver = getLatestVersion(packname); 203 if (!m_db.hasVersion(packname, ver)) return Json(null); 204 return m_db.getVersionInfo(packname, ver).serializeToJson(); 205 } 206 207 string getLatestVersion(string packname) 208 { 209 return m_db.getLatestVersion(packname); 210 } 211 212 PackageInfo getPackageInfo(string packname, bool include_errors = false) 213 { 214 DbPackage pack; 215 try pack = m_db.getPackage(packname); 216 catch(Exception) return PackageInfo.init; 217 218 return getPackageInfo(pack, include_errors); 219 } 220 221 PackageInfo getPackageInfo(DbPackage pack, bool include_errors) 222 { 223 auto rep = getRepository(pack.repository); 224 225 PackageInfo ret; 226 ret.versions = pack.versions.map!(v => getPackageVersionInfo(v, rep)).array; 227 228 Json nfo = Json.emptyObject; 229 nfo["id"] = pack._id.toString(); 230 nfo["dateAdded"] = pack._id.timeStamp.toISOExtString(); 231 nfo["owner"] = pack.owner.toString(); 232 nfo["name"] = pack.name; 233 nfo["versions"] = Json(ret.versions.map!(v => v.info).array); 234 nfo["repository"] = serializeToJson(pack.repository); 235 nfo["categories"] = serializeToJson(pack.categories); 236 if(include_errors) nfo["errors"] = serializeToJson(pack.errors); 237 238 ret.info = nfo; 239 240 return ret; 241 } 242 243 private PackageVersionInfo getPackageVersionInfo(DbPackageVersion v, Repository rep) 244 { 245 // JSON package version info as reported to the client 246 auto nfo = v.info.get!(Json[string]).dup; 247 nfo["version"] = v.version_; 248 nfo["date"] = v.date.toISOExtString(); 249 nfo["readmeFile"] = v.readme; 250 nfo["commitID"] = v.commitID; 251 252 PackageVersionInfo ret; 253 ret.info = Json(nfo); 254 ret.date = v.date; 255 ret.sha = v.commitID; 256 ret.version_ = v.version_; 257 ret.downloadURL = rep.getDownloadUrl(v.version_.startsWith("~") ? v.version_ : "v"~v.version_); 258 return ret; 259 } 260 261 string getReadme(Json version_info, DbRepository repository) 262 { 263 string ret; 264 auto file = version_info["readmeFile"].opt!string; 265 try { 266 if (!file.startsWith('/')) return null; 267 auto rep = getRepository(repository); 268 logDebug("reading readme file for %s: %s", version_info["name"].get!string, file); 269 rep.readFile(version_info["commitID"].get!string, Path(file), (scope data) { 270 ret = data.readAllUTF8(); 271 }); 272 logDebug("reading readme file done"); 273 } catch (Exception e) { 274 logDiagnostic("Failed to read README file (%s) for %s %s: %s", 275 file, version_info["name"].get!string, 276 version_info["version"].get!string, e.msg); 277 } 278 return ret; 279 } 280 281 void downloadPackageZip(string packname, string vers, void delegate(scope InputStream) del) 282 { 283 DbPackage pack = m_db.getPackage(packname); 284 auto rep = getRepository(pack.repository); 285 rep.download(vers, del); 286 } 287 288 void setPackageCategories(string pack_name, string[] categories) 289 { 290 m_db.setPackageCategories(pack_name, categories); 291 } 292 293 void setPackageRepository(string pack_name, DbRepository repository) 294 { 295 auto new_name = validateRepository(repository); 296 enforce(pack_name == new_name, "The package name of the new repository doesn't match the existing one: "~new_name); 297 m_db.setPackageRepository(pack_name, repository); 298 } 299 300 void updatePackages() 301 { 302 logDiagnostic("Triggering package update..."); 303 foreach (packname; this.availablePackages) 304 triggerPackageUpdate(packname); 305 } 306 307 protected string validateRepository(DbRepository repository) 308 { 309 // find the packge info of ~master or any available branch 310 PackageVersionInfo info; 311 auto rep = getRepository(repository); 312 auto branches = rep.getBranches(); 313 enforce(branches.length > 0, "The repository contains no branches."); 314 auto idx = branches.countUntil!(b => b.name == "master"); 315 if (idx > 0) swap(branches[0], branches[idx]); 316 string branch_errors; 317 foreach (b; branches) { 318 try { 319 info = rep.getVersionInfo(b, null); 320 enforce (info.info.type == Json.Type.object, 321 "JSON package description must be a JSON object."); 322 break; 323 } catch (Exception e) { 324 logDiagnostic("Error getting package info for %s", b); 325 branch_errors ~= format("\n%s: %s", b.name, e.msg); 326 } 327 } 328 enforce (info.info.type == Json.Type.object, 329 "Failed to find a branch containing a valid package description file:" ~ branch_errors); 330 331 // derive package name and perform various sanity checks 332 auto name = info.info["name"].get!string; 333 string package_desc_file = info.info["packageDescriptionFile"].get!string; 334 string package_check_string = format(`Check your %s.`, package_desc_file); 335 enforce(name.length <= 60, 336 "Package names must not be longer than 60 characters: \""~name[0 .. 60]~"...\" - "~package_check_string); 337 enforce(name == name.toLower(), 338 "Package names must be all lower case, not \""~name~"\". "~package_check_string); 339 enforce(info.info["license"].opt!string.length > 0, 340 `A "license" field in the package description file is missing or empty. `~package_check_string); 341 enforce(info.info["description"].opt!string.length > 0, 342 `A "description" field in the package description file is missing or empty. `~package_check_string); 343 checkPackageName(name, format(`Check the "name" field of your %s.`, package_desc_file)); 344 foreach (string n, vspec; info.info["dependencies"].opt!(Json[string])) { 345 auto parts = n.split(":").array; 346 // allow shortcut syntax ":subpack" 347 if (parts.length > 1 && parts[0].length == 0) parts = parts[1 .. $]; 348 // verify all other parts of the package name 349 foreach (p; parts) 350 checkPackageName(p, format(`Check the "dependencies" field of your %s.`, package_desc_file)); 351 } 352 353 // ensure that at least one tagged version is present 354 auto tags = rep.getTags(); 355 enforce(tags.canFind!(t => t.name.startsWith("v") && t.name[1 .. $].isValidVersion), 356 `The repository must have at least one tagged version (SemVer format, e.g. ` 357 ~ `"v1.0.0" or "v0.0.1") to be published on the registry. Please add a proper tag using ` 358 ~ `"git tag" or equivalent means and see http://semver.org for more information.`); 359 360 return name; 361 } 362 363 protected bool addVersion(string packname, string ver, Repository rep, RefInfo reference) 364 { 365 logDiagnostic("Adding new version info %s for %s", ver, packname); 366 assert(ver.startsWith("~") && !ver.startsWith("~~") || isValidVersion(ver)); 367 368 auto dbpack = m_db.getPackage(packname); 369 string deffile; 370 foreach (t; dbpack.versions) 371 if (t.version_ == ver) { 372 deffile = t.info["packageDescriptionFile"].opt!string; 373 break; 374 } 375 auto info = getVersionInfo(rep, reference, deffile); 376 377 //assert(info.info.name == info.info.name.get!string.toLower(), "Package names must be all lower case."); 378 info.info["name"] = info.info["name"].get!string.toLower(); 379 enforce(info.info["name"] == packname, 380 format("Package name (%s) does not match the original package name (%s). Check %s.", 381 info.info["name"].get!string, packname, info.info["packageDescriptionFile"].get!string)); 382 383 if ("description" !in info.info || "license" !in info.info) { 384 //enforce("description" in info.info && "license" in info.info, 385 throw new Exception( 386 "Published packages must contain \"description\" and \"license\" fields."); 387 } 388 389 foreach( string n, vspec; info.info["dependencies"].opt!(Json[string]) ) 390 foreach (p; n.split(":")) 391 checkPackageName(p, "Check "~info.info["packageDescriptionFile"].get!string~"."); 392 393 DbPackageVersion dbver; 394 dbver.date = info.date; 395 dbver.version_ = ver; 396 dbver.commitID = info.sha; 397 dbver.info = info.info; 398 399 try { 400 rep.readFile(reference.sha, Path("/README.md"), (scope input) { input.readAll(); }); 401 dbver.readme = "/README.md"; 402 } catch (Exception e) { logDiagnostic("No README.md found for %s %s", packname, ver); } 403 404 if (m_db.hasVersion(packname, ver)) { 405 logDebug("Updating existing version info."); 406 m_db.updateVersion(packname, dbver); 407 return false; 408 } 409 410 //enforce(!m_db.hasVersion(packname, dbver.version_), "Version already exists."); 411 if (auto pv = "version" in info.info) 412 enforce(pv.get!string == ver, format("Package description contains an obsolete \"version\" field and does not match tag %s: %s", ver, pv.get!string)); 413 logDebug("Adding new version info."); 414 m_db.addVersion(packname, dbver); 415 return true; 416 } 417 418 protected void removeVersion(string packname, string ver) 419 { 420 assert(ver.startsWith("~") && !ver.startsWith("~~") || isValidVersion(ver)); 421 422 m_db.removeVersion(packname, ver); 423 } 424 425 private void processUpdateQueue() 426 { 427 scope (exit) logWarn("Update task was killed!"); 428 while (true) { 429 m_lastSignOfLifeOfUpdateTask = Clock.currTime(UTC()); 430 logDiagnostic("Getting new package to be updated..."); 431 string pack; 432 synchronized (m_updateQueueMutex) { 433 while (m_updateQueue.empty) { 434 logDiagnostic("Waiting for package to be updated..."); 435 m_updateQueueCondition.wait(); 436 } 437 pack = m_updateQueue.front; 438 m_updateQueue.popFront(); 439 m_currentUpdatePackage = pack; 440 } 441 scope(exit) m_currentUpdatePackage = null; 442 logDiagnostic("Updating package %s.", pack); 443 try updatePackage(pack); 444 catch (Exception e) { 445 logWarn("Failed to check versions for %s: %s", pack, e.msg); 446 logDiagnostic("Full error: %s", e.toString().sanitize); 447 } 448 } 449 } 450 451 private void updatePackage(string packname) 452 { 453 import std.encoding; 454 string[] errors; 455 456 PackageInfo pack; 457 try pack = getPackageInfo(packname); 458 catch( Exception e ){ 459 errors ~= format("Error getting package info: %s", e.msg); 460 logDebug("%s", sanitize(e.toString())); 461 return; 462 } 463 464 Repository rep; 465 try rep = getRepository(pack.info["repository"].deserializeJson!DbRepository); 466 catch( Exception e ){ 467 errors ~= format("Error accessing repository: %s", e.msg); 468 logDebug("%s", sanitize(e.toString())); 469 return; 470 } 471 472 bool[string] existing; 473 RefInfo[] tags, branches; 474 bool got_all_tags_and_branches = false; 475 try { 476 tags = rep.getTags() 477 .filter!(a => a.name.startsWith("v") && a.name[1 .. $].isValidVersion) 478 .array 479 .sort!((a, b) => compareVersions(a.name[1 .. $], b.name[1 .. $]) < 0) 480 .array; 481 branches = rep.getBranches(); 482 got_all_tags_and_branches = true; 483 } catch (Exception e) { 484 errors ~= format("Failed to get GIT tags/branches: %s", e.msg); 485 } 486 logDiagnostic("Updating tags for %s: %s", packname, tags.map!(t => t.name).array); 487 foreach (tag; tags) { 488 auto name = tag.name[1 .. $]; 489 existing[name] = true; 490 try { 491 if (addVersion(packname, name, rep, tag)) 492 logInfo("Package %s: added version %s", packname, name); 493 } catch( Exception e ){ 494 logDiagnostic("Error for version %s of %s: %s", name, packname, e.msg); 495 logDebug("Full error: %s", sanitize(e.toString())); 496 errors ~= format("Version %s: %s", name, e.msg); 497 } 498 } 499 logDiagnostic("Updating branches for %s: %s", packname, branches.map!(t => t.name).array); 500 foreach (branch; branches) { 501 auto name = "~" ~ branch.name; 502 existing[name] = true; 503 try { 504 if (addVersion(packname, name, rep, branch)) 505 logInfo("Package %s: added branch %s", packname, name); 506 } catch( Exception e ){ 507 logDiagnostic("Error for branch %s of %s: %s", name, packname, e.msg); 508 logDebug("Full error: %s", sanitize(e.toString())); 509 if (branch.name != "gh-pages") // ignore errors on the special GitHub website branch 510 errors ~= format("Branch %s: %s", name, e.msg); 511 } 512 } 513 if (got_all_tags_and_branches) { 514 foreach (v; pack.versions) { 515 auto ver = v.version_; 516 if (ver !in existing) { 517 logInfo("Package %s: removing version %s as the branch/tag was removed.", packname, ver); 518 removeVersion(packname, ver); 519 } 520 } 521 } 522 m_db.setPackageErrors(packname, errors); 523 524 updatePackageStats(packname); 525 } 526 } 527 528 private PackageVersionInfo getVersionInfo(Repository rep, RefInfo commit, string first_filename_try, Path sub_path = Path("/")) 529 { 530 import dub.recipe.io; 531 import dub.recipe.json; 532 533 PackageVersionInfo ret; 534 ret.date = commit.date.toSysTime(); 535 ret.sha = commit.sha; 536 foreach (filename; chain((&first_filename_try)[0 .. 1], packageInfoFilenames.filter!(f => f != first_filename_try))) { 537 if (!filename.length) continue; 538 try { 539 rep.readFile(commit.sha, sub_path ~ filename, (scope input) { 540 auto text = input.readAllUTF8(false); 541 auto recipe = parsePackageRecipe(text, filename); 542 ret.info = recipe.toJson(); 543 }); 544 545 ret.info["packageDescriptionFile"] = filename; 546 logDebug("Found package description file %s.", filename); 547 548 foreach (ref sp; ret.info["subPackages"].opt!(Json[])) { 549 if (sp.type == Json.Type..string) { 550 auto path = sp.get!string; 551 logDebug("Fetching path based sub package at %s", sub_path ~ path); 552 auto subpack = getVersionInfo(rep, commit, first_filename_try, sub_path ~ path); 553 sp = subpack.info; 554 sp["path"] = path; 555 } 556 } 557 558 break; 559 } catch (FileNotFoundException) { 560 logDebug("Package description file %s not found...", filename); 561 } 562 } 563 if (ret.info.type == Json.Type.undefined) 564 throw new Exception("Found no package description file in the repository."); 565 return ret; 566 } 567 568 private void checkPackageName(string n, string error_suffix) 569 { 570 enforce(n.length > 0, "Package names may not be empty. "~error_suffix); 571 foreach( ch; n ){ 572 switch(ch){ 573 default: 574 throw new Exception("Package names may only contain ASCII letters and numbers, as well as '_' and '-': "~n~" - "~error_suffix); 575 case 'a': .. case 'z': 576 case 'A': .. case 'Z': 577 case '0': .. case '9': 578 case '_', '-': 579 break; 580 } 581 } 582 } 583 584 struct PackageVersionInfo { 585 string version_; 586 SysTime date; 587 string sha; 588 string downloadURL; 589 Json info; /// JSON version information, as reported to the client 590 } 591 592 struct PackageInfo { 593 PackageVersionInfo[] versions; 594 Json info; /// JSON package information, as reported to the client 595 }