diff --git a/src/app/api/referral/[autonum]/route.ts b/src/app/api/referral/[autonum]/route.ts index 6f19fb9d..533e7a02 100644 --- a/src/app/api/referral/[autonum]/route.ts +++ b/src/app/api/referral/[autonum]/route.ts @@ -1,6 +1,6 @@ 'use server' -import { getPersonByAuto } from '@/app/utils/airtable' +import { getPersonByAuto } from '@/app/utils/server/airtable' import { redirect } from 'next/navigation' import { NextRequest } from 'next/server' diff --git a/src/app/api/slack_redirect/route.ts b/src/app/api/slack_redirect/route.ts index 3b964cf5..badb769a 100644 --- a/src/app/api/slack_redirect/route.ts +++ b/src/app/api/slack_redirect/route.ts @@ -1,8 +1,4 @@ -import { - getRedirectUri, - getSession, - createSlackSession, -} from '@/app/utils/auth' +import { getRedirectUri, createSlackSession } from '@/app/utils/server/auth' import { redirect } from 'next/navigation' import { NextRequest } from 'next/server' diff --git a/src/app/harbor/shipyard/ship-utils.ts b/src/app/harbor/shipyard/ship-utils.ts index 84e0ff2e..6bef12c2 100644 --- a/src/app/harbor/shipyard/ship-utils.ts +++ b/src/app/harbor/shipyard/ship-utils.ts @@ -1,6 +1,6 @@ 'use server' -import { getSelfPerson } from '@/app/utils/airtable' +import { getSelfPerson } from '@/app/utils/server/airtable' import { getSession } from '@/app/utils/auth' import { fetchShips, person } from '@/app/utils/data' import { getWakaSessions } from '@/app/utils/waka' @@ -222,6 +222,19 @@ export async function updateShip(ship: Ship) { throw error } + const existingShips = ( + await base()(shipsTableName) + .select({ filterByFormula: `{entrant__slack_id} = '${session.slackId}'` }) + .all() + ).filter((s) => s.id === ship.id) + if (!existingShips || existingShips.length === 0) { + const err = new Error( + `Tried to update a ghost ship: ${JSON.stringify(ship)}`, + ) + console.error(err) + throw err + } + console.log('updating!', ship) console.log(ship.yswsType) @@ -265,6 +278,19 @@ export async function stagedToShipped( return { error, ok: false } } + const existingShips = ( + await base()(shipsTableName) + .select({ filterByFormula: `{entrant__slack_id} = '${session.slackId}'` }) + .all() + ).filter((s) => s.id === ship.id) + if (!existingShips || existingShips.length === 0) { + const err = new Error( + `Tried to promote a ghost ship: ${JSON.stringify(ship)}`, + ) + console.error(err) + throw err + } + const p = await person() if (!p.fields.academy_completed) { @@ -334,6 +360,17 @@ export async function deleteShip(shipId: string) { throw error } + const existingShips = ( + await base()(shipsTableName) + .select({ filterByFormula: `{entrant__slack_id} = '${session.slackId}'` }) + .all() + ).filter((s) => s.id === shipId) + if (!existingShips || existingShips.length === 0) { + const err = new Error(`Tried to delete a ghost ship: ${shipId}`) + console.error(err) + throw err + } + await new Promise((resolve, reject) => { base()(shipsTableName).update( [ diff --git a/src/app/harbor/shop/shop-utils.ts b/src/app/harbor/shop/shop-utils.ts index 7b357f14..e15a1b10 100644 --- a/src/app/harbor/shop/shop-utils.ts +++ b/src/app/harbor/shop/shop-utils.ts @@ -2,7 +2,7 @@ import Airtable from 'airtable' import { getSession } from '@/app/utils/auth' -import { getSelfPerson } from '@/app/utils/airtable' +import { getSelfPerson } from '@/app/utils/server/airtable' import { NextResponse } from 'next/server' const base = () => { @@ -32,19 +32,6 @@ export interface ShopItem { maximumHoursEstimated: number } -export async function getPerson() { - const session = await getSession() - if (!('slackId' in session)) { - return - } - const person = await getSelfPerson(session.slackId) - if (!person) { - return NextResponse.json( - { error: "i don't even know who you are" }, - { status: 418 }, - ) - } -} export async function getShop(): Promise { const items: ShopItem[] = [] diff --git a/src/app/utils/airtable.ts b/src/app/utils/airtable.ts index ef1c63ff..29c2991d 100644 --- a/src/app/utils/airtable.ts +++ b/src/app/utils/airtable.ts @@ -2,159 +2,8 @@ import { getSession } from './auth' import { person, updateShowInLeaderboard } from './data' +import { getSelfPerson } from './server/airtable' -export const getSelfPerson = async (slackId: string) => { - const session = await getSession() - if (!session) { - throw new Error('No session when trying to get self person') - } - if (session.slackId !== slackId) { - const err = new Error('Session slackId does not match provided slackId') - console.error(err) - throw err - } - - const url = `https://middleman.hackclub.com/airtable/v0/${process.env.BASE_ID}/people` - const filterByFormula = encodeURIComponent(`{slack_id} = '${slackId}'`) - const response = await fetch(`${url}?filterByFormula=${filterByFormula}`, { - method: 'GET', - headers: { - Authorization: `Bearer ${process.env.AIRTABLE_API_KEY}`, - 'Content-Type': 'application/json', - 'User-Agent': 'highseas.hackclub.com (getSelfPerson)', - }, - }) - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - let data - try { - data = await response.json() - } catch (e) { - console.error(e, await response.text()) - throw e - } - return data.records[0] -} - -export const getSignpostUpdates = async () => { - const url = `https://middleman.hackclub.com/airtable/v0/${process.env.BASE_ID}/signpost` - const response = await fetch(url, { - method: 'GET', - headers: { - Authorization: `Bearer ${process.env.AIRTABLE_API_KEY}`, - 'Content-Type': 'application/json', - 'User-Agent': 'highseas.hackclub.com (getSignpostUpdates)', - }, - }) - - if (!response.ok) { - console.log(response) - throw new Error(`HTTP error! status: ${response.status}`) - } - - let data - try { - data = await response.json() - } catch (e) { - console.error(e, await response.text()) - throw e - } - - return data.records -} - -export async function getPersonByAuto(num: string): Promise<{ - slackId: string -} | null> { - const baseId = process.env.BASE_ID - const apiKey = process.env.AIRTABLE_API_KEY - const table = 'people' - - const url = `https://middleman.hackclub.com/airtable/v0/${baseId}/${table}?filterByFormula={autonumber}='${encodeURIComponent(num)}'` - - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - 'User-Agent': 'highseas.hackclub.com (getPersonByMagicToken)', - }, - }) - - if (!response.ok) { - const err = new Error(`Airtable API error: ${await response.text()}`) - console.error(err) - throw err - } - - const data = await response.json() - if (!data.records || data.records.length === 0) return null - - const id = data.records[0].id - const email = data.records[0].fields.email - const slackId = data.records[0].fields.slack_id - - if (!id || !email || !slackId) return null - - return { slackId } -} - -export async function getPersonByMagicToken(token: string): Promise<{ - id: string - email: string - slackId: string -} | null> { - const baseId = process.env.BASE_ID - const apiKey = process.env.AIRTABLE_API_KEY - const table = 'people' - - const url = `https://middleman.hackclub.com/airtable/v0/${baseId}/${table}?filterByFormula={magic_auth_token}='${encodeURIComponent(token)}'` - - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - 'User-Agent': 'highseas.hackclub.com (getPersonByMagicToken)', - }, - }) - - if (!response.ok) { - const err = new Error(`Airtable API error: ${await response.text()}`) - console.error(err) - throw err - } - - const data = await response.json() - if (!data.records || data.records.length === 0) return null - - const id = data.records[0].id - const email = data.records[0].fields.email - const slackId = data.records[0].fields.slack_id - - if (!id || !email || !slackId) return null - - return { id, email, slackId } -} - -export async function getSelfPersonIdentifier(slackId: string) { - const person = await getSelfPerson(slackId) - return person.fields.identifier -} - -export const getPersonTicketBalanceAndTutorialStatutWowThisMethodNameSureIsLongPhew = - async ( - slackId: string, - ): Promise<{ tickets: number; hasCompletedTutorial: boolean }> => { - const person = await getSelfPerson(slackId) - const tickets = person.fields.settled_tickets as number - const hasCompletedTutorial = person.fields.academy_completed === true - - return { tickets, hasCompletedTutorial } - } - -// deprecate export async function getVotesRemainingForNextPendingShip(slackId: string) { const person = await getSelfPerson(slackId) return person['fields']['votes_remaining_for_next_pending_ship'] as number @@ -174,7 +23,6 @@ export interface SafePerson { referralLink: string } -// Good method export async function safePerson(): Promise { const record = await person() diff --git a/src/app/utils/auth.ts b/src/app/utils/auth.ts index 63f7cdb8..2b207cdc 100644 --- a/src/app/utils/auth.ts +++ b/src/app/utils/auth.ts @@ -1,190 +1,28 @@ 'use server' +/* Functions exported from this module will be exposed + * as HTTP endpoints. Dragons be here. + */ + +import { cookies } from 'next/headers' +import { getPersonByMagicToken } from './server/airtable' +import { getSelfPerson } from './server/airtable' +import { + HsSession, + sessionCookieName, + signAndSet, + verifySession, +} from './server/auth' -import { cookies, headers } from 'next/headers' -import { getPersonByMagicToken, getSelfPerson } from './airtable' - -export interface HsSession { - /// The Person record ID in the high seas base - personId: string - - authType: 'slack-oauth' | 'magic-link' | 'impersonation' - slackId: string - name?: string - firstName?: string - lastName?: string - givenName?: string - email: string - picture?: string - sig?: string -} - -const sessionCookieName = 'hs-session' - -function parseJwt(token: string) { - const base64Url = token.split('.')[1] - const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') - const jsonPayload = decodeURIComponent( - atob(base64) - .split('') - .map(function (c) { - return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) - }) - .join(''), - ) - - return JSON.parse(jsonPayload) -} - -async function hashSession(session: HsSession) { - const str = [ - session.personId, - session.authType, - session.slackId, - session.name || '', - session.firstName || '', - session.lastName || '', - session.givenName || '', - session.email, - session.picture || '', - ].join('|') - - const authSecret = process.env.AUTH_SECRET - if (!authSecret) throw new Error('Env AUTH_SECRET is not set') - - // Convert string and key to Uint8Array - const encoder = new TextEncoder() - const data = encoder.encode(str) - const keyData = encoder.encode(authSecret) - - // Import the secret key - const key = await crypto.subtle.importKey( - 'raw', - keyData, - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'], - ) - - // Generate HMAC - const hashBuffer = await crypto.subtle.sign('HMAC', key, data) - - // Convert hash to hex string - const hashArray = Array.from(new Uint8Array(hashBuffer)) - const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') - - return hashHex -} - -export async function impersonate(slackId: string) { - // only allow impersonation in development while testing - if (process.env.NODE_ENV !== 'development') { - return - } - - // look for airtable user with this record - const person = await getSelfPerson(slackId) - const id = person.id - const email = person.fields.email - - const session: HsSession = { - personId: id, - authType: 'impersonation', - slackId, - email, - } - - await signAndSet(session) -} - -async function signAndSet(session: HsSession) { - session.sig = await hashSession(session) - - cookies().set(sessionCookieName, JSON.stringify(session), { - secure: process.env.NODE_ENV !== 'development', - httpOnly: true, - maxAge: 60 * 60 * 24 * 7, - }) -} - -export async function verifySession( - session: HsSession, -): Promise { - const hashCheck = await hashSession(session) - - if (session.sig === hashCheck) { - return session - } else { - return null - } -} - -export async function createSlackSession(slackOpenidToken: string) { +export async function getSession(): Promise { try { - const payload = parseJwt(slackOpenidToken) - - if (!payload) throw new Error('Failed to decode the Slack OpenId JWT') - - let person = (await getSelfPerson(payload.sub as string)) as any - - if (!person) { - const body = JSON.stringify({ - performUpsert: { - fieldsToMergeOn: ['email'], - }, - records: [ - { - fields: { - email: payload.email, - slack_id: payload.sub, - }, - }, - ], - }) - - // Let's create a Person record - const result = await fetch( - 'https://middleman.hackclub.com/airtable/v0/appTeNFYcUiYfGcR6/people', - { - method: 'POST', - headers: { - Authorization: `Bearer ${process.env.AIRTABLE_API_KEY}`, - 'Content-Type': 'application/json', - 'User-Agent': 'highseas.hackclub.com (createPersonRecord)', - }, - body, - }, - ).then((d) => d.json()) - - console.error('MAXSIGNINTEST', { - payload, - payloadSub: payload.sub, - body, - atApiKey: process.env.AIRTABLE_API_KEY, - result, - }) - - person = result.records[0] - } - - if (!person) - throw new Error( - "Couldn't find OR create a person! :(( Sad face. Tell malted that Airtable broke", - ) - - const sessionData: HsSession = { - personId: person.id, - authType: 'slack-oauth', - slackId: payload.sub as string, - email: payload.email as string, - name: payload.name as string, - givenName: payload.given_name as string, - picture: payload.picture as string, - } + const sessionCookie = cookies().get(sessionCookieName) + if (!sessionCookie) return null - await signAndSet(sessionData) + const unsafeSession = JSON.parse(sessionCookie.value) + return verifySession(unsafeSession) } catch (error) { - console.error('Error creating Slack session:', error) - throw error + console.error('Error verifying session:', error) + return null } } @@ -212,32 +50,23 @@ export async function createMagicSession(magicCode: string) { } } -export async function getSession(): Promise { - try { - const sessionCookie = cookies().get(sessionCookieName) - if (!sessionCookie) return null - - const unsafeSession = JSON.parse(sessionCookie.value) - return verifySession(unsafeSession) - } catch (error) { - console.error('Error verifying session:', error) - return null +export async function impersonate(slackId: string) { + // only allow impersonation in development while testing + if (process.env.NODE_ENV !== 'development') { + return } -} -export async function deleteSession() { - const cookieKeys = - 'academy-completed ships signpost-feed tickets verification waka' - .split(' ') - .forEach((key) => cookies().delete(key)) - cookies().delete(sessionCookieName) -} + // look for airtable user with this record + const person = await getSelfPerson(slackId) + const id = person.id + const email = person.fields.email -export async function getRedirectUri(): Promise { - const headersList = headers() - const host = headersList.get('host') || '' - const proto = headersList.get('x-forwarded-proto') || 'http' - const uri = encodeURIComponent(`${proto}://${host}/api/slack_redirect`) + const session: HsSession = { + personId: id, + authType: 'impersonation', + slackId, + email, + } - return uri + await signAndSet(session) } diff --git a/src/app/utils/server/airtable.ts b/src/app/utils/server/airtable.ts new file mode 100644 index 00000000..83434acf --- /dev/null +++ b/src/app/utils/server/airtable.ts @@ -0,0 +1,125 @@ +import 'server-only' + +export const getSelfPerson = async (slackId: string) => { + if (!/^[UW][A-Z0-9]{8,11}$/.test(slackId)) { + const err = new Error( + `Invalid Slack ID passed to getSelfPerson: ${slackId}`, + ) + console.error(err) + throw err + } + + const url = `https://middleman.hackclub.com/airtable/v0/${process.env.BASE_ID}/people` + const filterByFormula = encodeURIComponent(`{slack_id} = '${slackId}'`) + const response = await fetch(`${url}?filterByFormula=${filterByFormula}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${process.env.AIRTABLE_API_KEY}`, + 'Content-Type': 'application/json', + 'User-Agent': 'highseas.hackclub.com (getSelfPerson)', + }, + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + let data + try { + data = await response.json() + } catch (e) { + console.error(e, await response.text()) + throw e + } + return data.records[0] +} + +export async function getPersonByAuto(num: string): Promise<{ + slackId: string +} | null> { + const baseId = process.env.BASE_ID + const apiKey = process.env.AIRTABLE_API_KEY + const table = 'people' + + if (!Number(num)) { + const err = new Error( + `Non-numeric getPersonByAuto parameter passed: ${num}`, + ) + console.error(err) + throw err + } + + const url = `https://middleman.hackclub.com/airtable/v0/${baseId}/${table}?filterByFormula={autonumber}='${encodeURIComponent(num)}'` + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'User-Agent': 'highseas.hackclub.com (getPersonByAuto)', + }, + }) + + if (!response.ok) { + const err = new Error(`Airtable API error: ${await response.text()}`) + console.error(err) + throw err + } + + const data = await response.json() + if (!data.records || data.records.length === 0) return null + + const id = data.records[0].id + const email = data.records[0].fields.email + const slackId = data.records[0].fields.slack_id + + if (!id || !email || !slackId) return null + + return { slackId } +} + +export async function getPersonByMagicToken(token: string): Promise<{ + id: string + email: string + slackId: string +} | null> { + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + if (!uuidRegex.test(token)) { + const err = new Error( + `Invalid magic token passed to getPersonByMagicToken: ${token}`, + ) + console.error(err) + throw err + } + + const baseId = process.env.BASE_ID + const apiKey = process.env.AIRTABLE_API_KEY + const table = 'people' + + const url = `https://middleman.hackclub.com/airtable/v0/${baseId}/${table}?filterByFormula={magic_auth_token}='${encodeURIComponent(token)}'` + + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'User-Agent': 'highseas.hackclub.com (getPersonByMagicToken)', + }, + }) + + if (!response.ok) { + const err = new Error(`Airtable API error: ${await response.text()}`) + console.error(err) + throw err + } + + const data = await response.json() + if (!data.records || data.records.length === 0) return null + + const id = data.records[0].id + const email = data.records[0].fields.email + const slackId = data.records[0].fields.slack_id + + if (!id || !email || !slackId) return null + + return { id, email, slackId } +} diff --git a/src/app/utils/server/auth.ts b/src/app/utils/server/auth.ts new file mode 100644 index 00000000..82c14c96 --- /dev/null +++ b/src/app/utils/server/auth.ts @@ -0,0 +1,184 @@ +import 'server-only' + +import { cookies, headers } from 'next/headers' +import { getSelfPerson } from './airtable' + +export interface HsSession { + /// The Person record ID in the high seas base + personId: string + + authType: 'slack-oauth' | 'magic-link' | 'impersonation' + slackId: string + name?: string + firstName?: string + lastName?: string + givenName?: string + email: string + picture?: string + sig?: string +} +export const sessionCookieName = 'hs-session' + +function parseJwt(token: string) { + const base64Url = token.split('.')[1] + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') + const jsonPayload = decodeURIComponent( + atob(base64) + .split('') + .map(function (c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) + }) + .join(''), + ) + + return JSON.parse(jsonPayload) +} + +async function hashSession(session: HsSession) { + const str = [ + session.personId, + session.authType, + session.slackId, + session.name || '', + session.firstName || '', + session.lastName || '', + session.givenName || '', + session.email, + session.picture || '', + ].join('|') + + const authSecret = process.env.AUTH_SECRET + if (!authSecret) throw new Error('Env AUTH_SECRET is not set') + + // Convert string and key to Uint8Array + const encoder = new TextEncoder() + const data = encoder.encode(str) + const keyData = encoder.encode(authSecret) + + // Import the secret key + const key = await crypto.subtle.importKey( + 'raw', + keyData, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ) + + // Generate HMAC + const hashBuffer = await crypto.subtle.sign('HMAC', key, data) + + // Convert hash to hex string + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') + + return hashHex +} + +export async function verifySession( + session: HsSession, +): Promise { + const hashCheck = await hashSession(session) + + if (session.sig === hashCheck) { + return session + } else { + return null + } +} + +export async function deleteSession() { + const cookieKeys = + 'academy-completed ships signpost-feed tickets verification waka' + .split(' ') + .forEach((key) => cookies().delete(key)) + cookies().delete(sessionCookieName) +} + +export async function signAndSet(session: HsSession) { + session.sig = await hashSession(session) + + cookies().set(sessionCookieName, JSON.stringify(session), { + secure: process.env.NODE_ENV !== 'development', + httpOnly: true, + maxAge: 60 * 60 * 24 * 7, + }) +} + +export async function createSlackSession(slackOpenidToken: string) { + try { + const payload = parseJwt(slackOpenidToken) + + if (!payload) throw new Error('Failed to decode the Slack OpenId JWT') + + let person = (await getSelfPerson(payload.sub as string)) as any + + if (!person) { + const body = JSON.stringify({ + performUpsert: { + fieldsToMergeOn: ['email'], + }, + records: [ + { + fields: { + email: payload.email, + slack_id: payload.sub, + }, + }, + ], + }) + + // Let's create a Person record + const result = await fetch( + 'https://middleman.hackclub.com/airtable/v0/appTeNFYcUiYfGcR6/people', + { + method: 'POST', + headers: { + Authorization: `Bearer ${process.env.AIRTABLE_API_KEY}`, + 'Content-Type': 'application/json', + 'User-Agent': 'highseas.hackclub.com (createPersonRecord)', + }, + body, + }, + ).then((d) => d.json()) + + console.error('MAXSIGNINTEST', { + payload, + payloadSub: payload.sub, + body, + atApiKey: process.env.AIRTABLE_API_KEY, + result, + }) + + person = result.records[0] + } + + if (!person) + throw new Error( + "Couldn't find OR create a person! :(( Sad face. Tell malted that Airtable broke", + ) + + const sessionData: HsSession = { + personId: person.id, + authType: 'slack-oauth', + slackId: payload.sub as string, + email: payload.email as string, + name: payload.name as string, + givenName: payload.given_name as string, + picture: payload.picture as string, + } + + await signAndSet(sessionData) + } catch (error) { + console.error('Error creating Slack session:', error) + throw error + } +} + +export async function getRedirectUri(): Promise { + const headersList = headers() + const host = headersList.get('host') || '' + const proto = headersList.get('x-forwarded-proto') || 'http' + const uri = encodeURIComponent(`${proto}://${host}/api/slack_redirect`) + + return uri +}