v0.1.1: System default AI config, configurable URLs, single .env, website refactor

This commit is contained in:
lotherk
2026-03-27 13:51:15 +00:00
parent 749a913309
commit f6c4da1da3
23 changed files with 486 additions and 180 deletions

View File

@@ -1,51 +0,0 @@
# =============================================================================
# DearDiary Configuration
# =============================================================================
# -----------------------------------------------------------------------------
# Database (SQLite, PostgreSQL, or MySQL)
# -----------------------------------------------------------------------------
DATABASE_URL="file:./data/deardiary.db"
# Example PostgreSQL:
# DATABASE_URL="postgresql://postgres:password@db:5432/deardiary"
# Example MySQL:
# DATABASE_URL="mysql://root:password@localhost:3306/deardiary"
# -----------------------------------------------------------------------------
# Application
# -----------------------------------------------------------------------------
# App name displayed in UI
APP_NAME="DearDiary"
# App version
VERSION="0.1.0"
# Server port
PORT="3000"
# -----------------------------------------------------------------------------
# Security
# -----------------------------------------------------------------------------
# JWT secret for authentication tokens (REQUIRED in production)
JWT_SECRET="change-this-to-a-random-string-in-production"
# CORS origin (use specific domain in production, e.g., "https://yourapp.com")
CORS_ORIGIN="*"
# -----------------------------------------------------------------------------
# User Management
# -----------------------------------------------------------------------------
# Enable/disable user registration ("true" or "false")
REGISTRATION_ENABLED="false"
# Default admin user (auto-created on startup if doesn't exist)
DEFAULT_USER_EMAIL="admin@localhost"
DEFAULT_USER_PASSWORD="changeme123"
# -----------------------------------------------------------------------------
# Storage
# -----------------------------------------------------------------------------
# Media storage directory
MEDIA_DIR="./data/media"

View File

