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.internal.utils;
10 import dubregistry.repositories.bitbucket;
11 import dubregistry.repositories.github;
12 import dubregistry.registry;
13 import dubregistry.viewutils; // dummy import to make rdmd happy
14 
15 import dub.semver;
16 import std.algorithm : sort, startsWith, splitter;
17 import std.array;
18 import std.file;
19 import std.path;
20 import std.string;
21 import userman.web;
22 import userman.db.controller : UserManController;
23 import vibe.d;
24 
25 
26 DubRegistryWebFrontend registerDubRegistryWebFrontend(URLRouter router, DubRegistry registry, UserManController userman)
27 {
28 	DubRegistryWebFrontend webfrontend;
29 	if (userman) {
30 		auto ff = new DubRegistryFullWebFrontend(registry, userman);
31 		webfrontend = ff;
32 		router.registerWebInterface(ff);
33 		router.registerUserManWebInterface(userman);
34 	} else {
35 		webfrontend = new DubRegistryWebFrontend(registry, userman);
36 		router.registerWebInterface(webfrontend);
37 	}
38 	router.get("*", serveStaticFiles("./public"));
39 	return webfrontend;
40 }
41 
42 class DubRegistryWebFrontend {
43 	protected {
44 		static struct CachedPackageSlot {
45 			static struct Version {
46 				string version_;
47 				SysTime date;
48 			}
49 
50 			BsonObjectID id;
51 			string name;
52 			DbPackageStats stats;
53 			string[] categories;
54 			Version[] versions;
55 			@property SysTime dateAdded() { return id.timeStamp; }
56 		}
57 
58 		DubRegistry m_registry;
59 		UserManController m_userman;
60 		Category[] m_categories;
61 		Category[string] m_categoryMap;
62 		CachedPackageSlot[] m_packages;
63 	}
64 
65 	this(DubRegistry registry, UserManController userman)
66 	{
67 		m_registry = registry;
68 		m_userman = userman;
69 		updateCategories();
70 		updatePackageList();
71 		setTimer(30.seconds, &updatePackageList, true);
72 	}
73 
74 	private auto searchForPackages(string sort = "updated", string category = null, ulong skip = 0, ulong limit = 20)
75 	{
76 		import std.algorithm.comparison : min;
77 		import std.algorithm.iteration : filter, map;
78 		import std.typecons : tuple;
79 
80 		static import std.algorithm.sorting;
81 		import std.algorithm.searching : any;
82 
83 		CachedPackageSlot[] packages;
84 		if (category.length) {
85 			packages = m_packages
86 				.filter!(p => p.categories.any!(c => c.startsWith(category)))
87 				.array;
88 		} else {
89 			packages = m_packages.dup;
90 		}
91 		auto pcount = packages.length;
92 
93 		// sort by date of last version
94 		SysTime getDate(in ref CachedPackageSlot p) {
95 			if (p.versions.length == 0) return SysTime(0, UTC());
96 			return p.versions[$-1].date;
97 		}
98 		bool compare(in ref CachedPackageSlot a, in ref CachedPackageSlot b) {
99 			bool a_has_ver = a.versions.any!(v => !v.version_.startsWith("~"));
100 			bool b_has_ver = b.versions.any!(v => !v.version_.startsWith("~"));
101 			if (a_has_ver != b_has_ver) return a_has_ver;
102 			return getDate(a) > getDate(b);
103 		}
104 		switch (sort) {
105 			case "name": std.algorithm.sorting.sort!((a, b) => a.name < b.name)(packages); break;
106 			case "score": std.algorithm.sorting.sort!((a, b) => a.stats.score > b.stats.score)(packages); break;
107 			case "added": std.algorithm.sorting.sort!((a, b) => a.dateAdded > b.dateAdded)(packages); break;
108 			case "updated":
109 			default: std.algorithm.sorting.sort!compare(packages); break;
110 		}
111 		// TODO: No need to use untyped Json
112 		static struct Package { DbPackageStats stats; Json _; alias _ this; }
113 
114 		// limit package list to current page
115 		packages = packages[min(skip, $) .. min(skip + limit, $)];
116 
117 		// collect package infos
118 		Json[string] infos;
119 		foreach (p; m_registry.getPackageInfos(packages.map!(p => p.name).array))
120 			infos[p.info["name"].opt!string] = p.info;
121 
122 		return tuple(packages.map!(p => Package(p.stats, infos.get(p.name, Json.init))).array, pcount);
123 
124 	}
125 
126 	void search(string sort = "updated", string category = null, ulong skip = 0, ulong limit = 20)
127 	{
128 		auto packages = searchForPackages(sort, category, skip, limit);
129 
130 		static struct Info {
131 			typeof(searchForPackages()[0]) packages;
132 			size_t packageCount;
133 			ulong skip;
134 			ulong limit;
135 			Category[] categories;
136 			Category[string] categoryMap;
137 		}
138 
139 		Info info = {
140 			packageCount: packages[1],
141 			packages: packages[0],
142 			skip: skip,
143 			limit: limit,
144 			categories: m_categories,
145 			categoryMap: m_categoryMap,
146 		};
147 
148 		render!("home.dt", info);
149 	}
150 
151 
152 	@path("/")
153 	void getHome(string sort = "updated", string category = null, ulong skip = 0, ulong limit = 20)
154 	{
155 		if (request.requestURI != "/")
156 			return search(sort, category, skip, limit);
157 
158 		limit = 5;
159 		auto topScored = searchForPackages("score", null, 0, limit)[0];
160 		auto topAdded = searchForPackages("added", null, 0, limit)[0];
161 		auto topUpdated = searchForPackages("updated", null, 0, limit)[0];
162 
163 		static struct Info {
164 			size_t packageCount;
165 			Category[] categories;
166 			Category[string] categoryMap;
167 		}
168 		Info info = {
169 			packageCount: m_packages.length,
170 			categories: m_categories,
171 			categoryMap: m_categoryMap,
172 		};
173 		render!("start.dt", topScored, topAdded, topUpdated, info);
174 	}
175 
176 	// compatibility route
177 	void getAvailable(HTTPServerRequest req, HTTPServerResponse res)
178 	{
179 		res.redirect("/packages/index.json");
180 	}
181 
182 	@path("/packages/index.json")
183 	void getPackages(HTTPServerRequest req, HTTPServerResponse res)
184 	{
185 		res.writeJsonBody(m_registry.availablePackages.array);
186 	}
187 
188 	@path("/view_package/:packname")
189 	void getRedirectViewPackage(string _packname)
190 	{
191 		redirect("/packages/"~_packname);
192 	}
193 
194 	@path("/packages/:packname")
195 	void getPackage(HTTPServerRequest req, HTTPServerResponse res, string _packname)
196 	{
197 		getPackageVersion(req, res, _packname, null);
198 	}
199 
200 	@path("/packages/:packname/logo")
201 	void getPackageLogo(HTTPServerRequest req, HTTPServerResponse res, string _packname)
202 	{
203 		bdata_t rev, logo;
204 		logo = m_registry.getPackageLogo(_packname, rev);
205 
206 		if (logo.length) {
207 			// TODO: add caching, etag (using rev), content-control, etc.
208 			res.writeBody(logo, "image/png");
209 		} else {
210 			bool acceptsSVG;
211 			// make sure requester actually supports svg, if a custom IDE or tool for fetching images is used it should only get png if it doesn't support svg.
212 			// if requester is an IDE sending Accept: */*, but not accepting SVG it's their own fault.
213 			foreach (accept; req.headers.get("Accept", "").splitter(",")) {
214 				if (accept.startsWith("image/*", "image/svg", "*/*", "*/svg")) {
215 					acceptsSVG = true;
216 					break;
217 				}
218 			}
219 			auto settings = new HTTPFileServerSettings();
220 			if (acceptsSVG)
221 				sendFile(req, res, NativePath("public/images/default-logo.svg"), settings);
222 			else
223 				sendFile(req, res, NativePath("public/images/default-logo.png"), settings);
224 		}
225 	}
226 
227 	@path("/packages/:packname/:version")
228 	void getPackageVersion(HTTPServerRequest req, HTTPServerResponse res, string _packname, string _version)
229 	{
230 		import std.algorithm : canFind;
231 
232 		auto pname = _packname;
233 		auto ver = _version.replace(" ", "+");
234 		string ext;
235 
236 		if (_version.length) {
237 			if (ver.endsWith(".zip")) ext = "zip", ver = ver[0 .. $-4];
238 			else if( ver.endsWith(".json") ) ext = "json", ver = ver[0 .. $-5];
239 		} else {
240 			if (pname.endsWith(".json")) {
241 				pname = pname[0 .. $-5];
242 				ext = "json";
243 			}
244 		}
245 
246 		PackageInfo packinfo;
247 		PackageVersionInfo verinfo;
248 		if (!getPackageInfo(pname, ver, packinfo, verinfo))
249 			return;
250 
251 		auto packageInfo = packinfo.info;
252 		auto versionInfo = verinfo.info;
253 
254 		if (ext == "zip") {
255 			if (pname.canFind(":")) return;
256 
257 			// This log line is a weird workaround to make otherwise undefined Json fields
258 			// available. Smells like a compiler bug.
259 			logDebug("%s %s", packageInfo["id"].toString(), verinfo.downloadURL);
260 
261 			// add download to statistic
262 			m_registry.addDownload(BsonObjectID.fromString(packageInfo["id"].get!string), ver, req.headers.get("User-agent", null));
263 			if (verinfo.downloadURL.length > 0) {
264 				// redirect to hosting service specific URL
265 				redirect(verinfo.downloadURL);
266 			} else {
267 				// directly forward from hoster
268 				res.headers["Content-Disposition"] = "attachment; filename=\""~pname~"-"~(ver.startsWith("~") ? ver[1 .. $] : ver) ~ ".zip\"";
269 				m_registry.downloadPackageZip(pname, ver.startsWith("~") ? ver : "v"~ver, (scope data) {
270 					res.writeBody(data, "application/zip");
271 				});
272 			}
273 		} else if (ext == "json") {
274 			if (pname.canFind(":")) return;
275 			res.writeJsonBody(_version.length ? versionInfo : packageInfo);
276 		} else {
277 			userman.db.controller.User user;
278 			if (m_userman) {
279 				try user = m_userman.getUser(User.ID.fromString(packageInfo["owner"].get!string));
280 				catch (Exception e) {
281 					logDebug("Failed to get owner '%s' for %s %s: %s",
282 						packageInfo["owner"].get!string, pname, ver, e.msg);
283 				}
284 			}
285 
286 			auto gitVer = verinfo.version_;
287 			gitVer = gitVer.startsWith("~") ? gitVer[1 .. $] : "v"~gitVer;
288 			string urlFilter(string url, bool is_image)
289 			{
290 				if (url.startsWith("http://") || url.startsWith("https://"))
291 					return url;
292 
293 				if (auto pr = "repository" in packageInfo) {
294 					auto owner = (*pr)["owner"].get!string;
295 					auto project = (*pr)["project"].get!string;
296 					switch ((*pr)["kind"].get!string) {
297 						default: return url;
298 						// TODO: BitBucket + GitLab
299 						case "github":
300 							if (is_image) return format("https://github.com/%s/%s/raw/%s/%s", owner, project, gitVer, url);
301 							else return format("https://github.com/%s/%s/blob/%s/%s", owner, project, gitVer, url.startsWith("#") ? "README.md" ~ url : url);
302 					}
303 				}
304 
305 				return url;
306 			}
307 
308 			auto packageName = pname;
309 			auto registry = m_registry;
310 			auto readmeContents = m_registry.getReadme(versionInfo, packageInfo["repository"].deserializeJson!DbRepository);
311 			//auto sampleURLs = ["test1", "test2"]; /* TODO: actually make this array exist and embed samples generated from repository */
312 			string[] sampleURLs;
313 			auto activeTab = req.query.get("tab", "info");
314 			render!("view_package.dt",
315 					packageName,
316 					user,
317 					packinfo,
318 					versionInfo,
319 					readmeContents,
320 					sampleURLs,
321 					urlFilter,
322 					registry,
323 					activeTab,
324 			);
325 		}
326 	}
327 
328 	@path("/packages/:packname/versions")
329 	void getAllPackageVersions(HTTPServerRequest req, HTTPServerResponse res, string _packname) {
330 		import std.algorithm : canFind;
331 
332 		auto pname = _packname;
333 
334 		auto ppath = _packname.urlDecode().split(":");
335 		auto packageInfo = m_registry.getPackageInfo(ppath[0]);
336 
337 		auto packageName = pname;
338 		auto registry = m_registry;
339 		render!("view_package.versions.dt",
340 			packageName,
341 			packageInfo,
342 			registry);
343 	}
344 
345 	private bool getPackageInfo(string pack_name, string pack_version, out PackageInfo pkg_info, out PackageVersionInfo ver_info) {
346 		import std.algorithm : map;
347 		auto ppath = pack_name.urlDecode().split(":");
348 
349 		if (!ppath.length || !ppath[0].length) return false;
350 
351 		pkg_info = m_registry.getPackageInfo(ppath[0]);
352 		if (pkg_info.info.type == Json.Type.null_) return false;
353 
354 		if (pack_version.length) {
355 			foreach (ref v; pkg_info.versions) {
356 				if (v.version_ == pack_version) {
357 					ver_info = v;
358 					break;
359 				}
360 			}
361 			if (ver_info.info.type != Json.Type.Object) return false;
362 		} else {
363 			import dubregistry.viewutils;
364 			if (pkg_info.versions.length == 0) return false;
365 			auto vidx = getBestVersionIndex(pkg_info.versions.map!(v => v.version_));
366 			ver_info = pkg_info.versions[vidx];
367 		}
368 
369 		foreach (i; 1 .. ppath.length) {
370 			if ("subPackages" !in ver_info.info) return false;
371 			bool found = false;
372 			foreach (sp; ver_info.info["subPackages"]) {
373 				if (sp["name"] == ppath[i]) {
374 					Json newv = Json.emptyObject;
375 					// inherit certain fields
376 					foreach (field; ["version", "date", "license", "authors", "homepage"])
377 						if (auto pv = field in ver_info.info) newv[field] = *pv;
378 					// copy/overwrite the rest frmo the sub package
379 					foreach (string name, value; sp) newv[name] = value;
380 					ver_info.info = newv;
381 					found = true;
382 					break;
383 				}
384 			}
385 			if (!found) return false;
386 		}
387 		return true;
388 	}
389 
390 	private void updateCategories()
391 	{
392 		auto catfile = openFile("categories.json");
393 		scope(exit) catfile.close();
394 		auto json = parseJsonString(catfile.readAllUTF8());
395 
396 		Category[string] catmap;
397 
398 		Category processNode(Json node, string[] path)
399 		{
400 			path ~= node["name"].get!string;
401 			auto cat = new Category;
402 			cat.name = path.join(".");
403 			cat.description = node["description"].get!string;
404 			if (path.length > 2)
405 				cat.indentedDescription = "\u00a0\u00a0\u00a0\u00a0".replicate(path.length-2) ~ "\u00a0└ " ~ cat.description;
406 			else if (path.length == 2)
407 				cat.indentedDescription = "\u00a0└ " ~ cat.description;
408 			else cat.indentedDescription = cat.description;
409 			Json icons = json["icons"];
410 			foreach_reverse (i; 0 .. path.length) {
411 				string dotPath = path[0 .. i+1].join(".");
412 				if (dotPath in icons) {
413 					cat.imageName = icons[dotPath].get!string;
414 					cat.imageDescription = path[0 .. i+1].join("/");
415 					break;
416 				}
417 			}
418 
419 			catmap[cat.name] = cat;
420 
421 			if ("categories" in node)
422 				foreach (subcat; node["categories"])
423 					cat.subCategories ~= processNode(subcat, path);
424 
425 			return cat;
426 		}
427 
428 		Category[] cats;
429 		foreach (top_level_cat; json["list"])
430 			cats ~= processNode(top_level_cat, null);
431 
432 		m_categories = cats;
433 		m_categoryMap = catmap;
434 	}
435 
436 	private void updatePackageList()
437 	{
438 		import std.algorithm.iteration : map;
439 		import core.memory : GC;
440 		() @trusted { GC.collect(); } ();
441 
442 
443 		// NOTE: all string/array data is reallocated to avoid pinning the
444 		//       underlying buffer that holds the MongoDB reply
445 		PackedStringAllocator strings;
446 		auto newpacks = appender!(CachedPackageSlot[]);
447 		newpacks.reserve(m_packages.length);
448 		foreach (p; m_registry.db.getPackageDump()) {
449 			CachedPackageSlot cp;
450 			cp.id = p._id;
451 			cp.name = strings.alloc(p.name);
452 			cp.stats = p.stats;
453 			cp.categories = p.categories.map!(c => strings.alloc(c)).array;
454 			cp.versions = p.versions
455 				.map!(v => CachedPackageSlot.Version(strings.alloc(v.version_), v.date))
456 				.array;
457 			newpacks ~= cp;
458 		}
459 		m_packages = newpacks.data;
460 	}
461 }
462 
463 class DubRegistryFullWebFrontend : DubRegistryWebFrontend {
464 	private {
465 		UserManWebAuthenticator m_usermanauth;
466 	}
467 
468 	this(DubRegistry registry, UserManController userman)
469 	{
470 		super(registry, userman);
471 		m_usermanauth = new UserManWebAuthenticator(userman);
472 	}
473 
474 	void querySearch(string q = "")
475 	{
476 		auto results = m_registry.searchPackages(q);
477 		auto queryString = q;
478 		render!("search_results.dt", queryString, results);
479 	}
480 
481 	void getGettingStarted() { redirect("https://dub.pm/getting_started"); }
482 	void getAbout() { getGettingStarted(); }
483 	void getUsage() { getGettingStarted(); }
484 	void getAdvancedUsage() { redirect("https://dub.pm/getting_started"); }
485 
486 	void getPublish() { redirect("https://dub.pm/publish"); }
487 	void getDevelop() { redirect("https://dub.pm/commandline"); }
488 
489 	@path("/package-format")
490 	void getPackageFormat(string lang = null)
491 	{
492 		switch (lang) {
493 			default: redirect("https://dub.pm/package-format-json"); break;
494 			case "json": redirect("https://dub.pm/package-format-json"); break;
495 			case "sdl": redirect("https://dub.pm/package-format-sdl"); break;
496 		}
497 	}
498 
499 	private auto downloadInfo()
500 	{
501 		static struct DownloadFile {
502 			string fileName;
503 			string platformCaption;
504 			string typeCaption;
505 		}
506 
507 		static struct DownloadVersion {
508 			string id;
509 			DownloadFile[][string] files;
510 		}
511 
512 		static struct Info {
513 			DownloadVersion[] versions;
514 			string latest = "";
515 
516 			void addFile(string ver, string platform, string filename)
517 			{
518 
519 				auto df = DownloadFile(filename);
520 				switch (platform) {
521 					default:
522 						auto pts = platform.split("-");
523 						df.platformCaption = format("%s%s (%s)", pts[0][0 .. 1].toUpper(), pts[0][1 .. $], pts[1].replace("_", "-").toUpper());
524 						break;
525 					case "osx-x86": df.platformCaption = "OS X (X86)"; break;
526 					case "osx-x86_64": df.platformCaption = "OS X (X86-64)"; break;
527 				}
528 
529 				if (filename.endsWith(".tar.gz")) df.typeCaption = "binary tarball";
530 				else if (filename.endsWith(".zip")) df.typeCaption = "zipped binaries";
531 				else if (filename.endsWith(".rpm")) df.typeCaption = "binary RPM package";
532 				else if (filename.endsWith("setup.exe")) df.typeCaption = "installer";
533 				else df.typeCaption = "Unknown";
534 
535 				foreach(ref v; versions)
536 					if( v.id == ver ){
537 						v.files[platform] ~= df;
538 						return;
539 					}
540 				DownloadVersion dv = DownloadVersion(ver);
541 				dv.files[platform] = [df];
542 				versions ~= dv;
543 				if (!isPreReleaseVersion(ver) && (latest.empty || compareVersions(ver, latest) > 0))
544 					latest = ver;
545 			}
546 		}
547 
548 		Info info;
549 
550 		if (!"public/files".exists || !"public/files".isDir)
551 			return info;
552 
553 		import std.regex;
554 		Regex!char[][string] platformPatterns;
555 		platformPatterns["windows-x86"] = [
556 			regex("^dub-(?P<version>[^-]+)(?:-(?P<prerelease>.*))?(?:-setup\\.exe|-windows-x86\\.zip)$")
557 		];
558 		platformPatterns["linux-x86_64"] = [
559 			regex("^dub-(?P<version>[^-]+)(?:-(?P<prerelease>.+))?-linux-x86_64\\.tar\\.gz$"),
560 			regex("^dub-(?P<version>[^-]+)-(?:0\\.(?P<prerelease>.+)|[^0].*)\\.x86_64\\.rpm$")
561 		];
562 		platformPatterns["linux-x86"] = [
563 			regex("^dub-(?P<version>[^-]+)(?:-(?P<prerelease>.+))?-linux-x86\\.tar\\.gz$"),
564 			regex("^dub-(?P<version>[^-]+)-(?:0\\.(?P<prerelease>.+)|[^0].*)\\.x86\\.rpm$")
565 		];
566 		platformPatterns["linux-arm"] = [
567 			regex("^dub-(?P<version>[^-]+)(?:-(?P<prerelease>.+))?-linux-arm\\.tar\\.gz$"),
568 			regex("^dub-(?P<version>[^-]+)-(?:0\\.(?P<prerelease>.+)|[^0].*)\\.arm\\.rpm$")
569 		];
570 		platformPatterns["osx-x86_64"] = [
571 			regex("^dub-(?P<version>[^-]+)(?:-(?P<prerelease>.+))?-osx-x86_64\\.tar\\.gz$"),
572 		];
573 
574 		foreach(de; dirEntries("public/files", "*.*", SpanMode.shallow)) {
575 			auto name = NativePath(de.name).head.name;
576 
577 			foreach (platform, rexes; platformPatterns) {
578 				foreach (rex; rexes) {
579 					auto match = match(name, rex).captures;//matchFirst(name, rex);
580 					if (match.empty) continue;
581 					auto ver = match["version"] ~ (match["prerelease"].length ? "-" ~ match["prerelease"] : "");
582 					if (!ver.isValidVersion()) continue;
583 					info.addFile(ver, platform, name);
584 				}
585 			}
586 		}
587 
588 		info.versions.sort!((a, b) => vcmp(a.id, b.id))();
589 		return info;
590 	}
591 
592 	void getDownload()
593 	{
594 		redirect("https://github.com/dlang/dub/releases");
595 	}
596 
597 	@path("/download/LATEST")
598 	void getLatest(HTTPServerResponse res)
599 	{
600 		auto info = downloadInfo();
601 		enforceHTTP(!info.latest.empty, HTTPStatus.notFound, "No version available.");
602 		res.writeBody(info.latest);
603 	}
604 
605 
606 	@auth
607 	void getMyPackages(User _user)
608 	{
609 		auto user = _user;
610 		auto registry = m_registry;
611 		render!("my_packages.dt", user, registry);
612 	}
613 
614 	@auth @path("/register_package")
615 	void getRegisterPackage(User _user, string url = null, string _error = null)
616 	{
617 		auto user = _user;
618 		string error = _error;
619 		auto registry = m_registry;
620 		render!("my_packages.register.dt", user, url, error, registry);
621 	}
622 
623 	@auth @path("/register_package") @errorDisplay!getRegisterPackage
624 	void postRegisterPackage(string url, User _user, bool ignore_fork = false)
625 	{
626 		import std.algorithm.searching : canFind;
627 		DbRepository rep;
628 		if (!url.canFind("://"))
629 			url = "https://" ~ url;
630 		rep.parseURL(URL.fromString(url));
631 
632 		string kind = rep.kind;
633 		string owner = rep.owner;
634 		string project = rep.project;
635 
636 		if (!ignore_fork) {
637 			auto info = m_registry.getRepositoryInfo(rep);
638 			if (info.isFork) {
639 				render!("my_packages.register.warn_fork.dt", url);
640 				return;
641 			}
642 		}
643 
644 		m_registry.addPackage(rep, _user.id);
645 		redirect("/my_packages");
646 	}
647 
648 	@auth @path("/my_packages/:packname")
649 	void getMyPackagesPackage(string _packname, User _user, string _error = null)
650 	{
651 		enforceUserPackage(_user, _packname);
652 		auto packageName = _packname;
653 		auto nfo = m_registry.getPackageInfo(packageName, PackageInfoFlags.includeErrors);
654 		if (nfo.info.type == Json.Type.null_) return;
655 		auto categories = m_categories;
656 		auto registry = m_registry;
657 		auto user = _user;
658 		auto error = _error;
659 		render!("my_packages.package.dt", packageName, categories, user, registry, error);
660 	}
661 
662 	@auth @path("/my_packages/:packname/update")
663 	void postUpdatePackage(string _packname, User _user)
664 	{
665 		enforceUserPackage(_user, _packname);
666 		m_registry.triggerPackageUpdate(_packname);
667 		redirect("/my_packages/"~_packname);
668 	}
669 
670 	@auth @path("/my_packages/:packname/remove")
671 	void postShowRemovePackage(string _packname, User _user)
672 	{
673 		auto packageName = _packname;
674 		auto user = _user;
675 		enforceUserPackage(user, packageName);
676 		render!("my_packages.remove.dt", packageName, user);
677 	}
678 
679 	@auth @path("/my_packages/:packname/remove_confirm")
680 	void postRemovePackage(string _packname, User _user)
681 	{
682 		enforceUserPackage(_user, _packname);
683 		m_registry.removePackage(_packname, _user.id);
684 		redirect("/my_packages");
685 	}
686 
687 	@auth @path("/my_packages/:packname/set_categories")
688 	void postSetPackageCategories(string[] categories, string _packname, User _user)
689 	{
690 		enforceUserPackage(_user, _packname);
691 		string[] uniquecategories;
692 		outer: foreach (cat; categories) {
693 			if (!cat.length) continue;
694 			foreach (j, ec; uniquecategories) {
695 				if (cat.startsWith(ec)) continue outer;
696 				if (ec.startsWith(cat)) {
697 					uniquecategories[j] = cat;
698 					continue outer;
699 				}
700 			}
701 			uniquecategories ~= cat;
702 		}
703 		m_registry.setPackageCategories(_packname, uniquecategories);
704 
705 		redirect("/my_packages/"~_packname~"#categories");
706 	}
707 
708 	@auth @path("/my_packages/:packname/set_repository") @errorDisplay!getMyPackagesPackage
709 	void postSetPackageRepository(string kind, string owner, string project, string _packname, User _user)
710 	{
711 		enforceUserPackage(_user, _packname);
712 
713 		DbRepository rep;
714 		rep.kind = kind;
715 		rep.owner = owner;
716 		rep.project = project;
717 		m_registry.setPackageRepository(_packname, rep);
718 
719 		redirect("/my_packages/"~_packname~"#repository");
720 	}
721 
722 	@auth @path("/my_packages/:packname/set_logo") @errorDisplay!getMyPackagesPackage
723 	void postSetLogo(scope HTTPServerRequest request, string _packname, User _user)
724 	{
725 		enforceUserPackage(_user, _packname);
726 		const FilePart logo = request.files.get("logo");
727 		enforceBadRequest(logo != FilePart.init);
728 		auto info = getFileInfo(logo.tempPath);
729 		enforceBadRequest(info.size < 1024 * 1024, "Logo too big, at most 1 MB");
730 		auto renamed = NativePath(logo.tempPath.toString ~ logo.filename.name.extension);
731 		moveFile(logo.tempPath, renamed, true);
732 		scope (exit)
733 			removeFile(renamed);
734 		m_registry.setPackageLogo(_packname, renamed);
735 
736 		redirect("/my_packages/"~_packname);
737 	}
738 
739 	@auth @path("/my_packages/:packname/delete_logo")
740 	void postDeleteLogo(scope HTTPServerRequest request, string _packname, User _user)
741 	{
742 		enforceUserPackage(_user, _packname);
743 		m_registry.unsetPackageLogo(_packname);
744 
745 		redirect("/my_packages/"~_packname);
746 	}
747 
748 	@auth @path("/my_packages/:packname/set_documentation_url") @errorDisplay!getMyPackagesPackage
749 	void postSetDocumentationURL(scope HTTPServerRequest request, string documentation_url, string _packname, User _user)
750 	{
751 		enforceUserPackage(_user, _packname);
752 		auto isValidURL = documentation_url.empty || documentation_url.startsWith("http://", "https://");
753 		enforceBadRequest(isValidURL, "URL is neither null nor starts with http:// or https://");
754 		m_registry.setDocumentationURL(_packname, documentation_url);
755 		redirect("/my_packages/"~_packname);
756 	}
757 
758 	@path("/docs/commandline")
759 	void getCommandLineDocs()
760 	{
761 		redirect("https://dub.pm/commandline");
762 	}
763 
764 	private void enforceUserPackage(User user, string package_name)
765 	{
766 		enforceHTTP(m_registry.isUserPackage(user.id, package_name), HTTPStatus.forbidden, "You don't have access rights for this package.");
767 	}
768 
769 	// Attribute for authenticated routes
770 	private enum auth = before!performAuth("_user");
771 	mixin PrivateAccessProxy;
772 
773 	private User performAuth(HTTPServerRequest req, HTTPServerResponse res)
774 	{
775 		return m_usermanauth.performAuth(req, res);
776 	}
777 }
778 
779 final class Category {
780 	string name, description, indentedDescription, imageName, imageDescription;
781 	Category[] subCategories;
782 }