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("Removing package %s of %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 	Json getPackageStats(string packname)
164 	{
165 		DbPackage pack;
166 		try pack = m_db.getPackage(packname);
167 		catch(Exception) return Json(null);
168 		return PackageStats(m_db.getDownloadStats(pack._id)).serializeToJson();
169 	}
170 
171 	Json getPackageStats(string packname, string ver)
172 	{
173 		DbPackage pack;
174 		try pack = m_db.getPackage(packname);
175 		catch(Exception) return Json(null);
176 		if (ver == "latest") ver = getLatestVersion(packname);
177 		if (!m_db.hasVersion(packname, ver)) return Json(null);
178 		return PackageStats(m_db.getDownloadStats(pack._id, ver)).serializeToJson();
179 	}
180 
181 	Json getPackageVersionInfo(string packname, string ver)
182 	{
183 		if (ver == "latest") ver = getLatestVersion(packname);
184 		if (!m_db.hasVersion(packname, ver)) return Json(null);
185 		return m_db.getVersionInfo(packname, ver).serializeToJson();
186 	}
187 
188 	string getLatestVersion(string packname)
189 	{
190 		return m_db.getLatestVersion(packname);
191 	}
192 
193 	PackageInfo getPackageInfo(string packname, bool include_errors = false)
194 	{
195 		DbPackage pack;
196 		try pack = m_db.getPackage(packname);
197 		catch(Exception) return PackageInfo.init;
198 
199 		return getPackageInfo(pack, include_errors);
200 	}
201 
202 	PackageInfo getPackageInfo(DbPackage pack, bool include_errors)
203 	{
204 		auto rep = getRepository(pack.repository);
205 
206 		PackageInfo ret;
207 		ret.versions = pack.versions.map!(v => getPackageVersionInfo(v, rep)).array;
208 
209 		Json nfo = Json.emptyObject;
210 		nfo["id"] = pack._id.toString();
211 		nfo["dateAdded"] = pack._id.timeStamp.toISOExtString();
212 		nfo["owner"] = pack.owner.toString();
213 		nfo["name"] = pack.name;
214 		nfo["versions"] = Json(ret.versions.map!(v => v.info).array);
215 		nfo["repository"] = serializeToJson(pack.repository);
216 		nfo["categories"] = serializeToJson(pack.categories);
217 		if(include_errors) nfo["errors"] = serializeToJson(pack.errors);
218 
219 		ret.info = nfo;
220 
221 		return ret;
222 	}
223 
224 	private PackageVersionInfo getPackageVersionInfo(DbPackageVersion v, Repository rep)
225 	{
226 		// JSON package version info as reported to the client
227 		auto nfo = v.info.get!(Json[string]).dup;
228 		nfo["version"] = v.version_;
229 		nfo["date"] = v.date.toISOExtString();
230 		nfo["readmeFile"] = v.readme;
231 		nfo["commitID"] = v.commitID;
232 
233 		PackageVersionInfo ret;
234 		ret.info = Json(nfo);
235 		ret.date = v.date;
236 		ret.sha = v.commitID;
237 		ret.version_ = v.version_;
238 		ret.downloadURL = rep.getDownloadUrl(v.version_.startsWith("~") ? v.version_ : "v"~v.version_);
239 		return ret;
240 	}
241 
242 	string getReadme(Json version_info, DbRepository repository)
243 	{
244 		string ret;
245 		auto file = version_info["readmeFile"].opt!string;
246 		try {
247 			if (!file.startsWith('/')) return null;
248 			auto rep = getRepository(repository);
249 			logDebug("reading readme file for %s: %s", version_info["name"].get!string, file);
250 			rep.readFile(version_info["commitID"].get!string, Path(file), (scope data) {
251 				ret = data.readAllUTF8();
252 			});
253 			logDebug("reading readme file done");
254 		} catch (Exception e) {
255 			logDiagnostic("Failed to read README file (%s) for %s %s: %s",
256 				file, version_info["name"].get!string,
257 				version_info["version"].get!string, e.msg);
258 		}
259 		return ret;
260 	}
261 
262 	void downloadPackageZip(string packname, string vers, void delegate(scope InputStream) del)
263 	{
264 		DbPackage pack = m_db.getPackage(packname);
265 		auto rep = getRepository(pack.repository);
266 		rep.download(vers, del);
267 	}
268 
269 	void setPackageCategories(string pack_name, string[] categories)
270 	{
271 		m_db.setPackageCategories(pack_name, categories);
272 	}
273 
274 	void setPackageRepository(string pack_name, DbRepository repository)
275 	{
276 		auto new_name = validateRepository(repository);
277 		enforce(pack_name == new_name, "The package name of the new repository doesn't match the existing one: "~new_name);
278 		m_db.setPackageRepository(pack_name, repository);
279 	}
280 
281 	void checkForNewVersions()
282 	{
283 		logInfo("Triggering check for new versions...");
284 		foreach (packname; this.availablePackages)
285 			triggerPackageUpdate(packname);
286 	}
287 
288 	protected string validateRepository(DbRepository repository)
289 	{
290 		// find the packge info of ~master or any available branch
291 		PackageVersionInfo info;
292 		auto rep = getRepository(repository);
293 		auto branches = rep.getBranches();
294 		enforce(branches.length > 0, "The repository contains no branches.");
295 		auto idx = branches.countUntil!(b => b.name == "master");
296 		if (idx > 0) swap(branches[0], branches[idx]);
297 		string branch_errors;
298 		foreach (b; branches) {
299 			try {
300 				info = rep.getVersionInfo(b, null);
301 				enforce (info.info.type == Json.Type.object,
302 					"JSON package description must be a JSON object.");
303 				break;
304 			} catch (Exception e) {
305 				logDiagnostic("Error getting package info for %s", b);
306 				branch_errors ~= format("\n%s: %s", b.name, e.msg);
307 			}
308 		}
309 		enforce (info.info.type == Json.Type.object,
310 			"Failed to find a branch containing a valid package description file:" ~ branch_errors);
311 
312 		// derive package name and perform various sanity checks
313 		auto name = info.info["name"].get!string;
314 		string package_desc_file = info.info["packageDescriptionFile"].get!string;
315 		string package_check_string = format(`Check your %s.`, package_desc_file);
316 		enforce(name.length <= 60,
317 			"Package names must not be longer than 60 characters: \""~name[0 .. 60]~"...\" - "~package_check_string);
318 		enforce(name == name.toLower(),
319 			"Package names must be all lower case, not \""~name~"\". "~package_check_string);
320 		enforce(info.info["license"].opt!string.length > 0,
321 			`A "license" field in the package description file is missing or empty. `~package_check_string);
322 		enforce(info.info["description"].opt!string.length > 0,
323 			`A "description" field in the package description file is missing or empty. `~package_check_string);
324 		checkPackageName(name, format(`Check the "name" field of your %s.`, package_desc_file));
325 		foreach (string n, vspec; info.info["dependencies"].opt!(Json[string])) {
326 			auto parts = n.split(":").array;
327 			// allow shortcut syntax ":subpack"
328 			if (parts.length > 1 && parts[0].length == 0) parts = parts[1 .. $];
329 			// verify all other parts of the package name
330 			foreach (p; parts)
331 				checkPackageName(p, format(`Check the "dependencies" field of your %s.`, package_desc_file));
332 		}
333 
334 		// ensure that at least one tagged version is present
335 		auto tags = rep.getTags();
336 		enforce(tags.canFind!(t => t.name.startsWith("v") && t.name[1 .. $].isValidVersion),
337 			`The repository must have at least one tagged version (SemVer format, e.g. `
338 			~ `"v1.0.0" or "v0.0.1") to be published on the registry. Please add a proper tag using `
339 			~ `"git tag" or equivalent means and see http://semver.org for more information.`);
340 
341 		return name;
342 	}
343 
344 	protected bool addVersion(string packname, string ver, Repository rep, RefInfo reference)
345 	{
346 		logDiagnostic("Adding new version info %s for %s", ver, packname);
347 		assert(ver.startsWith("~") && !ver.startsWith("~~") || isValidVersion(ver));
348 
349 		auto dbpack = m_db.getPackage(packname);
350 		string deffile;
351 		foreach (t; dbpack.versions)
352 			if (t.version_ == ver) {
353 				deffile = t.info["packageDescriptionFile"].opt!string;
354 				break;
355 			}
356 		auto info = getVersionInfo(rep, reference, deffile);
357 
358 		//assert(info.info.name == info.info.name.get!string.toLower(), "Package names must be all lower case.");
359 		info.info["name"] = info.info["name"].get!string.toLower();
360 		enforce(info.info["name"] == packname,
361 			format("Package name (%s) does not match the original package name (%s). Check %s.",
362 				info.info["name"].get!string, packname, info.info["packageDescriptionFile"].get!string));
363 
364 		if ("description" !in info.info || "license" !in info.info) {
365 		//enforce("description" in info.info && "license" in info.info,
366 			throw new Exception(
367 			"Published packages must contain \"description\" and \"license\" fields.");
368 		}
369 
370 		foreach( string n, vspec; info.info["dependencies"].opt!(Json[string]) )
371 			foreach (p; n.split(":"))
372 				checkPackageName(p, "Check "~info.info["packageDescriptionFile"].get!string~".");
373 
374 		DbPackageVersion dbver;
375 		dbver.date = info.date;
376 		dbver.version_ = ver;
377 		dbver.commitID = info.sha;
378 		dbver.info = info.info;
379 
380 		try {
381 			rep.readFile(reference.sha, Path("/README.md"), (scope input) { input.readAll(); });
382 			dbver.readme = "/README.md";
383 		} catch (Exception e) { logDiagnostic("No README.md found for %s %s", packname, ver); }
384 
385 		if (m_db.hasVersion(packname, ver)) {
386 			logDebug("Updating existing version info.");
387 			m_db.updateVersion(packname, dbver);
388 			return false;
389 		}
390 
391 		//enforce(!m_db.hasVersion(packname, dbver.version_), "Version already exists.");
392 		if (auto pv = "version" in info.info)
393 			enforce(pv.get!string == ver, format("Package description contains an obsolete \"version\" field and does not match tag %s: %s", ver, pv.get!string));
394 		logDebug("Adding new version info.");
395 		m_db.addVersion(packname, dbver);
396 		return true;
397 	}
398 
399 	protected void removeVersion(string packname, string ver)
400 	{
401 		assert(ver.startsWith("~") && !ver.startsWith("~~") || isValidVersion(ver));
402 
403 		m_db.removeVersion(packname, ver);
404 	}
405 
406 	private void processUpdateQueue()
407 	{
408 		scope (exit) logWarn("Update task was killed!");
409 		while (true) {
410 			m_lastSignOfLifeOfUpdateTask = Clock.currTime(UTC());
411 			logDiagnostic("Getting new package to be updated...");
412 			string pack;
413 			synchronized (m_updateQueueMutex) {
414 				while (m_updateQueue.empty) {
415 					logDiagnostic("Waiting for package to be updated...");
416 					m_updateQueueCondition.wait();
417 				}
418 				pack = m_updateQueue.front;
419 				m_updateQueue.popFront();
420 				m_currentUpdatePackage = pack;
421 			}
422 			scope(exit) m_currentUpdatePackage = null;
423 			logDiagnostic("Updating package %s.", pack);
424 			try checkForNewVersions(pack);
425 			catch (Exception e) {
426 				logWarn("Failed to check versions for %s: %s", pack, e.msg);
427 				logDiagnostic("Full error: %s", e.toString().sanitize);
428 			}
429 		}
430 	}
431 
432 	private void checkForNewVersions(string packname)
433 	{
434 		import std.encoding;
435 		string[] errors;
436 
437 		PackageInfo pack;
438 		try pack = getPackageInfo(packname);
439 		catch( Exception e ){
440 			errors ~= format("Error getting package info: %s", e.msg);
441 			logDebug("%s", sanitize(e.toString()));
442 			return;
443 		}
444 
445 		Repository rep;
446 		try rep = getRepository(pack.info["repository"].deserializeJson!DbRepository);
447 		catch( Exception e ){
448 			errors ~= format("Error accessing repository: %s", e.msg);
449 			logDebug("%s", sanitize(e.toString()));
450 			return;
451 		}
452 
453 		bool[string] existing;
454 		RefInfo[] tags, branches;
455 		bool got_all_tags_and_branches = false;
456 		try {
457 			tags = rep.getTags()
458 				.filter!(a => a.name.startsWith("v") && a.name[1 .. $].isValidVersion)
459 				.array
460 				.sort!((a, b) => compareVersions(a.name[1 .. $], b.name[1 .. $]) < 0)
461 				.array;
462 			branches = rep.getBranches();
463 			got_all_tags_and_branches = true;
464 		} catch (Exception e) {
465 			errors ~= format("Failed to get GIT tags/branches: %s", e.msg);
466 		}
467 		logInfo("Updating tags for %s: %s", packname, tags.map!(t => t.name).array);
468 		foreach (tag; tags) {
469 			auto name = tag.name[1 .. $];
470 			existing[name] = true;
471 			try {
472 				if (addVersion(packname, name, rep, tag))
473 					logInfo("Added version %s of %s", name, packname);
474 			} catch( Exception e ){
475 				logInfo("Error for version %s of %s: %s", name, packname, e.msg);
476 				logDebug("Full error: %s", sanitize(e.toString()));
477 				errors ~= format("Version %s: %s", name, e.msg);
478 			}
479 		}
480 		logInfo("Updating branches for %s: %s", packname, branches.map!(t => t.name).array);
481 		foreach (branch; branches) {
482 			auto name = "~" ~ branch.name;
483 			existing[name] = true;
484 			try {
485 				if (addVersion(packname, name, rep, branch))
486 					logInfo("Added branch %s for %s", name, packname);
487 			} catch( Exception e ){
488 				logInfo("Error for branch %s of %s: %s", name, packname, e.msg);
489 				logDebug("Full error: %s", sanitize(e.toString()));
490 				if (branch.name != "gh-pages") // ignore errors on the special GitHub website branch
491 					errors ~= format("Branch %s: %s", name, e.msg);
492 			}
493 		}
494 		if (got_all_tags_and_branches) {
495 			foreach (v; pack.versions) {
496 				auto ver = v.version_;
497 				if (ver !in existing) {
498 					logInfo("Removing version %s as the branch/tag was removed.", ver);
499 					removeVersion(packname, ver);
500 				}
501 			}
502 		}
503 		m_db.setPackageErrors(packname, errors);
504 	}
505 }
506 
507 private PackageVersionInfo getVersionInfo(Repository rep, RefInfo commit, string first_filename_try, Path sub_path = Path("/"))
508 {
509 	import dub.recipe.io;
510 	import dub.recipe.json;
511 
512 	PackageVersionInfo ret;
513 	ret.date = commit.date.toSysTime();
514 	ret.sha = commit.sha;
515 	foreach (filename; chain((&first_filename_try)[0 .. 1], packageInfoFilenames.filter!(f => f != first_filename_try))) {
516 		if (!filename.length) continue;
517 		try {
518 			rep.readFile(commit.sha, sub_path ~ filename, (scope input) {
519 				auto text = input.readAllUTF8(false);
520 				auto recipe = parsePackageRecipe(text, filename);
521 				ret.info = recipe.toJson();
522 			});
523 
524 			ret.info["packageDescriptionFile"] = filename;
525 			logDebug("Found package description file %s.", filename);
526 
527 			foreach (ref sp; ret.info["subPackages"].opt!(Json[])) {
528 				if (sp.type == Json.Type..string) {
529 					auto path = sp.get!string;
530 					logDebug("Fetching path based sub package at %s", sub_path ~ path);
531 					auto subpack = getVersionInfo(rep, commit, first_filename_try, sub_path ~ path);
532 					sp = subpack.info;
533 					sp["path"] = path;
534 				}
535 			}
536 
537 			break;
538 		} catch (FileNotFoundException) {
539 			logDebug("Package description file %s not found...", filename);
540 		}
541 	}
542 	if (ret.info.type == Json.Type.undefined)
543 		 throw new Exception("Found no package description file in the repository.");
544 	return ret;
545 }
546 
547 private void checkPackageName(string n, string error_suffix)
548 {
549 	enforce(n.length > 0, "Package names may not be empty. "~error_suffix);
550 	foreach( ch; n ){
551 		switch(ch){
552 			default:
553 				throw new Exception("Package names may only contain ASCII letters and numbers, as well as '_' and '-': "~n~" - "~error_suffix);
554 			case 'a': .. case 'z':
555 			case 'A': .. case 'Z':
556 			case '0': .. case '9':
557 			case '_', '-':
558 				break;
559 		}
560 	}
561 }
562 
563 struct PackageStats {
564 	DbDownloadStats downloads;
565 }
566 
567 struct PackageVersionInfo {
568 	string version_;
569 	SysTime date;
570 	string sha;
571 	string downloadURL;
572 	Json info; /// JSON version information, as reported to the client
573 }
574 
575 struct PackageInfo {
576 	PackageVersionInfo[] versions;
577 	Json info; /// JSON package information, as reported to the client
578 }