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 }