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