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