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>
This commit is contained in:
the_4n0nym0u53 2022-05-05 20:51:51 +02:00 committed by GitHub
parent 1bf566b1c7
commit d3d1ee420b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 124 additions and 26 deletions

View file

@ -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);

View file

@ -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<User> cr = cb.createQuery(User.class);
Root<User> 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<User> cr = cb.createQuery(User.class);
Root<User> 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);

View file

@ -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)

View file

@ -34,6 +34,9 @@ public class User implements Serializable {
@Column(name = "channel", length = 30)
private Set<String> subscribed_ids;
@OneToMany(mappedBy = "owner", cascade = CascadeType.ALL)
private Set<Playlist> playlists;
public User() {
}
@ -83,4 +86,12 @@ public class User implements Serializable {
public void setSubscribed(Set<String> subscribed_ids) {
this.subscribed_ids = subscribed_ids;
}
public Set<Playlist> getPlaylists() {
return playlists;
}
public void setPlaylists(Set<Playlist> playlists) {
this.playlists = playlists;
}
}

View file

@ -0,0 +1,7 @@
package me.kavin.piped.utils.resp;
public class DeleteUserRequest {
public String password;
}

View file

@ -0,0 +1,10 @@
package me.kavin.piped.utils.resp;
public class DeleteUserResponse {
public String username;
public DeleteUserResponse(String username) {
this.username = username;
}
}

View file

@ -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