From 91f8cf702968abcd916a9a3ec0e702091b13c5df Mon Sep 17 00:00:00 2001 From: breadddevv Date: Mon, 13 Jul 2026 17:25:54 +0100 Subject: [PATCH 1/3] Added user location, resorting to IP if it can't get the location --- components/topbar.tsx | 7 +- pages/_app.tsx | 23 ++- pages/api/user/sessions/index.ts | 2 + .../migration.sql | 173 ++++++++++++++++++ .../migration.sql | 9 + prisma/schema.prisma | 2 + utils/database.ts | 4 +- utils/randomText.ts | 1 + utils/session.ts | 14 +- 9 files changed, 226 insertions(+), 9 deletions(-) create mode 100644 prisma/migrations/20260713091923_added_locations/migration.sql create mode 100644 prisma/migrations/20260713092345_changed_to_region/migration.sql diff --git a/components/topbar.tsx b/components/topbar.tsx index a3876990..ea69730b 100644 --- a/components/topbar.tsx +++ b/components/topbar.tsx @@ -2,7 +2,6 @@ import type { NextPage } from "next"; import { Dialog, Transition } from "@headlessui/react"; import { loginState } from "@/state"; import { useRecoilState } from "recoil"; -import { Menu } from "@headlessui/react"; import Router, { useRouter } from "next/router"; import { useTheme } from "next-themes"; import { @@ -23,8 +22,8 @@ import { Fragment, useEffect, useRef, useState } from "react"; import toast from "react-hot-toast"; import { DiscordOAuthAvailable } from "@/hooks/useDiscordOAuth"; import { GoogleOAuthAvailable } from "@/hooks/useGoogleOAuth"; +import { CrownIcon } from "lucide-react"; import moment from "moment"; -import { CrownIcon, RefreshCwIcon } from "lucide-react"; type Session = { id: string; @@ -36,6 +35,8 @@ type Session = { createdAt: string; expiresAt: string; isCurrent: boolean; + region?: string; + country?: string; }; function DeviceIcon({ device }: { device: string | null }) { @@ -347,7 +348,7 @@ const Topbar: NextPage = () => { )}

- {s.ipAddress} · {moment(s.createdAt).fromNow()} + {s.country && s.region ? `${s.region}, ${s.country}` : `${s.ipAddress} · ${moment(s.createdAt).fromNow()}`}

