diff --git a/docker/webdis.json b/docker/webdis.json index cac46753..077c6a3b 100644 --- a/docker/webdis.json +++ b/docker/webdis.json @@ -11,7 +11,18 @@ { "http_basic_auth": "user:password", - "enabled": ["GET", "SET", "DEL", "SETEX", "HSET", "HGETALL"] + "enabled": [ + "GET", + "SET", + "DEL", + "SETEX", + "HSET", + "HGETALL", + "HGET", + "SREM", + "SADD", + "SMEMBERS" + ] } ], diff --git a/src/actions/auth.action.ts b/src/actions/auth.action.ts index 2c976793..e056696b 100644 --- a/src/actions/auth.action.ts +++ b/src/actions/auth.action.ts @@ -105,18 +105,19 @@ export const getSession = cache(async function getSession(): Promise { return session; }); -export const redirectIfNotAuthed = cache(async function getUserOrRedirect( +export const getUserOrRedirect = cache(async function getUserOrRedirect( redirectToPath?: string ) { - const session = await getSession(); + const user = await getAuthedUser(); - if (!session.user) { + if (!user) { const searchParams = new URLSearchParams(); if (redirectToPath) { searchParams.set("nextUrl", redirectToPath); } redirect("/login?" + searchParams.toString()); } + return user; }); export const getAuthedUser = cache(async function getUser() { diff --git a/src/actions/session.action.ts b/src/actions/session.action.ts new file mode 100644 index 00000000..eb4bfc57 --- /dev/null +++ b/src/actions/session.action.ts @@ -0,0 +1,75 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { withAuth, type AuthState } from "~/actions/middlewares"; +import { nextCache } from "~/lib/server/rsc-utils.server"; +import { Session } from "~/lib/server/session.server"; +import { CacheKeys } from "~/lib/shared/cache-keys.shared"; +import { jsonFetch } from "~/lib/shared/utils.shared"; + +export type SuccessfulLocationData = { + status: "success"; + country: string; + countryCode: string; + region: string; + regionName: string; + city: string; + zip: string; + lat: number; + lon: number; + timezone: string; + isp: string; + org: string; + as: string; + query: string; +}; +export type FailedLocationData = { + status: "fail"; + message: string; + query: string; +}; +export type LocationData = SuccessfulLocationData | FailedLocationData; + +export async function getLocationData(session: Session) { + const fn = nextCache( + (ip: string) => + jsonFetch(`http://ip-api.com/json/${ip}`, { + cache: "force-cache" + }), + { + tags: CacheKeys.geo(session.ip) + } + ); + + return await fn(session.ip); +} + +export const revokeSession = withAuth(async function revokeSession( + sessionId: string, + _: FormData, + { session: currentSession, currentUser }: AuthState +) { + const foundSesssion = await Session.getUserSession(currentUser.id, sessionId); + + if (!foundSesssion) { + await currentSession.addFlash({ + type: "error", + message: "The session you are trying to revoke is no longer accessible." + }); + } else if (foundSesssion.id === currentSession.id) { + await currentSession.addFlash({ + type: "warning", + message: "You cannot revoke the current active session." + }); + } else { + await Session.endUserSession(currentUser.id, sessionId); + await currentSession.addFlash({ + type: "success", + message: "Session succesfully revoked." + }); + } + + revalidatePath("/"); + redirect("/settings/sessions"); +}); diff --git a/src/app/(app)/settings/account/page.tsx b/src/app/(app)/settings/account/page.tsx index c6e1f7d1..7752585a 100644 --- a/src/app/(app)/settings/account/page.tsx +++ b/src/app/(app)/settings/account/page.tsx @@ -5,7 +5,7 @@ import { UpdateUserInfosForm } from "~/components/update-user-infos-form"; import { Button } from "~/components/button"; // utils -import { getAuthedUser, redirectIfNotAuthed } from "~/actions/auth.action"; +import { getUserOrRedirect } from "~/actions/auth.action"; import { updateUserProfileInfosInputValidator } from "~/models/dto/update-profile-info-input-validator"; // types @@ -16,9 +16,8 @@ export const metadata: Metadata = { }; export default async function Page() { - await redirectIfNotAuthed("/settings/account"); + const user = await getUserOrRedirect("/settings/account"); - const user = (await getAuthedUser())!; return (
diff --git a/src/app/(app)/settings/appearance/page.tsx b/src/app/(app)/settings/appearance/page.tsx index ea5326dd..c2a269a5 100644 --- a/src/app/(app)/settings/appearance/page.tsx +++ b/src/app/(app)/settings/appearance/page.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import { ThemeForm } from "~/components/theme-form"; // utils -import { redirectIfNotAuthed } from "~/actions/auth.action"; +import { getUserOrRedirect } from "~/actions/auth.action"; import { getTheme } from "~/actions/theme.action"; // types @@ -15,7 +15,7 @@ export const metadata: Metadata = { }; export default async function Page() { - await redirectIfNotAuthed("/settings/appearance"); + await getUserOrRedirect("/settings/appearance"); const theme = await getTheme(); return (
diff --git a/src/app/(app)/settings/layout.tsx b/src/app/(app)/settings/layout.tsx index 467f7a4b..e6947e21 100644 --- a/src/app/(app)/settings/layout.tsx +++ b/src/app/(app)/settings/layout.tsx @@ -1,10 +1,10 @@ // components import { Avatar } from "~/components/avatar"; -import { VerticalNavlist } from "~/components/vertical-navlist"; +import { SettingsVerticalNavlist } from "~/components/settings-vertical-navlist"; // utils -import { getAuthedUser, redirectIfNotAuthed } from "~/actions/auth.action"; +import { getUserOrRedirect } from "~/actions/auth.action"; import { clsx } from "~/lib/shared/utils.shared"; export default async function SettingsLayout({ @@ -12,8 +12,7 @@ export default async function SettingsLayout({ }: { children: React.ReactNode; }) { - await redirectIfNotAuthed("/settings/account"); - const user = (await getAuthedUser())!; + const user = await getUserOrRedirect("/settings/account"); return ( <>
-

{user.username}

+

+ {user.name ?? user.username}   + {user.name && ( + ({user.username}) + )} +

+

Your personnal account

- +
{children}
diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 99583946..11766c2c 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -1,7 +1,7 @@ import { redirect } from "next/navigation"; -import { redirectIfNotAuthed } from "~/actions/auth.action"; +import { getUserOrRedirect } from "~/actions/auth.action"; export default async function Page() { - await redirectIfNotAuthed("/settings/account"); + await getUserOrRedirect("/settings/account"); redirect("/settings/account"); } diff --git a/src/app/(app)/settings/sessions/[id]/page.tsx b/src/app/(app)/settings/sessions/[id]/page.tsx new file mode 100644 index 00000000..77394524 --- /dev/null +++ b/src/app/(app)/settings/sessions/[id]/page.tsx @@ -0,0 +1,254 @@ +import * as React from "react"; + +// components +import { + ArrowLeftIcon, + DeviceDesktopIcon, + DeviceMobileIcon, + QuestionIcon +} from "@primer/octicons-react"; +import Link from "next/link"; +import { Button } from "~/components/button"; +import { Session } from "~/lib/server/session.server"; +import { Skeleton } from "~/components/skeleton"; + +// utils +import { redirect } from "next/navigation"; +import { getSession, getUserOrRedirect } from "~/actions/auth.action"; +import { + getLocationData, + revokeSession, + type SuccessfulLocationData +} from "~/actions/session.action"; +import { + clsx, + formatDate, + isDateLessThanAnHourAgo +} from "~/lib/shared/utils.shared"; +import { userAgent } from "next/server"; + +// types +import type { PageProps } from "~/lib/types"; +import { SubmitButton } from "~/components/submit-button"; + +export const metadata = { + title: "Session detail" +}; + +export default async function SessionDetailPage({ + params +}: PageProps<{ + id: string; +}>) { + const user = await getUserOrRedirect(`/settings/sessions/${params.id}`); + const [sessionData, currentUserSession] = await Promise.all([ + Session.getUserSession(user.id, params.id), + getSession() + ]); + + if (!sessionData) { + await currentUserSession.addFlash({ + type: "warning", + message: + "The session you are looking for has expired and is no longer accessible." + }); + redirect(`/settings/sessions`); + } + + return ( +
+
+

+ Session details +

+ + +
+
+ {/* Activity indicator */} + + + {/* Device Icon */} + + + {/* Location & IP */} +
+ + +
+
+ + {/* Button */} + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ } + > + + + + + + View all sessions + + +
+ ); +} + +async function SessionDetails({ + sessionData, + currentSession: currentUserSesssion +}: { sessionData: Session; currentSession: Session }) { + let lastLocation: SuccessfulLocationData | null = null; + const locationData = await getLocationData(sessionData); + if (locationData.status === "success") { + lastLocation = locationData; + } + const isActiveSession = sessionData.lastAccessed + ? isDateLessThanAnHourAgo(sessionData.lastAccessed) + : sessionData.lastLogin + ? isDateLessThanAnHourAgo(sessionData.lastLogin) + : false; + + const { browser, os } = userAgent({ + headers: new Headers({ + "user-agent": sessionData.userAgent + }) + }); + + const revokeSessionBound = revokeSession.bind(null, sessionData.id); + + return ( +
+
+
+ {/* Activity indicator */} + + Active session + + + {/* Device Icon */} +
+ {sessionData.device === "desktop" ? ( + + ) : sessionData.device === "mobile" ? ( + + ) : sessionData.device === "tablet" ? ( + + ) : ( + + )} +
+ + {/* Location & IP */} +
+

+ {lastLocation?.city ?? "Unknown city"} {sessionData.ip} +

+ + {currentUserSesssion.id === sessionData.id + ? "Your current session" + : sessionData.lastAccessed + ? `Last accessed ${formatDate(sessionData.lastAccessed)}` + : sessionData.lastLogin + ? `Last login ${formatDate(sessionData.lastLogin)}` + : null} + +
+
+ + {sessionData.id !== currentUserSesssion.id && ( +
+ + Revoke session + +
+ )} +
+ {lastLocation && ( + Seen in {lastLocation.countryCode} + )} + +
+
+
Device :
+
+ {browser.name ?? "Unknown browser"} on  + {os.name} +
+
+ +
+
Last location:
+
+ {lastLocation ? ( + <> + {lastLocation.city}, {lastLocation.regionName},{" "} + {lastLocation.country} + + ) : ( + "unknown" + )} +
+
+ +
+
Signed at :
+
+ {new Intl.DateTimeFormat("en-US", { + dateStyle: "long", + timeStyle: "short" + }).format(sessionData.lastLogin)} +
+
+
+
+ ); +} diff --git a/src/app/(app)/settings/sessions/page.tsx b/src/app/(app)/settings/sessions/page.tsx new file mode 100644 index 00000000..827334bd --- /dev/null +++ b/src/app/(app)/settings/sessions/page.tsx @@ -0,0 +1,227 @@ +import * as React from "react"; + +// components +import { + DeviceDesktopIcon, + DeviceMobileIcon, + QuestionIcon +} from "@primer/octicons-react"; +import { getSession, getUserOrRedirect } from "~/actions/auth.action"; +import { Button } from "~/components/button"; + +// utils +import { Session } from "~/lib/server/session.server"; +import { + clsx, + formatDate, + isDateLessThanAnHourAgo, + range +} from "~/lib/shared/utils.shared"; +import { Skeleton } from "~/components/skeleton"; +import { + getLocationData, + type SuccessfulLocationData +} from "~/actions/session.action"; + +export const metadata = { + title: "Sessions" +}; + +export default async function SessionListPage() { + const user = await getUserOrRedirect("/settings/sessions"); + + return ( +
+
+

+ Sessions +

+ +

+ This is a list of devices that have logged into your account. Revoke + any sessions that you do not recognize. +

+ + + + Loading sessions... + + {range(0, 2).map((index) => ( +
  • +
    +
    + {/* Activity indicator */} + + + {/* Device Icon */} + + + {/* Location & IP */} +
    + + +
    +
    + + {/* Button */} + +
    + +
  • + ))} + + } + > + +
    +
    +
    + ); +} + +async function AllSessions({ userId }: { userId: number }) { + const [currentSesssion, userSessions] = await Promise.all([ + getSession(), + Session.getUserSessions(userId).then((sessions) => + Promise.all( + sessions.map(async (session) => { + const location = await getLocationData(session); + return { session, location }; + }) + ) + ) + ]); + + return ( + + ); +} diff --git a/src/components/vertical-navlist.tsx b/src/components/settings-vertical-navlist.tsx similarity index 64% rename from src/components/vertical-navlist.tsx rename to src/components/settings-vertical-navlist.tsx index 00988ec4..3c9dc71f 100644 --- a/src/components/vertical-navlist.tsx +++ b/src/components/settings-vertical-navlist.tsx @@ -2,14 +2,20 @@ import * as React from "react"; // components import { VerticalNavLink } from "./vertical-nav-link"; -import { GearIcon, PaintbrushIcon } from "@primer/octicons-react"; +import { + BroadcastIcon, + GearIcon, + PaintbrushIcon +} from "@primer/octicons-react"; import { clsx } from "~/lib/shared/utils.shared"; -export type VerticalNavlistProps = { +export type SettingsVerticalNavlistProps = { className?: string; }; -export function VerticalNavlist({ className }: VerticalNavlistProps) { +export function SettingsVerticalNavlist({ + className +}: SettingsVerticalNavlistProps) { return ( diff --git a/src/components/update-user-infos-form.tsx b/src/components/update-user-infos-form.tsx index 1fcc1551..4e30298f 100644 --- a/src/components/update-user-infos-form.tsx +++ b/src/components/update-user-infos-form.tsx @@ -34,7 +34,7 @@ export function UpdateUserInfosForm({ defaultValues }: UpdateUserInfosProps) { > } args Arguments for the command. * @returns {Promise} The result of the fetch operation. */ async #fetch(command, ...args) { + /** @type Array<(typeof command)> */ + const commandsWithSingleBody = ["SET", "HSET", "SETEX"]; + const authString = `${env.REDIS_HTTP_USERNAME}:${env.REDIS_HTTP_PASSWORD}`; const [key, ...restArgs] = args; @@ -31,7 +34,7 @@ export class WebdisKV { if ( i === urlParts.length - 1 && - (command === "SET" || command === "HSET" || command === "SETEX") + commandsWithSingleBody.includes(command) ) { body = part.toString(); continue; @@ -51,7 +54,7 @@ export class WebdisKV { const rand = Math.ceil(Math.random() * 10e15); console.time(`[${rand} webdis] ${fullURL}`); return await fetch(fullURL, { - method: ["GET", "HGETALL"].includes(command) ? "GET" : "PUT", + method: "PUT", cache: "no-store", headers: { Authorization: `Basic ${btoa(authString)}` @@ -131,4 +134,31 @@ export class WebdisKV { async delete(key) { await this.#fetch("DEL", key); } + + /** + * @param {string} key + * @param {string|number} value + * @returns {Promise} + */ + async sAdd(key, value) { + await this.#fetch("SADD", key, value); + } + + /** + * @param {string} key + * @returns {Promise>} + */ + async sMembers(key) { + const value = await this.#fetch("SMEMBERS", key); + return value.SMEMBERS; + } + + /** + * @param {string} key + * @param {string|number} value + * @returns {Promise} + */ + async sRem(key, value) { + await this.#fetch("SREM", key, value); + } } diff --git a/src/lib/server/session.server.ts b/src/lib/server/session.server.ts index 7ccc7fb5..3ff763d6 100644 --- a/src/lib/server/session.server.ts +++ b/src/lib/server/session.server.ts @@ -1,6 +1,6 @@ import "server-only"; -import { kv } from "~/lib/server/kv/index.server"; -import { preprocess, z } from "zod"; +import { WebdisKV } from "~/lib/server/kv/webdis.server.mjs"; +import { z } from "zod"; import { LOGGED_IN_SESSION_TTL, @@ -18,20 +18,38 @@ import type { ResponseCookie } from "next/dist/compiled/@edge-runtime/cookies"; const sessionSchema = z.object({ id: z.string(), - expiry: preprocess((arg) => new Date(arg as any), z.date()), + expiry: z.coerce.date(), user: createSelectSchema(users) .pick({ id: true, preferred_theme: true, github_id: true }) + .extend({ + lastLogin: z.coerce.date() + }) .nullish(), flashMessages: z .record(z.enum(["success", "error", "info", "warning"]), z.string()) .optional(), signature: z.string(), additionnalData: z.record(z.string(), z.any()).nullish(), - bot: z.boolean().optional().default(false) + bot: z.coerce.boolean().optional().default(false), + userAgent: z.string(), + device: z + .enum([ + "console", + "mobile", + "tablet", + "smarttv", + "wearable", + "embedded", + "desktop", + "unknown" + ]) + .default("unknown"), + ip: z.string(), + lastAccess: z.coerce.date().optional().catch(undefined) }); export type SerializedSession = z.TypeOf; @@ -44,14 +62,22 @@ export type SessionFlash = { message: Required[DefinedSessionKeys]; }; +const SESSION_KEY_PREFIX = "session"; +const USER_SESSION_KEY_PREFIX = "user_session"; export class Session { #_session: SerializedSession; + static #kv = new WebdisKV(); - public static async get(signedSessionId: string) { + public static async get(signedSessionId: string, verify = true) { try { - const verifiedSessionId = await this.#verifySessionId(signedSessionId); + const verifiedSessionId = verify + ? await this.#verifySessionId(signedSessionId) + : signedSessionId; + + const sessionObject: any = await Session.#kv.get( + `${SESSION_KEY_PREFIX}:${verifiedSessionId}` + ); - const sessionObject = await kv.get(`session:${verifiedSessionId}`); if (sessionObject) { return this.#fromPayload(sessionSchema.parse(sessionObject)); } else { @@ -71,22 +97,42 @@ export class Session { return new Session(serializedPayload); } - public static async create(isBot = false) { + public static async create({ + isBot = false, + userAgent, + device, + ip, + lastAccess + }: { + isBot?: boolean; + userAgent: string; + device: SerializedSession["device"]; + ip: string; + lastAccess: Date; + }) { return Session.#fromPayload( await Session.#create({ - isBot + isBot, + userAgent, + device, + ip, + lastAccess }) ); } - public async extendValidity() { + public async extendValidity(options: { newIp: string }) { this.#_session.expiry = new Date( Date.now() + (this.#_session.user ? LOGGED_IN_SESSION_TTL : LOGGED_OUT_SESSION_TTL) * 1000 ); // saving the session in the storage will reset the TTL - await Session.#save(this.#_session); + await Session.#save({ + ...this.#_session, + ip: options.newIp, + lastAccess: new Date() + }); } public getCookie(): ResponseCookie { @@ -108,7 +154,7 @@ export class Session { throw new Error("cannot set theme if the user is not set"); } - this.#_session.user["preferred_theme"] = theme; + this.#_session.user.preferred_theme = theme; await Session.#save(this.#_session); return this; } @@ -125,9 +171,14 @@ export class Session { user: { id: user.id, preferred_theme: user.preferred_theme, - github_id: user.github_id + github_id: user.github_id, + lastLogin: new Date() } - } + }, + userAgent: this.#_session.userAgent, + device: this.#_session.device, + ip: this.#_session.ip, + lastAccess: new Date() }); await Session.#save(this.#_session); @@ -135,6 +186,17 @@ export class Session { } public async invalidate(): Promise { + const userAgent = this.#_session.userAgent; + const device = this.device; + const ip = this.ip; + + if (this.user) { + await Session.#kv.sRem( + `${USER_SESSION_KEY_PREFIX}:${this.user.id}`, + this.id + ); + } + // delete the old session await Session.#delete(this.#_session); @@ -143,7 +205,11 @@ export class Session { init: { flashMessages: this.#_session.flashMessages, additionnalData: this.#_session.additionnalData - } + }, + userAgent, + device, + ip, + lastAccess: new Date() }); return this; @@ -208,16 +274,69 @@ export class Session { return this.#_session.user; } + public get userAgent() { + return this.#_session.userAgent; + } + + public get device() { + return this.#_session.device; + } + + public get id() { + return this.#_session.id; + } + + public get ip() { + return this.#_session.ip; + } + + public get lastLogin() { + return this.#_session.user?.lastLogin; + } + public get lastAccessed() { + return this.#_session.lastAccess; + } + + public static async getUserSessions(userId: number) { + return await Session.#kv + .sMembers(`${USER_SESSION_KEY_PREFIX}:${userId}`) + .then((sessionIds) => + Promise.all( + sessionIds.map((sessionId) => Session.get(sessionId, false)) + ).then( + (sessions) => + sessions.filter((session) => session !== null) as Session[] + ) + ); + } + + public static async getUserSession(userId: number, sessionId: string) { + const session = await Session.get(sessionId, false); + return session?.user?.id === userId ? session : null; + } + + public static async endUserSession(userId: number, sessionId: string) { + const session = await Session.get(sessionId, false); + if (session && session.user?.id === userId) { + await this.#kv.sRem(`${USER_SESSION_KEY_PREFIX}:${userId}`, sessionId); + await this.#delete(session.#_session, false); + } + } + /***********************************/ /* PRIVATE METHODS */ /***********************************/ - static async #create(options?: { + static async #create(options: { init?: Pick< SerializedSession, "flashMessages" | "additionnalData" | "user" >; isBot?: boolean; + userAgent: string; + device: SerializedSession["device"]; + ip: string; + lastAccess: Date; }) { const { sessionId, signature } = await Session.#generateSessionId(); @@ -229,10 +348,14 @@ export class Session { ? new Date(Date.now() + LOGGED_IN_SESSION_TTL * 1000) : new Date(Date.now() + LOGGED_OUT_SESSION_TTL * 1000), signature, - flashMessages: options?.init?.flashMessages, - additionnalData: options?.init?.additionnalData, - bot: Boolean(options?.isBot), - user: options?.init?.user + flashMessages: options.init?.flashMessages, + additionnalData: options.init?.additionnalData, + bot: Boolean(options.isBot), + user: options.init?.user, + userAgent: options.userAgent, + device: options.device, + ip: options.ip, + lastAccess: options.lastAccess } satisfies SerializedSession; await Session.#save(sessionObject); @@ -242,9 +365,6 @@ export class Session { static async #save(session: SerializedSession) { await Session.#verifySessionId(`${session.id}.${session.signature}`); - // don't store expiry as a date, but a timestamp instead - const expiry = session.expiry.getTime(); - let sessionTTL = session.user ? LOGGED_IN_SESSION_TTL : LOGGED_OUT_SESSION_TTL; @@ -252,14 +372,33 @@ export class Session { sessionTTL = 5; // only 5 seconds for bot sessions } - await kv.set(`session:${session.id}`, { ...session, expiry }, sessionTTL); + const { userAgent, ...remainingSession } = session; + + await Promise.all([ + this.#kv.set( + `${SESSION_KEY_PREFIX}:${session.id}`, + { + ...remainingSession, + expiry: session.expiry.getTime(), + lastAccess: session.lastAccess?.getTime() ?? new Date().getTime(), + userAgent + }, + sessionTTL + ), + session.user + ? this.#kv.sAdd( + `${USER_SESSION_KEY_PREFIX}:${session.user.id}`, + session.id + ) + : null + ]); } - static async #delete(session: SerializedSession) { - const verifiedSessionId = await Session.#verifySessionId( - `${session.id}.${session.signature}` - ); - await kv.delete(`session:${verifiedSessionId}`); + static async #delete(session: SerializedSession, verify = true) { + const verifiedSessionId = verify + ? await Session.#verifySessionId(`${session.id}.${session.signature}`) + : session.id; + await this.#kv.delete(`${SESSION_KEY_PREFIX}:${verifiedSessionId}`); } static #arrayBufferToBase64(buffer: ArrayBuffer) { diff --git a/src/lib/shared/cache-keys.shared.ts b/src/lib/shared/cache-keys.shared.ts index 71d2fba8..cb0d96de 100644 --- a/src/lib/shared/cache-keys.shared.ts +++ b/src/lib/shared/cache-keys.shared.ts @@ -25,5 +25,6 @@ export const CacheKeys = { props.user, props.repo, props.number - ] + ], + geo: (ip: string) => ["GEO", ip] } as const; diff --git a/src/lib/shared/utils.shared.ts b/src/lib/shared/utils.shared.ts index e7593f1b..f8f36895 100644 --- a/src/lib/shared/utils.shared.ts +++ b/src/lib/shared/utils.shared.ts @@ -255,6 +255,13 @@ export function formatDate(date: Date | string): string { } } +export function isDateLessThanAnHourAgo(date: Date | string) { + date = new Date(date); + const now = dayjs(); + + return now.diff(date, "hours") === 0; +} + /** * Returns an excerpt of the input string. If the string is longer than `maxChars`, * it's cut off and '...' is appended to it. diff --git a/src/middleware.ts b/src/middleware.ts index 7a1bd5c4..03c66047 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -4,7 +4,7 @@ import { GITHUB_REPOSITORY_NAME, SESSION_COOKIE_KEY } from "./lib/shared/constants"; -import { Session } from "./lib/server/session.server"; +import { Session, type SerializedSession } from "./lib/server/session.server"; import type { ResponseCookie } from "next/dist/compiled/@edge-runtime/cookies"; @@ -33,6 +33,37 @@ function setRequestAndResponseCookies( return response; } +function isPrivateOrLocalIP(ip: string): boolean { + const privateIPv4Regex = + /^(10\.\d{1,3}\.\d{1,3}\.\d{1,3})|(172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3})|(192\.168\.\d{1,3}\.\d{1,3})|(127\.\d{1,3}\.\d{1,3}\.\d{1,3})$/; + const privateIPv6Regex = /^(fc00::\/7|fd[0-9a-f]{2})/; + const localIPv6Regex = /^::1$/; + + return ( + privateIPv4Regex.test(ip) || + privateIPv6Regex.test(ip) || + localIPv6Regex.test(ip) + ); +} + +async function getPublicIP(request: NextRequest) { + let publicIP = + request.headers.get("CF-Connecting-IP") ?? + request.headers.get("X-Forwarded-For")!; + + console.log({ + publicIP, + cf_ip: request.headers.get("CF-Connecting-IP") + }); + + if (isPrivateOrLocalIP(publicIP)) { + publicIP = await fetch("https://ipinfo.io/ip", { + cache: "force-cache" + }).then((r) => r.text()); + } + return publicIP; +} + export default async function middleware(request: NextRequest) { // Ignore images in PUBLIC FOLDER if ( @@ -63,10 +94,32 @@ export default async function middleware(request: NextRequest) { // Ensure a session is attached to each user const sessionId = request.cookies.get(SESSION_COOKIE_KEY)?.value; let session = sessionId ? await Session.get(sessionId) : null; - const { isBot } = userAgent(request); - console.log({ isBot }); + const { isBot, device } = userAgent(request); + let userDevice = device.type as SerializedSession["device"] | undefined; + if (!userDevice) { + const uaString = ( + request.headers.get("user-agent") ?? "unknown" + ).toLowerCase(); + + if ( + uaString.includes("mozilla") || + uaString.includes("chrome") || + uaString.includes("safari") + ) { + userDevice = "desktop"; + } else { + userDevice = "unknown"; + } + } + if (!session) { - session = await Session.create(isBot); + session = await Session.create({ + isBot, + userAgent: request.headers.get("user-agent") ?? "unknown", + device: userDevice, + ip: await getPublicIP(request), + lastAccess: new Date() + }); return setRequestAndResponseCookies(request, session.getCookie()); } @@ -74,9 +127,16 @@ export default async function middleware(request: NextRequest) { // only if the request doesn't come from a bot if (request.headers.get("accept")?.includes("text/html") && !isBot) { try { - await session.extendValidity(); + await session.extendValidity({ + newIp: await getPublicIP(request) + }); } catch (error) { - session = await Session.create(); + session = await Session.create({ + userAgent: request.headers.get("user-agent") ?? "unknown", + device: userDevice, + ip: await getPublicIP(request), + lastAccess: new Date() + }); } finally { return setRequestAndResponseCookies(request, session.getCookie()); }