= ({userData}) => {
- const router = useRouter()
-
- return (
-
- {userData ?
- JSON.stringify(userData)
- :
-
- }
-
- )
-}
-
-export default Page
diff --git a/styles/globals.css b/styles/globals.css
index 948b2b0..accc1fd 100644
--- a/styles/globals.css
+++ b/styles/globals.css
@@ -1,4 +1,4 @@
-html {
+html, body {
padding: 0;
margin: 0;
}
@@ -7,6 +7,14 @@ html {
box-sizing: border-box;
}
+a {
+ color: #4444ff;
+}
+
+a:visited {
+ color: #aa44ff;
+}
+
@media (prefers-color-scheme: light) {
body {
background-color: white;
@@ -19,4 +27,4 @@ html {
background-color: black;
color: white;
}
-}
\ No newline at end of file
+}
diff --git a/styles/nav.css b/styles/nav.css
new file mode 100644
index 0000000..756541f
--- /dev/null
+++ b/styles/nav.css
@@ -0,0 +1,39 @@
+nav {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+
+ padding: 4px;
+}
+
+nav h1 {
+ font-size: 32px;
+ margin: 0;
+}
+
+.nav-left, .nav-right {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.nav-telegram-login {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+}
+
+@media (prefers-color-scheme: light) {
+ nav {
+ background-color: rgba(255, 255, 255, 0.2);
+ }
+}
+
+@media (prefers-color-scheme: dark) {
+ nav {
+ background-color: rgba(0, 0, 0, 0.2);
+ }
+}
diff --git a/styles/postcard.css b/styles/postcard.css
new file mode 100644
index 0000000..fbda973
--- /dev/null
+++ b/styles/postcard.css
@@ -0,0 +1,22 @@
+.postcard {
+ width: 100vw;
+ height: 100vh;
+ object-fit: cover;
+
+ position: absolute;
+ z-index: -1;
+
+ user-select: none;
+}
+
+@media (prefers-color-scheme: light) {
+ .postcard {
+ filter: blur(16px) contrast(25%) brightness(175%);
+ }
+}
+
+@media (prefers-color-scheme: dark) {
+ .postcard {
+ filter: blur(16px) contrast(50%) brightness(50%);
+ }
+}
diff --git a/styles/telegram.css b/styles/telegram.css
new file mode 100644
index 0000000..593ef33
--- /dev/null
+++ b/styles/telegram.css
@@ -0,0 +1,26 @@
+/* Taken from the Telegram widget button */
+.btn-telegram {
+ display: inline-block;
+ vertical-align: top;
+ font-weight: 500;
+ background-color: #54a9eb;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ margin: 0;
+ border: none;
+ color: #fff;
+ cursor: pointer;
+
+ font-size: 16px;
+ line-height: 20px;
+ padding: 9px 21px 11px;
+ border-radius: 20px;
+}
+
+.img-telegram-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 20px;
+
+ margin-left: 4px;
+}
diff --git a/utils/querystring.ts b/utils/querystring.ts
new file mode 100644
index 0000000..ba8b3e5
--- /dev/null
+++ b/utils/querystring.ts
@@ -0,0 +1,21 @@
+import { ParsedUrlQuery } from "querystring"
+
+/**
+ * Ensure that the passed {@link ParsedUrlQuery} object has **one and only one** key with the specified name, and get its value.
+ *
+ * @param queryObj The object to read the value from.
+ * @param key The name of the value to read.
+ * @returns The resulting string.
+ */
+export function getSingle(queryObj: ParsedUrlQuery, key: string): string {
+ const value = queryObj[key]
+
+ switch(typeof value) {
+ case "undefined":
+ throw new Error(`No "${key}" parameter found in the query string.`)
+ case "object":
+ throw new Error(`Multiple "${key}" parameters specified in the query string.`)
+ case "string":
+ return value
+ }
+}
\ No newline at end of file
diff --git a/utils/react-telegram-login.d.ts b/utils/react-telegram-login.d.ts
new file mode 100644
index 0000000..a4f1860
--- /dev/null
+++ b/utils/react-telegram-login.d.ts
@@ -0,0 +1 @@
+declare module "react-telegram-login";
\ No newline at end of file
diff --git a/utils/telegram.ts b/utils/telegram.ts
index 6f46b07..f4fcb47 100644
--- a/utils/telegram.ts
+++ b/utils/telegram.ts
@@ -1,22 +1,54 @@
import nodecrypto from "crypto"
+import { ParsedUrlQuery } from "querystring"
+import * as QueryString from "./querystring"
/**
- * The validated user data serialized by the server.
+ * Serializable Telegram user data without any technical information.
*/
-export interface LoginData {
+export interface UserData {
id: number
first_name: string
- last_name: string | null
- username: string | null
- photo_url: string | null
- lang: string | null
+ last_name?: string
+ username?: string
+ photo_url?: string
+ lang?: string
+}
+
+/**
+ * Serializable Telegram login data with technical information.
+ *
+ * Can be turned in a {@link LoginResponse} for additional methods.
+ */
+export interface LoginData extends UserData {
+ auth_date: number
+ hash: string
+}
+
+
+/**
+ * Create a {@link LoginData} object from a {@link ParsedUrlQuery}.
+ *
+ * @param queryObj The source object.
+ * @returns The created object.
+ */
+export function queryStringToLoginData(queryObj: ParsedUrlQuery): LoginData {
+ return {
+ id: parseInt(QueryString.getSingle(queryObj, "id")),
+ first_name: QueryString.getSingle(queryObj, "first_name"),
+ last_name: QueryString.getSingle(queryObj, "last_name"),
+ username: QueryString.getSingle(queryObj, "username"),
+ photo_url: QueryString.getSingle(queryObj, "photo_url"),
+ lang: QueryString.getSingle(queryObj, "lang"),
+ auth_date: parseInt(QueryString.getSingle(queryObj, "auth_date")),
+ hash: QueryString.getSingle(queryObj, "hash"),
+ }
}
/**
* The response sent by Telegram after a login.
*/
-export class LoginResponse {
+export class LoginResponse implements LoginData {
id: number
first_name: string
last_name?: string
@@ -31,56 +63,15 @@ export class LoginResponse {
*
* @param queryObj The query string object, from `context.query`.
*/
- constructor(queryObj: {[_: string]: string | string[]}) {
- if(typeof queryObj.id === "object") {
- throw new Error("Multiple `id` parameters specified in the query string, cannot construct LoginResponse.")
- }
- if(typeof queryObj.first_name === "object") {
- throw new Error("Multiple `first_name` parameters specified in the query string, cannot construct LoginResponse.")
- }
- if(typeof queryObj.last_name === "object") {
- throw new Error("Multiple `last_name` parameters specified in the query string, cannot construct LoginResponse.")
- }
- if(typeof queryObj.username === "object") {
- throw new Error("Multiple `username` parameters specified in the query string, cannot construct LoginResponse.")
- }
- if(typeof queryObj.photo_url === "object") {
- throw new Error("Multiple `photo_url` parameters specified in the query string, cannot construct LoginResponse.")
- }
- if(typeof queryObj.auth_date === "object") {
- throw new Error("Multiple `auth_date` parameters specified in the query string, cannot construct LoginResponse.")
- }
- if(typeof queryObj.hash === "object") {
- throw new Error("Multiple `hash` parameters specified in the query string, cannot construct LoginResponse.")
- }
- if(typeof queryObj.lang === "object") {
- throw new Error("Multiple `hash` parameters specified in the query string, cannot construct LoginResponse.")
- }
-
- this.id = parseInt(queryObj.id)
- this.first_name = queryObj.first_name
- this.last_name = queryObj.last_name
- this.username = queryObj.username
- this.photo_url = queryObj.photo_url
- this.auth_date = parseInt(queryObj.auth_date)
- this.hash = queryObj.hash
- this.lang = queryObj.lang
- }
-
- /**
- * Serialize this response into a {@link LoginData} object, which can be passed to the client by Next.js.
- *
- * @returns The {@link LoginData} object.
- */
- serialize(): LoginData {
- return {
- id: this.id ?? null,
- first_name: this.first_name ?? null,
- last_name: this.last_name ?? null,
- username: this.username ?? null,
- photo_url: this.photo_url ?? null,
- lang: this.lang ?? null,
- }
+ constructor(ld: LoginData) {
+ this.id = ld.id
+ this.first_name = ld.first_name
+ this.last_name = ld.last_name
+ this.username = ld.username
+ this.photo_url = ld.photo_url
+ this.auth_date = ld.auth_date
+ this.hash = ld.hash
+ this.lang = ld.lang
}
/**
@@ -102,24 +93,26 @@ export class LoginResponse {
/**
* Check if the `auth_date` of the response is recent: it must be in the past, but within `maxSeconds` from the current date.
*
- * @param maxSeconds The maximum number of milliseconds that may pass after authentication for the response to be considered valid; defaults to `300000`, 5 minutes.
+ * @param maxSeconds The maximum number of milliseconds that may pass after authentication for the response to be considered valid; defaults to `864_000_000`, 1 day.
* @returns `true` if the response can be considered recent, `false` otherwise.
*/
- isRecent(maxSeconds: number = 300000): boolean {
+ isRecent(maxSeconds: number = 864_000_000): boolean {
const diff = new Date().getTime() - new Date(this.auth_date * 1000).getTime()
return 0 < diff && diff <= maxSeconds
}
-
/**
* Calculate the "`hash`" of a Telegram Login using [this procedure](https://core.telegram.org/widgets/login#checking-authorization).
*
+ * _Only works on Node.js, due to usage of the `crypto` module._
+ *
* @param token The bot token used to validate the signature.
* @returns The calculated value of the `hash` {@link LoginResponse} parameter.
*/
- hmac(token: string): string {
- const key = hashToken(token)
- const hmac = nodecrypto.createHmac("sha256", key)
+ hmac(token: string): string {
+ const hash = nodecrypto.createHash("sha256")
+ hash.update(token)
+ const hmac = nodecrypto.createHmac("sha256", hash.digest())
hmac.update(this.stringify())
return hmac.digest("hex")
}
@@ -127,6 +120,8 @@ export class LoginResponse {
/**
* Validate a Telegram Login using [this procedure](https://core.telegram.org/widgets/login#checking-authorization).
*
+ * _Only works on Node.js, due to usage of the `crypto` module._
+ *
* @param token The bot token used to validate the signature.
* @returns `true` if the validation is successful, `false` otherwise.
*/
@@ -136,15 +131,3 @@ export class LoginResponse {
return client === server
}
}
-
-/**
- * Hash a Telegram bot token using SHA-256.
- *
- * @param token The bot token to hash.
- * @returns The hex digest of the hash.
- */
-function hashToken(token: string): Buffer {
- const hash = nodecrypto.createHash("sha256")
- hash.update(token)
- return hash.digest()
-}