feat: allow configuring user/org socials on profile page (implements #1395)

This commit is contained in:
MiniDigger | Martin 2025-01-04 11:49:00 +01:00
parent 5c1ea9152b
commit d81b824d3a
14 changed files with 2168 additions and 1661 deletions

View File

@ -25,7 +25,6 @@ import io.papermc.hangar.model.internal.user.notifications.HangarInvite;
import io.papermc.hangar.model.internal.user.notifications.HangarNotification;
import io.papermc.hangar.security.annotations.Anyone;
import io.papermc.hangar.security.annotations.LoggedIn;
import io.papermc.hangar.security.annotations.aal.RequireAal;
import io.papermc.hangar.security.annotations.currentuser.CurrentUser;
import io.papermc.hangar.security.annotations.permission.PermissionRequired;
import io.papermc.hangar.security.annotations.ratelimit.RateLimit;
@ -45,12 +44,9 @@ import io.papermc.hangar.service.internal.users.invites.ProjectInviteService;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
@ -75,8 +71,6 @@ import org.springframework.web.server.ResponseStatusException;
@RequestMapping(path = "/api/internal", produces = MediaType.APPLICATION_JSON_VALUE, method = {RequestMethod.GET, RequestMethod.POST})
public class HangarUserController extends HangarComponent {
private static final Set<String> ACCEPTED_SOCIAL_TYPES = Set.of("discord", "github", "twitter", "youtube", "website");
private final UsersApiService usersApiService;
private final UserService userService;
private final NotificationService notificationService;
@ -172,6 +166,27 @@ public class HangarUserController extends HangarComponent {
this.actionLogger.user(LogAction.USER_TAGLINE_CHANGED.create(UserContext.of(userTable.getId()), userTable.getTagline(), oldTagline));
}
// @el(userName: String)
@Unlocked
@CurrentUser("#userName")
@ResponseStatus(HttpStatus.OK)
@RateLimit(overdraft = 7, refillTokens = 1, refillSeconds = 20)
@PermissionRequired(NamedPermission.EDIT_OWN_USER_SETTINGS)
@PostMapping(path = "/users/{userName}/settings/socials", consumes = MediaType.APPLICATION_JSON_VALUE)
public void saveSocials(@PathVariable final String userName, @RequestBody final Map<String, String> socials) {
final UserTable userTable = this.userService.getUserTable(userName);
if (userTable == null) {
throw new HangarApiException(HttpStatus.NOT_FOUND);
}
this.userService.validateSocials(socials);
final JSONB oldSocials = userTable.getSocials() == null ? new JSONB("[]") : userTable.getSocials();
userTable.setSocials(new JSONB(socials));
this.userService.updateUser(userTable);
this.actionLogger.user(LogAction.USER_SOCIALS_CHANGED.create(UserContext.of(userTable.getId()), userTable.getSocials().toString(), oldSocials.toString()));
}
// @el(userName: String)
@Unlocked
@CurrentUser("#userName")
@ -246,20 +261,8 @@ public class HangarUserController extends HangarComponent {
throw new HangarApiException(HttpStatus.NOT_FOUND);
}
Map<String, String> map = new HashMap<>();
for (final String[] social : settings.socials()) {
if (social.length != 2) {
throw new HangarApiException("Badly formatted request, " + Arrays.toString(social) + " wasn't of length 2!");
}
if (!ACCEPTED_SOCIAL_TYPES.contains(social[0])) {
throw new HangarApiException("Badly formatted request, social type " + social[0] + " is unknown!");
}
if ("website".equals(social[0]) && !social[1].matches(this.config.getUrlRegex())) {
throw new HangarApiException("Badly formatted request, website " + social[1] + " is not a valid url! (Did you add https://?)");
}
map.put(social[0], social[1]);
}
userTable.setSocials(new JSONB(map));
this.userService.validateSocials(settings.socials());
userTable.setSocials(new JSONB(settings.socials()));
userTable.setTagline(settings.tagline());
// TODO user action logging
this.userService.updateUser(userTable);

View File

@ -1,7 +1,9 @@
package io.papermc.hangar.controller.internal;
import com.fasterxml.jackson.databind.JsonNode;
import io.papermc.hangar.HangarComponent;
import io.papermc.hangar.components.images.service.AvatarService;
import io.papermc.hangar.db.customtypes.JSONB;
import io.papermc.hangar.exceptions.HangarApiException;
import io.papermc.hangar.model.common.NamedPermission;
import io.papermc.hangar.model.common.Permission;
@ -194,6 +196,27 @@ public class OrganizationController extends HangarComponent {
this.actionLogger.user(LogAction.USER_TAGLINE_CHANGED.create(UserContext.of(userTable.getId()), userTable.getTagline(), oldTagline));
}
@Unlocked
@RequireAal(1)
@ResponseStatus(HttpStatus.OK)
@RateLimit(overdraft = 7, refillTokens = 1, refillSeconds = 20)
@PermissionRequired(type = PermissionType.ORGANIZATION, perms = NamedPermission.EDIT_SUBJECT_SETTINGS, args = "{#orgName}")
@PostMapping(path = "/org/{orgName}/settings/socials", consumes = MediaType.APPLICATION_JSON_VALUE)
public void saveSocials(@PathVariable final String orgName, @RequestBody final Map<String, String> socials) {
final UserTable userTable = this.userService.getUserTable(orgName);
final OrganizationTable organizationTable = this.organizationService.getOrganizationTable(orgName);
if (userTable == null || organizationTable == null) {
throw new HangarApiException(HttpStatus.NOT_FOUND);
}
this.userService.validateSocials(socials);
final JSONB oldSocials = userTable.getSocials() == null ? new JSONB("[]") : userTable.getSocials();
userTable.setSocials(new JSONB(socials));
this.userService.updateUser(userTable);
this.actionLogger.user(LogAction.USER_SOCIALS_CHANGED.create(UserContext.of(userTable.getId()), userTable.getSocials().toString(), oldSocials.toString()));
}
@Unlocked
@RequireAal(1)
@ResponseBody

View File

@ -45,6 +45,7 @@ public class PGLoggedAction extends PGobject {
// Users
public static final PGLoggedAction USER_TAGLINE_CHANGED = new PGLoggedAction("user_tagline_changed");
public static final PGLoggedAction USER_SOCIALS_CHANGED = new PGLoggedAction("user_socials_changed");
public static final PGLoggedAction USER_LOCKED = new PGLoggedAction("user_locked");
public static final PGLoggedAction USER_UNLOCKED = new PGLoggedAction("user_unlocked");
public static final PGLoggedAction USER_APIKEY_CREATED = new PGLoggedAction("user_apikey_created");

View File

@ -1,3 +1,5 @@
package io.papermc.hangar.model.internal.api.requests;
public record UserProfileSettings(String tagline, String[][] socials) {}
import java.util.Map;
public record UserProfileSettings(String tagline, Map<String, String> socials) {}

View File

@ -55,6 +55,7 @@ public class LogAction<LC extends LogContext<? extends LoggedActionTable, LC>> {
// Users
public static final LogAction<UserContext> USER_TAGLINE_CHANGED = new LogAction<>(PGLoggedAction.USER_TAGLINE_CHANGED, "User Tagline Changed");
public static final LogAction<UserContext> USER_SOCIALS_CHANGED = new LogAction<>(PGLoggedAction.USER_SOCIALS_CHANGED, "User Socials Changed");
public static final LogAction<UserContext> USER_LOCKED = new LogAction<>(PGLoggedAction.USER_LOCKED, "User Locked");
public static final LogAction<UserContext> USER_UNLOCKED = new LogAction<>(PGLoggedAction.USER_UNLOCKED, "User Unlocked");
public static final LogAction<UserContext> USER_APIKEY_CREATED = new LogAction<>(PGLoggedAction.USER_APIKEY_CREATED, "User Apikey Created");

View File

@ -3,11 +3,16 @@ package io.papermc.hangar.service.internal.users;
import io.papermc.hangar.HangarComponent;
import io.papermc.hangar.db.dao.internal.HangarUsersDAO;
import io.papermc.hangar.db.dao.internal.table.UserDAO;
import io.papermc.hangar.exceptions.HangarApiException;
import io.papermc.hangar.model.common.Prompt;
import io.papermc.hangar.model.db.UserTable;
import io.papermc.hangar.model.internal.logs.LogAction;
import io.papermc.hangar.model.internal.logs.LoggedAction;
import io.papermc.hangar.model.internal.logs.contexts.UserContext;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import org.jetbrains.annotations.NotNull;
@ -22,6 +27,8 @@ public class UserService extends HangarComponent {
private final UserDAO userDAO;
private final HangarUsersDAO hangarUsersDAO;
private static final Set<String> ACCEPTED_SOCIAL_TYPES = Set.of("discord", "github", "twitter", "youtube", "website");
@Autowired
public UserService(final UserDAO userDAO, final HangarUsersDAO hangarUsersDAO) {
this.userDAO = userDAO;
@ -89,6 +96,17 @@ public class UserService extends HangarComponent {
this.userDAO.update(userTable);
}
public void validateSocials(Map<String, String> socials) {
for (final Map.Entry<String, String> social : socials.entrySet()) {
if (!ACCEPTED_SOCIAL_TYPES.contains(social.getKey())) {
throw new HangarApiException("Badly formatted request, social type " + social.getKey() + " is unknown!");
}
if ("website".equals(social.getKey()) && !social.getValue().matches(this.config.getUrlRegex())) {
throw new HangarApiException("Badly formatted request, website " + social.getValue() + " is not a valid url! (Did you add https://?)");
}
}
}
private @Nullable <T> UserTable getUserTable(final @Nullable T identifier, final @NotNull Function<T, UserTable> userTableFunction) {
if (identifier == null) {
return null;

View File

@ -0,0 +1 @@
ALTER TYPE logged_action_type ADD VALUE 'user_socials_changed'

View File

@ -86,6 +86,11 @@ const canEditCurrentUser = computed<boolean>(() => {
</div>
</template>
</Popper>
<SocialsModal
v-if="canEditCurrentUser"
:socials="viewingUser.socials"
:action="`${viewingUser.isOrganization ? 'organizations/org' : 'users'}/${viewingUser.name}/settings/socials`"
/>
</h1>
<Skeleton v-else class="text-2xl px-1 w-50" />

View File

@ -0,0 +1,48 @@
<script lang="ts" setup>
const { t } = useI18n();
const notification = useNotificationStore();
const socials = defineModel<Record<string, string>>({ required: true });
const linkType = ref<string>();
const linkTypes = [
{ value: "discord", text: "Discord" },
{ value: "github", text: "GitHub" },
{ value: "twitter", text: "Twitter" },
{ value: "youtube", text: "YouTube" },
{ value: "website", text: "Website" },
];
function addLink() {
if (!linkType.value) {
return notification.error("You have to select a type");
}
if (Object.keys(socials.value).includes(linkType.value)) {
return notification.error("You already have a link of that type added");
}
socials.value[linkType.value] = "";
}
function removeLink(type: string) {
delete socials.value[type];
}
</script>
<template>
<div v-for="(_, type) in socials" :key="type" class="flex items-center mt-2">
<span class="w-25">{{ linkTypes.find((e) => e.value === type)?.text }}</span>
<div class="w-75">
<InputText v-if="type === 'website'" v-model="socials[type]" label="URL" :rules="[required(), validUrl()]" />
<InputText v-else v-model="socials[type]" :label="t('auth.settings.account.username')" :rules="[required()]" />
</div>
<IconMdiBin class="ml-2 w-6 h-6 cursor-pointer hover:color-red" @click="removeLink(type)" />
</div>
<div class="flex items-center mt-2">
<div class="w-25">
<Button button-type="secondary" @click.prevent="addLink">Add link</Button>
</div>
<div class="w-75">
<InputSelect v-model="linkType" :values="linkTypes" :label="t('project.settings.links.typeField')" />
</div>
</div>
</template>

View File

@ -0,0 +1,37 @@
<script lang="ts" setup>
import type { JsonNode } from "~/types/backend";
const props = defineProps<{
socials: JsonNode;
action: string;
}>();
const newSocials = ref(props.socials);
const router = useRouter();
const i18n = useI18n();
const loading = ref(false);
async function save() {
loading.value = true;
try {
await useInternalApi(props.action, "post", newSocials.value);
router.go(0);
} catch (err) {
handleRequestError(err);
}
loading.value = false;
}
</script>
<template>
<Modal :title="i18n.t('author.editSocials')" window-classes="w-200 text-lg">
<SocialForm v-model="newSocials" />
<Button class="mt-3" @click="save">{{ i18n.t("general.change") }}</Button>
<template #activator="{ on }">
<Button size="small" class="ml-2 inline-flex text-lg" v-on="on">
<IconMdiPencil />
</Button>
</template>
</Modal>
</template>

View File

@ -787,6 +787,7 @@
"viewOnForums": "View on forums ",
"taglineLabel": "User Tagline",
"editTagline": "Edit Tagline",
"editSocials": "Edit Socials",
"editOrgVisibility": "Edit Organization Visibility",
"orgVisibilityModal": "Toggle an organization to hide your membership publicly.",
"memberSince": "Member since {0}",
@ -1137,6 +1138,7 @@
"VersionPlatformDependencyAdded": "A platform dependency was added",
"VersionPlatformDependencyRemoved": "A platform dependency was removed",
"UserTaglineChanged": "The user tagline changed",
"UserSocialsChanged": "The user socials changed",
"UserLocked": "This user is locked",
"UserUnlocked": "This user is unlocked",
"UserApikeyCreated": "An apikey was created",

View File

@ -1,5 +1,6 @@
<script lang="ts" setup>
import type { SettingsResponse } from "~/types/backend";
import SocialForm from "~/components/form/SocialForm.vue";
defineProps<{
settings?: SettingsResponse;
@ -15,30 +16,8 @@ const loading = ref(false);
const profileForm = reactive({
tagline: auth.user?.tagline,
socials: auth.user?.socials ? Object.entries(auth.user.socials) : [],
socials: auth.user?.socials,
});
const linkType = ref<string>();
const linkTypes = [
{ value: "discord", text: "Discord" },
{ value: "github", text: "GitHub" },
{ value: "twitter", text: "Twitter" },
{ value: "youtube", text: "YouTube" },
{ value: "website", text: "Website" },
];
function addLink() {
if (!linkType.value) {
return notification.error("You have to select a type");
}
if (profileForm.socials.some((e) => (e[0] as string) === linkType.value)) {
return notification.error("You already have a link of that type added");
}
profileForm.socials.push([linkType.value, ""]);
}
function removeLink(idx: number) {
profileForm.socials.splice(idx, 1);
}
async function saveProfile() {
if (!(await v.value.$validate())) return;
@ -71,22 +50,7 @@ async function saveProfile() {
<InputText v-model="profileForm.tagline" :label="t('auth.settings.profile.tagline')" counter :maxlength="useBackendData.validations.userTagline.max" />
<h3 class="text-lg font-bold mt-4">{{ t("auth.settings.profile.social") }}</h3>
<div v-for="(link, idx) in profileForm.socials" :key="link[0]" class="flex items-center mt-2">
<span class="w-25">{{ linkTypes.find((e) => e.value === link[0])?.text }}</span>
<div class="w-75">
<InputText v-if="link[0] === 'website'" v-model="link[1]" label="URL" :rules="[required(), validUrl()]" />
<InputText v-else v-model="link[1]" :label="t('auth.settings.account.username')" :rules="[required()]" />
</div>
<IconMdiBin class="ml-2 w-6 h-6 cursor-pointer hover:color-red" @click="removeLink(idx)" />
</div>
<div class="flex items-center mt-2">
<div class="w-25">
<Button button-type="secondary" @click.prevent="addLink">Add link</Button>
</div>
<div class="w-75">
<InputSelect v-model="linkType" :values="linkTypes" :label="t('project.settings.links.typeField')" />
</div>
</div>
<SocialForm v-model="profileForm.socials" />
<Button type="submit" class="w-max mt-2" :disabled="loading" @click.prevent="saveProfile">{{ t("general.save") }}</Button>
</div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff