Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 36 additions & 24 deletions api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { ORIGIN } from '/api/lib/env.ts'
import { fetchJson } from '/api/lib/fetcher.ts'
import {
decodeGoogleJWT,
generateStateToken,
getGoogleAuthUrl,
GOOGLE_OAUTH_CONFIG,
verifyGoogleToken,
verifyState,
} from '/api/lib/google-oauth.ts'
import { respond } from '@01edu/api/response'
Expand Down Expand Up @@ -78,36 +78,48 @@ export async function handleGoogleCallback(
}

// Exchange the code for tokens
const tokenResponse = await fetch(GOOGLE_OAUTH_CONFIG.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
code,
client_id: GOOGLE_OAUTH_CONFIG.clientId,
client_secret: GOOGLE_OAUTH_CONFIG.clientSecret,
redirect_uri: GOOGLE_OAUTH_CONFIG.redirectUri,
grant_type: 'authorization_code',
}),
})

if (!tokenResponse.ok) {
const errorBody = await tokenResponse.text().catch(() => 'unknown')
let tokens: GoogleTokens
try {
tokens = await fetchJson<GoogleTokens>(
GOOGLE_OAUTH_CONFIG.tokenEndpoint,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
code,
client_id: GOOGLE_OAUTH_CONFIG.clientId,
client_secret: GOOGLE_OAUTH_CONFIG.clientSecret,
redirect_uri: GOOGLE_OAUTH_CONFIG.redirectUri,
grant_type: 'authorization_code',
}),
},
)
} catch (err) {
log.error('oauth-token-exchange-failed', {
status: tokenResponse.status,
body: errorBody,
error: err,
})
const message = err instanceof Error ? err.message : 'Unknown error'
throw new respond.UnauthorizedError({
message: 'Failed to exchange authorization code',
details: 'Could not obtain access token from Google',
message: `Failed to exchange authorization code: ${message}`,
error: err,
})
}

const tokens = await tokenResponse.json() as GoogleTokens

// Verify and decode the ID token
await verifyGoogleToken(tokens.id_token)
try {
await fetchJson<unknown>(
`https://oauth2.googleapis.com/tokeninfo?id_token=${tokens.id_token}`,
)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
throw new respond.UnauthorizedError({
message: `Failed to verify Google ID token: ${message}`,
error: err,
})
}

const userInfo = decodeGoogleJWT(tokens.id_token) as GoogleUserInfo
userInfo.picture &&= await savePicture(userInfo.picture)
const sessionId = await authenticateOauthUser(userInfo)
Expand Down
10 changes: 7 additions & 3 deletions api/clickhouse-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ async function insertLogs(
data: LogsInput,
) {
const logsToInsert = Array.isArray(data) ? data : [data]
if (logsToInsert.length === 0) throw respond.NoContent()
if (logsToInsert.length === 0) return respond.NoContent()

const rows = logsToInsert.map((log) => {
const traceHex = numberToHex128(log.trace_id)
Expand All @@ -106,7 +106,9 @@ async function insertLogs(
return respond.OK()
} catch (error) {
console.error('Error inserting logs into ClickHouse:', { error })
throw respond.InternalServerError()
throw new respond.InternalServerErrorError({
message: 'Failed to insert logs into ClickHouse',
})
}
}

Expand Down Expand Up @@ -213,7 +215,9 @@ async function getLogs(dep: string, data: FetchTablesParams) {
return (await rs.json<Log>()).data
} catch (e) {
console.error('ClickHouse query failed', { error: e, query, params })
throw respond.InternalServerError()
throw new respond.InternalServerErrorError({
message: 'Failed to fetch logs from ClickHouse',
})
}
}

Expand Down
56 changes: 30 additions & 26 deletions api/fix-query.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { render } from '@deno/gfm'
import { promptTemplate } from '/api/fix-query-prompt.ts'
import { fetchJson } from '/api/lib/fetcher.ts'
import { GEMINI_API_KEY, GEMINI_MODEL } from '/api/lib/env.ts'
import { AIAnalysisCacheCollection } from '/api/schema.ts'
import { log } from '/api/lib/logger.ts'
import { respond } from '@01edu/api/response'

const GEMINI_URL =
`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:streamGenerateContent?alt=json&key=${GEMINI_API_KEY}`
Expand Down Expand Up @@ -35,33 +37,35 @@ async function callGemini(payload: string, thinkingLevel: string) {
promptLength: prompt.length,
})

const res = await fetch(GEMINI_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: { thinkingConfig: { thinkingLevel } },
}),
})

if (!res.ok) {
const body = await res.text()
log.error('gemini-request-failed', { status: res.status, body })
throw new Error(`Gemini API error ${res.status}: ${body}`)
try {
const chunks = await fetchJson<{
candidates?: {
content?: { parts?: { text?: string }[] }
}[]
}[]>(GEMINI_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: { thinkingConfig: { thinkingLevel } },
}),
})

const markdown = chunks
.flatMap((chunk) => chunk.candidates ?? [])
.flatMap((candidate) => candidate.content?.parts ?? [])
.map((part) => part.text ?? '')
.join('')

