1 module vibelog.webadmin; 2 3 public import vibelog.controller; 4 5 import vibelog.config; 6 import vibelog.post; 7 import vibelog.user; 8 9 import vibe.http.router; 10 import vibe.web.web; 11 import std.exception : enforce; 12 import std.typecons : Nullable; 13 14 15 void registerVibeLogWebAdmin(URLRouter router, VibeLogController controller) 16 { 17 auto websettings = new WebInterfaceSettings; 18 websettings.urlPrefix = (controller.settings.siteURL.path ~ controller.settings.adminPrefix).toString(); 19 router.registerWebInterface(new VibeLogWebAdmin(controller), websettings); 20 } 21 22 /// private 23 /*private*/ final class VibeLogWebAdmin { 24 private { 25 VibeLogController m_ctrl; 26 VibeLogSettings m_settings; 27 string m_subPath; 28 string m_config; 29 } 30 31 this(VibeLogController controller) 32 { 33 m_ctrl = controller; 34 m_settings = controller.settings; 35 m_subPath = (m_settings.siteURL.path ~ m_settings.adminPrefix).toString(); 36 } 37 38 // the whole admin interface needs authentication 39 @auth: 40 41 void get(AuthInfo _auth) 42 { 43 auto info = AdminInfo(_auth, m_settings); 44 45 render!("vibelog.admin.home.dt", info); 46 } 47 48 // 49 // Configs 50 // 51 52 @path("configs/") 53 void getConfigs(AuthInfo _auth) 54 { 55 enforceAuth(_auth.loginUser.isConfigAdmin()); 56 57 auto info = ConfigsInfo(_auth, m_settings); 58 info.configs = m_ctrl.db.getAllConfigs(); 59 info.activeConfigName = m_settings.configName; 60 61 render!("vibelog.admin.editconfiglist.dt", info); 62 } 63 64 @path("configs/:configname/") 65 void getConfigEdit(string _configname, AuthInfo _auth) 66 { 67 enforceAuth(_auth.loginUser.isConfigAdmin()); 68 69 auto info = ConfigEditInfo(_auth, m_settings); 70 info.config = m_ctrl.db.getConfig(_configname); 71 info.globalConfig = m_ctrl.db.getConfig("global", true); 72 73 render!("vibelog.admin.editconfig.dt", info); 74 } 75 76 @path("configs/:configname/") 77 void postPutConfig(HTTPServerRequest req, string language, string copyrightString, string feedTitle, string feedLink, string feedDescription, string feedImageTitle, string feedImageUrl, string _configname, AuthInfo _auth, string categories = null) 78 { 79 import std.string; 80 81 enforceAuth(_auth.loginUser.isConfigAdmin()); 82 Config cfg = m_ctrl.db.getConfig(_configname); 83 if( cfg.name == "global" ) 84 cfg.categories = categories.splitLines(); 85 else { 86 cfg.categories = null; 87 foreach( k, v; req.form ){ 88 if( k.startsWith("category_") ) 89 cfg.categories ~= k[9 .. $]; 90 } 91 } 92 cfg.language = language; 93 cfg.copyrightString = copyrightString; 94 cfg.feedTitle = feedTitle; 95 cfg.feedLink = feedLink; 96 cfg.feedDescription = feedDescription; 97 cfg.feedImageTitle = feedImageTitle; 98 cfg.feedImageUrl = feedImageUrl; 99 100 m_ctrl.db.setConfig(cfg); 101 102 redirect(m_subPath ~ "configs/"); 103 } 104 105 @path("configs/:configname/delete") 106 void postDeleteConfig(string _configname, AuthInfo _auth) 107 { 108 enforceAuth(_auth.loginUser.isConfigAdmin()); 109 m_ctrl.db.deleteConfig(_configname); 110 redirect(m_subPath ~ "configs/"); 111 } 112 113 114 // 115 // Users 116 // 117 118 @path("users/") 119 void getUsers(AuthInfo _auth) 120 { 121 auto info = AdminInfo(_auth, m_settings); 122 123 render!("vibelog.admin.edituserlist.dt", info); 124 } 125 126 @path("users/:username/") 127 void getUserEdit(string _username, AuthInfo _auth) 128 { 129 auto info = UserEditInfo(_auth, m_settings); 130 131 info.globalConfig = m_ctrl.db.getConfig("global", true); 132 info.user = m_ctrl.db.getUserByName(_username); 133 134 render!("vibelog.admin.edituser.dt", info); 135 } 136 137 @path("users/:username/") 138 void postPutUser(string id, string username, string password, string name, string email, string passwordConfirmation, Nullable!string oldPassword, string _username, HTTPServerRequest req, AuthInfo _auth) 139 { 140 import vibe.crypto.passwordhash; 141 import vibe.data.bson : BsonObjectID; 142 import std.algorithm.searching : startsWith; 143 144 User usr; 145 if( id.length > 0 ){ 146 enforce(_auth.loginUser.isUserAdmin() || username == _auth.loginUser.username, 147 "You can only change your own account."); 148 usr = m_ctrl.db.getUser(BsonObjectID.fromHexString(id)); 149 enforce(usr.username == username, "Cannot change the user name!"); 150 } else { 151 enforce(_auth.loginUser.isUserAdmin(), "You are not allowed to add users."); 152 usr = new User; 153 usr.username = username; 154 foreach (u; _auth.users) 155 enforce(u.username != usr.username, "A user with the specified user name already exists!"); 156 } 157 enforce(password == passwordConfirmation, "Passwords do not match!"); 158 159 usr.name = name; 160 usr.email = email; 161 162 if (password.length) { 163 enforce(_auth.loginUser.isUserAdmin() || testSimplePasswordHash(oldPassword, usr.password), "Old password does not match."); 164 usr.password = generateSimplePasswordHash(password); 165 } 166 167 if (_auth.loginUser.isUserAdmin()) { 168 usr.groups = null; 169 foreach( k, v; req.form ){ 170 if( k.startsWith("group_") ) 171 usr.groups ~= k[6 .. $]; 172 } 173 174 usr.allowedCategories = null; 175 foreach( k, v; req.form ){ 176 if( k.startsWith("category_") ) 177 usr.allowedCategories ~= k[9 .. $]; 178 } 179 } 180 181 if( id.length > 0 ){ 182 m_ctrl.db.modifyUser(usr); 183 } else { 184 usr._id = m_ctrl.db.addUser(usr); 185 } 186 187 if (_auth.loginUser.isUserAdmin()) redirect(m_subPath~"users/"); 188 else redirect(m_subPath); 189 } 190 191 @path("users/:username/delete") 192 void postDeleteUser(string _username, AuthInfo _auth) 193 { 194 enforceAuth(_auth.loginUser.isUserAdmin(), "You are not authorized to delete users!"); 195 enforce(_auth.loginUser.username != _username, "Cannot delete the own user account!"); 196 foreach (usr; _auth.users) 197 if (usr.username == _username) { 198 m_ctrl.db.deleteUser(usr._id); 199 redirect(m_subPath ~ "users/"); 200 return; 201 } 202 203 // fall-through (404) 204 } 205 206 @path("users/") 207 void postAddUser(string username, AuthInfo _auth) 208 { 209 enforceAuth(_auth.loginUser.isUserAdmin(), "You are not authorized to add users!"); 210 if (username !in _auth.users) { 211 auto u = new User; 212 u.username = username; 213 m_ctrl.db.addUser(u); 214 } 215 redirect(m_subPath ~ "users/" ~ username ~ "/"); 216 } 217 218 // 219 // Posts 220 // 221 222 @path("posts/") 223 void getPosts(AuthInfo _auth) 224 { 225 auto info = PostsInfo(_auth, m_settings); 226 m_ctrl.db.getAllPosts(0, (size_t idx, Post post){ 227 if (_auth.loginUser.isPostAdmin() || post.author == _auth.loginUser.username 228 || _auth.loginUser.mayPostInCategory(post.category)) 229 { 230 info.posts ~= post; 231 } 232 return true; 233 }); 234 235 render!("vibelog.admin.editpostslist.dt", info); 236 } 237 238 @path("make_post") 239 void getMakePost(AuthInfo _auth, string _error = null) 240 { 241 auto info = PostEditInfo(_auth, m_settings); 242 info.globalConfig = m_ctrl.db.getConfig("global", true); 243 info.error = _error; 244 245 render!("vibelog.admin.editpost.dt", info); 246 } 247 248 @auth @errorDisplay!getMakePost 249 void postMakePost(bool isPublic, bool commentsAllowed, string author, 250 string date, string category, string slug, string headerImage, string header, string subHeader, 251 string content, string filters, AuthInfo _auth) 252 { 253 postPutPost(null, isPublic, commentsAllowed, author, date, category, slug, headerImage, header, subHeader, content, filters, null, _auth); 254 } 255 256 @path("posts/:postname/") 257 void getEditPost(string _postname, AuthInfo _auth, string _error = null) 258 { 259 auto info = PostEditInfo(_auth, m_settings); 260 info.globalConfig = m_ctrl.db.getConfig("global", true); 261 info.post = m_ctrl.db.getPost(_postname); 262 info.files = m_ctrl.db.getFiles(_postname); 263 info.error = _error; 264 render!("vibelog.admin.editpost.dt", info); 265 } 266 267 @path("posts/:postname/delete") 268 void postDeletePost(string id, string _postname, AuthInfo _auth) 269 { 270 import vibe.data.bson : BsonObjectID; 271 // FIXME: check permissons! 272 auto bid = BsonObjectID.fromHexString(id); 273 m_ctrl.db.deletePost(bid); 274 redirect(m_subPath ~ "posts/"); 275 } 276 277 @path("posts/:postname/") @errorDisplay!getEditPost 278 void postPutPost(string id, bool isPublic, bool commentsAllowed, string author, 279 string date, string category, string slug, string headerImage, string header, string subHeader, 280 string content, string filters, string _postname, AuthInfo _auth) 281 { 282 import vibe.data.bson : BsonObjectID; 283 284 Post p; 285 if( id.length > 0 ){ 286 p = m_ctrl.db.getPost(BsonObjectID.fromHexString(id)); 287 enforce(_postname == p.name, "URL does not match the edited post!"); 288 } else { 289 p = new Post; 290 p.category = "general"; 291 p.date = Clock.currTime().toUTC(); 292 } 293 enforce(_auth.loginUser.mayPostInCategory(category), "You are now allowed to post in the '"~category~"' category."); 294 295 p.isPublic = isPublic; 296 p.commentsAllowed = commentsAllowed; 297 p.author = author; 298 p.date = SysTime.fromSimpleString(date); 299 p.category = category; 300 p.slug = slug.length ? slug : header.length ? makeSlugFromHeader(header) : id; 301 p.headerImage = headerImage; 302 p.header = header; 303 p.subHeader = subHeader; 304 p.content = content; 305 import std.array : split; 306 p.filters = filters.split(); 307 308 enforce(!m_ctrl.db.hasPost(p.slug) || m_ctrl.db.getPost(p.slug).id == p.id, "Post slug is already used for another article."); 309 310 if( id.length > 0 ) 311 { 312 m_ctrl.db.modifyPost(p); 313 _postname = p.name; 314 } 315 else 316 { 317 p.id = m_ctrl.db.addPost(p); 318 } 319 redirect(m_subPath~"posts/"); 320 } 321 322 @path("posts/:postname/files/:filename/delete") @errorDisplay!getEditPost 323 void postDeleteFile(string _postname, string _filename, AuthInfo _auth) 324 { 325 m_ctrl.db.removeFile(_postname, _filename); 326 redirect("../../"); 327 } 328 329 @path("posts/:postname/files/") @errorDisplay!getEditPost 330 void postUploadFile(string _postname, HTTPServerRequest req, AuthInfo _auth) 331 { 332 import vibe.core.file; 333 334 import vibe.core.log; 335 logInfo("FILES %s %s", req.files.length, req.files.getAll("files")); 336 foreach (f; req.files) { 337 logInfo("FILE %s", f.filename); 338 auto fil = openFile(f.tempPath, FileMode.read); 339 scope (exit) fil.close(); 340 m_ctrl.db.addFile(_postname, f.filename.toString(), fil); 341 } 342 redirect("../"); 343 } 344 345 private enum auth = before!performAuth("_auth"); 346 347 private AuthInfo performAuth(HTTPServerRequest req, HTTPServerResponse res) 348 { 349 import vibe.crypto.passwordhash; 350 import vibe.http.auth.basic_auth; 351 352 User[string] users = m_ctrl.db.getAllUsers(); 353 bool testauth(string user, string password) 354 { 355 auto pu = user in users; 356 if( pu is null ) return false; 357 return testSimplePasswordHash(pu.password, password); 358 } 359 string username = performBasicAuth(req, res, "VibeLog admin area", &testauth); 360 auto pusr = username in users; 361 assert(pusr, "Authorized with unknown username !?"); 362 return AuthInfo(*pusr, users); 363 } 364 365 mixin PrivateAccessProxy; 366 } 367 368 struct AdminInfo 369 { 370 import vibelog.info : VibeLogInfo; 371 VibeLogInfo vli; 372 alias vli this; 373 374 User loginUser; 375 User[string] users; 376 Path rootPath; 377 378 import vibelog.settings : VibeLogSettings; 379 this(AuthInfo auth, VibeLogSettings settings) 380 { 381 vli = VibeLogInfo(settings); 382 loginUser = auth.loginUser; 383 users = auth.users; 384 this.settings = settings; 385 rootPath = settings.siteURL.path ~ settings.adminPrefix; 386 } 387 } 388 389 enum string mixAdminInfo = q{AdminInfo ai; alias ai this;}; 390 enum string mixInitAdminInfo = q{ai = AdminInfo(auth, settings);}; 391 392 struct PostEditInfo 393 { 394 mixin(mixAdminInfo); 395 396 import vibelog.config : Config; 397 Config globalConfig; 398 399 import vibelog.post : Post; 400 Post post; 401 402 string[] files; 403 string error; 404 405 import vibelog.settings : VibeLogSettings; 406 this(AuthInfo auth, VibeLogSettings settings) 407 { 408 mixin(mixInitAdminInfo); 409 } 410 } 411 412 struct ConfigEditInfo 413 { 414 mixin(mixAdminInfo); 415 416 Config config; 417 Config globalConfig; 418 419 import vibelog.settings : VibeLogSettings; 420 this(AuthInfo auth, VibeLogSettings settings, Config config, Config globalConfig) 421 { 422 this(auth, settings); 423 this.config = config; 424 this.globalConfig = globalConfig; 425 } 426 427 this(AuthInfo auth, VibeLogSettings settings) 428 { 429 mixin(mixInitAdminInfo); 430 } 431 } 432 433 struct ConfigsInfo 434 { 435 mixin(mixAdminInfo); 436 437 import vibelog.config : Config; 438 Config[] configs; 439 string activeConfigName; 440 441 import vibelog.settings : VibeLogSettings; 442 this(AuthInfo auth, VibeLogSettings settings, Config[] configs, string activeConfigName) 443 { 444 this(auth, settings); 445 this.configs = configs; 446 this.activeConfigName = activeConfigName; 447 } 448 this(AuthInfo auth, VibeLogSettings settings) 449 { 450 mixin(mixInitAdminInfo); 451 } 452 } 453 454 struct UserEditInfo 455 { 456 mixin(mixAdminInfo); 457 458 import vibelog.config : Config; 459 Config globalConfig; 460 461 import vibelog.user : User; 462 User user; 463 464 import vibelog.settings : VibeLogSettings; 465 this(AuthInfo auth, VibeLogSettings settings) 466 { 467 mixin(mixInitAdminInfo); 468 } 469 } 470 471 struct PostsInfo 472 { 473 mixin(mixAdminInfo); 474 475 import vibelog.post : Post; 476 Post[] posts; 477 478 import vibelog.settings : VibeLogSettings; 479 this(AuthInfo auth, VibeLogSettings settings) 480 { 481 mixin(mixInitAdminInfo); 482 } 483 } 484 485 private struct AuthInfo { 486 User loginUser; 487 User[string] users; 488 } 489 490 private void enforceAuth(bool cond, lazy string message = "Not authorized to perform this action!") 491 { 492 if (!cond) throw new HTTPStatusException(HTTPStatus.forbidden, message); 493 }