diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 461ce197..6f2ce4fd 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -2,26 +2,7 @@ #### General guidelines - Make sure to respect line endings (LF) -- Tabs should be 4 spaces - -## Building and running `board-server` -You will need to build and run the `board-server` project before using or contributing to the client or server. - -`board-server` is written in Java. Make sure you have [Apache Maven](https://maven.apache.org/index.html) installed on your system before attempting to build the project. - -#### Build Steps -- Clone or fork this repository -- Run `mvn clean package` in `board-server` to generate server artifact in the newly created `target` folder -- Set the working directory of the generated server artifact to `board-web-client` (copy the `.jar` to `board-web-client`) and run in a terminal using `java -jar board-server-xxx.jar` - - This starts an HTTP server listening on port 80, which you can connect in your browser at [localhost](http://localhost) - - Stop the server gracefully by running the `stop` command in the console or by killing the process - - -You may also want to create a run configuration in your IDE of choice to simplify the run process. - -Modifying `board-server` will require you to recompile the project and restart the server. - -Modifying `board-web-client` only requires you to refresh your browser. If you do not see your new changes, then use `Ctrl+F5` to also reset your browser cache. +- Do not use the tab character. Tabs should be represented as 4 spaces ## Contributing to `board-server` It is recommended to use an IDE such as [IntelliJ IDEA](https://www.jetbrains.com/idea/) if you plan on contributing to `board-server`. @@ -29,5 +10,5 @@ It is recommended to use an IDE such as [IntelliJ IDEA](https://www.jetbrains.co - Keep things IDE agnostic - avoid checking in IDE specific files (.iml, .idea, etc.) to version control ## Contributing to `board-web-client` -- Use double quotes ("") when editing HTML and single quotes ('') when editing JavaScript +- Use double quotes (" ") when editing HTML and single quotes (' ') when editing JavaScript diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..78332de9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,62 @@ +##v0.1 +Add real time drawing + +- Change file structure and how files are served +- Fix custom invite URL +- Automatic connection + +##v0.2 +Basic functionality to allow for more features to be added + +###v0.2.1 +Room to document based model + +- Add sidebar +- Change offset points to absolute points in draw +- Refactor packets +- Refactor js +- Event based WebSocketHandler +- Change how URLs work + +###v0.2.2 +Add document opening/closing + +- Change how queued packets work +- Documents are saved before closed +- Change protocol again + +###v0.2.3 +Flat file data persistence + +- Remove ConsoleManager +- Add shutdown hooks for saving +- Change handshake +- Functioning Java serialization +- Complete rework of config system +- SSL support + +###v0.2.4 +User identity + +- Change URL parsing +- Add HTTP cache as a config option +- Session management +- Sessions are linked to documents +- Improve toString debug + +###v0.2.5 +Multiple clients per user + +- Change how sessions are handled +- Clean up script loading +- Fix hashcode issues + +###v0.2.6 +Design +- Clean up CSS +- Add changelog +- Add invite/connected users toolbar +- "Fix" canvas resize +- Change/clean up config system +- Improve client debug +- Better local user with different handshake \ No newline at end of file diff --git a/README.md b/README.md index fddda6ce..ddfc4b8e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,54 @@ welcome to the board project -:D -:D -̿̿ ̿̿ ̿̿ ̿'̿'\̵͇̿̿\з= ( ▀ ͜͞ʖ▀) =ε/̵͇̿̿/’̿’̿ ̿ ̿̿ ̿̿ ̿̿ +name pending + +This project is separated into the server (`board-server`) and the client (`board-web-client`). All files inside `board-web-client` can be served to the browser and are therefore public. + +You will need to build and run the `board-server` project before using or contributing to the client or server. + +## Building `board-server` + +`board-server` is an HTTP/WebSocket server written in Java 8. Make sure you have the Java 8 JDK and [Apache Maven](https://maven.apache.org/index.html) installed on your system before attempting to build the project. + +#### Build Steps +- Clone this repository to your local machine +- Run `mvn clean package` in `board-server` to generate server artifact in the newly created `target` folder + +Modifying `board-server` will require you to recompile the project and restart the server. + +Modifying `board-web-client` only requires you to refresh your browser. If you do not see your new changes, then use `ctrl+F5` to also reset your browser cache. + +## Running `board-server` + +Once you have built `board-server`, you may run it using using the following command: + + java -jar board-server-xxx.jar --ssl.keystore http + +- This starts an HTTP/WebSocket server listening on port 80, which you can connect in your browser at [http://localhost](http://localhost). +- All WebSocket connections run on the same port as the HTTP server, and should connect to [ws://localhost/websocket](ws://localhost/websocket). +- Stop the server gracefully to save persistent data (`ctrl+c` on Windows terminals). Killing the process may result in data loss from any unsaved persistent data. + +### Properties + +These can be set in several different ways: + +##### `board.properties` +Create a file called `board.properties` in the working directory of the server and format as the following + + key=value + other.key=value with spaces + +##### Command line arguments + + java -jar board-server-xxx.jar --key value --other.key "value with spaces" +File indicates a relative (/folder/file.ext) or absolute path (C:/folder/file.ext) + +| Flag | Type | Default | Description | +| --- | ---- | ------- | ----------- | +| ssl.keystore.path | file | (required) | Path to the PKCS12 `mykeystore.pfx` containing the private key for the server. Set to `http` to use HTTP without SSL encryption | +| ssl.passphrase | string | (optional) | Passphrase for `mykeystore.pfx` | +| document.root.path | file | documentRoot | Should be set to where `board-web-client` is. HTTP requests to a directory (`localhost` or `localhost/folder/`) will be served the `index.html` file of those directories HTTP requests to a file that do not specify an extension (`localhost/file`) will be served a `.html` that corresponds to the requested name +| data.root.path | file | data | Sets the folder location of the flat file storage | +| debug.log.traffic | boolean | false | Will print network throughput (read/write) into the console | +| autosave.interval | integer (seconds) | -1 | Sets how often flat file storage will be saved to disk, or negative value to disable | +| http.cache.time | integer (seconds) | 0 | How long before a cached item expires. Set to 0 for development purposes so refreshing the page will always load your new changes (equivalent of `crtl+f5`) | \ No newline at end of file diff --git a/board-server/pom.xml b/board-server/pom.xml index 975ed271..3183f094 100644 --- a/board-server/pom.xml +++ b/board-server/pom.xml @@ -6,7 +6,7 @@ net.stzups.board board-server - 0.1-SNAPSHOT + 0.2 UTF-8 diff --git a/board-server/src/main/java/net/stzups/board/Board.java b/board-server/src/main/java/net/stzups/board/Board.java index e91b3639..6866a4c0 100644 --- a/board-server/src/main/java/net/stzups/board/Board.java +++ b/board-server/src/main/java/net/stzups/board/Board.java @@ -1,39 +1,111 @@ package net.stzups.board; +import io.netty.channel.ChannelFuture; +import net.stzups.board.config.Config; +import net.stzups.board.config.ConfigBuilder; +import net.stzups.board.config.configs.ArgumentConfig; +import net.stzups.board.config.configs.PropertiesConfig; +import net.stzups.board.data.TokenGenerator; +import net.stzups.board.data.database.flatfile.FlatFileStorage; +import net.stzups.board.data.objects.Document; +import net.stzups.board.data.objects.HttpSession; +import net.stzups.board.data.objects.User; +import net.stzups.board.data.objects.UserSession; import net.stzups.board.server.Server; +import java.io.IOException; import java.util.logging.Logger; public class Board { - private static Server server; private static Logger logger; + private static Config config; - public static void main(String[] args) { + private static final String DEFAULT_DOCUMENT_NAME = "Untitled Document"; + + private static FlatFileStorage users;//user id -> user + private static FlatFileStorage documents;//document id -> document + private static FlatFileStorage httpSessions;//http session id -> http session todo unused + private static FlatFileStorage userSessions;//user session id -> user session + + public static Document getDocument(long id) { + return documents.get(id); + } + + + public static void addUser(User user) { + users.put(user.getId(), user); + } + + public static User getUser(long id) { + return users.get(id); + } + + public static UserSession removeUserSession(long token) { + return userSessions.remove(token); + } + + public static void addUserSession(UserSession userSession) { + userSessions.put(userSession.getToken(), userSession); + } + + public static HttpSession getHttpSession(long token) { + return httpSessions.get(token); + } + + public static void addHttpSession(HttpSession httpSession) { + httpSessions.put(httpSession.getToken(), httpSession); + } + + public static Document createDocument(User owner) { + Document document = new Document(TokenGenerator.getSecureRandom().nextLong(), owner, DEFAULT_DOCUMENT_NAME); + documents.put(document.getId(), document); + return document; + } + + + public static void main(String[] args) throws Exception { logger = LogFactory.getLogger("Board Server"); logger.info("Starting Board server..."); long start = System.currentTimeMillis(); - new ConsoleManager(); + config = new ConfigBuilder() + .addConfig(new ArgumentConfig(args)) + .addConfig(new PropertiesConfig("board.properties")) + .build(); - server = new Server(); - server.run(); + Server server = new Server(); + ChannelFuture channelFuture = server.start(); + + try { + users = new FlatFileStorage<>("users"); + documents = new FlatFileStorage<>("documents"); + httpSessions = new FlatFileStorage<>("httpSessions"); + userSessions = new FlatFileStorage<>("userSessions"); + } catch (IOException e) { + e.printStackTrace(); + return; + } logger.info("Started Board server in " + (System.currentTimeMillis() - start) + "ms"); - } - static void stop() { - logger.info("Stopping Board server..."); + channelFuture.sync(); - long start = System.currentTimeMillis(); + start = System.currentTimeMillis(); + + logger.info("Stopping Board Server"); server.stop(); - logger.info("Stopped Board server in " + (System.currentTimeMillis() - start) + "ms"); + logger.info("Stopped Board Server in " + (System.currentTimeMillis() - start) + "ms"); } public static Logger getLogger() { return logger; } + + public static Config getConfig() { + return config; + } } diff --git a/board-server/src/main/java/net/stzups/board/ConsoleManager.java b/board-server/src/main/java/net/stzups/board/ConsoleManager.java deleted file mode 100644 index 9df5e355..00000000 --- a/board-server/src/main/java/net/stzups/board/ConsoleManager.java +++ /dev/null @@ -1,32 +0,0 @@ -package net.stzups.board; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; - -/** - * Handles user input to the console, allowing the user to execute commands - */ -public class ConsoleManager implements Runnable { - ConsoleManager() { - new Thread(this).start(); - } - - @Override - public void run() { - try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in))) { - while (true) { - String[] args = bufferedReader.readLine().split("\\s"); - switch (args[0].toLowerCase()) { - case "stop": - Board.stop(); - return; - default: - System.out.println("Unknown command " + args[0]); - } - } - } catch (IOException e) { - e.printStackTrace(); - } - } -} diff --git a/board-server/src/main/java/net/stzups/board/RandomString.java b/board-server/src/main/java/net/stzups/board/RandomString.java new file mode 100644 index 00000000..d5571aea --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/RandomString.java @@ -0,0 +1,18 @@ +package net.stzups.board; + +import java.util.Random; + +public class RandomString { + public static final char[] LOWERCASE_ALPHABET = "abcdefghijklmnopqrstuvwxyz".toCharArray(); + public static final char[] NUMERIC = "0123456789".toCharArray(); + + private static final Random random = new Random(); + + public static String randomString(int length, char[] chars) { + char[] randomChars = new char[length]; + for (int i = 0; i < randomChars.length; i++) { + randomChars[i] = chars[random.nextInt(chars.length)]; + } + return new String(randomChars); + } +} diff --git a/board-server/src/main/java/net/stzups/board/config/Config.java b/board-server/src/main/java/net/stzups/board/config/Config.java new file mode 100644 index 00000000..db53f0b7 --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/config/Config.java @@ -0,0 +1,69 @@ +package net.stzups.board.config; + +import java.nio.ByteBuffer; +import java.util.List; + +/** + * Used to store and retrieve key-value pairs by finding the first result from many different strategies. + */ +public class Config {//todo probably needs a better name + private List configProviders; + + /** + * Constructs a new ConfigProvider from its builder + */ + Config(List configProviders) { + this.configProviders = configProviders; + } + + /** + * Gets a String value for a String key from any config provider + */ + public String get(String key) { + for (ConfigProvider configProvider : configProviders) { + String value = configProvider.get(key); + if (value != null) { + return value; + } + } + + return null; + } + + public int getInt(String key) { + try { + return Integer.parseInt(get(key)); + } catch (NumberFormatException e) { + return 0; + } + } + + public int getInt(String key, int defaultValue) { + try { + return Integer.parseInt(get(key)); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + public boolean getBoolean(String key) { + return Boolean.parseBoolean(get(key)); + } + + public boolean getBoolean(String key, boolean defaultValue) { + String value = get(key); + if (value == null) { + return defaultValue; + } else { + return Boolean.parseBoolean(key); + } + } + + public String get(String key, String defaultValue) { + String value = get(key); + if (value == null) { + return defaultValue; + } + return value; + } +} diff --git a/board-server/src/main/java/net/stzups/board/config/ConfigBuilder.java b/board-server/src/main/java/net/stzups/board/config/ConfigBuilder.java new file mode 100644 index 00000000..884384e4 --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/config/ConfigBuilder.java @@ -0,0 +1,22 @@ +package net.stzups.board.config; + +import java.util.ArrayList; +import java.util.List; + +/** + * Builds a {@link Config} with several different configs. + */ +public class ConfigBuilder { + private List configProviders = new ArrayList<>(); + + public ConfigBuilder() {} + + public ConfigBuilder addConfig(ConfigProvider configProvider) { + configProviders.add(configProvider); + return this; + } + + public Config build() { + return new Config(configProviders); + } +} diff --git a/board-server/src/main/java/net/stzups/board/config/ConfigProvider.java b/board-server/src/main/java/net/stzups/board/config/ConfigProvider.java new file mode 100644 index 00000000..b892a205 --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/config/ConfigProvider.java @@ -0,0 +1,5 @@ +package net.stzups.board.config; + +public interface ConfigProvider { + String get(String key); +} diff --git a/board-server/src/main/java/net/stzups/board/config/configs/ArgumentConfig.java b/board-server/src/main/java/net/stzups/board/config/configs/ArgumentConfig.java new file mode 100644 index 00000000..b7d0c911 --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/config/configs/ArgumentConfig.java @@ -0,0 +1,47 @@ +package net.stzups.board.config.configs; + +import net.stzups.board.config.ConfigProvider; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +/** + * Takes arguments from the console and parses them as key value pairs. + */ +public class ArgumentConfig implements ConfigProvider { + private Map flags = new HashMap<>(); + + /** + * Format: + * --flag value + * --flag "value with space" + * Flags that are not properly formatted will be ignored. + * @param args should be take from program entry point, in the proper format + */ + public ArgumentConfig(String[] args) { + Iterator iterator = Arrays.asList(args).iterator(); + while (iterator.hasNext()) { + String flag = iterator.next(); + if (flag.startsWith("--") && iterator.hasNext()) { + String value = iterator.next(); + if (value.startsWith("\"")) { + value = value.substring(1); + while (iterator.hasNext() && !value.endsWith("\"")) { + value += iterator.next(); + } + if (value.endsWith("\"")) { + value = value.substring(0, value.length() - 1); + } + } + flags.put(flag.substring(2), value); + } + } + } + + @Override + public String get(String key) { + return flags.get(key); + } +} diff --git a/board-server/src/main/java/net/stzups/board/config/configs/PropertiesConfig.java b/board-server/src/main/java/net/stzups/board/config/configs/PropertiesConfig.java new file mode 100644 index 00000000..a918299a --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/config/configs/PropertiesConfig.java @@ -0,0 +1,35 @@ +package net.stzups.board.config.configs; + +import net.stzups.board.config.ConfigProvider; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.Properties; + +/** + * Loads a file that should be formatted as a Java {@link Properties} file, and adds any values defined in that value. + * This still works if the file does not exist or no values are present in the file. + */ +public class PropertiesConfig implements ConfigProvider { + private Properties properties; + + /** + * Loads .properties formatted file from given path, only if it exists. + * @param path path to the .properties file + */ + public PropertiesConfig(String path) throws IOException { + properties = new Properties(); + File file = new File(path); + if (file.exists()) {//load user defined config if created + try (FileInputStream fileInputStream = new FileInputStream(file)) { + properties.load(fileInputStream); + } + } + } + + @Override + public String get(String key) { + return properties.getProperty(key); + } +} diff --git a/board-server/src/main/java/net/stzups/board/data/TokenGenerator.java b/board-server/src/main/java/net/stzups/board/data/TokenGenerator.java new file mode 100644 index 00000000..13e80e7e --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/data/TokenGenerator.java @@ -0,0 +1,18 @@ +package net.stzups.board.data; + +import java.security.SecureRandom; +import java.util.Random; + +public class TokenGenerator { + + private static final SecureRandom secureRandom = new SecureRandom(); + private static final Random random = new Random(); + + public static SecureRandom getSecureRandom() { + return secureRandom; + } + + public static Random getRandom() { + return random; + } +} diff --git a/board-server/src/main/java/net/stzups/board/data/database/flatfile/FlatFileStorage.java b/board-server/src/main/java/net/stzups/board/data/database/flatfile/FlatFileStorage.java new file mode 100644 index 00000000..96403055 --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/data/database/flatfile/FlatFileStorage.java @@ -0,0 +1,88 @@ +package net.stzups.board.data.database.flatfile; + +import net.stzups.board.Board; + +import java.io.EOFException; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InvalidClassException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +public class FlatFileStorage extends HashMap { + private static final int SAVE_INTERVAL = Board.getConfig().getInt("autosave.interval", -1);//in seconds, -1 to disable + private static final String FILE_EXTENSION = "data"; + + private File file; + + public FlatFileStorage(String name) throws IOException { + File directory = new File(Board.getConfig().get("data.root.path", "data")); + if (!directory.exists() && !directory.mkdirs()) { + throw new IOException("Error while making directory at " + directory.getPath()); + } + file = new File(directory, name + "." + FILE_EXTENSION); + if (!file.exists()) { + if (!file.createNewFile()) { + throw new IOException("Error while making file at " + file.getPath()); + } + save(); + } + load(); + if (SAVE_INTERVAL > 0) { + Executors.newSingleThreadScheduledExecutor().scheduleWithFixedDelay(() -> { + try { + save(); + } catch (IOException e) { + Board.getLogger().warning(new IOException("Autosave: Failed to save to disk (ruh roh raggy!)", e).toString()); + } + }, 10, SAVE_INTERVAL, TimeUnit.SECONDS); + } + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + try { + save(); + } catch (IOException e) { + Board.getLogger().warning(new IOException("Failed to save to disk (ruh roh raggy!)", e).toString()); + } + })); + } + + protected File getFile() { + return file; + } + + @SuppressWarnings("unchecked") + public void load() throws IOException { + try (ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(getFile()))) { + while (true) { + Object key; + Object value; + try { + key = objectInputStream.readObject(); + value = objectInputStream.readObject(); + } catch (ClassNotFoundException | InvalidClassException e) { + e.printStackTrace(); + continue;//maybe just one bad entry, so keep going + } catch (EOFException e) { + break;//indicates end of file + } + put((K) key, (V) value);//todo unchecked + } + } + } + + public void save() throws IOException { + try (ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream(getFile()))) { + for (Map.Entry entry : entrySet()) { + objectOutputStream.writeObject(entry.getKey()); + objectOutputStream.writeObject(entry.getValue()); + } + } + } +} diff --git a/board-server/src/main/java/net/stzups/board/data/objects/Document.java b/board-server/src/main/java/net/stzups/board/data/objects/Document.java new file mode 100644 index 00000000..dc8631a3 --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/data/objects/Document.java @@ -0,0 +1,54 @@ +package net.stzups.board.data.objects; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class Document implements Serializable { + private long id; + private User owner; + private String name; + private String inviteCode; + private Map> points = new HashMap<>(); + + public Document(long id, User owner, String name) { + this.id = id; + this.owner = owner; + owner.getOwnedDocuments().add(id); + this.name = name; + } + + public long getId() { + return id; + } + + public String getName() { + return name; + } + + public Map> getPoints() { + return points; + } + + public void addPoints(User user, Point[] points) { + List pts = this.points.get(user); + if (pts == null) { + pts = new ArrayList<>(); + } + pts.addAll(Arrays.asList(points)); + this.points.put(user, pts); + } + + @Override + public String toString() { + return "Document{id=" + id + ",name=" + name + "}"; + } + + @Override + public int hashCode() { + return Long.hashCode(id); + } +} diff --git a/board-server/src/main/java/net/stzups/board/data/objects/HttpSession.java b/board-server/src/main/java/net/stzups/board/data/objects/HttpSession.java new file mode 100644 index 00000000..2051ad9c --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/data/objects/HttpSession.java @@ -0,0 +1,88 @@ +package net.stzups.board.data.objects; + +import io.netty.buffer.Unpooled; +import io.netty.handler.codec.base64.Base64; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.cookie.Cookie; +import io.netty.handler.codec.http.cookie.DefaultCookie; +import io.netty.handler.codec.http.cookie.ServerCookieDecoder; +import net.stzups.board.Board; +import net.stzups.board.data.TokenGenerator; + +import java.io.Serializable; +import java.net.InetAddress; +import java.nio.charset.StandardCharsets; +import java.util.Set; + +public class HttpSession implements Serializable { + private static final long SESSION_TOKEN_MAX_AGE = 2314;//todo + + public static Cookie getCookie(HttpHeaders httpHeaders, InetAddress address) { + HttpSession httpSession = getSession(httpHeaders, address); + if (httpSession == null) { + return new HttpSession(address).generate(); + } + return null; + } + + public static HttpSession getSession(HttpHeaders httpHeaders, InetAddress address) { + String cookieString = httpHeaders.get(HttpHeaderNames.COOKIE); + if (cookieString != null) { + Set cookies = ServerCookieDecoder.STRICT.decode(cookieString); + for (Cookie cookie : cookies) { + if (cookie.name().equals("session-token")) { + HttpSession httpSession = Board.getHttpSession(Base64.decode(Unpooled.wrappedBuffer(cookie.value().getBytes())).getLong(0)); + if (httpSession != null && httpSession.validate(cookie, address)) { + System.out.println("good session"); + return httpSession; + } + //bad session + System.out.println("bad session"); + break; + } + } + } + //new session + System.out.println("no session"); + return null; + } + + private long token; + private InetAddress address; + + private HttpSession(InetAddress address) { + this.address = address; + } + + private Cookie generate() {//todo make sure this is only called once + token = TokenGenerator.getSecureRandom().nextLong(); + Cookie cookie = new DefaultCookie("session-token", Base64.encode(Unpooled.copyLong(token)).toString(StandardCharsets.US_ASCII));//todo allocation + cookie.setHttpOnly(true); + cookie.setDomain("localhost");//todo + cookie.setMaxAge(SESSION_TOKEN_MAX_AGE); + cookie.setPath("/");//todo + //cookie.setSecure(true); cant be done over http + cookie.setWrap(true);//todo + Board.addHttpSession(this); + return cookie; + } + + public long getToken() { + return token; + } + + private boolean validate(Cookie cookie, InetAddress address) { + return this.address.equals(address); + } + + @Override + public String toString() { + return "HttpSession{address=" + address + "}"; + } + + @Override + public int hashCode() { + return Long.hashCode(token); + } +} diff --git a/board-server/src/main/java/net/stzups/board/protocol/Point.java b/board-server/src/main/java/net/stzups/board/data/objects/Point.java similarity index 60% rename from board-server/src/main/java/net/stzups/board/protocol/Point.java rename to board-server/src/main/java/net/stzups/board/data/objects/Point.java index 5c7b0b3e..41c1aae3 100644 --- a/board-server/src/main/java/net/stzups/board/protocol/Point.java +++ b/board-server/src/main/java/net/stzups/board/data/objects/Point.java @@ -1,6 +1,8 @@ -package net.stzups.board.protocol; +package net.stzups.board.data.objects; -public class Point { +import java.io.Serializable; + +public class Point implements Serializable { public int dt; public short x; public short y; diff --git a/board-server/src/main/java/net/stzups/board/data/objects/User.java b/board-server/src/main/java/net/stzups/board/data/objects/User.java new file mode 100644 index 00000000..724ee6d0 --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/data/objects/User.java @@ -0,0 +1,42 @@ +package net.stzups.board.data.objects; + +import net.stzups.board.data.TokenGenerator; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +public class User implements Serializable { + private long id; + private List ownedDocuments; + private List sharedDocuments; + + public User() { + id = TokenGenerator.getSecureRandom().nextLong(); + ownedDocuments = new ArrayList<>(); + //ownedDocuments.add(Document.createDocument(this).getId()); todo do somewhere else + sharedDocuments = new ArrayList<>(); + } + + public long getId() { + return id; + } + + public List getOwnedDocuments() { + return ownedDocuments; + } + + public List getSharedDocuments() { + return sharedDocuments; + } + + @Override + public String toString() { + return "User{id=" + id + "}"; + } + + @Override + public int hashCode() { + return Long.hashCode(id); + } +} diff --git a/board-server/src/main/java/net/stzups/board/data/objects/UserSession.java b/board-server/src/main/java/net/stzups/board/data/objects/UserSession.java new file mode 100644 index 00000000..27e49c69 --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/data/objects/UserSession.java @@ -0,0 +1,44 @@ +package net.stzups.board.data.objects; + +import net.stzups.board.data.TokenGenerator; + +import java.io.Serializable; +import java.net.InetAddress; + +public class UserSession implements Serializable { + private static final int MAX_USER_SESSION_AGE = 10000000;//todo + private long userId; + private long token; + private long creationDate; + private InetAddress inetAddress;//todo hash? + + public UserSession(User user, InetAddress inetAddress) { + this.userId = user.getId(); + this.token = TokenGenerator.getSecureRandom().nextLong(); + this.creationDate = System.currentTimeMillis(); + this.inetAddress = inetAddress; + } + + public long getToken() { + return token; + } + + public long getUserId() { + return userId; + } + + public boolean validate(InetAddress inetAddress) { + token = 0; + return (System.currentTimeMillis() - creationDate) < MAX_USER_SESSION_AGE && this.inetAddress.equals(inetAddress); + } + + @Override + public String toString() { + return "UserSession{userId=" + userId + ",@" + hashCode()+ "}"; + } + + @Override + public int hashCode() { + return Long.hashCode(userId); + } +} diff --git a/board-server/src/main/java/net/stzups/board/protocol/PacketEncoder.java b/board-server/src/main/java/net/stzups/board/protocol/PacketEncoder.java deleted file mode 100644 index 5737f8cb..00000000 --- a/board-server/src/main/java/net/stzups/board/protocol/PacketEncoder.java +++ /dev/null @@ -1,59 +0,0 @@ -package net.stzups.board.protocol; - -import io.netty.buffer.ByteBuf; -import io.netty.channel.ChannelHandler; -import io.netty.channel.ChannelHandlerContext; -import io.netty.handler.codec.MessageToByteEncoder; -import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; -import net.stzups.board.protocol.server.ServerPacket; -import net.stzups.board.protocol.server.ServerPacketDraw; -import net.stzups.board.protocol.server.ServerPacketId; -import net.stzups.board.protocol.server.ServerPacketOpen; - -import java.nio.charset.StandardCharsets; -import java.util.List; - -/** - * Encodes a ServerPacket sent by the server to - */ -@ChannelHandler.Sharable -public class PacketEncoder extends MessageToByteEncoder> { - @Override - protected void encode(ChannelHandlerContext ctx, List serverPackets, ByteBuf b) { - BinaryWebSocketFrame binaryWebSocketFrame = new BinaryWebSocketFrame(); - ByteBuf byteBuf = binaryWebSocketFrame.content(); - for (ServerPacket serverPacket : serverPackets) { - byteBuf.writeByte((byte) serverPacket.getPacketType().getId()); //todo test with values over 127 (sign issues) - if (serverPacket instanceof ServerPacketId) { - byteBuf.writeShort((short) ((ServerPacketId) serverPacket).getId()); - } - switch (serverPacket.getPacketType()) { - case ADD_CLIENT: - case REMOVE_CLIENT: - case WRONG_ROOM: - break; - case DRAW: { - ServerPacketDraw packetDraw = (ServerPacketDraw) serverPacket; - Point[] points = packetDraw.getPoints(); - byteBuf.writeShort((short) points.length); - for (Point point : points) { - byteBuf.writeByte((byte) point.dt); - byteBuf.writeShort(point.x); - byteBuf.writeShort(point.y); - } - break; - } - case OPEN: { - ServerPacketOpen serverPacketOpen = (ServerPacketOpen) serverPacket; - byte[] buffer = serverPacketOpen.getId().getBytes(StandardCharsets.UTF_8); - byteBuf.writeByte((byte) buffer.length); - byteBuf.writeBytes(buffer); - break; - } - default: - throw new UnsupportedOperationException("Unsupported packet type " + serverPacket + " while encoding"); - } - } - ctx.writeAndFlush(binaryWebSocketFrame); - } -} diff --git a/board-server/src/main/java/net/stzups/board/protocol/client/ClientPacketOpen.java b/board-server/src/main/java/net/stzups/board/protocol/client/ClientPacketOpen.java deleted file mode 100644 index 588f28cb..00000000 --- a/board-server/src/main/java/net/stzups/board/protocol/client/ClientPacketOpen.java +++ /dev/null @@ -1,13 +0,0 @@ -package net.stzups.board.protocol.client; - -public class ClientPacketOpen extends ClientPacket { - private String id; - public ClientPacketOpen(String id) { - super(ClientPacketType.OPEN); - this.id = id; - } - - public String getId() { - return id; - } -} diff --git a/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacket.java b/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacket.java deleted file mode 100644 index 6d07517a..00000000 --- a/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacket.java +++ /dev/null @@ -1,16 +0,0 @@ -package net.stzups.board.protocol.server; - -/** - * Represents a packet sent by the server - */ -public abstract class ServerPacket { - private ServerPacketType packetType; - - ServerPacket(ServerPacketType packetType) { - this.packetType = packetType; - } - - public ServerPacketType getPacketType() { - return packetType; - } -} diff --git a/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacketAddClient.java b/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacketAddClient.java deleted file mode 100644 index 0e7d10c6..00000000 --- a/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacketAddClient.java +++ /dev/null @@ -1,9 +0,0 @@ -package net.stzups.board.protocol.server; - -import net.stzups.board.room.Client; - -public class ServerPacketAddClient extends ServerPacketId { - public ServerPacketAddClient(Client client) { - super(ServerPacketType.ADD_CLIENT, client.getId()); - } -} diff --git a/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacketDraw.java b/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacketDraw.java deleted file mode 100644 index 1f29050a..00000000 --- a/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacketDraw.java +++ /dev/null @@ -1,16 +0,0 @@ -package net.stzups.board.protocol.server; - -import net.stzups.board.protocol.Point; - -public class ServerPacketDraw extends ServerPacketId { - private Point[] points; - - public ServerPacketDraw(int id, Point[] points) { - super(ServerPacketType.DRAW, id); - this.points = points; - } - - public Point[] getPoints() { - return points; - } -} diff --git a/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacketId.java b/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacketId.java deleted file mode 100644 index 57dc3d64..00000000 --- a/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacketId.java +++ /dev/null @@ -1,14 +0,0 @@ -package net.stzups.board.protocol.server; - -public abstract class ServerPacketId extends ServerPacket { - private int id; - - ServerPacketId(ServerPacketType packetType, int id) { - super(packetType); - this.id = id; - } - - public int getId() { - return id; - } -} diff --git a/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacketOpen.java b/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacketOpen.java deleted file mode 100644 index 2f271ade..00000000 --- a/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacketOpen.java +++ /dev/null @@ -1,14 +0,0 @@ -package net.stzups.board.protocol.server; - -public class ServerPacketOpen extends ServerPacket { - private String id; - - public ServerPacketOpen(String id) { - super(ServerPacketType.OPEN); - this.id = id; - } - - public String getId() { - return id; - } -} diff --git a/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacketRemoveClient.java b/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacketRemoveClient.java deleted file mode 100644 index 4a2e14af..00000000 --- a/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacketRemoveClient.java +++ /dev/null @@ -1,9 +0,0 @@ -package net.stzups.board.protocol.server; - -import net.stzups.board.room.Client; - -public class ServerPacketRemoveClient extends ServerPacketId { - public ServerPacketRemoveClient(Client client) { - super(ServerPacketType.REMOVE_CLIENT, client.getId()); - } -} diff --git a/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacketWrongRoom.java b/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacketWrongRoom.java deleted file mode 100644 index 5ad159dd..00000000 --- a/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacketWrongRoom.java +++ /dev/null @@ -1,8 +0,0 @@ -package net.stzups.board.protocol.server; - -public class ServerPacketWrongRoom extends ServerPacket { - - public ServerPacketWrongRoom() { - super(ServerPacketType.WRONG_ROOM); - } -} diff --git a/board-server/src/main/java/net/stzups/board/room/Client.java b/board-server/src/main/java/net/stzups/board/room/Client.java deleted file mode 100644 index 675bfec8..00000000 --- a/board-server/src/main/java/net/stzups/board/room/Client.java +++ /dev/null @@ -1,49 +0,0 @@ -package net.stzups.board.room; - -import io.netty.channel.Channel; -import net.stzups.board.protocol.Point; -import net.stzups.board.protocol.server.ServerPacket; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -public class Client { - private int id; - private Channel channel; - private List points = new ArrayList<>(); - private List packets = new ArrayList<>(); - - Client(int id, Channel channel) { - this.id = id; - this.channel = channel; - } - - public int getId() { - return id; - } - - void addPoints(Point[] points) { - this.points.addAll(Arrays.asList(points)); - } - - List getPoints() { - return points; - } - - void addPacket(ServerPacket serverPacket) { - packets.add(serverPacket); - } - - void sendPackets() { - if (packets.size() > 0) { - channel.writeAndFlush(packets); - packets = new ArrayList<>(); - } - } - - @Override - public String toString() { - return "Client{id=" + id + ",address=" + channel.remoteAddress() + "}"; - } -} diff --git a/board-server/src/main/java/net/stzups/board/room/EmptyClient.java b/board-server/src/main/java/net/stzups/board/room/EmptyClient.java deleted file mode 100644 index 01a51e8d..00000000 --- a/board-server/src/main/java/net/stzups/board/room/EmptyClient.java +++ /dev/null @@ -1,22 +0,0 @@ -package net.stzups.board.room; - -import net.stzups.board.protocol.server.ServerPacket; - -public class EmptyClient extends Client { - EmptyClient(int id) { - super(id, null); - } - - @Override - void addPacket(ServerPacket serverPacket) { - } - - @Override - void sendPackets() { - } - - @Override - public String toString() { - return "FakeClient{id=" + getId() + "}"; - } -} diff --git a/board-server/src/main/java/net/stzups/board/room/PacketHandler.java b/board-server/src/main/java/net/stzups/board/room/PacketHandler.java deleted file mode 100644 index 446e92fb..00000000 --- a/board-server/src/main/java/net/stzups/board/room/PacketHandler.java +++ /dev/null @@ -1,52 +0,0 @@ -package net.stzups.board.room; - -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.SimpleChannelInboundHandler; -import net.stzups.board.Board; -import net.stzups.board.protocol.client.ClientPacket; -import net.stzups.board.protocol.client.ClientPacketDraw; -import net.stzups.board.protocol.client.ClientPacketOpen; -import net.stzups.board.protocol.server.ServerPacketDraw; -import net.stzups.board.protocol.server.ServerPacketWrongRoom; - -import java.util.Collections; - -public class PacketHandler extends SimpleChannelInboundHandler { - private Room room; - private Client client; - - @Override - public void channelInactive(ChannelHandlerContext ctx) { - if (room != null) { - room.removeClient(client); - } - } - - @Override - protected void channelRead0(ChannelHandlerContext ctx, ClientPacket packet) { - switch (packet.getPacketType()) { - case DRAW: { - ClientPacketDraw clientPacketDraw = (ClientPacketDraw) packet; - client.addPoints(clientPacketDraw.getPoints()); - room.sendPacketExcept(new ServerPacketDraw(client.getId(), clientPacketDraw.getPoints()), client); - break; - } - case OPEN: { - if (room == null) { - ClientPacketOpen clientPacketOpen = (ClientPacketOpen) packet; - room = Room.getRoom(clientPacketOpen.getId()); - if (room != null) { - client = room.addClient(ctx.channel()); - } else { - ctx.writeAndFlush(Collections.singletonList(new ServerPacketWrongRoom())); - } - } else { - Board.getLogger().warning(client + " tried to open a new room when it was already open"); - } - break; - } - default: - throw new UnsupportedOperationException("Unsupported packet type " + packet.getPacketType() + " sent by " + client); - } - } -} diff --git a/board-server/src/main/java/net/stzups/board/room/Room.java b/board-server/src/main/java/net/stzups/board/room/Room.java deleted file mode 100644 index 97d336c9..00000000 --- a/board-server/src/main/java/net/stzups/board/room/Room.java +++ /dev/null @@ -1,170 +0,0 @@ -package net.stzups.board.room; - -import io.netty.channel.Channel; -import io.netty.util.collection.IntObjectHashMap; -import net.stzups.board.Board; -import net.stzups.board.protocol.Point; -import net.stzups.board.protocol.server.ServerPacket; -import net.stzups.board.protocol.server.ServerPacketAddClient; -import net.stzups.board.protocol.server.ServerPacketDraw; -import net.stzups.board.protocol.server.ServerPacketOpen; -import net.stzups.board.protocol.server.ServerPacketRemoveClient; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Timer; -import java.util.TimerTask; - -class Room { - private static final int SEND_PERIOD = 1000; - private static final int ROOM_ID_LENGTH = 6; - - private static Map rooms = new HashMap<>(); - static { - new Timer().scheduleAtFixedRate(new TimerTask() { - @Override - public void run() { - for (Room room : rooms.values()) { - for (Client client : room.clients.values()) { - client.sendPackets(); - } - } - } - }, 0, SEND_PERIOD); - } - - private int nextClientId = 1; //0 is reserved for room client - - private Map clients = new IntObjectHashMap<>(); //probably faster with smaller memory footprint for int keys - private Client emptyClient; - private String id; - - private Room(String id) { - this.id = id; - emptyClient = new EmptyClient(0); - clients.put(emptyClient.getId(), emptyClient); - } - - /** - * Creates a new room with a random id - * - * @return the created room - */ - private static Room createRoom() { - String id; - do { - id = String.valueOf((int) (Math.random() * Math.pow(10, ROOM_ID_LENGTH))); - } while (rooms.containsKey(id)); //todo improve - Room room = new Room(id); - rooms.put(room.getId(), room); - return room; - } - - /** - * Gets the corresponding room for an id - * - * @return the newly created or existing room - */ - static Room getRoom(String id) { - if (id.equals("")) { - return createRoom(); - } else { - return rooms.get(id); - } - } - - - - String getId() { - return id; - } - - /** - * Creates a new client using its channel - * - * @param channel the channel used by the client - * @return the newly created client - */ - Client addClient(Channel channel) { - Client client = new Client(nextClientId++, channel); - //for the new client - sendPacket(new ServerPacketOpen(id), client); - for (Client c : clients.values()) { - sendPacket(new ServerPacketAddClient(c), client); - List points = c.getPoints(); - if (points.size() > 0) { - sendPacket(new ServerPacketDraw(c.getId(), convert(new ArrayList<>(points))), client); - } - } - //for the existing clients - sendPacket(new ServerPacketAddClient(client)); - clients.put(client.getId(), client); - Board.getLogger().info("Added " + client + " to " + this); - return client; - } - - /** - * Converts a List to Point[], and marks them as instant draw - * @param points points to convert - * @return converted points - */ - private static Point[] convert(List points) { - Point[] pts = new Point[points.size()]; - int i = 0; - for (Point point : points) { - if (point.dt != 0) { - point.dt = -1; - } - pts[i++] = point; - } - return pts; - } - - void removeClient(Client client) { - emptyClient.addPoints(client.getPoints().toArray(new Point[0])); - clients.remove(client.getId()); - sendPacket(new ServerPacketRemoveClient(client)); - Board.getLogger().info("Removed " + client + " to " + this); - } - /** - * Send given packet to all members of the room except for the specified client - * - * @param serverPacket packet to send - * @param except client to exclude - */ - void sendPacketExcept(ServerPacket serverPacket, Client except) { - for (Client client : clients.values()) { - if (except != client) { - client.addPacket(serverPacket); - } - } - } - - /** - * Send packet to a client - * - * @param serverPacket the packet to send - * @param client the client to send to - */ - void sendPacket(ServerPacket serverPacket, Client client) { - client.addPacket(serverPacket); - } - - /** - * Send given packet to all clients of this room - * - * @param serverPacket the packet to send - */ - void sendPacket(ServerPacket serverPacket) { - for (Client client : clients.values()) { - client.addPacket(serverPacket); - } - } - - @Override - public String toString() { - return "Room{id=" + id + "}"; - } -} diff --git a/board-server/src/main/java/net/stzups/board/server/Server.java b/board-server/src/main/java/net/stzups/board/server/Server.java index 81e6a0d8..36c011dc 100644 --- a/board-server/src/main/java/net/stzups/board/server/Server.java +++ b/board-server/src/main/java/net/stzups/board/server/Server.java @@ -7,46 +7,83 @@ import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.handler.logging.LogLevel; import io.netty.handler.logging.LoggingHandler; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.SslProvider; import net.stzups.board.Board; import net.stzups.board.LogFactory; +import javax.net.ssl.KeyManagerFactory; +import java.io.FileInputStream; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyStore; + /** * Uses netty to create an HTTP/WebSocket server on the specified port */ public class Server { - private static final int PORT = 80; + private static final int HTTP_PORT = 80; + private static final int HTTPS_PORT = 443; - private ChannelFuture channelFuture; private EventLoopGroup bossGroup; private EventLoopGroup workerGroup; /** * Initializes the server and binds to the specified port */ - public void run() { + public ChannelFuture start() throws Exception { + SslContext sslContext; + int port; + + String keystorePath = Board.getConfig().get("ssl.keystore.path"); + if (keystorePath != null) {//must not be null + if (keystorePath.equals("http")) { + Board.getLogger().warning("Starting server using insecure http:// protocol without SSL"); + sslContext = null;//otherwise sslEngine is null and program continues with unencrypted sockets + port = HTTP_PORT; + } else { + String passphrase = Board.getConfig().get("ssl.passphrase"); + if (passphrase != null) {//can be null if value of keystore is http + try (FileInputStream fileInputStream = new FileInputStream(keystorePath)) { + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(fileInputStream, passphrase.toCharArray()); + + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(keyStore, passphrase.toCharArray()); + + //SelfSignedCertificate selfSignedCertificate = new SelfSignedCertificate(); + sslContext = SslContextBuilder.forServer(keyManagerFactory) + .sslProvider(SslProvider.JDK) + .build(); + port = HTTPS_PORT; + } catch (IOException | GeneralSecurityException e) { + throw new RuntimeException("Exception while getting SSL context", e); + } + } else { + throw new RuntimeException("Failed to specify SSL passphrase from --ssl.passphrase flag."); + } + } + } else { + throw new RuntimeException("Failed to set required flag --ssl.keystore.path. Perhaps you meant to explicitly disable encrypted sockets over HTTPS using --ssl.keystore.path http"); + } + bossGroup = new NioEventLoopGroup(); workerGroup = new NioEventLoopGroup(); ServerBootstrap serverBootstrap = new ServerBootstrap(); serverBootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) - .handler(new LoggingHandler(LogFactory.getLogger("netty").getName(), LogLevel.INFO)) - .childHandler(new ServerInitializer()); - channelFuture = serverBootstrap.bind(PORT); - Board.getLogger().info("Listening on port " + PORT); + .handler(new LoggingHandler(LogFactory.getLogger("netty").getName(), LogLevel.DEBUG)) + .childHandler(new ServerInitializer(sslContext)); + Board.getLogger().info("Binding to port " + port); + return serverBootstrap.bind(port).sync().channel().closeFuture(); } /** * Shuts down the server gracefully, then blocks until the server is shut down */ public void stop() { - Board.getLogger().info("Closing server..."); bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); - try { - channelFuture.channel().closeFuture().sync(); - } catch (InterruptedException e) { - e.printStackTrace(); - } - Board.getLogger().info("Closed server"); } } diff --git a/board-server/src/main/java/net/stzups/board/server/ServerInitializer.java b/board-server/src/main/java/net/stzups/board/server/ServerInitializer.java index d64e51d8..03d4b581 100644 --- a/board-server/src/main/java/net/stzups/board/server/ServerInitializer.java +++ b/board-server/src/main/java/net/stzups/board/server/ServerInitializer.java @@ -1,5 +1,7 @@ package net.stzups.board.server; +import io.netty.channel.ChannelDuplexHandler; +import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelPipeline; import io.netty.channel.socket.SocketChannel; @@ -7,13 +9,14 @@ import io.netty.handler.codec.http.HttpServerCodec; import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; import io.netty.handler.codec.http.websocketx.extensions.compression.WebSocketServerCompressionHandler; +import io.netty.handler.ssl.SslContext; import io.netty.handler.stream.ChunkedWriteHandler; import io.netty.handler.traffic.GlobalTrafficShapingHandler; import io.netty.handler.traffic.TrafficCounter; import net.stzups.board.Board; -import net.stzups.board.room.PacketHandler; -import net.stzups.board.protocol.PacketEncoder; -import net.stzups.board.protocol.PacketDecoder; +import net.stzups.board.server.websocket.protocol.PacketEncoder; +import net.stzups.board.server.websocket.protocol.PacketDecoder; +import net.stzups.board.server.http.HttpServerHandler; import java.util.concurrent.Executors; @@ -23,29 +26,49 @@ * Connections not made to the WebSocket path go to ServerHandler */ public class ServerInitializer extends ChannelInitializer { + private static final String WEB_SOCKET_PATH = "/websocket"; + private static final boolean DEBUG_LOG_TRAFFIC = Board.getConfig().getBoolean("debug.log.traffic", false); + private GlobalTrafficShapingHandler globalTrafficShapingHandler = new GlobalTrafficShapingHandler(Executors.newSingleThreadScheduledExecutor(), 0, 0, 1000) { @Override protected void doAccounting(TrafficCounter counter) { - System.out.print("\rread " + (double) counter.lastReadThroughput() / 1000 * 8 + "kb/s, write " + (double) counter.lastWriteThroughput() / 1000 * 8 + "kb/s"); + if (DEBUG_LOG_TRAFFIC) System.out.print("\rread " + (double) counter.lastReadThroughput() / 1000 * 8 + "kb/s, write " + (double) counter.lastWriteThroughput() / 1000 * 8 + "kb/s"); } }; + private PacketEncoder packetEncoder = new PacketEncoder(); private PacketDecoder packetDecoder = new PacketDecoder(); + private WebSocketInitializer webSocketInitializer = new WebSocketInitializer(); + private SslContext sslContext; + + ServerInitializer(SslContext sslContext) { + this.sslContext = sslContext; + } @Override protected void initChannel(SocketChannel socketChannel) { Board.getLogger().info("New connection from " + socketChannel.remoteAddress()); ChannelPipeline pipeline = socketChannel.pipeline(); - //todo ssl - pipeline.addLast(globalTrafficShapingHandler); - pipeline.addLast(new HttpServerCodec()); - pipeline.addLast(new HttpObjectAggregator(65536)); - pipeline.addLast(new ChunkedWriteHandler()); - pipeline.addLast(new WebSocketServerCompressionHandler()); - pipeline.addLast(new WebSocketServerProtocolHandler("/websocket", null, true)); - pipeline.addLast(new HttpServerHandler()); - pipeline.addLast(packetEncoder); - pipeline.addLast(packetDecoder); - pipeline.addLast(new PacketHandler()); + pipeline + .addLast(new ChannelDuplexHandler() { + @Override + public void exceptionCaught(ChannelHandlerContext channelHandlerContext, Throwable throwable) { + Board.getLogger().warning("Uncaught exception"); + throwable.printStackTrace(); + } + }) + .addLast(globalTrafficShapingHandler); + if (sslContext != null) { + pipeline.addLast(sslContext.newHandler(socketChannel.alloc())); + } + pipeline.addLast(new HttpServerCodec()) + .addLast(new HttpObjectAggregator(65536)) + .addLast(new ChunkedWriteHandler()) + .addLast(new WebSocketServerCompressionHandler()) + .addLast(new WebSocketServerProtocolHandler(WEB_SOCKET_PATH, null, true)) + .addLast(new HttpServerHandler()) + .addLast(packetEncoder) + .addLast(packetDecoder) + .addLast(webSocketInitializer); } } diff --git a/board-server/src/main/java/net/stzups/board/server/WebSocketInitializer.java b/board-server/src/main/java/net/stzups/board/server/WebSocketInitializer.java new file mode 100644 index 00000000..9a8a1bc7 --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/server/WebSocketInitializer.java @@ -0,0 +1,25 @@ +package net.stzups.board.server; + +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; +import io.netty.util.AttributeKey; +import net.stzups.board.data.objects.HttpSession; +import net.stzups.board.server.websocket.PacketHandler; + +import java.net.InetSocketAddress; + +@ChannelHandler.Sharable +public class WebSocketInitializer extends ChannelInboundHandlerAdapter { + public static final AttributeKey HTTP_SESSION_KEY = AttributeKey.valueOf(WebSocketInitializer.class, "HTTP_SESSION"); + + @Override + public void userEventTriggered(ChannelHandlerContext ctx, Object event) { + if (event instanceof WebSocketServerProtocolHandler.HandshakeComplete) { + WebSocketServerProtocolHandler.HandshakeComplete handshakeComplete = (WebSocketServerProtocolHandler.HandshakeComplete) event; + ctx.channel().attr(HTTP_SESSION_KEY).set(HttpSession.getSession(handshakeComplete.requestHeaders(), ((InetSocketAddress) ctx.channel().remoteAddress()).getAddress())); + ctx.pipeline().addLast(new PacketHandler()); + } + } +} diff --git a/board-server/src/main/java/net/stzups/board/server/HttpServerHandler.java b/board-server/src/main/java/net/stzups/board/server/http/HttpServerHandler.java similarity index 71% rename from board-server/src/main/java/net/stzups/board/server/HttpServerHandler.java rename to board-server/src/main/java/net/stzups/board/server/http/HttpServerHandler.java index 05d2b28f..896f29e6 100644 --- a/board-server/src/main/java/net/stzups/board/server/HttpServerHandler.java +++ b/board-server/src/main/java/net/stzups/board/server/http/HttpServerHandler.java @@ -1,4 +1,4 @@ -package net.stzups.board.server; +package net.stzups.board.server.http; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelFuture; @@ -19,22 +19,28 @@ import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.handler.codec.http.HttpVersion; import io.netty.handler.codec.http.LastHttpContent; +import io.netty.handler.codec.http.cookie.ClientCookieEncoder; +import io.netty.handler.codec.http.cookie.Cookie; import io.netty.handler.ssl.SslHandler; import io.netty.handler.stream.ChunkedFile; import io.netty.util.CharsetUtil; -import io.netty.util.internal.SystemPropertyUtil; +import net.stzups.board.Board; +import net.stzups.board.data.objects.HttpSession; -import javax.activation.MimetypesFileTypeMap; import java.io.File; import java.io.FileNotFoundException; +import java.io.IOException; import java.io.RandomAccessFile; import java.io.UnsupportedEncodingException; +import java.net.InetSocketAddress; import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; import java.util.Locale; +import java.util.Map; import java.util.TimeZone; import java.util.regex.Pattern; @@ -43,11 +49,19 @@ * modified from https://netty.io/4.1/xref/io/netty/example/http/file/HttpStaticFileServerHandler.html */ public class HttpServerHandler extends SimpleChannelInboundHandler { + private static final File HTTP_ROOT = new File(Board.getConfig().get("document.root.path", "documentRoot")); + static { + if (!HTTP_ROOT.exists()) { + if (!HTTP_ROOT.mkdirs()) { + throw new RuntimeException(new IOException("Failed to create directory at " + HTTP_ROOT.getPath())); + } + } + } private static final String HTTP_DATE_FORMAT = "EEE, dd MMM yyyy HH:mm:ss zzz"; private static final String HTTP_DATE_GMT_TIMEZONE = "GMT"; - private static final int HTTP_CACHE_SECONDS = 0; //todo change + private static final int HTTP_CACHE_SECONDS = Board.getConfig().getInt("http.cache.time", 0); + private static final String JOIN_PATH = "d"; - private static final MimetypesFileTypeMap mimeTypesMap = new MimetypesFileTypeMap(); private static final SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US); private static final Calendar time = new GregorianCalendar(); @@ -63,6 +77,17 @@ public class HttpServerHandler extends SimpleChannelInboundHandler entry : request.headers()) { + System.out.println(entry.getKey() + ":" + entry.getValue()); + } + System.out.println(); + System.out.println(request.content().toString(StandardCharsets.UTF_8)); + System.out.println("END=================================================================="); + } this.request = request; if (!request.decoderResult().isSuccess()) { sendError(ctx, HttpResponseStatus.BAD_REQUEST); @@ -75,26 +100,25 @@ public void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) thr } final boolean keepAlive = HttpUtil.isKeepAlive(request); - String uri = request.uri(); - /*if (uri.equals("/")) { //default directory - sendRedirect(ctx, uri + "index.html"); + + final String uri = sanitizeUri(request.uri()); + if (uri == null) { + sendError(ctx, HttpResponseStatus.FORBIDDEN); return; - }*/ - if (uri.startsWith("/r/")) { - //room code - uri = "/index.html"; - } else if (!uri.endsWith("/") && !uri.contains(".")) { - uri += ".html"; - } else if (uri.endsWith("/")) { - uri += "index.html"; } - final String path = sanitizeUri(uri); - if (path == null) { - sendError(ctx, HttpResponseStatus.FORBIDDEN); //todo return not found instead - return; + final String path; + // special cases + if (uri.startsWith("/" + JOIN_PATH + "/")) {// /JOIN_PATH/123456 -> index.html + path = "/index.html"; + } else if (!uri.endsWith("/") && !uri.contains(".")) {// /file -> /file.html + path = uri + ".html"; + } else if (uri.endsWith("/")) {// /directory/ -> /directory/index.html + path = uri + "index.html"; + } else { + path = uri; } - File file = new File(path); + File file = new File(HTTP_ROOT, path.replace('/', File.separatorChar)); if (file.isHidden() || !file.exists()) { sendError(ctx, HttpResponseStatus.NOT_FOUND); return; @@ -102,12 +126,11 @@ public void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) thr if (file.isDirectory()) { sendError(ctx, HttpResponseStatus.FORBIDDEN); - //sendRedirect(ctx, uri + ((uri.endsWith("/") ? "" : "/") + "index.html")); return; } if (!file.isFile()) { - sendError(ctx, HttpResponseStatus.FORBIDDEN); //todo return not found instead + sendError(ctx, HttpResponseStatus.FORBIDDEN); return; } @@ -136,6 +159,13 @@ public void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) thr long fileLength = raf.length(); HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); + if (path.equals("/index.html")) { + Cookie cookie = HttpSession.getCookie(request.headers(), ((InetSocketAddress) ctx.channel().remoteAddress()).getAddress()); + if (cookie != null) { + System.out.println(cookie.value()); + response.headers().set(HttpHeaderNames.SET_COOKIE, ClientCookieEncoder.STRICT.encode(cookie)); + } + } HttpUtil.setContentLength(response, fileLength); setContentTypeHeader(response, file); setDateAndCacheHeaders(response, file); @@ -180,7 +210,6 @@ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { /** * Simplistic dumb security check for client request uris * Will also replace file separators (/ or \) with the system specific separator - * todo thoroughly test before deploying to production environment. * * @param uri request uri from client * @return sanitized uri or null if the uri is unsafe and should not be handled @@ -190,26 +219,20 @@ private static String sanitizeUri(String uri) { try { uri = URLDecoder.decode(uri, "UTF-8"); } catch (UnsupportedEncodingException e) { - throw new Error(e); + return null; } if (uri.isEmpty() || uri.charAt(0) != '/') { return null; } - // Convert file separators. - uri = uri.replace('/', File.separatorChar); - - - if (uri.contains(File.separator + '.') || - uri.contains('.' + File.separator) || + if (uri.contains("/.") || uri.contains("./") || uri.charAt(0) == '.' || uri.charAt(uri.length() - 1) == '.' || INSECURE_URI.matcher(uri).matches()) { return null; } - // Convert to absolute path. - return SystemPropertyUtil.get("user.dir") + uri; + return uri; } private void sendRedirect(ChannelHandlerContext ctx, String newUri) { @@ -219,7 +242,7 @@ private void sendRedirect(ChannelHandlerContext ctx, String newUri) { sendAndCleanupConnection(ctx, response); } - private void sendError(ChannelHandlerContext ctx, HttpResponseStatus status) { + private void sendError(ChannelHandlerContext ctx, HttpResponseStatus status) {//todo fail2ban system where bad clients get blocked FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, Unpooled.copiedBuffer(status + "\r\n", CharsetUtil.UTF_8)); response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8"); @@ -258,24 +281,22 @@ private static void setDateHeader(FullHttpResponse response) { } private static void setDateAndCacheHeaders(HttpResponse response, File fileToCache) { - response.headers().set(HttpHeaderNames.CACHE_CONTROL, "no-store"); //todo re-enable caching - /*// Date header - Calendar time = new GregorianCalendar(); - response.headers().set(HttpHeaderNames.DATE, dateFormatter.format(time.getTime())); - - // Add cache headers - time.add(Calendar.SECOND, HTTP_CACHE_SECONDS); - response.headers().set(HttpHeaderNames.EXPIRES, dateFormatter.format(time.getTime())); - response.headers().set(HttpHeaderNames.CACHE_CONTROL, "private, max-age=" + HTTP_CACHE_SECONDS); - response.headers().set(HttpHeaderNames.LAST_MODIFIED, dateFormatter.format(new Date(fileToCache.lastModified())));*/ + if (HTTP_CACHE_SECONDS > 0) { + // Date header + Calendar time = new GregorianCalendar(); + response.headers().set(HttpHeaderNames.DATE, dateFormatter.format(time.getTime())); + + // Add cache headers + time.add(Calendar.SECOND, HTTP_CACHE_SECONDS); + response.headers().set(HttpHeaderNames.EXPIRES, dateFormatter.format(time.getTime())); + response.headers().set(HttpHeaderNames.CACHE_CONTROL, "private, max-age=" + HTTP_CACHE_SECONDS); + response.headers().set(HttpHeaderNames.LAST_MODIFIED, dateFormatter.format(new Date(fileToCache.lastModified()))); + } else { + response.headers().set(HttpHeaderNames.CACHE_CONTROL, "no-store"); + } } - /** - * Sets a MIME type for a file - * Additional MIME types can be defined in META-INF/mine.types - */ private static void setContentTypeHeader(HttpResponse response, File file) { - //System.out.println(file.getPath() + ", " + mimeTypesMap.getContentType(file.getPath())); //todo uncomment if you are having trouble with mime types - response.headers().set(HttpHeaderNames.CONTENT_TYPE, mimeTypesMap.getContentType(file.getPath())); + response.headers().set(HttpHeaderNames.CONTENT_TYPE, MimeTypes.getMimeTypeFromExtension(file)); } } diff --git a/board-server/src/main/java/net/stzups/board/server/http/MimeTypes.java b/board-server/src/main/java/net/stzups/board/server/http/MimeTypes.java new file mode 100644 index 00000000..3774a016 --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/server/http/MimeTypes.java @@ -0,0 +1,62 @@ +package net.stzups.board.server.http; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.HashMap; +import java.util.Map; + +/** + * Simple utility class that looks for a META-INF/mime.types file from resources and provides MIME type mappings. + * mime.types should be formatted as follows: + * text/html htm html + * image/jpeg jpg jpeg + * application/javascript js + */ +public class MimeTypes { + private static final String MIME_TYPES_FILE_PATH = "/META-INF/mime.types"; + private static final String DEFAULT_MIME_TYPE = "application/octet-stream"; + + private static Map extensionMimeTypeMap = new HashMap<>(); + + static { + try { + BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(MimeTypes.class.getResourceAsStream(MIME_TYPES_FILE_PATH))); + for (String line; (line = bufferedReader.readLine()) != null;) { + String[] split = line.split("\\s"); + if (split.length > 1) { + for (int i = 1; i < split.length; i++) { + extensionMimeTypeMap.put(split[i], split[0]); + } + } + } + } catch (IOException e) { + new IOException("Exception while loading MIME types from mime.types resource", e).printStackTrace(); + } + } + + /** + * Gets a MIME type for the extension from the path of a {@link java.io.File}. + * + * @param file the file + * @return the MIME type for the given file, or a default type + */ + public static String getMimeTypeFromExtension(File file) { + return getMimeTypeFromExtension(file.getPath()); + } + + /** + * Gets a MIME type for an extension. + * + * @param extension the extension, can include or exclude the file separator ("html" or ".html" both work) + * @return the MIME type for the given extension, or a default type + */ + public static String getMimeTypeFromExtension(String extension) { + int i = extension.lastIndexOf("."); + if (i != -1) { + extension = extension.substring(i + 1); + } + return extensionMimeTypeMap.getOrDefault(extension, DEFAULT_MIME_TYPE); + } +} diff --git a/board-server/src/main/java/net/stzups/board/server/websocket/Client.java b/board-server/src/main/java/net/stzups/board/server/websocket/Client.java new file mode 100644 index 00000000..3de8c3c6 --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/server/websocket/Client.java @@ -0,0 +1,69 @@ +package net.stzups.board.server.websocket; + +import io.netty.channel.Channel; +import net.stzups.board.data.TokenGenerator; +import net.stzups.board.data.objects.User; +import net.stzups.board.server.websocket.protocol.server.ServerPacket; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class Client { + private User user; + private Channel channel; + private short id; + + private List packets = new ArrayList<>(); + + Client(User user, Channel channel) { + this.user = user; + this.channel = channel; + regenerateId(); + } + + public User getUser() { + return user; + } + + public short getId() { + return id; + } + + public short regenerateId() { + id = (short) TokenGenerator.getRandom().nextInt(); //todo is this cast less random + if (id == 0) {//indicates fake client, should not be used by real clients + return regenerateId(); + } else { + return id; + } + } + + void queuePacket(ServerPacket serverPacket) { + packets.add(serverPacket); + } + + void sendPacket(ServerPacket serverPacket) { + channel.writeAndFlush(Collections.singletonList(serverPacket)); + } + + void flushPackets() { + if (packets.size() > 0) { + channel.writeAndFlush(packets); + packets = new ArrayList<>(); + } + } + + void disconnect() { + try { + channel.close().sync(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + + @Override + public String toString() { + return "Client{user=" + user + ",address=" + channel.remoteAddress() + "}"; + } +} diff --git a/board-server/src/main/java/net/stzups/board/server/websocket/PacketHandler.java b/board-server/src/main/java/net/stzups/board/server/websocket/PacketHandler.java new file mode 100644 index 00000000..fe81d2ea --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/server/websocket/PacketHandler.java @@ -0,0 +1,148 @@ +package net.stzups.board.server.websocket; + +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; +import net.stzups.board.Board; +import net.stzups.board.data.objects.Document; +import net.stzups.board.data.objects.User; +import net.stzups.board.data.objects.UserSession; +import net.stzups.board.server.websocket.protocol.client.ClientPacket; +import net.stzups.board.server.websocket.protocol.client.ClientPacketCreateDocument; +import net.stzups.board.server.websocket.protocol.client.ClientPacketDraw; +import net.stzups.board.server.websocket.protocol.client.ClientPacketHandshake; +import net.stzups.board.server.websocket.protocol.client.ClientPacketOpenDocument; +import net.stzups.board.server.websocket.protocol.server.ServerPacketAddDocument; +import net.stzups.board.server.websocket.protocol.server.ServerPacketAddUser; +import net.stzups.board.server.websocket.protocol.server.ServerPacketDrawClient; +import net.stzups.board.server.WebSocketInitializer; +import net.stzups.board.server.websocket.protocol.server.ServerPacketHandshake; + +import java.net.InetSocketAddress; +import java.util.HashMap; +import java.util.Map; + +public class PacketHandler extends SimpleChannelInboundHandler { + private static Map documents = new HashMap<>(); + private Room room; + private Client client; + + @Override + public void channelInactive(ChannelHandlerContext ctx) { + if (room != null) { + room.removeClient(client); + } + } + + @Override + public void handlerAdded(ChannelHandlerContext ctx) { + System.out.println(ctx.channel().hasAttr(WebSocketInitializer.HTTP_SESSION_KEY)); + System.out.println(ctx.channel().attr(WebSocketInitializer.HTTP_SESSION_KEY).get()); + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, ClientPacket packet) { + switch (packet.getPacketType()) { + case DRAW: { + ClientPacketDraw clientPacketDraw = (ClientPacketDraw) packet; + room.getDocument().addPoints(client.getUser(), clientPacketDraw.getPoints()); + room.queuePacketExcept(new ServerPacketDrawClient(client, clientPacketDraw.getPoints()), client);//todo this has tons of latency + break; + } + case OPEN_DOCUMENT: { + ClientPacketOpenDocument clientPacketOpenDocument = (ClientPacketOpenDocument) packet; + Document document = Board.getDocument(clientPacketOpenDocument.getId()); + if (document != null) { + if (room != null) { + room.removeClient(client); + } + room = getRoom(document); + room.addClient(client); + } else { + System.out.println(client + " tried to open document not that does not exist"); + } + break; + } + case CREATE_DOCUMENT: { + ClientPacketCreateDocument clientPacketCreateDocument = (ClientPacketCreateDocument) packet; + if (room != null) { + room.removeClient(client); + } + try { + room = getRoom(Board.createDocument(client.getUser())); + } catch (Exception e) { + e.printStackTrace(); + } + client.sendPacket(new ServerPacketAddDocument(room.getDocument())); + room.addClient(client); + break; + } + case HANDSHAKE: { + ClientPacketHandshake clientPacketHandshake = (ClientPacketHandshake) packet; + if (client == null) { + if (clientPacketHandshake.getToken() == 0) { + System.out.println("user authed with empty session"); + client = createUserSession(ctx, null); + } else { + UserSession userSession = Board.removeUserSession(clientPacketHandshake.getToken()); + if (userSession == null) { + System.out.println("user tried authenticating with nonexistant session"); + client = createUserSession(ctx, null); + } else if (!userSession.validate(((InetSocketAddress) ctx.channel().remoteAddress()).getAddress())) { + System.out.println("user tried authenticating with invalid session" + userSession); + client = createUserSession(ctx, null); + } else { + System.out.println("good user session"); + User user = Board.getUser(userSession.getUserId()); + if (user == null) { + System.out.println("very bad user does not exist"); + } + client = createUserSession(ctx, user); + } + } + } + client.queuePacket(new ServerPacketAddUser(client.getUser())); + if (client.getUser().getOwnedDocuments().size() == 0) { + client.queuePacket(new ServerPacketAddDocument(Board.createDocument(client.getUser()))); + } else { + for (long id : client.getUser().getOwnedDocuments()) { + client.queuePacket(new ServerPacketAddDocument(Board.getDocument(id))); + } + } + client.flushPackets(); + + break; + } + default: + throw new UnsupportedOperationException("Unsupported packet type " + packet.getPacketType() + " sent by " + client); + } + } + + private static Client createUserSession(ChannelHandlerContext ctx, User user) { + Client client; + if (user == null) { + client = new Client(new User(), ctx.channel()); + Board.addUser(client.getUser()); + } else { + client = new Client(user, ctx.channel()); + } + UserSession userSession = new UserSession(client.getUser(), ((InetSocketAddress) ctx.channel().remoteAddress()).getAddress()); + Board.addUserSession(userSession); + client.queuePacket(new ServerPacketHandshake(userSession)); + return client; + } + + /** + * Gets or creates a room for an existing document + * + * @param document the existing document + * @return the live room + */ + private static Room getRoom(Document document) { + Room r = documents.get(document); + if (r == null) { + r = Room.createRoom(document); + documents.put(r.getDocument(), r); + } + return r; + } +} diff --git a/board-server/src/main/java/net/stzups/board/server/websocket/Room.java b/board-server/src/main/java/net/stzups/board/server/websocket/Room.java new file mode 100644 index 00000000..1e4308d4 --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/server/websocket/Room.java @@ -0,0 +1,127 @@ +package net.stzups.board.server.websocket; + +import net.stzups.board.Board; +import net.stzups.board.data.objects.Document; +import net.stzups.board.data.objects.User; +import net.stzups.board.server.websocket.protocol.server.ServerPacket; +import net.stzups.board.server.websocket.protocol.server.ServerPacketAddClient; +import net.stzups.board.server.websocket.protocol.server.ServerPacketOpenDocument; +import net.stzups.board.server.websocket.protocol.server.ServerPacketRemoveClient; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.Timer; +import java.util.TimerTask; + +class Room { + private static final int SEND_PERIOD = 1000; + + private static List rooms = new ArrayList<>(); + static {//todo send some packets instantly and refactor to somewhere? + new Timer().scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + for (Room room : rooms) { + for (Client client : room.clients) { + client.flushPackets(); + } + } + } + }, 0, SEND_PERIOD); + } + + private Set clients = new HashSet<>(); + + private Document document; + private Room(Document document) { + this.document = document; + } + + /** + * Creates a new room with a random id + * + * @return the created room + */ + static Room createRoom(Document document) { + Room room = new Room(document); + rooms.add(room); + return room; + } + + Document getDocument() { + return document; + } + + /** + * Creates a new client using its channel. + * todo + */ + void addClient(Client client) { + + //for the new client + client.sendPacket(new ServerPacketOpenDocument(document)); + //for the existing clients + sendPacket(new ServerPacketAddClient(client)); + clients.add(client); + Board.getLogger().info("Added " + client + " to " + this); + } + + /** + * Removes given client from room + * + * @param client client to remove + */ + void removeClient(Client client) { + clients.remove(client); + sendPacket(new ServerPacketRemoveClient(client)); + Board.getLogger().info("Removed " + client + " to " + this); + } + + /** + * Send given packet to all members of the room except for the specified client + * + * @param serverPacket packet to send + * @param except client to exclude + */ + void sendPacketExcept(ServerPacket serverPacket, Client except) { + for (Client client : clients) { + if (except != client) { + client.sendPacket(serverPacket); + } + } + } + + /** + * Send given packet to all clients of this room + * + * @param serverPacket the packet to send + */ + void sendPacket(ServerPacket serverPacket) { + for (Client client : clients) { + client.sendPacket(serverPacket); + } + } + + void queuePacketExcept(ServerPacket serverPacket, Client except) { + for (Client client : clients) { + if (except != client) { + client.queuePacket(serverPacket); + } + } + } + + void queuePacket(ServerPacket serverPacket) { + for (Client client : clients) { + client.queuePacket(serverPacket); + } + } + + @Override + public String toString() { + return "Room{document=" + document + "}"; + } +} diff --git a/board-server/src/main/java/net/stzups/board/protocol/PacketDecoder.java b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/PacketDecoder.java similarity index 60% rename from board-server/src/main/java/net/stzups/board/protocol/PacketDecoder.java rename to board-server/src/main/java/net/stzups/board/server/websocket/protocol/PacketDecoder.java index bb8bdb26..494c1232 100644 --- a/board-server/src/main/java/net/stzups/board/protocol/PacketDecoder.java +++ b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/PacketDecoder.java @@ -1,4 +1,4 @@ -package net.stzups.board.protocol; +package net.stzups.board.server.websocket.protocol; import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelHandler; @@ -7,10 +7,13 @@ import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; import io.netty.handler.codec.http.websocketx.WebSocketFrame; -import net.stzups.board.protocol.client.ClientPacket; -import net.stzups.board.protocol.client.ClientPacketDraw; -import net.stzups.board.protocol.client.ClientPacketOpen; -import net.stzups.board.protocol.client.ClientPacketType; +import net.stzups.board.data.objects.Point; +import net.stzups.board.server.websocket.protocol.client.ClientPacket; +import net.stzups.board.server.websocket.protocol.client.ClientPacketCreateDocument; +import net.stzups.board.server.websocket.protocol.client.ClientPacketDraw; +import net.stzups.board.server.websocket.protocol.client.ClientPacketHandshake; +import net.stzups.board.server.websocket.protocol.client.ClientPacketOpenDocument; +import net.stzups.board.server.websocket.protocol.client.ClientPacketType; import javax.naming.OperationNotSupportedException; import java.nio.charset.StandardCharsets; @@ -28,6 +31,7 @@ protected void decode(ChannelHandlerContext ctx, WebSocketFrame webSocketFrame, } else if (webSocketFrame instanceof BinaryWebSocketFrame) { ByteBuf byteBuf = webSocketFrame.content(); ClientPacketType packetType = ClientPacketType.valueOf(byteBuf.readUnsignedByte()); + System.out.println("recv " + packetType); ClientPacket packet; switch (packetType) { case DRAW: @@ -37,10 +41,14 @@ protected void decode(ChannelHandlerContext ctx, WebSocketFrame webSocketFrame, } packet = new ClientPacketDraw(points); break; - case OPEN: - byte[] buffer = new byte[byteBuf.readUnsignedByte()]; - byteBuf.readBytes(buffer); - packet = new ClientPacketOpen(new String(buffer, StandardCharsets.UTF_8)); + case OPEN_DOCUMENT: + packet = new ClientPacketOpenDocument(byteBuf.readLong()); + break; + case CREATE_DOCUMENT: + packet = new ClientPacketCreateDocument(); + break; + case HANDSHAKE: + packet = new ClientPacketHandshake(byteBuf.readLong()); break; default: throw new OperationNotSupportedException("Unsupported packet type " + packetType+ " while decoding"); @@ -48,4 +56,10 @@ protected void decode(ChannelHandlerContext ctx, WebSocketFrame webSocketFrame, list.add(packet); } } + + private String readString(ByteBuf byteBuf) { + byte[] buffer = new byte[byteBuf.readUnsignedByte()]; + byteBuf.readBytes(buffer); + return new String(buffer, StandardCharsets.UTF_8); + } } diff --git a/board-server/src/main/java/net/stzups/board/server/websocket/protocol/PacketEncoder.java b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/PacketEncoder.java new file mode 100644 index 00000000..e46d51db --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/PacketEncoder.java @@ -0,0 +1,27 @@ +package net.stzups.board.server.websocket.protocol; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.MessageToByteEncoder; +import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; +import net.stzups.board.server.websocket.protocol.server.ServerPacket; + +import java.util.List; + +/** + * Encodes a ServerPacket sent by the server to + */ +@ChannelHandler.Sharable +public class PacketEncoder extends MessageToByteEncoder> { + @Override + protected void encode(ChannelHandlerContext ctx, List serverPackets, ByteBuf b) { + BinaryWebSocketFrame binaryWebSocketFrame = new BinaryWebSocketFrame(); + ByteBuf byteBuf = binaryWebSocketFrame.content(); + for (ServerPacket serverPacket : serverPackets) { + System.out.println("send " + serverPacket.getClass().getSimpleName()); + serverPacket.serialize(byteBuf); + } + ctx.writeAndFlush(binaryWebSocketFrame); + } +} diff --git a/board-server/src/main/java/net/stzups/board/protocol/client/ClientPacket.java b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/client/ClientPacket.java similarity index 83% rename from board-server/src/main/java/net/stzups/board/protocol/client/ClientPacket.java rename to board-server/src/main/java/net/stzups/board/server/websocket/protocol/client/ClientPacket.java index b890b5fd..761da7ac 100644 --- a/board-server/src/main/java/net/stzups/board/protocol/client/ClientPacket.java +++ b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/client/ClientPacket.java @@ -1,4 +1,4 @@ -package net.stzups.board.protocol.client; +package net.stzups.board.server.websocket.protocol.client; /** * Represents a packet sent by the client diff --git a/board-server/src/main/java/net/stzups/board/server/websocket/protocol/client/ClientPacketCreateDocument.java b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/client/ClientPacketCreateDocument.java new file mode 100644 index 00000000..a02beb26 --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/client/ClientPacketCreateDocument.java @@ -0,0 +1,7 @@ +package net.stzups.board.server.websocket.protocol.client; + +public class ClientPacketCreateDocument extends ClientPacket { + public ClientPacketCreateDocument() { + super(ClientPacketType.CREATE_DOCUMENT); + } +} diff --git a/board-server/src/main/java/net/stzups/board/protocol/client/ClientPacketDraw.java b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/client/ClientPacketDraw.java similarity index 72% rename from board-server/src/main/java/net/stzups/board/protocol/client/ClientPacketDraw.java rename to board-server/src/main/java/net/stzups/board/server/websocket/protocol/client/ClientPacketDraw.java index 72b61825..35379dcc 100644 --- a/board-server/src/main/java/net/stzups/board/protocol/client/ClientPacketDraw.java +++ b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/client/ClientPacketDraw.java @@ -1,6 +1,6 @@ -package net.stzups.board.protocol.client; +package net.stzups.board.server.websocket.protocol.client; -import net.stzups.board.protocol.Point; +import net.stzups.board.data.objects.Point; public class ClientPacketDraw extends ClientPacket { private Point[] points; diff --git a/board-server/src/main/java/net/stzups/board/server/websocket/protocol/client/ClientPacketHandshake.java b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/client/ClientPacketHandshake.java new file mode 100644 index 00000000..3518625e --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/client/ClientPacketHandshake.java @@ -0,0 +1,14 @@ +package net.stzups.board.server.websocket.protocol.client; + +public class ClientPacketHandshake extends ClientPacket { + private long token; + + public ClientPacketHandshake(long token) { + super(ClientPacketType.HANDSHAKE); + this.token = token; + } + + public long getToken() { + return token; + } +} diff --git a/board-server/src/main/java/net/stzups/board/server/websocket/protocol/client/ClientPacketOpenDocument.java b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/client/ClientPacketOpenDocument.java new file mode 100644 index 00000000..bbbbcaf4 --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/client/ClientPacketOpenDocument.java @@ -0,0 +1,14 @@ +package net.stzups.board.server.websocket.protocol.client; + +public class ClientPacketOpenDocument extends ClientPacket { + private long id; + + public ClientPacketOpenDocument(long id) { + super(ClientPacketType.OPEN_DOCUMENT); + this.id = id; + } + + public long getId() { + return id; + } +} diff --git a/board-server/src/main/java/net/stzups/board/protocol/client/ClientPacketType.java b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/client/ClientPacketType.java similarity index 87% rename from board-server/src/main/java/net/stzups/board/protocol/client/ClientPacketType.java rename to board-server/src/main/java/net/stzups/board/server/websocket/protocol/client/ClientPacketType.java index ddbaa3d1..6a83ebe7 100644 --- a/board-server/src/main/java/net/stzups/board/protocol/client/ClientPacketType.java +++ b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/client/ClientPacketType.java @@ -1,4 +1,4 @@ -package net.stzups.board.protocol.client; +package net.stzups.board.server.websocket.protocol.client; import io.netty.util.collection.IntObjectHashMap; @@ -6,8 +6,10 @@ import java.util.Map; public enum ClientPacketType { - OPEN(0), + OPEN_DOCUMENT(0), DRAW(1), + CREATE_DOCUMENT(2), + HANDSHAKE(3), ; private static Map packetTypeMap = new IntObjectHashMap<>(); diff --git a/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacket.java b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacket.java new file mode 100644 index 00000000..44978feb --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacket.java @@ -0,0 +1,31 @@ +package net.stzups.board.server.websocket.protocol.server; + +import io.netty.buffer.ByteBuf; + +import java.nio.charset.StandardCharsets; + +/** + * Represents a packet sent by the server + */ +public abstract class ServerPacket { + private ServerPacketType packetType; + + ServerPacket(ServerPacketType packetType) { + this.packetType = packetType; + } + + /** overriding classes need to call this first */ + public void serialize(ByteBuf bytebuf) { + bytebuf.writeByte((byte) packetType.getId()); + } + + /** poorly encodes strings as utf 8 preceded by a one byte unsigned length */ + static void writeString(String string, ByteBuf byteBuf) { + if (string.length() > 0xff) { + throw new UnsupportedOperationException("String too long"); + } + byte[] buffer = string.getBytes(StandardCharsets.UTF_8); + byteBuf.writeByte((byte) buffer.length); + byteBuf.writeBytes(buffer); + } +} diff --git a/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketAddClient.java b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketAddClient.java new file mode 100644 index 00000000..5d89a0e6 --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketAddClient.java @@ -0,0 +1,19 @@ +package net.stzups.board.server.websocket.protocol.server; + +import io.netty.buffer.ByteBuf; +import net.stzups.board.server.websocket.Client; + +public class ServerPacketAddClient extends ServerPacketClient { + private Client client; + + public ServerPacketAddClient(Client client) { + super(ServerPacketType.ADD_CLIENT, client); + this.client = client; + } + + @Override + public void serialize(ByteBuf byteBuf) { + super.serialize(byteBuf); + byteBuf.writeLong(client.getUser().getId()); + } +} diff --git a/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketAddDocument.java b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketAddDocument.java new file mode 100644 index 00000000..909035f0 --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketAddDocument.java @@ -0,0 +1,20 @@ +package net.stzups.board.server.websocket.protocol.server; + +import io.netty.buffer.ByteBuf; +import net.stzups.board.data.objects.Document; + +public class ServerPacketAddDocument extends ServerPacket { + private Document document; + + public ServerPacketAddDocument(Document document) { + super(ServerPacketType.ADD_DOCUMENT); + this.document = document; + } + + @Override + public void serialize(ByteBuf byteBuf) { + super.serialize(byteBuf); + byteBuf.writeLong(document.getId()); + writeString(document.getName(), byteBuf); + } +} diff --git a/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketAddUser.java b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketAddUser.java new file mode 100644 index 00000000..e87f0a3d --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketAddUser.java @@ -0,0 +1,15 @@ +package net.stzups.board.server.websocket.protocol.server; + +import io.netty.buffer.ByteBuf; +import net.stzups.board.data.objects.User; + +public class ServerPacketAddUser extends ServerPacketUser { + public ServerPacketAddUser(User user) { + super(ServerPacketType.ADD_USER, user); + } + + @Override + public void serialize(ByteBuf byteBuf) { + super.serialize(byteBuf); + } +} diff --git a/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketClient.java b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketClient.java new file mode 100644 index 00000000..c9a332ea --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketClient.java @@ -0,0 +1,20 @@ +package net.stzups.board.server.websocket.protocol.server; + +import io.netty.buffer.ByteBuf; +import net.stzups.board.data.objects.User; +import net.stzups.board.server.websocket.Client; + +public abstract class ServerPacketClient extends ServerPacket { + private short id; + + ServerPacketClient(ServerPacketType packetType, Client client) { + super(packetType); + this.id = client == null ? 0 : client.getId(); + } + + @Override + public void serialize(ByteBuf byteBuf) { + super.serialize(byteBuf); + byteBuf.writeShort(id); + } +} diff --git a/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketDrawClient.java b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketDrawClient.java new file mode 100644 index 00000000..bc43b03b --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketDrawClient.java @@ -0,0 +1,26 @@ +package net.stzups.board.server.websocket.protocol.server; + +import io.netty.buffer.ByteBuf; +import net.stzups.board.data.objects.Point; +import net.stzups.board.data.objects.User; +import net.stzups.board.server.websocket.Client; + +public class ServerPacketDrawClient extends ServerPacketClient { + private Point[] points; + + public ServerPacketDrawClient(Client client, Point[] points) { + super(ServerPacketType.DRAW, client); + this.points = points; + } + + @Override + public void serialize(ByteBuf byteBuf) { + super.serialize(byteBuf); + byteBuf.writeShort((short) points.length); + for (Point point : points) { + byteBuf.writeByte((byte) point.dt); + byteBuf.writeShort(point.x); + byteBuf.writeShort(point.y); + } + } +} diff --git a/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketHandshake.java b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketHandshake.java new file mode 100644 index 00000000..96dca937 --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketHandshake.java @@ -0,0 +1,22 @@ +package net.stzups.board.server.websocket.protocol.server; + +import io.netty.buffer.ByteBuf; +import net.stzups.board.data.objects.UserSession; + +public class ServerPacketHandshake extends ServerPacket { + private long token; + private long userId; + + public ServerPacketHandshake(UserSession userSession) { + super(ServerPacketType.HANDSHAKE); + this.token = userSession.getToken(); + this.userId = userSession.getUserId(); + } + + @Override + public void serialize(ByteBuf bytebuf) { + super.serialize(bytebuf); + bytebuf.writeLong(token); + bytebuf.writeLong(userId); + } +} diff --git a/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketOpenDocument.java b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketOpenDocument.java new file mode 100644 index 00000000..428064d1 --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketOpenDocument.java @@ -0,0 +1,39 @@ +package net.stzups.board.server.websocket.protocol.server; + +import io.netty.buffer.ByteBuf; +import net.stzups.board.data.objects.Point; +import net.stzups.board.data.objects.Document; +import net.stzups.board.data.objects.User; + +import java.util.List; +import java.util.Map; + +public class ServerPacketOpenDocument extends ServerPacket { + private Document document; + + public ServerPacketOpenDocument(Document document) { + super(ServerPacketType.OPEN_DOCUMENT); + this.document = document; + } + + @Override + public void serialize(ByteBuf byteBuf) { + super.serialize(byteBuf); + byteBuf.writeLong(document.getId()); + //OPEN_DOCUMENT is serialized, now serialize the other things + byteBuf.writeShort((short) document.getPoints().size()); + for (Map.Entry> entry : document.getPoints().entrySet()) { + byteBuf.writeLong(entry.getKey().getId()); + byteBuf.writeShort((short) entry.getValue().size()); + for (Point point : entry.getValue()) { + int dt = point.dt; + if (dt != 0) { + dt = -1; + } + byteBuf.writeByte((byte) dt); + byteBuf.writeShort(point.x); + byteBuf.writeShort(point.y); + } + } + } +} diff --git a/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketRemoveClient.java b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketRemoveClient.java new file mode 100644 index 00000000..33e6c385 --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketRemoveClient.java @@ -0,0 +1,9 @@ +package net.stzups.board.server.websocket.protocol.server; + +import net.stzups.board.server.websocket.Client; + +public class ServerPacketRemoveClient extends ServerPacketClient { + public ServerPacketRemoveClient(Client client) { + super(ServerPacketType.REMOVE_CLIENT, client); + } +} diff --git a/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacketType.java b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketType.java similarity index 86% rename from board-server/src/main/java/net/stzups/board/protocol/server/ServerPacketType.java rename to board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketType.java index 245f7ce6..bd3fecfa 100644 --- a/board-server/src/main/java/net/stzups/board/protocol/server/ServerPacketType.java +++ b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketType.java @@ -1,4 +1,4 @@ -package net.stzups.board.protocol.server; +package net.stzups.board.server.websocket.protocol.server; import io.netty.util.collection.IntObjectHashMap; @@ -9,8 +9,10 @@ public enum ServerPacketType { ADD_CLIENT(0), REMOVE_CLIENT(1), DRAW(2), - OPEN(3), - WRONG_ROOM(4), + OPEN_DOCUMENT(3), + ADD_DOCUMENT(4), + HANDSHAKE(5), + ADD_USER(6) ; private static Map packetTypeMap = new IntObjectHashMap<>(); diff --git a/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketUser.java b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketUser.java new file mode 100644 index 00000000..7b8e90b8 --- /dev/null +++ b/board-server/src/main/java/net/stzups/board/server/websocket/protocol/server/ServerPacketUser.java @@ -0,0 +1,19 @@ +package net.stzups.board.server.websocket.protocol.server; + +import io.netty.buffer.ByteBuf; +import net.stzups.board.data.objects.User; + +public abstract class ServerPacketUser extends ServerPacket { + private User user; + + ServerPacketUser(ServerPacketType serverPacketType, User user) { + super(serverPacketType); + this.user = user; + } + + @Override + public void serialize(ByteBuf byteBuf) { + super.serialize(byteBuf); + byteBuf.writeLong(user.getId()); + } +} diff --git a/board-server/src/main/resources/META-INF/mime.types b/board-server/src/main/resources/META-INF/mime.types index 2b7fea26..a2de552d 100644 --- a/board-server/src/main/resources/META-INF/mime.types +++ b/board-server/src/main/resources/META-INF/mime.types @@ -1,3 +1,5 @@ application/javascript js text/css css -image/x-icon ico \ No newline at end of file +image/x-icon ico +text/html htm html +image/png png \ No newline at end of file diff --git a/board-web-client/about-board.html b/board-web-client/about-board.html index a34f901a..bb144e4c 100644 --- a/board-web-client/about-board.html +++ b/board-web-client/about-board.html @@ -6,10 +6,10 @@ -
+
board about -
+