return render(markdown)
} catch (err) {
log.error('gemini-request-failed', { error: err })
const message = err instanceof Error ? err.message : 'Unknown error'
throw new respond.InternalServerErrorError({
message: `Gemini request failed: ${message}`,
error: err,
})
}

// streamGenerateContent with alt=json returns a JSON array of response chunks
const chunks: {
candidates?: { content?: { parts?: { text?: string }[] } }[]
}[] = await res.json()

const markdown = chunks
.flatMap((chunk) => chunk.candidates ?? [])
.flatMap((candidate) => candidate.content?.parts ?? [])
.map((part) => part.text ?? '')
.join('')

return render(markdown)
}

export async function analyzeQueryWithAI(
Expand Down
55 changes: 55 additions & 0 deletions api/lib/fetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
export class FetchNetworkError extends Error {
constructor(
public url: string,
public override cause?: unknown,
) {
super(`Network error while fetching ${url}`)
}
}

export class FetchHttpError extends Error {
constructor(
public url: string,
public status: number,
public body: string,
) {
super(`HTTP error ${status} while fetching ${url}`)
}
}

export class FetchJsonError extends Error {
constructor(
public url: string,
public status: number,
public body: string,
public override cause?: unknown,
) {
super(`Failed to parse JSON from ${url} (status ${status})`)
}
}

export async function fetchJson<T>(
url: string | URL,
init?: RequestInit,
): Promise<T> {
const urlStr = String(url)
let res: Response

try {
res = await fetch(url, init)
} catch (err) {
throw new FetchNetworkError(urlStr, err)
}

const body = await res.text()

if (!res.ok) {
throw new FetchHttpError(urlStr, res.status, body)
}

try {
return JSON.parse(body) as T
} catch (err) {
throw new FetchJsonError(urlStr, res.status, body, err)
}
}
10 changes: 0 additions & 10 deletions api/lib/google-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,16 +73,6 @@ export function getGoogleAuthUrl(state: string): string {
return `${GOOGLE_OAUTH_CONFIG.authEndpoint}?${params.toString()}`
}

export async function verifyGoogleToken(idToken: string) {
const response = await fetch(
`https://oauth2.googleapis.com/tokeninfo?id_token=${idToken}`,
)
if (!response.ok) {
throw new Error('Invalid token')
}
return response.json()
}

export function decodeGoogleJWT(idToken: string) {
const [, payload] = idToken.split('.')
if (!payload) throw new Error('Invalid ID token format')
Expand Down
36 changes: 20 additions & 16 deletions api/lmdb-store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { FetchHttpError, fetchJson } from '/api/lib/fetcher.ts'
import { STORE_SECRET, STORE_URL } from '/api/lib/env.ts'
import { log } from '/api/lib/logger.ts'
import { respond } from '@01edu/api/response'

const headers = { authorization: `Bearer ${STORE_SECRET}` }
export const getOne = async <T>(
Expand All @@ -8,19 +10,21 @@ export const getOne = async <T>(
): Promise<T | null> => {
const url = `${STORE_URL}/${path}/${encodeURIComponent(String(id))}`
try {
const res = await fetch(url, { headers })
if (res.status === 404) return null
if (!res.ok) {
log.error('store-get-one-failed', { path, id, status: res.status })
}
return res.json()
return await fetchJson<T>(url, { headers })
} catch (err) {
if (err instanceof FetchHttpError && err.status === 404) {
return null
}
log.error('store-get-one-error', {
path,
id,
error: err instanceof Error ? err.message : String(err),
error: err,
})
const message = err instanceof Error ? err.message : 'Unknown error'
throw new respond.InternalServerErrorError({
message: `Store request failed: ${message}`,
error: err,
})
throw err
}
}

Expand All @@ -29,18 +33,18 @@ export const get = async <T>(
params?: { q?: string; limit?: number; from?: number },
): Promise<T> => {
const q = new URLSearchParams(params as unknown as Record<string, string>)
const url = `${STORE_URL}/${path}/?${q}`
try {
const res = await fetch(url, { headers })
if (!res.ok) {
log.error('store-get-failed', { path, status: res.status })
}
return res.json()
return await fetchJson<T>(`${STORE_URL}/${path}/?${q}`, { headers })
} catch (err) {
log.error('store-get-error', {
path,
error: err instanceof Error ? err.message : String(err),
params,
error: err,
})
const message = err instanceof Error ? err.message : 'Unknown error'
throw new respond.InternalServerErrorError({
message: `Store request failed: ${message}`,
error: err,
})
throw err
}
}
3 changes: 2 additions & 1 deletion api/picture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const savePicture = async (url?: string) => {
url,
error: err instanceof Error ? err.message : String(err),
})
return undefined
}
}

Expand All @@ -44,6 +45,6 @@ export const getPicture = async (hash: string) => {
hash,
error: err instanceof Error ? err.message : String(err),
})
throw err
return new Response('Picture not found', { status: 404 })
}
}
Loading
Loading