1 /**
2 * Copyright © DiamondMVC 2019
3 * License: MIT (https://github.com/DiamondMVC/Diamond/blob/master/LICENSE)
4 * Author: Jacob Jensen (bausshf)
5 */
6 module diamond.http.sessions;
7 
8 import diamond.core.apptype;
9 
10 static if (isWeb)
11 {
12   import std.datetime : Clock, SysTime;
13   import std.variant : Variant;
14 
15   import diamond.core.webconfig;
16   import diamond.core.collections;
17   import diamond.errors.checks;
18   import diamond.http.cookies;
19   import diamond.http.client;
20   import diamond.tasks;
21 
22   /// The name of the session cookie.
23   private static const __gshared sessionCookieName = "__D_SESSION";
24 
25   /// The collection of currently stored sessions.
26   private __gshared InternalHttpSession[string] _sessions;
27 
28   /// The next session group id.
29   private size_t nextSessionGroupId;
30 
31   /// The sessions met.
32   private size_t sessionsMet;
33 
34   /// Wrapper for the internal http session.
35   private final class InternalHttpSession
36   {
37     /// The id of the session.
38     string id;
39 
40     /// The ip address of the session.
41     string ipAddress;
42 
43     /// The time when the session ends.
44     SysTime endTime;
45 
46     /// The value os the session.
47     Variant[string] values;
48 
49     /// Cached views.
50     HashSet!string cachedViews;
51 
52     /// The directory for the session view cache.
53     string directory;
54 
55     final:
56     /// Creates a new http session instance.
57     this()
58     {
59       cachedViews = new HashSet!string;
60     }
61   }
62 
63   /// Wrapper around a http session.
64   final class HttpSession
65   {
66     private:
67     /// The client.
68     HttpClient _client;
69 
70     /// The session.
71     InternalHttpSession _session;
72 
73     /**
74     * Creates a new http session.
75     * Params:
76     *   client =  The client.
77     *   session = The internal http session.
78     */
79     this(HttpClient client, InternalHttpSession session)
80     {
81       _client = enforceInput(client, "Cannot create a session without a client.");
82       _session = enforceInput(session, "Cannot create a session without an internal session.");
83     }
84 
85     public:
86     final:
87     @property
88     {
89       /// Gets the session id.
90       string id() { return _session.id; }
91 
92       /// Gets the time the session ends.
93       SysTime endTime() { return _session.endTime; }
94     }
95 
96     /**
97     * Gets a session value.
98     * Params:
99     *   name =         The name of the value to retrieve.
100     *   defaultValue = The default value to return if no value could be retrieved.
101     * Returns:
102     *   Returns the value retrieved or defaultValue if not found.
103     */
104     T getValue(T = string)(string name, lazy T defaultValue = T.init)
105     {
106       Variant value = _session.values.get(name, Variant.init);
107 
108       if (!value.hasValue)
109       {
110         return defaultValue;
111       }
112 
113       return value.get!T;
114     }
115 
116     /**
117     * Sets a session value.
118     * Params:
119     *   name =      The name of the value.
120     *   value =     The value.
121     */
122     void setValue(T = string)(string name, T value)
123     {
124       _session.values[name] = value;
125     }
126 
127     /**
128     * Removes a session value.
129     * Params:
130     *   name = The name of the value to remove.
131     */
132     void removeValue(string name)
133     {
134       if (_session.values && name in _session.values)
135       {
136         _session.values.remove(name);
137       }
138     }
139 
140     /**
141     * Caches a view in the session.
142     * Params:
143     *   viewName = The view to cache.
144     *   result =   The result of the view to cache.
145     */
146     void cacheView(string viewName, string result)
147     {
148       if (!webConfig.shouldCacheViews)
149       {
150         return;
151       }
152 
153       _session.cachedViews.add(viewName);
154 
155       import std.file : exists, write, mkdirRecurse;
156 
157       if (!exists(_session.directory))
158       {
159         mkdirRecurse(_session.directory);
160       }
161 
162       write(_session.directory ~ "/" ~ viewName ~ ".html", result);
163     }
164 
165     /**
166     * Gets a view from the session cache.
167     * Params:
168     *   viewName = The view to retrieve.
169     * Returns:
170     *   The result of the cached view if found, null otherwise.
171     */
172     string getCachedView(string viewName)
173     {
174       if (!webConfig.shouldCacheViews)
175       {
176         return null;
177       }
178 
179       import std.file : exists, readText;
180 
181       if (_session.cachedViews[viewName])
182       {
183         auto sessionViewFile = _session.directory ~ "/" ~ viewName ~ ".html";
184 
185         if (exists(sessionViewFile))
186         {
187           return readText(sessionViewFile);
188         }
189       }
190 
191       return null;
192     }
193 
194     /**
195     * Updates the session end time.
196     * Params:
197     *   newEndTime = The new end time.
198     */
199     void updateEndTime(SysTime newEndTime)
200     {
201       _session.endTime = newEndTime;
202     }
203 
204     /// Clears the session values for a session.
205     void clearValues()
206     {
207       _session.values.clear();
208     }
209 
210     /**
211     * Checks whether a value is present in the session.
212     * Params:
213     *   name = The name of the value to check for presence.
214     * Returns:
215     *   True if the value is present, false otherwise.
216     */
217     bool hasValue(string name)
218     {
219       return _session.values.get(name, Variant.init).hasValue;
220     }
221   }
222 
223   /**
224   * Gets a session.
225   * Params:
226   *   client =                 The client
227   *   createSessionIfInvalid = Boolean determining whether a new session should be created if the session is invalid.
228   * Returns:
229   *   Returns the session.
230   */
231   package(diamond.http) HttpSession getSession
232   (
233     HttpClient client,
234     bool createSessionIfInvalid = true
235   )
236   {
237     // Checks whether the request has already got its session assigned.
238     auto cachedSession = client.getContext!HttpSession(sessionCookieName);
239 
240     if (cachedSession)
241     {
242       return cachedSession;
243     }
244 
245     auto sessionId = client.cookies.get(sessionCookieName);
246     auto session = _sessions.get(sessionId, null);
247 
248     if (createSessionIfInvalid &&
249       (
250         !session ||
251         session.ipAddress != client.ipAddress ||
252         Clock.currTime() >= session.endTime
253       )
254     )
255     {
256       client.cookies.remove(sessionCookieName);
257 
258       return createSession(client);
259     }
260 
261     if (!session)
262     {
263       return null;
264     }
265 
266     auto httpSession = new HttpSession(client, session);
267     client.addContext(sessionCookieName, httpSession);
268 
269     return httpSession;
270   }
271 
272   /**
273   * Creates a http session.
274   * Params:
275   *   client =  The client.
276   * Returns:
277   *   Returns the session.
278   */
279   HttpSession createSession(HttpClient client)
280   {
281     auto clientSession = getSession(client, false);
282 
283     if (clientSession)
284     {
285       return clientSession;
286     }
287 
288     auto session = new InternalHttpSession;
289 
290     import diamond.security.tokens.sessiontoken;
291 
292     session.ipAddress = client.ipAddress;
293     session.id = sessionToken.generate(session.ipAddress);
294     _sessions[session.id] = session;
295 
296     if (webConfig.shouldCacheViews)
297     {
298       import std.conv : to;
299 
300       sessionsMet++;
301 
302       if (sessionsMet >= 1000)
303       {
304         sessionsMet = 0;
305         nextSessionGroupId++;
306       }
307 
308       session.directory = "sessions/" ~ to!string(nextSessionGroupId) ~ "/" ~ session.id[$-52 .. $] ~ "/";
309     }
310 
311     client.cookies.create(HttpCookieType.session, sessionCookieName, session.id, webConfig.sessionAliveTime * 60);
312     session.endTime = Clock.currTime();
313     session.endTime = session.endTime + webConfig.sessionAliveTime.minutes;
314 
315     clientSession = new HttpSession(client, session);
316     client.addContext(sessionCookieName, clientSession);
317 
318     executeTask((InternalHttpSession session) { invalidateSession(session, 3); }, session);
319 
320     return clientSession;
321   }
322 
323   /**
324   * Invalidates a session.
325   * Params:
326   *   session = The session to invalidate.
327   *   retries = The amount of retries left, if it failed to remove the session.
328   *   isRetry = Boolean determining whether the invalidation is a retry or not.
329   */
330   private void invalidateSession(InternalHttpSession session, size_t retries, bool isRetry = false)
331   {
332     import diamond.tasks : sleep;
333 
334     auto time = isRetry ? 100.msecs : (webConfig.sessionAliveTime + 2).minutes;
335 
336     sleep(time);
337 
338     try
339     {
340       // The endtime differs from the default, so we cycle once more.
341       if (Clock.currTime() < session.endTime)
342       {
343         executeTask((InternalHttpSession session) { invalidateSession(session, 3); }, session);
344       }
345       else
346       {
347         if (webConfig.shouldCacheViews)
348         {
349           try
350           {
351             import std.file : exists, rmdirRecurse;
352 
353             if (exists(session.directory))
354             {
355               rmdirRecurse(session.directory);
356             }
357           }
358           catch (Throwable t) { }
359         }
360 
361         _sessions.remove(session.id);
362       }
363     }
364     catch (Throwable)
365     {
366       if (retries)
367       {
368         executeTask((InternalHttpSession s, size_t r) { invalidateSession(s, r, true); }, session, retries - 1);
369       }
370     }
371   }
372 }