1
Fork 0
mirror of https://github.com/Steffo99/festa.git synced 2024-12-22 22:54:22 +00:00

Rearrange things

This commit is contained in:
Steffo 2022-05-29 04:01:56 +02:00
parent 6673b9fd15
commit e8086a7b5f
Signed by: steffo
GPG key ID: 6965406171929D01
29 changed files with 577 additions and 588 deletions

17
.vscode/launch.json vendored
View file

@ -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
View 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")}
/>
)
}

View file

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

View file

@ -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=""

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
View 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]
)
}

View 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))
},
[]
)
}

View file

@ -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",

View file

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

View file

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

View file

@ -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,14 +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>
{t("siteTitle")}
</h1>
</main>
:
<main id="page-hero" className="page">
<hgroup className="hgroup-hero">
<h1> <h1>
{t("siteTitle")} {t("siteTitle")}
</h1> </h1>
@ -29,25 +42,29 @@ const Page: NextPage = () => {
{t("siteSubtitle")} {t("siteSubtitle")}
</h2> </h2>
</hgroup> </hgroup>
<div> {
<TutorialTelegramLogin /> error ?
<div className="negative">
<p>
{t("telegramLoginError")}
</p>
<p>
<code>
{JSON.stringify(error)}
</code>
</p>
</div> </div>
:
<div>
<p>
{t("telegramLoginDescription")}
</p>
<TelegramLoginButton
dataOnauth={onLogin}
botName={process.env.NEXT_PUBLIC_TELEGRAM_USERNAME}
/>
</div>
}
</main> </main>
) )
} }
return (
<main className="page-index">
<hgroup>
<h1>
{t("siteTitle")}
</h1>
<h2>
{t("siteSubtitle")}
</h2>
</hgroup>
</main>
)
}
export default Page

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +0,0 @@
.container-btn-telegram > div {
height: 40px;
}

5
types/api.ts Normal file
View file

@ -0,0 +1,5 @@
export type ApiError = {
error: string
}
export type ApiResult<T> = ApiError | T

20
types/user.ts Normal file
View 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}

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

View file

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