@@ -4,7 +4,7 @@ generator client {
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
url = env("BACKEND_DATABASE_URL")
}
model User {
@@ -94,16 +94,17 @@ model Task {
}
model Settings {
userId String @id
aiProvider String @default("groq")
aiApiKey String?
aiModel String @default("llama-3.3-70b-versatile")
aiBaseUrl String?
journalPrompt String?
language String @default("en")
timezone String @default("UTC")
providerSettings String?
userId String @id
aiProvider String @default("groq")
aiApiKey String?
aiModel String @default("llama-3.3-70b-versatile")
aiBaseUrl String?
journalPrompt String?
language String @default("en")
timezone String @default("UTC")
providerSettings String?
journalContextDays Int @default(10)
useSystemDefault Boolean @default(true)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

View File

@@ -8,19 +8,35 @@ import { createHash, randomBytes } from 'crypto';
import * as jose from 'jose';
import { Prisma } from '@prisma/client';
import { createAIProvider } from './services/ai/provider';
import { eventsRoutes } from './routes/events';
const app = new Hono();
const envVars = env(app);
const envVars = env<{
BACKEND_DATABASE_URL?: string;
BACKEND_PORT?: string;
BACKEND_JWT_SECRET?: string;
BACKEND_CORS_ORIGIN?: string;
BACKEND_REGISTRATION_ENABLED?: string;
BACKEND_MEDIA_DIR?: string;
BACKEND_DEFAULT_USER_EMAIL?: string;
BACKEND_DEFAULT_USER_PASSWORD?: string;
BACKEND_DEFAULT_AI_PROVIDER?: string;
BACKEND_DEFAULT_AI_MODEL?: string;
BACKEND_DEFAULT_AI_API_KEY?: string;
BACKEND_DEFAULT_AI_BASE_URL?: string;
BACKEND_VERSION?: string;
BACKEND_APP_NAME?: string;
}>(app);
app.use('*', logger());
app.use('*', cors({
origin: envVars.CORS_ORIGIN || '*',
origin: envVars.BACKEND_CORS_ORIGIN || '*',
credentials: true,
}));
const prisma = new PrismaClient({
datasourceUrl: envVars.DATABASE_URL || 'file:./data/deardiary.db',
datasourceUrl: envVars.BACKEND_DATABASE_URL || 'file:./data/deardiary.db',
});
app.use('*', async (c, next) => {
@@ -104,7 +120,7 @@ app.post('/api/v1/auth/login', async (c) => {
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } }, 401);
}
const jwtSecret = envVars.JWT_SECRET || 'development-secret-change-in-production';
const jwtSecret = envVars.BACKEND_JWT_SECRET || 'development-secret-change-in-production';
const token = await new jose.SignJWT({ userId: user.id })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
@@ -117,9 +133,9 @@ app.post('/api/v1/auth/login', async (c) => {
app.get('/api/v1/server-info', async (c) => {
return c.json({
data: {
version: envVars.VERSION || '0.1.0',
registrationEnabled: envVars.REGISTRATION_ENABLED !== 'false',
appName: envVars.APP_NAME || 'DearDiary',
version: envVars.BACKEND_VERSION || '0.1.0',
registrationEnabled: envVars.BACKEND_REGISTRATION_ENABLED !== 'false',
appName: envVars.BACKEND_APP_NAME || 'DearDiary',
},
error: null
});
@@ -132,7 +148,7 @@ app.post('/api/v1/auth/api-key', async (c) => {
}
const token = authHeader.slice(7);
const jwtSecret = envVars.JWT_SECRET || 'development-secret-change-in-production';
const jwtSecret = envVars.BACKEND_JWT_SECRET || 'development-secret-change-in-production';
let userId: string;
try {
@@ -293,22 +309,7 @@ app.get('/api/v1/search', async (c) => {
}
});
app.delete('/api/v1/events/:id', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const { id } = c.req.param();
const existing = await prisma.event.findFirst({ where: { id, userId } });
if (!existing) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Event not found' } }, 404);
const journal = await prisma.journal.findFirst({ where: { userId, date: existing.date } });
if (journal) {
return c.json({ data: null, error: { code: 'EVENT_IMMUTABLE', message: 'Cannot delete event: journal already generated. Delete the journal first.' } }, 400);
}
await prisma.event.delete({ where: { id } });
return c.json({ data: { deleted: true }, error: null });
});
app.route('/api/v1', eventsRoutes);
// Journal routes
app.post('/api/v1/journal/generate/:date', async (c) => {
@@ -333,7 +334,7 @@ app.post('/api/v1/journal/generate/:date', async (c) => {
const providerConfig = providerSettings[provider] || {};
const apiKey = providerConfig.apiKey || settings?.aiApiKey;
if ((provider === 'openai' || provider === 'anthropic' || provider === 'groq') && !apiKey) {
if ((provider === 'openai' || provider === 'anthropic' || provider === 'groq' || provider === 'xai') && !apiKey) {
return c.json({ data: null, error: { code: 'NO_AI_CONFIG', message: 'AI not configured. Please set up your API key in settings.' } }, 400);
}
@@ -602,15 +603,28 @@ app.get('/api/v1/journals', async (c) => {
});
});
const getDefaultSettings = () => ({
aiProvider: envVars.BACKEND_DEFAULT_AI_PROVIDER || 'groq',
aiApiKey: envVars.BACKEND_DEFAULT_AI_API_KEY || null,
aiModel: envVars.BACKEND_DEFAULT_AI_MODEL || 'llama-3.3-70b-versatile',
aiBaseUrl: envVars.BACKEND_DEFAULT_AI_BASE_URL || null,
useSystemDefault: true,
});
const getUserSettings = async (userId: string) => {
let settings = await prisma.settings.findUnique({ where: { userId } });
if (!settings) {
settings = await prisma.settings.create({ data: { userId, ...getDefaultSettings() } });
}
return settings;
};
// Settings routes
app.get('/api/v1/settings', async (c) => {
const userId = await getUserId(c);
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
let settings = await prisma.settings.findUnique({ where: { userId } });
if (!settings) {
settings = await prisma.settings.create({ data: { userId } });
}
let settings = await getUserSettings(userId);
const oldDefaultLength = 400;
if (settings.journalPrompt && settings.journalPrompt.length > oldDefaultLength) {
@@ -625,21 +639,33 @@ app.put('/api/v1/settings', async (c) => {
if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
const body = await c.req.json();
const { aiProvider, aiApiKey, aiModel, aiBaseUrl, journalPrompt, language, providerSettings, journalContextDays } = body;
const { aiProvider, aiApiKey, aiModel, aiBaseUrl, journalPrompt, language, providerSettings, journalContextDays, useSystemDefault } = body;
const defaults = getDefaultSettings();
const data: Record<string, unknown> = {};
if (aiProvider !== undefined) data.aiProvider = aiProvider;
if (aiApiKey !== undefined) data.aiApiKey = aiApiKey;
if (aiApiKey !== undefined) data.aiApiKey = aiApiKey || null;
if (aiModel !== undefined) data.aiModel = aiModel;
if (aiBaseUrl !== undefined) data.aiBaseUrl = aiBaseUrl;
if (aiBaseUrl !== undefined) data.aiBaseUrl = aiBaseUrl || null;
if (journalPrompt !== undefined) data.journalPrompt = journalPrompt === '' ? null : journalPrompt;
if (language !== undefined) data.language = language;
if (providerSettings !== undefined) data.providerSettings = JSON.stringify(providerSettings);
if (journalContextDays !== undefined) data.journalContextDays = journalContextDays;
if (useSystemDefault !== undefined) data.useSystemDefault = useSystemDefault;
const shouldUseSystemDefaults = useSystemDefault === true;
if (shouldUseSystemDefaults) {
if (aiProvider === undefined) data.aiProvider = defaults.aiProvider;
if (aiApiKey === undefined) data.aiApiKey = defaults.aiApiKey;
if (aiModel === undefined) data.aiModel = defaults.aiModel;
if (aiBaseUrl === undefined) data.aiBaseUrl = defaults.aiBaseUrl;
}
const settings = await prisma.settings.upsert({
where: { userId },
create: { userId, ...data },
create: { userId, ...getDefaultSettings(), ...data },
update: data,
});
@@ -1030,10 +1056,7 @@ app.post('/api/v1/account/reset', async (c) => {
await prisma.settings.update({
where: { userId },
data: {
aiProvider: 'groq',
aiApiKey: null,
aiModel: 'llama-3.3-70b-versatile',
aiBaseUrl: null,
...getDefaultSettings(),
journalPrompt: null,
language: 'en',
timezone: 'UTC',
@@ -1089,7 +1112,7 @@ app.post('/api/v1/account/password', async (c) => {
});
app.post('/api/v1/auth/register', async (c) => {
const registrationEnabled = envVars.REGISTRATION_ENABLED !== 'false';
const registrationEnabled = envVars.BACKEND_REGISTRATION_ENABLED !== 'false';
if (!registrationEnabled) {
return c.json({ data: null, error: { code: 'REGISTRATION_DISABLED', message: 'Registration is currently disabled' } }, 403);
}
@@ -1184,11 +1207,11 @@ async function rebuildFTS() {
}
async function createDefaultUser() {
const defaultEmail = envVars.DEFAULT_USER_EMAIL;
const defaultPassword = envVars.DEFAULT_USER_PASSWORD;
const defaultEmail = envVars.BACKEND_DEFAULT_USER_EMAIL;
const defaultPassword = envVars.BACKEND_DEFAULT_USER_PASSWORD;
if (!defaultEmail || !defaultPassword) {
console.log('No default user configured (set DEFAULT_USER_EMAIL and DEFAULT_USER_PASSWORD)');
console.log('No default user configured (set BACKEND_DEFAULT_USER_EMAIL and BACKEND_DEFAULT_USER_PASSWORD)');
return;
}
@@ -1232,7 +1255,7 @@ async function createDefaultUser() {
}
}
const port = parseInt(envVars.PORT || '3000', 10);
const port = parseInt(envVars.BACKEND_PORT || '3000', 10);
console.log(`Starting DearDiary API on port ${port}`);
setupFTS().then(() => {

View File

@@ -6,10 +6,19 @@ export interface HonoEnv {
prisma: PrismaClient;
};
Bindings: {
DATABASE_URL: string;
JWT_SECRET: string;
MEDIA_DIR: string;
PORT: string;
CORS_ORIGIN: string;
BACKEND_DATABASE_URL: string;
BACKEND_JWT_SECRET: string;
BACKEND_MEDIA_DIR: string;
BACKEND_PORT: string;
BACKEND_CORS_ORIGIN: string;
BACKEND_REGISTRATION_ENABLED: string;
BACKEND_DEFAULT_USER_EMAIL: string;
BACKEND_DEFAULT_USER_PASSWORD: string;
BACKEND_DEFAULT_AI_PROVIDER: string;
BACKEND_DEFAULT_AI_MODEL: string;
BACKEND_DEFAULT_AI_API_KEY: string;
BACKEND_DEFAULT_AI_BASE_URL: string;
BACKEND_VERSION: string;
BACKEND_APP_NAME: string;
};
}

View File

@@ -34,6 +34,32 @@ export async function authMiddleware(c: Context<HonoEnv>, next: Next) {
export function authenticate() {
return async (c: Context<HonoEnv>, next: Next) => {
await authMiddleware(c, next);
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid Authorization header' } }, 401);
}
const apiKey = authHeader.slice(7);
const keyHash = createHash('sha256').update(apiKey).digest('hex');
const prisma = c.get('prisma');
const keyRecord = await prisma.apiKey.findUnique({
where: { keyHash },
include: { user: true },
});
if (!keyRecord) {
return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401);
}
await prisma.apiKey.update({
where: { id: keyRecord.id },
data: { lastUsedAt: new Date() },
});
c.set('userId', keyRecord.userId);
await next();
};
}

View File

@@ -59,7 +59,7 @@ authRoutes.post('/login', async (c) => {
}
const prisma = c.get('prisma');
const jwtSecret = c.env.JWT_SECRET || 'development-secret-change-in-production';
const jwtSecret = c.env.BACKEND_JWT_SECRET || 'development-secret-change-in-production';
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
@@ -88,7 +88,7 @@ authRoutes.post('/api-key', async (c) => {
}
const token = authHeader.slice(7);
const jwtSecret = c.env.JWT_SECRET || 'development-secret-change-in-production';
const jwtSecret = c.env.BACKEND_JWT_SECRET || 'development-secret-change-in-production';
let userId: string;
try {

View File

@@ -1,12 +1,15 @@
import { Hono } from 'hono';
import { HonoEnv } from '../lib/types';
import { authenticate } from '../middleware/auth';
export const eventsRoutes = new Hono<HonoEnv>();
eventsRoutes.post('/', async (c) => {
eventsRoutes.use(authenticate());
eventsRoutes.post('/events', async (c) => {
const userId = c.get('userId');
const prisma = c.get('prisma');
const mediaDir = c.env.MEDIA_DIR || './data/media';
const mediaDir = c.env.BACKEND_MEDIA_DIR || './data/media';
const body = await c.req.json();
const { date, type, content, metadata, latitude, longitude, placeName } = body;
@@ -42,7 +45,7 @@ eventsRoutes.post('/', async (c) => {
return c.json({ data: event, error: null }, 201);
});
eventsRoutes.get('/:id', async (c) => {
eventsRoutes.get('/events/:id', async (c) => {
const userId = c.get('userId');
const { id } = c.req.param();
const prisma = c.get('prisma');
@@ -58,7 +61,7 @@ eventsRoutes.get('/:id', async (c) => {
return c.json({ data: event, error: null });
});
eventsRoutes.put('/:id', async (c) => {
eventsRoutes.put('/events/:id', async (c) => {
const userId = c.get('userId');
const { id } = c.req.param();
const prisma = c.get('prisma');
@@ -97,7 +100,7 @@ eventsRoutes.put('/:id', async (c) => {
return c.json({ data: event, error: null });
});
eventsRoutes.delete('/:id', async (c) => {
eventsRoutes.delete('/events/:id', async (c) => {
const userId = c.get('userId');
const { id } = c.req.param();
const prisma = c.get('prisma');
@@ -120,11 +123,11 @@ eventsRoutes.delete('/:id', async (c) => {
return c.json({ data: { deleted: true }, error: null });
});
eventsRoutes.post('/:id/photo', async (c) => {
eventsRoutes.post('/events/:id/photo', async (c) => {
const userId = c.get('userId');
const { id } = c.req.param();
const prisma = c.get('prisma');
const mediaDir = c.env.MEDIA_DIR || './data/media';
const mediaDir = c.env.BACKEND_MEDIA_DIR || './data/media';
const event = await prisma.event.findFirst({
where: { id, userId },
@@ -157,11 +160,11 @@ eventsRoutes.post('/:id/photo', async (c) => {
return c.json({ data: { mediaPath: mediaUrl }, error: null }, 201);
});
eventsRoutes.post('/:id/voice', async (c) => {
eventsRoutes.post('/events/:id/voice', async (c) => {
const userId = c.get('userId');
const { id } = c.req.param();
const prisma = c.get('prisma');
const mediaDir = c.env.MEDIA_DIR || './data/media';
const mediaDir = c.env.BACKEND_MEDIA_DIR || './data/media';
const event = await prisma.event.findFirst({
where: { id, userId },

View File

@@ -40,6 +40,7 @@ import { AnthropicProvider } from './anthropic';
import { OllamaProvider } from './ollama';
import { LMStudioProvider } from './lmstudio';
import { GroqProvider } from './groq';
import { XAIProvider } from './xai';
import { CustomProvider } from './custom';
export function createAIProvider(config: AIProviderConfig): AIProvider {
@@ -56,6 +57,8 @@ export function createAIProvider(config: AIProviderConfig): AIProvider {
return new LMStudioProvider(config, settings);
case 'groq':
return new GroqProvider(config, settings);
case 'xai':
return new XAIProvider(config, settings);
case 'custom':
return new CustomProvider(config, settings);
default:

View File

@@ -0,0 +1,92 @@
import type { AIProvider, AIProviderConfig, AIProviderResult } from './provider';
import { ProviderSettings } from './openai';
export class XAIProvider implements AIProvider {
provider = 'xai' as const;
private apiKey: string;
private model: string;
private baseUrl: string;
private customHeaders?: Record<string, string>;
private customParameters?: Record<string, unknown>;
constructor(config: AIProviderConfig, settings?: ProviderSettings) {
this.apiKey = config.apiKey || '';
this.model = config.model || 'grok-4-1-fast-reasoning';
this.baseUrl = config.baseUrl || 'https://api.x.ai/v1';
this.customHeaders = settings?.headers;
this.customParameters = settings?.parameters;
}
async generate(prompt: string, systemPrompt?: string, options?: { jsonMode?: boolean }): Promise<AIProviderResult> {
const messages: Array<{ role: string; content: string }> = [];
if (systemPrompt) {
messages.push({ role: 'system', content: systemPrompt });
}
messages.push({ role: 'user', content: prompt });
const requestBody: Record<string, unknown> = {
model: this.model,
messages,
temperature: 0.7,
max_tokens: 2000,
...this.customParameters,
};
if (options?.jsonMode) {
requestBody.response_format = { type: 'json_object' };
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
...this.customHeaders,
};
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify(requestBody),
});
const responseData = await response.json();
if (!response.ok) {
throw new Error(`xAI API error: ${response.status} ${JSON.stringify(responseData)}`);
}
let content = responseData.choices?.[0]?.message?.content || '';
let title: string | undefined;
if (options?.jsonMode) {
try {
const parsed = JSON.parse(content);
title = parsed.title;
content = parsed.content || content;
} catch {
// If JSON parsing fails, use content as-is
}
}
return {
content,
title,
request: requestBody,
response: responseData,
};
}
async validate(): Promise<boolean> {
try {
const headers: Record<string, string> = {
'Authorization': `Bearer ${this.apiKey}`,
...this.customHeaders,
};
const response = await fetch(`${this.baseUrl}/models`, { headers });
return response.ok;
} catch {
return false;
}
}
}