feat(frontend): add username history, closes Hangar/HangarAuth#277

This commit is contained in:
MiniDigger | Martin 2022-12-27 19:38:59 +01:00
parent 886d0d84f7
commit aed7aa9cd4
16 changed files with 164 additions and 13 deletions

View File

@ -8,7 +8,9 @@ import org.springframework.boot.context.properties.bind.DefaultValue;
@ConfigurationProperties(prefix = "hangar.users")
public record UserConfig(
@DefaultValue("100") int maxTaglineLen,
@DefaultValue({"Hangar_Admin", "Hangar_Mod"}) List<String> staffRoles
@DefaultValue({"Hangar_Admin", "Hangar_Mod"}) List<String> staffRoles,
@DefaultValue("30") int nameChangeInterval,
@DefaultValue("90") int nameChangeHistory
) {
public Validation userTagline() {

View File

@ -4,6 +4,7 @@ import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import io.papermc.hangar.HangarComponent;
import io.papermc.hangar.exceptions.HangarApiException;
import io.papermc.hangar.exceptions.WebHookException;
import io.papermc.hangar.model.db.UserTable;
import io.papermc.hangar.model.internal.sso.SsoSyncData;
import io.papermc.hangar.model.internal.sso.URLWithNonce;
@ -20,6 +21,7 @@ import javax.servlet.http.HttpSession;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
@ -161,18 +163,22 @@ public class LoginController extends HangarComponent {
@PostMapping("/sync")
@RateLimit(overdraft = 5, refillTokens = 2, refillSeconds = 10)
@ResponseStatus(HttpStatus.ACCEPTED)
public void sync(@NotNull @RequestBody SsoSyncData body, @RequestHeader("X-Kratos-Hook-Api-Key") String apiKey) {
public ResponseEntity sync(@NotNull @RequestBody SsoSyncData body, @RequestHeader("X-Kratos-Hook-Api-Key") String apiKey) {
if (!apiKey.equals(config.sso.kratosApiKey())) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
if (body.state().equals("active")) {
logger.debug("Syncing {}'s new traits: {}", body.id(), body.traits());
ssoService.sync(body.id(), body.traits());
try {
ssoService.sync(body.id(), body.traits());
} catch (WebHookException ex) {
return ResponseEntity.badRequest().body(ex.getError());
}
} else {
logger.debug("Not syncing since its not active! {}", body);
}
return ResponseEntity.accepted().build();
}
private RedirectView addBaseAndRedirect(String url) {

View File

@ -1,7 +1,10 @@
package io.papermc.hangar.db.dao.internal.table;
import io.papermc.hangar.model.api.UserNameChange;
import io.papermc.hangar.model.db.UserTable;
import org.jdbi.v3.sqlobject.config.KeyColumn;
import org.jdbi.v3.sqlobject.config.RegisterConstructorMapper;
import org.jdbi.v3.sqlobject.config.ValueColumn;
import org.jdbi.v3.sqlobject.customizer.BindBean;
import org.jdbi.v3.sqlobject.customizer.Timestamped;
import org.jdbi.v3.sqlobject.statement.GetGeneratedKeys;
@ -10,7 +13,10 @@ import org.jdbi.v3.sqlobject.statement.SqlUpdate;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Repository;
import java.time.OffsetDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Repository
@ -65,4 +71,12 @@ public interface UserDAO {
LIMIT 49000
""")
List<String> getAuthorNames();
@RegisterConstructorMapper(UserNameChange.class)
@SqlQuery("SELECT old_name, new_name, date FROM users_history WHERE uuid = :uuid ORDER BY date ASC")
List<UserNameChange> getUserNameHistory(final @NotNull UUID uuid);
@Timestamped
@SqlUpdate("INSERT INTO users_history(uuid, old_name, new_name, date) VALUES (:uuid, :oldName, :newName, :now)")
void recordNameChange(final @NotNull UUID uuid, final @NotNull String oldName, final @NotNull String newName);
}

View File

@ -2,6 +2,7 @@ package io.papermc.hangar.db.dao.v1;
import io.papermc.hangar.db.extras.BindPagination;
import io.papermc.hangar.model.api.User;
import io.papermc.hangar.model.api.UserNameChange;
import io.papermc.hangar.model.api.project.ProjectCompact;
import io.papermc.hangar.model.api.requests.RequestPagination;
import org.jdbi.v3.sqlobject.config.RegisterConstructorMapper;
@ -9,8 +10,10 @@ import org.jdbi.v3.sqlobject.customizer.BindList;
import org.jdbi.v3.sqlobject.customizer.Define;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.stringtemplate4.UseStringTemplateEngine;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Repository;
import java.time.OffsetDateTime;
import java.util.List;
@Repository
@ -130,4 +133,14 @@ public interface UsersApiDAO {
" JOIN roles r ON ugr.role_id = r.id" +
" WHERE r.name in (<staffRoles>)")
long getStaffCount(@BindList(onEmpty = BindList.EmptyHandling.NULL_STRING) List<String> staffRoles);
@RegisterConstructorMapper(UserNameChange.class)
@SqlQuery("""
SELECT uh.old_name, uh.new_name, uh.date
FROM users_history uh
JOIN users u on uh.uuid = u.uuid
WHERE u.name = :name AND uh.date >= :date
ORDER BY date DESC
""")
List<UserNameChange> getUserNameHistory(@NotNull String name, @NotNull OffsetDateTime date);
}

View File

@ -0,0 +1,27 @@
package io.papermc.hangar.exceptions;
import java.util.List;
import java.util.Map;
public final class WebHookException extends RuntimeException {
private final Map<String, Object> error;
private WebHookException(final Map<String, Object> error) {
this.error = error;
}
public Map<String, Object> getError() {
return this.error;
}
public static WebHookException of(String message) {
return new WebHookException(Map.of("messages", List.of(Map.of(
"instance_ptr", "#/method",
"messages", List.of(
Map.of("id", 123,
"text", message,
"type", "error")
)))));
}
}

View File

@ -9,6 +9,7 @@ import org.jdbi.v3.core.mapper.reflect.JdbiConstructor;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Objects;
import javax.annotation.Nullable;
public class User extends Model implements Named {
@ -19,9 +20,10 @@ public class User extends Model implements Named {
private final long projectCount;
private final boolean isOrganization;
private final boolean locked;
private List<UserNameChange> nameHistory;
@JdbiConstructor
public User(OffsetDateTime createdAt, String name, String tagline, OffsetDateTime joinDate, List<GlobalRole> roles, long projectCount, boolean locked) {
public User(OffsetDateTime createdAt, String name, String tagline, OffsetDateTime joinDate, List<GlobalRole> roles, long projectCount, boolean locked, @Nullable List<UserNameChange> nameHistory) {
super(createdAt);
this.name = name;
this.tagline = tagline;
@ -30,6 +32,7 @@ public class User extends Model implements Named {
this.projectCount = projectCount;
this.isOrganization = roles.contains(GlobalRole.ORGANIZATION);
this.locked = locked;
this.nameHistory = nameHistory;
}
@Override
@ -62,6 +65,15 @@ public class User extends Model implements Named {
return locked;
}
@Nullable
public List<UserNameChange> getNameHistory() {
return this.nameHistory;
}
public void setNameHistory(final List<UserNameChange> nameHistory) {
this.nameHistory = nameHistory;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;

View File

@ -0,0 +1,5 @@
package io.papermc.hangar.model.api;
import java.time.OffsetDateTime;
public record UserNameChange(String oldName, String newName, OffsetDateTime date) {}

View File

@ -2,11 +2,13 @@ package io.papermc.hangar.model.internal.user;
import io.papermc.hangar.model.Identified;
import io.papermc.hangar.model.api.User;
import io.papermc.hangar.model.api.UserNameChange;
import io.papermc.hangar.model.common.Permission;
import io.papermc.hangar.model.common.roles.GlobalRole;
import java.time.OffsetDateTime;
import java.util.List;
import javax.annotation.Nullable;
public class HangarUser extends User implements Identified {
@ -17,8 +19,8 @@ public class HangarUser extends User implements Identified {
private final String theme;
private String accessToken;
public HangarUser(OffsetDateTime createdAt, String name, String tagline, OffsetDateTime joinDate, List<GlobalRole> roles, long projectCount, boolean locked, long id, List<Integer> readPrompts, String language, String theme) {
super(createdAt, name, tagline, joinDate, roles, projectCount, locked);
public HangarUser(OffsetDateTime createdAt, String name, String tagline, OffsetDateTime joinDate, List<GlobalRole> roles, long projectCount, boolean locked, @Nullable List<UserNameChange> nameHistory, long id, List<Integer> readPrompts, String language, String theme) {
super(createdAt, name, tagline, joinDate, roles, projectCount, locked, nameHistory);
this.id = id;
this.readPrompts = readPrompts;
this.language = language;
@ -66,7 +68,9 @@ public class HangarUser extends User implements Identified {
this.getJoinDate(),
this.getRoles(),
this.getProjectCount(),
this.isLocked());
this.isLocked(),
this.getNameHistory()
);
}
public static class HeaderData {

View File

@ -9,19 +9,24 @@ import io.papermc.hangar.exceptions.HangarApiException;
import io.papermc.hangar.model.api.PaginatedResult;
import io.papermc.hangar.model.api.Pagination;
import io.papermc.hangar.model.api.User;
import io.papermc.hangar.model.api.UserNameChange;
import io.papermc.hangar.model.api.project.ProjectCompact;
import io.papermc.hangar.model.api.project.ProjectSortingStrategy;
import io.papermc.hangar.model.api.requests.RequestPagination;
import io.papermc.hangar.model.common.Permission;
import io.papermc.hangar.model.internal.user.HangarUser;
import io.papermc.hangar.model.internal.user.HangarUser.HeaderData;
import io.papermc.hangar.security.authentication.HangarPrincipal;
import io.papermc.hangar.service.PermissionService;
import io.papermc.hangar.service.internal.admin.FlagService;
import io.papermc.hangar.service.internal.organizations.OrganizationService;
import io.papermc.hangar.service.internal.projects.PinnedProjectService;
import io.papermc.hangar.service.internal.projects.ProjectAdminService;
import io.papermc.hangar.service.internal.versions.ReviewService;
import java.time.OffsetDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Optional;
import java.util.function.BiFunction;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
@ -62,6 +67,7 @@ public class UsersApiService extends HangarComponent {
public <T extends User> T getUser(String name, Class<T> type) {
T user = getUserRequired(name, usersDAO::getUser, type);
this.supplyNameHistory(user);
return user instanceof HangarUser ? (T) supplyHeaderData((HangarUser) user) : user;
}
@ -147,6 +153,17 @@ public class UsersApiService extends HangarComponent {
return hangarUser;
}
public void supplyNameHistory(final User user) {
final Optional<HangarPrincipal> hangarPrincipal = this.getOptionalHangarPrincipal();
final List<UserNameChange> userNameHistory;
if (hangarPrincipal.isPresent() && hangarPrincipal.get().isAllowedGlobal(Permission.SeeHidden)) {
userNameHistory = this.usersApiDAO.getUserNameHistory(user.getName(), OffsetDateTime.MIN);
} else {
userNameHistory = this.usersApiDAO.getUserNameHistory(user.getName(), OffsetDateTime.now().minus(this.config.user.nameChangeHistory(), ChronoUnit.DAYS));
}
user.setNameHistory(userNameHistory);
}
public List<ProjectCompact> getUserPinned(String userName) {
return pinnedProjectService.getPinnedVersions(getUserRequired(userName, usersDAO::getUser, HangarUser.class).getId());
}

View File

@ -6,6 +6,9 @@ import io.papermc.hangar.config.hangar.HangarConfig;
import io.papermc.hangar.db.dao.internal.table.UserDAO;
import io.papermc.hangar.db.dao.internal.table.auth.UserOauthTokenDAO;
import io.papermc.hangar.db.dao.internal.table.auth.UserSignOnDAO;
import io.papermc.hangar.exceptions.HangarApiException;
import io.papermc.hangar.exceptions.WebHookException;
import io.papermc.hangar.model.api.UserNameChange;
import io.papermc.hangar.model.db.UserTable;
import io.papermc.hangar.model.db.auth.UserOauthTokenTable;
import io.papermc.hangar.model.db.auth.UserSignOnTable;
@ -17,7 +20,9 @@ import io.papermc.hangar.service.TokenService;
import io.papermc.hangar.service.internal.projects.ProjectService;
import java.math.BigInteger;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
@ -135,6 +140,7 @@ public class SSOService {
user = this.userDAO.create(uuid, traits.username(), traits.email(), "", traits.language(), List.of(), false, traits.theme());
} else {
if (!user.getName().equals(traits.username())) {
this.handleUsernameChange(user, traits.username());
user.setName(traits.username());
refreshHomeProjects = true; // must refresh this view when a username is changed
}
@ -155,6 +161,22 @@ public class SSOService {
return user;
}
private void handleUsernameChange(final UserTable user, final String newName) {
// make sure a user with that name doesn't exist yet
if (this.userDAO.getUserTable(newName) != null) {
throw new HangarApiException("A user with that name already exists!");
}
// check that last change was long ago
final List<UserNameChange> userNameHistory = this.userDAO.getUserNameHistory(user.getUuid());
userNameHistory.sort(Comparator.comparing(UserNameChange::date).reversed());
final OffsetDateTime nextChange = userNameHistory.get(0).date().plus(this.hangarConfig.user.nameChangeInterval(), ChronoUnit.DAYS);
if (!userNameHistory.isEmpty() && nextChange.isAfter(OffsetDateTime.now())) {
throw WebHookException.of("You can't change your name that soon! You have to wait till " + nextChange.format(DateTimeFormatter.RFC_1123_DATE_TIME));
}
// record the change into the db
this.userDAO.recordNameChange(user.getUuid(), user.getName(), newName);
}
public String redeemCode(final String code, final String redirect) {
final HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

View File

@ -132,7 +132,8 @@ hangar:
- Hangar_Admin
- Hangar_Mod
- Hangar_Dev
name-change-interval: 30
name-change-history: 90
sso:
enabled: true

View File

@ -0,0 +1,9 @@
create table users_history
(
uuid uuid not null
constraint users_history_users_uuid_fk
references users (uuid),
old_name varchar(255) not null,
new_name varchar(255) not null,
date timestamp with time zone not null
);

View File

@ -31,10 +31,14 @@ async function fetch() {
}).catch<any>((e) => handleRequestError(e));
loading.value = false;
if (!import.meta.env.SSR) {
await nextTick(setupAdmonition);
// if (typeof renderedMarkdown.value?.includes === "function" && renderedMarkdown.value?.includes("<code")) {
await usePrismStore().handlePrism();
// }
console.log("before", loading.value, document.querySelectorAll('code[class*="language-"]'));
await nextTick();
console.log("after", loading.value, document.querySelectorAll('code[class*="language-"]'));
setupAdmonition();
if (typeof renderedMarkdown.value?.includes === "function" && renderedMarkdown.value?.includes("<code")) {
await usePrismStore().handlePrism();
}
console.log("after2", loading.value, document.querySelectorAll('code[class*="language-"]'));
}
}
await fetch();

View File

@ -3,6 +3,7 @@ import { Organization } from "hangar-internal";
import { User } from "hangar-api";
import { useI18n } from "vue-i18n";
import { computed } from "vue";
import { prettyDateTime } from "../lib/composables/useDate";
import UserAvatar from "~/components/UserAvatar.vue";
import { avatarUrl } from "~/composables/useUrlHelper";
import Card from "~/lib/components/design/Card.vue";
@ -14,6 +15,7 @@ import Tag from "~/components/Tag.vue";
import AvatarChangeModal from "~/lib/components/modals/AvatarChangeModal.vue";
import Tooltip from "~/lib/components/design/Tooltip.vue";
import Button from "~/lib/components/design/Button.vue";
import Popper from "~/lib/components/design/Popper.vue";
const props = defineProps<{
user: User;
@ -63,6 +65,17 @@ const canEditCurrentUser = computed<boolean>(() => {
<span v-if="user.locked" class="ml-1">
<IconMdiLockOutline />
</span>
<span v-if="user.nameHistory?.length > 0" class="text-md">
<Popper>
<IconMdiChevronDown />
<template #content="{ close }">
<div class="-mt-2 p-2 rounded border-t-2 border-primary-400 background-default filter drop-shadow-xl flex flex-col text-lg" @click="close()">
<div class="font-bold">Was known as:</div>
<div v-for="(history, idx) of user.nameHistory" :key="idx">{{ history.oldName }} till {{ prettyDateTime(history.date) }}</div>
</div>
</template>
</Popper>
</span>
</h1>
<div>

View File

@ -20,6 +20,7 @@ declare module "hangar-api" {
projectCount: number;
isOrganization: boolean;
locked: boolean;
nameHistory: { oldName: string; newName: string; date: string }[];
}
interface ApiKey extends Model, Named {

View File

@ -25,6 +25,7 @@ declare module "@vue/runtime-core" {
IconMdiCheckDecagram: typeof import("~icons/mdi/check-decagram")["default"];
IconMdiCheckDecagramOutline: typeof import("~icons/mdi/check-decagram-outline")["default"];
IconMdiChevronDoubleDown: typeof import("~icons/mdi/chevron-double-down")["default"];
IconMdiChevronDown: typeof import("~icons/mdi/chevron-down")["default"];
IconMdiCircle: typeof import("~icons/mdi/circle")["default"];
IconMdiClipboardOutline: typeof import("~icons/mdi/clipboard-outline")["default"];
IconMdiClose: typeof import("~icons/mdi/close")["default"];