001/* 002 * JGrapes Event Driven Framework 003 * Copyright (C) 2017-2018 Michael N. Lipp 004 * 005 * This program is free software; you can redistribute it and/or modify it 006 * under the terms of the GNU Affero General Public License as published by 007 * the Free Software Foundation; either version 3 of the License, or 008 * (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, but 011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 013 * for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License along 016 * with this program; if not, see <http://www.gnu.org/licenses/>. 017 */ 018 019package org.jgrapes.webconsole.base; 020 021import java.lang.ref.ReferenceQueue; 022import java.lang.ref.WeakReference; 023import java.time.Duration; 024import java.time.Instant; 025import java.util.Collections; 026import java.util.HashSet; 027import java.util.Locale; 028import java.util.Map; 029import java.util.Optional; 030import java.util.Set; 031import java.util.concurrent.ConcurrentHashMap; 032import java.util.function.Supplier; 033import org.jgrapes.core.Channel; 034import org.jgrapes.core.Components; 035import org.jgrapes.core.Components.Timer; 036import org.jgrapes.core.EventPipeline; 037import org.jgrapes.core.Subchannel; 038import org.jgrapes.http.Session; 039import org.jgrapes.io.IOSubchannel; 040import org.jgrapes.io.IOSubchannel.DefaultIOSubchannel; 041import org.jgrapes.io.events.Closed; 042import org.jgrapes.io.util.LinkedIOSubchannel; 043 044/** 045 * The server side representation of a window in the browser 046 * that displays a console page (a console session). An instance 047 * is created when a new console window opens the WebSocket 048 * connection to the server for the first time. If the 049 * connection between the browser and the server is lost 050 * (e.g. due to temporary network failure), the console code in 051 * the browser tries to establish a new WebSocket connection 052 * to the same, already existing {@link ConsoleSession}. 053 * The {@link ConsoleSession} object is thus independent 054 * of the WebSocket connection that handles the actual transfer 055 * of notifications. 056 * 057 *  058 * 059 * To allow reconnection and because there is no reliable way 060 * to be notified when a window in a browser closes, {@link Closed} 061 * events from the network are ignored and {@link ConsoleSession}s 062 * remain in an open state as long as they are in use. Only if no 063 * data is received (i.e. {@link #refresh()} isn't called) for the 064 * time span configured with 065 * {@link ConsoleWeblet#setConsoleSessionNetworkTimeout} 066 * a {@link Closed} event is fired on the channel and the console 067 * session is closed. (Method {@link #isConnected()} can be used 068 * to check the connection state.) 069 * In order to keep the console connection in an open state while 070 * the session is inactive, i.e. no data is sent due to user activity, 071 * the SPA automatically generates refresh messages as configured 072 * with {@link ConsoleWeblet#setConsoleSessionRefreshInterval(Duration)}. 073 * 074 * {@link ConsoleSession} implements the {@link IOSubchannel} 075 * interface. This allows the instances to be used as channels 076 * for exchanging console session scoped events with the 077 * {@link WebConsole} component. The upstream channel 078 * (see {@link #upstreamChannel()}) is the channel of the 079 * WebSocket. It may be unavailable if the connection has 080 * been interrupted and not (yet) re-established. 081 * The {@link IOSubchannel}'s response {@link EventPipeline} 082 * must be used to send events (responses) to the console session 083 * in the browser. No other event pipeline may be used for 084 * this purpose, else messages will interleave. 085 * 086 * To avoid having too many open WebSockets with inactive sessions, 087 * a maximum inactivity time can be configured with 088 * {@link ConsoleWeblet#setConsoleSessionInactivityTimeout(Duration)}. 089 * The SPA always checks if the time since the last user activity 090 * has reached or exceeded the configured limit before sending the 091 * next refresh message. In case it has, the SPA stops sending 092 * refresh messages and displays a "suspended" dialog to the user. 093 * 094 * When the user chooses to resume, a new WebSocket is opened by the 095 * SPA. If the {@link Session} used before the idle timeout is 096 * still available (hasn't reached its idle timeout or absolute timeout) 097 * and refers to a {@link ConsoleSession} not yet closed, then this 098 * {@link ConsoleSession} is reused, else the SPA is reloaded. 099 * 100 * As a convenience, the {@link ConsoleSession} provides 101 * direct access to the browser session, which can 102 * usually only be obtained from the HTTP event or WebSocket 103 * channel by looking for an association of type {@link Session}. 104 * 105 * @startuml ConsoleSession.svg 106 * class ConsoleSession { 107 * -{static}Map<String,ConsoleSession> consoleSessions 108 * +{static}findOrCreate(String consoleSessionId, Manager component): ConsoleSession 109 * +setTimeout(timeout: long): ConsoleSession 110 * +refresh(): void 111 * +setUpstreamChannel(IOSubchannel upstreamChannel): ConsoleSession 112 * +setSessionSupplier(Session browserSessionSupplier): ConsoleSession 113 * +upstreamChannel(): Optional<IOSubchannel> 114 * +consoleSessionId(): String 115 * +browserSession(): Optional<Session> 116 * +locale(): Locale 117 * +setLocale(Locale locale): void 118 * } 119 * Interface IOSubchannel { 120 * } 121 * IOSubchannel <|.. ConsoleSession 122 * 123 * ConsoleSession "1" *-- "*" ConsoleSession : maintains 124 * 125 * package org.jgrapes.http { 126 * class Session 127 * } 128 * 129 * ConsoleSession "*" -up-> "1" Session: browser session 130 * @enduml 131 */ 132@SuppressWarnings("PMD.LinguisticNaming") 133public final class ConsoleSession extends DefaultIOSubchannel { 134 135 private static Map<String, WeakReference<ConsoleSession>> consoleSessions 136 = new ConcurrentHashMap<>(); 137 private static ReferenceQueue<ConsoleSession> unusedSessions 138 = new ReferenceQueue<>(); 139 140 private String consoleSessionId; 141 private final WebConsole console; 142 private final Set<Locale> supportedLocales; 143 private Locale locale; 144 private long timeout; 145 private final Timer timeoutTimer; 146 private boolean active = true; 147 private boolean connected = true; 148 private Supplier<Optional<Session>> browserSessionSupplier; 149 private IOSubchannel upstreamChannel; 150 151 /** 152 * Weak reference to session. 153 */ 154 private static class SessionReference 155 extends WeakReference<ConsoleSession> { 156 157 private final String id; 158 159 /** 160 * Instantiates a new session reference. 161 * 162 * @param referent the referent 163 */ 164 public SessionReference(ConsoleSession referent) { 165 super(referent, unusedSessions); 166 id = referent.consoleSessionId(); 167 } 168 } 169 170 private static void cleanUnused() { 171 while (true) { 172 SessionReference unused = (SessionReference) unusedSessions.poll(); 173 if (unused == null) { 174 break; 175 } 176 consoleSessions.remove(unused.id); 177 } 178 } 179 180 /** 181 * Lookup the console session (the channel) 182 * for the given console session id. 183 * 184 * @param consoleSessionId the console session id 185 * @return the channel 186 */ 187 /* default */ static Optional<ConsoleSession> 188 lookup(String consoleSessionId) { 189 cleanUnused(); 190 return Optional.ofNullable(consoleSessions.get(consoleSessionId)) 191 .flatMap(ref -> Optional.ofNullable(ref.get())); 192 } 193 194 /** 195 * Return all sessions that belong to the given console as a new 196 * unmodifiable set. 197 * 198 * @param console the console 199 * @return the sets the 200 */ 201 public static Set<ConsoleSession> byConsole(WebConsole console) { 202 cleanUnused(); 203 Set<ConsoleSession> result = new HashSet<>(); 204 for (WeakReference<ConsoleSession> psr : consoleSessions.values()) { 205 ConsoleSession psess = psr.get(); 206 if (psess != null && psess.console != null 207 && psess.console.equals(console)) { 208 result.add(psess); 209 } 210 } 211 return Collections.unmodifiableSet(result); 212 } 213 214 /** 215 * Lookup (and create if not found) the console browserSessionSupplier channel 216 * for the given console browserSessionSupplier id. 217 * 218 * @param consoleSessionId the browserSessionSupplier id 219 * @param console the console that this session belongs to 220 * class' constructor if a new channel is created, usually 221 * the console 222 * @param supportedLocales the locales supported by the console 223 * @param timeout the console session timeout in milli seconds 224 * @return the channel 225 */ 226 /* default */ static ConsoleSession lookupOrCreate( 227 String consoleSessionId, WebConsole console, 228 Set<Locale> supportedLocales, long timeout) { 229 cleanUnused(); 230 return consoleSessions.computeIfAbsent(consoleSessionId, 231 psi -> new SessionReference(new ConsoleSession( 232 console, supportedLocales, consoleSessionId, timeout))) 233 .get(); 234 } 235 236 /** 237 * Replace the id of the console session with the new id. 238 * 239 * @param newConsoleSessionId the new console session id 240 * @return the console session 241 */ 242 /* default */ ConsoleSession replaceId(String newConsoleSessionId) { 243 consoleSessions.remove(consoleSessionId); 244 consoleSessionId = newConsoleSessionId; 245 consoleSessions.put(consoleSessionId, new SessionReference( 246 this)); 247 connected = true; 248 return this; 249 } 250 251 private ConsoleSession(WebConsole console, Set<Locale> supportedLocales, 252 String consoleSessionId, long timeout) { 253 super(console.channel(), console.newEventPipeline()); 254 this.console = console; 255 this.supportedLocales = supportedLocales; 256 this.consoleSessionId = consoleSessionId; 257 this.timeout = timeout; 258 timeoutTimer = Components.schedule( 259 tmr -> discard(), Duration.ofMillis(timeout)); 260 } 261 262 /** 263 * Changes the timeout for this {@link ConsoleSession} to the 264 * given value. 265 * 266 * @param timeout the timeout in milli seconds 267 * @return the console session for easy chaining 268 */ 269 public ConsoleSession setTimeout(long timeout) { 270 this.timeout = timeout; 271 timeoutTimer.reschedule(Duration.ofMillis(timeout)); 272 return this; 273 } 274 275 /** 276 * Returns the time when this session will expire. 277 * 278 * @return the instant 279 */ 280 public Instant expiresAt() { 281 return timeoutTimer.scheduledFor(); 282 } 283 284 /** 285 * Resets the {@link ConsoleSession}'s timeout. 286 */ 287 public void refresh() { 288 timeoutTimer.reschedule(Duration.ofMillis(timeout)); 289 } 290 291 /** 292 * Discards this session. 293 */ 294 public void discard() { 295 consoleSessions.remove(consoleSessionId); 296 connected = false; 297 active = false; 298 console.newEventPipeline().fire(new Closed(), this); 299 } 300 301 /** 302 * Checks if the console session has become stale (inactive). 303 * 304 * @return true, if is stale 305 */ 306 public boolean isStale() { 307 return !active; 308 } 309 310 /* default */ void disconnected() { 311 connected = false; 312 } 313 314 /** 315 * Checks if a network connection with the browser exists. 316 * 317 * @return true, if is connected 318 */ 319 public boolean isConnected() { 320 return connected; 321 } 322 323 /** 324 * Provides access to the weblet's channel. 325 * 326 * @return the channel 327 */ 328 public Channel webletChannel() { 329 return console.webletChannel(); 330 } 331 332 /** 333 * Sets or updates the upstream channel. This method should only 334 * be invoked by the creator of the {@link ConsoleSession}, by default 335 * the {@link ConsoleWeblet}. 336 * 337 * @param upstreamChannel the upstream channel (WebSocket connection) 338 * @return the console session for easy chaining 339 */ 340 public ConsoleSession setUpstreamChannel(IOSubchannel upstreamChannel) { 341 if (upstreamChannel == null) { 342 throw new IllegalArgumentException(); 343 } 344 this.upstreamChannel = upstreamChannel; 345 return this; 346 } 347 348 /** 349 * Sets or updates associated browser session. This method should only 350 * be invoked by the creator of the {@link ConsoleSession}, by default 351 * the {@link ConsoleWeblet}. 352 * 353 * @param sessionSupplier the browser session supplier 354 * @return the console session for easy chaining 355 */ 356 public ConsoleSession 357 setSessionSupplier(Supplier<Optional<Session>> sessionSupplier) { 358 this.browserSessionSupplier = sessionSupplier; 359 if (locale == null) { 360 locale = browserSession().locale(); 361 } 362 return this; 363 } 364 365 /** 366 * @return the upstream channel 367 */ 368 public IOSubchannel upstreamChannel() { 369 return upstreamChannel; 370 } 371 372 /** 373 * The console session id is used in the communication between the 374 * browser and the server. It is not guaranteed to remain the same 375 * over time, even if the console session is maintained. To prevent 376 * wrong usage, its visibility is therefore set to package. 377 * 378 * @return the consoleSessionId 379 */ 380 /* default */ String consoleSessionId() { 381 return consoleSessionId; 382 } 383 384 /** 385 * @return the browserSession 386 */ 387 public Session browserSession() { 388 return browserSessionSupplier.get().get(); 389 } 390 391 /** 392 * Returns the supported locales. 393 * 394 * @return the set of locales supported by the console 395 */ 396 public Set<Locale> supportedLocales() { 397 return supportedLocales; 398 } 399 400 /** 401 * Return the console session's locale. The locale is initialized 402 * from the browser session's locale. 403 * 404 * @return the locale 405 */ 406 public Locale locale() { 407 return locale == null ? Locale.getDefault() : locale; 408 } 409 410 /** 411 * Sets the locale for this console session. 412 * 413 * @param locale the locale 414 * @return the console session 415 */ 416 public ConsoleSession setLocale(Locale locale) { 417 this.locale = locale; 418 return this; 419 } 420 421 /* 422 * (non-Javadoc) 423 * 424 * @see java.lang.Object#toString() 425 */ 426 @Override 427 public String toString() { 428 StringBuilder builder = new StringBuilder(); 429 builder.append(Subchannel.toString(this)); 430 Optional.ofNullable(upstreamChannel).ifPresent(upstr -> builder.append( 431 LinkedIOSubchannel.upstreamToString(upstr))); 432 return builder.toString(); 433 } 434 435}