this is a very cool website that you should totally tell all your friend about

\ No newline at end of file diff --git a/board-web-client/index.css b/board-web-client/index.css index cf70e3d7..7d1d0611 100644 --- a/board-web-client/index.css +++ b/board-web-client/index.css @@ -1,11 +1,80 @@ -#canvas { - display: block; +.noselect { + user-select: none; +} + +html, body { + height: 100%; + color: white; +} + +body { + display: flex; + flex-direction: row; + align-items: stretch; + align-content: stretch; +} + +header { + background-color: black; + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + height: 5em; +} + +header * { + padding: .2em; +} + +aside { + display: flex; + flex-direction: column; + background-color: lightgray; + flex: 0; +} +#side * { + padding: .2em; } #canvasWrapper { - flex: 1 1 auto; + flex: 2 +} + +aside * { + font-size: 2.5em; + font-family: serif; + text-align: left; +} + +aside button { + background-color: lightgray; + border: 0; + font-size: 2.5rem; +} + +aside button:hover { + color: darkgray; +} + +aside button:active { + box-shadow: none; +} + +.active { + background-color: red; +} + +#clientsToolbar { + display: flex; + flex-direction: column-reverse; + position: absolute; + top: 12px; + right: 12px; } -#inviteButton { - margin-left: auto; +#clientsToolbar * { + width: 50px; + height: 50px; + margin: .5em; } \ No newline at end of file diff --git a/board-web-client/index.html b/board-web-client/index.html index d98b91fe..422cb63c 100644 --- a/board-web-client/index.html +++ b/board-web-client/index.html @@ -5,19 +5,22 @@ Board | Empty board - - - + -
- board - about - -
+
+
+ +
- \ No newline at end of file diff --git a/board-web-client/scripts/Board.js b/board-web-client/scripts/Board.js new file mode 100644 index 00000000..54b9fa7a --- /dev/null +++ b/board-web-client/scripts/Board.js @@ -0,0 +1 @@ +import './Document.js'; \ No newline at end of file diff --git a/board-web-client/scripts/Client.js b/board-web-client/scripts/Client.js index 417451c9..7ae2dc2f 100644 --- a/board-web-client/scripts/Client.js +++ b/board-web-client/scripts/Client.js @@ -1,10 +1,32 @@ +import {clientsToolbar, ctx} from './Document.js' + export default class Client { - constructor(id) { + constructor(id, user) { this.id = id; + this.user = user; this.x = 0; this.y = 0; this.points = []; - clients.set(this.id, this); + this.icon = document.createElement('img'); + this.icon.setAttribute('src', '/bin/default.png'); + this.icon.addEventListener('mouseenter', (event) => { + let rect = this.icon.getBoundingClientRect(); + this.iconTooltip.style.visibility = 'visible'; + this.iconTooltip.style.top = rect.top + 'px'; + this.iconTooltip.style.left = rect.left + -50 + 'px'; + }) + this.icon.addEventListener('mouseleave', (event) => { + this.iconTooltip.style.visibility = 'hidden'; + }) + this.iconTooltip = document.createElement('div'); + if (user != null) { + this.iconTooltip.innerText = user.id; + } + this.iconTooltip.style.position = 'absolute'; + this.iconTooltip.style.visibility = 'hidden'; + this.iconTooltip.style.zIndex = '1000'; + this.iconTooltip.style.color = 'black'; + document.getElementsByTagName('body')[0].parentNode.appendChild(this.iconTooltip); } draw(dt) { @@ -14,8 +36,8 @@ export default class Client { //console.log(this.points.length); let point = this.points[0]; if (point.dt === 255) { - this.x += point.x; - this.y += point.y; + this.x = point.x; + this.y = point.y; ctx.lineTo(this.x, this.y); this.points.splice(0, 1); @@ -29,21 +51,20 @@ export default class Client { this.points.splice(0, 1); continue; } - let multiplier; - + if (dt + point.usedDt < point.dt) { - multiplier = (dt + point.usedDt) / point.dt; - ctx.lineTo(this.x + lerp(0, point.x, multiplier), this.y + lerp(0, point.y, multiplier)); + let multiplier = (dt + point.usedDt) / point.dt; + ctx.lineTo(lerp(this.x, point.x, multiplier), lerp(this.y, point.y, multiplier)); point.usedDt += dt; dt = 0; } else { - multiplier = 1; - ctx.lineTo(this.x + lerp(0, point.x, multiplier), this.y + lerp(0, point.y, multiplier)); + this.x = point.x; + this.y = point.y; + ctx.lineTo(this.x, this.y); dt -= point.dt + point.usedDt; - this.x += point.x; - this.y += point.y; + this.points.splice(0, 1); } } diff --git a/board-web-client/scripts/Document.js b/board-web-client/scripts/Document.js new file mode 100644 index 00000000..6d5ddcb2 --- /dev/null +++ b/board-web-client/scripts/Document.js @@ -0,0 +1,151 @@ +export const canvas = document.getElementById('canvas'); +export const ctx = canvas.getContext('2d'); + +import LocalClient from './LocalClient.js'; +import SidebarItem from './SidebarItem.js'; +import Client from './Client.js' +import socket from './WebSocketHandler.js' +import * as User from "./User.js"; + +const documents = new Map(); +let activeDocument = null; +export const clientsToolbar = document.getElementById("clientsToolbar"); + +class Document { + constructor(name, id) { + this.clients = new Map(); + this.name = name; + this.id = id; + this.sidebarItem = new SidebarItem(this.name, () => { + if (this.id != null) { + if (activeDocument != null) activeDocument.close(); + socket.sendOpen(this.id) + } else { + console.log('id was null', this); + } + }); + documents.set(this.id, this); + this.points = {}; + } + + open() { + activeDocument = this; + console.log('opened ' + this.name); + this.sidebarItem.setActive(false); + window.history.pushState(document.name, document.title, '/d/' + this.id); + this.points.forEach((points, id) => { + ctx.beginPath(); + points.forEach((point) => { + if (point.dt === 0) { + ctx.stroke();//todo only do this at the end + ctx.moveTo(point.x, point.y); + } else { + ctx.lineTo(point.x, point.y); + } + }) + ctx.stroke(); + }); + this.addClient(localClient); + } + + close() { + localClient.update(); + ctx.clearRect(0, 0, canvas.width, canvas.height);//todo a loading screen? + this.clients.forEach((client) => { + this.removeClient(client.id); + }) + } + + draw(dt) { + this.clients.forEach((client) => { + client.draw(dt); + }) + } + + addClient(client) { + this.clients.set(client.id, client); + clientsToolbar.appendChild(client.icon); + } + + removeClient(id) { + this.clients.get(id).icon.remove(); + this.clients.delete(id); + } +} + +document.getElementById('add').addEventListener('click', () => { + if (activeDocument != null) activeDocument.close(); + socket.sendCreate(); +}); + +window.addEventListener('resize', resizeCanvas); +function resizeCanvas() { + let imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + let rect = canvas.parentNode.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height; + ctx.putImageData(imageData, 0, 0); + //todo redraw? +} +resizeCanvas(); + +let last = performance.now(); + +function draw(now) { + let dt = (now - last); + last = now; + + if (activeDocument != null) { + activeDocument.draw(dt); + } + + window.requestAnimationFrame(draw); +} +window.requestAnimationFrame(draw); + +socket.addEventListener('protocol.addclient', (event) => { + let client = new Client(event.id, User.getUser(event.userId)); + activeDocument.addClient(client); + console.log('Add client ', client); +}); +socket.addEventListener('protocol.removeclient', (event) => { + console.log('Remove client ', activeDocument.removeClient(event.id)); +}); +socket.addEventListener('protocol.draw', (event) => { + let client = activeDocument.clients.get(event.id); + console.log(activeDocument.clients, event.id); + event.points.forEach((point) => { + client.points.push(point); + }); +}); +socket.addEventListener('protocol.adddocument', (event) => { + documents.set(event.id, new Document(event.name, event.id)); +}); +socket.addEventListener('protocol.opendocument', (event) => { + if (activeDocument != null) { + activeDocument.close(); + } + + activeDocument = documents.get(event.document.id); + Object.assign(activeDocument, event.document); + activeDocument.open(); + +}); +socket.addEventListener('protocol.handshake', (event) => { + window.localStorage.setItem('token', event.token.toString()); +}) +socket.addEventListener('socket.open', () => { + let token = window.localStorage.getItem('token'); + if (token != null) { + token = BigInt(window.localStorage.getItem('token')); + } else { + token = BigInt(0); + } + socket.sendHandshake(token); + let invite = document.location.href.substring(document.location.href.lastIndexOf("/") + 1); + if (invite !== '') { + socket.sendOpen(invite); + } +}); + +const localClient = new LocalClient(); \ No newline at end of file diff --git a/board-web-client/scripts/LocalClient.js b/board-web-client/scripts/LocalClient.js index 2d75a3d1..25b714ef 100644 --- a/board-web-client/scripts/LocalClient.js +++ b/board-web-client/scripts/LocalClient.js @@ -1,15 +1,23 @@ import Client from './Client.js' +import socket from './WebSocketHandler.js' +import {canvas, ctx} from './Document.js' + +const UPDATE_INTERVAL = 1000; + export default class LocalClient extends Client { - constructor(id) { + constructor() { super(-1); this.point = null; this.lastTime = 0; this.lastSend = 0; this.lastDirection = 0; + this.lastX = 0; + this.lastY = 0; this.refresh = true; this.points = []; canvas.addEventListener('mousedown', (event) => {this.mousedown(event)}); canvas.addEventListener('mouseup', (event) => { + document.getElementsByTagName('body')[0].classList.remove('noselect'); if (this.point !== null) { this.pushPoint(); this.point = null; @@ -24,26 +32,34 @@ export default class LocalClient extends Client { ctx.stroke(); let now = performance.now(); this.point.dt += now - this.lastTime; - this.point.x += event.movementX; - this.point.y += event.movementY; - if (this.point.dt > 10 && (Math.abs(Math.atan2(this.point.y, this.point.x) - this.lastDirection) > 0.1 || this.point.dt > UPDATE_INTERVAL)) { + this.point.x = event.offsetX; + this.point.y = event.offsetY; + if (this.point.dt > 10 && (Math.abs(Math.atan2(this.point.y - this.lastX, this.point.x - this.lastY) - this.lastDirection) > 0.1 || this.point.dt > UPDATE_INTERVAL)) { this.pushPoint(); this.point = { dt:0, - x:0, - y:0, + x:this.point.x, + y:this.point.y, }; } this.lastTime = now; } }); + setInterval(() => this.update(), UPDATE_INTERVAL); } draw(dt) { - + this.lastSend = performance.now(); + socket.sendDraw(this.points); + } + + update() { + this.lastSend = performance.now(); + socket.sendDraw(this.points); } mousedown(event) { + document.getElementsByTagName('body')[0].classList.add('noselect'); if (event.buttons & 1) { this.lastTime = performance.now(); this.points.push({ @@ -53,9 +69,11 @@ export default class LocalClient extends Client { }) this.point = { dt:this.lastTime - this.lastSend, - x:0, - y:0, + x:event.offsetX, + y:event.offsetY, } + this.lastX = event.offsetX; + this.lastY = event.offsetY; } } @@ -67,10 +85,4 @@ export default class LocalClient extends Client { this.lastDirection = Math.atan2(this.point.y, this.point.x); } } - - getPoints() { - this.lastSend = performance.now(); - - return this.points; - } } \ No newline at end of file diff --git a/board-web-client/scripts/SidebarItem.js b/board-web-client/scripts/SidebarItem.js new file mode 100644 index 00000000..b8fb159a --- /dev/null +++ b/board-web-client/scripts/SidebarItem.js @@ -0,0 +1,29 @@ +const items = []; +const sidebar = document.getElementById('side'); + +export default class SidebarItem { + constructor(display, open) { + this.open = open; + this.button = document.createElement("button"); + let inner = document.createTextNode(display); + this.button.appendChild(inner); + this.button.addEventListener('click', () => { + this.setActive(true); + }); + sidebar.appendChild(this.button); + items.push(this); + } + + setActive(open) { + items.forEach(item => { + if (this === item) { + this.button.classList.add('active'); + if (open) { + this.open(); + } + } else { + item.button.classList.remove('active'); + } + }); + } +} \ No newline at end of file diff --git a/board-web-client/scripts/User.js b/board-web-client/scripts/User.js new file mode 100644 index 00000000..024a8718 --- /dev/null +++ b/board-web-client/scripts/User.js @@ -0,0 +1,30 @@ +import socket from "./WebSocketHandler.js"; + +const users = new Map(); +let user = null; + +export function getUser(id) { + if (id == null) { + return user; + } else { + return users.get(id); + } +} + +class User { + constructor(id) { + this.id = id; + users.set(id, this); + } +} + +socket.addEventListener('protocol.handshake', (event) => { + user = new User(event.userId); +}) +socket.addEventListener('protocol.adduser', (event) => { + let user = users.get(event.user.id); + if (user == null) { + user = new User(event.user.id); + } + Object.assign(user, event.user); +}); \ No newline at end of file diff --git a/board-web-client/scripts/WebSocketHandler.js b/board-web-client/scripts/WebSocketHandler.js index 82303a8b..88cde9c1 100644 --- a/board-web-client/scripts/WebSocketHandler.js +++ b/board-web-client/scripts/WebSocketHandler.js @@ -1,47 +1,39 @@ -import Client from './Client.js' - -export default class WebSocketHandler { +class WebSocketHandler { constructor() { - inviteButton.innerHTML = "Connecting...";//todo add spinner - - this.socket = new WebSocket('ws://localhost/websocket'); + this.events = {}; + [ + 'socket.open', + 'socket.close', + 'protocol.addclient', + 'protocol.adduser', + 'protocol.removeclient', + 'protocol.draw', + 'protocol.opendocument', + 'protocol.adddocument', + 'protocol.handshake', + ].forEach((type) => { + this.events[type] = []; + }) + + let webSocketUrl; + if (window.location.protocol === 'https:') { + webSocketUrl = 'wss://localhost/websocket'; + } else { + console.warn('Insecure connection, this better be a development environment');//todo disable/disallow + webSocketUrl = 'ws://localhost/websocket'; + } + console.log('Opening WebSocket connection to ' + webSocketUrl); + this.socket = new WebSocket(webSocketUrl); this.socket.binaryType = 'arraybuffer'; this.socket.addEventListener('open', (event) => { console.log('WebSocket connection opened'); - this.sendOpen(); - setInterval(() => { - let points = localClient.getPoints(); - if (points.length === 0) { - return; - } - let buffer = new ArrayBuffer(1 + 1 + points.length * 5); - let dataView = new DataView(buffer); - let offset = 0; - - dataView.setUint8(offset, 1); - offset += 1; - - dataView.setUint8(offset, points.length); - offset += 1; - - points.forEach(point => { - dataView.setUint8(offset, point.dt); - offset += 1; - dataView.setInt16(offset, point.x); - offset += 2; - dataView.setInt16(offset, point.y); - offset += 2; - }); - - points.length = 0;//clear - - this.send(buffer); - }, UPDATE_INTERVAL); + this.dispatchEvent('socket.open'); }); this.socket.addEventListener('close', (event) => { console.log('WebSocket connection closed'); + this.dispatchEvent('socket.close'); }); this.socket.addEventListener('message', (event) => { @@ -54,23 +46,30 @@ export default class WebSocketHandler { while (offset < dataView.byteLength) { let type = dataView.getUint8(offset); offset += 1; - console.log('got ' + type); switch (type) { - case 0: {//add client - console.log('Add client ', new Client(dataView.getUint16(offset))); + case 0: { + let e = {}; + e.id = dataView.getInt16(offset); offset += 2; + e.userId = dataView.getBigInt64(offset); + offset += 8; + this.dispatchEvent('protocol.addclient', e); break; } - case 1: {//remove client - console.log('Remove client ', clients.delete(dataView.getUint16(offset))); + case 1: { + let e = {}; + e.id = dataView.getInt16(offset); offset += 2; + this.dispatchEvent('protocol.removeclient', e); break; } - case 2: {//draw - let client = clients.get(dataView.getUint16(offset)); + case 2: { + let e = {}; + e.id = dataView.getInt16(offset); offset += 2; let size = dataView.getUint16(offset); offset += 2; + e.points = []; for (let i = 0; i < size; i++) { let point = {}; point.dt = dataView.getUint8(offset); @@ -80,22 +79,67 @@ export default class WebSocketHandler { point.y = dataView.getInt16(offset); offset += 2; point.usedDt = 0; - client.points.push(point); + e.points.push(point); } + this.dispatchEvent('protocol.draw', e); break; } - case 3: {//open - let length = dataView.getUint8(offset); + case 3: { + let e = {}; + e.document = {}; + e.document.id = dataView.getBigInt64(offset); + offset += 8; + e.document.points = new Map(); + let length = dataView.getUint16(offset); + offset += 2; + for (let i = 0; i < length; i++) { + let id = dataView.getBigInt64(offset); + offset += 8; + let size = dataView.getUint16(offset); + offset += 2; + let points = []; + for (let i = 0; i < size; i++) { + let point = {}; + point.dt = dataView.getUint8(offset); + offset += 1; + point.x = dataView.getInt16(offset); + offset += 2; + point.y = dataView.getInt16(offset); + offset += 2; + point.usedDt = 0; + points.push(point); + } + e.document.points.set(id, points); + } + this.dispatchEvent('protocol.opendocument', e); + break; + } + case 4: { + let e = {}; + e.id = dataView.getBigInt64(offset); + offset += 8; + length = dataView.getUint8(offset); offset += 1; - let roomName = new TextDecoder().decode(event.data.slice(offset, offset + length)); + e.name = new TextDecoder().decode(event.data.slice(offset, offset + length)); offset += length; - window.history.pushState(roomName, document.title, '/r/' + roomName); - console.log(roomName, length); - inviteButton.innerHTML = roomName;//todo add spinner + this.dispatchEvent('protocol.adddocument', e); + break; + } + case 5: { + let e = {}; + e.token = dataView.getBigInt64(offset); + offset += 8; + e.userId = dataView.getBigInt64(offset); + offset += 8; + this.dispatchEvent('protocol.handshake', e); break; } - case 4: {//wrong room - inviteButton.innerHTML = 'Invalid room id';//todo add spinner + case 6: { + let e = {}; + e.user = {}; + e.user.id = dataView.getBigInt64(offset); + offset += 8; + this.dispatchEvent('protocol.adduser', e); break; } default: @@ -106,34 +150,94 @@ export default class WebSocketHandler { }); this.socket.addEventListener('error', (event) => { - console.log('error: ' + event); + console.log('socket error', event); }); } + addEventListener(type, onevent) { + this.events[type].push(onevent); + } + + dispatchEvent(type, event) { + console.log('recv', type, event); + this.events[type].forEach(onevent => onevent(event)); + } + send(payload) { if (this.socket.readyState === WebSocket.OPEN) { - console.log('sending'); + console.log('send', payload); this.socket.send(payload); } else { - console.error('tried to send payload while websocket was closed' + payload); + console.error('tried to send payload while websocket was closed', payload); } } - sendOpen() { - let encoded = new TextEncoder().encode(document.location.href.substring(document.location.href.lastIndexOf("/") + 1)); - let buffer = new ArrayBuffer(2 + encoded.length); + sendOpen(id) { + let buffer = new ArrayBuffer(1 + 8); let dataView = new DataView(buffer); let offset = 0; dataView.setUint8(offset, 0); offset += 1; - dataView.setUint8(offset, encoded.byteLength); + dataView.setBigInt64(offset, id); + offset += 8; + + this.send(buffer); + } + + sendCreate() { + let buffer = new ArrayBuffer(1); + let dataView = new DataView(buffer); + let offset = 0; + + dataView.setUint8(offset, 2); + offset += 1; + + this.send(buffer); + } + + sendDraw(points) { + if (points.length === 0) { + return; + } + let buffer = new ArrayBuffer(1 + 1 + points.length * 5); + let dataView = new DataView(buffer); + let offset = 0; + + dataView.setUint8(offset, 1); + offset += 1; + + dataView.setUint8(offset, points.length); + offset += 1; + + points.forEach(point => { + dataView.setUint8(offset, point.dt); + offset += 1; + dataView.setInt16(offset, point.x); + offset += 2; + dataView.setInt16(offset, point.y); + offset += 2; + }); + + points.length = 0;//clear + + this.send(buffer); + } + + sendHandshake(token) { + let buffer = new ArrayBuffer(9); + let dataView = new DataView(buffer); + let offset = 0; + + dataView.setUint8(offset, 3); offset += 1; - let newBuffer = new Uint8Array(buffer); - newBuffer.set(encoded, offset); + console.log(token); + dataView.setBigInt64(offset, token); + offset += 8; - this.send(newBuffer); + this.send(buffer); } -} \ No newline at end of file +} +export default new WebSocketHandler(); diff --git a/board-web-client/scripts/globals.js b/board-web-client/scripts/globals.js deleted file mode 100644 index 14d0b163..00000000 --- a/board-web-client/scripts/globals.js +++ /dev/null @@ -1,8 +0,0 @@ -const UPDATE_INTERVAL = 1000; - -const canvas = document.getElementById('canvas'); -const ctx = canvas.getContext('2d'); -const clients = new Map(); -const inviteButton = document.getElementById('inviteButton'); - -var localClient; diff --git a/board-web-client/scripts/main.js b/board-web-client/scripts/main.js index dcd9a48b..9185a21e 100644 --- a/board-web-client/scripts/main.js +++ b/board-web-client/scripts/main.js @@ -1,38 +1,6 @@ -import LocalClient from './LocalClient.js'; -import WebSocketHandler from './WebSocketHandler.js' +const Board = {}; -// handle resize -window.addEventListener('resize', resizeCanvas); -function resizeCanvas() { - let imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - canvas.width = canvas.parentElement.offsetWidth; - canvas.height = canvas.parentElement.offsetHeight; - ctx.putImageData(imageData, 0, 0); - //todo redraw? -}; -resizeCanvas(); - -localClient = new LocalClient(); -var socket = null; - -let last = performance.now(); -function draw(now) { - let dt = (now - last); - last = now; - - clients.forEach(e => e.draw(dt)); - - window.requestAnimationFrame(draw); -} -window.requestAnimationFrame(draw); - -inviteButton.addEventListener('click', (event) => { - if (socket == null) { - socket = new WebSocketHandler(); - } -}); - -let index = document.location.href.lastIndexOf("/"); -if (document.location.href.substring(index - 2, index + 1) === '/r/') { - socket = new WebSocketHandler(); -} \ No newline at end of file +let script = document.createElement('script'); +script.type = "module" +script.src = "/scripts/Board.js"; +document.head.appendChild(script); \ No newline at end of file diff --git a/board-web-client/style.css b/board-web-client/style.css index 7810cd13..0e3b1b14 100644 --- a/board-web-client/style.css +++ b/board-web-client/style.css @@ -3,57 +3,14 @@ padding: 0; } -html, body { - height: 100%; -} - -a { - color: white; - text-decoration: none; -} - -a:hover { - color: lightgray; -} - -a:active { - color: gray; -} - - -body { - display: flex; - flex-flow: column; -} - -.header { - background-color: black; - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: center; - height: 5em; -} - -.header * { - padding: .2em; - font-size: 2.5em; - font-family: serif; -} - -.header p { - color: white; -} - -.header button { +button { border: .08em solid gray; - height: 100%; } -.header button:focus { +button:focus { outline: none; } -.header button:active { +button:active { box-shadow: inset 0 0 .5em #c1c1c1; -} \ No newline at end of file +}