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("Removing package %s of %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 Json getPackageStats(string packname) 164 { 165 DbPackage pack; 166 try pack = m_db.getPackage(packname); 167 catch(Exception) return Json(null); 168 return PackageStats(m_db.getDownloadStats(pack._id)).serializeToJson(); 169 } 170 171 Json getPackageStats(string packname, string ver) 172 { 173 DbPackage pack; 174 try pack = m_db.getPackage(packname); 175 catch(Exception) return Json(null); 176 if (ver == "latest") ver = getLatestVersion(packname); 177 if (!m_db.hasVersion(packname, ver)) return Json(null); 178 return PackageStats(m_db.getDownloadStats(pack._id, ver)).serializeToJson(); 179 } 180 181 Json getPackageVersionInfo(string packname, string ver) 182 { 183 if (ver == "latest") ver = getLatestVersion(packname); 184 if (!m_db.hasVersion(packname, ver)) return Json(null); 185 return m_db.getVersionInfo(packname, ver).serializeToJson(); 186 } 187 188 string getLatestVersion(string packname) 189 { 190 return m_db.getLatestVersion(packname); 191 } 192 193 PackageInfo getPackageInfo(string packname, bool include_errors = false) 194 { 195 DbPackage pack; 196 try pack = m_db.getPackage(packname); 197 catch(Exception) return PackageInfo.init; 198 199 return getPackageInfo(pack, include_errors); 200 } 201 202 PackageInfo getPackageInfo(DbPackage pack, bool include_errors) 203 { 204 auto rep = getRepository(pack.repository); 205 206 PackageInfo ret; 207 ret.versions = pack.versions.map!(v => getPackageVersionInfo(v, rep)).array; 208 209 Json nfo = Json.emptyObject; 210 nfo["id"] = pack._id.toString(); 211 nfo["dateAdded"] = pack._id.timeStamp.toISOExtString(); 212 nfo["owner"] = pack.owner.toString(); 213 nfo["name"] = pack.name; 214 nfo["versions"] = Json(ret.versions.map!(v => v.info).array); 215 nfo["repository"] = serializeToJson(pack.repository); 216 nfo["categories"] = serializeToJson(pack.categories); 217 if(include_errors) nfo["errors"] = serializeToJson(pack.errors); 218 219 ret.info = nfo; 220 221 return ret; 222 } 223 224 private PackageVersionInfo getPackageVersionInfo(DbPackageVersion v, Repository rep) 225 { 226 // JSON package version info as reported to the client 227 auto nfo = v.info.get!(Json[string]).dup; 228 nfo["version"] = v.version_; 229 nfo["date"] = v.date.toISOExtString(); 230 nfo["readmeFile"] = v.readme; 231 nfo["commitID"] = v.commitID; 232 233 PackageVersionInfo ret; 234 ret.info = Json(nfo); 235 ret.date = v.date; 236 ret.sha = v.commitID; 237 ret.version_ = v.version_; 238 ret.downloadURL = rep.getDownloadUrl(v.version_.startsWith("~") ? v.version_ : "v"~v.version_); 239 return ret; 240 } 241 242 string getReadme(Json version_info, DbRepository repository) 243 { 244 string ret; 245 auto file = version_info["readmeFile"].opt!string; 246 try { 247 if (!file.startsWith('/')) return null; 248 auto rep = getRepository(repository); 249 logDebug("reading readme file for %s: %s", version_info["name"].get!string, file); 250 rep.readFile(version_info["commitID"].get!string, Path(file), (scope data) { 251 ret = data.readAllUTF8(); 252 }); 253 logDebug("reading readme file done"); 254 } catch (Exception e) { 255 logDiagnostic("Failed to read README file (%s) for %s %s: %s", 256 file, version_info["name"].get!string, 257 version_info["version"].get!string, e.msg); 258 } 259 return ret; 260 } 261 262 void downloadPackageZip(string packname, string vers, void delegate(scope InputStream) del) 263 { 264 DbPackage pack = m_db.getPackage(packname); 265 auto rep = getRepository(pack.repository); 266 rep.download(vers, del); 267 } 268 269 void setPackageCategories(string pack_name, string[] categories) 270 { 271 m_db.setPackageCategories(pack_name, categories); 272 } 273 274 void setPackageRepository(string pack_name, DbRepository repository) 275 { 276 auto new_name = validateRepository(repository); 277 enforce(pack_name == new_name, "The package name of the new repository doesn't match the existing one: "~new_name); 278 m_db.setPackageRepository(pack_name, repository); 279 } 280 281 void checkForNewVersions() 282 { 283 logInfo("Triggering check for new versions..."); 284 foreach (packname; this.availablePackages) 285 triggerPackageUpdate(packname); 286 } 287 288 protected string validateRepository(DbRepository repository) 289 { 290 // find the packge info of ~master or any available branch 291 PackageVersionInfo info; 292 auto rep = getRepository(repository); 293 auto branches = rep.getBranches(); 294 enforce(branches.length > 0, "The repository contains no branches."); 295 auto idx = branches.countUntil!(b => b.name == "master"); 296 if (idx > 0) swap(branches[0], branches[idx]); 297 string branch_errors; 298 foreach (b; branches) { 299 try { 300 info = rep.getVersionInfo(b, null); 301 enforce (info.info.type == Json.Type.object, 302 "JSON package description must be a JSON object."); 303 break; 304 } catch (Exception e) { 305 logDiagnostic("Error getting package info for %s", b); 306 branch_errors ~= format("\n%s: %s", b.name, e.msg); 307 } 308 } 309 enforce (info.info.type == Json.Type.object, 310 "Failed to find a branch containing a valid package description file:" ~ branch_errors); 311 312 // derive package name and perform various sanity checks 313 auto name = info.info["name"].get!string; 314 string package_desc_file = info.info["packageDescriptionFile"].get!string; 315 string package_check_string = format(`Check your %s.`, package_desc_file); 316 enforce(name.length <= 60, 317 "Package names must not be longer than 60 characters: \""~name[0 .. 60]~"...\" - "~package_check_string); 318 enforce(name == name.toLower(), 319 "Package names must be all lower case, not \""~name~"\". "~package_check_string); 320 enforce(info.info["license"].opt!string.length > 0, 321 `A "license" field in the package description file is missing or empty. `~package_check_string); 322 enforce(info.info["description"].opt!string.length > 0, 323 `A "description" field in the package description file is missing or empty. `~package_check_string); 324 checkPackageName(name, format(`Check the "name" field of your %s.`, package_desc_file)); 325 foreach (string n, vspec; info.info["dependencies"].opt!(Json[string])) { 326 auto parts = n.split(":").array; 327 // allow shortcut syntax ":subpack" 328 if (parts.length > 1 && parts[0].length == 0) parts = parts[1 .. $]; 329 // verify all other parts of the package name 330 foreach (p; parts) 331 checkPackageName(p, format(`Check the "dependencies" field of your %s.`, package_desc_file)); 332 } 333 334 // ensure that at least one tagged version is present 335 auto tags = rep.getTags(); 336 enforce(tags.canFind!(t => t.name.startsWith("v") && t.name[1 .. $].isValidVersion), 337 `The repository must have at least one tagged version (SemVer format, e.g. ` 338 ~ `"v1.0.0" or "v0.0.1") to be published on the registry. Please add a proper tag using ` 339 ~ `"git tag" or equivalent means and see http://semver.org for more information.`); 340 341 return name; 342 } 343 344 protected bool addVersion(string packname, string ver, Repository rep, RefInfo reference) 345 { 346 logDiagnostic("Adding new version info %s for %s", ver, packname); 347 assert(ver.startsWith("~") && !ver.startsWith("~~") || isValidVersion(ver)); 348 349 auto dbpack = m_db.getPackage(packname); 350 string deffile; 351 foreach (t; dbpack.versions) 352 if (t.version_ == ver) { 353 deffile = t.info["packageDescriptionFile"].opt!string; 354 break; 355 } 356 auto info = getVersionInfo(rep, reference, deffile); 357 358 //assert(info.info.name == info.info.name.get!string.toLower(), "Package names must be all lower case."); 359 info.info["name"] = info.info["name"].get!string.toLower(); 360 enforce(info.info["name"] == packname, 361 format("Package name (%s) does not match the original package name (%s). Check %s.", 362 info.info["name"].get!string, packname, info.info["packageDescriptionFile"].get!string)); 363 364 if ("description" !in info.info || "license" !in info.info) { 365 //enforce("description" in info.info && "license" in info.info, 366 throw new Exception( 367 "Published packages must contain \"description\" and \"license\" fields."); 368 } 369 370 foreach( string n, vspec; info.info["dependencies"].opt!(Json[string]) ) 371 foreach (p; n.split(":")) 372 checkPackageName(p, "Check "~info.info["packageDescriptionFile"].get!string~"."); 373 374 DbPackageVersion dbver; 375 dbver.date = info.date; 376 dbver.version_ = ver; 377 dbver.commitID = info.sha; 378 dbver.info = info.info; 379 380 try { 381 rep.readFile(reference.sha, Path("/README.md"), (scope input) { input.readAll(); }); 382 dbver.readme = "/README.md"; 383 } catch (Exception e) { logDiagnostic("No README.md found for %s %s", packname, ver); } 384 385 if (m_db.hasVersion(packname, ver)) { 386 logDebug("Updating existing version info."); 387 m_db.updateVersion(packname, dbver); 388 return false; 389 } 390 391 //enforce(!m_db.hasVersion(packname, dbver.version_), "Version already exists."); 392 if (auto pv = "version" in info.info) 393 enforce(pv.get!string == ver, format("Package description contains an obsolete \"version\" field and does not match tag %s: %s", ver, pv.get!string)); 394 logDebug("Adding new version info."); 395 m_db.addVersion(packname, dbver); 396 return true; 397 } 398 399 protected void removeVersion(string packname, string ver) 400 { 401 assert(ver.startsWith("~") && !ver.startsWith("~~") || isValidVersion(ver)); 402 403 m_db.removeVersion(packname, ver); 404 } 405 406 private void processUpdateQueue() 407 { 408 scope (exit) logWarn("Update task was killed!"); 409 while (true) { 410 m_lastSignOfLifeOfUpdateTask = Clock.currTime(UTC()); 411 logDiagnostic("Getting new package to be updated..."); 412 string pack; 413 synchronized (m_updateQueueMutex) { 414 while (m_updateQueue.empty) { 415 logDiagnostic("Waiting for package to be updated..."); 416 m_updateQueueCondition.wait(); 417 } 418 pack = m_updateQueue.front; 419 m_updateQueue.popFront(); 420 m_currentUpdatePackage = pack; 421 } 422 scope(exit) m_currentUpdatePackage = null; 423 logDiagnostic("Updating package %s.", pack); 424 try checkForNewVersions(pack); 425 catch (Exception e) { 426 logWarn("Failed to check versions for %s: %s", pack, e.msg); 427 logDiagnostic("Full error: %s", e.toString().sanitize); 428 } 429 } 430 } 431 432 private void checkForNewVersions(string packname) 433 { 434 import std.encoding; 435 string[] errors; 436 437 PackageInfo pack; 438 try pack = getPackageInfo(packname); 439 catch( Exception e ){ 440 errors ~= format("Error getting package info: %s", e.msg); 441 logDebug("%s", sanitize(e.toString())); 442 return; 443 } 444 445 Repository rep; 446 try rep = getRepository(pack.info["repository"].deserializeJson!DbRepository); 447 catch( Exception e ){ 448 errors ~= format("Error accessing repository: %s", e.msg); 449 logDebug("%s", sanitize(e.toString())); 450 return; 451 } 452 453 bool[string] existing; 454 RefInfo[] tags, branches; 455 bool got_all_tags_and_branches = false; 456 try { 457 tags = rep.getTags() 458 .filter!(a => a.name.startsWith("v") && a.name[1 .. $].isValidVersion) 459 .array 460 .sort!((a, b) => compareVersions(a.name[1 .. $], b.name[1 .. $]) < 0) 461 .array; 462 branches = rep.getBranches(); 463 got_all_tags_and_branches = true; 464 } catch (Exception e) { 465 errors ~= format("Failed to get GIT tags/branches: %s", e.msg); 466 } 467 logInfo("Updating tags for %s: %s", packname, tags.map!(t => t.name).array); 468 foreach (tag; tags) { 469 auto name = tag.name[1 .. $]; 470 existing[name] = true; 471 try { 472 if (addVersion(packname, name, rep, tag)) 473 logInfo("Added version %s of %s", name, packname); 474 } catch( Exception e ){ 475 logInfo("Error for version %s of %s: %s", name, packname, e.msg); 476 logDebug("Full error: %s", sanitize(e.toString())); 477 errors ~= format("Version %s: %s", name, e.msg); 478 } 479 } 480 logInfo("Updating branches for %s: %s", packname, branches.map!(t => t.name).array); 481 foreach (branch; branches) { 482 auto name = "~" ~ branch.name; 483 existing[name] = true; 484 try { 485 if (addVersion(packname, name, rep, branch)) 486 logInfo("Added branch %s for %s", name, packname); 487 } catch( Exception e ){ 488 logInfo("Error for branch %s of %s: %s", name, packname, e.msg); 489 logDebug("Full error: %s", sanitize(e.toString())); 490 if (branch.name != "gh-pages") // ignore errors on the special GitHub website branch 491 errors ~= format("Branch %s: %s", name, e.msg); 492 } 493 } 494 if (got_all_tags_and_branches) { 495 foreach (v; pack.versions) { 496 auto ver = v.version_; 497 if (ver !in existing) { 498 logInfo("Removing version %s as the branch/tag was removed.", ver); 499 removeVersion(packname, ver); 500 } 501 } 502 } 503 m_db.setPackageErrors(packname, errors); 504 } 505 } 506 507 private PackageVersionInfo getVersionInfo(Repository rep, RefInfo commit, string first_filename_try, Path sub_path = Path("/")) 508 { 509 import dub.recipe.io; 510 import dub.recipe.json; 511 512 PackageVersionInfo ret; 513 ret.date = commit.date.toSysTime(); 514 ret.sha = commit.sha; 515 foreach (filename; chain((&first_filename_try)[0 .. 1], packageInfoFilenames.filter!(f => f != first_filename_try))) { 516 if (!filename.length) continue; 517 try { 518 rep.readFile(commit.sha, sub_path ~ filename, (scope input) { 519 auto text = input.readAllUTF8(false); 520 auto recipe = parsePackageRecipe(text, filename); 521 ret.info = recipe.toJson(); 522 }); 523 524 ret.info["packageDescriptionFile"] = filename; 525 logDebug("Found package description file %s.", filename); 526 527 foreach (ref sp; ret.info["subPackages"].opt!(Json[])) { 528 if (sp.type == Json.Type..string) { 529 auto path = sp.get!string; 530 logDebug("Fetching path based sub package at %s", sub_path ~ path); 531 auto subpack = getVersionInfo(rep, commit, first_filename_try, sub_path ~ path); 532 sp = subpack.info; 533 sp["path"] = path; 534 } 535 } 536 537 break; 538 } catch (FileNotFoundException) { 539 logDebug("Package description file %s not found...", filename); 540 } 541 } 542 if (ret.info.type == Json.Type.undefined) 543 throw new Exception("Found no package description file in the repository."); 544 return ret; 545 } 546 547 private void checkPackageName(string n, string error_suffix) 548 { 549 enforce(n.length > 0, "Package names may not be empty. "~error_suffix); 550 foreach( ch; n ){ 551 switch(ch){ 552 default: 553 throw new Exception("Package names may only contain ASCII letters and numbers, as well as '_' and '-': "~n~" - "~error_suffix); 554 case 'a': .. case 'z': 555 case 'A': .. case 'Z': 556 case '0': .. case '9': 557 case '_', '-': 558 break; 559 } 560 } 561 } 562 563 struct PackageStats { 564 DbDownloadStats downloads; 565 } 566 567 struct PackageVersionInfo { 568 string version_; 569 SysTime date; 570 string sha; 571 string downloadURL; 572 Json info; /// JSON version information, as reported to the client 573 } 574 575 struct PackageInfo { 576 PackageVersionInfo[] versions; 577 Json info; /// JSON package information, as reported to the client 578 }