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.dbcontroller;
7 
8 import dub.semver;
9 import std.array;
10 import std.algorithm;
11 import std.exception;
12 //import std.string;
13 import std.typecons : tuple;
14 import std.uni;
15 import vibe.vibe;
16 
17 
18 class DbController {
19 	private {
20 		MongoCollection m_packages;
21 		MongoCollection m_downloads;
22 	}
23 
24 	private alias bson = serializeToBson;
25 
26 	this(string dbname)
27 	{
28 		auto db = connectMongoDB("127.0.0.1").getDatabase(dbname);
29 		m_packages = db["packages"];
30 		m_downloads = db["downloads"];
31 
32 		// migrations
33 
34 		// update package format
35 		foreach(p; m_packages.find()){
36 			bool any_change = false;
37 			if (p["branches"].type == Bson.Type.object) {
38 				Bson[] branches;
39 				foreach( b; p["branches"] )
40 					branches ~= b;
41 				p["branches"] = branches;
42 				any_change = true;
43 			}
44 			if (p["branches"].type == Bson.Type.array) {
45 				auto versions = p["versions"].get!(Bson[]);
46 				foreach (b; p["branches"]) versions ~= b;
47 				p["branches"] = Bson(null);
48 				p["versions"] = Bson(versions);
49 				any_change = true;
50 			}
51 			if (any_change) m_packages.update(["_id": p["_id"]], p);
52 		}
53 
54 		// add updateCounter field for packages that don't have it yet
55 		m_packages.update(["updateCounter": ["$exists": false]], ["$set" : ["updateCounter" : 0L]], UpdateFlags.multiUpdate);
56 
57 		// add default non-@optional stats to packages
58 		DbPackageStats stats;
59 		m_packages.update(["stats": ["$exists": false]], ["$set": ["stats": stats]], UpdateFlags.multiUpdate);
60 
61 		// create indices
62 		m_packages.ensureIndex([tuple("name", 1)], IndexFlags.Unique);
63 		m_downloads.ensureIndex([tuple("package", 1), tuple("version", 1)]);
64 
65 		Bson[string] doc;
66 		doc["v"] = 1;
67 		doc["key"] = ["_fts": Bson("text"), "_ftsx": Bson(1)];
68 		doc["ns"] = db.name ~ "." ~ m_packages.name;
69 		doc["name"] = "packages_full_text_search_index";
70 		doc["weights"] = [
71 			"name": Bson(4),
72 			"versions.info.description" : Bson(3),
73 			// TODO: try to index readme
74 			"versions.info.homepage" : Bson(1),
75 			"versions.info.author" : Bson(1),
76 		];
77 		doc["background"] = true;
78 		db["system.indexes"].insert(doc);
79 
80 		// sort package versions newest to oldest
81 		// TODO: likely can be removed as we're now sorting on insert
82 		repairVersionOrder();
83 	}
84 
85 	void addPackage(ref DbPackage pack)
86 	{
87 		enforce(m_packages.findOne(["name": pack.name], ["_id": true]).isNull(), "A package with the same name is already registered.");
88 		if (pack._id == BsonObjectID.init)
89 			pack._id = BsonObjectID.generate();
90 		m_packages.insert(pack);
91 	}
92 
93 	void addOrSetPackage(ref DbPackage pack)
94 	{
95 		enforce(pack._id != BsonObjectID.init, "Cannot update a packag with no ID.");
96 		m_packages.update(["_id": pack._id], pack, UpdateFlags.upsert);
97 	}
98 
99 	DbPackage getPackage(string packname)
100 	{
101 		auto bpack = m_packages.findOne(["name": packname]);
102 		enforce!RecordNotFound(!bpack.isNull(), "Unknown package name.");
103 		return deserializeBson!DbPackage(bpack);
104 	}
105 
106 	BsonObjectID getPackageID(string packname)
107 	{
108 		auto bpack = m_packages.findOne(["name": packname], ["_id": 1]);
109 		enforce(!bpack.isNull(), "Unknown package name.");
110 		return bpack["_id"].get!BsonObjectID;
111 	}
112 
113 	DbPackage getPackage(BsonObjectID id)
114 	{
115 		auto bpack = m_packages.findOne(["_id": id]);
116 		enforce!RecordNotFound(!bpack.isNull(), "Unknown package ID.");
117 		return deserializeBson!DbPackage(bpack);
118 	}
119 
120 	auto getAllPackages()
121 	{
122 		return m_packages.find(Bson.emptyObject, ["name": 1]).map!(p => p["name"].get!string)();
123 	}
124 
125 	auto getAllPackageIDs()
126 	{
127 		return m_packages.find(Bson.emptyObject, ["_id": 1]).map!(p => p["_id"].get!BsonObjectID)();
128 	}
129 
130 	auto getPackageDump()
131 	{
132 		return m_packages.find(Bson.emptyObject).map!(p => p.deserializeBson!DbPackage);
133 	}
134 
135 	auto getUserPackages(BsonObjectID user_id)
136 	{
137 		return m_packages.find(["owner": user_id], ["name": 1]).map!(p => p["name"].get!string)();
138 	}
139 
140 	bool isUserPackage(BsonObjectID user_id, string package_name)
141 	{
142 		return !m_packages.findOne(["owner": Bson(user_id), "name": Bson(package_name)]).isNull();
143 	}
144 
145 	void removePackage(string packname, BsonObjectID user)
146 	{
147 		m_packages.remove(["name": Bson(packname), "owner": Bson(user)]);
148 	}
149 
150 	void setPackageErrors(string packname, string[] error...)
151 	{
152 		m_packages.update(["name": packname], ["$set": ["errors": error]]);
153 	}
154 
155 	void setPackageCategories(string packname, string[] categories...)
156 	{
157 		m_packages.update(["name": packname], ["$set": ["categories": categories]]);
158 	}
159 
160 	void setPackageRepository(string packname, DbRepository repo)
161 	{
162 		m_packages.update(["name": packname], ["$set": ["repository": repo]]);
163 	}
164 
165 	void addVersion(string packname, DbPackageVersion ver)
166 	{
167 		assert(ver.version_.startsWith("~") || ver.version_.isValidVersion());
168 
169 		size_t nretrys = 0;
170 
171 		while (true) {
172 			auto pack = m_packages.findOne(["name": packname], ["versions": true, "updateCounter": true]);
173 			auto counter = pack["updateCounter"].get!long;
174 			auto versions = deserializeBson!(DbPackageVersion[])(pack["versions"]);
175 			auto new_versions = versions ~ ver;
176 			new_versions.sort!((a, b) => vcmp(a, b));
177 
178 			// remove versions with invalid dependency names to avoid the findAndModify below to fail
179 			new_versions = new_versions.filter!(
180 					v => !v.info["dependencies"].opt!(Json[string]).byKey.canFind!(k => k.canFind("."))
181 				).array;
182 
183 			//assert((cast(Json)bversions).toString() == (cast(Json)serializeToBson(versions)).toString());
184 
185 			auto res = m_packages.findAndModify(
186 				["name": Bson(packname), "updateCounter": Bson(counter)],
187 				["$set": ["versions": serializeToBson(new_versions), "updateCounter": Bson(counter+1)]],
188 				["_id": true]);
189 
190 			if (!res.isNull) return;
191 
192 			enforce(nretrys++ < 20, format("Failed to store updated version list for %s", packname));
193 			logDebug("Failed to update version list atomically, retrying...");
194 		}
195 	}
196 
197 	void removeVersion(string packname, string ver)
198 	{
199 		assert(ver.startsWith("~") || ver.isValidVersion());
200 		m_packages.update(["name": packname], ["$pull": ["versions": ["version": ver]]]);
201 	}
202 
203 	void updateVersion(string packname, DbPackageVersion ver)
204 	{
205 		assert(ver.version_.startsWith("~") || ver.version_.isValidVersion());
206 		m_packages.update(["name": packname, "versions.version": ver.version_], ["$set": ["versions.$": ver]]);
207 	}
208 
209 	bool hasVersion(string packname, string ver)
210 	{
211 		auto ret = m_packages.findOne(["name": packname, "versions.version" : ver], ["_id": true]);
212 		return !ret.isNull();
213 	}
214 
215 	string getLatestVersion(string packname)
216 	{
217 		auto slice = serializeToBson(["$slice": -1]);
218 		auto pack = m_packages.findOne(["name": packname], ["_id": Bson(true), "versions": slice]);
219 		if (pack.isNull() || pack["versions"].isNull() || pack["versions"].length != 1) return null;
220 		return deserializeBson!(string)(pack["versions"][0]["version"]);
221 	}
222 
223 	DbPackageVersion getVersionInfo(string packname, string ver)
224 	{
225 		auto pack = m_packages.findOne(["name": packname, "versions.version": ver], ["versions.$": true]);
226 		enforce(!pack.isNull(), "unknown package/version");
227 		assert(pack["versions"].length == 1);
228 		return deserializeBson!(DbPackageVersion)(pack["versions"][0]);
229 	}
230 
231 	DbPackage[] searchPackages(string query)
232 	{
233 		if (!query.strip.length) {
234 			return m_packages.find()
235 				.sort(["name": 1])
236 				.map!(deserializeBson!DbPackage)
237 				.array;
238 		}
239 
240 		return m_packages
241 			.find(["$text": ["$search": query]], ["score": ["$meta": "textScore"]])
242 			.sort(["score": ["$meta": "textScore"]])
243 			.map!(deserializeBson!DbPackage)
244 			.array;
245 	}
246 
247 	BsonObjectID addDownload(BsonObjectID pack, string ver, string user_agent)
248 	{
249 		DbPackageDownload download;
250 		download._id = BsonObjectID.generate();
251 		download.package_ = pack;
252 		download.version_ = ver;
253 		download.time = Clock.currTime(UTC());
254 		download.userAgent = user_agent;
255 		m_downloads.insert(download);
256 		return download._id;
257 	}
258 
259 	DbPackageStats getPackageStats(string packname)
260 	{
261 		auto pack = m_packages.findOne(["name": Bson(packname)], ["stats": true]);
262 		enforce!RecordNotFound(!pack.isNull(), "Unknown package name.");
263 		logDebug("getPackageStats(%s) %s", packname, pack["stats"]);
264 		return pack["stats"].deserializeBson!DbPackageStats;
265 	}
266 
267 	void updatePackageStats(BsonObjectID packId, ref DbPackageStats stats)
268 	{
269 		stats.updatedAt = Clock.currTime(UTC());
270 		logDebug("updatePackageStats(%s, %s)", packId, stats);
271 		m_packages.update(["_id": packId], ["$set": ["stats": stats]]);
272 	}
273 
274 	DbDownloadStats aggregateDownloadStats(BsonObjectID packId, string ver = null)
275 	{
276 		static Bson newerThan(SysTime time)
277 		{
278 			// doc.time >= time ? 1 : 0
279 			alias bs = serializeToBson;
280 			return bs([
281 				"$cond": [bs(["$gte": [bs("$time"), bs(time)]]), bs(1), bs(0)]
282 			]);
283 		}
284 
285 		auto match = Bson.emptyObject();
286 		match["package"] = Bson(packId);
287 		if (ver.length) match["version"] = ver;
288 
289 		immutable now = Clock.currTime;
290 		auto res = m_downloads.aggregate(
291 			["$match": match],
292 			["$project": [
293 					"_id": Bson(false),
294 					"total": serializeToBson(["$literal": 1]),
295 					"monthly": newerThan(now - 30.days),
296 					"weekly": newerThan(now - 7.days),
297 					"daily": newerThan(now - 1.days)]],
298 			["$group": [
299 					"_id": Bson(null), // single group
300 					"total": Bson(["$sum": Bson("$total")]),
301 					"monthly": Bson(["$sum": Bson("$monthly")]),
302 					"weekly": Bson(["$sum": Bson("$weekly")]),
303 					"daily": Bson(["$sum": Bson("$daily")])]]);
304 		assert(res.length <= 1);
305 		return res.length ? deserializeBson!DbDownloadStats(res[0]) : DbDownloadStats.init;
306 	}
307 
308 	private void repairVersionOrder()
309 	{
310 		foreach( bp; m_packages.find() ){
311 			auto p = deserializeBson!DbPackage(bp);
312 			auto newversions = p.versions
313 				.filter!(v => v.version_.startsWith("~") || v.version_.isValidVersion)
314 				.array
315 				.sort!((a, b) => vcmp(a, b))
316 				.uniq!((a, b) => a.version_ == b.version_)
317 				.array;
318 			if (p.versions != newversions)
319 				m_packages.update(["_id": p._id], ["$set": ["versions": newversions]]);
320 		}
321 	}
322 }
323 
324 class RecordNotFound : Exception
325 {
326     @nogc @safe pure nothrow this(string msg, string file = __FILE__, size_t line = __LINE__, Throwable next = null)
327     {
328         super(msg, file, line, next);
329     }
330 
331     @nogc @safe pure nothrow this(string msg, Throwable next, string file = __FILE__, size_t line = __LINE__)
332     {
333         super(msg, file, line, next);
334     }
335 }
336 
337 struct DbPackage {
338 	BsonObjectID _id;
339 	BsonObjectID owner;
340 	string name;
341 	DbRepository repository;
342 	DbPackageVersion[] versions;
343 	DbPackageStats stats;
344 	string[] errors;
345 	string[] categories;
346 	long updateCounter = 0; // used to implement lockless read-modify-write cycles
347 }
348 
349 struct DbRepository {
350 	string kind;
351 	string owner;
352 	string project;
353 }
354 
355 struct DbPackageVersion {
356 	SysTime date;
357 	string version_;
358 	@optional string commitID;
359 	Json info;
360 	@optional string readme;
361 }
362 
363 struct DbPackageDownload {
364 	BsonObjectID _id;
365 	BsonObjectID package_;
366 	string version_;
367 	SysTime time;
368 	string userAgent;
369 }
370 
371 struct DbPackageStats {
372 	SysTime updatedAt;
373 	DbDownloadStats downloads;
374 	DbRepoStats repo;
375 }
376 
377 struct DbDownloadStats {
378 	uint total, monthly, weekly, daily;
379 }
380 
381 struct DbRepoStats {
382 	uint stars, watchers, forks, issues;
383 }
384 
385 bool vcmp(DbPackageVersion a, DbPackageVersion b)
386 {
387 	return vcmp(a.version_, b.version_);
388 }
389 
390 bool vcmp(string va, string vb)
391 {
392 	import dub.dependency;
393 	return Version(va) < Version(vb);
394 }
395 
396 private string[] splitAlphaNumParts(string str)
397 {
398 	string[] ret;
399 	while (!str.empty) {
400 		while (!str.empty && !str.front.isIdentChar()) str.popFront();
401 		if (str.empty) break;
402 		size_t i = str.length;
403 		foreach (j, dchar ch; str)
404 			if (!isIdentChar(ch)) {
405 				i = j;
406 				break;
407 			}
408 		if (i > 0) {
409 			ret ~= str[0 .. i];
410 			str = str[i .. $];
411 		}
412 		if (!str.empty) str.popFront(); // pop non-ident-char
413 	}
414 	return ret;
415 }
416 
417 private bool isIdentChar(dchar ch)
418 {
419 	return std.uni.isAlpha(ch) || std.uni.isNumber(ch);
420 }