{!s.isCurrent && ( diff --git a/pages/_app.tsx b/pages/_app.tsx index 7eaa6765..63dd55a9 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -137,7 +137,24 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { {!showLoader && (
- +
@@ -168,7 +185,7 @@ function Initializer() { mounted = false; try { posthogRef.current?.reset(); - } catch (e) {} + } catch (e) { } }; }, []); @@ -188,7 +205,7 @@ function Initializer() { } else { try { ph.reset(); - } catch (e) {} + } catch (e) { } } } } catch (e) { diff --git a/pages/api/user/sessions/index.ts b/pages/api/user/sessions/index.ts index 46357225..9ad00506 100644 --- a/pages/api/user/sessions/index.ts +++ b/pages/api/user/sessions/index.ts @@ -20,6 +20,8 @@ export async function handler( createdAt: s.createdAt, expiresAt: s.expiresAt, isCurrent: s.id === req.auth.session?.id, + country: s.country, + region: s.region })), }) } diff --git a/prisma/migrations/20260713091923_added_locations/migration.sql b/prisma/migrations/20260713091923_added_locations/migration.sql new file mode 100644 index 00000000..e3de3bfe --- /dev/null +++ b/prisma/migrations/20260713091923_added_locations/migration.sql @@ -0,0 +1,173 @@ +-- AlterTable +ALTER TABLE "AuthSession" ADD COLUMN "city" TEXT, +ADD COLUMN "country" TEXT; + +-- CreateTable +CREATE TABLE "form" ( + "id" UUID NOT NULL, + "workspaceGroupId" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "isEnabled" BOOLEAN NOT NULL, + "settings" JSONB NOT NULL, + "visibility" JSONB, + "createdById" BIGINT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "form_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "formQuestion" ( + "id" UUID NOT NULL, + "formId" UUID NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "type" TEXT NOT NULL, + "required" BOOLEAN NOT NULL DEFAULT false, + "position" INTEGER NOT NULL, + "settings" JSONB, + "visibilityRules" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedat" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "formQuestion_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "formSubmission" ( + "id" UUID NOT NULL, + "formId" UUID NOT NULL, + "workspaceGroupId" INTEGER NOT NULL, + "userId" BIGINT, + "robloxUserId" BIGINT, + "discordUserId" TEXT, + "status" TEXT NOT NULL DEFAULT 'SUBMITTED', + "metadata" JSONB, + "submittedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "formSubmission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "formAnswer" ( + "id" UUID NOT NULL, + "submissionId" UUID NOT NULL, + "questionId" UUID NOT NULL, + "value" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "formAnswer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "formReview" ( + "id" UUID NOT NULL, + "submissionId" UUID NOT NULL, + "reviewerId" BIGINT NOT NULL, + "vote" TEXT, + "score" INTEGER, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "formReview_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "formComment" ( + "id" UUID NOT NULL, + "submissionId" UUID NOT NULL, + "authorId" BIGINT NOT NULL, + "content" TEXT NOT NULL, + "internal" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "formComment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "formAuditLog" ( + "id" UUID NOT NULL, + "workspaceGroupId" INTEGER, + "formId" UUID, + "submissionId" UUID, + "actorId" BIGINT, + "action" TEXT NOT NULL, + "details" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "formAuditLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "form_workspaceGroupId_idx" ON "form"("workspaceGroupId"); + +-- CreateIndex +CREATE INDEX "formQuestion_formId_idx" ON "formQuestion"("formId"); + +-- CreateIndex +CREATE INDEX "formSubmission_formId_idx" ON "formSubmission"("formId"); + +-- CreateIndex +CREATE INDEX "formSubmission_workspaceGroupId_idx" ON "formSubmission"("workspaceGroupId"); + +-- CreateIndex +CREATE INDEX "formSubmission_status_idx" ON "formSubmission"("status"); + +-- CreateIndex +CREATE INDEX "formAnswer_submissionId_idx" ON "formAnswer"("submissionId"); + +-- CreateIndex +CREATE INDEX "formAnswer_questionId_idx" ON "formAnswer"("questionId"); + +-- CreateIndex +CREATE INDEX "formReview_submissionId_idx" ON "formReview"("submissionId"); + +-- CreateIndex +CREATE INDEX "formReview_reviewerId_idx" ON "formReview"("reviewerId"); + +-- CreateIndex +CREATE INDEX "formComment_submissionId_idx" ON "formComment"("submissionId"); + +-- CreateIndex +CREATE INDEX "formAuditLog_formId_idx" ON "formAuditLog"("formId"); + +-- CreateIndex +CREATE INDEX "formAuditLog_submissionId_idx" ON "formAuditLog"("submissionId"); + +-- CreateIndex +CREATE INDEX "formAuditLog_workspaceGroupId_idx" ON "formAuditLog"("workspaceGroupId"); + +-- AddForeignKey +ALTER TABLE "form" ADD CONSTRAINT "form_workspaceGroupId_fkey" FOREIGN KEY ("workspaceGroupId") REFERENCES "workspace"("groupId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "form" ADD CONSTRAINT "form_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "user"("userid") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "formQuestion" ADD CONSTRAINT "formQuestion_formId_fkey" FOREIGN KEY ("formId") REFERENCES "form"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "formSubmission" ADD CONSTRAINT "formSubmission_formId_fkey" FOREIGN KEY ("formId") REFERENCES "form"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "formAnswer" ADD CONSTRAINT "formAnswer_submissionId_fkey" FOREIGN KEY ("submissionId") REFERENCES "formSubmission"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "formAnswer" ADD CONSTRAINT "formAnswer_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "formQuestion"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "formReview" ADD CONSTRAINT "formReview_submissionId_fkey" FOREIGN KEY ("submissionId") REFERENCES "formSubmission"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "formComment" ADD CONSTRAINT "formComment_submissionId_fkey" FOREIGN KEY ("submissionId") REFERENCES "formSubmission"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "formAuditLog" ADD CONSTRAINT "formAuditLog_formId_fkey" FOREIGN KEY ("formId") REFERENCES "form"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "formAuditLog" ADD CONSTRAINT "formAuditLog_submissionId_fkey" FOREIGN KEY ("submissionId") REFERENCES "formSubmission"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260713092345_changed_to_region/migration.sql b/prisma/migrations/20260713092345_changed_to_region/migration.sql new file mode 100644 index 00000000..87daae10 --- /dev/null +++ b/prisma/migrations/20260713092345_changed_to_region/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `city` on the `AuthSession` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "AuthSession" DROP COLUMN "city", +ADD COLUMN "region" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 14acff7d..b4d16557 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -113,6 +113,8 @@ model AuthSession { browser String? os String? device String? + region String? + country String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt expiresAt DateTime diff --git a/utils/database.ts b/utils/database.ts index 6412c2c1..dd6ee79a 100644 --- a/utils/database.ts +++ b/utils/database.ts @@ -1,4 +1,4 @@ -import { PrismaClient, role, workspace, user, Session, SessionType, schedule, ActivitySession, document, wallPost, inactivityNotice, sessionUser, Quota, Ally, allyVisit, RoleMember } from "@prisma/client"; +import { PrismaClient, role, workspace, user, Session, SessionType, schedule, ActivitySession, document, wallPost, inactivityNotice, sessionUser, Quota, Ally, allyVisit, RoleMember, AuthSession } from "@prisma/client"; import { PrismaPg } from '@prisma/adapter-pg'; import { Pool } from 'pg'; @@ -22,5 +22,5 @@ const prisma = globalThis.prisma || new PrismaClient({ adapter }); if (process.env.NODE_ENV === 'development') globalThis.prisma = prisma -export type { role, workspace, user, Session, SessionType, schedule, ActivitySession, document, wallPost, inactivityNotice, sessionUser, Quota, Ally, allyVisit, RoleMember }; +export type { role, workspace, user, Session, SessionType, schedule, ActivitySession, document, wallPost, inactivityNotice, sessionUser, Quota, Ally, allyVisit, RoleMember, AuthSession }; export default prisma; \ No newline at end of file diff --git a/utils/randomText.ts b/utils/randomText.ts index b5c3442a..d208602c 100644 --- a/utils/randomText.ts +++ b/utils/randomText.ts @@ -176,6 +176,7 @@ const randomText = (name: string): string => { `The 2 AM club has a new member: ${name} 🎟️`, `Working in the shadows, ${name}? 👤`, `Nighttime is the best time for big ideas, right ${name}? 💡`, + `Sleepless on the onyx night, ${name}?` ]; // updated time logic const hour = new Date().getHours(); diff --git a/utils/session.ts b/utils/session.ts index f59ae853..891614c5 100644 --- a/utils/session.ts +++ b/utils/session.ts @@ -1,6 +1,12 @@ import * as crypto from 'crypto' import prisma from '@/utils/database' import { UAParser } from 'ua-parser-js' +import axios from 'axios' + +interface IpapiRes { + country_name: string, + region: string +} const SESSION_SECRET = process.env.SESSION_SECRET! @@ -108,7 +114,9 @@ async function createSession( ipAddress?: string, userAgent?: string ) { - const rawToken = generateToken() + const rawToken = generateToken(); + + const info = await axios.get('https://ipapi.co/json'); const { browser, os, device } = parseUA(userAgent) @@ -125,6 +133,8 @@ async function createSession( browser, os, device, + country: info.data.country_name, + region: info.data.region }, include: { @@ -273,6 +283,8 @@ async function listActiveSessions(userId: bigint) { userAgent: true, createdAt: true, expiresAt: true, + country: true, + region: true }, }) From 4957eb571183f983c86e52a583861d5aee17d1a9 Mon Sep 17 00:00:00 2001 From: breadddevv Date: Mon, 13 Jul 2026 23:46:20 +0100 Subject: [PATCH 2/3] fix for ratelimit --- pages/api/auth/checkOwner.ts | 3 +- utils/session.ts | 69 +++++++++++++++++++++++++++++++----- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/pages/api/auth/checkOwner.ts b/pages/api/auth/checkOwner.ts index 0383d551..16be2586 100644 --- a/pages/api/auth/checkOwner.ts +++ b/pages/api/auth/checkOwner.ts @@ -28,8 +28,7 @@ export async function handler(req: AuthenticatedRequest, res: NextApiResponse() +const GEO_CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24h + +function isPrivateOrLocalIp(ip: string): boolean { + return ( + ip === '::1' || + ip === '127.0.0.1' || + ip.startsWith('10.') || + ip.startsWith('192.168.') || + /^172\.(1[6-9]|2\d|3[0-1])\./.test(ip) + ) +} + +async function lookupGeo(ipAddress?: string): Promise { + const empty: GeoResult = { country: null, region: null } + + if (!ipAddress || isPrivateOrLocalIp(ipAddress)) { + return empty + } + + const cached = geoCache.get(ipAddress) + if (cached && cached.expiresAt > Date.now()) { + return cached.data + } + + try { + const res = await axios.get( + `https://ipapi.co/${ipAddress}/json/`, + { timeout: 2000 } + ) + + if ((res.data as any).error) { + console.warn('ipapi.co returned an error payload:', (res.data as any).reason) + return empty + } + + const geo: GeoResult = { + country: res.data.country_name ?? null, + region: res.data.region ?? null, + } + + geoCache.set(ipAddress, { data: geo, expiresAt: Date.now() + GEO_CACHE_TTL_MS }) + + return geo + } catch (err) { + console.error('ipapi.co lookup failed, continuing without geo data:', err) + return empty + } +} function generateToken(): string { const token = crypto.randomBytes(32).toString('hex') @@ -116,9 +171,8 @@ async function createSession( ) { const rawToken = generateToken(); - const info = await axios.get('https://ipapi.co/json'); - - const { browser, os, device } = parseUA(userAgent) + const { browser, os, device } = parseUA(userAgent); + const geo = await lookupGeo(ipAddress); const session = await prisma.authSession.create({ data: { @@ -133,8 +187,8 @@ async function createSession( browser, os, device, - country: info.data.country_name, - region: info.data.region + country: geo.country, + region: geo.region, }, include: { @@ -259,7 +313,6 @@ async function deleteOtherSessions( userId: bigint, sid: string ) { - return prisma.authSession.deleteMany({ where: { userId, @@ -329,5 +382,5 @@ export { deleteOtherSessions, deleteAllUserSessions, listActiveSessions, - purgeExpiredSessions, + purgeExpiredSessions } \ No newline at end of file From 68c9a1d3091099b7d095966be62b81b401c38c97 Mon Sep 17 00:00:00 2001 From: bread Date: Mon, 13 Jul 2026 23:49:36 +0100 Subject: [PATCH 3/3] Potential fix for pull request finding 'CodeQL / Server-side request forgery' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- utils/session.ts | 58 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/utils/session.ts b/utils/session.ts index d1002131..36d51796 100644 --- a/utils/session.ts +++ b/utils/session.ts @@ -2,6 +2,7 @@ import * as crypto from 'crypto' import prisma from '@/utils/database' import { UAParser } from 'ua-parser-js' import axios from 'axios' +import * as net from 'net' interface IpapiRes { country_name: string, @@ -23,31 +24,66 @@ const SESSION_SECRET = process.env.SESSION_SECRET!; const geoCache = new Map() const GEO_CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24h +function normalizeIp(rawIp: string): string { + const trimmed = rawIp.trim() + const mappedV4Prefix = '::ffff:' + if (trimmed.toLowerCase().startsWith(mappedV4Prefix)) { + return trimmed.slice(mappedV4Prefix.length) + } + return trimmed +} + function isPrivateOrLocalIp(ip: string): boolean { - return ( - ip === '::1' || - ip === '127.0.0.1' || - ip.startsWith('10.') || - ip.startsWith('192.168.') || - /^172\.(1[6-9]|2\d|3[0-1])\./.test(ip) - ) + if (ip === '::1' || ip === '127.0.0.1') return true + + if (net.isIP(ip) === 4) { + return ( + ip.startsWith('10.') || + ip.startsWith('192.168.') || + /^172\.(1[6-9]|2\d|3[0-1])\./.test(ip) || + ip.startsWith('127.') || + ip.startsWith('169.254.') + ) + } + + if (net.isIP(ip) === 6) { + const lower = ip.toLowerCase() + return ( + lower === '::1' || + lower.startsWith('fc') || + lower.startsWith('fd') || + lower.startsWith('fe80:') + ) + } + + return true +} + +function sanitizePublicIp(ipAddress?: string): string | null { + if (!ipAddress) return null + const normalized = normalizeIp(ipAddress) + if (!normalized || net.isIP(normalized) === 0) return null + if (isPrivateOrLocalIp(normalized)) return null + return normalized } async function lookupGeo(ipAddress?: string): Promise { const empty: GeoResult = { country: null, region: null } - if (!ipAddress || isPrivateOrLocalIp(ipAddress)) { + const safeIp = sanitizePublicIp(ipAddress) + if (!safeIp) { return empty } - const cached = geoCache.get(ipAddress) + const cached = geoCache.get(safeIp) if (cached && cached.expiresAt > Date.now()) { return cached.data } try { + const geoUrl = new URL(`/${encodeURIComponent(safeIp)}/json/`, 'https://ipapi.co') const res = await axios.get( - `https://ipapi.co/${ipAddress}/json/`, + geoUrl.toString(), { timeout: 2000 } ) @@ -61,7 +97,7 @@ async function lookupGeo(ipAddress?: string): Promise { region: res.data.region ?? null, } - geoCache.set(ipAddress, { data: geo, expiresAt: Date.now() + GEO_CACHE_TTL_MS }) + geoCache.set(safeIp, { data: geo, expiresAt: Date.now() + GEO_CACHE_TTL_MS }) return geo } catch (err) {