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
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,4 +394,45 @@ journalctl -u redcalibur -n 100 --no-pager
- Frontend can’t reach API in dev: ensure the backend is running and Vite proxy is active
- Port conflicts: Vite will choose another port (5174) if 5173 is busy; change API port with `--port` in `api/run.py` if needed
- Scripts say venv missing: create the venv at `.venv` and install requirements (see Quickstart)
- Missing keys: the app will still run, but some features return reduced data
- Missing keys: the app will still run, but some features return reduced data

---

## ☁️ Deploy to Vercel (Frontend only)

Vercel hosts the React frontend. The FastAPI backend should run on a persistent host (Render/Fly.io/Railway/EC2/your server). You have two ways to connect the frontend to the backend:

Option A — Environment variable (simplest)
1) Deploy your FastAPI backend to a public HTTPS URL (confirm `/health`).
2) In Vercel → New Project → Import this repo. Set Project root directory to `frontend`.
3) Build settings:
- Build Command: `npm run build`
- Output Directory: `dist`
4) Add an environment variable in Vercel → Settings → Environment Variables:
- Name: `VITE_API_BASE`
- Value: `https://your-backend.example.com`
- Scope: Production (and Preview if desired)
5) Deploy. The app will call your backend directly using that URL.

Option B — Rewrites (no CORS, keep `/api`)
1) Deploy your FastAPI backend to a public HTTPS URL.
2) Add a `vercel.json` in the `frontend/` folder with:
```json
{
"rewrites": [
{ "source": "/api/(.*)", "destination": "https://your-backend.example.com/$1" }
]
}
```
3) Do not set `VITE_API_BASE` in Vercel. The app uses `/api`, which Vercel proxies to your backend.
4) In Vercel, set root to `frontend`, build to `npm run build`, output to `dist`, then deploy.

Checks after deploy
- Open your Vercel site → header health badge should show `ok`.
- DevTools → Network: `/api/health` should succeed (rewrites) or calls should hit your backend URL (env var).

Why FastAPI is required
- The browser cannot perform many OSINT tasks (port scans, WHOIS, raw sockets, multi-host probes) due to CORS, sandboxing, and blocked network primitives.
- API keys (Shodan, VirusTotal, Gemini) must be kept server-side. Exposing them in the frontend would leak your secrets.
- The backend orchestrates parallel tasks with timeouts/retries and aggregates results, which is not reliable purely client-side.
- Some tasks are long-running or network-heavy and need server resources and control.
4 changes: 2 additions & 2 deletions api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import time
from redcalibur.osint.network_threat_intel.shodan_integration import perform_shodan_scan
from redcalibur.osint.user_identity.username_lookup import lookup_username
from redcalibur.osint.virustotal_integration import scan_url
from redcalibur.osint.virustotal_integration import scan_url_full
from redcalibur.osint.url_health_check import basic_url_health
from redcalibur.osint.ai_enhanced.recon_summarizer import summarize_recon_data
from redcalibur.osint.ai_enhanced.risk_scoring import calculate_risk_score
Expand Down Expand Up @@ -192,7 +192,7 @@ def urlscan(req: URLScanRequest):
def runner():
if not config.VIRUSTOTAL_API_KEY:
return {"note": "VIRUSTOTAL_API_KEY not configured", "health": basic_url_health(req.url)}
return scan_url(config.VIRUSTOTAL_API_KEY, req.url) or {"error": "no_data"}
return scan_url_full(config.VIRUSTOTAL_API_KEY, req.url) or {"error": "no_data"}

with ThreadPoolExecutor(max_workers=1) as ex_url:
fut = ex_url.submit(runner)
Expand Down
42 changes: 42 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.30.1",
"remark-gfm": "^4.0.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.2",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
Expand Down
41 changes: 34 additions & 7 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ const Header = ({ palette, setPalette, fx, setFx, apiStatus }) => {
}

export default function App() {
const [loading, setLoading] = useState(false)
// Independent loading flags per section so only the triggered action shows a spinner
const [domainLoading, setDomainLoading] = useState(false)
const [scanLoading, setScanLoading] = useState(false)
const [usernameLoading, setUsernameLoading] = useState(false)
const [urlLoading, setUrlLoading] = useState(false)
const [result, setResult] = useState(null)
const [logs, setLogs] = useState([])
const [apiStatus, setApiStatus] = useState('unknown')
Expand All @@ -84,9 +88,13 @@ export default function App() {
if (palette==='blue') {
root.style.setProperty('--primary-rgb','59,130,246')
root.style.setProperty('--secondary-rgb','244,63,94')
root.style.setProperty('--primary-color','#4299E1')
root.style.setProperty('--glow-color','rgba(66,153,225,0.75)')
} else {
root.style.setProperty('--primary-rgb','244,63,94')
root.style.setProperty('--secondary-rgb','59,130,246')
root.style.setProperty('--primary-color','#E53E3E')
root.style.setProperty('--glow-color','rgba(229,62,62,0.75)')
}
try { localStorage.setItem('rc_palette', palette) } catch {}
}, [palette])
Expand All @@ -95,7 +103,6 @@ export default function App() {

const api = useMemo(()=>({
post: async (path, body, {updateResult=true}={}) => {
setLoading(true)
const start = performance.now()
try {
const res = await fetch(`${API_BASE}${path}`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) })
Expand All @@ -106,7 +113,7 @@ export default function App() {
} catch(e) {
setLogs(l=>[{ time:new Date().toLocaleTimeString(), path, ok:false, error:e.message }, ...l])
throw e
} finally { setLoading(false) }
}
}
}), [])

