mirror of
https://github.com/linsa-io/linsa.git
synced 2026-04-21 07:51:38 +02:00
Add initial API documentation for Linsa API endpoints to docs/api.md
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 5613 --strictPort",
|
||||
"dev": "vite dev --port 5625 --strictPort",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -299,6 +299,31 @@ async function seed() {
|
||||
ADD COLUMN IF NOT EXISTS "cloudflare_customer_code" text
|
||||
`)
|
||||
|
||||
// Create API keys table
|
||||
await appDb.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "api_keys" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"key_hash" text NOT NULL UNIQUE,
|
||||
"name" text NOT NULL DEFAULT 'Default',
|
||||
"last_used_at" timestamptz,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`)
|
||||
|
||||
// Create bookmarks table
|
||||
await appDb.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "bookmarks" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"url" text NOT NULL,
|
||||
"title" text,
|
||||
"description" text,
|
||||
"tags" text,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`)
|
||||
|
||||
// ========== Seed nikiv user ==========
|
||||
const nikivUserId = "nikiv"
|
||||
const nikivEmail = "nikita.voloboev@gmail.com"
|
||||
|
||||
@@ -20,6 +20,7 @@ interface SettingsPanelProps {
|
||||
activeSection: SettingsSection
|
||||
onSelect: (section: SettingsSection) => void
|
||||
profile?: UserProfile | null | undefined
|
||||
showBilling?: boolean
|
||||
}
|
||||
|
||||
type NavItem = {
|
||||
@@ -66,7 +67,12 @@ export default function SettingsPanel({
|
||||
activeSection,
|
||||
onSelect,
|
||||
profile,
|
||||
showBilling = false,
|
||||
}: SettingsPanelProps) {
|
||||
const filteredNavItems = showBilling
|
||||
? navItems
|
||||
: navItems.filter((item) => item.id !== "billing")
|
||||
|
||||
return (
|
||||
<aside className="shrink-0 bg-transparent border border-white/5 rounded-2xl h-[calc(100vh-6em)] sticky top-6 px-2 py-4 items-start flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2 items-start w-full">
|
||||
@@ -78,7 +84,7 @@ export default function SettingsPanel({
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Back to app</span>
|
||||
</a>
|
||||
{navItems.map(({ id, label, icon: Icon }) => {
|
||||
{filteredNavItems.map(({ id, label, icon: Icon }) => {
|
||||
const isActive = activeSection === id
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -572,3 +572,44 @@ export type CreatorTier = z.infer<typeof selectCreatorTierSchema>
|
||||
export type CreatorSubscription = z.infer<typeof selectCreatorSubscriptionSchema>
|
||||
export type CreatorProduct = z.infer<typeof selectCreatorProductSchema>
|
||||
export type CreatorPurchase = z.infer<typeof selectCreatorPurchaseSchema>
|
||||
|
||||
// =============================================================================
|
||||
// API Keys
|
||||
// =============================================================================
|
||||
|
||||
export const api_keys = pgTable("api_keys", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
user_id: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
key_hash: text("key_hash").notNull().unique(), // SHA-256 hash of the key
|
||||
name: text("name").notNull().default("Default"), // User-friendly name
|
||||
last_used_at: timestamp("last_used_at", { withTimezone: true }),
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
})
|
||||
|
||||
export const selectApiKeySchema = createSelectSchema(api_keys)
|
||||
export type ApiKey = z.infer<typeof selectApiKeySchema>
|
||||
|
||||
// =============================================================================
|
||||
// Bookmarks
|
||||
// =============================================================================
|
||||
|
||||
export const bookmarks = pgTable("bookmarks", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
user_id: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
url: text("url").notNull(),
|
||||
title: text("title"),
|
||||
description: text("description"),
|
||||
tags: text("tags"), // Comma-separated tags
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
})
|
||||
|
||||
export const selectBookmarkSchema = createSelectSchema(bookmarks)
|
||||
export type Bookmark = z.infer<typeof selectBookmarkSchema>
|
||||
|
||||
@@ -84,7 +84,7 @@ export const getAuth = () => {
|
||||
usePlural: true,
|
||||
schema,
|
||||
}),
|
||||
trustedOrigins: [env.APP_BASE_URL ?? "http://localhost:3000"],
|
||||
trustedOrigins: [env.APP_BASE_URL ?? "http://localhost:5625"],
|
||||
plugins: [
|
||||
tanstackStartCookies(),
|
||||
emailOTP({
|
||||
|
||||
@@ -14,7 +14,7 @@ export const usersCollection = createCollection(
|
||||
"/api/users",
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "http://localhost:3000",
|
||||
: "http://localhost:5625",
|
||||
).toString(),
|
||||
parser: {
|
||||
timestamptz: (date: string) => new Date(date),
|
||||
@@ -28,7 +28,7 @@ export const usersCollection = createCollection(
|
||||
const baseUrl =
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "http://localhost:3000"
|
||||
: "http://localhost:5625"
|
||||
|
||||
// Create collections lazily to avoid fetching before authentication
|
||||
// Using a factory pattern so each call gets the same collection instance
|
||||
|
||||
133
packages/web/src/routes/api/api-keys.ts
Normal file
133
packages/web/src/routes/api/api-keys.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { createAPIFileRoute } from "@tanstack/react-start/api"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { getDb } from "@/db/connection"
|
||||
import { api_keys } from "@/db/schema"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { headers } from "@tanstack/react-start/server"
|
||||
|
||||
// Generate a random API key
|
||||
function generateApiKey(): string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
let key = "lk_" // linsa key prefix
|
||||
for (let i = 0; i < 32; i++) {
|
||||
key += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// Hash function for API key storage
|
||||
async function hashApiKey(key: string): Promise<string> {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(key)
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
export const APIRoute = createAPIFileRoute("/api/api-keys")({
|
||||
// GET - List user's API keys (without the actual key, just metadata)
|
||||
GET: async () => {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user?.id) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const db = getDb(process.env.DATABASE_URL!)
|
||||
|
||||
const keys = await db
|
||||
.select({
|
||||
id: api_keys.id,
|
||||
name: api_keys.name,
|
||||
last_used_at: api_keys.last_used_at,
|
||||
created_at: api_keys.created_at,
|
||||
})
|
||||
.from(api_keys)
|
||||
.where(eq(api_keys.user_id, session.user.id))
|
||||
.orderBy(api_keys.created_at)
|
||||
|
||||
return Response.json({ keys })
|
||||
} catch (error) {
|
||||
console.error("Error fetching API keys:", error)
|
||||
return Response.json({ error: "Failed to fetch API keys" }, { status: 500 })
|
||||
}
|
||||
},
|
||||
|
||||
// POST - Create a new API key
|
||||
POST: async ({ request }) => {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user?.id) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const name = body.name || "Default"
|
||||
|
||||
const db = getDb(process.env.DATABASE_URL!)
|
||||
|
||||
// Generate new key
|
||||
const plainKey = generateApiKey()
|
||||
const keyHash = await hashApiKey(plainKey)
|
||||
|
||||
// Insert key record
|
||||
const [keyRecord] = await db
|
||||
.insert(api_keys)
|
||||
.values({
|
||||
user_id: session.user.id,
|
||||
key_hash: keyHash,
|
||||
name,
|
||||
})
|
||||
.returning({
|
||||
id: api_keys.id,
|
||||
name: api_keys.name,
|
||||
created_at: api_keys.created_at,
|
||||
})
|
||||
|
||||
// Return the plain key ONLY on creation (it won't be retrievable later)
|
||||
return Response.json({
|
||||
key: plainKey,
|
||||
id: keyRecord.id,
|
||||
name: keyRecord.name,
|
||||
created_at: keyRecord.created_at,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error creating API key:", error)
|
||||
return Response.json({ error: "Failed to create API key" }, { status: 500 })
|
||||
}
|
||||
},
|
||||
|
||||
// DELETE - Revoke an API key
|
||||
DELETE: async ({ request }) => {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user?.id) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const keyId = url.searchParams.get("id")
|
||||
|
||||
if (!keyId) {
|
||||
return Response.json({ error: "Key ID is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const db = getDb(process.env.DATABASE_URL!)
|
||||
|
||||
// Delete key (only if it belongs to the user)
|
||||
const [deleted] = await db
|
||||
.delete(api_keys)
|
||||
.where(eq(api_keys.id, keyId))
|
||||
.returning()
|
||||
|
||||
if (!deleted) {
|
||||
return Response.json({ error: "Key not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return Response.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Error deleting API key:", error)
|
||||
return Response.json({ error: "Failed to delete API key" }, { status: 500 })
|
||||
}
|
||||
},
|
||||
})
|
||||
124
packages/web/src/routes/api/bookmarks.ts
Normal file
124
packages/web/src/routes/api/bookmarks.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { createAPIFileRoute } from "@tanstack/react-start/api"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { getDb } from "@/db/connection"
|
||||
import { api_keys, bookmarks, users } from "@/db/schema"
|
||||
|
||||
// Hash function for API key verification
|
||||
async function hashApiKey(key: string): Promise<string> {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(key)
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
// Get user from API key
|
||||
async function getUserFromApiKey(apiKey: string) {
|
||||
const db = getDb(process.env.DATABASE_URL!)
|
||||
const keyHash = await hashApiKey(apiKey)
|
||||
|
||||
const [keyRecord] = await db
|
||||
.select({
|
||||
userId: api_keys.user_id,
|
||||
keyId: api_keys.id,
|
||||
})
|
||||
.from(api_keys)
|
||||
.where(eq(api_keys.key_hash, keyHash))
|
||||
.limit(1)
|
||||
|
||||
if (!keyRecord) return null
|
||||
|
||||
// Update last_used_at
|
||||
await db
|
||||
.update(api_keys)
|
||||
.set({ last_used_at: new Date() })
|
||||
.where(eq(api_keys.id, keyRecord.keyId))
|
||||
|
||||
// Get user
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, keyRecord.userId))
|
||||
.limit(1)
|
||||
|
||||
return user || null
|
||||
}
|
||||
|
||||
export const APIRoute = createAPIFileRoute("/api/bookmarks")({
|
||||
// POST - Add a bookmark
|
||||
POST: async ({ request }) => {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { url, title, description, tags, api_key } = body
|
||||
|
||||
if (!url) {
|
||||
return Response.json({ error: "URL is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!api_key) {
|
||||
return Response.json({ error: "API key is required" }, { status: 401 })
|
||||
}
|
||||
|
||||
const user = await getUserFromApiKey(api_key)
|
||||
if (!user) {
|
||||
return Response.json({ error: "Invalid API key" }, { status: 401 })
|
||||
}
|
||||
|
||||
const db = getDb(process.env.DATABASE_URL!)
|
||||
|
||||
// Insert bookmark
|
||||
const [bookmark] = await db
|
||||
.insert(bookmarks)
|
||||
.values({
|
||||
user_id: user.id,
|
||||
url,
|
||||
title: title || null,
|
||||
description: description || null,
|
||||
tags: tags || null,
|
||||
})
|
||||
.returning()
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
bookmark: {
|
||||
id: bookmark.id,
|
||||
url: bookmark.url,
|
||||
title: bookmark.title,
|
||||
created_at: bookmark.created_at,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error adding bookmark:", error)
|
||||
return Response.json({ error: "Failed to add bookmark" }, { status: 500 })
|
||||
}
|
||||
},
|
||||
|
||||
// GET - List bookmarks (requires API key in header)
|
||||
GET: async ({ request }) => {
|
||||
try {
|
||||
const apiKey = request.headers.get("x-api-key")
|
||||
|
||||
if (!apiKey) {
|
||||
return Response.json({ error: "API key is required" }, { status: 401 })
|
||||
}
|
||||
|
||||
const user = await getUserFromApiKey(apiKey)
|
||||
if (!user) {
|
||||
return Response.json({ error: "Invalid API key" }, { status: 401 })
|
||||
}
|
||||
|
||||
const db = getDb(process.env.DATABASE_URL!)
|
||||
|
||||
const userBookmarks = await db
|
||||
.select()
|
||||
.from(bookmarks)
|
||||
.where(eq(bookmarks.user_id, user.id))
|
||||
.orderBy(bookmarks.created_at)
|
||||
|
||||
return Response.json({ bookmarks: userBookmarks })
|
||||
} catch (error) {
|
||||
console.error("Error fetching bookmarks:", error)
|
||||
return Response.json({ error: "Failed to fetch bookmarks" }, { status: 500 })
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -24,6 +24,9 @@ export const Route = createFileRoute("/settings")({
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
// Feature flag: enable billing section
|
||||
const BILLING_ENABLED = false
|
||||
|
||||
type Option = { value: string; label: string }
|
||||
|
||||
function InlineSelect({
|
||||
@@ -641,6 +644,12 @@ function StreamingSection({ username }: { username: string | null | undefined })
|
||||
title="Streaming"
|
||||
description="Configure your live stream settings."
|
||||
/>
|
||||
<div className="mb-5 p-4 bg-purple-500/10 border border-purple-500/20 rounded-xl flex items-center gap-3">
|
||||
<span className="px-2 py-0.5 text-xs font-bold uppercase bg-purple-500 text-white rounded">Beta</span>
|
||||
<p className="text-sm text-purple-200">
|
||||
Streaming is currently in beta. Features may change and some functionality is still being developed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
{loading ? (
|
||||
<div className="h-32 bg-white/5 rounded-2xl animate-pulse" />
|
||||
@@ -953,7 +962,11 @@ function BillingSection() {
|
||||
disabled={subscribing}
|
||||
className="w-full py-3 rounded-xl text-sm font-semibold bg-teal-500 hover:bg-teal-400 text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{subscribing ? "Loading..." : "Subscribe Now"}
|
||||
{subscribing ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mx-auto" />
|
||||
) : (
|
||||
"Subscribe Now"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -992,7 +1005,7 @@ function SettingsPage() {
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="min-h-screen text-white grid place-items-center">
|
||||
<p className="text-slate-400">Loading settings…</p>
|
||||
<div className="w-6 h-6 border-2 border-white/20 border-t-white rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1005,6 +1018,7 @@ function SettingsPage() {
|
||||
activeSection={activeSection}
|
||||
onSelect={setActiveSection}
|
||||
profile={session?.user}
|
||||
showBilling={BILLING_ENABLED}
|
||||
/>
|
||||
<div className="flex-1 space-y-12 overflow-auto pr-1 pb-12">
|
||||
{activeSection === "preferences" ? (
|
||||
@@ -1017,7 +1031,7 @@ function SettingsPage() {
|
||||
/>
|
||||
) : activeSection === "streaming" ? (
|
||||
<StreamingSection username={session?.user?.username} />
|
||||
) : activeSection === "billing" ? (
|
||||
) : activeSection === "billing" && BILLING_ENABLED ? (
|
||||
<BillingSection />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user