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