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.repositories.repository;
11 
12 import dub.semver;
13 import dub.package_ : packageInfoFilenames;
14 import std.algorithm : canFind, countUntil, filter, map, sort, swap;
15 import std.array;
16 import std.datetime : Clock, UTC, hours, SysTime;
17 import std.encoding : sanitize;
18 import std.exception : enforce;
19 import std.range : chain, walkLength;
20 import std.string : format, startsWith, toLower;
21 import std.typecons;
22 import userman.db.controller;
23 import vibe.core.core;
24 import vibe.core.log;
25 import vibe.data.bson;
26 import vibe.data.json;
27 import vibe.stream.operations;
28 import vibe.utils.array : FixedRingBuffer;
29 
30 
31 /// Settings to configure the package registry.
32 class DubRegistrySettings {
33 	string databaseName = "vpmreg";
34 }
35 
36 class DubRegistry {
37 	private {
38 		DubRegistrySettings m_settings;
39 		DbController m_db;
40 
41 		// list of package names to check for updates
42 		FixedRingBuffer!string m_updateQueue;
43 		string m_currentUpdatePackage;
44 		Task m_updateQueueTask;
45 		TaskMutex m_updateQueueMutex;
46 		TaskCondition m_updateQueueCondition;
47 		SysTime m_lastSignOfLifeOfUpdateTask;
48 	}
49 
50 	this(DubRegistrySettings settings)
51 	{
52 		m_settings = settings;
53 		m_db = new DbController(settings.databaseName);
54 		m_updateQueue.capacity = 10000;
55 		m_updateQueueMutex = new TaskMutex;
56 		m_updateQueueCondition = new TaskCondition(m_updateQueueMutex);
57 		m_updateQueueTask = runTask(&processUpdateQueue);
58 	}
59 
60 	@property DbController db() nothrow { return m_db; }
61 
62 	@property auto availablePackages() { return m_db.getAllPackages(); }
63 	@property auto availablePackageIDs() { return m_db.getAllPackageIDs(); }
64 
65 	auto getPackageDump()
66 	{
67 		return m_db.getPackageDump();
68 	}
69 
70 	void triggerPackageUpdate(string pack_name)
71 	{
72 		synchronized (m_updateQueueMutex) {
73 			if (!m_updateQueue[].canFind(pack_name))
74 				m_updateQueue.put(pack_name);
75 		}
76 
77 		// watchdog for update task
78 		if (Clock.currTime(UTC()) - m_lastSignOfLifeOfUpdateTask > 2.hours) {
79 			logError("Update task has hung. Trying to interrupt.");
80 			m_updateQueueTask.interrupt();
81 		}
82 
83 		if (!m_updateQueueTask.running)
84 			m_updateQueueTask = runTask(&processUpdateQueue);
85 		m_updateQueueCondition.notifyAll();
86 	}
87 
88 	bool isPackageScheduledForUpdate(string pack_name)
89 	{
90 		if (m_currentUpdatePackage == pack_name) return true;
91 		synchronized (m_updateQueueMutex)
92 			if (m_updateQueue[].canFind(pack_name)) return true;
93 		return false;
94 	}
95 
96 	/** Returns the current index of a given package in the update queue.
97 
98 		An index of zero indicates that the package is currently being updated.
99 		A negative index is returned when the package is not in the update
100 		queue.
101 	*/
102 	sizediff_t getUpdateQueuePosition(string pack_name)
103 	{
104 		if (m_currentUpdatePackage == pack_name) return 0;
105 		synchronized (m_updateQueueMutex) {
106 			auto idx = m_updateQueue[].countUntil(pack_name);
107 			return idx >= 0 ? idx + 1 : -1;
108 		}
109 	}
110 
111 	auto searchPackages(string query)
112 	{
113 		static struct Info { string name; DbPackageVersion _base; alias _base this; }
114 		return m_db.searchPackages(query).filter!(p => p.versions.length > 0).map!(p =>
115 			Info(p.name, m_db.getVersionInfo(p.name, p.versions[$ - 1].version_)));
116 	}
117 
118 	RepositoryInfo getRepositoryInfo(DbRepository repository)
119 	{
120 		auto rep = getRepository(repository);
121 		return rep.getInfo();
122 	}
123 
124 	void addPackage(DbRepository repository, User.ID user)
125 	{
126 		auto pack_name = validateRepository(repository);
127 
128 		DbPackage pack;
129 		pack.owner = user.bsonObjectIDValue;
130 		pack.name = pack_name;
131 		pack.repository = repository;
132 		m_db.addPackage(pack);
133 
134 		triggerPackageUpdate(pack.name);
135 	}
136 
137 	void addOrSetPackage(DbPackage pack)
138 	{
139 		m_db.addOrSetPackage(pack);
140 	}
141 
142 	void addDownload(BsonObjectID pack_id, string ver, string agent)
143 	{
144 		m_db.addDownload(pack_id, ver, agent);
145 	}
146 
147 	void removePackage(string packname, User.ID user)
148 	{
149 		logInfo("Package %s: removing package owned by %s", packname, user);
150 		m_db.removePackage(packname, user.bsonObjectIDValue);
151 	}
152 
153 	auto getPackages(User.ID user)
154 	{
155 		return m_db.getUserPackages(user.bsonObjectIDValue);
156 	}
157 
158 	bool isUserPackage(User.ID user, string package_name)
159 	{
160 		return m_db.isUserPackage(user.bsonObjectIDValue, package_name);
161 	}
162 
163 	/// get stats (including downloads of all version) for a package
164 	DbPackageStats getPackageStats(string packname)
165 	{
166 		auto cached = m_db.getPackageStats(packname);
167 		if (cached.updatedAt > Clock.currTime(UTC()) - 24.hours)
168 			return cached;
169 		return updatePackageStats(packname);
170 	}
171 
172 	private DbPackageStats updatePackageStats(string packname)
173 	{
174 		logDiagnostic("Updating stats for %s", packname);
175 
176 		DbPackageStats stats;
177 		DbPackage pack = m_db.getPackage(packname);
178 		stats.downloads = m_db.aggregateDownloadStats(pack._id);
179 
180 		try {
181 			stats.repo = getRepositoryInfo(pack.repository).stats;
182 		} catch (Exception e){
183 			logWarn("Failed to get repository info for %s: %s", packname, e.msg);
184 			return typeof(return).init;
185 		}
186 
187 		m_db.updatePackageStats(pack._id, stats);
188 		return stats;
189 	}
190 
191 	/// get downloads for a package version
192 	DbDownloadStats getDownloadStats(string packname, string ver)
193 	{
194 		auto packid = m_db.getPackageID(packname);
195 		if (ver == "latest") ver = getLatestVersion(packname);
196 		enforce!RecordNotFound(m_db.hasVersion(packname, ver), "Unknown version for package.");
197 		return m_db.aggregateDownloadStats(packid, ver);
198 	}
199 
200 	Json getPackageVersionInfo(string packname, string ver)
201 	{
202 		if (ver == "latest") ver = getLatestVersion(packname);
203 		if (!m_db.hasVersion(packname, ver)) return Json(null);
204 		return m_db.getVersionInfo(packname, ver).serializeToJson();
205 	}
206 
207 	string getLatestVersion(string packname)
208 	{
209 		return m_db.getLatestVersion(packname);
210 	}
211 
212 	PackageInfo getPackageInfo(string packname, bool include_errors = false)
213 	{
214 		DbPackage pack;
215 		try pack = m_db.getPackage(packname);
216 		catch(Exception) return PackageInfo.init;
217 
218 		return getPackageInfo(pack, include_errors);
219 	}
220 
221 	PackageInfo getPackageInfo(DbPackage pack, bool include_errors)
222 	{
223 		auto rep = getRepository(pack.repository);
224 
225 		PackageInfo ret;
226 		ret.versions = pack.versions.map!(v => getPackageVersionInfo(v, rep)).array;
227 
228 		Json nfo = Json.emptyObject;
229 		nfo["id"] = pack._id.toString();
230 		nfo["dateAdded"] = pack._id.timeStamp.toISOExtString();
231 		nfo["owner"] = pack.owner.toString();
232 		nfo["name"] = pack.name;
233 		nfo["versions"] = Json(ret.versions.map!(v => v.info).array);
234 		nfo["repository"] = serializeToJson(pack.repository);
235 		nfo["categories"] = serializeToJson(pack.categories);
236 		if(include_errors) nfo["errors"] = serializeToJson(pack.errors);
237 
238 		ret.info = nfo;
239 
240 		return ret;
241 	}
242 
243 	private PackageVersionInfo getPackageVersionInfo(DbPackageVersion v, Repository rep)
244 	{
245 		// JSON package version info as reported to the client
246 		auto nfo = v.info.get!(Json[string]).dup;
247 		nfo["version"] = v.version_;
248 		nfo["date"] = v.date.toISOExtString();
249 		nfo["readmeFile"] = v.readme;
250 		nfo["commitID"] = v.commitID;
251 
252 		PackageVersionInfo ret;
253 		ret.info = Json(nfo);
254 		ret.date = v.date;
255 		ret.sha = v.commitID;
256 		ret.version_ = v.version_;
257 		ret.downloadURL = rep.getDownloadUrl(v.version_.startsWith("~") ? v.version_ : "v"~v.version_);
258 		return ret;
259 	}
260 
261 	string getReadme(Json version_info, DbRepository repository)
262 	{
263 		string ret;
264 		auto file = version_info["readmeFile"].opt!string;
265 		try {
266 			if (!file.startsWith('/')) return null;
267 			auto rep = getRepository(repository);
268 			logDebug("reading readme file for %s: %s", version_info["name"].get!string, file);
269 			rep.readFile(version_info["commitID"].get!string, Path(file), (scope data) {
270 				ret = data.readAllUTF8();
271 			});
272 			logDebug("reading readme file done");
273 		} catch (Exception e) {
274 			logDiagnostic("Failed to read README file (%s) for %s %s: %s",
275 				file, version_info["name"].get!string,
276 				version_info["version"].get!string, e.msg);
277 		}
278 		return ret;
279 	}
280 
281 	void downloadPackageZip(string packname, string vers, void delegate(scope InputStream) del)
282 	{
283 		DbPackage pack = m_db.getPackage(packname);
284 		auto rep = getRepository(pack.repository);
285 		rep.download(vers, del);
286 	}
287 
288 	void setPackageCategories(string pack_name, string[] categories)
289 	{
290 		m_db.setPackageCategories(pack_name, categories);
291 	}
292 
293 	void setPackageRepository(string pack_name, DbRepository repository)
294 	{
295 		auto new_name = validateRepository(repository);
296 		enforce(pack_name == new_name, "The package name of the new repository doesn't match the existing one: "~new_name);
297 		m_db.setPackageRepository(pack_name, repository);
298 	}
299 
300 	void updatePackages()
301 	{
302 		logDiagnostic("Triggering package update...");
303 		foreach (packname; this.availablePackages)
304 			triggerPackageUpdate(packname);
305 	}
306 
307 	protected string validateRepository(DbRepository repository)
308 	{
309 		// find the packge info of ~master or any available branch
310 		PackageVersionInfo info;
311 		auto rep = getRepository(repository);
312 		auto branches = rep.getBranches();
313 		enforce(branches.length > 0, "The repository contains no branches.");
314 		auto idx = branches.countUntil!(b => b.name == "master");
315 		if (idx > 0) swap(branches[0], branches[idx]);
316 		string branch_errors;
317 		foreach (b; branches) {
318 			try {
319 				info = rep.getVersionInfo(b, null);
320 				enforce (info.info.type == Json.Type.object,
321 					"JSON package description must be a JSON object.");
322 				break;
323 			} catch (Exception e) {
324 				logDiagnostic("Error getting package info for %s", b);
325 				branch_errors ~= format("\n%s: %s", b.name, e.msg);
326 			}
327 		}
328 		enforce (info.info.type == Json.Type.object,
329 			"Failed to find a branch containing a valid package description file:" ~ branch_errors);
330 
331 		// derive package name and perform various sanity checks
332 		auto name = info.info["name"].get!string;
333 		string package_desc_file = info.info["packageDescriptionFile"].get!string;
334 		string package_check_string = format(`Check your %s.`, package_desc_file);
335 		enforce(name.length <= 60,
336 			"Package names must not be longer than 60 characters: \""~name[0 .. 60]~"...\" - "~package_check_string);
337 		enforce(name == name.toLower(),
338 			"Package names must be all lower case, not \""~name~"\". "~package_check_string);
339 		enforce(info.info["license"].opt!string.length > 0,
340 			`A "license" field in the package description file is missing or empty. `~package_check_string);
341 		enforce(info.info["description"].opt!string.length > 0,
342 			`A "description" field in the package description file is missing or empty. `~package_check_string);
343 		checkPackageName(name, format(`Check the "name" field of your %s.`, package_desc_file));
344 		foreach (string n, vspec; info.info["dependencies"].opt!(Json[string])) {
345 			auto parts = n.split(":").array;
346 			// allow shortcut syntax ":subpack"
347 			if (parts.length > 1 && parts[0].length == 0) parts = parts[1 .. $];
348 			// verify all other parts of the package name
349 			foreach (p; parts)
350 				checkPackageName(p, format(`Check the "dependencies" field of your %s.`, package_desc_file));
351 		}
352 
353 		// ensure that at least one tagged version is present
354 		auto tags = rep.getTags();
355 		enforce(tags.canFind!(t => t.name.startsWith("v") && t.name[1 .. $].isValidVersion),
356 			`The repository must have at least one tagged version (SemVer format, e.g. `
357 			~ `"v1.0.0" or "v0.0.1") to be published on the registry. Please add a proper tag using `
358 			~ `"git tag" or equivalent means and see http://semver.org for more information.`);
359 
360 		return name;
361 	}
362 
363 	protected bool addVersion(string packname, string ver, Repository rep, RefInfo reference)
364 	{
365 		logDiagnostic("Adding new version info %s for %s", ver, packname);
366 		assert(ver.startsWith("~") && !ver.startsWith("~~") || isValidVersion(ver));
367 
368 		auto dbpack = m_db.getPackage(packname);
369 		string deffile;
370 		foreach (t; dbpack.versions)
371 			if (t.version_ == ver) {
372 				deffile = t.info["packageDescriptionFile"].opt!string;
373 				break;
374 			}
375 		auto info = getVersionInfo(rep, reference, deffile);
376 
377 		//assert(info.info.name == info.info.name.get!string.toLower(), "Package names must be all lower case.");
378 		info.info["name"] = info.info["name"].get!string.toLower();
379 		enforce(info.info["name"] == packname,
380 			format("Package name (%s) does not match the original package name (%s). Check %s.",
381 				info.info["name"].get!string, packname, info.info["packageDescriptionFile"].get!string));
382 
383 		if ("description" !in info.info || "license" !in info.info) {
384 		//enforce("description" in info.info && "license" in info.info,
385 			throw new Exception(
386 			"Published packages must contain \"description\" and \"license\" fields.");
387 		}
388 
389 		foreach( string n, vspec; info.info["dependencies"].opt!(Json[string]) )
390 			foreach (p; n.split(":"))
391 				checkPackageName(p, "Check "~info.info["packageDescriptionFile"].get!string~".");
392 
393 		DbPackageVersion dbver;
394 		dbver.date = info.date;
395 		dbver.version_ = ver;
396 		dbver.commitID = info.sha;
397 		dbver.info = info.info;
398 
399 		try {
400 			rep.readFile(reference.sha, Path("/README.md"), (scope input) { input.readAll(); });
401 			dbver.readme = "/README.md";
402 		} catch (Exception e) { logDiagnostic("No README.md found for %s %s", packname, ver); }
403 
404 		if (m_db.hasVersion(packname, ver)) {
405 			logDebug("Updating existing version info.");
406 			m_db.updateVersion(packname, dbver);
407 			return false;
408 		}
409 
410 		//enforce(!m_db.hasVersion(packname, dbver.version_), "Version already exists.");
411 		if (auto pv = "version" in info.info)
412 			enforce(pv.get!string == ver, format("Package description contains an obsolete \"version\" field and does not match tag %s: %s", ver, pv.get!string));
413 		logDebug("Adding new version info.");
414 		m_db.addVersion(packname, dbver);
415 		return true;
416 	}
417 
418 	protected void removeVersion(string packname, string ver)
419 	{
420 		assert(ver.startsWith("~") && !ver.startsWith("~~") || isValidVersion(ver));
421 
422 		m_db.removeVersion(packname, ver);
423 	}
424 
425 	private void processUpdateQueue()
426 	{
427 		scope (exit) logWarn("Update task was killed!");
428 		while (true) {
429 			m_lastSignOfLifeOfUpdateTask = Clock.currTime(UTC());
430 			logDiagnostic("Getting new package to be updated...");
431 			string pack;
432 			synchronized (m_updateQueueMutex) {
433 				while (m_updateQueue.empty) {
434 					logDiagnostic("Waiting for package to be updated...");
435 					m_updateQueueCondition.wait();
436 				}
437 				pack = m_updateQueue.front;
438 				m_updateQueue.popFront();
439 				m_currentUpdatePackage = pack;
440 			}
441 			scope(exit) m_currentUpdatePackage = null;
442 			logDiagnostic("Updating package %s.", pack);
443 			try updatePackage(pack);
444 			catch (Exception e) {
445 				logWarn("Failed to check versions for %s: %s", pack, e.msg);
446 				logDiagnostic("Full error: %s", e.toString().sanitize);
447 			}
448 		}
449 	}
450 
451 	private void updatePackage(string packname)
452 	{
453 		import std.encoding;
454 		string[] errors;
455 
456 		PackageInfo pack;
457 		try pack = getPackageInfo(packname);
458 		catch( Exception e ){
459 			errors ~= format("Error getting package info: %s", e.msg);
460 			logDebug("%s", sanitize(e.toString()));
461 			return;
462 		}
463 
464 		Repository rep;
465 		try rep = getRepository(pack.info["repository"].deserializeJson!DbRepository);
466 		catch( Exception e ){
467 			errors ~= format("Error accessing repository: %s", e.msg);
468 			logDebug("%s", sanitize(e.toString()));
469 			return;
470 		}
471 
472 		bool[string] existing;
473 		RefInfo[] tags, branches;
474 		bool got_all_tags_and_branches = false;
475 		try {
476 			tags = rep.getTags()
477 				.filter!(a => a.name.startsWith("v") && a.name[1 .. $].isValidVersion)
478 				.array
479 				.sort!((a, b) => compareVersions(a.name[1 .. $], b.name[1 .. $]) < 0)
480 				.array;
481 			branches = rep.getBranches();
482 			got_all_tags_and_branches = true;
483 		} catch (Exception e) {
484 			errors ~= format("Failed to get GIT tags/branches: %s", e.msg);
485 		}
486 		logDiagnostic("Updating tags for %s: %s", packname, tags.map!(t => t.name).array);
487 		foreach (tag; tags) {
488 			auto name = tag.name[1 .. $];
489 			existing[name] = true;
490 			try {
491 				if (addVersion(packname, name, rep, tag))
492 					logInfo("Package %s: added version %s", packname, name);
493 			} catch( Exception e ){
494 				logDiagnostic("Error for version %s of %s: %s", name, packname, e.msg);
495 				logDebug("Full error: %s", sanitize(e.toString()));
496 				errors ~= format("Version %s: %s", name, e.msg);
497 			}
498 		}
499 		logDiagnostic("Updating branches for %s: %s", packname, branches.map!(t => t.name).array);
500 		foreach (branch; branches) {
501 			auto name = "~" ~ branch.name;
502 			existing[name] = true;
503 			try {
504 				if (addVersion(packname, name, rep, branch))
505 					logInfo("Package %s: added branch %s", packname, name);
506 			} catch( Exception e ){
507 				logDiagnostic("Error for branch %s of %s: %s", name, packname, e.msg);
508 				logDebug("Full error: %s", sanitize(e.toString()));
509 				if (branch.name != "gh-pages") // ignore errors on the special GitHub website branch
510 					errors ~= format("Branch %s: %s", name, e.msg);
511 			}
512 		}
513 		if (got_all_tags_and_branches) {
514 			foreach (v; pack.versions) {
515 				auto ver = v.version_;
516 				if (ver !in existing) {
517 					logInfo("Package %s: removing version %s as the branch/tag was removed.", packname, ver);
518 					removeVersion(packname, ver);
519 				}
520 			}
521 		}
522 		m_db.setPackageErrors(packname, errors);
523 
524 		updatePackageStats(packname);
525 	}
526 }
527 
528 private PackageVersionInfo getVersionInfo(Repository rep, RefInfo commit, string first_filename_try, Path sub_path = Path("/"))
529 {
530 	import dub.recipe.io;
531 	import dub.recipe.json;
532 
533 	PackageVersionInfo ret;
534 	ret.date = commit.date.toSysTime();
535 	ret.sha = commit.sha;
536 	foreach (filename; chain((&first_filename_try)[0 .. 1], packageInfoFilenames.filter!(f => f != first_filename_try))) {
537 		if (!filename.length) continue;
538 		try {
539 			rep.readFile(commit.sha, sub_path ~ filename, (scope input) {
540 				auto text = input.readAllUTF8(false);
541 				auto recipe = parsePackageRecipe(text, filename);
542 				ret.info = recipe.toJson();
543 			});
544 
545 			ret.info["packageDescriptionFile"] = filename;
546 			logDebug("Found package description file %s.", filename);
547 
548 			foreach (ref sp; ret.info["subPackages"].opt!(Json[])) {
549 				if (sp.type == Json.Type..string) {
550 					auto path = sp.get!string;
551 					logDebug("Fetching path based sub package at %s", sub_path ~ path);
552 					auto subpack = getVersionInfo(rep, commit, first_filename_try, sub_path ~ path);
553 					sp = subpack.info;
554 					sp["path"] = path;
555 				}
556 			}
557 
558 			break;
559 		} catch (FileNotFoundException) {
560 			logDebug("Package description file %s not found...", filename);
561 		}
562 	}
563 	if (ret.info.type == Json.Type.undefined)
564 		 throw new Exception("Found no package description file in the repository.");
565 	return ret;
566 }
567 
568 private void checkPackageName(string n, string error_suffix)
569 {
570 	enforce(n.length > 0, "Package names may not be empty. "~error_suffix);
571 	foreach( ch; n ){
572 		switch(ch){
573 			default:
574 				throw new Exception("Package names may only contain ASCII letters and numbers, as well as '_' and '-': "~n~" - "~error_suffix);
575 			case 'a': .. case 'z':
576 			case 'A': .. case 'Z':
577 			case '0': .. case '9':
578 			case '_', '-':
579 				break;
580 		}
581 	}
582 }
583 
584 struct PackageVersionInfo {
585 	string version_;
586 	SysTime date;
587 	string sha;
588 	string downloadURL;
589 	Json info; /// JSON version information, as reported to the client
590 }
591 
592 struct PackageInfo {
593 	PackageVersionInfo[] versions;
594 	Json info; /// JSON package information, as reported to the client
595 }