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