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
6 changes: 6 additions & 0 deletions __tests__/api/generate-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ jest.mock("@/lib/book-utils", () => ({
})),
}))

jest.mock("@/lib/location-utils", () => ({
getWhimsicalLocation: jest.fn().mockResolvedValue("Mock Town"),
}))

describe("Generate API Auth", () => {
const mockCookies = {
getAll: jest.fn(),
Expand Down Expand Up @@ -101,6 +105,7 @@ describe("Generate API Auth", () => {
anonymousId: "anon-123",
}),
url: "http://localhost/api/books/generate",
headers: new Headers(),
} as unknown as Request

await POST(request)
Expand Down Expand Up @@ -131,6 +136,7 @@ describe("Generate API Auth", () => {
anonymousId: "anon-123",
}),
url: "http://localhost/api/books/generate",
headers: new Headers(),
} as unknown as Request

await POST(request)
Expand Down
115 changes: 115 additions & 0 deletions __tests__/api/generate-location.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { POST } from "@/app/api/books/generate/route"
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"
import { cookies } from "next/headers"
import { getWhimsicalLocation } from "@/lib/location-utils"

// Mock Supabase
jest.mock("@supabase/auth-helpers-nextjs", () => ({
createRouteHandlerClient: jest.fn(),
}))

jest.mock("@supabase/supabase-js", () => ({
createClient: jest.fn(() => ({
from: jest.fn(), // We won't strictly test the admin client anymore as we switched to route handler client
})),
}))

// Mock Next.js headers
jest.mock("next/headers", () => ({
cookies: jest.fn(),
}))

// Mock internal libs
jest.mock("@/lib/id_generator", () => ({
generateBookId: jest.fn().mockResolvedValue("TEST-CODE-LOC"),
}))
jest.mock("@/lib/book-utils", () => ({
parseBookMetadata: jest.fn(() => ({
title: "Test Book Location",
author: "Test Author",
cover_url: "http://example.com/cover.jpg",
isbn: "0000000001",
})),
}))

// Mock location utils specifically to verify usage
jest.mock("@/lib/location-utils", () => ({
getWhimsicalLocation: jest.fn(),
}))

describe("Generate API Location Privacy", () => {
const mockCookies = {
getAll: jest.fn(),
get: jest.fn(),
}
const mockSupabase = {
auth: {
getUser: jest.fn(),
},
from: jest.fn(),
}

beforeEach(() => {
jest.clearAllMocks()
;(cookies as jest.Mock).mockResolvedValue(mockCookies)
;(createRouteHandlerClient as jest.Mock).mockReturnValue(mockSupabase)
;(getWhimsicalLocation as jest.Mock).mockResolvedValue("Whimsical Town, USA")

// Mock auth
mockSupabase.auth.getUser.mockResolvedValue({
data: { user: { id: "user-loc-123" } },
error: null,
})

// Mock DB calls
const mockInsertSightings = jest.fn().mockResolvedValue({ error: null })
const mockInsertBooks = jest.fn().mockReturnValue({
select: jest.fn().mockReturnValue({
single: jest.fn().mockResolvedValue({ data: { id: "book-loc-123" }, error: null }),
}),
})

mockSupabase.from.mockImplementation((table) => {
if (table === "books") return { insert: mockInsertBooks }
if (table === "sightings") return { insert: mockInsertSightings }
return { select: jest.fn() }
})

// Attach helper to access mocks
// @ts-ignore
mockSupabase._mockInsertBooks = mockInsertBooks
// @ts-ignore
mockSupabase._mockInsertSightings = mockInsertSightings
})

it("should use whimsical location instead of raw coordinates in DB inserts", async () => {
const request = {
json: jest.fn().mockResolvedValue({
book: { title: "Test Book" },
location: { lat: 38.9, long: -77.0 },
anonymousId: "anon-loc-123",
}),
url: "http://localhost/api/books/generate",
} as unknown as Request

await POST(request)

// Verify getWhimsicalLocation was called with correct coords
expect(getWhimsicalLocation).toHaveBeenCalledWith(38.9, -77.0)

// Verify Books Insert
// @ts-ignore
const bookInsertCall = mockSupabase._mockInsertBooks.mock.calls[0][0]
expect(bookInsertCall.location).toBe("Whimsical Town, USA")
// Ensure we still store raw coords for mapping
expect(bookInsertCall.lat).toBe(38.9)
expect(bookInsertCall.lon).toBe(-77.0)

// Verify Sightings Insert
// @ts-ignore
const sightingInsertCall = mockSupabase._mockInsertSightings.mock.calls[0][0]
expect(sightingInsertCall.location).toBe("Whimsical Town, USA")
expect(sightingInsertCall.lat).toBe(38.9)
expect(sightingInsertCall.lon).toBe(-77.0)
})
})
12 changes: 12 additions & 0 deletions __tests__/app/api/books/generate/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ jest.mock("@supabase/auth-helpers-nextjs", () => ({
getSession: jest.fn().mockResolvedValue({ data: { session: null } }),
getUser: jest.fn().mockResolvedValue({ data: { user: null } }),
},
from: jest.fn().mockReturnValue({
insert: jest.fn().mockReturnValue({
select: jest.fn().mockReturnValue({
single: jest.fn().mockResolvedValue({ data: { id: "test-id" }, error: null }),
}),
}),
}),
}),
}))

