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.internal.utils; 11 import dubregistry.internal.workqueue; 12 import dubregistry.repositories.repository; 13 14 import dub.semver; 15 import dub.package_ : packageInfoFilenames; 16 import std.algorithm : canFind, countUntil, filter, map, sort, swap, equal, startsWith, endsWith; 17 import std.array; 18 import std.conv; 19 import std.datetime : Clock, UTC, hours, SysTime; 20 import std.digest : toHexString; 21 import std.encoding : sanitize; 22 import std.exception : enforce; 23 import std.range : chain, walkLength; 24 import std.path : extension; 25 import std.string : format, toLower, representation; 26 import std.uni : sicmp; 27 import std.typecons; 28 import std.uni : asUpperCase; 29 import userman.db.controller; 30 import vibe.core.core; 31 import vibe.core.log; 32 import vibe.data.bson; 33 import vibe.data.json; 34 import vibe.stream.operations; 35 36 37 /// Settings to configure the package registry. 38 class DubRegistrySettings { 39 string databaseName = "vpmreg"; 40 } 41 42 class DubRegistry { 43 @safe: 44 45 private { 46 DubRegistrySettings m_settings; 47 DbController m_db; 48 49 // list of package names to check for updates 50 PackageWorkQueue m_updateQueue; 51 // list of packages whose statistics need to be updated 52 PackageWorkQueue m_updateStatsQueue; 53 DbStatDistributions m_statDistributions; 54 } 55 56 this(DubRegistrySettings settings) 57 { 58 m_settings = settings; 59 m_db = new DbController(settings.databaseName); 60 61 // recompute scores on startup to pick up any algorithm changes 62 m_statDistributions = m_db.getStatDistributions(); 63 recomputeScores(m_statDistributions); 64 65 m_updateQueue = new PackageWorkQueue(&updatePackage); 66 m_updateStatsQueue = new PackageWorkQueue((p) { updatePackageStats(p); }); 67 } 68 69 @property DbController db() nothrow { return m_db; } 70 71 @property auto availablePackages() { return m_db.getAllPackages(); } 72 @property auto availablePackageIDs() { return m_db.getAllPackageIDs(); } 73 74 auto getPackageDump() 75 { 76 return m_db.getPackageDump(); 77 } 78 79 void triggerPackageUpdate(string pack_name) 80 { 81 m_updateQueue.putFront(pack_name); 82 } 83 84 bool isPackageScheduledForUpdate(string pack_name) 85 { 86 return m_updateQueue.isPending(pack_name); 87 } 88 89 /** Returns the current index of a given package in the update queue. 90 91 An index of zero indicates that the package is currently being updated. 92 A negative index is returned when the package is not in the update 93 queue. 94 */ 95 sizediff_t getUpdateQueuePosition(string pack_name) 96 { 97 return m_updateQueue.getPosition(pack_name); 98 } 99 100 auto searchPackages(string query) 101 { 102 static struct Info { string name; DbPackageStats stats; DbPackageVersion _base; alias _base this; } 103 return m_db.searchPackages(query).filter!(p => p.versions.length > 0).map!(p => 104 Info(p.name, p.stats, m_db.getVersionInfo(p.name, p.versions[$ - 1].version_))); 105 } 106 107 RepositoryInfo getRepositoryInfo(DbRepository repository) 108 { 109 auto rep = getRepository(repository); 110 return rep.getInfo(); 111 } 112 113 void addPackage(DbRepository repository, User.ID user) 114 { 115 auto pack_name = validateRepository(repository); 116 117 DbPackage pack; 118 pack.owner = user.bsonObjectIDValue; 119 pack.name = pack_name; 120 pack.repository = repository; 121 m_db.addPackage(pack); 122 123 triggerPackageUpdate(pack.name); 124 } 125 126 void addOrSetPackage(DbPackage pack) 127 { 128 m_db.addOrSetPackage(pack); 129 } 130 131 void addDownload(BsonObjectID pack_id, string ver, string agent) 132 { 133 m_db.addDownload(pack_id, ver, agent); 134 } 135 136 void removePackage(string packname, User.ID user) 137 { 138 logInfo("Package %s: removing package owned by %s", packname, user); 139 m_db.removePackage(packname, user.bsonObjectIDValue); 140 } 141 142 auto getPackages(User.ID user) 143 { 144 return m_db.getUserPackages(user.bsonObjectIDValue); 145 } 146 147 bool isUserPackage(User.ID user, string package_name) 148 { 149 return m_db.isUserPackage(user.bsonObjectIDValue, package_name); 150 } 151 152 /// get stats (including downloads of all version) for a package 153 DbPackageStats getPackageStats(string packname) 154 { 155 auto cached = m_db.getPackageStats(packname); 156 if (cached.updatedAt > Clock.currTime(UTC()) - 24.hours) 157 return cached; 158 return updatePackageStats(packname); 159 } 160 161 private DbPackageStats updatePackageStats(string packname) 162 { 163 logDiagnostic("Updating stats for %s", packname); 164 165 DbPackageStats stats; 166 DbPackage pack = m_db.getPackage(packname); 167 stats.downloads = m_db.aggregateDownloadStats(pack._id); 168 169 try { 170 stats.repo = getRepositoryInfo(pack.repository).stats; 171 } catch (FileNotFoundException e) { 172 // repo no longer exists, rate it down to zero (#221) 173 logInfo("Zero scoring %s because the repo no longer exists.", packname); 174 stats.score = 0; 175 } catch (Exception e) { 176 logWarn("Failed to get repository info for %s: %s", packname, e.msg); 177 return typeof(return).init; 178 } 179 180 if (auto pStatDist = pack.repository.kind in m_statDistributions.repos) 181 stats.score = computeScore(stats, m_statDistributions.downloads, *pStatDist); 182 else 183 logError("Missing stat distribution for %s repositories.", pack.repository.kind); 184 185 m_db.updatePackageStats(pack._id, stats); 186 return stats; 187 } 188 189 /// get downloads for a package version 190 DbDownloadStats getDownloadStats(string packname, string ver) 191 { 192 auto packid = m_db.getPackageID(packname); 193 if (ver == "latest") ver = getLatestVersion(packname); 194 enforce!RecordNotFound(m_db.hasVersion(packname, ver), "Unknown version for package."); 195 return m_db.aggregateDownloadStats(packid, ver); 196 } 197 198 Json getPackageVersionInfo(string packname, string ver, PackageInfoFlags flags) 199 { 200 if (ver == "latest") ver = getLatestVersion(packname); 201 if (!m_db.hasVersion(packname, ver)) return Json(null); 202 auto ret = m_db.getVersionInfo(packname, ver).serializeToJson(); 203 if (flags & PackageInfoFlags.minimize) ret.remove("readme"); 204 return ret; 205 } 206 207 string getLatestVersion(string packname) 208 { 209 return m_db.getLatestVersion(packname); 210 } 211 212 PackageInfo getPackageInfo(string packname, PackageInfoFlags flags = PackageInfoFlags.none) 213 { 214 DbPackage pack; 215 try pack = m_db.getPackage(packname); 216 catch(Exception) return PackageInfo.init; 217 218 return getPackageInfo(pack, flags); 219 } 220 221 /** Gets information about multiple packages at once. 222 223 The order and count of packages returned may not correspond to the list 224 of supplied package names. Only those packages that actually reference 225 an existing package will yield a result element. 226 227 The consequence is that the caller must manually match the result to 228 the supplied package names. 229 230 This function requires only a single query to the database. 231 232 Returns: 233 An unordered input range of `PackageInfo` values is returned, 234 corresponding to all or part of the packages of the given names. 235 */ 236 auto getPackageInfos(scope string[] pack_names, PackageInfoFlags flags = PackageInfoFlags.none) 237 { 238 return m_db.getPackages(pack_names) 239 .map!(pack => getPackageInfo(pack, flags)); 240 } 241 242 auto getPackageInfo(DbPackage pack, PackageInfoFlags flags = PackageInfoFlags.none) 243 { 244 auto rep = getRepository(pack.repository); 245 246 PackageInfo ret; 247 ret.versions = pack.versions.map!(v => getPackageVersionInfo(v, rep, flags)).array; 248 ret.logo = pack.logo; 249 250 Json nfo = Json.emptyObject; 251 nfo["versions"] = Json(ret.versions.map!(v => v.info).array); 252 if (!(flags & PackageInfoFlags.minimize)) 253 { 254 nfo["name"] = pack.name; 255 nfo["id"] = pack._id.toString(); 256 nfo["dateAdded"] = pack._id.timeStamp.toISOExtString(); 257 nfo["owner"] = pack.owner.toString(); 258 nfo["repository"] = serializeToJson(pack.repository); 259 nfo["categories"] = serializeToJson(pack.categories); 260 nfo["documentationURL"] = pack.documentationURL; 261 } 262 if (flags & PackageInfoFlags.includeErrors) 263 nfo["errors"] = serializeToJson(pack.errors); 264 265 ret.info = nfo; 266 267 return ret; 268 } 269 270 private PackageVersionInfo getPackageVersionInfo(DbPackageVersion v, Repository rep, PackageInfoFlags flags) 271 { 272 // JSON package version info as reported to the client 273 Json[string] nfo; 274 if (flags & PackageInfoFlags.minimize) 275 { 276 // only keep information relevant for dependency resolution 277 static Json[string] keepDeps(in Json info) 278 { 279 Json[string] ret; 280 ret["name"] = info["name"]; 281 ret["dependencies"] = info["dependencies"]; 282 auto cfgs = info["configurations"].opt!(Json[]) 283 .map!(cfg => Json(["name": cfg["name"], "dependencies": cfg["dependencies"]])); 284 if (!cfgs.empty) 285 ret["configurations"] = cfgs.array.Json; 286 return ret; 287 } 288 nfo = keepDeps(v.info); 289 auto subpkgs = v.info["subPackages"].opt!(Json[]).map!(subpkg => Json(keepDeps(subpkg))); 290 if (!subpkgs.empty) 291 nfo["subPackages"] = subpkgs.array.Json; 292 } 293 else 294 { 295 nfo = v.info.get!(Json[string]).dup; 296 nfo["date"] = v.date.toISOExtString(); 297 nfo["readme"] = v.readme; 298 nfo["commitID"] = v.commitID; 299 } 300 nfo["version"] = v.version_; 301 302 PackageVersionInfo ret; 303 ret.info = Json(nfo); 304 ret.date = v.date; 305 ret.sha = v.commitID; 306 ret.version_ = v.version_; 307 ret.downloadURL = rep.getDownloadUrl(v.version_.startsWith("~") ? v.version_ : "v"~v.version_); 308 return ret; 309 } 310 311 PackageInfo[string] getPackageInfosRecursive(string[] packnames, PackageInfoFlags flags) 312 { 313 import dub.recipe.packagerecipe : getBasePackageName; 314 315 PackageInfo[string] infos; 316 void[0][string] visited; 317 foreach (packname; packnames) 318 { 319 logDebug("getPackageInfosRecursive: %s", packname); 320 getPackageInfosRecursive(packname, flags, infos, visited); 321 } 322 logDebug("getPackageInfosRecursive for %s returned %s", packnames, infos.byKey); 323 return infos; 324 } 325 326 private void getPackageInfosRecursive(string packname, PackageInfoFlags flags, ref PackageInfo[string] infos, ref void[0][string] visited) 327 { 328 import dub.recipe.packagerecipe : getBasePackageName, getSubPackageName; 329 import std.range : dropOne; 330 import std.algorithm.searching : find; 331 332 if (packname in visited) 333 return; 334 visited[packname] = typeof(visited[packname]).init; 335 336 auto basepkg = getBasePackageName(packname); 337 auto p = basepkg in infos; 338 if (p is null) 339 p = &(infos[basepkg] = getPackageInfo(basepkg, flags)); 340 341 if (!(flags & PackageInfoFlags.includeDependencies)) 342 return; 343 344 void addDeps(Json info) 345 { 346 foreach (dep, _; info["dependencies"].opt!(Json[string])) 347 getPackageInfosRecursive(dep, flags, infos, visited); 348 foreach (cfg; info["configurations"].opt!(Json[])) 349 foreach (dep, _; cfg["dependencies"].opt!(Json[string])) 350 getPackageInfosRecursive(dep, flags, infos, visited); 351 } 352 353 Louter: foreach (v; p.versions) 354 { 355 import std.algorithm.iteration : splitter; 356 auto info = v.info; 357 foreach (subpkg; packname.splitter(":").dropOne) 358 { 359 auto sp = info["subPackages"].opt!(Json[]).find!(sp => sp["name"] == subpkg); 360 if (sp.empty) 361 continue Louter; 362 info = sp.front; 363 } 364 addDeps(info); 365 } 366 } 367 368 string getReadme(Json version_info, DbRepository repository) 369 { 370 auto readme = version_info["readme"].opt!string; 371 372 // compat migration, read file from repo if README hasn't yet been stored in the db 373 if (readme.length && readme.length < 256 && readme[0] == '/') { 374 try { 375 auto rep = getRepository(repository); 376 logDebug("reading readme file for %s: %s", version_info["name"].get!string, readme); 377 rep.readFile(version_info["commitID"].get!string, InetPath(readme), (scope data) { 378 readme = data.readAllUTF8(); 379 }); 380 } catch (Exception e) { 381 logDiagnostic("Failed to read README file (%s) for %s %s: %s", 382 readme, version_info["name"].get!string, 383 version_info["version"].get!string, e.msg); 384 } 385 } 386 return readme; 387 } 388 389 void downloadPackageZip(string packname, string vers, void delegate(scope InputStream) @safe del) 390 { 391 DbPackage pack = m_db.getPackage(packname); 392 auto rep = getRepository(pack.repository); 393 rep.download(vers, del); 394 } 395 396 void setPackageCategories(string pack_name, string[] categories) 397 { 398 m_db.setPackageCategories(pack_name, categories); 399 } 400 401 void setPackageRepository(string pack_name, DbRepository repository) 402 { 403 auto new_name = validateRepository(repository); 404 enforce(pack_name == new_name, "The package name of the new repository doesn't match the existing one: "~new_name); 405 m_db.setPackageRepository(pack_name, repository); 406 } 407 408 void setPackageLogo(string pack_name, NativePath path) 409 { 410 auto png = generateLogo(path); 411 if (png.length) 412 m_db.setPackageLogo(pack_name, png); 413 else 414 throw new Exception("Failed to generate logo"); 415 } 416 417 void unsetPackageLogo(string pack_name) 418 { 419 m_db.setPackageLogo(pack_name, null); 420 } 421 422 void setDocumentationURL(string pack_name, string documentationURL) 423 { 424 m_db.setDocumentationURL(pack_name, documentationURL); 425 } 426 427 bdata_t getPackageLogo(string pack_name, out bdata_t rev) 428 { 429 return m_db.getPackageLogo(pack_name, rev); 430 } 431 432 void updatePackages() 433 { 434 import std.random : randomShuffle; 435 436 logDiagnostic("Triggering package update..."); 437 // update stat distributions before score packages 438 m_statDistributions = m_db.getStatDistributions(); 439 440 // shuffle the package list to ensure a uniform probability of each 441 // package getting chosen over time, regardless of how long it takes 442 // to process the whole list and whether the process gets restarted 443 // in-between 444 auto allpacks = this.availablePackages.array; 445 randomShuffle(allpacks); 446 foreach (packname; allpacks) 447 if (!m_updateQueue.isPending(packname)) 448 triggerPackageUpdate(packname); 449 } 450 451 protected string validateRepository(DbRepository repository) 452 { 453 // find the packge info of ~master or any available branch 454 PackageVersionInfo info; 455 auto rep = getRepository(repository); 456 auto branches = rep.getBranches(); 457 enforce(branches.length > 0, "The repository contains no branches."); 458 auto idx = branches.countUntil!(b => b.name == "master"); 459 if (idx > 0) swap(branches[0], branches[idx]); 460 string branch_errors; 461 foreach (b; branches) { 462 try { 463 info = rep.getVersionInfo(b, null); 464 enforce (info.info.type == Json.Type.object, 465 "JSON package description must be a JSON object."); 466 break; 467 } catch (Exception e) { 468 logDiagnostic("Error getting package info for %s", b); 469 branch_errors ~= format("\n%s: %s", b.name, e.msg); 470 } 471 } 472 enforce (info.info.type == Json.Type.object, 473 "Failed to find a branch containing a valid package description file:" ~ branch_errors); 474 475 // derive package name and perform various sanity checks 476 auto name = info.info["name"].get!string; 477 string package_desc_file = info.info["packageDescriptionFile"].get!string; 478 string package_check_string = format(`Check your %s.`, package_desc_file); 479 enforce(name.length <= 60, 480 "Package names must not be longer than 60 characters: \""~name[0 .. 60]~"...\" - "~package_check_string); 481 enforce(name == name.toLower(), 482 "Package names must be all lower case, not \""~name~"\". "~package_check_string); 483 enforce(info.info["license"].opt!string.length > 0, 484 `A "license" field in the package description file is missing or empty. `~package_check_string); 485 enforce(info.info["description"].opt!string.length > 0, 486 `A "description" field in the package description file is missing or empty. `~package_check_string); 487 checkPackageName(name, format(`Check the "name" field of your %s.`, package_desc_file)); 488 foreach (string n, vspec; info.info["dependencies"].opt!(Json[string])) { 489 auto parts = n.split(":").array; 490 // allow shortcut syntax ":subpack" 491 if (parts.length > 1 && parts[0].length == 0) parts = parts[1 .. $]; 492 // verify all other parts of the package name 493 foreach (p; parts) 494 checkPackageName(p, format(`Check the "dependencies" field of your %s.`, package_desc_file)); 495 } 496 497 // ensure that at least one tagged version is present 498 auto tags = rep.getTags(); 499 enforce(tags.canFind!(t => t.name.startsWith("v") && t.name[1 .. $].isValidVersion), 500 `The repository must have at least one tagged version (SemVer format with a "v" prefix, e.g. ` 501 ~ `"v1.0.0" or "v0.0.1") to be published on the registry. Please add a proper tag using ` 502 ~ `"git tag" or equivalent means and see http://semver.org for more information.`); 503 504 return name; 505 } 506 507 protected bool addVersion(in ref DbPackage dbpack, string ver, Repository rep, RefInfo reference) 508 { 509 logDiagnostic("Adding new version info %s for %s", ver, dbpack.name); 510 assert(ver.startsWith("~") && !ver.startsWith("~~") || isValidVersion(ver)); 511 512 string deffile; 513 foreach (t; dbpack.versions) 514 if (t.version_ == ver) { 515 deffile = t.info["packageDescriptionFile"].opt!string; 516 break; 517 } 518 auto info = getVersionInfo(rep, reference, deffile); 519 520 //assert(info.info.name == info.info.name.get!string.toLower(), "Package names must be all lower case."); 521 info.info["name"] = info.info["name"].get!string.toLower(); 522 enforce(info.info["name"] == dbpack.name, 523 format("Package name (%s) does not match the original package name (%s). Check %s.", 524 info.info["name"].get!string, dbpack.name, info.info["packageDescriptionFile"].get!string)); 525 526 foreach( string n, vspec; info.info["dependencies"].opt!(Json[string]) ) 527 foreach (p; n.split(":")) 528 checkPackageName(p, "Check "~info.info["packageDescriptionFile"].get!string~"."); 529 530 DbPackageVersion dbver; 531 dbver.date = info.date; 532 dbver.version_ = ver; 533 dbver.commitID = info.sha; 534 dbver.info = info.info; 535 536 try { 537 auto files = rep.listFiles(reference.sha, InetPath("/")); 538 // check exactly for readme.me 539 ptrdiff_t readme; 540 readme = files.countUntil!(a => a.type == RepositoryFile.Type.file && a.path.head.name.asUpperCase.equal("README.MD")); 541 if (readme == -1) { 542 // check exactly for readme 543 readme = files.countUntil!(a => a.type == RepositoryFile.Type.file && a.path.head.name.asUpperCase.equal("README")); 544 } 545 if (readme == -1) { 546 // check for all other readmes such as README.txt, README.jp.md, etc. 547 readme = files.countUntil!(a => a.type == RepositoryFile.Type.file && a.path.head.name.asUpperCase.startsWith("README")); 548 } 549 550 if (readme != -1) { 551 rep.readFile(reference.sha, files[readme].path, (scope input) { 552 dbver.readme = input.readAllUTF8(); 553 string ext = files[readme].path.head.name.extension; 554 // endsWith doesn't like to work with asLowerCase 555 dbver.readmeMarkdown = ext.sicmp(".md") == 0; 556 }); 557 } else logDiagnostic("No README.md found for %s %s", dbpack.name, ver); 558 559 // TODO: load in example(s), sample(s), test(s) and docs for the view package page here. 560 // possibly also parsing the README.md file for a documentation link 561 } catch (Exception e) { logDiagnostic("Failed to read README.md for %s %s: %s", dbpack.name, ver, e.msg); } 562 563 if (m_db.hasVersion(dbpack.name, ver)) { 564 logDebug("Updating existing version info."); 565 m_db.updateVersion(dbpack.name, dbver); 566 return false; 567 } 568 569 if ("description" !in info.info || "license" !in info.info) { 570 throw new Exception( 571 "Published packages must contain \"description\" and \"license\" fields."); 572 } 573 //enforce(!m_db.hasVersion(packname, dbver.version_), "Version already exists."); 574 if (auto pv = "version" in info.info) 575 enforce(pv.get!string == ver, format("Package description contains an obsolete \"version\" field and does not match tag %s: %s", ver, pv.get!string)); 576 logDebug("Adding new version info."); 577 m_db.addVersion(dbpack.name, dbver); 578 return true; 579 } 580 581 protected void removeVersion(string packname, string ver) 582 { 583 assert(ver.startsWith("~") && !ver.startsWith("~~") || isValidVersion(ver)); 584 585 m_db.removeVersion(packname, ver); 586 } 587 588 private void updatePackage(string packname) 589 { 590 import std.encoding; 591 string[] errors; 592 593 DbPackage pack; 594 try pack = m_db.getPackage(packname); 595 catch( Exception e ){ 596 errors ~= format("Error getting package info: %s", e.msg); 597 () @trusted { logDebug("%s", sanitize(e.toString())); } (); 598 return; 599 } 600 601 Repository rep; 602 try rep = getRepository(pack.repository); 603 catch( Exception e ){ 604 errors ~= format("Error accessing repository: %s", e.msg); 605 () @trusted { logDebug("%s", sanitize(e.toString())); } (); 606 return; 607 } 608 609 bool[string] existing; 610 RefInfo[] tags, branches; 611 bool got_all_tags_and_branches = false; 612 try { 613 tags = rep.getTags() 614 .filter!(a => a.name.startsWith("v") && a.name[1 .. $].isValidVersion) 615 .array 616 .sort!((a, b) => compareVersions(a.name[1 .. $], b.name[1 .. $]) < 0) 617 .array; 618 branches = rep.getBranches(); 619 got_all_tags_and_branches = true; 620 } catch (Exception e) { 621 errors ~= format("Failed to get GIT tags/branches: %s", e.msg); 622 } 623 logDiagnostic("Updating tags for %s: %s", packname, tags.map!(t => t.name).array); 624 foreach (tag; tags) { 625 auto name = tag.name[1 .. $]; 626 existing[name] = true; 627 try { 628 if (addVersion(pack, name, rep, tag)) 629 logInfo("Package %s: added version %s", packname, name); 630 } catch( Exception e ){ 631 logDiagnostic("Error for version %s of %s: %s", name, packname, e.msg); 632 () @trusted { logDebug("Full error: %s", sanitize(e.toString())); } (); 633 errors ~= format("Version %s: %s", name, e.msg); 634 } 635 } 636 logDiagnostic("Updating branches for %s: %s", packname, branches.map!(t => t.name).array); 637 foreach (branch; branches) { 638 auto name = "~" ~ branch.name; 639 existing[name] = true; 640 try { 641 if (addVersion(pack, name, rep, branch)) 642 logInfo("Package %s: added branch %s", packname, name); 643 } catch( Exception e ){ 644 logDiagnostic("Error for branch %s of %s: %s", name, packname, e.msg); 645 () @trusted { logDebug("Full error: %s", sanitize(e.toString())); } (); 646 if (branch.name != "gh-pages") // ignore errors on the special GitHub website branch 647 errors ~= format("Branch %s: %s", name, e.msg); 648 } 649 } 650 if (got_all_tags_and_branches) { 651 foreach (ref v; pack.versions) { 652 auto ver = v.version_; 653 if (ver !in existing) { 654 logInfo("Package %s: removing version %s as the branch/tag was removed.", packname, ver); 655 removeVersion(packname, ver); 656 } 657 } 658 } 659 m_db.setPackageErrors(packname, errors); 660 661 m_updateStatsQueue.put(packname); 662 } 663 664 /// recompute all scores based on cached stats, e.g. after updating algorithm 665 private void recomputeScores(DbStatDistributions dists) 666 { 667 foreach (pack; this.getPackageDump()) { 668 auto stats = pack.stats; 669 stats.score = computeScore(stats, dists.downloads, dists.repos[pack.repository.kind]); 670 if (stats.score != pack.stats.score) 671 m_db.updatePackageStats(pack._id, stats); 672 } 673 } 674 } 675 676 private PackageVersionInfo getVersionInfo(Repository rep, RefInfo commit, string first_filename_try, InetPath sub_path = InetPath("/")) 677 @safe { 678 import dub.recipe.io; 679 import dub.recipe.json; 680 681 PackageVersionInfo ret; 682 ret.date = commit.date.toSysTime(); 683 ret.sha = commit.sha; 684 string[1] first_try; 685 first_try[0] = first_filename_try; 686 auto all_filenames = () @trusted { return packageInfoFilenames(); } (); 687 foreach (filename; chain(first_try[], all_filenames.filter!(f => f != first_filename_try))) { 688 if (!filename.length) continue; 689 try { 690 rep.readFile(commit.sha, sub_path ~ filename, (scope input) @safe { 691 auto text = input.readAllUTF8(false); 692 auto recipe = () @trusted { return parsePackageRecipe(text, filename); } (); 693 ret.info = () @trusted { return recipe.toJson(); } (); 694 }); 695 696 ret.info["packageDescriptionFile"] = filename; 697 logDebug("Found package description file %s.", filename); 698 699 foreach (ref sp; ret.info["subPackages"].opt!(Json[])) { 700 if (sp.type == Json.Type..string) { 701 auto path = sp.get!string; 702 logDebug("Fetching path based sub package at %s", sub_path ~ path); 703 auto subpack = getVersionInfo(rep, commit, first_filename_try, sub_path ~ path); 704 sp = subpack.info; 705 sp["path"] = path; 706 } 707 } 708 709 break; 710 } catch (FileNotFoundException) { 711 logDebug("Package description file %s not found...", filename); 712 } 713 } 714 if (ret.info.type == Json.Type.undefined) 715 throw new Exception("Found no package description file in the repository."); 716 return ret; 717 } 718 719 private void checkPackageName(string n, string error_suffix) 720 @safe { 721 enforce(n.length > 0, "Package names may not be empty. "~error_suffix); 722 foreach( ch; n ){ 723 switch(ch){ 724 default: 725 throw new Exception("Package names may only contain ASCII letters and numbers, as well as '_' and '-': "~n~" - "~error_suffix); 726 case 'a': .. case 'z': 727 case 'A': .. case 'Z': 728 case '0': .. case '9': 729 case '_', '-': 730 break; 731 } 732 } 733 } 734 735 struct PackageVersionInfo { 736 string version_; 737 SysTime date; 738 string sha; 739 string downloadURL; 740 Json info; /// JSON version information, as reported to the client 741 } 742 743 struct PackageInfo { 744 PackageVersionInfo[] versions; 745 BsonObjectID logo; 746 Json info; /// JSON package information, as reported to the client 747 } 748 749 /// flags to customize getPackageInfo* methods 750 enum PackageInfoFlags 751 { 752 none, 753 includeDependencies = 1 << 0, /// include package info of dependencies 754 includeErrors = 1 << 1, /// include package errors 755 minimize = 1 << 2, /// return only minimal information (for dependency resolver) 756 } 757 758 /// Computes a package score from given package stats and global distributions of those stats. 759 private float computeScore(DownDist, RepoDist)(in ref DbPackageStats stats, DownDist downDist, RepoDist repoDist) 760 @safe { 761 import std.algorithm.comparison : max; 762 import std.math : log1p, round, tanh; 763 764 if (!downDist.total.sum) // no stat distribution yet 765 return 0; 766 767 /// Using monthly downloads to penalize stale packages, logarithm to 768 /// offset exponential distribution, and tanh as smooth limiter to [0..1]. 769 immutable downloadScore = tanh(log1p(stats.downloads.monthly / (downDist.monthly.mean + double.min_normal))); 770 logDebug("downloadScore %s %s %s", downloadScore, stats.downloads.monthly, downDist.monthly.mean); 771 772 // Compute score for repo 773 float sum=0, wsum=0; 774 void add(T)(float weight, float value, T dist) 775 { 776 if (dist.sum == 0) 777 return; // ignore metrics missing for that repository kind 778 sum += weight * log1p(value / dist.mean); 779 wsum += weight; 780 } 781 with (stats.repo) 782 { 783 alias d = repoDist; 784 // all of those values are highly correlated 785 add(1.0f, stars, d.stars); 786 add(1.0f, watchers, d.watchers); 787 add(1.0f, forks, d.forks); 788 add(-1.0f, issues, d.issues); // penalize many open issues/PRs 789 } 790 791 immutable repoScore = max(0.0, tanh(sum / wsum)); 792 logDebug("repoScore: %s %s %s", repoScore, sum, wsum); 793 794 // average scores 795 immutable avgScore = (repoScore + downloadScore) / 2; 796 assert(0 <= avgScore && avgScore <= 1.0, "%s %s".format(repoScore, downloadScore)); 797 immutable scaled = stats.minScore + avgScore * (stats.maxScore - stats.minScore); 798 logDebug("score: %s %s %s %s %s %s", stats.downloads.monthly, downDist.monthly.mean, downloadScore, repoScore, avgScore, scaled); 799 800 return scaled; 801 }