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