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