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.uni;
14 import vibe.vibe;
15 
16 
17 class DbController {
18 	private {
19 		MongoCollection m_packages;
20 		MongoCollection m_downloads;
21 	}
22 
23 	this(string dbname)
24 	{
25 		auto db = connectMongoDB("127.0.0.1").getDatabase(dbname);
26 		m_packages = db["packages"];
27 		m_downloads = db["downloads"];
28 
29 		// update package format
30 		foreach(p; m_packages.find()){
31 			if (p.branches.type == Bson.Type.object) {
32 				Bson[] branches;
33 				foreach( b; p.branches )
34 					branches ~= b;
35 				p.branches = branches;
36 			}
37 			if (p.branches.type == Bson.Type.array) {
38 				auto versions = p.versions.get!(Bson[]);
39 				foreach (b; p.branches) versions ~= b;
40 				p.branches = Bson(null);
41 				p.versions = Bson(versions);
42 			}
43 			m_packages.update(["_id": p._id], p);
44 		}
45 
46 		repairVersionOrder();
47 
48 		// create indices
49 		m_packages.ensureIndex(["name": 1], IndexFlags.Unique);
50 		m_packages.ensureIndex(["searchTerms": 1]);
51 		m_downloads.ensureIndex([tuple("package", 1), tuple("version", 1)]);
52 	}
53 
54 	void addPackage(ref DbPackage pack)
55 	{
56 		enforce(m_packages.findOne(["name": pack.name], ["_id": true]).isNull(), "A package with the same name is already registered.");
57 		pack._id = BsonObjectID.generate();
58 		m_packages.insert(pack);
59 		updateKeywords(pack.name);
60 	}
61 
62 	DbPackage getPackage(string packname)
63 	{
64 		auto bpack = m_packages.findOne(["name": packname]);
65 		enforce(!bpack.isNull(), "Unknown package name.");
66 		return deserializeBson!DbPackage(bpack);
67 	}
68 
69 	auto getAllPackages()
70 	{
71 		return m_packages.find(Bson.emptyObject, ["name": 1]).map!(p => p.name.get!string)();
72 	}
73 
74 	auto getUserPackages(BsonObjectID user_id)
75 	{
76 		return m_packages.find(["owner": user_id], ["name": 1]).map!(p => p.name.get!string)();
77 	}
78 
79 	bool isUserPackage(BsonObjectID user_id, string package_name)
80 	{
81 		return !m_packages.findOne(["owner": Bson(user_id), "name": Bson(package_name)]).isNull();
82 	}
83 
84 	void removePackage(string packname, BsonObjectID user)
85 	{
86 		m_packages.remove(["name": Bson(packname), "owner": Bson(user)]);
87 	}
88 
89 	void setPackageErrors(string packname, string[] error...)
90 	{
91 		m_packages.update(["name": packname], ["$set": ["errors": error]]);
92 	}
93 
94 	void setPackageCategories(string packname, string[] categories...)
95 	{
96 		m_packages.update(["name": packname], ["$set": ["categories": categories]]);
97 	}
98 
99 	void setPackageRepository(string packname, Json repo)
100 	{
101 		m_packages.update(["name": packname], ["$set": ["repository": repo]]);
102 	}
103 
104 	void addVersion(string packname, DbPackageVersion ver)
105 	{
106 		assert(ver.version_.startsWith("~") || ver.version_.isValidVersion());
107 
108 		size_t nretrys = 0;
109 
110 		while (true) {
111 			auto bversions = m_packages.findOne(["name": packname], ["versions": true]).versions;
112 			auto versions = deserializeBson!(DbPackageVersion[])(bversions);
113 			auto new_versions = versions ~ ver;
114 			new_versions.sort!((a, b) => vcmp(a, b));
115 
116 			//assert((cast(Json)bversions).toString() == (cast(Json)serializeToBson(versions)).toString());
117 
118 			auto res = m_packages.findAndModify(["name": Bson(packname), "versions": bversions], ["$set": ["versions": new_versions]], ["_id": true]);
119 			if (!res.isNull) {
120 				updateKeywords(packname);
121 				return;
122 			}
123 
124 			enforce(nretrys++ < 20, format("Failed to store updated version list for %s", packname));
125 			logDebug("Failed to update version list atomically, retrying...");
126 		}
127 	}
128 
129 	void removeVersion(string packname, string ver)
130 	{
131 		assert(ver.startsWith("~") || ver.isValidVersion());
132 		m_packages.update(["name": packname], ["$pull": ["versions": ["version": ver]]]);
133 	}
134 
135 	void updateVersion(string packname, DbPackageVersion ver)
136 	{
137 		assert(ver.version_.startsWith("~") || ver.version_.isValidVersion());
138 		m_packages.update(["name": packname, "versions.version": ver.version_], ["$set": ["versions.$": ver]]);
139 		updateKeywords(packname);
140 	}
141 
142 	bool hasVersion(string packname, string ver)
143 	{
144 		auto ret = m_packages.findOne(["name": packname, "versions.version" : ver], ["_id": true]);
145 		return !ret.isNull();
146 	}
147 
148 	string getLatestVersion(string packname)
149 	{
150 		auto slice = serializeToBson(["$slice": -1]);
151 		auto pack = m_packages.findOne(["name": packname], ["_id": Bson(true), "versions": slice]);
152 		if (pack.isNull() || pack.versions.isNull() || pack.versions.length != 1) return null;
153 		return deserializeBson!(string)(pack.versions[0]["version"]);
154 	}
155 
156 	DbPackageVersion getVersionInfo(string packname, string ver)
157 	{
158 		auto pack = m_packages.findOne(["name": packname, "versions.version": ver], ["versions.$": true]);
159 		enforce(!pack.isNull(), "unknown package/version");
160 		assert(pack.versions.length == 1);
161 		return deserializeBson!(DbPackageVersion)(pack.versions[0]);
162 	}
163 
164 	auto searchPackages(string[] keywords)
165 	{
166 		Appender!(string[]) barekeywords;
167 		foreach( kw; keywords ) {
168 			kw = kw.strip();
169 			//kw = kw.normalize(); // separate character from diacritics
170 			string[] parts = splitAlphaNumParts(kw.toLower());
171 			barekeywords ~= parts.filter!(p => p.count >= 2).map!(p => p.toLower).array;
172 		}
173 		logInfo("search for %s %s", keywords, barekeywords.data);
174 
175 		static if (0) {
176 			// performs only exact matches - we should implement something more
177 			// flexible, for example based on elastic search
178 			return m_packages.find(["searchTerms": ["$all": barekeywords.data]]).map!(b => deserializeBson!DbPackage(b))();
179 		} else {
180 			// in the meantime, we'll perform a brute force search instead
181 			Appender!(Tuple!(DbPackage, size_t)[]) results;
182 			foreach (p; m_packages.find().map!(b => deserializeBson!DbPackage(b))) {
183 				size_t score = 0;
184 				foreach (t; p.searchTerms)
185 					foreach (kw; barekeywords.data) {
186 						import std.algorithm;
187 						auto dist = levenshteinDistance(t, kw);
188 						if (dist <= 3 && dist+1 < kw.length) score += 3 - dist;
189 					}
190 				if (score > 0) results ~= tuple(p, score);
191 			}
192 			sort!((a, b) => a[1] > b[1])(results.data);
193 			return results.data.map!(r => r[0]);
194 		}
195 	}
196 
197 	BsonObjectID addDownload(BsonObjectID pack, string ver, string user_agent)
198 	{
199 		DbPackageDownload download;
200 		download._id = BsonObjectID.generate();
201 		download.package_ = pack;
202 		download.version_ = ver;
203 		download.time = Clock.currTime(UTC());
204 		download.userAgent = user_agent;
205 		m_downloads.insert(download);
206 		return download._id;
207 	}
208 
209 	auto getDownloadStats(BsonObjectID pack, string ver = null)
210 	{
211 		static Bson newerThan(SysTime time)
212 		{
213 			// doc.time >= time ? 1 : 0
214 			alias bs = serializeToBson;
215 			return bs([
216 				"$cond": [bs(["$gte": [bs("$time"), bs(time)]]), bs(1), bs(0)]
217 			]);
218 		}
219 
220 		auto match = Bson.emptyObject();
221 		match["package"] = Bson(pack);
222 		if (ver.length) match["version"] = ver;
223 
224 		immutable now = Clock.currTime;
225 		auto res = m_downloads.aggregate(
226 			["$match": match],
227 			["$project": [
228 					"_id": Bson(false),
229 					"total": serializeToBson(["$literal": 1]),
230 					"monthly": newerThan(now - 30.days),
231 					"weekly": newerThan(now - 7.days),
232 					"daily": newerThan(now - 1.days)]],
233 			["$group": [
234 					"_id": Bson(null), // single group
235 					"total": Bson(["$sum": Bson("$total")]),
236 					"monthly": Bson(["$sum": Bson("$monthly")]),
237 					"weekly": Bson(["$sum": Bson("$weekly")]),
238 					"daily": Bson(["$sum": Bson("$daily")])]]);
239 		assert(res.length <= 1);
240 		return res.length ? deserializeBson!DbDownloadStats(res[0]) : DbDownloadStats.init;
241 	}
242 
243 	private void updateKeywords(string package_name)
244 	{
245 		auto p = getPackage(package_name);
246 		bool[string] keywords;
247 		void processString(string str) {
248 			if (str.length == 0) return;
249 			foreach (w; splitAlphaNumParts(str))
250 				if (w.count >= 2)
251 					keywords[w.toLower()] = true;
252 		}
253 		void processVer(Json info) {
254 			if (auto pv = "description" in info) processString(pv.opt!string);
255 			if (auto pv = "authors" in info) processString(pv.opt!string);
256 			if (auto pv = "homepage" in info) processString(pv.opt!string);
257 		}
258 
259 		processString(p.name);
260 		foreach (ver; p.versions) processVer(ver.info);
261 
262 		Appender!(string[]) kwarray;
263 		foreach (kw; keywords.byKey) kwarray ~= kw;
264 		m_packages.update(["name": package_name], ["$set": ["searchTerms": kwarray.data]]);
265 	}
266 
267 	private void repairVersionOrder()
268 	{
269 		foreach( bp; m_packages.find() ){
270 			auto p = deserializeBson!DbPackage(bp);
271 			p.versions = p.versions
272 				.filter!(v => v.version_.startsWith("~") || v.version_.isValidVersion)
273 				.array
274 				.sort!((a, b) => vcmp(a, b))
275 				.array;
276 			m_packages.update(["_id": p._id], ["$set": ["versions": p.versions]]);
277 		}
278 	}
279 }
280 
281 struct DbPackage {
282 	BsonObjectID _id;
283 	BsonObjectID owner;
284 	string name;
285 	Json repository;
286 	DbPackageVersion[] versions;
287 	string[] errors;
288 	string[] categories;
289 	string[] searchTerms;
290 }
291 
292 struct DbPackageVersion {
293 	BsonDate date;
294 	string version_;
295 	@optional string commitID;
296 	Json info;
297 	@optional string readme;
298 }
299 
300 struct DbPackageDownload {
301 	BsonObjectID _id;
302 	BsonObjectID package_;
303 	string version_;
304 	SysTime time;
305 	string userAgent;
306 }
307 
308 struct DbDownloadStats {
309 	uint total, monthly, weekly, daily;
310 }
311 
312 bool vcmp(DbPackageVersion a, DbPackageVersion b)
313 {
314 	return vcmp(a.version_, b.version_);
315 }
316 
317 bool vcmp(string va, string vb)
318 {
319 	import dub.dependency;
320 	return Version(va) < Version(vb);
321 }
322 
323 private string[] splitAlphaNumParts(string str)
324 {
325 	string[] ret;
326 	while (!str.empty) {
327 		while (!str.empty && !str.front.isIdentChar()) str.popFront();
328 		if (str.empty) break;
329 		size_t i = str.length;
330 		foreach (j, dchar ch; str)
331 			if (!isIdentChar(ch)) {
332 				i = j;
333 				break;
334 			}
335 		if (i > 0) {
336 			ret ~= str[0 .. i];
337 			str = str[i .. $];
338 		}
339 		if (!str.empty) str.popFront(); // pop non-ident-char
340 	}
341 	return ret;
342 }
343 
344 private bool isIdentChar(dchar ch)
345 {
346 	return std.uni.isAlpha(ch) || std.uni.isNumber(ch);
347 }