Expand All @@ -47,6 +54,7 @@ jest.mock("@/lib/id_generator", () => ({
generateBookId: jest.fn().mockResolvedValue("TEST-CODE"),
}))


jest.mock("@/lib/book-utils", () => ({
parseBookMetadata: jest.fn().mockReturnValue({
title: "Test Title",
Expand All @@ -56,6 +64,10 @@ jest.mock("@/lib/book-utils", () => ({
}),
}))

jest.mock("@/lib/location-utils", () => ({
getWhimsicalLocation: jest.fn().mockResolvedValue("Mock Town"),
}))

describe("POST /api/books/generate", () => {
it("should generate a book code successfully", async () => {
const request = new Request("http://localhost:3000/api/books/generate", {
Expand Down
92 changes: 92 additions & 0 deletions __tests__/lib/location-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@

import { getWhimsicalLocation } from "@/lib/location-utils"

// Mock global fetch
global.fetch = jest.fn()

describe("getWhimsicalLocation", () => {
beforeEach(() => {
jest.clearAllMocks()
})

it("should return 'Near [Neighbourhood] in [City]' when both are present", async () => {
(fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({
address: {
neighbourhood: "Cooktown",
town: "Herndon",
country: "United States",
},
}),
})

const result = await getWhimsicalLocation(38.9, -77.0)
expect(result).toBe("Near Cooktown in Herndon")
// Verify zoom level change
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining("zoom=14"),
expect.any(Object)
)
})

it("should return 'Near [Neighbourhood]' when only neighbourhood is present", async () => {
(fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({
address: {
suburb: "Suburbtown", // Using suburb as partial alias for neighbourhood logic
},
}),
})

const result = await getWhimsicalLocation(10, 20)
expect(result).toBe("Near Suburbtown")
})

it("should return '[City]' when only city is present", async () => {
(fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({
address: {
city: "Buffalo",
},
}),
})

const result = await getWhimsicalLocation(10, 20)
expect(result).toBe("Buffalo")
})

it("should return 'The Wilds' when no relevant address parts are found", async () => {
(fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({
address: {
country: "Nowhere Land",
},
}),
})

const result = await getWhimsicalLocation(0, 0)
expect(result).toBe("The Wilds")
})

it("should return 'The Wilds' on fetch failure (api error)", async () => {
(fetch as jest.Mock).mockResolvedValue({
ok: false,
status: 500,
statusText: "Internal Server Error",
})

const result = await getWhimsicalLocation(0, 0)
expect(result).toBe("The Wilds")
})

it("should return 'The Wilds' on network exception", async () => {
(fetch as jest.Mock).mockRejectedValue(new Error("Network Error"))

const result = await getWhimsicalLocation(0, 0)
expect(result).toBe("The Wilds")
})
})
22 changes: 10 additions & 12 deletions app/api/books/generate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createClient } from "@supabase/supabase-js"
import { parseBookMetadata } from "@/lib/book-utils"
import { OpenLibraryDoc, getBookCover } from "@/lib/openLibrary"
import { BookMetadata } from "@/lib/types"
import { getWhimsicalLocation } from "@/lib/location-utils"

export const dynamic = "force-dynamic"

Expand All @@ -32,14 +33,8 @@ export async function POST(request: Request) {
// 2. Generate Code
const code = await generateBookId(lat, long)

// 3. Initialize Admin Client for persistence (bypassing RLS for anonymous inserts)
// Use DB_KEY as the service role key
const adminSupabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.DB_KEY!, {
auth: {
persistSession: false,
autoRefreshToken: false,
},
})
// 3. (Admin Client already imported as adminSupabase)
// Removed local initialization that used incorrect key.

// 4. Prepare Metadata
// The 'book' object might be an OpenLibraryDoc or a GoogleBookData object
Expand All @@ -54,7 +49,10 @@ export async function POST(request: Request) {
}

// 5. Persist Book
const { data, error } = await adminSupabase
// 5. Persist Book (Using mapped client to respect RLS)
const whimsicalLocation = await getWhimsicalLocation(lat, long)

const { data, error } = await supabase
.from("books")
.insert({
code,
Expand All @@ -64,7 +62,7 @@ export async function POST(request: Request) {
author: author,
isbn: isbn,
cover_url: cover_link,
location: `${lat},${long}`,
location: whimsicalLocation,
})
.select()
.single()
Expand All @@ -75,11 +73,11 @@ export async function POST(request: Request) {
}

// 4. Create Initial Sighting
const { error: sightingError } = await adminSupabase.from("sightings").insert({
const { error: sightingError } = await supabase.from("sightings").insert({
book_id: data.id,
lat,
lon: long,
location: `${lat},${long}`,
location: whimsicalLocation,
sighting_type: "REGISTER",
// If user is logged in, associate with them. Otherwise anonymous.
user_id: userId,
Expand Down
6 changes: 4 additions & 2 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,10 @@ export default async function Home() {
const sightings = rawSightings.map((sighting) => {
if (sighting.user_id && usersMap.has(sighting.user_id)) {
const fullEmail = usersMap.get(sighting.user_id) || ""
// Security: Mask the email server-side, only exposing the username part.
const maskedEmail = fullEmail.split("@")[0]
// Security: Obfuscate the email server-side.
// Format: first 3 chars + ... + last 3 chars of username.
const username = fullEmail.split("@")[0]
const maskedEmail = `${username.slice(0, 3)}...${username.slice(-3)}`
Comment on lines +69 to +71

Copilot AI Dec 21, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The email obfuscation logic has a bug with short usernames. For usernames with 6 or fewer characters, the slice operations will produce overlapping or duplicate characters. For example:

  • A username "abc" would become "abc...abc" (duplicating all characters)
  • A username "test" would become "tes...est" (overlapping)

The logic should check the username length and handle short usernames appropriately, perhaps by showing them as "ab..." or similar for very short names.

Suggested change
// Format: first 3 chars + ... + last 3 chars of username.
const username = fullEmail.split("@")[0]
const maskedEmail = `${username.slice(0, 3)}...${username.slice(-3)}`
// For typical usernames (> 6 chars): first 3 chars + ... + last 3 chars of username.
const username = fullEmail.split("@")[0]
let maskedUsername: string
if (username.length <= 2) {
// Very short usernames: show first char only.
maskedUsername = `${username.slice(0, 1)}...`
} else if (username.length <= 6) {
// Short usernames: avoid overlapping/duplicating characters.
maskedUsername = `${username.slice(0, 2)}...${username.slice(-1)}`
} else {
maskedUsername = `${username.slice(0, 3)}...${username.slice(-3)}`
}
const maskedEmail = maskedUsername

Copilot uses AI. Check for mistakes.
return {
...sighting,
user: {
Expand Down
54 changes: 54 additions & 0 deletions lib/location-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Fetches a "whimsical" location name (suburb, town, city) from a lat/long pair
* using OpenStreetMap's Nominatim API.
*
* This is used to obfuscate precise coordinates in public feeds.
*/
export async function getWhimsicalLocation(lat: number, lon: number): Promise<string> {
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lon}&zoom=14`,
{
headers: {
// Nominatim requires a User-Agent.
"User-Agent": "TaleTrail/1.0 (taletrail.org / taletrail@chrislates.com)",
},
next: {
// Cache for a long time to avoid rate limits on known coords
revalidate: 86400,
},
}
)
Comment on lines +9 to +21

Copilot AI Dec 21, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Nominatim Usage Policy requires no more than 1 request per second and recommends implementing proper caching. While the code implements caching with a 24-hour revalidation period, there's no rate limiting mechanism in place. If multiple books are generated simultaneously with different coordinates, this could violate the API's usage policy. Consider implementing a request queue or rate limiter to ensure compliance with the 1 request/second limit.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not considering this high-priority; if we hit a rate limit I'll be impressed.


if (!res.ok) {
console.warn("Nominatim API Error:", res.status, res.statusText)
return "The Wilds"
}
Comment on lines +20 to +26

Copilot AI Dec 21, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getWhimsicalLocation function is called during book generation, which is a synchronous operation in the request flow. If the Nominatim API is slow or times out, this will block the entire book generation process. Consider adding a timeout to the fetch request to prevent indefinite hanging. For example, use signal: AbortSignal.timeout(5000) to timeout after 5 seconds and fall back to the coordinate format.

Suggested change
}
)
if (!res.ok) {
console.warn("Nominatim API Error:", res.status, res.statusText)
return `${lat.toFixed(2)}, ${lon.toFixed(2)}`
}
signal: AbortSignal.timeout(5000),
}
)
if (!res.ok) {
console.warn("Nominatim API Error:", res.status, res.statusText)
return `${lat.toFixed(2)}, ${lon.toFixed(2)}`

Copilot uses AI. Check for mistakes.

const data = await res.json()
const addr = data.address || {}

// Extract relevant parts
const neighbourhood = addr.neighbourhood || addr.suburb || addr.quarter
const city = addr.town || addr.city || addr.village || addr.municipality
const county = addr.county

// Format: "Near [Neighbourhood] in [City]" or just "[City]"
let name = "The Wilds"

if (neighbourhood && city) {
name = `Near ${neighbourhood} in ${city}`
} else if (neighbourhood) {
name = `Near ${neighbourhood}`
} else if (city) {
name = city
} else if (county) {
name = county
}

return name
} catch (error) {
console.error("Failed to fetch whimsical location:", error)
return "The Wilds"
}
}
Loading