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.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)
199 	{
200 		if (ver == "latest") ver = getLatestVersion(packname);
201 		if (!m_db.hasVersion(packname, ver)) return Json(null);
202 		return m_db.getVersionInfo(packname, ver).serializeToJson();
203 	}
204 
205 	string getLatestVersion(string packname)
206 	{
207 		return m_db.getLatestVersion(packname);
208 	}
209 
210 	PackageInfo getPackageInfo(string packname, bool include_errors = false)
211 	{
212 		DbPackage pack;
213 		try pack = m_db.getPackage(packname);
214 		catch(Exception) return PackageInfo.init;
215 
216 		return getPackageInfo(pack, include_errors);
217 	}
218 
219 	/** Gets information about multiple packages at once.
220 
221 		The order and count of packages returned may not correspond to the list
222 		of supplied package names. Only those packages that actually reference
223 		an existing package will yield a result element.
224 
225 		The consequence is that the caller must manually match the result to
226 		the supplied package names.
227 
228 		This function requires only a single query to the database.
229 
230 		Returns:
231 			An unordered input range of `PackageInfo` values is returned,
232 			corresponding to all or part of the packages of the given names.
233 	*/
234 	auto getPackageInfos(scope string[] pack_names, bool include_errors = false)
235 	{
236 		return m_db.getPackages(pack_names)
237 			.map!(pack => getPackageInfo(pack, include_errors));
238 	}
239 
240 	PackageInfo getPackageInfo(DbPackage pack, bool include_errors)
241 	{
242 		auto rep = getRepository(pack.repository);
243 
244 		PackageInfo ret;
245 		ret.versions = pack.versions.map!(v => getPackageVersionInfo(v, rep)).array;
246 		ret.logo = pack.logo;
247 
248 		Json nfo = Json.emptyObject;
249 		nfo["id"] = pack._id.toString();
250 		nfo["dateAdded"] = pack._id.timeStamp.toISOExtString();
251 		nfo["owner"] = pack.owner.toString();
252 		nfo["name"] = pack.name;
253 		nfo["versions"] = Json(ret.versions.map!(v => v.info).array);
254 		nfo["repository"] = serializeToJson(pack.repository);
255 		nfo["categories"] = serializeToJson(pack.categories);
256 		if(include_errors) nfo["errors"] = serializeToJson(pack.errors);
257 
258 		ret.info = nfo;
259 
260 		return ret;
261 	}
262 
263 	private PackageVersionInfo getPackageVersionInfo(DbPackageVersion v, Repository rep)
264 	{
265 		// JSON package version info as reported to the client
266 		auto nfo = v.info.get!(Json[string]).dup;
267 		nfo["version"] = v.version_;
268 		nfo["date"] = v.date.toISOExtString();
269 		nfo["readme"] = v.readme;
270 		nfo["commitID"] = v.commitID;
271 
272 		PackageVersionInfo ret;
273 		ret.info = Json(nfo);
274 		ret.date = v.date;
275 		ret.sha = v.commitID;
276 		ret.version_ = v.version_;
277 		ret.downloadURL = rep.getDownloadUrl(v.version_.startsWith("~") ? v.version_ : "v"~v.version_);
278 		return ret;
279 	}
280 
281 	string getReadme(Json version_info, DbRepository repository)
282 	{
283 		auto readme = version_info["readme"].opt!string;
284 
285 		// compat migration, read file from repo if README hasn't yet been stored in the db
286 		if (readme.length && readme.length < 256 && readme[0] == '/') {
287 			try {
288 				auto rep = getRepository(repository);
289 				logDebug("reading readme file for %s: %s", version_info["name"].get!string, readme);
290 				rep.readFile(version_info["commitID"].get!string, InetPath(readme), (scope data) {
291 					readme = data.readAllUTF8();
292 				});
293 			} catch (Exception e) {
294 				logDiagnostic("Failed to read README file (%s) for %s %s: %s",
295 					readme, version_info["name"].get!string,
296 					version_info["version"].get!string, e.msg);
297 			}
298 		}
299 		return readme;
300 	}
301 
302 	void downloadPackageZip(string packname, string vers, void delegate(scope InputStream) @safe del)
303 	{
304 		DbPackage pack = m_db.getPackage(packname);
305 		auto rep = getRepository(pack.repository);
306 		rep.download(vers, del);
307 	}
308 
309 	void setPackageCategories(string pack_name, string[] categories)
310 	{
311 		m_db.setPackageCategories(pack_name, categories);
312 	}
313 
314 	void setPackageRepository(string pack_name, DbRepository repository)
315 	{
316 		auto new_name = validateRepository(repository);
317 		enforce(pack_name == new_name, "The package name of the new repository doesn't match the existing one: "~new_name);
318 		m_db.setPackageRepository(pack_name, repository);
319 	}
320 
321 	void setPackageLogo(string pack_name, NativePath path)
322 	{
323 		auto png = generateLogo(path);
324 		if (png.length)
325 			m_db.setPackageLogo(pack_name, png);
326 		else
327 			throw new Exception("Failed to generate logo");
328 	}
329 
330 	void unsetPackageLogo(string pack_name)
331 	{
332 		m_db.setPackageLogo(pack_name, null);
333 	}
334 
335 	bdata_t getPackageLogo(string pack_name, out bdata_t rev)
336 	{
337 		return m_db.getPackageLogo(pack_name, rev);
338 	}
339 
340 	void updatePackages()
341 	{
342 		logDiagnostic("Triggering package update...");
343 		// update stat distributions before score packages
344 		m_statDistributions = m_db.getStatDistributions();
345 		foreach (packname; this.availablePackages)
346 			triggerPackageUpdate(packname);
347 	}
348 
349 	protected string validateRepository(DbRepository repository)
350 	{
351 		// find the packge info of ~master or any available branch
352 		PackageVersionInfo info;
353 		auto rep = getRepository(repository);
354 		auto branches = rep.getBranches();
355 		enforce(branches.length > 0, "The repository contains no branches.");
356 		auto idx = branches.countUntil!(b => b.name == "master");
357 		if (idx > 0) swap(branches[0], branches[idx]);
358 		string branch_errors;
359 		foreach (b; branches) {
360 			try {
361 				info = rep.getVersionInfo(b, null);
362 				enforce (info.info.type == Json.Type.object,
363 					"JSON package description must be a JSON object.");
364 				break;
365 			} catch (Exception e) {
366 				logDiagnostic("Error getting package info for %s", b);
367 				branch_errors ~= format("\n%s: %s", b.name, e.msg);
368 			}
369 		}
370 		enforce (info.info.type == Json.Type.object,
371 			"Failed to find a branch containing a valid package description file:" ~ branch_errors);
372 
373 		// derive package name and perform various sanity checks
374 		auto name = info.info["name"].get!string;
375 		string package_desc_file = info.info["packageDescriptionFile"].get!string;
376 		string package_check_string = format(`Check your %s.`, package_desc_file);
377 		enforce(name.length <= 60,
378 			"Package names must not be longer than 60 characters: \""~name[0 .. 60]~"...\" - "~package_check_string);
379 		enforce(name == name.toLower(),
380 			"Package names must be all lower case, not \""~name~"\". "~package_check_string);
381 		enforce(info.info["license"].opt!string.length > 0,
382 			`A "license" field in the package description file is missing or empty. `~package_check_string);
383 		enforce(info.info["description"].opt!string.length > 0,
384 			`A "description" field in the package description file is missing or empty. `~package_check_string);
385 		checkPackageName(name, format(`Check the "name" field of your %s.`, package_desc_file));
386 		foreach (string n, vspec; info.info["dependencies"].opt!(Json[string])) {
387 			auto parts = n.split(":").array;
388 			// allow shortcut syntax ":subpack"
389 			if (parts.length > 1 && parts[0].length == 0) parts = parts[1 .. $];
390 			// verify all other parts of the package name
391 			foreach (p; parts)
392 				checkPackageName(p, format(`Check the "dependencies" field of your %s.`, package_desc_file));
393 		}
394 
395 		// ensure that at least one tagged version is present
396 		auto tags = rep.getTags();
397 		enforce(tags.canFind!(t => t.name.startsWith("v") && t.name[1 .. $].isValidVersion),
398 			`The repository must have at least one tagged version (SemVer format, e.g. `
399 			~ `"v1.0.0" or "v0.0.1") to be published on the registry. Please add a proper tag using `
400 			~ `"git tag" or equivalent means and see http://semver.org for more information.`);
401 
402 		return name;
403 	}
404 
405 	protected bool addVersion(in ref DbPackage dbpack, string ver, Repository rep, RefInfo reference)
406 	{
407 		logDiagnostic("Adding new version info %s for %s", ver, dbpack.name);
408 		assert(ver.startsWith("~") && !ver.startsWith("~~") || isValidVersion(ver));
409 
410 		string deffile;
411 		foreach (t; dbpack.versions)
412 			if (t.version_ == ver) {
413 				deffile = t.info["packageDescriptionFile"].opt!string;
414 				break;
415 			}
416 		auto info = getVersionInfo(rep, reference, deffile);
417 
418 		//assert(info.info.name == info.info.name.get!string.toLower(), "Package names must be all lower case.");
419 		info.info["name"] = info.info["name"].get!string.toLower();
420 		enforce(info.info["name"] == dbpack.name,
421 			format("Package name (%s) does not match the original package name (%s). Check %s.",
422 				info.info["name"].get!string, dbpack.name, info.info["packageDescriptionFile"].get!string));
423 
424 		foreach( string n, vspec; info.info["dependencies"].opt!(Json[string]) )
425 			foreach (p; n.split(":"))
426 				checkPackageName(p, "Check "~info.info["packageDescriptionFile"].get!string~".");
427 
428 		DbPackageVersion dbver;
429 		dbver.date = info.date;
430 		dbver.version_ = ver;
431 		dbver.commitID = info.sha;
432 		dbver.info = info.info;
433 
434 		try {
435 			auto files = rep.listFiles(reference.sha, InetPath("/"));
436 			// check exactly for readme.me
437 			ptrdiff_t readme;
438 			readme = files.countUntil!(a => a.type == RepositoryFile.Type.file && a.path.head.name.asUpperCase.equal("README.MD"));
439 			if (readme == -1) {
440 				// check exactly for readme
441 				readme = files.countUntil!(a => a.type == RepositoryFile.Type.file && a.path.head.name.asUpperCase.equal("README"));
442 			}
443 			if (readme == -1) {
444 				// check for all other readmes such as README.txt, README.jp.md, etc.
445 				readme = files.countUntil!(a => a.type == RepositoryFile.Type.file && a.path.head.name.asUpperCase.startsWith("README"));
446 			}
447 
448 			if (readme != -1) {
449 				rep.readFile(reference.sha, files[readme].path, (scope input) {
450 					dbver.readme = input.readAllUTF8();
451 					string ext = files[readme].path.head.name.extension;
452 					// endsWith doesn't like to work with asLowerCase
453 					dbver.readmeMarkdown = ext.sicmp(".md") == 0;
454 				});
455 			} else logDiagnostic("No README.md found for %s %s", dbpack.name, ver);
456 
457 			// TODO: load in example(s), sample(s), test(s) and docs for the view package page here.
458 			// possibly also parsing the README.md file for a documentation link
459 		} catch (Exception e) { logDiagnostic("Failed to read README.md for %s %s: %s", dbpack.name, ver, e.msg); }
460 
461 		if (m_db.hasVersion(dbpack.name, ver)) {
462 			logDebug("Updating existing version info.");
463 			m_db.updateVersion(dbpack.name, dbver);
464 			return false;
465 		}
466 
467 		if ("description" !in info.info || "license" !in info.info) {
468 			throw new Exception(
469 			"Published packages must contain \"description\" and \"license\" fields.");
470 		}
471 		//enforce(!m_db.hasVersion(packname, dbver.version_), "Version already exists.");
472 		if (auto pv = "version" in info.info)
473 			enforce(pv.get!string == ver, format("Package description contains an obsolete \"version\" field and does not match tag %s: %s", ver, pv.get!string));
474 		logDebug("Adding new version info.");
475 		m_db.addVersion(dbpack.name, dbver);
476 		return true;
477 	}
478 
479 	protected void removeVersion(string packname, string ver)
480 	{
481 		assert(ver.startsWith("~") && !ver.startsWith("~~") || isValidVersion(ver));
482 
483 		m_db.removeVersion(packname, ver);
484 	}
485 
486 	private void updatePackage(string packname)
487 	{
488 		import std.encoding;
489 		string[] errors;
490 
491 		DbPackage pack;
492 		try pack = m_db.getPackage(packname);
493 		catch( Exception e ){
494 			errors ~= format("Error getting package info: %s", e.msg);
495 			() @trusted { logDebug("%s", sanitize(e.toString())); } ();
496 			return;
497 		}
498 
499 		Repository rep;
500 		try rep = getRepository(pack.repository);
501 		catch( Exception e ){
502 			errors ~= format("Error accessing repository: %s", e.msg);
503 			() @trusted { logDebug("%s", sanitize(e.toString())); } ();
504 			return;
505 		}
506 
507 		bool[string] existing;
508 		RefInfo[] tags, branches;
509 		bool got_all_tags_and_branches = false;
510 		try {
511 			tags = rep.getTags()
512 				.filter!(a => a.name.startsWith("v") && a.name[1 .. $].isValidVersion)
513 				.array
514 				.sort!((a, b) => compareVersions(a.name[1 .. $], b.name[1 .. $]) < 0)
515 				.array;
516 			branches = rep.getBranches();
517 			got_all_tags_and_branches = true;
518 		} catch (Exception e) {
519 			errors ~= format("Failed to get GIT tags/branches: %s", e.msg);
520 		}
521 		logDiagnostic("Updating tags for %s: %s", packname, tags.map!(t => t.name).array);
522 		foreach (tag; tags) {
523 			auto name = tag.name[1 .. $];
524 			existing[name] = true;
525 			try {
526 				if (addVersion(pack, name, rep, tag))
527 					logInfo("Package %s: added version %s", packname, name);
528 			} catch( Exception e ){
529 				logDiagnostic("Error for version %s of %s: %s", name, packname, e.msg);
530 				() @trusted  { logDebug("Full error: %s", sanitize(e.toString())); } ();
531 				errors ~= format("Version %s: %s", name, e.msg);
532 			}
533 		}
534 		logDiagnostic("Updating branches for %s: %s", packname, branches.map!(t => t.name).array);
535 		foreach (branch; branches) {
536 			auto name = "~" ~ branch.name;
537 			existing[name] = true;
538 			try {
539 				if (addVersion(pack, name, rep, branch))
540 					logInfo("Package %s: added branch %s", packname, name);
541 			} catch( Exception e ){
542 				logDiagnostic("Error for branch %s of %s: %s", name, packname, e.msg);
543 				() @trusted { logDebug("Full error: %s", sanitize(e.toString())); } ();
544 				if (branch.name != "gh-pages") // ignore errors on the special GitHub website branch
545 					errors ~= format("Branch %s: %s", name, e.msg);
546 			}
547 		}
548 		if (got_all_tags_and_branches) {
549 			foreach (ref v; pack.versions) {
550 				auto ver = v.version_;
551 				if (ver !in existing) {
552 					logInfo("Package %s: removing version %s as the branch/tag was removed.", packname, ver);
553 					removeVersion(packname, ver);
554 				}
555 			}
556 		}
557 		m_db.setPackageErrors(packname, errors);
558 
559 		m_updateStatsQueue.put(packname);
560 	}
561 
562 	/// recompute all scores based on cached stats, e.g. after updating algorithm
563 	private void recomputeScores(DbStatDistributions dists)
564 	{
565 		foreach (pack; this.getPackageDump()) {
566 			auto stats = pack.stats;
567 			stats.score = computeScore(stats, dists.downloads, dists.repos[pack.repository.kind]);
568 			if (stats.score != pack.stats.score)
569 				m_db.updatePackageStats(pack._id, stats);
570 		}
571 	}
572 }
573 
574 private PackageVersionInfo getVersionInfo(Repository rep, RefInfo commit, string first_filename_try, InetPath sub_path = InetPath("/"))
575 @safe {
576 	import dub.recipe.io;
577 	import dub.recipe.json;
578 
579 	PackageVersionInfo ret;
580 	ret.date = commit.date.toSysTime();
581 	ret.sha = commit.sha;
582 	string[1] first_try;
583 	first_try[0] = first_filename_try;
584 	auto all_filenames = () @trusted { return packageInfoFilenames(); } ();
585 	foreach (filename; chain(first_try[], all_filenames.filter!(f => f != first_filename_try))) {
586 		if (!filename.length) continue;
587 		try {
588 			rep.readFile(commit.sha, sub_path ~ filename, (scope input) @safe {
589 				auto text = input.readAllUTF8(false);
590 				auto recipe = () @trusted { return parsePackageRecipe(text, filename); } ();
591 				ret.info = () @trusted { return recipe.toJson(); } ();
592 			});
593 
594 			ret.info["packageDescriptionFile"] = filename;
595 			logDebug("Found package description file %s.", filename);
596 
597 			foreach (ref sp; ret.info["subPackages"].opt!(Json[])) {
598 				if (sp.type == Json.Type..string) {
599 					auto path = sp.get!string;
600 					logDebug("Fetching path based sub package at %s", sub_path ~ path);
601 					auto subpack = getVersionInfo(rep, commit, first_filename_try, sub_path ~ path);
602 					sp = subpack.info;
603 					sp["path"] = path;
604 				}
605 			}
606 
607 			break;
608 		} catch (FileNotFoundException) {
609 			logDebug("Package description file %s not found...", filename);
610 		}
611 	}
612 	if (ret.info.type == Json.Type.undefined)
613 		 throw new Exception("Found no package description file in the repository.");
614 	return ret;
615 }
616 
617 private void checkPackageName(string n, string error_suffix)
618 @safe {
619 	enforce(n.length > 0, "Package names may not be empty. "~error_suffix);
620 	foreach( ch; n ){
621 		switch(ch){
622 			default:
623 				throw new Exception("Package names may only contain ASCII letters and numbers, as well as '_' and '-': "~n~" - "~error_suffix);
624 			case 'a': .. case 'z':
625 			case 'A': .. case 'Z':
626 			case '0': .. case '9':
627 			case '_', '-':
628 				break;
629 		}
630 	}
631 }
632 
633 struct PackageVersionInfo {
634 	string version_;
635 	SysTime date;
636 	string sha;
637 	string downloadURL;
638 	Json info; /// JSON version information, as reported to the client
639 }
640 
641 struct PackageInfo {
642 	PackageVersionInfo[] versions;
643 	BsonObjectID logo;
644 	Json info; /// JSON package information, as reported to the client
645 }
646 
647 /// Computes a package score from given package stats and global distributions of those stats.
648 private float computeScore(DownDist, RepoDist)(in ref DbPackageStats stats, DownDist downDist, RepoDist repoDist)
649 @safe {
650 	import std.algorithm.comparison : max;
651 	import std.math : log1p, round, tanh;
652 
653     if (!downDist.total.sum) // no stat distribution yet
654         return 0;
655 
656 	/// Using monthly downloads to penalize stale packages, logarithm to
657 	/// offset exponential distribution, and tanh as smooth limiter to [0..1].
658 	immutable downloadScore = tanh(log1p(stats.downloads.monthly / downDist.monthly.mean));
659 	logDebug("downloadScore %s %s %s", downloadScore, stats.downloads.monthly, downDist.monthly.mean);
660 
661 	// Compute score for repo
662 	float sum=0, wsum=0;
663 	void add(T)(float weight, float value, T dist)
664 	{
665 		if (dist.sum == 0)
666 			return; // ignore metrics missing for that repository kind
667 		sum += weight * log1p(value / dist.mean);
668 		wsum += weight;
669 	}
670 	with (stats.repo)
671 	{
672 		alias d = repoDist;
673 		// all of those values are highly correlated
674 		add(1.0f, stars, d.stars);
675 		add(1.0f, watchers, d.watchers);
676 		add(1.0f, forks, d.forks);
677 		add(-1.0f, issues, d.issues); // penalize many open issues/PRs
678 	}
679 
680 	immutable repoScore = max(0.0, tanh(sum / wsum));
681 	logDebug("repoScore: %s %s %s", repoScore, sum, wsum);
682 
683 	// average scores
684 	immutable avgScore = (repoScore + downloadScore) / 2;
685 	assert(0 <= avgScore && avgScore <= 1.0, "%s %s".format(repoScore, downloadScore));
686 	immutable scaled = stats.minScore + avgScore * (stats.maxScore - stats.minScore);
687 	logDebug("score: %s %s %s %s %s %s", stats.downloads.monthly, downDist.monthly.mean, downloadScore, repoScore, avgScore, scaled);
688 
689 	return scaled;
690 }