diff --git a/src/equicordplugins/themeLibrary/auth.tsx b/src/equicordplugins/themeLibrary/auth.tsx new file mode 100644 index 00000000..6e214f6b --- /dev/null +++ b/src/equicordplugins/themeLibrary/auth.tsx @@ -0,0 +1,141 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import * as DataStore from "@api/DataStore"; +import { showNotification } from "@api/Notifications"; +import { openModal } from "@utils/modal"; +import { OAuth2AuthorizeModal, Toasts, UserStore } from "@webpack/common"; + +import { logger } from "./components/LikesComponent"; + +export async function authorizeUser(triggerModal: boolean = true) { + const isAuthorized = await getAuthorization(); + + if (isAuthorized === false) { + if (!triggerModal) return false; + openModal((props: any) => { + if (!location) return logger.error("No redirect location returned"); + + try { + const response = await fetch(location, { + headers: { Accept: "application/json" } + }); + + const { token } = await response.json(); + + if (token) { + logger.debug("Authorized via OAuth2, got token"); + await DataStore.set("ThemeLibrary_uniqueToken", token); + showNotification({ + title: "ThemeLibrary", + body: "Successfully authorized with ThemeLibrary!" + }); + } else { + logger.debug("Tried to authorize via OAuth2, but no token returned"); + showNotification({ + title: "ThemeLibrary", + body: "Failed to authorize, check console" + }); + } + } catch (e: any) { + logger.error("Failed to authorize", e); + showNotification({ + title: "ThemeLibrary", + body: "Failed to authorize, check console" + }); + } + } + } + />); + } else { + return isAuthorized; + } +} + +export async function deauthorizeUser() { + const uniqueToken = await DataStore.get>("ThemeLibrary_uniqueToken"); + + if (!uniqueToken) return Toasts.show({ + message: "No uniqueToken present, try authorizing first!", + id: Toasts.genId(), + type: Toasts.Type.FAILURE, + options: { + duration: 2e3, + position: Toasts.Position.BOTTOM + } + }); + + const res = await fetch("https://themes-delta.vercel.app/api/user/revoke", { + method: "DELETE", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ token: uniqueToken, userId: UserStore.getCurrentUser().id }) + }); + + if (res.ok) { + await DataStore.del("ThemeLibrary_uniqueToken"); + showNotification({ + title: "ThemeLibrary", + body: "Successfully deauthorized from ThemeLibrary!" + }); + } else { + // try to delete anyway + try { + await DataStore.del("ThemeLibrary_uniqueToken"); + } catch (e) { + logger.error("Failed to delete token", e); + showNotification({ + title: "ThemeLibrary", + body: "Failed to deauthorize, check console" + }); + } + } +} + +export async function getAuthorization() { + const uniqueToken = await DataStore.get>("ThemeLibrary_uniqueToken"); + + if (!uniqueToken) { + return false; + } else { + // check if valid + const res = await fetch("https://themes-delta.vercel.app/api/user/findUserByToken", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ token: uniqueToken }) + }); + + if (res.status === 400 || res.status === 500) { + return false; + } else { + logger.debug("User is already authorized, skipping"); + return uniqueToken; + } + } + +} + +export async function isAuthorized(triggerModal: boolean = true) { + const isAuthorized = await getAuthorization(); + const token = await DataStore.get("ThemeLibrary_uniqueToken"); + + if (isAuthorized === false || !token) { + await authorizeUser(triggerModal); + } else { + return true; + } +} diff --git a/src/equicordplugins/themeLibrary/components/LikesComponent.tsx b/src/equicordplugins/themeLibrary/components/LikesComponent.tsx index d097e0dc..db7b0fcc 100644 --- a/src/equicordplugins/themeLibrary/components/LikesComponent.tsx +++ b/src/equicordplugins/themeLibrary/components/LikesComponent.tsx @@ -4,32 +4,24 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import * as DataStore from "@api/DataStore"; import { Logger } from "@utils/Logger"; -import { Button, useEffect, UserStore, useState } from "@webpack/common"; +import { Button, useEffect, useRef, UserStore, useState } from "@webpack/common"; import type { User } from "discord-types/general"; +import { isAuthorized } from "../auth"; import type { Theme, ThemeLikeProps } from "../types"; import { themeRequest } from "./ThemeTab"; -const logger = new Logger("ThemeLibrary", "#e5c890"); - -const fetchLikes = async () => { - try { - const response = await themeRequest("/likes/get"); - const data = await response.json(); - return data; - } catch (err) { - logger.error(err); - } -}; +export const logger = new Logger("ThemeLibrary", "#e5c890"); export const LikesComponent = ({ themeId, likedThemes: initialLikedThemes }: { themeId: Theme["id"], likedThemes: ThemeLikeProps | undefined; }) => { const [likesCount, setLikesCount] = useState(0); const [likedThemes, setLikedThemes] = useState(initialLikedThemes); + const debounce = useRef(false); useEffect(() => { const likes = getThemeLikes(themeId); - logger.debug("likes", likes, "for:", themeId); setLikesCount(likes); }, [likedThemes, themeId]); @@ -45,10 +37,17 @@ export const LikesComponent = ({ themeId, likedThemes: initialLikedThemes }: { t ); const handleLikeClick = async (themeId: Theme["id"]) => { + if (!isAuthorized()) return; const theme = likedThemes?.likes.find(like => like.themeId === themeId as unknown as Number); const currentUser: User = UserStore.getCurrentUser(); const hasLiked: boolean = theme?.userIds.includes(currentUser.id) ?? false; const endpoint = hasLiked ? "/likes/remove" : "/likes/add"; + const token = await DataStore.get("ThemeLibrary_uniqueToken"); + + // doing this so the delay is not visible to the user + if (debounce.current) return; + setLikesCount(likesCount + (hasLiked ? -1 : 1)); + debounce.current = true; try { const response = await themeRequest(endpoint, { @@ -56,14 +55,13 @@ export const LikesComponent = ({ themeId, likedThemes: initialLikedThemes }: { t headers: { "Content-Type": "application/json", }, - cache: "no-store", body: JSON.stringify({ - userId: currentUser.id, + token, themeId: themeId, }), }); - if (!response.ok) return logger.error("Couldnt update likes, res:", response.statusText); + if (!response.ok) return logger.error("Couldnt update likes, response not ok"); const fetchLikes = async () => { try { @@ -76,11 +74,10 @@ export const LikesComponent = ({ themeId, likedThemes: initialLikedThemes }: { t }; fetchLikes(); - // doing it locally isnt the best way probably, but it does the same - setLikesCount(likesCount + (hasLiked ? -1 : 1)); } catch (err) { logger.error(err); } + debounce.current = false; }; const hasLiked = likedThemes?.likes.some(like => like.themeId === themeId as unknown as Number && like.userIds.includes(UserStore.getCurrentUser().id)) ?? false; diff --git a/src/equicordplugins/themeLibrary/components/ThemeInfoModal.tsx b/src/equicordplugins/themeLibrary/components/ThemeInfoModal.tsx index 1c116d3e..34df22d1 100644 --- a/src/equicordplugins/themeLibrary/components/ThemeInfoModal.tsx +++ b/src/equicordplugins/themeLibrary/components/ThemeInfoModal.tsx @@ -18,7 +18,7 @@ const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaul export const ThemeInfoModal: React.FC = ({ author, theme, ...props }) => { - const content = atob(theme.content); + const content = window.atob(theme.content); const metadata = content.match(/\/\*\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*+\//g)?.[0] || ""; const donate = metadata.match(/@donate\s+(.+)/)?.[1] || ""; const version = metadata.match(/@version\s+(.+)/)?.[1] || ""; diff --git a/src/equicordplugins/themeLibrary/components/ThemeTab.tsx b/src/equicordplugins/themeLibrary/components/ThemeTab.tsx index 73b094d9..023dceb0 100644 --- a/src/equicordplugins/themeLibrary/components/ThemeTab.tsx +++ b/src/equicordplugins/themeLibrary/components/ThemeTab.tsx @@ -8,6 +8,7 @@ import "./styles.css"; import { generateId } from "@api/Commands"; +import * as DataStore from "@api/DataStore"; import { Settings } from "@api/Settings"; import { classNameFactory } from "@api/Styles"; import { CodeBlock } from "@components/CodeBlock"; @@ -20,16 +21,17 @@ import { Margins } from "@utils/margins"; import { classes } from "@utils/misc"; import { openModal } from "@utils/modal"; import { findByPropsLazy, findLazy } from "@webpack"; -import { Button, Card, FluxDispatcher, Forms, React, Select, showToast, TabBar, TextArea, TextInput, Toasts, useEffect, UserStore, UserUtils, useState } from "@webpack/common"; +import { Button, Card, FluxDispatcher, Forms, React, SearchableSelect, TabBar, TextArea, TextInput, Toasts, useEffect, UserStore, UserUtils, useState } from "@webpack/common"; import { User } from "discord-types/general"; import { Constructor } from "type-fest"; +import { isAuthorized } from "../auth"; import { SearchStatus, TabItem, Theme, ThemeLikeProps } from "../types"; import { LikesComponent } from "./LikesComponent"; import { ThemeInfoModal } from "./ThemeInfoModal"; const cl = classNameFactory("vc-plugins-"); -const InputStyles = findByPropsLazy("inputDefault", "inputWrapper"); +const InputStyles = findByPropsLazy("inputDefault", "inputWrapper", "error"); const UserRecord: Constructor> = proxyLazy(() => UserStore.getCurrentUser().constructor) as any; const TextAreaProps = findLazy(m => typeof m.textarea === "string"); @@ -37,33 +39,18 @@ const API_URL = "https://themes-delta.vercel.app/api"; const logger = new Logger("ThemeLibrary", "#e5c890"); -async function fetchThemes(url: string): Promise { - const response = await fetch(url); +export async function fetchAllThemes(): Promise { + const response = await themeRequest("/themes"); const data = await response.json(); const themes: Theme[] = Object.values(data); themes.forEach(theme => { if (!theme.source) { theme.source = `${API_URL}/${theme.name}`; - } else { - theme.source = theme.source.replace("?raw=true", "") + "?raw=true"; } }); return themes.sort((a, b) => new Date(b.release_date).getTime() - new Date(a.release_date).getTime()); } -function API_TYPE(theme: Theme | Object, returnAll?: boolean) { - if (!theme) return; - const settings = Settings.plugins.ThemeLibrary.domain ?? false; - - if (returnAll) { - const url = settings ? "https://raw.githubusercontent.com/Faf4a/plugins/main/assets/meta.json" : `${API_URL}/themes`; - return fetchThemes(url); - } else { - // @ts-ignore - return settings ? theme.source : `${API_URL}/${theme.name}`; - } -} - export async function themeRequest(path: string, options: RequestInit = {}) { return fetch(API_URL + path, { ...options, @@ -115,7 +102,7 @@ function ThemeTab() { const onStatusChange = (status: SearchStatus) => setSearchValue(prev => ({ ...prev, status })); const themeFilter = (theme: Theme) => { - const enabled = themeLinks.includes(API_TYPE(theme)); + const enabled = themeLinks.includes(`${API_URL}/${theme.name}`); if (enabled && searchValue.status === SearchStatus.DISABLED) return false; if (!theme.tags.includes("theme") && searchValue.status === SearchStatus.THEME) return false; if (!theme.tags.includes("snippet") && searchValue.status === SearchStatus.SNIPPET) return false; @@ -146,7 +133,7 @@ function ThemeTab() { useEffect(() => { const fetchThemes = async () => { try { - const themes = await API_TYPE({}, true); + const themes = await fetchAllThemes(); // fetch likes setThemes(themes); const likes = await fetchLikes(); @@ -187,7 +174,7 @@ function ThemeTab() { ) : ( <> {hideWarningCard ? null : ( - + Want your theme removed? If you want your theme(s) permanently removed, please open an issue on GitHub @@ -200,7 +187,7 @@ function ThemeTab() { size={Button.Sizes.SMALL} color={Button.Colors.RED} look={Button.Looks.FILLED} - className={Margins.top8} + className={classes(Margins.top16, "vce-warning-button")} >Hide )} @@ -247,10 +234,10 @@ function ThemeTab() { )}
- {themeLinks.includes(API_TYPE(theme)) ? ( + {themeLinks.includes(`${API_URL}/${theme.name}`) ? (