← back to Patty

app/api/settings/credentials/route.ts

147 lines

import { NextRequest, NextResponse } from 'next/server';
import { exec } from 'child_process';
import { promisify } from 'util';
import fs from 'fs';
import path from 'path';
import { query } from '@/lib/db';
import { verifyAuth } from '@/lib/auth';

const execAsync = promisify(exec);

const AGENTS_BASE = '/root/Projects/SDCC/agents';

const AGENT_ENV_MAP: Record<string, { dir: string; pm2Name: string; vars: string[] }> = {
  twitter: {
    dir: path.join(AGENTS_BASE, 'twitter-agent'),
    pm2Name: 'sdcc-twitter',
    vars: ['TWITTER_API_KEY', 'TWITTER_API_SECRET', 'TWITTER_ACCESS_TOKEN', 'TWITTER_ACCESS_SECRET', 'TWITTER_BEARER_TOKEN'],
  },
  reddit: {
    dir: path.join(AGENTS_BASE, 'reddit-agent'),
    pm2Name: 'sdcc-reddit',
    vars: ['REDDIT_CLIENT_ID', 'REDDIT_CLIENT_SECRET', 'REDDIT_USERNAME', 'REDDIT_PASSWORD'],
  },
  discord: {
    dir: path.join(AGENTS_BASE, 'discord-agent'),
    pm2Name: 'sdcc-discord',
    vars: ['DISCORD_BOT_TOKEN', 'DISCORD_DEFAULT_CHANNEL_ID'],
  },
  bluesky: {
    dir: path.join(AGENTS_BASE, 'bluesky-agent'),
    pm2Name: 'sdcc-bluesky',
    vars: ['BSKY_HANDLE', 'BSKY_PASSWORD'],
  },
};

function maskValue(val: string): string {
  if (!val || val.length < 4) return '***';
  return val.slice(0, 3) + '*'.repeat(Math.min(val.length - 3, 12));
}

function buildEnvFile(vars: Record<string, string>): string {
  return Object.entries(vars)
    .filter(([, v]) => v)
    .map(([k, v]) => `${k}=${v}`)
    .join('\n') + '\n';
}

/**
 * GET /api/settings/credentials
 * Returns masked credential status per platform.
 */
export async function GET(request: NextRequest) {
  const user = verifyAuth(request);
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  const result: Record<string, Record<string, string>> = {};

  for (const [platform, cfg] of Object.entries(AGENT_ENV_MAP)) {
    const envPath = path.join(cfg.dir, '.env');
    result[platform] = {};

    if (fs.existsSync(envPath)) {
      const lines = fs.readFileSync(envPath, 'utf8').split('\n');
      for (const line of lines) {
        const [key, ...rest] = line.split('=');
        const val = rest.join('=').trim();
        if (key && cfg.vars.includes(key.trim())) {
          result[platform][key.trim()] = val ? maskValue(val) : '';
        }
      }
    }

    // Ensure all expected vars appear (empty if not set)
    for (const v of cfg.vars) {
      if (!(v in result[platform])) result[platform][v] = '';
    }
  }

  return NextResponse.json({ credentials: result });
}

/**
 * POST /api/settings/credentials
 * Saves credentials to .env files and restarts agents.
 * Body: { platform: 'twitter'|'reddit'|'discord'|'bluesky', credentials: { KEY: 'val', ... } }
 */
export async function POST(request: NextRequest) {
  const user = verifyAuth(request);
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  try {
    const body = await request.json();
    const { platform, credentials } = body as { platform: string; credentials: Record<string, string> };

    const cfg = AGENT_ENV_MAP[platform];
    if (!cfg) {
      return NextResponse.json({ error: `Unknown platform: ${platform}` }, { status: 400 });
    }

    // Merge with existing .env (only update provided non-empty keys)
    const envPath = path.join(cfg.dir, '.env');
    const existing: Record<string, string> = {};

    if (fs.existsSync(envPath)) {
      const lines = fs.readFileSync(envPath, 'utf8').split('\n');
      for (const line of lines) {
        const eq = line.indexOf('=');
        if (eq > 0) {
          existing[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
        }
      }
    }

    const merged: Record<string, string> = { ...existing };
    for (const [k, v] of Object.entries(credentials)) {
      if (v && v.trim()) merged[k] = v.trim();
    }

    fs.writeFileSync(envPath, buildEnvFile(merged), 'utf8');

    // Restart the agent so it picks up new env vars
    let restartResult = 'ok';
    try {
      await execAsync(`pm2 restart ${cfg.pm2Name}`);
    } catch (err) {
      restartResult = (err as Error).message;
    }

    // Log to DB audit
    await query(
      `INSERT INTO audit_events (event_type, entity_type, entity_id, metadata)
       VALUES ('credentials.saved', 'platform', $1, $2)`,
      [platform, JSON.stringify({ keys_set: Object.keys(credentials).filter(k => credentials[k]), restart: restartResult })]
    ).catch(() => {});

    return NextResponse.json({
      platform,
      keys_saved: Object.keys(merged).length,
      restarted: restartResult === 'ok',
      restart_output: restartResult === 'ok' ? undefined : restartResult,
    });
  } catch (err) {
    console.error('[credentials] POST error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to save credentials' }, { status: 500 });
  }
}