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 }