From d3d1ee420bb98eb6851ed59fadcb5472d89353cf Mon Sep 17 00:00:00 2001 From: the_4n0nym0u53 Date: Thu, 5 May 2022 20:51:51 +0200 Subject: [PATCH] Feature: Implement account deletion (#248) * Add backend code for user deletion * Add endpoint and delete user test * Delete all user content from db * Delete from playlist_videos table on user deletion * Avoid raw SQL in unsubscribeResponse() * Fix unsubscribeResponse() * Don't delete PlaylistVideos from other users' playlists * Fix pruneUnusedPlaylistVideos() * Remove unused commented-out code * Fix oopsie * Proper type declaration due to false error-reporting by VSCode * Use delete query for better performance * Cleanup and add OneToMany relationship. * Revert unsubscribe logic. Co-authored-by: Kavin <20838718+FireMasterK@users.noreply.github.com> --- .../java/me/kavin/piped/ServerLauncher.java | 10 ++ .../me/kavin/piped/utils/DatabaseHelper.java | 16 ++-- .../me/kavin/piped/utils/ResponseHelper.java | 93 +++++++++++++++---- .../me/kavin/piped/utils/obj/db/User.java | 11 +++ .../piped/utils/resp/DeleteUserRequest.java | 7 ++ .../piped/utils/resp/DeleteUserResponse.java | 10 ++ testing/api-test.sh | 3 + 7 files changed, 124 insertions(+), 26 deletions(-) create mode 100644 src/main/java/me/kavin/piped/utils/resp/DeleteUserRequest.java create mode 100644 src/main/java/me/kavin/piped/utils/resp/DeleteUserResponse.java diff --git a/src/main/java/me/kavin/piped/ServerLauncher.java b/src/main/java/me/kavin/piped/ServerLauncher.java index 4102b74..2df68e1 100644 --- a/src/main/java/me/kavin/piped/ServerLauncher.java +++ b/src/main/java/me/kavin/piped/ServerLauncher.java @@ -14,6 +14,7 @@ import io.activej.inject.module.Module; import io.activej.launchers.http.MultithreadedHttpServerLauncher; import me.kavin.piped.consts.Constants; import me.kavin.piped.utils.*; +import me.kavin.piped.utils.resp.DeleteUserRequest; import me.kavin.piped.utils.resp.ErrorResponse; import me.kavin.piped.utils.resp.LoginRequest; import me.kavin.piped.utils.resp.SubscriptionUpdateRequest; @@ -327,6 +328,15 @@ public class ServerLauncher extends MultithreadedHttpServerLauncher { } catch (Exception e) { return getErrorResponse(e, request.getPath()); } + })).map(POST, "/user/delete", AsyncServlet.ofBlocking(executor, request -> { + try { + DeleteUserRequest body = Constants.mapper.readValue(request.loadBody().getResult().asArray(), + DeleteUserRequest.class); + return getJsonResponse(ResponseHelper.deleteUserResponse(request.getHeader(AUTHORIZATION), body.password), + "private"); + } catch (Exception e) { + return getErrorResponse(e, request.getPath()); + } })); return new CustomServletDecorator(router); diff --git a/src/main/java/me/kavin/piped/utils/DatabaseHelper.java b/src/main/java/me/kavin/piped/utils/DatabaseHelper.java index d70bf05..c4eaa13 100644 --- a/src/main/java/me/kavin/piped/utils/DatabaseHelper.java +++ b/src/main/java/me/kavin/piped/utils/DatabaseHelper.java @@ -16,15 +16,19 @@ public class DatabaseHelper { public static User getUserFromSession(String session) { try (Session s = DatabaseSessionFactory.createSession()) { s.setHibernateFlushMode(FlushMode.MANUAL); - CriteriaBuilder cb = s.getCriteriaBuilder(); - CriteriaQuery cr = cb.createQuery(User.class); - Root root = cr.from(User.class); - cr.select(root).where(cb.equal(root.get("sessionId"), session)); - - return s.createQuery(cr).uniqueResult(); + return getUserFromSession(session, s); } } + public static User getUserFromSession(String session, Session s) { + CriteriaBuilder cb = s.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(User.class); + Root root = cr.from(User.class); + cr.select(root).where(cb.equal(root.get("sessionId"), session)); + + return s.createQuery(cr).uniqueResult(); + } + public static User getUserFromSessionWithSubscribed(String session) { try (Session s = DatabaseSessionFactory.createSession()) { s.setHibernateFlushMode(FlushMode.MANUAL); diff --git a/src/main/java/me/kavin/piped/utils/ResponseHelper.java b/src/main/java/me/kavin/piped/utils/ResponseHelper.java index e1e6fac..d897f1b 100644 --- a/src/main/java/me/kavin/piped/utils/ResponseHelper.java +++ b/src/main/java/me/kavin/piped/utils/ResponseHelper.java @@ -622,8 +622,6 @@ public class ResponseHelper { } - private static final Argon2PasswordEncoder argon2PasswordEncoder = new Argon2PasswordEncoder(); - public static byte[] registerResponse(String user, String pass) throws IOException { if (Constants.DISABLE_REGISTRATION) @@ -641,9 +639,8 @@ public class ResponseHelper { cr.select(root).where(cb.equal(root.get("username"), user)); boolean registered = s.createQuery(cr).uniqueResult() != null; - if (registered) { + if (registered) return Constants.mapper.writeValueAsBytes(new AlreadyRegisteredResponse()); - } if (Constants.COMPROMISED_PASSWORD_CHECK) { String sha1Hash = DigestUtils.sha1Hex(pass).toUpperCase(); @@ -668,8 +665,16 @@ public class ResponseHelper { } } + private static final Argon2PasswordEncoder argon2PasswordEncoder = new Argon2PasswordEncoder(); + private static final BCryptPasswordEncoder bcryptPasswordEncoder = new BCryptPasswordEncoder(); + private static boolean hashMatch(String hash, String pass) { + return hash.startsWith("$argon2") ? + argon2PasswordEncoder.matches(pass, hash) : + bcryptPasswordEncoder.matches(pass, hash); + } + public static byte[] loginResponse(String user, String pass) throws IOException { @@ -688,11 +693,7 @@ public class ResponseHelper { if (dbuser != null) { String hash = dbuser.getPassword(); - if (hash.startsWith("$argon2")) { - if (argon2PasswordEncoder.matches(pass, hash)) { - return Constants.mapper.writeValueAsBytes(new LoginResponse(dbuser.getSessionId())); - } - } else if (bcryptPasswordEncoder.matches(pass, hash)) { + if (hashMatch(hash, pass)) { return Constants.mapper.writeValueAsBytes(new LoginResponse(dbuser.getSessionId())); } } @@ -701,6 +702,37 @@ public class ResponseHelper { } } + public static byte[] deleteUserResponse(String session, String pass) throws IOException { + + if (StringUtils.isBlank(pass)) + return Constants.mapper.writeValueAsBytes(new InvalidRequestResponse()); + + try (Session s = DatabaseSessionFactory.createSession()) { + User user = DatabaseHelper.getUserFromSession(session); + + if (user == null) + return Constants.mapper.writeValueAsBytes(new AuthenticationFailureResponse()); + + String hash = user.getPassword(); + + if (!hashMatch(hash, pass)) + return Constants.mapper.writeValueAsBytes(new IncorrectCredentialsResponse()); + + try { + s.delete(user); + + s.getTransaction().begin(); + s.getTransaction().commit(); + + Multithreading.runAsync(() -> pruneUnusedPlaylistVideos()); + } catch (Exception e) { + return Constants.mapper.writeValueAsBytes(new ErrorResponse(ExceptionUtils.getStackTrace(e), e.getMessage())); + } + + return Constants.mapper.writeValueAsBytes(new DeleteUserResponse(user.getUsername())); + } + } + public static byte[] subscribeResponse(String session, String channelId) throws IOException { @@ -902,6 +934,7 @@ public class ResponseHelper { Multithreading.runAsync(() -> { try (Session s = DatabaseSessionFactory.createSession()) { var channels = DatabaseHelper.getChannelsFromIds(s, Arrays.asList(channelIds)); + outer: for (String channelId : channelIds) { for (var channel : channels) @@ -1001,6 +1034,8 @@ public class ResponseHelper { s.getTransaction().begin(); s.getTransaction().commit(); + + Multithreading.runAsync(() -> pruneUnusedPlaylistVideos()); } return Constants.mapper.writeValueAsBytes(new AcceptedResponse()); @@ -1008,21 +1043,16 @@ public class ResponseHelper { public static byte[] playlistsResponse(String session) throws IOException { - User user = DatabaseHelper.getUserFromSession(session); - - if (user == null) - return Constants.mapper.writeValueAsBytes(new AuthenticationFailureResponse()); - try (Session s = DatabaseSessionFactory.createSession()) { - var cb = s.getCriteriaBuilder(); - var query = cb.createQuery(me.kavin.piped.utils.obj.db.Playlist.class); - var root = query.from(me.kavin.piped.utils.obj.db.Playlist.class); - query.select(root); - query.where(cb.equal(root.get("owner"), user)); + + User user = DatabaseHelper.getUserFromSession(session, s); + + if (user == null) + return Constants.mapper.writeValueAsBytes(new AuthenticationFailureResponse()); var playlists = new ObjectArrayList<>(); - for (var playlist : s.createQuery(query).list()) { + for (var playlist : user.getPlaylists()) { ObjectNode node = Constants.mapper.createObjectNode(); node.put("id", String.valueOf(playlist.getPlaylistId())); node.put("name", playlist.getName()); @@ -1137,6 +1167,8 @@ public class ResponseHelper { s.getTransaction().begin(); s.getTransaction().commit(); + Multithreading.runAsync(() -> pruneUnusedPlaylistVideos()); + return Constants.mapper.writeValueAsBytes(new AcceptedResponse()); } } @@ -1157,6 +1189,27 @@ public class ResponseHelper { } } + private static void pruneUnusedPlaylistVideos() { + + try (Session s = DatabaseSessionFactory.createSession()) { + CriteriaBuilder cb = s.getCriteriaBuilder(); + + var pvQuery = cb.createCriteriaDelete(PlaylistVideo.class); + var pvRoot = pvQuery.from(PlaylistVideo.class); + + var subQuery = pvQuery.subquery(me.kavin.piped.utils.obj.db.Playlist.class); + var subRoot = subQuery.from(me.kavin.piped.utils.obj.db.Playlist.class); + + subQuery.select(subRoot.join("videos").get("id")); + + pvQuery.where(cb.not(pvRoot.get("id").in(subQuery))); + + s.getTransaction().begin(); + s.createQuery(pvQuery).executeUpdate(); + s.getTransaction().commit(); + } + } + private static void handleNewVideo(StreamInfo info, long time, me.kavin.piped.utils.obj.db.Channel channel, Session s) { if (channel == null) diff --git a/src/main/java/me/kavin/piped/utils/obj/db/User.java b/src/main/java/me/kavin/piped/utils/obj/db/User.java index dc5a0f9..a7f496e 100644 --- a/src/main/java/me/kavin/piped/utils/obj/db/User.java +++ b/src/main/java/me/kavin/piped/utils/obj/db/User.java @@ -34,6 +34,9 @@ public class User implements Serializable { @Column(name = "channel", length = 30) private Set subscribed_ids; + @OneToMany(mappedBy = "owner", cascade = CascadeType.ALL) + private Set playlists; + public User() { } @@ -83,4 +86,12 @@ public class User implements Serializable { public void setSubscribed(Set subscribed_ids) { this.subscribed_ids = subscribed_ids; } + + public Set getPlaylists() { + return playlists; + } + + public void setPlaylists(Set playlists) { + this.playlists = playlists; + } } diff --git a/src/main/java/me/kavin/piped/utils/resp/DeleteUserRequest.java b/src/main/java/me/kavin/piped/utils/resp/DeleteUserRequest.java new file mode 100644 index 0000000..f6228e2 --- /dev/null +++ b/src/main/java/me/kavin/piped/utils/resp/DeleteUserRequest.java @@ -0,0 +1,7 @@ +package me.kavin.piped.utils.resp; + +public class DeleteUserRequest { + + public String password; + +} diff --git a/src/main/java/me/kavin/piped/utils/resp/DeleteUserResponse.java b/src/main/java/me/kavin/piped/utils/resp/DeleteUserResponse.java new file mode 100644 index 0000000..5654712 --- /dev/null +++ b/src/main/java/me/kavin/piped/utils/resp/DeleteUserResponse.java @@ -0,0 +1,10 @@ +package me.kavin.piped.utils.resp; + +public class DeleteUserResponse { + + public String username; + + public DeleteUserResponse(String username) { + this.username = username; + } +} diff --git a/testing/api-test.sh b/testing/api-test.sh index 817d834..27a17d7 100755 --- a/testing/api-test.sh +++ b/testing/api-test.sh @@ -107,3 +107,6 @@ curl ${CURLOPTS[@]} $HOST/user/playlists/remove -X POST -H "Content-Type: applic # Delete Playlist Test curl ${CURLOPTS[@]} $HOST/user/playlists/delete -X POST -H "Content-Type: application/json" -H "Authorization: $AUTH_TOKEN" -d $(jq -n --compact-output --arg playlistId $PLAYLIST_ID '{"playlistId": $playlistId}') || exit 1 + +# Delete User Test +curl ${CURLOPTS[@]} $HOST/user/delete -X POST -H "Content-Type: application/json" -H "Authorization: $AUTH_TOKEN" -d $(jq -n --compact-output --arg password "$PASS" '{"password": $password}') || exit 1