diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1691f22 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.png filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text diff --git a/components/LoginButton.tsx b/components/LoginButton.tsx new file mode 100644 index 0000000..5098ea7 --- /dev/null +++ b/components/LoginButton.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import {LoginContext} from "../contexts/login" +import OriginalTelegramLoginButton from 'react-telegram-login' +import { useDefinedContext } from '../hooks/useDefinedContext'; + +export function LoginButton(props: any) { + const [login, setLogin] = useDefinedContext(LoginContext) + + return React.useMemo(() => ( + login ? + + : + + ), + [login] + ) +} \ No newline at end of file diff --git a/components/Navbar.tsx b/components/Navbar.tsx new file mode 100644 index 0000000..70a8bf3 --- /dev/null +++ b/components/Navbar.tsx @@ -0,0 +1,27 @@ +import Link from "next/link"; +import { LoginButton } from "./LoginButton"; +import { UserAvatar } from "./UserAvatar"; + +export function Navbar() { + return ( + + ) +} \ No newline at end of file diff --git a/components/Postcard.tsx b/components/Postcard.tsx new file mode 100644 index 0000000..b472338 --- /dev/null +++ b/components/Postcard.tsx @@ -0,0 +1,18 @@ +import Image from "next/image"; +import { PostcardContext } from "../contexts/postcard"; +import { useDefinedContext } from "../hooks/useDefinedContext"; + +export function Postcard() { + const [postcard, _] = useDefinedContext(PostcardContext) + + console.log(postcard) + + return ( + + ) +} \ No newline at end of file diff --git a/components/UserAvatar.tsx b/components/UserAvatar.tsx new file mode 100644 index 0000000..83597ee --- /dev/null +++ b/components/UserAvatar.tsx @@ -0,0 +1,14 @@ +import { LoginContext } from "../contexts/login"; +import { useDefinedContext } from "../hooks/useDefinedContext"; + +export function UserAvatar() { + const [login, _] = useDefinedContext(LoginContext) + + return login ? + + : + null +} \ No newline at end of file diff --git a/contexts/login.tsx b/contexts/login.tsx new file mode 100644 index 0000000..778f2de --- /dev/null +++ b/contexts/login.tsx @@ -0,0 +1,12 @@ +import { createStateContext } from "../hooks/useStateContext"; +import * as Telegram from "../utils/telegram" + + +/** + * Context containing data about the user's current login status: + * - `null` if the user is not logged in + * - an instance of {@link LoginData} if the user is logged in + * + * Please note that the data containing in this context is not validated, and will need to be validated by the server on every request. + */ +export const LoginContext = createStateContext() diff --git a/contexts/postcard.tsx b/contexts/postcard.tsx new file mode 100644 index 0000000..feec34a --- /dev/null +++ b/contexts/postcard.tsx @@ -0,0 +1,9 @@ +import { createStateContext } from "../hooks/useStateContext"; +import { StaticImageData } from "next/image"; +import * as Telegram from "../utils/telegram" + + +/** + * Context containing data about the website's current postcard, the blurred background image. + */ +export const PostcardContext = createStateContext() diff --git a/hooks/useDefinedContext.tsx b/hooks/useDefinedContext.tsx new file mode 100644 index 0000000..f87d9ba --- /dev/null +++ b/hooks/useDefinedContext.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import { useContext } from "react" + +/** + * Create a new context which is `undefined` outside of all providers. + * + * @returns The created context. + */ +export function createDefinedContext(): React.Context { + return React.createContext(undefined) +} + +/** + * Use a context which is `undefined` outside of its providers, immediately accessing the value if it is available, or throwing an error if it isn't. + * + * @param context The context to use. + * @returns The non-undefined value of the context. + * @throws If the hook is called outside of all providers of the given context, or if the value of the context is `undefined`. + */ +export function useDefinedContext(context: React.Context): T { + const value = useContext(context) + if(value === undefined) { + throw new Error(`Tried to access ${context.displayName} outside of a provider.`) + } + return value +} diff --git a/hooks/useStateContext.tsx b/hooks/useStateContext.tsx new file mode 100644 index 0000000..5565d79 --- /dev/null +++ b/hooks/useStateContext.tsx @@ -0,0 +1,12 @@ +import * as React from "react" +import { useContext } from "react" +import { createDefinedContext } from "./useDefinedContext" + +/** + * Create a new defined context (see {@link createDefinedContext}) containing the tuple returned by {@link React.useState} for the given type. + * + * @returns The created context. + */ +export function createStateContext(): React.Context<[T, React.Dispatch>] | undefined> { + return createDefinedContext<[T, React.Dispatch>]>() +} diff --git a/images/adi-goldstein-Hli3R6LKibo-unsplash.jpg b/images/adi-goldstein-Hli3R6LKibo-unsplash.jpg new file mode 100644 index 0000000..4a2c70e --- /dev/null +++ b/images/adi-goldstein-Hli3R6LKibo-unsplash.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb59cf402cf7a824cd91710c1c7971e5d9b64d7d70d9f521915bdc572d96f51c +size 2582879 diff --git a/pages/_app.tsx b/pages/_app.tsx index 8c15d85..11429ae 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,8 +1,30 @@ import '../styles/globals.css' +import '../styles/nav.css' +import '../styles/telegram.css' +import '../styles/postcard.css' import type { AppProps } from 'next/app' +import { LoginContext } from '../contexts/login' +import { useState } from 'react' +import * as Telegram from "../utils/telegram" +import { Navbar } from '../components/Navbar' +import defaultPostcard from "../images/adi-goldstein-Hli3R6LKibo-unsplash.jpg" +import { Postcard } from '../components/Postcard' +import { PostcardContext } from '../contexts/postcard' +import { StaticImageData } from 'next/image' const App = ({ Component, pageProps }: AppProps): React.ReactNode => { - return + const loginHook = useState(null) + const postcardHook = useState(defaultPostcard) + + return ( + + + + + + + + ) } export default App diff --git a/pages/index.tsx b/pages/index.tsx index 4397b65..c8ae59b 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -3,7 +3,7 @@ import type { NextPage } from 'next' const Page: NextPage = () => { return (
- wrong page bro +
) } diff --git a/pages/party/[slug].tsx b/pages/party/[slug].tsx deleted file mode 100644 index 95a55fa..0000000 --- a/pages/party/[slug].tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { NextPage, NextPageContext } from 'next' -import { useRouter } from 'next/router' -import TelegramLoginButton from 'react-telegram-login' -import * as Telegram from "../../utils/telegram" - -interface PageProps { - userData: Telegram.LoginData | null -} - -export async function getServerSideProps(context: NextPageContext): Promise<{props: PageProps}> { - const props: PageProps = { - userData: null, - } - - const token = process.env.BOT_TOKEN - if(token === undefined) { - throw new Error("BOT_TOKEN is not set on the server-side, cannot perform login validation.") - } - - if(context.query.hash !== undefined) { - const loginResponse = new Telegram.LoginResponse(context.query) - if(loginResponse.isRecent() && loginResponse.isValid(process.env.BOT_TOKEN)) { - props.userData = loginResponse.serialize() - } - } - - return {props} -} - -const Page: NextPage = ({userData}) => { - const router = useRouter() - - return ( -
- {userData ? - JSON.stringify(userData) - : - - } -
- ) -} - -export default Page diff --git a/styles/globals.css b/styles/globals.css index 948b2b0..accc1fd 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -1,4 +1,4 @@ -html { +html, body { padding: 0; margin: 0; } @@ -7,6 +7,14 @@ html { box-sizing: border-box; } +a { + color: #4444ff; +} + +a:visited { + color: #aa44ff; +} + @media (prefers-color-scheme: light) { body { background-color: white; @@ -19,4 +27,4 @@ html { background-color: black; color: white; } -} \ No newline at end of file +} diff --git a/styles/nav.css b/styles/nav.css new file mode 100644 index 0000000..756541f --- /dev/null +++ b/styles/nav.css @@ -0,0 +1,39 @@ +nav { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + padding: 4px; +} + +nav h1 { + font-size: 32px; + margin: 0; +} + +.nav-left, .nav-right { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} + +.nav-telegram-login { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} + +@media (prefers-color-scheme: light) { + nav { + background-color: rgba(255, 255, 255, 0.2); + } +} + +@media (prefers-color-scheme: dark) { + nav { + background-color: rgba(0, 0, 0, 0.2); + } +} diff --git a/styles/postcard.css b/styles/postcard.css new file mode 100644 index 0000000..fbda973 --- /dev/null +++ b/styles/postcard.css @@ -0,0 +1,22 @@ +.postcard { + width: 100vw; + height: 100vh; + object-fit: cover; + + position: absolute; + z-index: -1; + + user-select: none; +} + +@media (prefers-color-scheme: light) { + .postcard { + filter: blur(16px) contrast(25%) brightness(175%); + } +} + +@media (prefers-color-scheme: dark) { + .postcard { + filter: blur(16px) contrast(50%) brightness(50%); + } +} diff --git a/styles/telegram.css b/styles/telegram.css new file mode 100644 index 0000000..593ef33 --- /dev/null +++ b/styles/telegram.css @@ -0,0 +1,26 @@ +/* Taken from the Telegram widget button */ +.btn-telegram { + display: inline-block; + vertical-align: top; + font-weight: 500; + background-color: #54a9eb; + text-overflow: ellipsis; + overflow: hidden; + margin: 0; + border: none; + color: #fff; + cursor: pointer; + + font-size: 16px; + line-height: 20px; + padding: 9px 21px 11px; + border-radius: 20px; +} + +.img-telegram-avatar { + width: 40px; + height: 40px; + border-radius: 20px; + + margin-left: 4px; +} diff --git a/utils/querystring.ts b/utils/querystring.ts new file mode 100644 index 0000000..ba8b3e5 --- /dev/null +++ b/utils/querystring.ts @@ -0,0 +1,21 @@ +import { ParsedUrlQuery } from "querystring" + +/** + * Ensure that the passed {@link ParsedUrlQuery} object has **one and only one** key with the specified name, and get its value. + * + * @param queryObj The object to read the value from. + * @param key The name of the value to read. + * @returns The resulting string. + */ +export function getSingle(queryObj: ParsedUrlQuery, key: string): string { + const value = queryObj[key] + + switch(typeof value) { + case "undefined": + throw new Error(`No "${key}" parameter found in the query string.`) + case "object": + throw new Error(`Multiple "${key}" parameters specified in the query string.`) + case "string": + return value + } +} \ No newline at end of file diff --git a/utils/react-telegram-login.d.ts b/utils/react-telegram-login.d.ts new file mode 100644 index 0000000..a4f1860 --- /dev/null +++ b/utils/react-telegram-login.d.ts @@ -0,0 +1 @@ +declare module "react-telegram-login"; \ No newline at end of file diff --git a/utils/telegram.ts b/utils/telegram.ts index 6f46b07..f4fcb47 100644 --- a/utils/telegram.ts +++ b/utils/telegram.ts @@ -1,22 +1,54 @@ import nodecrypto from "crypto" +import { ParsedUrlQuery } from "querystring" +import * as QueryString from "./querystring" /** - * The validated user data serialized by the server. + * Serializable Telegram user data without any technical information. */ -export interface LoginData { +export interface UserData { id: number first_name: string - last_name: string | null - username: string | null - photo_url: string | null - lang: string | null + last_name?: string + username?: string + photo_url?: string + lang?: string +} + +/** + * Serializable Telegram login data with technical information. + * + * Can be turned in a {@link LoginResponse} for additional methods. + */ +export interface LoginData extends UserData { + auth_date: number + hash: string +} + + +/** + * Create a {@link LoginData} object from a {@link ParsedUrlQuery}. + * + * @param queryObj The source object. + * @returns The created object. + */ +export function queryStringToLoginData(queryObj: ParsedUrlQuery): LoginData { + return { + id: parseInt(QueryString.getSingle(queryObj, "id")), + first_name: QueryString.getSingle(queryObj, "first_name"), + last_name: QueryString.getSingle(queryObj, "last_name"), + username: QueryString.getSingle(queryObj, "username"), + photo_url: QueryString.getSingle(queryObj, "photo_url"), + lang: QueryString.getSingle(queryObj, "lang"), + auth_date: parseInt(QueryString.getSingle(queryObj, "auth_date")), + hash: QueryString.getSingle(queryObj, "hash"), + } } /** * The response sent by Telegram after a login. */ -export class LoginResponse { +export class LoginResponse implements LoginData { id: number first_name: string last_name?: string @@ -31,56 +63,15 @@ export class LoginResponse { * * @param queryObj The query string object, from `context.query`. */ - constructor(queryObj: {[_: string]: string | string[]}) { - if(typeof queryObj.id === "object") { - throw new Error("Multiple `id` parameters specified in the query string, cannot construct LoginResponse.") - } - if(typeof queryObj.first_name === "object") { - throw new Error("Multiple `first_name` parameters specified in the query string, cannot construct LoginResponse.") - } - if(typeof queryObj.last_name === "object") { - throw new Error("Multiple `last_name` parameters specified in the query string, cannot construct LoginResponse.") - } - if(typeof queryObj.username === "object") { - throw new Error("Multiple `username` parameters specified in the query string, cannot construct LoginResponse.") - } - if(typeof queryObj.photo_url === "object") { - throw new Error("Multiple `photo_url` parameters specified in the query string, cannot construct LoginResponse.") - } - if(typeof queryObj.auth_date === "object") { - throw new Error("Multiple `auth_date` parameters specified in the query string, cannot construct LoginResponse.") - } - if(typeof queryObj.hash === "object") { - throw new Error("Multiple `hash` parameters specified in the query string, cannot construct LoginResponse.") - } - if(typeof queryObj.lang === "object") { - throw new Error("Multiple `hash` parameters specified in the query string, cannot construct LoginResponse.") - } - - this.id = parseInt(queryObj.id) - this.first_name = queryObj.first_name - this.last_name = queryObj.last_name - this.username = queryObj.username - this.photo_url = queryObj.photo_url - this.auth_date = parseInt(queryObj.auth_date) - this.hash = queryObj.hash - this.lang = queryObj.lang - } - - /** - * Serialize this response into a {@link LoginData} object, which can be passed to the client by Next.js. - * - * @returns The {@link LoginData} object. - */ - serialize(): LoginData { - return { - id: this.id ?? null, - first_name: this.first_name ?? null, - last_name: this.last_name ?? null, - username: this.username ?? null, - photo_url: this.photo_url ?? null, - lang: this.lang ?? null, - } + constructor(ld: LoginData) { + this.id = ld.id + this.first_name = ld.first_name + this.last_name = ld.last_name + this.username = ld.username + this.photo_url = ld.photo_url + this.auth_date = ld.auth_date + this.hash = ld.hash + this.lang = ld.lang } /** @@ -102,24 +93,26 @@ export class LoginResponse { /** * Check if the `auth_date` of the response is recent: it must be in the past, but within `maxSeconds` from the current date. * - * @param maxSeconds The maximum number of milliseconds that may pass after authentication for the response to be considered valid; defaults to `300000`, 5 minutes. + * @param maxSeconds The maximum number of milliseconds that may pass after authentication for the response to be considered valid; defaults to `864_000_000`, 1 day. * @returns `true` if the response can be considered recent, `false` otherwise. */ - isRecent(maxSeconds: number = 300000): boolean { + isRecent(maxSeconds: number = 864_000_000): boolean { const diff = new Date().getTime() - new Date(this.auth_date * 1000).getTime() return 0 < diff && diff <= maxSeconds } - /** * Calculate the "`hash`" of a Telegram Login using [this procedure](https://core.telegram.org/widgets/login#checking-authorization). * + * _Only works on Node.js, due to usage of the `crypto` module._ + * * @param token The bot token used to validate the signature. * @returns The calculated value of the `hash` {@link LoginResponse} parameter. */ - hmac(token: string): string { - const key = hashToken(token) - const hmac = nodecrypto.createHmac("sha256", key) + hmac(token: string): string { + const hash = nodecrypto.createHash("sha256") + hash.update(token) + const hmac = nodecrypto.createHmac("sha256", hash.digest()) hmac.update(this.stringify()) return hmac.digest("hex") } @@ -127,6 +120,8 @@ export class LoginResponse { /** * Validate a Telegram Login using [this procedure](https://core.telegram.org/widgets/login#checking-authorization). * + * _Only works on Node.js, due to usage of the `crypto` module._ + * * @param token The bot token used to validate the signature. * @returns `true` if the validation is successful, `false` otherwise. */ @@ -136,15 +131,3 @@ export class LoginResponse { return client === server } } - -/** - * Hash a Telegram bot token using SHA-256. - * - * @param token The bot token to hash. - * @returns The hex digest of the hash. - */ -function hashToken(token: string): Buffer { - const hash = nodecrypto.createHash("sha256") - hash.update(token) - return hash.digest() -}