1 /**
2 	File system based database controller.
3 
4 	Copyright: 2015-2018 RejectedSoftware e.K.
5 	License: Subject to the terms of the General Public License version 3, as written in the included LICENSE.txt file.
6 	Authors: Sönke Ludwig
7 */
8 module userman.db.file;
9 
10 import userman.db.controller;
11 
12 import vibe.core.file;
13 import vibe.data.json;
14 import vibe.textfilter.urlencode;
15 import vibe.utils.validation;
16 
17 import std.datetime;
18 import std.exception;
19 import std..string;
20 import std.conv;
21 import std.uuid;
22 
23 
24 class FileUserManController : UserManController {
25 	private {
26 		NativePath m_basePath;
27 	}
28 
29 	this(UserManSettings settings)
30 	{
31 		super(settings);
32 
33 		enforce(settings.databaseURL.startsWith("file://"),
34 			"Database URL must have a file:// schema.");
35 
36 		m_basePath = cast(NativePath)URL(settings.databaseURL).path;
37 		string[] paths = [".", "user", "user/byName", "user/byEmail", "group", "group/byName"];
38 		foreach (p; paths)
39 			if (!existsFile(m_basePath ~ p))
40 				createDirectory(m_basePath ~ p);
41 	}
42 
43 	override bool isEmailRegistered(string email)
44 	{
45 		return existsFile(userByEmailFile(email));
46 	}
47 
48 	private final NativePath userByNameFile(string name) @safe { return m_basePath ~ "user/byName/" ~ (urlEncode(name) ~ ".json"); }
49 	private final NativePath userByEmailFile(string email) @safe { return m_basePath ~ "user/byEmail/" ~ (urlEncode(email) ~ ".json"); }
50 	private final NativePath userFile(User.ID id) @safe { return m_basePath ~ "user/" ~ (id.toString() ~ ".json"); }
51 	private final NativePath groupFile(string id) @safe in { assert(isValidGroupID(id)); } body { return m_basePath ~ ("group/" ~ id ~ ".json"); }
52 
53 	override User.ID addUser(ref User usr)
54 	{
55 		validateUser(usr);
56 		enforce(!isEmailRegistered(usr.email), "The email address is already taken.");
57 		enforce(!existsFile(userByNameFile(usr.name)), "The user name is already taken.");
58 
59 		usr.id = User.ID(randomUUID());
60 		if (usr.resetCodeExpireTime == SysTime.init)
61 			usr.resetCodeExpireTime = SysTime(0);
62 
63 		// Indexes
64 		writeJsonFile(userByEmailFile(usr.email), usr.id);
65 		scope (failure) removeFile(userByEmailFile(usr.email));
66 		writeJsonFile(userByNameFile(usr.name), usr.id);
67 		scope (failure) removeFile(userByNameFile(usr.name));
68 
69 		writeJsonFile(userFile(usr.id), usr);
70 
71 		return usr.id;
72 	}
73 
74 	override User getUser(User.ID id)
75 	{
76 		return readJsonFile!User(userFile(id));
77 	}
78 
79 	override User getUserByName(string name)
80 	{
81 		name = name.toLower();
82 		auto uid = User.ID.fromString(readFileUTF8(userByNameFile(name)).deserializeJson!string);
83 		return getUser(uid);
84 	}
85 
86 	override User getUserByEmail(string email)
87 	{
88 		email = email.toLower();
89 		auto uid = User.ID.fromString(readFileUTF8(userByEmailFile(email)).deserializeJson!string);
90 		return getUser(uid);
91 	}
92 
93 	override User getUserByEmailOrName(string email_or_name)
94 	{
95 		if (isEmailRegistered(email_or_name)) return getUserByEmail(email_or_name);
96 		else return getUserByName(email_or_name);
97 	}
98 
99 	alias enumerateUsers = UserManController.enumerateUsers;
100 	override void enumerateUsers(long first_user, long max_count, scope void delegate(ref User usr) @safe del)
101 	{
102 		listDirectory(m_basePath ~ "user/", (de) {
103 			if (!de.name.endsWith(".json") || de.isDirectory) return true;
104 			if (first_user > 0) {
105 				first_user--;
106 				return true;
107 			}
108 			if (max_count-- <= 0) return false;
109 			auto usr = getUser(User.ID.fromString(de.name[0 .. $-5]));
110 			del(usr);
111 			return true;
112 		});
113 	}
114 
115 	override long getUserCount()
116 	{
117 		long count = 0;
118 		listDirectory(m_basePath ~ "user/", (de) {
119 			if (!de.name.endsWith(".json") || de.isDirectory) return true;
120 			count++;
121 			return true;
122 		});
123 		return count;
124 	}
125 
126 	override void deleteUser(User.ID user_id)
127 	{
128 		auto usr = getUser(user_id);
129 		removeFile(userByEmailFile(usr.email));
130 		removeFile(userByNameFile(usr.name));
131 		removeFile(userFile(user_id));
132 	}
133 
134 	override void updateUser(in ref User user)
135 	{
136 
137 		enforce(existsFile(userFile(user.id)), "Invalid user ID.");
138 		validateUser(user);
139 		enforce(m_settings.useUserNames || user.name == user.email, "User name must equal email address if user names are not used.");
140 
141 		auto oldusr = getUser(user.id);
142 		auto oldemailfile = userByEmailFile(oldusr.email);
143 		auto newemailfile = userByEmailFile(user.email);
144 		auto oldnamefile = userByNameFile(oldusr.name);
145 		auto newnamefile = userByNameFile(user.name);
146 
147 		if (existsFile(newemailfile)) {
148 			auto euid = User.ID.fromString(readFileUTF8(newemailfile).deserializeJson!string);
149 			enforce(euid == user.id, "E-mail address is already in use.");
150 		} else {
151 			moveFile(oldemailfile, newemailfile);
152 		}
153 		scope (failure) moveFile(newemailfile, oldemailfile);
154 
155 		if (existsFile(newnamefile)) {
156 			auto euid = User.ID.fromString(readFileUTF8(newnamefile).deserializeJson!string);
157 			enforce(euid == user.id, "User name is already in use.");
158 		} else {
159 			moveFile(oldnamefile, newnamefile);
160 		}
161 		scope (failure) moveFile(newnamefile, oldnamefile);
162 
163 		writeJsonFile(userFile(user.id), user);
164 	}
165 
166 	override void setEmail(User.ID user, string email)
167 	{
168 		auto usr = getUser(user);
169 		usr.email = email;
170 		updateUser(usr);
171 	}
172 
173 	override void setFullName(User.ID user, string full_name)
174 	{
175 		auto usr = getUser(user);
176 		usr.fullName = full_name;
177 		updateUser(usr);
178 	}
179 
180 	override void setPassword(User.ID user, string password)
181 	{
182 		auto usr = getUser(user);
183 		usr.auth.method = "password";
184 		usr.auth.passwordHash = generatePasswordHash(password);
185 		updateUser(usr);
186 	}
187 
188 	override void setProperty(User.ID user, string name, Json value)
189 	{
190 		auto usr = getUser(user);
191 		usr.properties[name] = value;
192 		updateUser(usr);
193 	}
194 
195 	override void removeProperty(User.ID user, string name)
196 	{
197 		auto usr = getUser(user);
198 		usr.properties.remove(name);
199 		updateUser(usr);
200 	}
201 
202 	override void addGroup(string id, string description)
203 	{
204 		enforce(isValidGroupID(id), "Invalid group ID.");
205 		enforce(!existsFile(groupFile(id)), "A group with this name already exists.");
206 
207 		Group grp;
208 		grp.id = id;
209 		grp.description = description;
210 		writeJsonFile(groupFile(grp.id), grp);
211 	}
212 
213 	override void removeGroup(string id)
214 	{
215 		removeFile(groupFile(id));
216 	}
217 
218 	override void setGroupDescription(string name, string description)
219 	{
220 		auto grp = getGroup(name);
221 		grp.description = description;
222 		writeJsonFile(groupFile(name), grp);
223 	}
224 
225 	override long getGroupCount()
226 	{
227 		long ret = 0;
228 		listDirectory(m_basePath ~ "group/", (de) {
229 			if (!de.name.endsWith(".json") || de.isDirectory) return true;
230 			ret++;
231 			return true;
232 		});
233 		return ret;
234 	}
235 
236 	override Group getGroup(string id)
237 	{
238 		auto json = readFileUTF8(groupFile(id)).parseJsonString();
239 		// migration from 0.3.x to 0.4.x
240 		if (auto pn = "name" in json)
241 			if ("id" !in json)
242 				json["id"] = *pn;
243 		return deserializeJson!Group(json);
244 	}
245 
246 	alias enumerateGroups = UserManController.enumerateGroups;
247 	override void enumerateGroups(long first_group, long max_count, scope void delegate(ref Group grp) @safe del)
248 	{
249 		listDirectory(m_basePath ~ "group/", (de) {
250 			if (!de.name.endsWith(".json") || de.isDirectory) return true;
251 			if (first_group > 0) {
252 				first_group--;
253 				return true;
254 			}
255 			if (max_count-- <= 0) return false;
256 			auto usr = getGroup(de.name[0 .. $-5]);
257 			del(usr);
258 			return true;
259 		});
260 	}
261 
262 	override void addGroupMember(string group, User.ID user)
263 	{
264 		import std.algorithm : canFind;
265 		auto usr = getUser(user);
266 		if (!usr.groups.canFind(group))
267 			usr.groups ~= group;
268 		updateUser(usr);
269 	}
270 
271 	override void removeGroupMember(string group, User.ID user)
272 	{
273 		import std.algorithm : countUntil;
274 		auto usr = getUser(user);
275 		auto idx = usr.groups.countUntil(group);
276 		if (idx >= 0) usr.groups = usr.groups[0 .. idx] ~ usr.groups[idx+1 .. $];
277 		updateUser(usr);
278 	}
279 
280 	override long getGroupMemberCount(string group)
281 	{
282 		import std.algorithm : canFind;
283 		long ret = 0;
284 		enumerateUsers(0, long.max, (ref u) {
285 			if (u.groups.canFind(group))
286 				ret++;
287 		});
288 		return ret;
289 	}
290 
291 	alias enumerateGroupMembers = UserManController.enumerateGroupMembers;
292 	override void enumerateGroupMembers(string group, long first_member, long max_count, scope void delegate(User.ID usr) @safe del)
293 	{
294 		import std.algorithm : canFind;
295 		long cnt = 0;
296 		enumerateUsers(0, long.max, (ref u) {
297 			if (!u.groups.canFind(group)) return;
298 			if (cnt++ < first_member) return;
299 			if (max_count-- <= 0) return;
300 			del(u.id);
301 		});
302 	}
303 }
304 
305 private void writeJsonFile(T)(NativePath filename, T value)
306 {
307 	writeFileUTF8(filename, value.serializeToPrettyJson());
308 }
309 
310 private T readJsonFile(T)(NativePath filename)
311 {
312 	import vibe.http.common : HTTPStatusException;
313 	import vibe.http.status : HTTPStatus;
314 	if (!existsFile(filename))
315 		throw new HTTPStatusException(HTTPStatus.notFound, "Database object does not exist ("~filename.toNativeString()~").");
316 	return readFileUTF8(filename).deserializeJson!T();
317 }