1
Fork 0
mirror of https://github.com/Steffo99/festa.git synced 2024-10-16 15:07:27 +00:00

Make some progress

This commit is contained in:
Steffo 2022-05-24 18:55:21 +02:00
parent fb96441289
commit 6ce9bfe755
Signed by: steffo
GPG key ID: 6965406171929D01
20 changed files with 347 additions and 126 deletions

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
*.png filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text

View file

@ -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 ?
<button onClick={() => setLogin(null)} className="btn-telegram">
Log out
</button>
:
<OriginalTelegramLoginButton
dataOnauth={setLogin}
usePic={false}
{...props}
/>
),
[login]
)
}

27
components/Navbar.tsx Normal file
View file

@ -0,0 +1,27 @@
import Link from "next/link";
import { LoginButton } from "./LoginButton";
import { UserAvatar } from "./UserAvatar";
export function Navbar() {
return (
<nav>
<div className="nav-left">
<h1>
<Link href="/">
Festàpp
</Link>
</h1>
</div>
<div>
nome ovviamente WIP
</div>
<div className="nav-right">
<LoginButton
className="nav-telegram-login"
botName="festaappbot"
/>
<UserAvatar/>
</div>
</nav>
)
}

18
components/Postcard.tsx Normal file
View file

@ -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 (
<img
className="postcard"
src={typeof postcard === "string" ? postcard : postcard.src}
alt=""
draggable={false}
/>
)
}

14
components/UserAvatar.tsx Normal file
View file

@ -0,0 +1,14 @@
import { LoginContext } from "../contexts/login";
import { useDefinedContext } from "../hooks/useDefinedContext";
export function UserAvatar() {
const [login, _] = useDefinedContext(LoginContext)
return login ?
<img
src={login?.photo_url}
className="img-telegram-avatar"
/>
:
null
}

12
contexts/login.tsx Normal file
View file

@ -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<Telegram.LoginData | null>()

9
contexts/postcard.tsx Normal file
View file

@ -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<string | StaticImageData>()

View file

@ -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<T>(): React.Context<T | undefined> {
return React.createContext<T | undefined>(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<T>(context: React.Context<T | undefined>): T {
const value = useContext(context)
if(value === undefined) {
throw new Error(`Tried to access ${context.displayName} outside of a provider.`)
}
return value
}

12
hooks/useStateContext.tsx Normal file
View file

@ -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<T>(): React.Context<[T, React.Dispatch<React.SetStateAction<T>>] | undefined> {
return createDefinedContext<[T, React.Dispatch<React.SetStateAction<T>>]>()
}

BIN
images/adi-goldstein-Hli3R6LKibo-unsplash.jpg (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -1,8 +1,30 @@
import '../styles/globals.css' import '../styles/globals.css'
import '../styles/nav.css'
import '../styles/telegram.css'
import '../styles/postcard.css'
import type { AppProps } from 'next/app' 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 => { const App = ({ Component, pageProps }: AppProps): React.ReactNode => {
return <Component {...pageProps} /> const loginHook = useState<Telegram.LoginData | null>(null)
const postcardHook = useState<string | StaticImageData>(defaultPostcard)
return (
<PostcardContext.Provider value={postcardHook}>
<LoginContext.Provider value={loginHook}>
<Postcard/>
<Navbar/>
<Component {...pageProps} />
</LoginContext.Provider>
</PostcardContext.Provider>
)
} }
export default App export default App

View file

@ -3,7 +3,7 @@ import type { NextPage } from 'next'
const Page: NextPage = () => { const Page: NextPage = () => {
return ( return (
<div> <div>
wrong page bro
</div> </div>
) )
} }

View file

@ -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<PageProps> = ({userData}) => {
const router = useRouter()
return (
<div>
{userData ?
JSON.stringify(userData)
:
<TelegramLoginButton
dataAuthUrl={router.asPath}
botName="festaappbot"
/>
}
</div>
)
}
export default Page

View file

@ -1,4 +1,4 @@
html { html, body {
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
@ -7,6 +7,14 @@ html {
box-sizing: border-box; box-sizing: border-box;
} }
a {
color: #4444ff;
}
a:visited {
color: #aa44ff;
}
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
body { body {
background-color: white; background-color: white;

39
styles/nav.css Normal file
View file

@ -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);
}
}

22
styles/postcard.css Normal file
View file

@ -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%);
}
}

26
styles/telegram.css Normal file
View file

@ -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;
}

21
utils/querystring.ts Normal file
View file

@ -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
}
}

1
utils/react-telegram-login.d.ts vendored Normal file
View file

@ -0,0 +1 @@
declare module "react-telegram-login";

View file

@ -1,22 +1,54 @@
import nodecrypto from "crypto" 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 id: number
first_name: string first_name: string
last_name: string | null last_name?: string
username: string | null username?: string
photo_url: string | null photo_url?: string
lang: string | null 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. * The response sent by Telegram after a login.
*/ */
export class LoginResponse { export class LoginResponse implements LoginData {
id: number id: number
first_name: string first_name: string
last_name?: string last_name?: string
@ -31,56 +63,15 @@ export class LoginResponse {
* *
* @param queryObj The query string object, from `context.query`. * @param queryObj The query string object, from `context.query`.
*/ */
constructor(queryObj: {[_: string]: string | string[]}) { constructor(ld: LoginData) {
if(typeof queryObj.id === "object") { this.id = ld.id
throw new Error("Multiple `id` parameters specified in the query string, cannot construct LoginResponse.") this.first_name = ld.first_name
} this.last_name = ld.last_name
if(typeof queryObj.first_name === "object") { this.username = ld.username
throw new Error("Multiple `first_name` parameters specified in the query string, cannot construct LoginResponse.") this.photo_url = ld.photo_url
} this.auth_date = ld.auth_date
if(typeof queryObj.last_name === "object") { this.hash = ld.hash
throw new Error("Multiple `last_name` parameters specified in the query string, cannot construct LoginResponse.") this.lang = ld.lang
}
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,
}
} }
/** /**
@ -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. * 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. * @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() const diff = new Date().getTime() - new Date(this.auth_date * 1000).getTime()
return 0 < diff && diff <= maxSeconds return 0 < diff && diff <= maxSeconds
} }
/** /**
* Calculate the "`hash`" of a Telegram Login using [this procedure](https://core.telegram.org/widgets/login#checking-authorization). * 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. * @param token The bot token used to validate the signature.
* @returns The calculated value of the `hash` {@link LoginResponse} parameter. * @returns The calculated value of the `hash` {@link LoginResponse} parameter.
*/ */
hmac(token: string): string { hmac(token: string): string {
const key = hashToken(token) const hash = nodecrypto.createHash("sha256")
const hmac = nodecrypto.createHmac("sha256", key) hash.update(token)
const hmac = nodecrypto.createHmac("sha256", hash.digest())
hmac.update(this.stringify()) hmac.update(this.stringify())
return hmac.digest("hex") 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). * 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. * @param token The bot token used to validate the signature.
* @returns `true` if the validation is successful, `false` otherwise. * @returns `true` if the validation is successful, `false` otherwise.
*/ */
@ -136,15 +131,3 @@ export class LoginResponse {
return client === server 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()
}