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.web; 7 8 import dubregistry.dbcontroller; 9 import dubregistry.repositories.bitbucket; 10 import dubregistry.repositories.github; 11 import dubregistry.registry; 12 import dubregistry.viewutils; // dummy import to make rdmd happy 13 14 import dub.semver; 15 import std.algorithm : sort, startsWith; 16 import std.array; 17 import std.file; 18 import std.path; 19 import std.string; 20 import userman.web; 21 import vibe.d; 22 23 24 DubRegistryWebFrontend registerDubRegistryWebFrontend(URLRouter router, DubRegistry registry, UserManController userman) 25 { 26 DubRegistryWebFrontend webfrontend; 27 if (userman) { 28 auto ff = new DubRegistryFullWebFrontend(registry, userman); 29 webfrontend = ff; 30 router.registerWebInterface(ff); 31 router.registerUserManWebInterface(userman); 32 } else { 33 webfrontend = new DubRegistryWebFrontend(registry, userman); 34 router.registerWebInterface(webfrontend); 35 } 36 router.get("*", serveStaticFiles("./public")); 37 return webfrontend; 38 } 39 40 class DubRegistryWebFrontend { 41 protected { 42 DubRegistry m_registry; 43 UserManController m_userman; 44 Category[] m_categories; 45 Category[string] m_categoryMap; 46 DbPackage[] m_packages; 47 } 48 49 this(DubRegistry registry, UserManController userman) 50 { 51 m_registry = registry; 52 m_userman = userman; 53 updateCategories(); 54 updatePackageList(); 55 } 56 57 @path("/") 58 void getHome(string sort = "updated", string category = null, ulong skip = 0, ulong limit = 20) 59 { 60 import std.algorithm.comparison : min; 61 import std.algorithm.iteration : filter, map; 62 63 static struct Info { 64 Json[] packages; 65 size_t packageCount; 66 size_t skip; 67 size_t limit; 68 Category[] categories; 69 Category[string] categoryMap; 70 } 71 72 static import std.algorithm.sorting; 73 import std.algorithm.searching : any; 74 75 DbPackage[] packages; 76 if (category.length) { 77 packages = m_packages 78 .filter!(p => p.categories.any!(c => c.startsWith(category))) 79 .array; 80 } else { 81 packages = m_packages.dup; 82 } 83 84 // sort by date of last version 85 SysTime getDate(in ref DbPackage p) { 86 if (p.versions.length == 0) return SysTime(0, UTC()); 87 return p.versions[$-1].date; 88 } 89 SysTime getDateAdded(in ref DbPackage p) { return (cast(BsonObjectID*)&p._id).timeStamp; } 90 bool compare(in ref DbPackage a, in ref DbPackage b) { 91 bool a_has_ver = a.versions.any!(v => !v.version_.startsWith("~")); 92 bool b_has_ver = b.versions.any!(v => !v.version_.startsWith("~")); 93 if (a_has_ver != b_has_ver) return a_has_ver; 94 return getDate(a) > getDate(b); 95 } 96 switch (sort) { 97 default: std.algorithm.sorting.sort!compare(packages); break; 98 case "name": std.algorithm.sorting.sort!((a, b) => a.name < b.name)(packages); break; 99 case "added": std.algorithm.sorting.sort!((a, b) => getDateAdded(a) > getDateAdded(b))(packages); break; 100 } 101 102 Info info; 103 info.packageCount = packages.length; 104 info.packages = packages[min(skip, $) .. min(skip + limit, $)] 105 .map!(p => m_registry.getPackageInfo(p, false).info) 106 .array; 107 info.skip = skip; 108 info.limit = limit; 109 info.categories = m_categories; 110 info.categoryMap = m_categoryMap; 111 112 render!("home.dt", info); 113 } 114 115 // compatibility route 116 void getAvailable(HTTPServerRequest req, HTTPServerResponse res) 117 { 118 res.redirect("/packages/index.json"); 119 } 120 121 @path("/packages/index.json") 122 void getPackages(HTTPServerRequest req, HTTPServerResponse res) 123 { 124 res.writeJsonBody(m_registry.availablePackages.array); 125 } 126 127 @path("/view_package/:packname") 128 void getRedirectViewPackage(string _packname) 129 { 130 redirect("/packages/"~_packname); 131 } 132 133 @path("/packages/:packname") 134 void getPackage(HTTPServerRequest req, HTTPServerResponse res, string _packname) 135 { 136 getPackageVersion(req, res, _packname, null); 137 } 138 139 @path("/packages/:packname/:version") 140 void getPackageVersion(HTTPServerRequest req, HTTPServerResponse res, string _packname, string _version) 141 { 142 import std.algorithm : canFind; 143 144 auto pname = _packname; 145 auto ver = _version.replace(" ", "+"); 146 string ext; 147 148 if (_version.length) { 149 if (ver.endsWith(".zip")) ext = "zip", ver = ver[0 .. $-4]; 150 else if( ver.endsWith(".json") ) ext = "json", ver = ver[0 .. $-5]; 151 } else { 152 if (pname.endsWith(".json")) { 153 pname = pname[0 .. $-5]; 154 ext = "json"; 155 } 156 } 157 158 PackageInfo packinfo; 159 PackageVersionInfo verinfo; 160 if (!getPackageInfo(pname, ver, packinfo, verinfo)) 161 return; 162 163 auto packageInfo = packinfo.info; 164 auto versionInfo = verinfo.info; 165 166 if (ext == "zip") { 167 if (pname.canFind(":")) return; 168 169 // This log line is a weird workaround to make otherwise undefined Json fields 170 // available. Smells like a compiler bug. 171 logDebug("%s %s", packageInfo["id"].toString(), verinfo.downloadURL); 172 173 // add download to statistic 174 m_registry.addDownload(BsonObjectID.fromString(packageInfo["id"].get!string), ver, req.headers.get("User-agent", null)); 175 if (verinfo.downloadURL.length > 0) { 176 // redirect to hosting service specific URL 177 redirect(verinfo.downloadURL); 178 } else { 179 // directly forward from hoster 180 res.headers["Content-Disposition"] = "attachment; filename=\""~pname~"-"~(ver.startsWith("~") ? ver[1 .. $] : ver) ~ ".zip\""; 181 m_registry.downloadPackageZip(pname, ver.startsWith("~") ? ver : "v"~ver, (scope data) { 182 res.writeBody(data, "application/zip"); 183 }); 184 } 185 } else if (ext == "json") { 186 if (pname.canFind(":")) return; 187 res.writeJsonBody(_version.length ? versionInfo : packageInfo); 188 } else { 189 User user; 190 if (m_userman) { 191 try user = m_userman.getUser(User.ID.fromString(packageInfo["owner"].get!string)); 192 catch (Exception e) { 193 logDebug("Failed to get owner '%s' for %s %s: %s", 194 packageInfo["owner"].get!string, pname, ver, e.msg); 195 } 196 } 197 198 auto gitVer = verinfo.version_; 199 gitVer = gitVer.startsWith("~") ? gitVer[1 .. $] : "v"~gitVer; 200 string urlFilter(string url, bool is_image) 201 { 202 if (url.startsWith("http://") || url.startsWith("https://")) 203 return url; 204 205 if (auto pr = "repository" in packageInfo) { 206 auto owner = (*pr)["owner"].get!string; 207 auto project = (*pr)["project"].get!string; 208 switch ((*pr)["kind"].get!string) { 209 default: return url; 210 // TODO: BitBucket + GitLab 211 case "github": 212 if (is_image) return format("https://github.com/%s/%s/raw/%s/%s", owner, project, gitVer, url); 213 else return format("https://github.com/%s/%s/blob/%s/%s", owner, project, gitVer, url); 214 } 215 } 216 217 return url; 218 } 219 220 auto packageName = pname; 221 auto registry = m_registry; 222 auto readmeContents = m_registry.getReadme(versionInfo, packageInfo["repository"].deserializeJson!DbRepository); 223 render!("view_package.dt", packageName, user, packageInfo, versionInfo, readmeContents, urlFilter, registry); 224 } 225 } 226 227 @path("/packages/:packname/versions") 228 void getAllPackageVersions(HTTPServerRequest req, HTTPServerResponse res, string _packname) { 229 import std.algorithm : canFind; 230 231 auto pname = _packname; 232 233 auto ppath = _packname.urlDecode().split(":"); 234 auto packageInfo = m_registry.getPackageInfo(ppath[0]); 235 236 auto packageName = pname; 237 auto registry = m_registry; 238 render!("view_package.versions.dt", 239 packageName, 240 packageInfo, 241 registry); 242 } 243 244 private bool getPackageInfo(string pack_name, string pack_version, out PackageInfo pkg_info, out PackageVersionInfo ver_info) { 245 import std.algorithm : map; 246 auto ppath = pack_name.urlDecode().split(":"); 247 248 pkg_info = m_registry.getPackageInfo(ppath[0]); 249 if (pkg_info.info.type == Json.Type.null_) return false; 250 251 if (pack_version.length) { 252 foreach (ref v; pkg_info.versions) { 253 if (v.version_ == pack_version) { 254 ver_info = v; 255 break; 256 } 257 } 258 if (ver_info.info.type != Json.Type.Object) return false; 259 } else { 260 import dubregistry.viewutils; 261 if (pkg_info.versions.length == 0) return false; 262 auto vidx = getBestVersionIndex(pkg_info.versions.map!(v => v.version_)); 263 ver_info = pkg_info.versions[vidx]; 264 } 265 266 foreach (i; 1 .. ppath.length) { 267 if ("subPackages" !in ver_info.info) return false; 268 bool found = false; 269 foreach (sp; ver_info.info["subPackages"]) { 270 if (sp["name"] == ppath[i]) { 271 Json newv = Json.emptyObject; 272 // inherit certain fields 273 foreach (field; ["version", "date", "license", "authors", "homepage"]) 274 if (auto pv = field in ver_info.info) newv[field] = *pv; 275 // copy/overwrite the rest frmo the sub package 276 foreach (string name, value; sp) newv[name] = value; 277 ver_info.info = newv; 278 found = true; 279 break; 280 } 281 } 282 if (!found) return false; 283 } 284 return true; 285 } 286 287 private void updateCategories() 288 { 289 auto catfile = openFile("categories.json"); 290 scope(exit) catfile.close(); 291 auto json = parseJsonString(catfile.readAllUTF8()); 292 293 Category[string] catmap; 294 295 Category processNode(Json node, string[] path) 296 { 297 path ~= node["name"].get!string; 298 auto cat = new Category; 299 cat.name = path.join("."); 300 cat.description = node["description"].get!string; 301 if (path.length > 2) 302 cat.indentedDescription = "\u00a0\u00a0\u00a0\u00a0".replicate(path.length-2) ~ "\u00a0└ " ~ cat.description; 303 else if (path.length == 2) 304 cat.indentedDescription = "\u00a0└ " ~ cat.description; 305 else cat.indentedDescription = cat.description; 306 foreach_reverse (i; 0 .. path.length) 307 if (existsFile("public/images/categories/"~path[0 .. i+1].join(".")~".png")) { 308 cat.imageName = path[0 .. i+1].join("."); 309 break; 310 } 311 312 catmap[cat.name] = cat; 313 314 if ("categories" in node) 315 foreach (subcat; node["categories"]) 316 cat.subCategories ~= processNode(subcat, path); 317 318 return cat; 319 } 320 321 Category[] cats; 322 foreach (top_level_cat; json) 323 cats ~= processNode(top_level_cat, null); 324 325 m_categories = cats; 326 m_categoryMap = catmap; 327 } 328 329 private void updatePackageList() 330 { 331 setTimer(30.seconds, &updatePackageList); 332 m_packages = m_registry.getPackageDump().array; 333 } 334 } 335 336 class DubRegistryFullWebFrontend : DubRegistryWebFrontend { 337 private { 338 UserManWebAuthenticator m_usermanauth; 339 } 340 341 this(DubRegistry registry, UserManController userman) 342 { 343 super(registry, userman); 344 m_usermanauth = new UserManWebAuthenticator(userman); 345 } 346 347 void querySearch(string q = "") 348 { 349 auto results = m_registry.searchPackages(q); 350 auto queryString = q; 351 render!("search_results.dt", queryString, results); 352 } 353 354 void getGettingStarted() { render!("getting_started.dt"); } 355 void getAbout() { redirect("/getting_started"); } 356 void getUsage() { redirect("/getting_started"); } 357 void getAdvancedUsage() { render!("advanced_usage.dt"); } 358 359 void getPublish() { render!("publish.dt"); } 360 void getDevelop() { render!("develop.dt"); } 361 362 @path("/package-format") 363 void getPackageFormat(string lang = null) 364 { 365 switch (lang) { 366 default: redirect("package-format?lang=json"); break; 367 case "json": render!("package_format_json.dt"); break; 368 case "sdl": render!("package_format_sdl.dt"); break; 369 } 370 } 371 372 private auto downloadInfo() 373 { 374 static struct DownloadFile { 375 string fileName; 376 string platformCaption; 377 string typeCaption; 378 } 379 380 static struct DownloadVersion { 381 string id; 382 DownloadFile[][string] files; 383 } 384 385 static struct Info { 386 DownloadVersion[] versions; 387 string latest = ""; 388 389 void addFile(string ver, string platform, string filename) 390 { 391 392 auto df = DownloadFile(filename); 393 switch (platform) { 394 default: 395 auto pts = platform.split("-"); 396 df.platformCaption = format("%s%s (%s)", pts[0][0 .. 1].toUpper(), pts[0][1 .. $], pts[1].replace("_", "-").toUpper()); 397 break; 398 case "osx-x86": df.platformCaption = "OS X (X86)"; break; 399 case "osx-x86_64": df.platformCaption = "OS X (X86-64)"; break; 400 } 401 402 if (filename.endsWith(".tar.gz")) df.typeCaption = "binary tarball"; 403 else if (filename.endsWith(".zip")) df.typeCaption = "zipped binaries"; 404 else if (filename.endsWith(".rpm")) df.typeCaption = "binary RPM package"; 405 else if (filename.endsWith("setup.exe")) df.typeCaption = "installer"; 406 else df.typeCaption = "Unknown"; 407 408 foreach(ref v; versions) 409 if( v.id == ver ){ 410 v.files[platform] ~= df; 411 return; 412 } 413 DownloadVersion dv = DownloadVersion(ver); 414 dv.files[platform] = [df]; 415 versions ~= dv; 416 if (!isPreReleaseVersion(ver) && (latest.empty || compareVersions(ver, latest) > 0)) 417 latest = ver; 418 } 419 } 420 421 Info info; 422 423 if (!"public/files".exists || !"public/files".isDir) 424 return info; 425 426 import std.regex; 427 Regex!char[][string] platformPatterns; 428 platformPatterns["windows-x86"] = [ 429 regex("^dub-(?P<version>[^-]+)(?:-(?P<prerelease>.*))?(?:-setup\\.exe|-windows-x86\\.zip)$") 430 ]; 431 platformPatterns["linux-x86_64"] = [ 432 regex("^dub-(?P<version>[^-]+)(?:-(?P<prerelease>.+))?-linux-x86_64\\.tar\\.gz$"), 433 regex("^dub-(?P<version>[^-]+)-(?:0\\.(?P<prerelease>.+)|[^0].*)\\.x86_64\\.rpm$") 434 ]; 435 platformPatterns["linux-x86"] = [ 436 regex("^dub-(?P<version>[^-]+)(?:-(?P<prerelease>.+))?-linux-x86\\.tar\\.gz$"), 437 regex("^dub-(?P<version>[^-]+)-(?:0\\.(?P<prerelease>.+)|[^0].*)\\.x86\\.rpm$") 438 ]; 439 platformPatterns["linux-arm"] = [ 440 regex("^dub-(?P<version>[^-]+)(?:-(?P<prerelease>.+))?-linux-arm\\.tar\\.gz$"), 441 regex("^dub-(?P<version>[^-]+)-(?:0\\.(?P<prerelease>.+)|[^0].*)\\.arm\\.rpm$") 442 ]; 443 platformPatterns["osx-x86_64"] = [ 444 regex("^dub-(?P<version>[^-]+)(?:-(?P<prerelease>.+))?-osx-x86_64\\.tar\\.gz$"), 445 ]; 446 447 foreach(de; dirEntries("public/files", "*.*", SpanMode.shallow)) { 448 auto name = Path(de.name).head.toString(); 449 450 foreach (platform, rexes; platformPatterns) { 451 foreach (rex; rexes) { 452 auto match = match(name, rex).captures;//matchFirst(name, rex); 453 if (match.empty) continue; 454 auto ver = match["version"] ~ (match["prerelease"].length ? "-" ~ match["prerelease"] : ""); 455 if (!ver.isValidVersion()) continue; 456 info.addFile(ver, platform, name); 457 } 458 } 459 } 460 461 info.versions.sort!((a, b) => vcmp(a.id, b.id))(); 462 return info; 463 } 464 465 void getDownload() 466 { 467 auto info = downloadInfo(); 468 render!("download.dt", info); 469 } 470 471 @path("/download/LATEST") 472 void getLatest(HTTPServerResponse res) 473 { 474 auto info = downloadInfo(); 475 enforceHTTP(!info.latest.empty, HTTPStatus.notFound, "No version available."); 476 res.writeBody(info.latest); 477 } 478 479 480 @auth 481 void getMyPackages(User _user) 482 { 483 auto user = _user; 484 auto registry = m_registry; 485 render!("my_packages.dt", user, registry); 486 } 487 488 @auth @path("/register_package") 489 void getRegisterPackage(User _user, string kind = null, string owner = null, string project = null, string _error = null) 490 { 491 auto user = _user; 492 string error = _error; 493 auto registry = m_registry; 494 render!("my_packages.register.dt", user, kind, owner, project, error, registry); 495 } 496 497 @auth @path("/register_package") @errorDisplay!getRegisterPackage 498 void postRegisterPackage(string kind, string owner, string project, User _user, bool ignore_fork = false) 499 { 500 DbRepository rep; 501 rep.kind = kind; 502 rep.owner = owner; 503 rep.project = project; 504 505 if (!ignore_fork) { 506 auto info = m_registry.getRepositoryInfo(rep); 507 if (info.isFork) { 508 render!("my_packages.register.warn_fork.dt", kind, owner, project); 509 return; 510 } 511 } 512 513 m_registry.addPackage(rep, _user.id); 514 redirect("/my_packages"); 515 } 516 517 @auth @path("/my_packages/:packname") 518 void getMyPackagesPackage(string _packname, User _user, string _error = null) 519 { 520 enforceUserPackage(_user, _packname); 521 auto packageName = _packname; 522 auto nfo = m_registry.getPackageInfo(packageName); 523 if (nfo.info.type == Json.Type.null_) return; 524 auto categories = m_categories; 525 auto registry = m_registry; 526 auto user = _user; 527 auto error = _error; 528 render!("my_packages.package.dt", packageName, categories, user, registry, error); 529 } 530 531 @auth @path("/my_packages/:packname/update") 532 void postUpdatePackage(string _packname, User _user) 533 { 534 enforceUserPackage(_user, _packname); 535 m_registry.triggerPackageUpdate(_packname); 536 redirect("/my_packages/"~_packname); 537 } 538 539 @auth @path("/my_packages/:packname/remove") 540 void postShowRemovePackage(string _packname, User _user) 541 { 542 auto packageName = _packname; 543 auto user = _user; 544 enforceUserPackage(user, packageName); 545 render!("my_packages.remove.dt", packageName, user); 546 } 547 548 @auth @path("/my_packages/:packname/remove_confirm") 549 void postRemovePackage(string _packname, User _user) 550 { 551 enforceUserPackage(_user, _packname); 552 m_registry.removePackage(_packname, _user.id); 553 redirect("/my_packages"); 554 } 555 556 @auth @path("/my_packages/:packname/set_categories") 557 void postSetPackageCategories(string[] categories, string _packname, User _user) 558 { 559 enforceUserPackage(_user, _packname); 560 string[] uniquecategories; 561 outer: foreach (cat; categories) { 562 if (!cat.length) continue; 563 foreach (j, ec; uniquecategories) { 564 if (cat.startsWith(ec)) continue outer; 565 if (ec.startsWith(cat)) { 566 uniquecategories[j] = cat; 567 continue outer; 568 } 569 } 570 uniquecategories ~= cat; 571 } 572 m_registry.setPackageCategories(_packname, uniquecategories); 573 574 redirect("/my_packages/"~_packname); 575 } 576 577 @auth @path("/my_packages/:packname/set_repository") @errorDisplay!getMyPackagesPackage 578 void postSetPackageRepository(string kind, string owner, string project, string _packname, User _user) 579 { 580 enforceUserPackage(_user, _packname); 581 582 DbRepository rep; 583 rep.kind = kind; 584 rep.owner = owner; 585 rep.project = project; 586 m_registry.setPackageRepository(_packname, rep); 587 588 redirect("/my_packages/"~_packname); 589 } 590 591 @path("/docs/commandline") 592 void getCommandLineDocs() 593 { 594 import dub.commandline; 595 auto commands = getCommands(); 596 render!("docs.commandline.dt", commands); 597 } 598 599 private void enforceUserPackage(User user, string package_name) 600 { 601 enforceHTTP(m_registry.isUserPackage(user.id, package_name), HTTPStatus.forbidden, "You don't have access rights for this package."); 602 } 603 604 // Attribute for authenticated routes 605 private enum auth = before!performAuth("_user"); 606 mixin PrivateAccessProxy; 607 608 private User performAuth(HTTPServerRequest req, HTTPServerResponse res) 609 { 610 return m_usermanauth.performAuth(req, res); 611 } 612 } 613 614 final class Category { 615 string name, description, indentedDescription, imageName; 616 Category[] subCategories; 617 }