Expand All @@ -122,10 +129,30 @@ export default function App() {
<Header palette={palette} setPalette={setPalette} fx={fx} setFx={setFx} apiStatus={apiStatus} />
<main className="relative z-10 max-w-7xl mx-auto px-4 py-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-6">
<Section title="Domain Reconnaissance"><DomainForm onSubmit={(data)=>api.post('/domain', data)} loading={loading} /></Section>
<Section title="Network Scan"><ScanForm onSubmit={(data)=>api.post('/scan', data)} loading={loading} /></Section>
<Section title="Username Lookup"><UsernameForm onSubmit={(data)=>api.post('/username', data)} loading={loading} /></Section>
<Section title="URL Malware Scan (VirusTotal)"><URLScanForm onSubmit={(data)=>api.post('/urlscan', data)} loading={loading} /></Section>
<Section title="Domain Reconnaissance">
<DomainForm
onSubmit={async (data)=>{ setDomainLoading(true); try { await api.post('/domain', data) } finally { setDomainLoading(false) } }}
loading={domainLoading}
/>
</Section>
<Section title="Network Scan">
<ScanForm
onSubmit={async (data)=>{ setScanLoading(true); try { await api.post('/scan', data) } finally { setScanLoading(false) } }}
loading={scanLoading}
/>
</Section>
<Section title="Username Lookup">
<UsernameForm
onSubmit={async (data)=>{ setUsernameLoading(true); try { await api.post('/username', data) } finally { setUsernameLoading(false) } }}
loading={usernameLoading}
/>
</Section>
<Section title="URL Malware Scan (VirusTotal)">
<URLScanForm
onSubmit={async (data)=>{ setUrlLoading(true); try { await api.post('/urlscan', data) } finally { setUrlLoading(false) } }}
loading={urlLoading}
/>
</Section>
</div>
<div className="space-y-6">
<Section title="Results">
Expand Down
106 changes: 106 additions & 0 deletions frontend/src/components/Landing.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React, { useEffect, useRef, useState } from 'react'

export default function Landing({ palette, setPalette, onEnter, generateMission }) {
const [toggleState, setToggleState] = useState(0)
const [isGenerating, setIsGenerating] = useState(false)
const missionRef = useRef(null)

// Inject Share Tech Mono font for landing
useEffect(() => {
const link1 = document.createElement('link')
link1.rel = 'preconnect'; link1.href = 'https://fonts.googleapis.com'
const link2 = document.createElement('link')
link2.rel = 'preconnect'; link2.href = 'https://fonts.gstatic.com'; link2.crossOrigin = 'anonymous'
const link3 = document.createElement('link')
link3.href = 'https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap'; link3.rel = 'stylesheet'
document.head.append(link1, link2, link3)
return () => { link1.remove(); link2.remove(); link3.remove(); }
}, [])

// Typewriter effect
const typeWriter = (el, text, speed = 30) => {
if (!el) return
let i = 0
el.innerHTML = ''
const cursor = document.createElement('span')
cursor.style.borderRight = '3px solid var(--primary-color)'
cursor.style.animation = 'blink-caret .75s step-end infinite'
el.appendChild(cursor)
const typing = () => {
if (i < text.length) {
cursor.insertAdjacentText('beforebegin', text.charAt(i))
i++
window.setTimeout(typing, speed)
} else {
cursor.style.animation = 'none'; cursor.style.borderRight = 'none'
}
}
typing()
}

const themes = [
{ name: 'red', primary: '#E53E3E', glow: 'rgba(229, 62, 62, 0.75)' },
{ name: 'blue', primary: '#4299E1', glow: 'rgba(66, 153, 225, 0.75)' },
]

const cycleTheme = () => {
const next = (toggleState + 1) % themes.length
setToggleState(next)
const t = themes[next]
document.documentElement.style.setProperty('--primary-color', t.primary)
document.documentElement.style.setProperty('--glow-color', t.glow)
setPalette(t.name)
}

const handleGenerate = async () => {
if (isGenerating) return
setIsGenerating(true)
if (missionRef.current) missionRef.current.innerHTML = '<div class="loader"></div>'
try {
const mission = await generateMission()
typeWriter(missionRef.current, (mission || 'Transmission failed.').trim())
} catch (e) {
if (missionRef.current) missionRef.current.textContent = 'Error: Could not connect to Archangel network. Please try again.'
} finally { setIsGenerating(false) }
}

return (
<div className="fixed inset-0 z-40 flex items-center justify-center p-4 hacker-bg">
<style>{`
:root { --primary-color:#E53E3E; --glow-color: rgba(229,62,62,0.75); }
.hacker-bg{ background:#0a0a0a; position:relative; overflow:hidden; font-family:'Share Tech Mono',monospace; color:var(--primary-color); text-shadow:0 0 5px var(--glow-color),0 0 10px var(--glow-color); }
.hacker-bg::before{ content:''; position:absolute; inset:0; background-image:linear-gradient(0deg, rgba(0,0,0,0) 50%, rgba(255,255,255,0.05) 50%); background-size:100% 4px; animation:scanlines 5s linear infinite; opacity:.2; pointer-events:none }
@keyframes scanlines { 0%{background-position:0 0} 100%{background-position:0 100px} }
.smooth-transition{ transition: opacity .8s ease, transform .8s ease }
.glowing-btn{ border:2px solid var(--primary-color); box-shadow: 0 0 10px var(--glow-color), 0 0 20px var(--glow-color) inset; color:var(--primary-color) }
.glowing-btn:hover{ background:var(--primary-color); color:#0a0a0a; box-shadow: 0 0 20px var(--glow-color), 0 0 30px var(--glow-color) inset }
.loader{ width:24px; height:24px; border:3px solid var(--primary-color); border-bottom-color:transparent; border-radius:50%; display:inline-block; animation:rotation 1s linear infinite }
@keyframes rotation { 0%{transform:rotate(0)} 100%{transform:rotate(360deg)} }
@keyframes blink-caret { from,to{border-color:transparent} 50%{border-color:var(--primary-color)} }
`}</style>

{/* Theme toggle in top-right */}
<div className="fixed top-4 right-4 flex items-center z-50" style={{textShadow:'none'}}>
<span className="mr-4 uppercase tracking-widest text-sm text-gray-300">Theme:</span>
<button onClick={cycleTheme} className="rounded-full w-[120px] h-[40px] flex items-center justify-center bg-gradient-to-r from-rose-600 via-purple-500 to-blue-500 shadow-inner">
<span className="text-white text-xs">Cycle</span>
</button>
</div>

{/* Card */}
<div className="text-center smooth-transition w-full max-w-3xl">
<h1 className="text-3xl md:text-5xl font-bold mb-4">Welcome to Red Calibur</h1>
<button onClick={onEnter} className="underline decoration-dotted hover:decoration-solid text-lg">Click here to open the website</button>

<div className="opacity-100 translate-y-0 w-full max-w-3xl mx-auto mt-8">
<div className="w-full mx-auto p-6 border-2 rounded-lg mb-8 flex items-center justify-center" style={{borderColor:'var(--primary-color)', boxShadow:'0 0 10px var(--glow-color)'}}>
<p ref={missionRef} className="text-lg text-left">Awaiting transmission...</p>
</div>
<button onClick={handleGenerate} disabled={isGenerating} className="glowing-btn font-bold py-3 px-8 rounded-lg text-xl uppercase tracking-widest">
{isGenerating ? 'Contacting…' : '✨ Generate Mission'}
</button>
</div>
</div>
</div>
)
}
10 changes: 9 additions & 1 deletion frontend/src/main.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import App from './App'
import LandingPage from './pages/LandingPage'
import './styles.css'

createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
<BrowserRouter>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/app" element={<App />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</React.StrictMode>
)
43 changes: 43 additions & 0 deletions frontend/src/pages/LandingPage.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React, { useMemo, useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import Landing from '../components/Landing'

const API_BASE = import.meta.env.VITE_API_BASE || '/api'

export default function LandingPage(){
const navigate = useNavigate()
const [palette, setPalette] = useState(() => (typeof window==='undefined' ? 'red' : (localStorage.getItem('rc_palette')||'red')))

useEffect(() => {
const root = document.documentElement
if (palette==='blue') {
root.style.setProperty('--primary-rgb','59,130,246')
root.style.setProperty('--secondary-rgb','244,63,94')
root.style.setProperty('--primary-color','#4299E1')
root.style.setProperty('--glow-color','rgba(66,153,225,0.75)')
} else {
root.style.setProperty('--primary-rgb','244,63,94')
root.style.setProperty('--secondary-rgb','59,130,246')
root.style.setProperty('--primary-color','#E53E3E')
root.style.setProperty('--glow-color','rgba(229,62,62,0.75)')
}
try { localStorage.setItem('rc_palette', palette) } catch {}
}, [palette])

const generateMission = useMemo(()=> async () => {
const payload = { payload: { brief: 'Generate a short cryptic hacker mission briefing (<40 words). Code name: Archangel. Target: corporate project. Style: tense, high-stakes.' } }
try {
const res = await fetch(`${API_BASE}/summarize`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) })
const json = await res.json(); return json?.summary || 'No mission received.'
} catch { return 'Error contacting mission service.' }
}, [])

return (
<Landing
palette={palette}
setPalette={setPalette}
generateMission={generateMission}
onEnter={()=> navigate('/app')}
/>
)
}
Loading
Loading