diff --git a/.vscode/launch.json b/.vscode/launch.json index 363c83f..a4fd5ea 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,13 +9,26 @@ "request": "launch", "runtimeArgs": [ "run", - "app:dev", + "dev", ], "runtimeExecutable": "yarn", "skipFiles": [ "/**" ], "type": "node" - } + }, + { + "name": "Prisma Studio", + "request": "launch", + "runtimeArgs": [ + "run", + "studio", + ], + "runtimeExecutable": "yarn", + "skipFiles": [ + "/**" + ], + "type": "node" + }, ] } \ No newline at end of file diff --git a/components/Avatar.tsx b/components/Avatar.tsx new file mode 100644 index 0000000..717b05e --- /dev/null +++ b/components/Avatar.tsx @@ -0,0 +1,13 @@ +import Image, { ImageProps } from "next/image"; +import { HTMLProps } from "react"; +import classNames from "classnames" + +export function Avatar(props: ImageProps) { + return ( + + ) +} \ No newline at end of file diff --git a/components/InputEventSlug.tsx b/components/InputEventSlug.tsx deleted file mode 100644 index a881c1a..0000000 --- a/components/InputEventSlug.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import * as React from "react" - -export interface InputSlug extends React.HTMLProps { - onSlugChange?: (val: string) => void, -} - -export function InputSlug(props: InputSlug) { - const [text, setText] = React.useState("") - - const handleChange = React.useCallback((event: React.ChangeEvent) => { - props.onChange?.(event) - - let slug = event.target.value.toLowerCase().replaceAll(/[^a-z0-9]/g, "-") - props.onSlugChange?.(slug) - setText(slug) - }, - [] - ) - - return ( - - ) -} \ No newline at end of file diff --git a/components/Postcard.tsx b/components/Postcard.tsx index f6d2eda..2d62f1b 100644 --- a/components/Postcard.tsx +++ b/components/Postcard.tsx @@ -6,7 +6,7 @@ export function Postcard() { const [postcard, _] = useDefinedContext(PostcardContext) return ( - (null) + + return
+
+} \ No newline at end of file diff --git a/components/TelegramAvatar.tsx b/components/TelegramAvatar.tsx deleted file mode 100644 index aa9406f..0000000 --- a/components/TelegramAvatar.tsx +++ /dev/null @@ -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 ? - - : - null -} \ No newline at end of file diff --git a/components/TelegramUser.tsx b/components/TelegramUserInline.tsx similarity index 75% rename from components/TelegramUser.tsx rename to components/TelegramUserInline.tsx index 49dd72b..571a648 100644 --- a/components/TelegramUser.tsx +++ b/components/TelegramUserInline.tsx @@ -1,11 +1,11 @@ -import { UserData } from "../utils/telegram"; +import { TelegramLoginData } from "../types/user"; import { TelegramAvatar } from "./TelegramAvatar"; -interface TelegramUserLinkProps { - u: UserData +interface Props { + u: TelegramLoginData } -export function TelegramUser({u}: TelegramUserLinkProps) { +export function TelegramUserInline({u}: Props) { if(u.username) return ( diff --git a/components/TutorialTelegramLogin.tsx b/components/TutorialTelegramLogin.tsx deleted file mode 100644 index 635fbb8..0000000 --- a/components/TutorialTelegramLogin.tsx +++ /dev/null @@ -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 <> -
- {t("introTelegramLogin")} -
- - - } - else { - return <> -
- -
- - } -} \ No newline at end of file diff --git a/contexts/login.tsx b/contexts/login.tsx index 6421d86..f8bfca5 100644 --- a/contexts/login.tsx +++ b/contexts/login.tsx @@ -1,6 +1,5 @@ -import { useStorageState } from "react-storage-hooks"; +import { FestaLoginData } from "../types/user"; 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. */ -export const LoginContext = createStateContext() +export const LoginContext = createStateContext() diff --git a/contexts/postcard.tsx b/contexts/postcard.tsx index 77632cd..5824148 100644 --- a/contexts/postcard.tsx +++ b/contexts/postcard.tsx @@ -1,6 +1,6 @@ import { createStateContext } from "../utils/stateContext"; import { StaticImageData } from "next/image"; -import * as Telegram from "../utils/telegram" +import * as Telegram from "../utils/TelegramUserDataClass" /** diff --git a/hooks/useStoredLogin.ts b/hooks/useStoredLogin.ts new file mode 100644 index 0000000..92389aa --- /dev/null +++ b/hooks/useStoredLogin.ts @@ -0,0 +1,40 @@ +import { useEffect } from "react" +import { FestaLoginData } from "../types/user" + +export function useStoredLogin(setLogin: React.Dispatch>): 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] + ) +} \ No newline at end of file diff --git a/hooks/useTelegramToFestaCallback.ts b/hooks/useTelegramToFestaCallback.ts new file mode 100644 index 0000000..786c56c --- /dev/null +++ b/hooks/useTelegramToFestaCallback.ts @@ -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>, setError: Dispatch>): (data: TelegramLoginData) => Promise { + return useCallback( + async (data: TelegramLoginData) => { + setError(null) + + try { + var response = await axios.post>("/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)) + }, + [] + ) +} \ No newline at end of file diff --git a/package.json b/package.json index fd4c8e1..b3e0190 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,13 @@ { - "name": "festa", + "name": "@steffo/festa", "version": "0.1.0", "private": true, "scripts": { - "app:dev": "next dev", - "app:build": "next build", - "app:start": "next start", - "app:lint": "next lint", - "db:dev": "dotenv -e .env.local prisma db push --force-reset", - "db:generate": "dotenv -e .env.local prisma generate" + "dev": "dotenv -e .env.local prisma db push && dotenv -e .env.local prisma generate && dotenv -e .env.local next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "studio": "dotenv -e .env.local prisma studio" }, "dependencies": { "@prisma/client": "3.14.0", diff --git a/pages/_app.tsx b/pages/_app.tsx index 290d36b..268626b 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -2,42 +2,19 @@ import '../styles/globals.css' import type { AppProps } from 'next/app' import { LoginContext } from '../contexts/login' import { useEffect, useState } from 'react' -import * as Telegram from "../utils/telegram" import defaultPostcard from "../public/postcards/adi-goldstein-Hli3R6LKibo-unsplash.jpg" import { Postcard } from '../components/Postcard' import { PostcardContext } from '../contexts/postcard' import { StaticImageData } from 'next/image' 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 [login, setLogin] = useState(null) + const [login, setLogin] = useState(null) const [postcard, setPostcard] = useState(defaultPostcard) - - // 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] - ) + useStoredLogin(setLogin) return ( diff --git a/pages/api/login/index.ts b/pages/api/login/index.ts new file mode 100644 index 0000000..9b2b1dc --- /dev/null +++ b/pages/api/login/index.ts @@ -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>) { + 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>) { + 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) +} \ No newline at end of file diff --git a/pages/api/login/telegram.ts b/pages/api/login/telegram.ts deleted file mode 100644 index d1f4d0e..0000000 --- a/pages/api/login/telegram.ts +++ /dev/null @@ -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"}) - } -} \ No newline at end of file diff --git a/pages/index.tsx b/pages/index.tsx index 18a83d7..ec47a88 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,10 +1,13 @@ -import type { NextPage, NextPageContext } from 'next' +import { NextPageContext } from 'next' import { useTranslation } from 'next-i18next' import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; -import { Intro } from '../components/Intro'; -import { TutorialTelegramLogin } from '../components/TutorialTelegramLogin'; +import { useState } from 'react'; import { LoginContext } from '../contexts/login'; 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) { return { @@ -14,14 +17,24 @@ export async function getStaticProps(context: NextPageContext) { } } -const Page: NextPage = () => { + +export default function PageIndex() { const { t } = useTranslation("common") const [login, setLogin] = useDefinedContext(LoginContext) + const [error, setError] = useState(null) - if (!login) { - return ( + const onLogin = useTelegramToFestaCallback(setLogin, setError) + + return ( + login ?
-
+

+ {t("siteTitle")} +

+
+ : +
+

{t("siteTitle")}

@@ -29,25 +42,29 @@ const Page: NextPage = () => { {t("siteSubtitle")}
-
- -
+ { + error ? +
+

+ {t("telegramLoginError")} +

+

+ + {JSON.stringify(error)} + +

+
+ : +
+

+ {t("telegramLoginDescription")} +

+ +
+ }
- ) - } - - return ( -
-
-

- {t("siteTitle")} -

-

- {t("siteSubtitle")} -

-
-
) } - -export default Page diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b70584f..168dfda 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,145 +1,72 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - -generator client { - provider = "prisma-client-js" -} +// Prisma Schema file +// https://pris.ly/d/prisma-schema +// Use the PostgreSQL database at the URL specified via the DATABASE_URL environment variable. datasource db { provider = "postgresql" url = env("DATABASE_URL") } -/// An event is the representation of a gathering of people in a certain place at a certain time. -model Event { - id Int @id @default(autoincrement()) - // - 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[] +// Generate @prisma/client for use in JavaScript and TypeScript. +generator client { + provider = "prisma-client-js" } -/// 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 { - id BigInt @id - firstName String - lastName String? - username String? - photoUrl String? - lastAuthDate DateTime - tokens Token[] + /// A unique id for the user on the Festa website. + id String @id @default(uuid()) @db.Uuid + /// The power level of the user on the Festa website. + powerLevel PowerLevel @default(USER) + /// The displayed name of the user. + displayName String + /// The URL of the displayed avatar of the user. + displayAvatarURL String? + /// The tokens the user can use to login. + tokens Token[] + /// The Telegram accounts associated with this user. + accountsTelegram AccountTelegram[] } +/// A possible powerLevel value for an {@link User}. +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]) + /// 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 { - userId BigInt + /// 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 + /// The datetime after which the token should cease to be valid for authentication. 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 -} diff --git a/public/locales/it-IT/common.json b/public/locales/it-IT/common.json index a8ed247..dc56174 100644 --- a/public/locales/it-IT/common.json +++ b/public/locales/it-IT/common.json @@ -1,9 +1,6 @@ { "siteTitle": "Festa", "siteSubtitle": "Organizza con facilità il tuo evento!", - "introTelegramLogin": "Per prima cosa, effettua il login con Telegram.", - "introTelegramLoggedIn": "Sei connesso come <1/>!", - "introTelegramLogout": "Non sei tu?", - "introCreateEvent": "Dai un nome al tuo primo evento:", - "introCreateEventSlugPlaceholder": "nome-evento-2022" + "telegramLoginDescription": "Per iniziare, effettua il login con Telegram.", + "logOutPrompt": "Non sei tu?" } diff --git a/styles/variables.css b/styles/color-schemes.css similarity index 82% rename from styles/variables.css rename to styles/color-schemes.css index 6145ef1..cace027 100644 --- a/styles/variables.css +++ b/styles/color-schemes.css @@ -14,6 +14,10 @@ --negative: #880000; } + .postcard { + filter: blur(16px) contrast(25%) brightness(175%); + } + /* Dark theme */ @media (prefers-color-scheme: dark) { body { @@ -30,4 +34,8 @@ --positive: #88ff88; --negative: #ff8888; } + + .postcard { + filter: blur(16px) contrast(50%) brightness(50%); + } } diff --git a/styles/globals.css b/styles/globals.css index 0647813..bf01a6a 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -1,3 +1,6 @@ +@import "color-schemes.css"; +@import "page-tweaks.css"; + * { box-sizing: border-box; } @@ -17,10 +20,15 @@ body { color: var(--foreground); font-family: sans-serif; text-shadow: 1px 1px 1px var(--background); + + min-height: 100vh; +} + +hgroup > * { + margin: 0; } h1, h2, h3, h4, h5, h6 { - margin: 0; text-shadow: 2px 2px 2px var(--background); } @@ -36,6 +44,14 @@ a:active { color: var(--anchor-active); } +.positive { + color: var(--positive); +} + +.negative { + color: var(--negative); +} + input, button { padding: 8px; margin: 2px 4px; @@ -65,22 +81,35 @@ input[type="submit"]:active, button:active { border-style: inset; } -.input-square { +.square-40 { width: 40px; height: 40px; } -.input-positive { +input.positive, button.positive { border-color: var(--positive); - color: var(--positive); } -.input-negative { +input.negative, button.negative { border-color: var(--negative); - color: var(--negative); } -@import "index.css"; -@import "postcard.css"; -@import "telegram.css"; -@import "variables.css"; \ No newline at end of file +.page { + min-height: 100vh; +} + +.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; +} diff --git a/styles/index.css b/styles/index.css deleted file mode 100644 index da15a28..0000000 --- a/styles/index.css +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/styles/page-tweaks.css b/styles/page-tweaks.css new file mode 100644 index 0000000..377dd65 --- /dev/null +++ b/styles/page-tweaks.css @@ -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; +} \ No newline at end of file diff --git a/styles/postcard.css b/styles/postcard.css deleted file mode 100644 index 240e6a0..0000000 --- a/styles/postcard.css +++ /dev/null @@ -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%); - } -} diff --git a/styles/telegram.css b/styles/telegram.css deleted file mode 100644 index 0baa5fe..0000000 --- a/styles/telegram.css +++ /dev/null @@ -1,3 +0,0 @@ -.container-btn-telegram > div { - height: 40px; -} diff --git a/types/api.ts b/types/api.ts new file mode 100644 index 0000000..9ca46f0 --- /dev/null +++ b/types/api.ts @@ -0,0 +1,5 @@ +export type ApiError = { + error: string +} + +export type ApiResult = ApiError | T diff --git a/types/user.ts b/types/user.ts new file mode 100644 index 0000000..b2c0f6b --- /dev/null +++ b/types/user.ts @@ -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} diff --git a/utils/TelegramUserDataClass.ts b/utils/TelegramUserDataClass.ts new file mode 100644 index 0000000..46acb61 --- /dev/null +++ b/utils/TelegramUserDataClass.ts @@ -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 + } +} diff --git a/utils/telegram.ts b/utils/telegram.ts deleted file mode 100644 index d0aad04..0000000 --- a/utils/telegram.ts +++ /dev/null @@ -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 - } -}