diff --git a/client/configs/vars.mjs b/client/configs/vars.mjs index 112686a..4ad0aee 100644 --- a/client/configs/vars.mjs +++ b/client/configs/vars.mjs @@ -7,7 +7,7 @@ export const apiServerBaseURL = configuration.webserver.base_url; export const sentryDSN = configuration.sentry_dsn_js; export const apiServerPort = !apiServerBaseURL ? undefined - : configuration.webserver.port; + : configuration.webserver?.port; export const siteName = configuration.webserver.ui.home.site_name || "Kemono"; export const homeBackgroundImage = configuration.webserver.ui.home.home_background_image; diff --git a/client/src/api/posts/popular.ts b/client/src/api/posts/popular.ts index 71aa408..f3d4c0e 100644 --- a/client/src/api/posts/popular.ts +++ b/client/src/api/posts/popular.ts @@ -56,7 +56,7 @@ export async function fetchPopularPosts( const path = `/posts/popular`; const params = new URLSearchParams(); - if (date) { + if (date && scale !== "recent") { params.set("date", date); } diff --git a/client/src/components/links/links.tsx b/client/src/components/links/links.tsx index bbdf756..356e23c 100644 --- a/client/src/components/links/links.tsx +++ b/client/src/components/links/links.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import { ReactNode } from "react"; +import { MouseEventHandler, ReactNode } from "react"; import { NavLink, NavLinkProps } from "react-router"; export interface IFancyLinkProps extends IBaseLinkProps { @@ -34,6 +34,7 @@ interface IBaseLinkProps { url: string; className?: string; children?: ReactNode; + onClick?: MouseEventHandler; } export function FancyLink({ diff --git a/client/src/entities/files/file_hash_search.module.scss b/client/src/entities/files/file_hash_search.module.scss deleted file mode 100644 index 60cdc5d..0000000 --- a/client/src/entities/files/file_hash_search.module.scss +++ /dev/null @@ -1,5 +0,0 @@ -.block { - text-align: center; - padding-top: 1em; - padding-bottom: 0.5em; -} diff --git a/client/src/entities/files/file_hash_search.tsx b/client/src/entities/files/file_hash_search.tsx deleted file mode 100644 index de7c143..0000000 --- a/client/src/entities/files/file_hash_search.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { FormRouter, FormSection } from "#components/forms"; - -import * as styles from "./file_hash_search.module.scss"; - -interface IProps { - id: string; - hash?: string; -} - -export function FileSearchForm({ id, hash }: IProps) { - return ( - <>Submit} - > - - - - - - - -
- —or— -
-
-
- - - - - -
- ); -} diff --git a/client/src/entities/files/index.ts b/client/src/entities/files/index.ts index 9c21d6c..1631241 100644 --- a/client/src/entities/files/index.ts +++ b/client/src/entities/files/index.ts @@ -1,4 +1,3 @@ -export { FileSearchForm } from "./file_hash_search"; export { ArchiveFileOverview } from "./archive-overview"; export type { IFile, diff --git a/client/src/entities/posts/overview/body.tsx b/client/src/entities/posts/overview/body.tsx index 52ee5a5..46d5356 100644 --- a/client/src/entities/posts/overview/body.tsx +++ b/client/src/entities/posts/overview/body.tsx @@ -100,7 +100,7 @@ export function PostBody({ {incomplete_rewards && (
-

+          
{incomplete_rewards}
)} diff --git a/client/src/lib/urls/files.ts b/client/src/lib/urls/files.ts index 40a4c27..1137da3 100644 --- a/client/src/lib/urls/files.ts +++ b/client/src/lib/urls/files.ts @@ -33,14 +33,3 @@ export function createArchiveFileURL( return new InternalURL(path, params); } - -export function createFileSearchPageURL(hash?: string) { - const path = `/search_hash`; - const params = new URLSearchParams(); - - if (hash) { - params.set("hash", hash); - } - - return new InternalURL(path, params); -} diff --git a/client/src/lib/urls/index.ts b/client/src/lib/urls/index.ts index 691e538..e66278f 100644 --- a/client/src/lib/urls/index.ts +++ b/client/src/lib/urls/index.ts @@ -48,7 +48,6 @@ export { createThumbnailURL, createFilePageURL, createArchiveFileURL, - createFileSearchPageURL, } from "./files"; export { createImporterStatusPageURL } from "./importer"; export { diff --git a/client/src/pages/errors/404.tsx b/client/src/pages/errors/404.tsx new file mode 100644 index 0000000..b1fa0b3 --- /dev/null +++ b/client/src/pages/errors/404.tsx @@ -0,0 +1,22 @@ +import { KemonoLink } from "#components/links"; +import { useNavigate } from "react-router"; +import * as styles from "./errors.module.scss"; + +export function Error404() { + const navigate = useNavigate(); + + function goBack() { + navigate(-1); + } + + return ( +
+

404

+
The page you are looking for does not exist.
+
+ Home{" | "} + Go Back +
+
+ ) +} diff --git a/client/src/pages/errors/errors.module.scss b/client/src/pages/errors/errors.module.scss new file mode 100644 index 0000000..37722e0 --- /dev/null +++ b/client/src/pages/errors/errors.module.scss @@ -0,0 +1,13 @@ +.errorPage { + width: fit-content; + margin-left: auto; + margin-right: auto; + margin-top: 10vh; + text-align: center; + + h1 { + margin-bottom: 5vh; + font-size: 500%; + user-select: none; + } +} diff --git a/client/src/pages/importer/importer_list.module.scss b/client/src/pages/importer/importer_list.module.scss new file mode 100644 index 0000000..d886519 --- /dev/null +++ b/client/src/pages/importer/importer_list.module.scss @@ -0,0 +1,5 @@ +.error { + color: var(--negative-colour1-primary); + text-align: center; + margin-bottom: 1em; +} diff --git a/client/src/pages/importer/importer_list.tsx b/client/src/pages/importer/importer_list.tsx index 26c0d01..260457e 100644 --- a/client/src/pages/importer/importer_list.tsx +++ b/client/src/pages/importer/importer_list.tsx @@ -1,34 +1,245 @@ import clsx from "clsx"; -import { useState } from "react"; -import { ActionFunctionArgs, redirect } from "react-router"; +import { FormEvent, useState } from "react"; +import { redirect, useNavigate } from "react-router"; import { PAYSITE_LIST, SITE_NAME } from "#env/env-vars"; -import { createImporterStatusPageURL } from "#lib/urls"; import { fetchCreateImport } from "#api/imports"; -import { useClient } from "#hooks"; import { PageSkeleton } from "#components/pages"; -import { FormRouter, FormSection } from "#components/forms"; -import { paysites } from "#entities/paysites"; -import { isRegisteredAccount } from "#entities/account"; -const dmLookup = ["patreon", "fansly"]; +import * as styles from "./importer_list.module.scss"; + +const MAX_LENGTH = 1024; + +function titlize(s: string) { + return s.replace("_", " ").replace(/\b\w+/g, text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()); +} + +interface Input { + name: string; + label?: string; + hint?: string; + default?: () => string; +} + +interface PaysiteForm { + name?: string; + inputs: Array; + // Returns an error message, or undefined if no errors. + validate: (...args: Array) => string | undefined; + includeDMs?: boolean; +} + +const PAYSITES: { [name: string]: PaysiteForm } = { + patreon: { + inputs: [ + { name: "session_key" }, + ], + validate({ session_key }) { + if (session_key.length != 43) + return `Invalid key: Expected 43 characters, got ${session_key.length}`; + }, + includeDMs: true, + }, + fanbox: { + name: "Pixiv Fanbox", + inputs: [ + { name: "session_key" }, + ], + validate({ session_key }) { + if (!session_key.match(/^\d+_\w+$/) || session_key.length > MAX_LENGTH) { + return "Invalid key."; + } + }, + }, + afdian: { + inputs: [ + { name: "session_key" }, + ], + validate({ session_key }) { + if (session_key.length > MAX_LENGTH) { + return "Key is too long."; + } + }, + }, + boosty: { + inputs: [ + { name: "session_key" }, + ], + validate({ session_key }) { + try { + JSON.parse(decodeURIComponent(session_key)); + } catch { + return "Invalid key: Expected valid JSON."; + } + }, + }, + discord: { + inputs: [ + { name: "session_key", label: "Token" }, + { name: "channel_ids", label: "Channel IDs", hint: "Separate with commas." }, + ], + validate({ session_key, channel_ids }) { + if (!session_key.match(/^(mfa.[a-z0-9_-]{20,})|([a-z0-9_-]{23,28}.[a-z0-9_-]{6,7}.[a-z0-9_-]{27})$/)) { + return "Invalid token format."; + } + for (const id of channel_ids.split(/\s*,\s*/)) { + if (id && !parseInt(id)) { // + return `${id} is not a valid channel ID.`; + } + } + }, + }, + dlsite: { + name: "DLsite", + inputs: [ + { name: "session_key" }, + ], + validate({ session_key }) { + if (session_key.length > MAX_LENGTH) { + return "Key is too long."; + } + }, + }, + fantia: { + inputs: [ + { name: "session_key" }, + ], + validate({ session_key }) { + if (![32, 64].includes(session_key.length)) { + return "Invalid key: Expected 32 or 64 characters."; + } + }, + }, + gumroad: { + inputs: [ + { name: "session_key" }, + ], + validate({ session_key }) { + if (session_key.length < 200 || session_key.length > MAX_LENGTH) { + return `Invalid key: Expected 200 to ${MAX_LENGTH} characters.`; + } + }, + }, + subscribestar: { + name: "SubscribeStar", + inputs: [ + { name: "session_key" }, + ], + validate({ session_key }) { + if (session_key.length > MAX_LENGTH) { + return "Key is too long."; + } + }, + }, + onlyfans: { + name: "OnlyFans", + inputs: [ + { name: "session_key", hint: "Can be found in cookies -> sess." }, + { name: "auth_id", label: "User ID", hint: "Can be found in cookies -> auth_id." }, + { name: "x-bc", label: "BC Token", hint: "Can be found in local storage -> bcTokenSha. Paste localStorage.bcTokenSha into the console for easy access." }, + { + name: "user_agent", + label: "User-Agent", + hint: "This needs to be set to the User-Agent of the last device that logged into your OnlyFans account; leave it as the default value if you are on it right now.", + default: () => navigator.userAgent, + }, + ], + validate({ session_key, auth_id: user_id, "x-bc": bc_token, user_agent }) { + if (session_key.length > MAX_LENGTH) { + return "Key is too long."; + } + if (!parseInt(user_id)) { + return "User ID must consist of only digits."; + } + if (!bc_token.match(/^[a-f0-9]{40}$/)) { + return "Invalid BC token (expected 40 hexadecimal characters)."; + } + if (!/^[\x00-\x7F]*$/.test(user_agent)) { + return "Invalid User-Agent (contains non-ASCII characters)."; + } + }, + }, + fansly: { + inputs: [ + { + name: "session_key", + hint: ` + Copy the following string and enter it into the browser Console, + accessible by pressing F12. + btoa(JSON.stringify({...JSON.parse(localStorage?.session_active_session||'{}'),device:localStorage?.device_device_id})) + ` + }, + ], + validate({ session_key }) { + if (session_key.length == 71 && !/^[A-Za-z0-9]{71}$/.test(session_key)) { + return "The key doesn't match the required pattern."; + } + try { + if (!JSON.parse(atob(session_key))?.token) { + return "Token not found in JSON."; + } + } catch { + return "Key is not valid JSON." + } + }, + includeDMs: true, + }, + candfans: { + name: "CandFans", + inputs: [ + { name: "session_key", hint: "On CandFans page, Press F12 -> \"Application\" tab (check >> if its hidden) -> Storage: Cookies -> candfans.jp -> secure_candfans_session value." }, + ], + validate({ session_key }) { + try { + let keys = Object.keys(JSON.parse(atob(decodeURIComponent(session_key)))); + if (!["mac", "iv", "tag", "value"].every(key => keys.includes(key))) { + return "The key does not contain the appropriate values."; + } + } catch { + return "The key was not decodable."; + } + }, + } +} /** * TODO: split into separate pages per service */ export function ImporterPage() { - const isClient = useClient(); const [selectedService, changeSelectedService] = useState(PAYSITE_LIST[0]); + const [error, setError] = useState(undefined); + const navigate = useNavigate(); const title = "Import paywall posts/comments/DMs"; const heading = "Import from Paysite"; + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setError(undefined); + let form = event.target as HTMLFormElement; + let inputs = form.querySelectorAll("input"); + let args: {[key: string]: string} = { service: selectedService }; + inputs.forEach(el => { + if (el.type == "checkbox") { + args[el.name] = el.checked ? "1" : "0"; + } else { + args[el.name] = el.value.trim(); + } + }); + let error = PAYSITES[selectedService].validate(args); + if (error) { + setError(error); + } else { + try { + let { import_id } = await fetchCreateImport(args as any); + await navigate(`/importer/status/${import_id}`); + } catch (resp: any) { + setError(resp.message) + } + } + } + return ( - "Submit key"} - > +
- - - - - - Learn how to get your session key. - - - - - - - - - - - - {selectedService !== "onlyfans" ? undefined : ( -
-
+ {PAYSITES[selectedService].inputs.map((input, index) => { + return ( +
+ - - Your user ID. Can be found in Cookies -{">"} auth_id. - + {index === 0 && ( + + Learn how to get your session key. + + )} + {input.hint && ( + + )}
+ ) + })} -
- + {PAYSITES[selectedService].includeDMs && ( + - -
- - - - This needs to be set to the{" "} - - User-Agent - - of the last device that logged into your OnlyFans account; leave - it as the default value if you are on it right now. - -
+
)} -
- - - comma separated, no spaces -
- -