diff --git a/.gitignore b/.gitignore index c3251931..5f722e9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,13 @@ +build + +# IntelliJ IDEA .idea # Gradle .gradle -build + # Gradle binaries -# These are not needed for building the project with Docker -# These would be needed to build the projct without Docker, so instead just install Gradle on your system -gradle +# just install gradle lol +gradle/wrapper gradlew gradlew.bat \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 50c77b02..804f2448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -145,7 +145,7 @@ Canvas manipulation did a bunch of commits on master -### v0.6 +## v0.6 Rework project architecture ### v0.6.0 @@ -154,4 +154,13 @@ Typescript refactor ### v0.6.1 Fix build system -- Rework rollup with typescript and environment variables \ No newline at end of file +- Rework rollup with typescript and environment variables + +## v0.7 +HTTP improvements + +### v0.7.0 +Rework authentication +- Robust exception handling +- Password based authentication schemes +- Log in, log out, registration pages \ No newline at end of file diff --git a/scribbleshare-app/Dockerfile b/app/Dockerfile similarity index 79% rename from scribbleshare-app/Dockerfile rename to app/Dockerfile index ab629164..2c7e425f 100644 --- a/scribbleshare-app/Dockerfile +++ b/app/Dockerfile @@ -5,11 +5,11 @@ FROM node:15 AS scribbleshare-frontend-build WORKDIR /usr/src/app -COPY scribbleshare-frontend . +COPY frontend . RUN npm install -RUN chmod +x build.sh \ -build.sh +RUN chmod +x build.sh +RUN ./build.sh # @@ -25,7 +25,7 @@ WORKDIR /usr/app COPY --from=scribbleshare-frontend-build /usr/src/app/build ./html # Copy jar (should already be built with gradle shadowJar) -COPY scribbleshare-backend/build/libs/scribbleshare-backend-all.jar scribbleshare-backend.jar +COPY backend/build/libs/scribbleshare-backend-all.jar scribbleshare-backend.jar HEALTHCHECK --interval=5s --timeout=5s --retries=5 CMD curl --fail http://localhost:80/healthcheck || exit 1 diff --git a/scribbleshare-app/scribbleshare-backend/build.gradle b/app/backend/build.gradle similarity index 83% rename from scribbleshare-app/scribbleshare-backend/build.gradle rename to app/backend/build.gradle index daa7acd7..2984fcf7 100644 --- a/scribbleshare-app/scribbleshare-backend/build.gradle +++ b/app/backend/build.gradle @@ -3,12 +3,8 @@ plugins { id 'com.github.johnrengelman.shadow' version '6.1.0' } -repositories { - mavenCentral() -} - dependencies { - implementation project(':scribbleshare-commons') + implementation project(':commons') } // https://github.com/johnrengelman/shadow/issues/609#issuecomment-795983873 diff --git a/scribbleshare-app/scribbleshare-backend/src/main/java/net/stzups/scribbleshare/backend/ScribbleshareBackend.java b/app/backend/src/main/java/net/stzups/scribbleshare/backend/ScribbleshareBackend.java similarity index 87% rename from scribbleshare-app/scribbleshare-backend/src/main/java/net/stzups/scribbleshare/backend/ScribbleshareBackend.java rename to app/backend/src/main/java/net/stzups/scribbleshare/backend/ScribbleshareBackend.java index 07c5ff86..1222cc20 100644 --- a/scribbleshare-app/scribbleshare-backend/src/main/java/net/stzups/scribbleshare/backend/ScribbleshareBackend.java +++ b/app/backend/src/main/java/net/stzups/scribbleshare/backend/ScribbleshareBackend.java @@ -2,9 +2,9 @@ import io.netty.channel.ChannelFuture; import net.stzups.scribbleshare.Scribbleshare; +import net.stzups.scribbleshare.backend.data.database.ScribbleshareBackendDatabase; +import net.stzups.scribbleshare.backend.data.database.implementations.PostgresDatabase; import net.stzups.scribbleshare.backend.server.BackendHttpServerInitializer; -import net.stzups.scribbleshare.data.database.ScribbleshareDatabase; -import net.stzups.scribbleshare.data.database.implementations.PostgresDatabase; public class ScribbleshareBackend extends Scribbleshare implements AutoCloseable { private final ScribbleshareBackendConfig config; @@ -33,7 +33,7 @@ public static void main(String[] args) throws Exception { } } - public ScribbleshareDatabase getDatabase() { + public ScribbleshareBackendDatabase getDatabase() { return database; } diff --git a/scribbleshare-app/scribbleshare-backend/src/main/java/net/stzups/scribbleshare/backend/ScribbleshareBackendConfig.java b/app/backend/src/main/java/net/stzups/scribbleshare/backend/ScribbleshareBackendConfig.java similarity index 60% rename from scribbleshare-app/scribbleshare-backend/src/main/java/net/stzups/scribbleshare/backend/ScribbleshareBackendConfig.java rename to app/backend/src/main/java/net/stzups/scribbleshare/backend/ScribbleshareBackendConfig.java index 39e3fc4e..1b769355 100644 --- a/scribbleshare-app/scribbleshare-backend/src/main/java/net/stzups/scribbleshare/backend/ScribbleshareBackendConfig.java +++ b/app/backend/src/main/java/net/stzups/scribbleshare/backend/ScribbleshareBackendConfig.java @@ -1,8 +1,8 @@ package net.stzups.scribbleshare.backend; +import net.stzups.netty.http.handler.handlers.FileRequestHandler; import net.stzups.scribbleshare.ScribbleshareConfig; -import net.stzups.scribbleshare.backend.server.http.HttpServerHandler; -public interface ScribbleshareBackendConfig extends ScribbleshareConfig, HttpServerHandler.Config { +public interface ScribbleshareBackendConfig extends ScribbleshareConfig, FileRequestHandler.Config { } diff --git a/scribbleshare-app/scribbleshare-backend/src/main/java/net/stzups/scribbleshare/backend/ScribbleshareBackendConfigImplementation.java b/app/backend/src/main/java/net/stzups/scribbleshare/backend/ScribbleshareBackendConfigImplementation.java similarity index 76% rename from scribbleshare-app/scribbleshare-backend/src/main/java/net/stzups/scribbleshare/backend/ScribbleshareBackendConfigImplementation.java rename to app/backend/src/main/java/net/stzups/scribbleshare/backend/ScribbleshareBackendConfigImplementation.java index 882c99ef..72f14925 100644 --- a/scribbleshare-app/scribbleshare-backend/src/main/java/net/stzups/scribbleshare/backend/ScribbleshareBackendConfigImplementation.java +++ b/app/backend/src/main/java/net/stzups/scribbleshare/backend/ScribbleshareBackendConfigImplementation.java @@ -1,13 +1,14 @@ package net.stzups.scribbleshare.backend; +import net.stzups.config.ConfigKey; +import net.stzups.config.OptionalConfigKey; import net.stzups.scribbleshare.ScribbleshareConfigImplementation; -import net.stzups.scribbleshare.config.ConfigKey; -import net.stzups.scribbleshare.config.OptionalConfigKey; public class ScribbleshareBackendConfigImplementation extends ScribbleshareConfigImplementation implements ScribbleshareBackendConfig { private static final ConfigKey HTML_ROOT = new OptionalConfigKey<>("html.root", "html"); private static final ConfigKey MIME_TYPES_FILE_PATH = new OptionalConfigKey<>("mimetypes.path", "mime.types"); private static final ConfigKey HTTP_CACHE_SECONDS = new OptionalConfigKey<>("http.cache.seconds", 0); + private static final ConfigKey DEBUG_JS_ROOT = new OptionalConfigKey<>("debug.js.root", ""); @Override public String getHttpRoot() { @@ -24,4 +25,9 @@ public String getMimeTypesFilePath() { return getString(MIME_TYPES_FILE_PATH); } + @Override + public String getDebugJsRoot() { + return getString(DEBUG_JS_ROOT); + } + } diff --git a/app/backend/src/main/java/net/stzups/scribbleshare/backend/data/PersistentHttpUserSession.java b/app/backend/src/main/java/net/stzups/scribbleshare/backend/data/PersistentHttpUserSession.java new file mode 100644 index 00000000..68ed0fa2 --- /dev/null +++ b/app/backend/src/main/java/net/stzups/scribbleshare/backend/data/PersistentHttpUserSession.java @@ -0,0 +1,102 @@ +package net.stzups.scribbleshare.backend.data; + +import io.netty.buffer.ByteBuf; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpResponse; +import net.stzups.netty.http.exception.exceptions.BadRequestException; +import net.stzups.netty.http.exception.exceptions.InternalServerException; +import net.stzups.netty.http.exception.exceptions.UnauthorizedException; +import net.stzups.scribbleshare.backend.data.database.databases.PersistentHttpSessionDatabase; +import net.stzups.scribbleshare.data.database.databases.HttpSessionDatabase; +import net.stzups.scribbleshare.data.database.databases.UserDatabase; +import net.stzups.scribbleshare.data.database.exception.DatabaseException; +import net.stzups.scribbleshare.data.objects.User; +import net.stzups.scribbleshare.data.objects.authentication.AuthenticationResult; +import net.stzups.scribbleshare.data.objects.authentication.UserSession; +import net.stzups.scribbleshare.data.objects.authentication.http.HttpConfig; +import net.stzups.scribbleshare.data.objects.authentication.http.HttpSessionCookie; +import net.stzups.scribbleshare.data.objects.authentication.http.HttpUserSession; +import net.stzups.util.DebugString; + +import java.sql.Timestamp; +import java.time.Duration; +import java.time.Instant; + +public class PersistentHttpUserSession extends UserSession { + private static final Duration MAX_AGE = Duration.ofDays(90); + + public static final String LOGIN_PATH = "/"; + + public PersistentHttpUserSession(HttpConfig config, HttpUserSession httpSession, HttpResponse response) { + super(httpSession.getUser()); + new PersistentHttpUserSessionCookie(getId(), generateToken()).setCookie(config, response); + } + + public PersistentHttpUserSession(long id, Timestamp creation, Timestamp expiration, long userId, ByteBuf byteBuf) { + super(id, creation, expiration, userId, byteBuf); + } + + public AuthenticationResult validate(HttpSessionCookie cookie) { + if (!Instant.now().isBefore(getCreated().toInstant().plus(MAX_AGE))) + return AuthenticationResult.STALE; + + return validate(cookie.getToken()); + } + + @Override + public String toString() { + return DebugString.get(PersistentHttpUserSession.class, super.toString()) + .toString(); + } + + /** get and expire persistent http user session */ + + public static PersistentHttpUserSession getSession(T database, FullHttpRequest request, HttpResponse response) throws UnauthorizedException, InternalServerException { + PersistentHttpUserSessionCookie cookie = PersistentHttpUserSessionCookie.getCookie(request); + if (cookie == null) { + return null; + } + + PersistentHttpUserSession session; + try { + session = database.getPersistentHttpUserSession(cookie); + + if (session == null) { + throw new UnauthorizedException("No " + PersistentHttpUserSession.class + " for " + cookie); + } + + database.expirePersistentHttpUserSession(session); + PersistentHttpUserSessionCookie.clearCookie(response); + } catch (DatabaseException e) { + throw new InternalServerException(e); + } + + AuthenticationResult result = session.validate(cookie); + if (result != AuthenticationResult.SUCCESS) { + throw new UnauthorizedException("Validating " + cookie + " for " + session + " resulted in " + result); + } + + return session; + } + + /** logs in if not authenticated, or null if no auth */ + public static HttpUserSession logIn(HttpConfig config, T database, FullHttpRequest request, HttpResponse response) throws UnauthorizedException, InternalServerException, BadRequestException { + PersistentHttpUserSession session = getSession(database, request, response); + if (session == null) { + return null; + } + + User user; + try { + user = database.getUser(session.getUser()); + } catch (DatabaseException e) { + throw new InternalServerException(e); + } + if (user == null) { + throw new InternalServerException("User somehow does not exist " + user); + } + + HttpUserSession s = new HttpUserSession(config, user, response); + return createHttpSession(config, database, user, headers); + } +} diff --git a/app/backend/src/main/java/net/stzups/scribbleshare/backend/data/PersistentHttpUserSessionCookie.java b/app/backend/src/main/java/net/stzups/scribbleshare/backend/data/PersistentHttpUserSessionCookie.java new file mode 100644 index 00000000..eb5ce6af --- /dev/null +++ b/app/backend/src/main/java/net/stzups/scribbleshare/backend/data/PersistentHttpUserSessionCookie.java @@ -0,0 +1,66 @@ +package net.stzups.scribbleshare.backend.data; + +import io.netty.buffer.ByteBuf; +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.handler.codec.http.cookie.Cookie; +import net.stzups.netty.http.HttpUtils; +import net.stzups.netty.http.exception.exceptions.BadRequestException; +import net.stzups.scribbleshare.data.objects.authentication.http.HttpConfig; +import net.stzups.scribbleshare.data.objects.authentication.http.HttpSessionCookie; +import net.stzups.scribbleshare.data.objects.exceptions.DeserializationException; +import net.stzups.util.DebugString; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; + +public class PersistentHttpUserSessionCookie extends HttpSessionCookie { + private static final String COOKIE_NAME = "persistent_session"; + private static final Duration MAX_AGE = Duration.ofDays(90); + + PersistentHttpUserSessionCookie(ByteBuf byteBuf) throws DeserializationException { + super(byteBuf); + } + + PersistentHttpUserSessionCookie(long id, byte[] token) { + super(id, token); + } + + /* @Override + protected static void setCookie(HttpConfig config, DefaultCookie cookie) { + + }*/ + + public void setCookie(HttpConfig config, HttpHeaders headers) { + Cookie cookie = getCookie(config, COOKIE_NAME); + cookie.setMaxAge(MAX_AGE.get(ChronoUnit.SECONDS)); //persistent cookie + cookie.setPath("/login");//todo + HttpUtils.setCookie(headers, cookie); + } + + public static PersistentHttpUserSessionCookie getCookie(HttpRequest request) throws BadRequestException { + ByteBuf byteBuf = HttpSessionCookie.getCookie(request, COOKIE_NAME); + if (byteBuf != null) { + try { + return new PersistentHttpUserSessionCookie(byteBuf); + } catch (DeserializationException e) { + throw new BadRequestException("Malformed cookie", e); + } finally { + byteBuf.release(); + } + } + + return null; + } + + public static void clearCookie(HttpResponse response) { + HttpSessionCookie.clearCookie(COOKIE_NAME, response); + } + + @Override + public String toString() { + return DebugString.get(PersistentHttpUserSessionCookie.class, super.toString()) + .toString(); + } +} diff --git a/app/backend/src/main/java/net/stzups/scribbleshare/backend/data/database/ScribbleshareBackendDatabase.java b/app/backend/src/main/java/net/stzups/scribbleshare/backend/data/database/ScribbleshareBackendDatabase.java new file mode 100644 index 00000000..98f20603 --- /dev/null +++ b/app/backend/src/main/java/net/stzups/scribbleshare/backend/data/database/ScribbleshareBackendDatabase.java @@ -0,0 +1,7 @@ +package net.stzups.scribbleshare.backend.data.database; + +import net.stzups.scribbleshare.backend.data.database.databases.PersistentHttpSessionDatabase; +import net.stzups.scribbleshare.data.database.ScribbleshareDatabase; + +public interface ScribbleshareBackendDatabase extends ScribbleshareDatabase, PersistentHttpSessionDatabase { +} diff --git a/app/backend/src/main/java/net/stzups/scribbleshare/backend/data/database/databases/PersistentHttpSessionDatabase.java b/app/backend/src/main/java/net/stzups/scribbleshare/backend/data/database/databases/PersistentHttpSessionDatabase.java new file mode 100644 index 00000000..ca1aa658 --- /dev/null +++ b/app/backend/src/main/java/net/stzups/scribbleshare/backend/data/database/databases/PersistentHttpSessionDatabase.java @@ -0,0 +1,24 @@ +package net.stzups.scribbleshare.backend.data.database.databases; + +import net.stzups.scribbleshare.backend.data.PersistentHttpUserSession; +import net.stzups.scribbleshare.backend.data.PersistentHttpUserSessionCookie; +import net.stzups.scribbleshare.data.database.exception.DatabaseException; +import net.stzups.scribbleshare.data.objects.authentication.http.HttpSessionCookie; +import org.jetbrains.annotations.Nullable; + +public interface PersistentHttpSessionDatabase { + /** + * @param cookie {@link HttpSessionCookie} of {@link PersistentHttpUserSession} + * @return null if {@link PersistentHttpUserSession} does not exist + */ + @Nullable + PersistentHttpUserSession getPersistentHttpUserSession(PersistentHttpUserSessionCookie cookie) throws DatabaseException; + + void addPersistentHttpUserSession(PersistentHttpUserSession persistentHttpSession) throws DatabaseException; + + /** + * Expire existing {@link PersistentHttpUserSession} + * todo fail silently or loudly if the persistent http user session does not exist? + */ + void expirePersistentHttpUserSession(PersistentHttpUserSession persistentHttpUserSession) throws DatabaseException; +} diff --git a/app/backend/src/main/java/net/stzups/scribbleshare/backend/data/database/implementations/PostgresDatabase.java b/app/backend/src/main/java/net/stzups/scribbleshare/backend/data/database/implementations/PostgresDatabase.java new file mode 100644 index 00000000..5af53839 --- /dev/null +++ b/app/backend/src/main/java/net/stzups/scribbleshare/backend/data/database/implementations/PostgresDatabase.java @@ -0,0 +1,81 @@ +package net.stzups.scribbleshare.backend.data.database.implementations; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufInputStream; +import io.netty.buffer.Unpooled; +import net.stzups.scribbleshare.backend.data.PersistentHttpUserSession; +import net.stzups.scribbleshare.backend.data.PersistentHttpUserSessionCookie; +import net.stzups.scribbleshare.backend.data.database.ScribbleshareBackendDatabase; +import net.stzups.scribbleshare.data.database.exception.ConnectionException; +import net.stzups.scribbleshare.data.database.exception.DatabaseException; +import net.stzups.scribbleshare.data.objects.Resource; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; + +public class PostgresDatabase extends net.stzups.scribbleshare.data.database.implementations.PostgresDatabase implements ScribbleshareBackendDatabase { + public PostgresDatabase(Config config) throws ConnectionException { + super(config); + } + + @Override + public void addPersistentHttpUserSession(PersistentHttpUserSession persistentHttpSession) throws DatabaseException { + try (PreparedStatement preparedStatement = connection.prepareStatement("INSERT INTO persistent_user_sessions(id, created, expired, user_id, data) VALUES (?, ?, ?, ?, ?)")) { + preparedStatement.setLong(1, persistentHttpSession.getId()); + preparedStatement.setTimestamp(2, persistentHttpSession.getCreated()); + preparedStatement.setTimestamp(3, persistentHttpSession.getExpired()); + preparedStatement.setLong(4, persistentHttpSession.getUser()); + + ByteBuf byteBuf = Unpooled.buffer(); + persistentHttpSession.serialize(byteBuf); + preparedStatement.setBinaryStream(5, new ByteBufInputStream(byteBuf)); + byteBuf.release(); + + preparedStatement.execute(); + } catch (SQLException e) { + throw new DatabaseException(e); + } + } + + @Override + public @Nullable PersistentHttpUserSession getPersistentHttpUserSession(PersistentHttpUserSessionCookie cookie) throws DatabaseException {//todo combine + PersistentHttpUserSession persistentHttpSession; + + try (PreparedStatement preparedStatement = connection.prepareStatement("SELECT * FROM persistent_user_sessions WHERE id=?")) { + preparedStatement.setLong(1, cookie.getId()); + try (ResultSet resultSet = preparedStatement.executeQuery()) { + if (!resultSet.next()) { + return null; + } + + persistentHttpSession = new PersistentHttpUserSession( + resultSet.getLong("id"), + resultSet.getTimestamp("created"), + resultSet.getTimestamp("expired"), + resultSet.getLong("user_id"), + Unpooled.wrappedBuffer(resultSet.getBinaryStream("data").readAllBytes())); + } + } catch (IOException e) { + throw new RuntimeException("Exception while getting " + Resource.class.getSimpleName() + " for " + cookie, e); + } catch (SQLException e) { + throw new DatabaseException("Exception while getting " + PersistentHttpUserSession.class.getSimpleName() + " for " + cookie); + } + + return persistentHttpSession; + } + + @Override + public void expirePersistentHttpUserSession(PersistentHttpUserSession session) throws DatabaseException { + try (PreparedStatement preparedStatement = connection.prepareStatement("UPDATE persistent_user_sessions SET expired=? WHERE id=?; ")) { + preparedStatement.setTimestamp(1, Timestamp.from(Instant.now())); + preparedStatement.setLong(2, session.getId()); + } catch (SQLException e) { + throw new DatabaseException("Exception while expiring " + session, e); + } + } +} diff --git a/app/backend/src/main/java/net/stzups/scribbleshare/backend/server/BackendHttpServerInitializer.java b/app/backend/src/main/java/net/stzups/scribbleshare/backend/server/BackendHttpServerInitializer.java new file mode 100644 index 00000000..7bafb5dc --- /dev/null +++ b/app/backend/src/main/java/net/stzups/scribbleshare/backend/server/BackendHttpServerInitializer.java @@ -0,0 +1,45 @@ +package net.stzups.scribbleshare.backend.server; + +import io.netty.channel.ChannelHandler; +import io.netty.channel.socket.SocketChannel; +import io.netty.handler.codec.http.HttpContentCompressor; +import io.netty.handler.stream.ChunkedWriteHandler; +import net.stzups.netty.http.DefaultHttpServerHandler; +import net.stzups.netty.http.HttpServerHandler; +import net.stzups.netty.http.HttpServerInitializer; +import net.stzups.netty.http.handler.handlers.FileRequestHandler; +import net.stzups.scribbleshare.backend.ScribbleshareBackendConfig; +import net.stzups.scribbleshare.backend.data.database.ScribbleshareBackendDatabase; +import net.stzups.scribbleshare.backend.server.handlers.AutoHandler; +import net.stzups.scribbleshare.backend.server.handlers.DocumentRequestHandler; +import net.stzups.scribbleshare.backend.server.handlers.LoginRequestHandler; +import net.stzups.scribbleshare.backend.server.handlers.LogoutRequestHandler; +import net.stzups.scribbleshare.backend.server.handlers.RegisterRequestHandler; + +import javax.net.ssl.SSLException; + +@ChannelHandler.Sharable +public class BackendHttpServerInitializer extends HttpServerInitializer { + private final HttpServerHandler httpServerHandler; + + public BackendHttpServerInitializer(ScribbleshareBackendConfig config, ScribbleshareBackendDatabase database) throws SSLException { + super(config); + httpServerHandler = new DefaultHttpServerHandler() + .addLast(new DocumentRequestHandler<>(config, database)) + .addLast(new LoginRequestHandler<>(config, database)) + .addLast(new LogoutRequestHandler(config)) + .addLast(new RegisterRequestHandler<>(config, database)) + .addLast(new AutoHandler<>(config, database)) + .addLast(new FileRequestHandler(config)); + } + + @Override + protected void initChannel(SocketChannel channel) { + super.initChannel(channel); + + channel.pipeline() + .addLast(new HttpContentCompressor()) + .addLast(new ChunkedWriteHandler()) + .addLast(httpServerHandler); + } +} diff --git a/app/backend/src/main/java/net/stzups/scribbleshare/backend/server/handlers/AutoHandler.java b/app/backend/src/main/java/net/stzups/scribbleshare/backend/server/handlers/AutoHandler.java new file mode 100644 index 00000000..c8789af4 --- /dev/null +++ b/app/backend/src/main/java/net/stzups/scribbleshare/backend/server/handlers/AutoHandler.java @@ -0,0 +1,35 @@ +package net.stzups.scribbleshare.backend.server.handlers; + +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpResponse; +import net.stzups.netty.http.exception.HttpException; +import net.stzups.netty.http.handler.HttpHandler; +import net.stzups.scribbleshare.backend.data.PersistentHttpUserSession; +import net.stzups.scribbleshare.backend.data.database.databases.PersistentHttpSessionDatabase; +import net.stzups.scribbleshare.data.database.databases.HttpSessionDatabase; +import net.stzups.scribbleshare.data.database.databases.UserDatabase; +import net.stzups.scribbleshare.data.objects.authentication.http.HttpConfig; +import net.stzups.scribbleshare.data.objects.authentication.http.HttpUserSession; + +public class AutoHandler extends HttpHandler { + private final HttpConfig config; + private final T database; + + public AutoHandler(HttpConfig config, T database) { + super("/"); + this.config = config; + this.database = database; + } + + @Override + public boolean handle(ChannelHandlerContext ctx, FullHttpRequest request, HttpResponse response) throws HttpException { + HttpUserSession session = HttpUserSession.getSession(database, request); + if (session != null) { + return false; + } + + HttpUserSession a = PersistentHttpUserSession.logIn(config, database, request, response); + return false; + } +} diff --git a/app/backend/src/main/java/net/stzups/scribbleshare/backend/server/handlers/DocumentRequestHandler.java b/app/backend/src/main/java/net/stzups/scribbleshare/backend/server/handlers/DocumentRequestHandler.java new file mode 100644 index 00000000..a0b0bbed --- /dev/null +++ b/app/backend/src/main/java/net/stzups/scribbleshare/backend/server/handlers/DocumentRequestHandler.java @@ -0,0 +1,136 @@ +package net.stzups.scribbleshare.backend.server.handlers; + +import io.netty.buffer.ByteBufInputStream; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpChunkedInput; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.handler.stream.ChunkedStream; +import net.stzups.netty.http.exception.HttpException; +import net.stzups.netty.http.exception.exceptions.BadRequestException; +import net.stzups.netty.http.exception.exceptions.InternalServerException; +import net.stzups.netty.http.exception.exceptions.MethodNotAllowedException; +import net.stzups.netty.http.exception.exceptions.NotFoundException; +import net.stzups.netty.http.exception.exceptions.UnauthorizedException; +import net.stzups.netty.http.handler.RequestHandler; +import net.stzups.netty.http.objects.Route; +import net.stzups.scribbleshare.data.database.databases.DocumentDatabase; +import net.stzups.scribbleshare.data.database.databases.HttpSessionDatabase; +import net.stzups.scribbleshare.data.database.databases.ResourceDatabase; +import net.stzups.scribbleshare.data.database.databases.UserDatabase; +import net.stzups.scribbleshare.data.database.exception.DatabaseException; +import net.stzups.scribbleshare.data.objects.Document; +import net.stzups.scribbleshare.data.objects.Resource; +import net.stzups.scribbleshare.data.objects.User; +import net.stzups.scribbleshare.data.objects.authentication.AuthenticatedUserSession; +import net.stzups.scribbleshare.data.objects.authentication.http.HttpConfig; +import net.stzups.scribbleshare.server.http.handler.handlers.HttpAuthenticator; + +import static net.stzups.netty.http.HttpUtils.send; + +public class DocumentRequestHandler extends RequestHandler { + private static final long MAX_AGE_NO_EXPIRE = 31536000;//one year, max age of a cookie + + private final T database; + + public DocumentRequestHandler(HttpConfig config, T database) { + super(config, "/", "/document"); + this.database = database; + } + + @Override + public void handleRequest(ChannelHandlerContext ctx, FullHttpRequest request, HttpResponse response) throws HttpException { + Route route = new Route(request.uri()); + AuthenticatedUserSession session = HttpAuthenticator.authenticateHttpUserSession(database, request); + + if (session == null) { + throw new UnauthorizedException("No authentication"); + } + + User user = session.getUser(); + + long documentId; + try { + documentId = Long.parseLong(route.get(2)); + } catch (NumberFormatException e) { + throw new BadRequestException("Exception while parsing " + route.get(2), e); + } + + if (!user.getOwnedDocuments().contains(documentId) && !user.getSharedDocuments().contains(documentId)) { + throw new NotFoundException("User tried to open document they don't have access to"); + //todo public documents + } + + // user has access to the document + + if (route.length() == 3) { // get document or submit new resource to document + if (request.method().equals(HttpMethod.GET)) { + //todo + throw new NotFoundException("todo not implemented yet"); + /*Resource resource = BackendServerInitializer.getDatabase(ctx).getResource(documentId, documentId); + if (resource == null) { //indicates an empty unsaved canvas, so serve that + send(ctx, request, Canvas.getEmptyCanvas()); + return; + } + HttpHeaders headers = new DefaultHttpHeaders(); + headers.set(HttpHeaderNames.CACHE_CONTROL, "private,max-age=0");//cache but always revalidate + sendChunkedResource(ctx, request, headers, new ChunkedStream(new ByteBufInputStream(resource.getData())), resource.getLastModified());//todo don't fetch entire document from db if not modified*/ + } else if (request.method().equals(HttpMethod.POST)) { //todo validation/security for submitted resources + Document document; + try { + document = database.getDocument(documentId); + } catch (DatabaseException e) { + throw new InternalServerException(e); + } + if (document == null) + throw new NotFoundException("Document with id " + documentId + " for user " + user + " somehow does not exist"); + + try { + send(ctx, request, response, Unpooled.copyLong(database.addResource(document.getId(), new Resource(request.content())))); + } catch (DatabaseException e) { + throw new InternalServerException(e); + } + } else { + throw new MethodNotAllowedException(request.method()); + } + } else { // route.length == 4, get resource from document + // does the document have this resource? + long resourceId; + try { + resourceId = Long.parseLong(route.get(3)); + } catch (NumberFormatException e) { + throw new BadRequestException("Exception while parsing " + route.get(3), e); + } + + Document document; + try { + document = database.getDocument(documentId); + } catch (DatabaseException e) { + throw new InternalServerException(e); + } + if (document == null) + throw new NotFoundException("Document with id " + documentId + " for user " + user + " somehow does not exist"); + + if (request.method().equals(HttpMethod.GET)) { + // get resource, resource must exist on the document + Resource resource; + try { + resource = database.getResource(resourceId, documentId); + } catch (DatabaseException e) { + throw new InternalServerException(e); + } + if (resource == null) { + throw new NotFoundException("Resource does not exist"); + } + + response.headers().add(HttpHeaderNames.CACHE_CONTROL, "private,max-age=" + MAX_AGE_NO_EXPIRE + ",immutable");//cache and never revalidate - permanent + send(ctx, request, response, new HttpChunkedInput(new ChunkedStream(new ByteBufInputStream(resource.getData())))); + } else { + throw new MethodNotAllowedException(request.method()); + } + } + } +} diff --git a/app/backend/src/main/java/net/stzups/scribbleshare/backend/server/handlers/LoginRequestHandler.java b/app/backend/src/main/java/net/stzups/scribbleshare/backend/server/handlers/LoginRequestHandler.java new file mode 100644 index 00000000..e5b87b0a --- /dev/null +++ b/app/backend/src/main/java/net/stzups/scribbleshare/backend/server/handlers/LoginRequestHandler.java @@ -0,0 +1,141 @@ +package net.stzups.scribbleshare.backend.server.handlers; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpResponse; +import net.stzups.netty.http.exception.HttpException; +import net.stzups.netty.http.exception.exceptions.InternalServerException; +import net.stzups.netty.http.handler.RequestHandler; +import net.stzups.scribbleshare.Scribbleshare; +import net.stzups.scribbleshare.backend.data.PersistentHttpUserSession; +import net.stzups.scribbleshare.backend.data.database.databases.PersistentHttpSessionDatabase; +import net.stzups.scribbleshare.data.database.databases.HttpSessionDatabase; +import net.stzups.scribbleshare.data.database.databases.LoginDatabase; +import net.stzups.scribbleshare.data.database.databases.UserDatabase; +import net.stzups.scribbleshare.data.database.exception.DatabaseException; +import net.stzups.scribbleshare.data.objects.User; +import net.stzups.scribbleshare.data.objects.authentication.http.HttpConfig; +import net.stzups.scribbleshare.data.objects.authentication.http.HttpUserSession; +import net.stzups.scribbleshare.data.objects.authentication.login.Login; + +import java.nio.charset.StandardCharsets; + +import static net.stzups.netty.http.HttpUtils.send; + +public class LoginRequestHandler extends RequestHandler { + private static class LoginRequest { + private final String username; + private final String password; + private final boolean remember; + + LoginRequest(ByteBuf byteBuf) { + username = readString(byteBuf); + password = readString(byteBuf); + remember = byteBuf.readBoolean(); + } + } + + private enum LoginResponseResult { + SUCCESS(0), + FAILED(1) + ; + private final int id; + + LoginResponseResult(int id) { + this.id = id; + } + + public void serialize(ByteBuf byteBuf) { + byteBuf.writeByte((byte) id); + } + } + + private static class LoginResponse { + private final LoginResponseResult status; + + LoginResponse(LoginResponseResult result) { + this.status = result; + } + + public void serialize(ByteBuf byteBuf) { + status.serialize(byteBuf); + } + } + + static final String LOGIN_PAGE = "/login"; // the login page, where login requests should come from + private static final String LOGIN_PATH = "/login"; // where login requests should go + + private final HttpConfig config; + private final T database; + + public LoginRequestHandler(HttpConfig config, T database) { + super(config, LOGIN_PAGE, LOGIN_PATH); + this.database = database; + this.config = config; + } + + @Override + public void handleRequest(ChannelHandlerContext ctx, FullHttpRequest request, HttpResponse response) throws HttpException { + LoginRequest loginRequest = new LoginRequest(request.content()); + + Login login; + try { + login = database.getLogin(loginRequest.username); + } catch (DatabaseException e) { + throw new InternalServerException(e); + } + if (!Login.verify(login, loginRequest.password.getBytes(StandardCharsets.UTF_8))) { + //todo rate limit and generic error handling + if (login == null) { + Scribbleshare.getLogger(ctx).info("Failed login attempt with bad username " + loginRequest.username); + } else { + Scribbleshare.getLogger(ctx).info("Failed login attempt with bad password for username " + loginRequest.username); + } + + ByteBuf byteBuf = Unpooled.buffer(); + new LoginResponse(LoginResponseResult.FAILED).serialize(byteBuf); + send(ctx, request, response, byteBuf); + byteBuf.release(); + return; + } + + assert login != null : "Verified logins should never be null"; + + User user; + try { + user = database.getUser(login.getId()); + } catch (DatabaseException e) { + throw new InternalServerException(e); + } + if (user == null) { + throw new InternalServerException("No user for id " + login.getId()); + } + + HttpUserSession userSession = new HttpUserSession(config, user, response); + try { + database.addHttpSession(userSession); + } catch (DatabaseException e) { + throw new InternalServerException(e); + } + if (loginRequest.remember) { + PersistentHttpUserSession persistentHttpUserSession = new PersistentHttpUserSession(config, userSession, response); + try { + database.addPersistentHttpUserSession(persistentHttpUserSession); + } catch (DatabaseException e) { + throw new InternalServerException(e); + } + } + + response.headers().set(headers); + ByteBuf byteBuf = Unpooled.buffer(); + new LoginResponse(LoginResponseResult.SUCCESS).serialize(byteBuf); + send(ctx, request, response, byteBuf); + Scribbleshare.getLogger(ctx).info("Logged in as " + user); + } + + static String readString(ByteBuf byteBuf) { + return byteBuf.readCharSequence(byteBuf.readUnsignedByte(), StandardCharsets.UTF_8).toString(); + } +} \ No newline at end of file diff --git a/app/backend/src/main/java/net/stzups/scribbleshare/backend/server/handlers/LogoutRequestHandler.java b/app/backend/src/main/java/net/stzups/scribbleshare/backend/server/handlers/LogoutRequestHandler.java new file mode 100644 index 00000000..98a68992 --- /dev/null +++ b/app/backend/src/main/java/net/stzups/scribbleshare/backend/server/handlers/LogoutRequestHandler.java @@ -0,0 +1,32 @@ +package net.stzups.scribbleshare.backend.server.handlers; + +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.handler.codec.http.HttpResponseStatus; +import net.stzups.netty.http.exception.HttpException; +import net.stzups.netty.http.handler.RequestHandler; +import net.stzups.scribbleshare.Scribbleshare; +import net.stzups.scribbleshare.backend.data.PersistentHttpUserSessionCookie; +import net.stzups.scribbleshare.data.objects.authentication.http.HttpConfig; +import net.stzups.scribbleshare.data.objects.authentication.http.HttpUserSessionCookie; + +import static net.stzups.netty.http.HttpUtils.sendRedirect; + +public class LogoutRequestHandler extends RequestHandler { + private static final String LOGOUT_PAGE = "/logout"; // the logout page, where logout requests should come from + private static final String LOGOUT_PATH = "/logout"; // where logout requests should go + private static final String LOGOUT_SUCCESS = LoginRequestHandler.LOGIN_PAGE; + + public LogoutRequestHandler(HttpConfig config) { + super(config, LOGOUT_PAGE, LOGOUT_PATH); + } + + @Override + protected void handleRequest(ChannelHandlerContext ctx, FullHttpRequest request, HttpResponse response) throws HttpException { //todo validate + HttpUserSessionCookie.clearCookie(response); + PersistentHttpUserSessionCookie.clearCookie(response); + sendRedirect(ctx, request, response, HttpResponseStatus.SEE_OTHER, LOGOUT_SUCCESS); + Scribbleshare.getLogger(ctx).info("Logged out"); + } +} diff --git a/app/backend/src/main/java/net/stzups/scribbleshare/backend/server/handlers/RegisterRequestHandler.java b/app/backend/src/main/java/net/stzups/scribbleshare/backend/server/handlers/RegisterRequestHandler.java new file mode 100644 index 00000000..25a09cdf --- /dev/null +++ b/app/backend/src/main/java/net/stzups/scribbleshare/backend/server/handlers/RegisterRequestHandler.java @@ -0,0 +1,150 @@ +package net.stzups.scribbleshare.backend.server.handlers; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpResponse; +import net.stzups.netty.http.exception.HttpException; +import net.stzups.netty.http.exception.exceptions.BadRequestException; +import net.stzups.netty.http.exception.exceptions.InternalServerException; +import net.stzups.netty.http.handler.RequestHandler; +import net.stzups.scribbleshare.Scribbleshare; +import net.stzups.scribbleshare.data.database.databases.HttpSessionDatabase; +import net.stzups.scribbleshare.data.database.databases.LoginDatabase; +import net.stzups.scribbleshare.data.database.databases.UserDatabase; +import net.stzups.scribbleshare.data.database.exception.DatabaseException; +import net.stzups.scribbleshare.data.objects.User; +import net.stzups.scribbleshare.data.objects.authentication.http.HttpConfig; +import net.stzups.scribbleshare.data.objects.authentication.http.HttpUserSession; +import net.stzups.scribbleshare.data.objects.authentication.login.Login; +import net.stzups.scribbleshare.server.http.handler.handlers.HttpAuthenticator; + +import static net.stzups.netty.http.HttpUtils.send; +import static net.stzups.scribbleshare.backend.server.handlers.LoginRequestHandler.readString; + +public class RegisterRequestHandler extends RequestHandler { + private static class RegisterRequest { + private final String username; + private final byte[] password; + + RegisterRequest(ByteBuf byteBuf) { + username = readString(byteBuf); + password = new byte[byteBuf.readUnsignedByte()]; + byteBuf.readBytes(password); + } + } + + private enum RegisterResponseResult { + SUCCESS(0), + USERNAME_TAKEN(1) + ; + + private final int id; + + private RegisterResponseResult(int id) { + this.id = id; + } + + public void serialize(ByteBuf byteBuf) { + byteBuf.writeByte((byte) id); + } + } + private static class RegisterResponse { + private final RegisterResponseResult result; + + private RegisterResponse(RegisterResponseResult result) { + this.result = result; + } + + public void serialize(ByteBuf byteBuf) { + result.serialize(byteBuf); + } + } + + static final String REGISTER_PAGE = "/register"; // the register page, where register requests should come from + private static final String REGISTER_PATH = "/register"; // where register requests should go + private static final String REGISTER_SUCCESS = LoginRequestHandler.LOGIN_PAGE; // redirect for a good register, should be the login page + + private final T database; + + public RegisterRequestHandler(HttpConfig config, T database) { + super(config, REGISTER_PAGE, REGISTER_PATH); + this.database = database; + } + + @Override + public void handleRequest(ChannelHandlerContext ctx, FullHttpRequest request, HttpResponse response) throws HttpException { + + // validate + RegisterRequest registerRequest = new RegisterRequest(request.content()); + + //todo validate + if (false) { + + throw new BadRequestException("Invalid todo"); + } + + User user; + HttpUserSession session = HttpAuthenticator.getHttpUserSession(database, request); + if (session != null) { + User u; + try { + u = database.getUser(session.getUser()); + } catch (DatabaseException e) { + throw new InternalServerException(e); + } + if (u == null) { + throw new InternalServerException("User somehow does not exist for " + session); + } + if (u.isRegistered()) { + Scribbleshare.getLogger(ctx).info("Registered user is creating a new account"); + user = new User(registerRequest.username); + try { + database.addUser(user); + } catch (DatabaseException e) { + throw new InternalServerException(e); + } + } else { + Scribbleshare.getLogger(ctx).info("Temporary user is registering"); + user = u; + } + } else { + Scribbleshare.getLogger(ctx).info("Brand new user is registering"); + user = new User(registerRequest.username); + try { + database.addUser(user); + } catch (DatabaseException e) { + throw new InternalServerException(e); + } + } + + + assert !user.isRegistered(); + + Login login = new Login(user, registerRequest.password); + boolean loginAdded; + try { + loginAdded = database.addLogin(login); + } catch (DatabaseException e) { + throw new InternalServerException(e); + } + RegisterResponseResult result; + if (!loginAdded) { + //todo hard rate limit + Scribbleshare.getLogger(ctx).info("Tried to register with duplicate username " + registerRequest.username); + + result = RegisterResponseResult.USERNAME_TAKEN;//todo delete old user????? because it was already added above and now its dead + } else { + result = RegisterResponseResult.SUCCESS; + } + + if (result == RegisterResponseResult.SUCCESS) { + Scribbleshare.getLogger(ctx).info("Registered with username " + registerRequest.username + " for user " + user); + } + + ByteBuf byteBuf = Unpooled.buffer(); + new RegisterResponse(result).serialize(byteBuf); + send(ctx, request, response, byteBuf); + } +} diff --git a/scribbleshare-app/scribbleshare-backend/src/main/resources/mime.types b/app/backend/src/main/resources/mime.types similarity index 100% rename from scribbleshare-app/scribbleshare-backend/src/main/resources/mime.types rename to app/backend/src/main/resources/mime.types diff --git a/scribbleshare-app/scribbleshare-frontend/build.sh b/app/frontend/build.sh similarity index 89% rename from scribbleshare-app/scribbleshare-frontend/build.sh rename to app/frontend/build.sh index bbbf399b..512b373f 100644 --- a/scribbleshare-app/scribbleshare-frontend/build.sh +++ b/app/frontend/build.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/bash # Clean up old build rm -rf build diff --git a/scribbleshare-app/scribbleshare-frontend/package.json b/app/frontend/package.json similarity index 100% rename from scribbleshare-app/scribbleshare-frontend/package.json rename to app/frontend/package.json diff --git a/app/frontend/rollup.config.js b/app/frontend/rollup.config.js new file mode 100644 index 00000000..3b9c2ec5 --- /dev/null +++ b/app/frontend/rollup.config.js @@ -0,0 +1,32 @@ +import {nodeResolve} from '@rollup/plugin-node-resolve'; +import typescript from '@rollup/plugin-typescript'; +import {terser} from "rollup-plugin-terser"; + +const plugins = [ + nodeResolve(), + terser({ + mangle: { + properties:true, + }, + }), + typescript(), +] + +export default [ + { + input: 'src/scripts/index.ts', + output: { + file: 'build/scripts/index.js', + format: 'iife', + }, + plugins: plugins + }, + { + input: 'src/scripts/logout.ts', + output: { + file: 'build/scripts/logout.js', + format: 'iife', + }, + plugins: plugins + } +]; diff --git a/scribbleshare-app/scribbleshare-frontend/src/assets/circle.png b/app/frontend/src/assets/circle.png similarity index 100% rename from scribbleshare-app/scribbleshare-frontend/src/assets/circle.png rename to app/frontend/src/assets/circle.png diff --git a/scribbleshare-app/scribbleshare-frontend/src/assets/default.png b/app/frontend/src/assets/default.png similarity index 100% rename from scribbleshare-app/scribbleshare-frontend/src/assets/default.png rename to app/frontend/src/assets/default.png diff --git a/scribbleshare-app/scribbleshare-frontend/src/assets/pointer.png b/app/frontend/src/assets/pointer.png similarity index 100% rename from scribbleshare-app/scribbleshare-frontend/src/assets/pointer.png rename to app/frontend/src/assets/pointer.png diff --git a/scribbleshare-app/scribbleshare-frontend/src/assets/scribbleshare.png b/app/frontend/src/assets/scribbleshare.png similarity index 100% rename from scribbleshare-app/scribbleshare-frontend/src/assets/scribbleshare.png rename to app/frontend/src/assets/scribbleshare.png diff --git a/scribbleshare-app/scribbleshare-frontend/src/assets/square.png b/app/frontend/src/assets/square.png similarity index 100% rename from scribbleshare-app/scribbleshare-frontend/src/assets/square.png rename to app/frontend/src/assets/square.png diff --git a/scribbleshare-app/scribbleshare-frontend/src/assets/trash.png b/app/frontend/src/assets/trash.png similarity index 100% rename from scribbleshare-app/scribbleshare-frontend/src/assets/trash.png rename to app/frontend/src/assets/trash.png diff --git a/scribbleshare-app/scribbleshare-frontend/src/assets/triangle.png b/app/frontend/src/assets/triangle.png similarity index 100% rename from scribbleshare-app/scribbleshare-frontend/src/assets/triangle.png rename to app/frontend/src/assets/triangle.png diff --git a/scribbleshare-app/scribbleshare-frontend/src/favicon.ico b/app/frontend/src/favicon.ico similarity index 100% rename from scribbleshare-app/scribbleshare-frontend/src/favicon.ico rename to app/frontend/src/favicon.ico diff --git a/scribbleshare-app/scribbleshare-frontend/src/index.css b/app/frontend/src/index.css similarity index 93% rename from scribbleshare-app/scribbleshare-frontend/src/index.css rename to app/frontend/src/index.css index 6364c2f0..6a70a219 100644 --- a/scribbleshare-app/scribbleshare-frontend/src/index.css +++ b/app/frontend/src/index.css @@ -110,4 +110,10 @@ aside button:active { input[type="radio"] { visibility: hidden; +} + +#loginModal { + display: flex; + flex-direction: column; + justify-content: flex-end; } \ No newline at end of file diff --git a/scribbleshare-app/scribbleshare-frontend/src/index.html b/app/frontend/src/index.html similarity index 97% rename from scribbleshare-app/scribbleshare-frontend/src/index.html rename to app/frontend/src/index.html index c5ad8efa..2a3ec6df 100644 --- a/scribbleshare-app/scribbleshare-frontend/src/index.html +++ b/app/frontend/src/index.html @@ -6,7 +6,7 @@ - +