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.internal.utils;
11 import dubregistry.internal.workqueue;
12 import dubregistry.repositories.repository;
13 
14 import dub.semver;
15 import dub.package_ : packageInfoFilenames;
16 import std.algorithm : canFind, countUntil, filter, map, sort, swap, equal, startsWith, endsWith;
17 import std.array;
18 import std.conv;
19 import std.datetime : Clock, UTC, hours, SysTime;
20 import std.digest : toHexString;
21 import std.encoding : sanitize;
22 import std.exception : enforce;
23 import std.range : chain, walkLength;
24 import std.path : extension;
25 import std.string : format, toLower, representation;
26 import std.uni : sicmp;
27 import std.typecons;
28 import std.uni : asUpperCase;
29 import userman.db.controller;
30 import vibe.core.core;
31 import vibe.core.log;
32 import vibe.data.bson;
33 import vibe.data.json;
34 import vibe.stream.operations;
35 
36 
37 /// Settings to configure the package registry.
38 class DubRegistrySettings {
39 	string databaseName = "vpmreg";
40 }
41 
42 class DubRegistry {
43 @safe:
44 
45 	private {
46 		DubRegistrySettings m_settings;
47 		DbController m_db;
48 
49 		// list of package names to check for updates
50 		PackageWorkQueue m_updateQueue;
51 		// list of packages whose statistics need to be updated
52 		PackageWorkQueue m_updateStatsQueue;
53 		DbStatDistributions m_statDistributions;
54 	}
55 
56 	this(DubRegistrySettings settings)
57 	{
58 		m_settings = settings;
59 		m_db = new DbController(settings.databaseName);
60 
61 		// recompute scores on startup to pick up any algorithm changes
62 		m_statDistributions = m_db.getStatDistributions();
63 		recomputeScores(m_statDistributions);
64 
65 		m_updateQueue = new PackageWorkQueue(&updatePackage);
66 		m_updateStatsQueue = new PackageWorkQueue((p) { updatePackageStats(p); });
67 	}
68 
69 	@property DbController db() nothrow { return m_db; }
70 
71 	@property auto availablePackages() { return m_db.getAllPackages(); }
72 	@property auto availablePackageIDs() { return m_db.getAllPackageIDs(); }
73 
74 	auto getPackageDump()
75 	{
76 		return m_db.getPackageDump();
77 	}
78 
79 	void triggerPackageUpdate(string pack_name)
80 	{
81 		m_updateQueue.putFront(pack_name);
82 	}
83 
84 	bool isPackageScheduledForUpdate(string pack_name)
85 	{
86 		return m_updateQueue.isPending(pack_name);
87 	}
88 
89 	/** Returns the current index of a given package in the update queue.
90 
91 		An index of zero indicates that the package is currently being updated.
92 		A negative index is returned when the package is not in the update
93 		queue.
94 	*/
95 	sizediff_t getUpdateQueuePosition(string pack_name)
96 	{
97 		return m_updateQueue.getPosition(pack_name);
98 	}
99 
100 	auto searchPackages(string query)
101 	{
102 		static struct Info { string name; DbPackageStats stats; DbPackageVersion _base; alias _base this; }
103 		return m_db.searchPackages(query).filter!(p => p.versions.length > 0).map!(p =>
104 			Info(p.name, p.stats, m_db.getVersionInfo(p.name, p.versions[$ - 1].version_)));
105 	}
106 
107 	RepositoryInfo getRepositoryInfo(DbRepository repository)
108 	{
109 		auto rep = getRepository(repository);
110 		return rep.getInfo();
111 	}
112 
113 	void addPackage(DbRepository repository, User.ID user)
114 	{
115 		auto pack_name = validateRepository(repository);
116 
117 		DbPackage pack;
118 		pack.owner = user.bsonObjectIDValue;
119 		pack.name = pack_name;
120 		pack.repository = repository;
121 		m_db.addPackage(pack);
122 
123 		triggerPackageUpdate(pack.name);
124 	}
125 
126 	void addOrSetPackage(DbPackage pack)
127 	{
128 		m_db.addOrSetPackage(pack);
129 	}
130 
131 	void addDownload(BsonObjectID pack_id, string ver, string agent)
132 	{
133 		m_db.addDownload(pack_id, ver, agent);
134 	}
135 
136 	void removePackage(string packname, User.ID user)
137 	{
138 		logInfo("Package %s: removing package owned by %s", packname, user);
139 		m_db.removePackage(packname, user.bsonObjectIDValue);
140 	}
141 
142 	auto getPackages(User.ID user)
143 	{
144 		return m_db.getUserPackages(user.bsonObjectIDValue);
145 	}
146 
147 	bool isUserPackage(User.ID user, string package_name)
148 	{
149 		return m_db.isUserPackage(user.bsonObjectIDValue, package_name);
150 	}
151 
152 	/// get stats (including downloads of all version) for a package
153 	DbPackageStats getPackageStats(string packname)
154 	{
155 		auto cached = m_db.getPackageStats(packname);
156 		if (cached.updatedAt > Clock.currTime(UTC()) - 24.hours)
157 			return cached;
158 		return updatePackageStats(packname);
159 	}
160 
161 	private DbPackageStats updatePackageStats(string packname)
162 	{
163 		logDiagnostic("Updating stats for %s", packname);
164 
165 		DbPackageStats stats;
166 		DbPackage pack = m_db.getPackage(packname);
167 		stats.downloads = m_db.aggregateDownloadStats(pack._id);
168 
169 		try {
170 			stats.repo = getRepositoryInfo(pack.repository).stats;
171 		} catch (FileNotFoundException e) {
172 			// repo no longer exists, rate it down to zero (#221)
173 			logInfo("Zero scoring %s because the repo no longer exists.", packname);
174 			stats.score = 0;
175 		} catch (Exception e) {
176 			logWarn("Failed to get repository info for %s: %s", packname, e.msg);
177 			return typeof(return).init;
178 		}
179 
180 		if (auto pStatDist = pack.repository.kind in m_statDistributions.repos)
181 			stats.score = computeScore(stats, m_statDistributions.downloads, *pStatDist);
182 		else
183 			logError("Missing stat distribution for %s repositories.", pack.repository.kind);
184 
185 		m_db.updatePackageStats(pack._id, stats);
186 		return stats;
187 	}
188 
189 	/// get downloads for a package version
190 	DbDownloadStats getDownloadStats(string packname, string ver)
191 	{
192 		auto packid = m_db.getPackageID(packname);
193 		if (ver == "latest") ver = getLatestVersion(packname);
194 		enforce!RecordNotFound(m_db.hasVersion(packname, ver), "Unknown version for package.");
195 		return m_db.aggregateDownloadStats(packid, ver);
196 	}
197 
198 	Json getPackageVersionInfo(string packname, string ver, PackageInfoFlags flags)
199 	{
200 		if (ver == "latest") ver = getLatestVersion(packname);
201 		if (!m_db.hasVersion(packname, ver)) return Json(null);
202 		auto ret = m_db.getVersionInfo(packname, ver).serializeToJson();
203 		if (flags & PackageInfoFlags.minimize) ret.remove("readme");
204 		return ret;
205 	}
206 
207 	string getLatestVersion(string packname)
208 	{
209 		return m_db.getLatestVersion(packname);
210 	}
211 
212 	PackageInfo getPackageInfo(string packname, PackageInfoFlags flags = PackageInfoFlags.none)
213 	{
214 		DbPackage pack;
215 		try pack = m_db.getPackage(packname);
216 		catch(Exception) return PackageInfo.init;
217 
218 		return getPackageInfo(pack, flags);
219 	}
220 
221 	/** Gets information about multiple packages at once.
222 
223 		The order and count of packages returned may not correspond to the list
224 		of supplied package names. Only those packages that actually reference
225 		an existing package will yield a result element.
226 
227 		The consequence is that the caller must manually match the result to
228 		the supplied package names.
229 
230 		This function requires only a single query to the database.
231 
232 		Returns:
233 			An unordered input range of `PackageInfo` values is returned,
234 			corresponding to all or part of the packages of the given names.
235 	*/
236 	auto getPackageInfos(scope string[] pack_names, PackageInfoFlags flags = PackageInfoFlags.none)
237 	{
238 		return m_db.getPackages(pack_names)
239 			.map!(pack => getPackageInfo(pack, flags));
240 	}
241 
242 	auto getPackageInfo(DbPackage pack, PackageInfoFlags flags = PackageInfoFlags.none)
243 	{
244 		auto rep = getRepository(pack.repository);
245 
246 		PackageInfo ret;
247 		ret.versions = pack.versions.map!(v => getPackageVersionInfo(v, rep, flags)).array;
248 		ret.logo = pack.logo;
249 
250 		Json nfo = Json.emptyObject;
251 		nfo["versions"] = Json(ret.versions.map!(v => v.info).array);
252 		if (!(flags & PackageInfoFlags.minimize))
253 		{
254 			nfo["name"] = pack.name;
255 			nfo["id"] = pack._id.toString();
256 			nfo["dateAdded"] = pack._id.timeStamp.toISOExtString();
257 			nfo["owner"] = pack.owner.toString();
258 			nfo["repository"] = serializeToJson(pack.repository);
259 			nfo["categories"] = serializeToJson(pack.categories);
260 			nfo["documentationURL"] = pack.documentationURL;
261 		}
262 		if (flags & PackageInfoFlags.includeErrors)
263 			nfo["errors"] = serializeToJson(pack.errors);
264 
265 		ret.info = nfo;
266 
267 		return ret;
268 	}
269 
270 	private PackageVersionInfo getPackageVersionInfo(DbPackageVersion v, Repository rep, PackageInfoFlags flags)
271 	{
272 		// JSON package version info as reported to the client
273 		Json[string] nfo;
274 		if (flags & PackageInfoFlags.minimize)
275 		{
276 			// only keep information relevant for dependency resolution
277 			static Json[string] keepDeps(in Json info)
278 			{
279 				Json[string] ret;
280 				ret["name"] = info["name"];
281 				ret["dependencies"] = info["dependencies"];
282 				auto cfgs = info["configurations"].opt!(Json[])
283 					.map!(cfg => Json(["name": cfg["name"], "dependencies": cfg["dependencies"]]));
284 				if (!cfgs.empty)
285 					ret["configurations"] = cfgs.array.Json;
286 				return ret;
287 			}
288 			nfo = keepDeps(v.info);
289 			auto subpkgs = v.info["subPackages"].opt!(Json[]).map!(subpkg => Json(keepDeps(subpkg)));
290 			if (!subpkgs.empty)
291 				nfo["subPackages"] = subpkgs.array.Json;
292 		}
293 		else
294 		{
295 			nfo = v.info.get!(Json[string]).dup;
296 			nfo["date"] = v.date.toISOExtString();
297 			nfo["readme"] = v.readme;
298 			nfo["commitID"] = v.commitID;
299 		}
300 		nfo["version"] = v.version_;
301 
302 		PackageVersionInfo ret;
303 		ret.info = Json(nfo);
304 		ret.date = v.date;
305 		ret.sha = v.commitID;
306 		ret.version_ = v.version_;
307 		ret.downloadURL = rep.getDownloadUrl(v.version_.startsWith("~") ? v.version_ : "v"~v.version_);
308 		return ret;
309 	}
310 
311 	PackageInfo[string] getPackageInfosRecursive(string[] packnames, PackageInfoFlags flags)
312 	{
313 		import dub.recipe.packagerecipe : getBasePackageName;
314 
315 		PackageInfo[string] infos;
316 		void[0][string] visited;
317 		foreach (packname; packnames)
318 		{
319 			logDebug("getPackageInfosRecursive: %s", packname);
320 			getPackageInfosRecursive(packname, flags, infos, visited);
321 		}
322 		logDebug("getPackageInfosRecursive for %s returned %s", packnames, infos.byKey);
323 		return infos;
324 	}
325 
326 	private void getPackageInfosRecursive(string packname, PackageInfoFlags flags, ref PackageInfo[string] infos, ref void[0][string] visited)
327 	{
328 		import dub.recipe.packagerecipe : getBasePackageName, getSubPackageName;
329 		import std.range : dropOne;
330 		import std.algorithm.searching : find;
331 
332 		if (packname in visited)
333 			return;
334 		visited[packname] = typeof(visited[packname]).init;
335 
336 		auto basepkg = getBasePackageName(packname);
337 		auto p = basepkg in infos;
338 		if (p is null)
339 			p = &(infos[basepkg] = getPackageInfo(basepkg, flags));
340 
341 		if (!(flags & PackageInfoFlags.includeDependencies))
342 			return;
343 
344 		void addDeps(Json info)
345 		{
346 			foreach (dep, _; info["dependencies"].opt!(Json[string]))
347 				getPackageInfosRecursive(dep, flags, infos, visited);
348 			foreach (cfg; info["configurations"].opt!(Json[]))
349 				foreach (dep, _; cfg["dependencies"].opt!(Json[string]))
350 					getPackageInfosRecursive(dep, flags, infos, visited);
351 		}
352 
353 	Louter: foreach (v; p.versions)
354 		{
355 			import std.algorithm.iteration : splitter;
356 			auto info = v.info;
357 			foreach (subpkg; packname.splitter(":").dropOne)
358 			{
359 				auto sp = info["subPackages"].opt!(Json[]).find!(sp => sp["name"] == subpkg);
360 				if (sp.empty)
361 					continue Louter;
362 				info = sp.front;
363 			}
364 			addDeps(info);
365 		}
366 	}
367 
368 	string getReadme(Json version_info, DbRepository repository)
369 	{
370 		auto readme = version_info["readme"].opt!string;
371 
372 		// compat migration, read file from repo if README hasn't yet been stored in the db
373 		if (readme.length && readme.length < 256 && readme[0] == '/') {
374 			try {
375 				auto rep = getRepository(repository);
376 				logDebug("reading readme file for %s: %s", version_info["name"].get!string, readme);
377 				rep.readFile(version_info["commitID"].get!string, InetPath(readme), (scope data) {
378 					readme = data.readAllUTF8();
379 				});
380 			} catch (Exception e) {
381 				logDiagnostic("Failed to read README file (%s) for %s %s: %s",
382 					readme, version_info["name"].get!string,
383 					version_info["version"].get!string, e.msg);
384 			}
385 		}
386 		return readme;
387 	}
388 
389 	void downloadPackageZip(string packname, string vers, void delegate(scope InputStream) @safe del)
390 	{
391 		DbPackage pack = m_db.getPackage(packname);
392 		auto rep = getRepository(pack.repository);
393 		rep.download(vers, del);
394 	}
395 
396 	void setPackageCategories(string pack_name, string[] categories)
397 	{
398 		m_db.setPackageCategories(pack_name, categories);
399 	}
400 
401 	void setPackageRepository(string pack_name, DbRepository repository)
402 	{
403 		auto new_name = validateRepository(repository);
404 		enforce(pack_name == new_name, "The package name of the new repository doesn't match the existing one: "~new_name);
405 		m_db.setPackageRepository(pack_name, repository);
406 	}
407 
408 	void setPackageLogo(string pack_name, NativePath path)
409 	{
410 		auto png = generateLogo(path);
411 		if (png.length)
412 			m_db.setPackageLogo(pack_name, png);
413 		else
414 			throw new Exception("Failed to generate logo");
415 	}
416 
417 	void unsetPackageLogo(string pack_name)
418 	{
419 		m_db.setPackageLogo(pack_name, null);
420 	}
421 
422 	void setDocumentationURL(string pack_name, string documentationURL)
423 	{
424 		m_db.setDocumentationURL(pack_name, documentationURL);
425 	}
426 
427 	bdata_t getPackageLogo(string pack_name, out bdata_t rev)
428 	{
429 		return m_db.getPackageLogo(pack_name, rev);
430 	}
431 
432 	void updatePackages()
433 	{
434 		import std.random : randomShuffle;
435 
436 		logDiagnostic("Triggering package update...");
437 		// update stat distributions before score packages
438 		m_statDistributions = m_db.getStatDistributions();
439 
440 		// shuffle the package list to ensure a uniform probability of each
441 		// package getting chosen over time, regardless of how long it takes
442 		// to process the whole list and whether the process gets restarted
443 		// in-between
444 		auto allpacks = this.availablePackages.array;
445 		randomShuffle(allpacks);
446 		foreach (packname; allpacks)
447 			if (!m_updateQueue.isPending(packname))
448 				triggerPackageUpdate(packname);
449 	}
450 
451 	protected string validateRepository(DbRepository repository)
452 	{
453 		// find the packge info of ~master or any available branch
454 		PackageVersionInfo info;
455 		auto rep = getRepository(repository);
456 		auto branches = rep.getBranches();
457 		enforce(branches.length > 0, "The repository contains no branches.");
458 		auto idx = branches.countUntil!(b => b.name == "master");
459 		if (idx > 0) swap(branches[0], branches[idx]);
460 		string branch_errors;
461 		foreach (b; branches) {
462 			try {
463 				info = rep.getVersionInfo(b, null);
464 				enforce (info.info.type == Json.Type.object,
465 					"JSON package description must be a JSON object.");
466 				break;
467 			} catch (Exception e) {
468 				logDiagnostic("Error getting package info for %s", b);
469 				branch_errors ~= format("\n%s: %s", b.name, e.msg);
470 			}
471 		}
472 		enforce (info.info.type == Json.Type.object,
473 			"Failed to find a branch containing a valid package description file:" ~ branch_errors);
474 
475 		// derive package name and perform various sanity checks
476 		auto name = info.info["name"].get!string;
477 		string package_desc_file = info.info["packageDescriptionFile"].get!string;
478 		string package_check_string = format(`Check your %s.`, package_desc_file);
479 		enforce(name.length <= 60,
480 			"Package names must not be longer than 60 characters: \""~name[0 .. 60]~"...\" - "~package_check_string);
481 		enforce(name == name.toLower(),
482 			"Package names must be all lower case, not \""~name~"\". "~package_check_string);
483 		enforce(info.info["license"].opt!string.length > 0,
484 			`A "license" field in the package description file is missing or empty. `~package_check_string);
485 		enforce(info.info["description"].opt!string.length > 0,
486 			`A "description" field in the package description file is missing or empty. `~package_check_string);
487 		checkPackageName(name, format(`Check the "name" field of your %s.`, package_desc_file));
488 		foreach (string n, vspec; info.info["dependencies"].opt!(Json[string])) {
489 			auto parts = n.split(":").array;
490 			// allow shortcut syntax ":subpack"
491 			if (parts.length > 1 && parts[0].length == 0) parts = parts[1 .. $];
492 			// verify all other parts of the package name
493 			foreach (p; parts)
494 				checkPackageName(p, format(`Check the "dependencies" field of your %s.`, package_desc_file));
495 		}
496 
497 		// ensure that at least one tagged version is present
498 		auto tags = rep.getTags();
499 		enforce(tags.canFind!(t => t.name.startsWith("v") && t.name[1 .. $].isValidVersion),
500 			`The repository must have at least one tagged version (SemVer format with a "v" prefix, e.g. `
501 			~ `"v1.0.0" or "v0.0.1") to be published on the registry. Please add a proper tag using `
502 			~ `"git tag" or equivalent means and see http://semver.org for more information.`);
503 
504 		return name;
505 	}
506 
507 	protected bool addVersion(in ref DbPackage dbpack, string ver, Repository rep, RefInfo reference)
508 	{
509 		logDiagnostic("Adding new version info %s for %s", ver, dbpack.name);
510 		assert(ver.startsWith("~") && !ver.startsWith("~~") || isValidVersion(ver));
511 
512 		string deffile;
513 		foreach (t; dbpack.versions)
514 			if (t.version_ == ver) {
515 				deffile = t.info["packageDescriptionFile"].opt!string;
516 				break;
517 			}
518 		auto info = getVersionInfo(rep, reference, deffile);
519 
520 		//assert(info.info.name == info.info.name.get!string.toLower(), "Package names must be all lower case.");
521 		info.info["name"] = info.info["name"].get!string.toLower();
522 		enforce(info.info["name"] == dbpack.name,
523 			format("Package name (%s) does not match the original package name (%s). Check %s.",
524 				info.info["name"].get!string, dbpack.name, info.info["packageDescriptionFile"].get!string));
525 
526 		foreach( string n, vspec; info.info["dependencies"].opt!(Json[string]) )
527 			foreach (p; n.split(":"))
528 				checkPackageName(p, "Check "~info.info["packageDescriptionFile"].get!string~".");
529 
530 		DbPackageVersion dbver;
531 		dbver.date = info.date;
532 		dbver.version_ = ver;
533 		dbver.commitID = info.sha;
534 		dbver.info = info.info;
535 
536 		try {
537 			auto files = rep.listFiles(reference.sha, InetPath("/"));
538 			// check exactly for readme.me
539 			ptrdiff_t readme;
540 			readme = files.countUntil!(a => a.type == RepositoryFile.Type.file && a.path.head.name.asUpperCase.equal("README.MD"));
541 			if (readme == -1) {
542 				// check exactly for readme
543 				readme = files.countUntil!(a => a.type == RepositoryFile.Type.file && a.path.head.name.asUpperCase.equal("README"));
544 			}
545 			if (readme == -1) {
546 				// check for all other readmes such as README.txt, README.jp.md, etc.
547 				readme = files.countUntil!(a => a.type == RepositoryFile.Type.file && a.path.head.name.asUpperCase.startsWith("README"));
548 			}
549 
550 			if (readme != -1) {
551 				rep.readFile(reference.sha, files[readme].path, (scope input) {
552 					dbver.readme = input.readAllUTF8();
553 					string ext = files[readme].path.head.name.extension;
554 					// endsWith doesn't like to work with asLowerCase
555 					dbver.readmeMarkdown = ext.sicmp(".md") == 0;
556 				});
557 			} else logDiagnostic("No README.md found for %s %s", dbpack.name, ver);
558 
559 			// TODO: load in example(s), sample(s), test(s) and docs for the view package page here.
560 			// possibly also parsing the README.md file for a documentation link
561 		} catch (Exception e) { logDiagnostic("Failed to read README.md for %s %s: %s", dbpack.name, ver, e.msg); }
562 
563 		if (m_db.hasVersion(dbpack.name, ver)) {
564 			logDebug("Updating existing version info.");
565 			m_db.updateVersion(dbpack.name, dbver);
566 			return false;
567 		}
568 
569 		if ("description" !in info.info || "license" !in info.info) {
570 			throw new Exception(
571 			"Published packages must contain \"description\" and \"license\" fields.");
572 		}
573 		//enforce(!m_db.hasVersion(packname, dbver.version_), "Version already exists.");
574 		if (auto pv = "version" in info.info)
575 			enforce(pv.get!string == ver, format("Package description contains an obsolete \"version\" field and does not match tag %s: %s", ver, pv.get!string));
576 		logDebug("Adding new version info.");
577 		m_db.addVersion(dbpack.name, dbver);
578 		return true;
579 	}
580 
581 	protected void removeVersion(string packname, string ver)
582 	{
583 		assert(ver.startsWith("~") && !ver.startsWith("~~") || isValidVersion(ver));
584 
585 		m_db.removeVersion(packname, ver);
586 	}
587 
588 	private void updatePackage(string packname)
589 	{
590 		import std.encoding;
591 		string[] errors;
592 
593 		DbPackage pack;
594 		try pack = m_db.getPackage(packname);
595 		catch( Exception e ){
596 			errors ~= format("Error getting package info: %s", e.msg);
597 			() @trusted { logDebug("%s", sanitize(e.toString())); } ();
598 			return;
599 		}
600 
601 		Repository rep;
602 		try rep = getRepository(pack.repository);
603 		catch( Exception e ){
604 			errors ~= format("Error accessing repository: %s", e.msg);
605 			() @trusted { logDebug("%s", sanitize(e.toString())); } ();
606 			return;
607 		}
608 
609 		bool[string] existing;
610 		RefInfo[] tags, branches;
611 		bool got_all_tags_and_branches = false;
612 		try {
613 			tags = rep.getTags()
614 				.filter!(a => a.name.startsWith("v") && a.name[1 .. $].isValidVersion)
615 				.array
616 				.sort!((a, b) => compareVersions(a.name[1 .. $], b.name[1 .. $]) < 0)
617 				.array;
618 			branches = rep.getBranches();
619 			got_all_tags_and_branches = true;
620 		} catch (Exception e) {
621 			errors ~= format("Failed to get GIT tags/branches: %s", e.msg);
622 		}
623 		logDiagnostic("Updating tags for %s: %s", packname, tags.map!(t => t.name).array);
624 		foreach (tag; tags) {
625 			auto name = tag.name[1 .. $];
626 			existing[name] = true;
627 			try {
628 				if (addVersion(pack, name, rep, tag))
629 					logInfo("Package %s: added version %s", packname, name);
630 			} catch( Exception e ){
631 				logDiagnostic("Error for version %s of %s: %s", name, packname, e.msg);
632 				() @trusted  { logDebug("Full error: %s", sanitize(e.toString())); } ();
633 				errors ~= format("Version %s: %s", name, e.msg);
634 			}
635 		}
636 		logDiagnostic("Updating branches for %s: %s", packname, branches.map!(t => t.name).array);
637 		foreach (branch; branches) {
638 			auto name = "~" ~ branch.name;
639 			existing[name] = true;
640 			try {
641 				if (addVersion(pack, name, rep, branch))
642 					logInfo("Package %s: added branch %s", packname, name);
643 			} catch( Exception e ){
644 				logDiagnostic("Error for branch %s of %s: %s", name, packname, e.msg);
645 				() @trusted { logDebug("Full error: %s", sanitize(e.toString())); } ();
646 				if (branch.name != "gh-pages") // ignore errors on the special GitHub website branch
647 					errors ~= format("Branch %s: %s", name, e.msg);
648 			}
649 		}
650 		if (got_all_tags_and_branches) {
651 			foreach (ref v; pack.versions) {
652 				auto ver = v.version_;
653 				if (ver !in existing) {
654 					logInfo("Package %s: removing version %s as the branch/tag was removed.", packname, ver);
655 					removeVersion(packname, ver);
656 				}
657 			}
658 		}
659 		m_db.setPackageErrors(packname, errors);
660 
661 		m_updateStatsQueue.put(packname);
662 	}
663 
664 	/// recompute all scores based on cached stats, e.g. after updating algorithm
665 	private void recomputeScores(DbStatDistributions dists)
666 	{
667 		foreach (pack; this.getPackageDump()) {
668 			auto stats = pack.stats;
669 			stats.score = computeScore(stats, dists.downloads, dists.repos[pack.repository.kind]);
670 			if (stats.score != pack.stats.score)
671 				m_db.updatePackageStats(pack._id, stats);
672 		}
673 	}
674 }
675 
676 private PackageVersionInfo getVersionInfo(Repository rep, RefInfo commit, string first_filename_try, InetPath sub_path = InetPath("/"))
677 @safe {
678 	import dub.recipe.io;
679 	import dub.recipe.json;
680 
681 	PackageVersionInfo ret;
682 	ret.date = commit.date.toSysTime();
683 	ret.sha = commit.sha;
684 	string[1] first_try;
685 	first_try[0] = first_filename_try;
686 	auto all_filenames = () @trusted { return packageInfoFilenames(); } ();
687 	foreach (filename; chain(first_try[], all_filenames.filter!(f => f != first_filename_try))) {
688 		if (!filename.length) continue;
689 		try {
690 			rep.readFile(commit.sha, sub_path ~ filename, (scope input) @safe {
691 				auto text = input.readAllUTF8(false);
692 				auto recipe = () @trusted { return parsePackageRecipe(text, filename); } ();
693 				ret.info = () @trusted { return recipe.toJson(); } ();
694 			});
695 
696 			ret.info["packageDescriptionFile"] = filename;
697 			logDebug("Found package description file %s.", filename);
698 
699 			foreach (ref sp; ret.info["subPackages"].opt!(Json[])) {
700 				if (sp.type == Json.Type..string) {
701 					auto path = sp.get!string;
702 					logDebug("Fetching path based sub package at %s", sub_path ~ path);
703 					auto subpack = getVersionInfo(rep, commit, first_filename_try, sub_path ~ path);
704 					sp = subpack.info;
705 					sp["path"] = path;
706 				}
707 			}
708 
709 			break;
710 		} catch (FileNotFoundException) {
711 			logDebug("Package description file %s not found...", filename);
712 		}
713 	}
714 	if (ret.info.type == Json.Type.undefined)
715 		 throw new Exception("Found no package description file in the repository.");
716 	return ret;
717 }
718 
719 private void checkPackageName(string n, string error_suffix)
720 @safe {
721 	enforce(n.length > 0, "Package names may not be empty. "~error_suffix);
722 	foreach( ch; n ){
723 		switch(ch){
724 			default:
725 				throw new Exception("Package names may only contain ASCII letters and numbers, as well as '_' and '-': "~n~" - "~error_suffix);
726 			case 'a': .. case 'z':
727 			case 'A': .. case 'Z':
728 			case '0': .. case '9':
729 			case '_', '-':
730 				break;
731 		}
732 	}
733 }
734 
735 struct PackageVersionInfo {
736 	string version_;
737 	SysTime date;
738 	string sha;
739 	string downloadURL;
740 	Json info; /// JSON version information, as reported to the client
741 }
742 
743 struct PackageInfo {
744 	PackageVersionInfo[] versions;
745 	BsonObjectID logo;
746 	Json info; /// JSON package information, as reported to the client
747 }
748 
749 /// flags to customize getPackageInfo* methods
750 enum PackageInfoFlags
751 {
752 	none,
753 	includeDependencies = 1 << 0, /// include package info of dependencies
754 	includeErrors = 1 << 1, /// include package errors
755 	minimize = 1 << 2, /// return only minimal information (for dependency resolver)
756 }
757 
758 /// Computes a package score from given package stats and global distributions of those stats.
759 private float computeScore(DownDist, RepoDist)(in ref DbPackageStats stats, DownDist downDist, RepoDist repoDist)
760 @safe {
761 	import std.algorithm.comparison : max;
762 	import std.math : log1p, round, tanh;
763 
764     if (!downDist.total.sum) // no stat distribution yet
765         return 0;
766 
767 	/// Using monthly downloads to penalize stale packages, logarithm to
768 	/// offset exponential distribution, and tanh as smooth limiter to [0..1].
769 	immutable downloadScore = tanh(log1p(stats.downloads.monthly / (downDist.monthly.mean + double.min_normal)));
770 	logDebug("downloadScore %s %s %s", downloadScore, stats.downloads.monthly, downDist.monthly.mean);
771 
772 	// Compute score for repo
773 	float sum=0, wsum=0;
774 	void add(T)(float weight, float value, T dist)
775 	{
776 		if (dist.sum == 0)
777 			return; // ignore metrics missing for that repository kind
778 		sum += weight * log1p(value / dist.mean);
779 		wsum += weight;
780 	}
781 	with (stats.repo)
782 	{
783 		alias d = repoDist;
784 		// all of those values are highly correlated
785 		add(1.0f, stars, d.stars);
786 		add(1.0f, watchers, d.watchers);
787 		add(1.0f, forks, d.forks);
788 		add(-1.0f, issues, d.issues); // penalize many open issues/PRs
789 	}
790 
791 	immutable repoScore = max(0.0, tanh(sum / wsum));
792 	logDebug("repoScore: %s %s %s", repoScore, sum, wsum);
793 
794 	// average scores
795 	immutable avgScore = (repoScore + downloadScore) / 2;
796 	assert(0 <= avgScore && avgScore <= 1.0, "%s %s".format(repoScore, downloadScore));
797 	immutable scaled = stats.minScore + avgScore * (stats.maxScore - stats.minScore);
798 	logDebug("score: %s %s %s %s %s %s", stats.downloads.monthly, downDist.monthly.mean, downloadScore, repoScore, avgScore, scaled);
799 
800 	return scaled;
801 }