mirror of
https://github.com/Steffo99/festa.git
synced 2025-03-11 11:17:45 +00:00
Make some progress
This commit is contained in:
parent
fb96441289
commit
6ce9bfe755
20 changed files with 347 additions and 126 deletions
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
*.png filter=lfs diff=lfs merge=lfs -text
|
||||
*.jpg filter=lfs diff=lfs merge=lfs -text
|
23
components/LoginButton.tsx
Normal file
23
components/LoginButton.tsx
Normal 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
27
components/Navbar.tsx
Normal 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
18
components/Postcard.tsx
Normal 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
14
components/UserAvatar.tsx
Normal 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
12
contexts/login.tsx
Normal 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
9
contexts/postcard.tsx
Normal 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>()
|
26
hooks/useDefinedContext.tsx
Normal file
26
hooks/useDefinedContext.tsx
Normal 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
12
hooks/useStateContext.tsx
Normal 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
BIN
images/adi-goldstein-Hli3R6LKibo-unsplash.jpg
(Stored with Git LFS)
Normal file
Binary file not shown.
|
@ -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 <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
|
||||
|
|
|
@ -3,7 +3,7 @@ import type { NextPage } from 'next'
|
|||
const Page: NextPage = () => {
|
||||
return (
|
||||
<div>
|
||||
wrong page bro
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
39
styles/nav.css
Normal file
39
styles/nav.css
Normal 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
22
styles/postcard.css
Normal 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
26
styles/telegram.css
Normal 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
21
utils/querystring.ts
Normal 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
1
utils/react-telegram-login.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
declare module "react-telegram-login";
|
|
@ -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()
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue