mirror of
https://github.com/Steffo99/festa.git
synced 2024-12-22 22:54:22 +00:00
Rearrange things
This commit is contained in:
parent
6673b9fd15
commit
e8086a7b5f
29 changed files with 577 additions and 588 deletions
17
.vscode/launch.json
vendored
17
.vscode/launch.json
vendored
|
@ -9,13 +9,26 @@
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"runtimeArgs": [
|
"runtimeArgs": [
|
||||||
"run",
|
"run",
|
||||||
"app:dev",
|
"dev",
|
||||||
],
|
],
|
||||||
"runtimeExecutable": "yarn",
|
"runtimeExecutable": "yarn",
|
||||||
"skipFiles": [
|
"skipFiles": [
|
||||||
"<node_internals>/**"
|
"<node_internals>/**"
|
||||||
],
|
],
|
||||||
"type": "node"
|
"type": "node"
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
"name": "Prisma Studio",
|
||||||
|
"request": "launch",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"run",
|
||||||
|
"studio",
|
||||||
|
],
|
||||||
|
"runtimeExecutable": "yarn",
|
||||||
|
"skipFiles": [
|
||||||
|
"<node_internals>/**"
|
||||||
|
],
|
||||||
|
"type": "node"
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
13
components/Avatar.tsx
Normal file
13
components/Avatar.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import Image, { ImageProps } from "next/image";
|
||||||
|
import { HTMLProps } from "react";
|
||||||
|
import classNames from "classnames"
|
||||||
|
|
||||||
|
export function Avatar(props: ImageProps) {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
{...props}
|
||||||
|
className={classNames(props.className, "avatar")}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,28 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
export interface InputSlug extends React.HTMLProps<HTMLInputElement> {
|
|
||||||
onSlugChange?: (val: string) => void,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InputSlug(props: InputSlug) {
|
|
||||||
const [text, setText] = React.useState("")
|
|
||||||
|
|
||||||
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
props.onChange?.(event)
|
|
||||||
|
|
||||||
let slug = event.target.value.toLowerCase().replaceAll(/[^a-z0-9]/g, "-")
|
|
||||||
props.onSlugChange?.(slug)
|
|
||||||
setText(slug)
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={text}
|
|
||||||
onChange={handleChange}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -6,7 +6,7 @@ export function Postcard() {
|
||||||
const [postcard, _] = useDefinedContext(PostcardContext)
|
const [postcard, _] = useDefinedContext(PostcardContext)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<Image
|
||||||
className="postcard"
|
className="postcard"
|
||||||
src={typeof postcard === "string" ? postcard : postcard.src}
|
src={typeof postcard === "string" ? postcard : postcard.src}
|
||||||
alt=""
|
alt=""
|
||||||
|
|
17
components/SectionLoginTelegram.tsx
Normal file
17
components/SectionLoginTelegram.tsx
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { useTranslation } from "next-i18next"
|
||||||
|
import { useCallback, useState } from "react"
|
||||||
|
import { LoginContext } from "../contexts/login"
|
||||||
|
import { useDefinedContext } from "../utils/definedContext"
|
||||||
|
import { TelegramLoginButton } from "./TelegramLoginButton"
|
||||||
|
import { default as axios } from "axios"
|
||||||
|
import { AuthenticatedUserData, TelegramLoginData } from "../types/user"
|
||||||
|
|
||||||
|
|
||||||
|
export function SectionLoginTelegram() {
|
||||||
|
const { t } = useTranslation("common")
|
||||||
|
const [login, setLogin] = useDefinedContext(LoginContext)
|
||||||
|
const [error, setError] = useState<any>(null)
|
||||||
|
|
||||||
|
return <section>
|
||||||
|
</section>
|
||||||
|
}
|
|
@ -1,21 +0,0 @@
|
||||||
import { LoginContext } from "../contexts/login";
|
|
||||||
import { useDefinedContext } from "../utils/definedContext";
|
|
||||||
import { UserData } from "../utils/telegram";
|
|
||||||
|
|
||||||
|
|
||||||
export interface TelegramAvatarProps {
|
|
||||||
u: UserData
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function TelegramAvatar({u}: TelegramAvatarProps) {
|
|
||||||
const [login, _] = useDefinedContext(LoginContext)
|
|
||||||
|
|
||||||
return login ?
|
|
||||||
<img
|
|
||||||
src={u.photo_url}
|
|
||||||
className="avatar-telegram-inline"
|
|
||||||
/>
|
|
||||||
:
|
|
||||||
null
|
|
||||||
}
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { UserData } from "../utils/telegram";
|
import { TelegramLoginData } from "../types/user";
|
||||||
import { TelegramAvatar } from "./TelegramAvatar";
|
import { TelegramAvatar } from "./TelegramAvatar";
|
||||||
|
|
||||||
interface TelegramUserLinkProps {
|
interface Props {
|
||||||
u: UserData
|
u: TelegramLoginData
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TelegramUser({u}: TelegramUserLinkProps) {
|
export function TelegramUserInline({u}: Props) {
|
||||||
|
|
||||||
if(u.username) return (
|
if(u.username) return (
|
||||||
<a href={`https://t.me/${u.username}`}>
|
<a href={`https://t.me/${u.username}`}>
|
|
@ -1,41 +0,0 @@
|
||||||
import { useTranslation } from "next-i18next"
|
|
||||||
import { useCallback } from "react"
|
|
||||||
import { LoginContext } from "../contexts/login"
|
|
||||||
import { useDefinedContext } from "../utils/definedContext"
|
|
||||||
import { TelegramLoginButton } from "./TelegramLoginButton"
|
|
||||||
import * as Telegram from "../utils/telegram"
|
|
||||||
import axios from "axios"
|
|
||||||
|
|
||||||
|
|
||||||
export function TutorialTelegramLogin() {
|
|
||||||
const { t } = useTranslation("common")
|
|
||||||
const [login, setLogin] = useDefinedContext(LoginContext)
|
|
||||||
|
|
||||||
const onLogin = useCallback(
|
|
||||||
async (data: Telegram.LoginData) => {
|
|
||||||
console.debug("[Telegram] Logged in successfully, now forwarding to the server...")
|
|
||||||
const response = await axios.post("/api/login/telegram", data)
|
|
||||||
console.info(response)
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!login) {
|
|
||||||
return <>
|
|
||||||
<div>
|
|
||||||
{t("introTelegramLogin")}
|
|
||||||
</div>
|
|
||||||
<TelegramLoginButton
|
|
||||||
dataOnauth={onLogin}
|
|
||||||
botName={process.env.NEXT_PUBLIC_TELEGRAM_USERNAME}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return <>
|
|
||||||
<div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { useStorageState } from "react-storage-hooks";
|
import { FestaLoginData } from "../types/user";
|
||||||
import { createStateContext } from "../utils/stateContext";
|
import { createStateContext } from "../utils/stateContext";
|
||||||
import * as Telegram from "../utils/telegram"
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -10,4 +9,4 @@ import * as Telegram from "../utils/telegram"
|
||||||
*
|
*
|
||||||
* Please note that the data containing in this context is not validated, and will need to be validated by the server on every request.
|
* 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>()
|
export const LoginContext = createStateContext<FestaLoginData | null>()
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { createStateContext } from "../utils/stateContext";
|
import { createStateContext } from "../utils/stateContext";
|
||||||
import { StaticImageData } from "next/image";
|
import { StaticImageData } from "next/image";
|
||||||
import * as Telegram from "../utils/telegram"
|
import * as Telegram from "../utils/TelegramUserDataClass"
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
40
hooks/useStoredLogin.ts
Normal file
40
hooks/useStoredLogin.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { FestaLoginData } from "../types/user"
|
||||||
|
|
||||||
|
export function useStoredLogin(setLogin: React.Dispatch<React.SetStateAction<FestaLoginData | null>>): void {
|
||||||
|
const thatStorageOverThere = typeof localStorage !== "undefined" ? localStorage : undefined
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => {
|
||||||
|
if(thatStorageOverThere === undefined) return
|
||||||
|
|
||||||
|
const raw = localStorage.getItem("login")
|
||||||
|
if(raw === null) {
|
||||||
|
console.debug("No stored login data found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var parsed = JSON.parse(raw)
|
||||||
|
}
|
||||||
|
catch(e) {
|
||||||
|
console.error("Failed to parse stored login data as JSON.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
...parsed,
|
||||||
|
expiresAt: new Date(parsed.expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if(new Date().getTime() >= data.expiresAt.getTime()) {
|
||||||
|
console.debug("Stored login data has expired, clearing...")
|
||||||
|
thatStorageOverThere.removeItem("login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogin(data)
|
||||||
|
},
|
||||||
|
[thatStorageOverThere]
|
||||||
|
)
|
||||||
|
}
|
26
hooks/useTelegramToFestaCallback.ts
Normal file
26
hooks/useTelegramToFestaCallback.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import { default as axios, AxiosError } from "axios"
|
||||||
|
import { useCallback, Dispatch, SetStateAction } from "react"
|
||||||
|
import { ApiError, ApiResult } from "../types/api"
|
||||||
|
import { FestaLoginData, TelegramLoginData } from "../types/user"
|
||||||
|
|
||||||
|
|
||||||
|
export function useTelegramToFestaCallback(setLogin: Dispatch<SetStateAction<FestaLoginData | null>>, setError: Dispatch<SetStateAction<ApiError | null | undefined>>): (data: TelegramLoginData) => Promise<void> {
|
||||||
|
return useCallback(
|
||||||
|
async (data: TelegramLoginData) => {
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
var response = await axios.post<ApiResult<FestaLoginData>>("/api/login?provider=telegram", data)
|
||||||
|
}
|
||||||
|
catch(e) {
|
||||||
|
const axe = e as AxiosError
|
||||||
|
setError(axe?.response?.data as ApiError | undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogin(response.data as FestaLoginData)
|
||||||
|
localStorage.setItem("login", JSON.stringify(response.data))
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
}
|
13
package.json
13
package.json
|
@ -1,14 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "festa",
|
"name": "@steffo/festa",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"app:dev": "next dev",
|
"dev": "dotenv -e .env.local prisma db push && dotenv -e .env.local prisma generate && dotenv -e .env.local next dev",
|
||||||
"app:build": "next build",
|
"build": "next build",
|
||||||
"app:start": "next start",
|
"start": "next start",
|
||||||
"app:lint": "next lint",
|
"lint": "next lint",
|
||||||
"db:dev": "dotenv -e .env.local prisma db push --force-reset",
|
"studio": "dotenv -e .env.local prisma studio"
|
||||||
"db:generate": "dotenv -e .env.local prisma generate"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "3.14.0",
|
"@prisma/client": "3.14.0",
|
||||||
|
|
|
@ -2,42 +2,19 @@ import '../styles/globals.css'
|
||||||
import type { AppProps } from 'next/app'
|
import type { AppProps } from 'next/app'
|
||||||
import { LoginContext } from '../contexts/login'
|
import { LoginContext } from '../contexts/login'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import * as Telegram from "../utils/telegram"
|
|
||||||
import defaultPostcard from "../public/postcards/adi-goldstein-Hli3R6LKibo-unsplash.jpg"
|
import defaultPostcard from "../public/postcards/adi-goldstein-Hli3R6LKibo-unsplash.jpg"
|
||||||
import { Postcard } from '../components/Postcard'
|
import { Postcard } from '../components/Postcard'
|
||||||
import { PostcardContext } from '../contexts/postcard'
|
import { PostcardContext } from '../contexts/postcard'
|
||||||
import { StaticImageData } from 'next/image'
|
import { StaticImageData } from 'next/image'
|
||||||
import { appWithTranslation } from 'next-i18next'
|
import { appWithTranslation } from 'next-i18next'
|
||||||
import { useStorageState } from 'react-storage-hooks'
|
import { FestaLoginData } from '../types/user'
|
||||||
|
import {useStoredLogin} from "../hooks/useStoredLogin"
|
||||||
|
|
||||||
const dummyStorage = {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {},
|
|
||||||
removeItem: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const App = ({ Component, pageProps }: AppProps): JSX.Element => {
|
const App = ({ Component, pageProps }: AppProps): JSX.Element => {
|
||||||
const [login, setLogin] = useState<Telegram.LoginData | null>(null)
|
const [login, setLogin] = useState<FestaLoginData | null>(null)
|
||||||
const [postcard, setPostcard] = useState<string | StaticImageData>(defaultPostcard)
|
const [postcard, setPostcard] = useState<string | StaticImageData>(defaultPostcard)
|
||||||
|
useStoredLogin(setLogin)
|
||||||
// Ha ha ha. Fooled you again, silly SSR!
|
|
||||||
const thatStorageOverThere = typeof sessionStorage !== "undefined" ? sessionStorage : undefined
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => {
|
|
||||||
if(thatStorageOverThere === undefined) return
|
|
||||||
|
|
||||||
const raw = sessionStorage.getItem("login")
|
|
||||||
if(raw === null) return
|
|
||||||
|
|
||||||
const parsed = JSON.parse(raw) as Telegram.LoginData
|
|
||||||
const response = new Telegram.LoginResponse(parsed)
|
|
||||||
if(!response.isRecent) return
|
|
||||||
|
|
||||||
setLogin(parsed)
|
|
||||||
},
|
|
||||||
[thatStorageOverThere]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PostcardContext.Provider value={[postcard, setPostcard]}>
|
<PostcardContext.Provider value={[postcard, setPostcard]}>
|
||||||
|
|
89
pages/api/login/index.ts
Normal file
89
pages/api/login/index.ts
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
import { NextApiRequest, NextApiResponse } from "next"
|
||||||
|
import { prisma } from "../../../utils/prismaClient"
|
||||||
|
import { TelegramUserDataClass } from "../../../utils/TelegramUserDataClass"
|
||||||
|
import { default as cryptoRandomString } from "crypto-random-string"
|
||||||
|
import { ApiResult } from "../../../types/api"
|
||||||
|
import { Token, User } from "@prisma/client"
|
||||||
|
import { FestaLoginData } from "../../../types/user"
|
||||||
|
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse<ApiResult<FestaLoginData>>) {
|
||||||
|
switch (req.method) {
|
||||||
|
case "POST":
|
||||||
|
switch(req.query.provider) {
|
||||||
|
case "telegram":
|
||||||
|
return await loginTelegram(req, res)
|
||||||
|
default:
|
||||||
|
return res.status(400).json({ error: "Unknown login provider" })
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return res.status(405).json({ error: "Invalid method" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function loginTelegram(req: NextApiRequest, res: NextApiResponse<ApiResult<FestaLoginData>>) {
|
||||||
|
const botToken = process.env.TELEGRAM_TOKEN
|
||||||
|
if (!botToken) {
|
||||||
|
return res.status(503).json({ error: "`TELEGRAM_TOKEN` was not set up" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashExpirationMs = parseInt(process.env.TELEGRAM_HASH_EXPIRATION_MS!)
|
||||||
|
if (!hashExpirationMs) {
|
||||||
|
return res.status(503).json({ error: "`TELEGRAM_HASH_EXPIRATION_MS` was not set up" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenExpirationMs = parseInt(process.env.FESTA_TOKEN_EXPIRATION_MS!) // Wrong typing?
|
||||||
|
if (!tokenExpirationMs) {
|
||||||
|
return res.status(503).json({ error: "`FESTA_TOKEN_EXPIRATION_MS` was not set up" })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var userData: TelegramUserDataClass = new TelegramUserDataClass(req.body)
|
||||||
|
}
|
||||||
|
catch (_) {
|
||||||
|
return res.status(422).json({ error: "Malformed data" })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userData.isRecent(hashExpirationMs)) {
|
||||||
|
return res.status(408).json({ error: "Telegram login data is not recent" })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userData.isValid(botToken)) {
|
||||||
|
return res.status(401).json({ error: "Telegram login data has been tampered" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountTelegram = await prisma.accountTelegram.upsert({
|
||||||
|
where: {
|
||||||
|
telegramId: userData.id
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
...userData.toDatabase(),
|
||||||
|
user: {
|
||||||
|
create: {
|
||||||
|
displayName: userData.toTelegramName(),
|
||||||
|
displayAvatarURL: userData.photoUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
...userData.toDatabase(),
|
||||||
|
user: {
|
||||||
|
update: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const token = await prisma.token.create({
|
||||||
|
data: {
|
||||||
|
userId: accountTelegram.userId,
|
||||||
|
token: cryptoRandomString({ length: 16, type: "base64" }),
|
||||||
|
expiresAt: new Date(userData.authDate.getTime() + tokenExpirationMs)
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.status(200).json(token)
|
||||||
|
}
|
|
@ -1,79 +0,0 @@
|
||||||
import { NextApiRequest, NextApiResponse } from "next"
|
|
||||||
import { prisma } from "../../../utils/prismaClient"
|
|
||||||
import * as Telegram from "../../../utils/telegram"
|
|
||||||
import {default as cryptoRandomString} from "crypto-random-string"
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
|
||||||
switch(req.method) {
|
|
||||||
case "POST":
|
|
||||||
const token = process.env.TELEGRAM_TOKEN
|
|
||||||
const validity_ms = parseInt(process.env.FESTA_TOKEN_VALIDITY_MS!) // Wrong typing?
|
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
if(!token) {
|
|
||||||
return res.status(503).json({error: "`TELEGRAM_TOKEN` was not set up"})
|
|
||||||
}
|
|
||||||
if(!validity_ms) {
|
|
||||||
return res.status(503).json({error: "`FESTA_TOKEN_VALIDITY_MS` was not set up"})
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
var lr: Telegram.LoginResponse = new Telegram.LoginResponse(req.body)
|
|
||||||
}
|
|
||||||
catch(_) {
|
|
||||||
return res.status(422).json({error: "Malformed data"})
|
|
||||||
}
|
|
||||||
|
|
||||||
if(!lr.isRecent()) {
|
|
||||||
// Not sure?
|
|
||||||
return res.status(408).json({error: "Telegram login data is not recent"})
|
|
||||||
}
|
|
||||||
|
|
||||||
if(!lr.isValid(token)) {
|
|
||||||
return res.status(401).json({error: "Telegram login data has been tampered"})
|
|
||||||
}
|
|
||||||
|
|
||||||
prisma.user.upsert({
|
|
||||||
where: {
|
|
||||||
id: lr.id,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
id: lr.id,
|
|
||||||
firstName: lr.first_name,
|
|
||||||
lastName: lr.last_name,
|
|
||||||
username: lr.username,
|
|
||||||
photoUrl: lr.photo_url,
|
|
||||||
lastAuthDate: now,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
id: lr.id,
|
|
||||||
firstName: lr.first_name,
|
|
||||||
lastName: lr.last_name,
|
|
||||||
username: lr.username,
|
|
||||||
photoUrl: lr.photo_url,
|
|
||||||
lastAuthDate: now,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const tokenString = cryptoRandomString({length: 16, type: "base64"})
|
|
||||||
const tokenExpiration = new Date(+ now + validity_ms)
|
|
||||||
|
|
||||||
prisma.token.create({
|
|
||||||
data: {
|
|
||||||
userId: lr.id,
|
|
||||||
token: tokenString,
|
|
||||||
expiresAt: tokenExpiration,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
token: tokenString,
|
|
||||||
expiresAt: tokenExpiration.toISOString(),
|
|
||||||
})
|
|
||||||
|
|
||||||
default:
|
|
||||||
return res.status(405).json({error: "Invalid method"})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,10 +1,13 @@
|
||||||
import type { NextPage, NextPageContext } from 'next'
|
import { NextPageContext } from 'next'
|
||||||
import { useTranslation } from 'next-i18next'
|
import { useTranslation } from 'next-i18next'
|
||||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
|
||||||
import { Intro } from '../components/Intro';
|
import { useState } from 'react';
|
||||||
import { TutorialTelegramLogin } from '../components/TutorialTelegramLogin';
|
|
||||||
import { LoginContext } from '../contexts/login';
|
import { LoginContext } from '../contexts/login';
|
||||||
import { useDefinedContext } from '../utils/definedContext';
|
import { useDefinedContext } from '../utils/definedContext';
|
||||||
|
import { ApiError } from '../types/api';
|
||||||
|
import { TelegramLoginButton } from "../components/TelegramLoginButton"
|
||||||
|
import { useTelegramToFestaCallback } from '../hooks/useTelegramToFestaCallback';
|
||||||
|
|
||||||
|
|
||||||
export async function getStaticProps(context: NextPageContext) {
|
export async function getStaticProps(context: NextPageContext) {
|
||||||
return {
|
return {
|
||||||
|
@ -14,31 +17,24 @@ export async function getStaticProps(context: NextPageContext) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const Page: NextPage = () => {
|
|
||||||
|
export default function PageIndex() {
|
||||||
const { t } = useTranslation("common")
|
const { t } = useTranslation("common")
|
||||||
const [login, setLogin] = useDefinedContext(LoginContext)
|
const [login, setLogin] = useDefinedContext(LoginContext)
|
||||||
|
const [error, setError] = useState<ApiError | null | undefined>(null)
|
||||||
|
|
||||||
|
const onLogin = useTelegramToFestaCallback(setLogin, setError)
|
||||||
|
|
||||||
if (!login) {
|
|
||||||
return (
|
return (
|
||||||
|
login ?
|
||||||
<main className="page-index">
|
<main className="page-index">
|
||||||
<hgroup>
|
|
||||||
<h1>
|
<h1>
|
||||||
{t("siteTitle")}
|
{t("siteTitle")}
|
||||||
</h1>
|
</h1>
|
||||||
<h2>
|
|
||||||
{t("siteSubtitle")}
|
|
||||||
</h2>
|
|
||||||
</hgroup>
|
|
||||||
<div>
|
|
||||||
<TutorialTelegramLogin />
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
)
|
:
|
||||||
}
|
<main id="page-hero" className="page">
|
||||||
|
<hgroup className="hgroup-hero">
|
||||||
return (
|
|
||||||
<main className="page-index">
|
|
||||||
<hgroup>
|
|
||||||
<h1>
|
<h1>
|
||||||
{t("siteTitle")}
|
{t("siteTitle")}
|
||||||
</h1>
|
</h1>
|
||||||
|
@ -46,8 +42,29 @@ const Page: NextPage = () => {
|
||||||
{t("siteSubtitle")}
|
{t("siteSubtitle")}
|
||||||
</h2>
|
</h2>
|
||||||
</hgroup>
|
</hgroup>
|
||||||
|
{
|
||||||
|
error ?
|
||||||
|
<div className="negative">
|
||||||
|
<p>
|
||||||
|
{t("telegramLoginError")}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<code>
|
||||||
|
{JSON.stringify(error)}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
{t("telegramLoginDescription")}
|
||||||
|
</p>
|
||||||
|
<TelegramLoginButton
|
||||||
|
dataOnauth={onLogin}
|
||||||
|
botName={process.env.NEXT_PUBLIC_TELEGRAM_USERNAME}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Page
|
|
||||||
|
|
|
@ -1,145 +1,72 @@
|
||||||
// This is your Prisma schema file,
|
// Prisma Schema file
|
||||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
// https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
generator client {
|
|
||||||
provider = "prisma-client-js"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Use the PostgreSQL database at the URL specified via the DATABASE_URL environment variable.
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An event is the representation of a gathering of people in a certain place at a certain time.
|
// Generate @prisma/client for use in JavaScript and TypeScript.
|
||||||
model Event {
|
generator client {
|
||||||
id Int @id @default(autoincrement())
|
provider = "prisma-client-js"
|
||||||
//
|
|
||||||
slug String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
viewPassword String?
|
|
||||||
joinPassword String?
|
|
||||||
//
|
|
||||||
name String
|
|
||||||
description String
|
|
||||||
postcard String?
|
|
||||||
startTime DateTime?
|
|
||||||
endTime DateTime?
|
|
||||||
location String?
|
|
||||||
//
|
|
||||||
partecipants Partecipant[]
|
|
||||||
neededItems Item[]
|
|
||||||
vehicles Vehicle[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A user is a person who is using the Festa website, who logged in via Telegram.
|
/// A person who is using the Festa website, who may have logged in via various account types.
|
||||||
model User {
|
model User {
|
||||||
id BigInt @id
|
/// A unique id for the user on the Festa website.
|
||||||
firstName String
|
id String @id @default(uuid()) @db.Uuid
|
||||||
lastName String?
|
/// The power level of the user on the Festa website.
|
||||||
username String?
|
powerLevel PowerLevel @default(USER)
|
||||||
photoUrl String?
|
/// The displayed name of the user.
|
||||||
lastAuthDate DateTime
|
displayName String
|
||||||
|
/// The URL of the displayed avatar of the user.
|
||||||
|
displayAvatarURL String?
|
||||||
|
/// The tokens the user can use to login.
|
||||||
tokens Token[]
|
tokens Token[]
|
||||||
|
/// The Telegram accounts associated with this user.
|
||||||
|
accountsTelegram AccountTelegram[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Token {
|
/// A possible powerLevel value for an {@link User}.
|
||||||
userId BigInt
|
enum PowerLevel {
|
||||||
|
/// The user has no special privileges.
|
||||||
|
USER
|
||||||
|
/// The user can override any permission check.
|
||||||
|
SUPERUSER
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A container for user data associated with a single [Telegram](https://telegram.org/).
|
||||||
|
model AccountTelegram {
|
||||||
|
/// The id of the {@link User} associated with this account.
|
||||||
|
userId String @db.Uuid
|
||||||
|
/// The {@link User} associated with this account.
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
/// The Telegram id of the account.
|
||||||
|
telegramId Int @id
|
||||||
|
/// The Telegram first name of the account. Always present.
|
||||||
|
firstName String
|
||||||
|
/// The Telegram last name of the account. May be omitted.
|
||||||
|
lastName String?
|
||||||
|
/// The username of the account. May not be present if the account has not opted in to public discovery on Telegram.
|
||||||
|
/// If set, allows the user to be contacted via `https://t.me/USERNAME`.
|
||||||
|
username String?
|
||||||
|
/// The URL where the user's avatar is accessible at.
|
||||||
|
photoUrl String?
|
||||||
|
/// The locale of the user. Its presence is VERY inconsistent, don't make any assumption based on that.
|
||||||
|
lang String?
|
||||||
|
/// The last time the account was updated.
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A token that can be used to authenticate to the API as an {@link User}.
|
||||||
|
model Token {
|
||||||
|
/// The id of the user that the token allows to login as.
|
||||||
|
userId String @db.Uuid
|
||||||
|
/// The user that the token allows to login as.
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
/// The token itself, a string.
|
||||||
token String @id
|
token String @id
|
||||||
|
/// The datetime after which the token should cease to be valid for authentication.
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A partecipant is a person who may or may not partecipate to the event.
|
|
||||||
model Partecipant {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
eventId Int
|
|
||||||
event Event @relation(fields: [eventId], references: [id])
|
|
||||||
//
|
|
||||||
name String
|
|
||||||
email String
|
|
||||||
//
|
|
||||||
means PartecipationMeans
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
joinedAt DateTime?
|
|
||||||
//
|
|
||||||
answer PartecipationAnswer
|
|
||||||
shouldBring Item[]
|
|
||||||
drives Vehicle[] @relation("VehicleDrive")
|
|
||||||
rides Vehicle[] @relation("VehicleRide")
|
|
||||||
expenses Transaction[] @relation("TransactionFrom")
|
|
||||||
income Transaction[] @relation("TransactionTo")
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PartecipationMeans {
|
|
||||||
CREATOR
|
|
||||||
INVITED
|
|
||||||
ACCEPTED
|
|
||||||
JOINED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PartecipationAnswer {
|
|
||||||
HOST
|
|
||||||
YES
|
|
||||||
MAYBE
|
|
||||||
NO
|
|
||||||
PENDING
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An item which should be bought and brought by somebody to the event.
|
|
||||||
model Item {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
eventId Int
|
|
||||||
event Event @relation(fields: [eventId], references: [id])
|
|
||||||
//
|
|
||||||
quantity Int
|
|
||||||
name String
|
|
||||||
purchased Boolean @default(false)
|
|
||||||
//
|
|
||||||
assignedId Int?
|
|
||||||
assigned Partecipant? @relation(fields: [assignedId], references: [id])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A vehicle which is being used to transport people from and to the event.
|
|
||||||
model Vehicle {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
eventId Int
|
|
||||||
event Event @relation(fields: [eventId], references: [id])
|
|
||||||
//
|
|
||||||
driverId Int
|
|
||||||
driver Partecipant @relation("VehicleDrive", fields: [driverId], references: [id])
|
|
||||||
riders Partecipant[] @relation("VehicleRide")
|
|
||||||
//
|
|
||||||
type VehicleType @default(CAR)
|
|
||||||
slots Int @default(4)
|
|
||||||
password String?
|
|
||||||
//
|
|
||||||
voyage VoyageType
|
|
||||||
location String
|
|
||||||
departureAt DateTime
|
|
||||||
arrivalAt DateTime
|
|
||||||
}
|
|
||||||
|
|
||||||
enum VehicleType {
|
|
||||||
CAR
|
|
||||||
OTHER
|
|
||||||
}
|
|
||||||
|
|
||||||
enum VoyageType {
|
|
||||||
TO
|
|
||||||
FROM
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A monetary transaction related to the event.
|
|
||||||
model Transaction {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
//
|
|
||||||
fromId Int?
|
|
||||||
from Partecipant? @relation("TransactionFrom", fields: [fromId], references: [id])
|
|
||||||
toId Int?
|
|
||||||
to Partecipant? @relation("TransactionTo", fields: [toId], references: [id])
|
|
||||||
//
|
|
||||||
amount Decimal
|
|
||||||
currency String
|
|
||||||
reason String
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,9 +1,6 @@
|
||||||
{
|
{
|
||||||
"siteTitle": "Festa",
|
"siteTitle": "Festa",
|
||||||
"siteSubtitle": "Organizza con facilità il tuo evento!",
|
"siteSubtitle": "Organizza con facilità il tuo evento!",
|
||||||
"introTelegramLogin": "Per prima cosa, effettua il login con Telegram.",
|
"telegramLoginDescription": "Per iniziare, effettua il login con Telegram.",
|
||||||
"introTelegramLoggedIn": "Sei connesso come <1/>!",
|
"logOutPrompt": "Non sei tu?"
|
||||||
"introTelegramLogout": "Non sei tu?",
|
|
||||||
"introCreateEvent": "Dai un nome al tuo primo evento:",
|
|
||||||
"introCreateEventSlugPlaceholder": "nome-evento-2022"
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,10 @@
|
||||||
--negative: #880000;
|
--negative: #880000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.postcard {
|
||||||
|
filter: blur(16px) contrast(25%) brightness(175%);
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark theme */
|
/* Dark theme */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
body {
|
body {
|
||||||
|
@ -30,4 +34,8 @@
|
||||||
--positive: #88ff88;
|
--positive: #88ff88;
|
||||||
--negative: #ff8888;
|
--negative: #ff8888;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.postcard {
|
||||||
|
filter: blur(16px) contrast(50%) brightness(50%);
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,3 +1,6 @@
|
||||||
|
@import "color-schemes.css";
|
||||||
|
@import "page-tweaks.css";
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
@ -17,10 +20,15 @@ body {
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
text-shadow: 1px 1px 1px var(--background);
|
text-shadow: 1px 1px 1px var(--background);
|
||||||
|
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
hgroup > * {
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
margin: 0;
|
|
||||||
text-shadow: 2px 2px 2px var(--background);
|
text-shadow: 2px 2px 2px var(--background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,6 +44,14 @@ a:active {
|
||||||
color: var(--anchor-active);
|
color: var(--anchor-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.positive {
|
||||||
|
color: var(--positive);
|
||||||
|
}
|
||||||
|
|
||||||
|
.negative {
|
||||||
|
color: var(--negative);
|
||||||
|
}
|
||||||
|
|
||||||
input, button {
|
input, button {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
margin: 2px 4px;
|
margin: 2px 4px;
|
||||||
|
@ -65,22 +81,35 @@ input[type="submit"]:active, button:active {
|
||||||
border-style: inset;
|
border-style: inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-square {
|
.square-40 {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-positive {
|
input.positive, button.positive {
|
||||||
border-color: var(--positive);
|
border-color: var(--positive);
|
||||||
color: var(--positive);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-negative {
|
input.negative, button.negative {
|
||||||
border-color: var(--negative);
|
border-color: var(--negative);
|
||||||
color: var(--negative);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@import "index.css";
|
.page {
|
||||||
@import "postcard.css";
|
min-height: 100vh;
|
||||||
@import "telegram.css";
|
}
|
||||||
@import "variables.css";
|
|
||||||
|
.container-btn-telegram > div {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.postcard {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
object-fit: cover;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
|
@ -1,30 +0,0 @@
|
||||||
.page-index {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
justify-content: space-evenly;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 639px) {
|
|
||||||
.page-index h1 {
|
|
||||||
font-size: 5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-index h2 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (min-width: 640px) {
|
|
||||||
.page-index h1 {
|
|
||||||
font-size: 10rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-index h2 {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
44
styles/page-tweaks.css
Normal file
44
styles/page-tweaks.css
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
#page-hero {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
justify-content: space-evenly;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page-hero h1 {
|
||||||
|
font-size: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page-hero h2 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
#page-hero h1 {
|
||||||
|
font-size: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page-hero h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#page-events {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
justify-content: space-evenly;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page-create {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
justify-content: space-evenly;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
|
@ -1,23 +0,0 @@
|
||||||
.postcard {
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
object-fit: cover;
|
|
||||||
|
|
||||||
position: absolute;
|
|
||||||
z-index: -1;
|
|
||||||
|
|
||||||
user-select: none;
|
|
||||||
pointer-events: 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%);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
.container-btn-telegram > div {
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
5
types/api.ts
Normal file
5
types/api.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export type ApiError = {
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiResult<T> = ApiError | T
|
20
types/user.ts
Normal file
20
types/user.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { Token, User } from "@prisma/client"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializable Telegram login data with technical information.
|
||||||
|
*/
|
||||||
|
export type TelegramLoginData = {
|
||||||
|
id: number
|
||||||
|
first_name: string
|
||||||
|
last_name?: string
|
||||||
|
username?: string
|
||||||
|
photo_url?: string
|
||||||
|
lang?: string
|
||||||
|
auth_date: number
|
||||||
|
hash: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login data for a specific Festa user.
|
||||||
|
*/
|
||||||
|
export type FestaLoginData = Token & {user: User}
|
142
utils/TelegramUserDataClass.ts
Normal file
142
utils/TelegramUserDataClass.ts
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
import { prisma } from "./prismaClient"
|
||||||
|
import { AccountTelegram, Token, User } from "@prisma/client"
|
||||||
|
import nodecrypto from "crypto"
|
||||||
|
import { TelegramLoginData } from "../types/user"
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link TelegramLoginData} object extended with various utility methods.
|
||||||
|
*/
|
||||||
|
export class TelegramUserDataClass {
|
||||||
|
id: number
|
||||||
|
firstName: string
|
||||||
|
lastName?: string
|
||||||
|
username?: string
|
||||||
|
photoUrl?: string
|
||||||
|
authDate: Date
|
||||||
|
hash: string
|
||||||
|
lang?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct a {@link TelegramUserDataClass} object from a {@link TelegramLoginData}, validating it in the process.
|
||||||
|
*
|
||||||
|
* @param u The {@link TelegramLoginData} to use.
|
||||||
|
*/
|
||||||
|
constructor(u: TelegramLoginData) {
|
||||||
|
if(!u.id) throw new Error("Missing `id`")
|
||||||
|
if(!u.first_name) throw new Error("Missing `first_name`")
|
||||||
|
if(!u.auth_date) throw new Error("Missing `auth_date`")
|
||||||
|
if(!u.hash) throw new Error("Missing `hash`")
|
||||||
|
|
||||||
|
this.id = u.id
|
||||||
|
this.firstName = u.first_name
|
||||||
|
this.lastName = u.last_name
|
||||||
|
this.username = u.username
|
||||||
|
this.photoUrl = u.photo_url
|
||||||
|
this.authDate = new Date(u.auth_date * 1000)
|
||||||
|
|
||||||
|
// https://stackoverflow.com/a/12372720/4334568
|
||||||
|
if(isNaN(this.authDate.getTime())) throw new Error("Invalid `auth_date`")
|
||||||
|
|
||||||
|
this.hash = u.hash
|
||||||
|
this.lang = u.lang
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert this object back into a {@link TelegramLoginData}.
|
||||||
|
*
|
||||||
|
* @return The {@link TelegramLoginData} object, ready to be serialized.
|
||||||
|
*/
|
||||||
|
toObject(): TelegramLoginData {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
first_name: this.firstName,
|
||||||
|
last_name: this.lastName,
|
||||||
|
username: this.username,
|
||||||
|
photo_url: this.photoUrl,
|
||||||
|
lang: this.lang,
|
||||||
|
auth_date: this.authDate.getTime() / 1000,
|
||||||
|
hash: this.hash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert this object into a partial {@link AccountTelegram} database object.
|
||||||
|
*/
|
||||||
|
toDatabase() {
|
||||||
|
return {
|
||||||
|
telegramId: this.id,
|
||||||
|
firstName: this.firstName,
|
||||||
|
lastName: this.lastName ?? null,
|
||||||
|
username: this.username ?? null,
|
||||||
|
photoUrl: this.photoUrl ?? null,
|
||||||
|
lang: this.lang ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert this object in a string, using [the format required to verify a Telegram Login](https://core.telegram.org/widgets/login#checking-authorization).
|
||||||
|
*
|
||||||
|
* @param data The data to encode.
|
||||||
|
* @returns The stringified data.
|
||||||
|
*/
|
||||||
|
toString(): string {
|
||||||
|
const string = Object.entries(this.toObject())
|
||||||
|
.filter(([key, _]) => key !== "hash")
|
||||||
|
.filter(([_, value]) => value !== undefined)
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.sort()
|
||||||
|
.join("\n")
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the `auth_date` of the response is recent: it must be in the past, but within `maxMs` from the current date.
|
||||||
|
*
|
||||||
|
* @param maxMs The maximum number of milliseconds that may pass after authentication for the response to be considered valid.
|
||||||
|
* @returns `true` if the request was sent within the requested timeframe, `false` otherwise.
|
||||||
|
*/
|
||||||
|
isRecent(maxMs: number): boolean {
|
||||||
|
const diff = new Date().getTime() - this.authDate.getTime()
|
||||||
|
return diff <= maxMs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the "`hash`" of this object using [the Telegram Login verification 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` parameter.
|
||||||
|
*/
|
||||||
|
hmac(token: string): string {
|
||||||
|
const hash = nodecrypto.createHash("sha256")
|
||||||
|
hash.update(token)
|
||||||
|
const hmac = nodecrypto.createHmac("sha256", hash.digest())
|
||||||
|
hmac.update(this.toString())
|
||||||
|
return hmac.digest("hex")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate this object using [the Telegram Login verification 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 hash matches the value calculated with {@link hmac}, `false` otherwise.
|
||||||
|
*/
|
||||||
|
isValid(token: string): boolean {
|
||||||
|
const received = this.hash
|
||||||
|
const calculated = this.hmac(token)
|
||||||
|
return received === calculated
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Telegram "displayed name" of the user represented by this object.
|
||||||
|
*/
|
||||||
|
toTelegramName(): string {
|
||||||
|
if(this.username) return this.username
|
||||||
|
else if(this.lastName) return this.firstName + " " + this.lastName
|
||||||
|
else return this.firstName
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,148 +0,0 @@
|
||||||
import nodecrypto from "crypto"
|
|
||||||
import { ParsedUrlQuery } from "querystring"
|
|
||||||
import * as QueryString from "./queryString"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serializable Telegram user data without any technical information.
|
|
||||||
*/
|
|
||||||
export interface UserData {
|
|
||||||
id: number
|
|
||||||
first_name: string
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the default Telegram representation of a username.
|
|
||||||
*
|
|
||||||
* @param u
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export function getTelegramName(u: UserData) {
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 implements LoginData {
|
|
||||||
id: number
|
|
||||||
first_name: string
|
|
||||||
last_name?: string
|
|
||||||
username?: string
|
|
||||||
photo_url?: string
|
|
||||||
auth_date: number
|
|
||||||
hash: string
|
|
||||||
lang?: string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Construct a new {@link LoginResponse} from a query string object as returned by Next.js.
|
|
||||||
*
|
|
||||||
* @param queryObj The query string object, from `context.query`.
|
|
||||||
*/
|
|
||||||
constructor(ld: LoginData) {
|
|
||||||
if(!ld.id) throw new Error("Missing `id`")
|
|
||||||
if(!ld.first_name) throw new Error("Missing `first_name`")
|
|
||||||
if(!ld.auth_date) throw new Error("Missing `auth_date`")
|
|
||||||
if(!ld.hash) throw new Error("Missing `hash`")
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stringify a {@link LoginResponse} in [the format required to verify a Telegram Login](https://core.telegram.org/widgets/login#checking-authorization).
|
|
||||||
*
|
|
||||||
* @param data The data to encode.
|
|
||||||
* @returns The stringified data.
|
|
||||||
*/
|
|
||||||
stringify(): string {
|
|
||||||
const string = Object.entries(this)
|
|
||||||
.filter(([key, _]) => key !== "hash")
|
|
||||||
.filter(([_, value]) => value !== undefined)
|
|
||||||
.map(([key, value]) => `${key}=${value}`)
|
|
||||||
.sort()
|
|
||||||
.join("\n")
|
|
||||||
return string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 `864_000_000`, 1 day.
|
|
||||||
* @returns `true` if the response can be considered recent, `false` otherwise.
|
|
||||||
*/
|
|
||||||
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 hash = nodecrypto.createHash("sha256")
|
|
||||||
hash.update(token)
|
|
||||||
const hmac = nodecrypto.createHmac("sha256", hash.digest())
|
|
||||||
hmac.update(this.stringify())
|
|
||||||
return hmac.digest("hex")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
isValid(token: string): boolean {
|
|
||||||
const client = this.hmac(token)
|
|
||||||
const server = this.hash
|
|
||||||
return client === server
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in a new issue