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 }