nothumanallowed 3.7.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli.mjs +6 -0
- package/src/commands/scan.mjs +187 -0
- package/src/commands/ui.mjs +50 -2
- package/src/services/web-ui.mjs +57 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nothumanallowed",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "NotHumanAllowed — 38 AI agents for security, code, DevOps, data & daily ops. Ask agents directly, plan your day with 5 specialist agents, manage tasks, connect Gmail + Calendar.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { cmdOps } from './commands/ops.mjs';
|
|
|
15
15
|
import { cmdChat } from './commands/chat.mjs';
|
|
16
16
|
import { cmdUI } from './commands/ui.mjs';
|
|
17
17
|
import { cmdGoogle } from './commands/google-auth.mjs';
|
|
18
|
+
import { cmdScan } from './commands/scan.mjs';
|
|
18
19
|
import { banner, info, ok, warn, fail, C, G, Y, D, W, BOLD, NC, M, B, R } from './ui.mjs';
|
|
19
20
|
|
|
20
21
|
export async function main(argv) {
|
|
@@ -65,6 +66,9 @@ export async function main(argv) {
|
|
|
65
66
|
case 'google':
|
|
66
67
|
return cmdGoogle(args);
|
|
67
68
|
|
|
69
|
+
case 'scan':
|
|
70
|
+
return cmdScan(args);
|
|
71
|
+
|
|
68
72
|
case 'pif':
|
|
69
73
|
return cmdPif(args);
|
|
70
74
|
|
|
@@ -355,6 +359,8 @@ function cmdHelp() {
|
|
|
355
359
|
console.log(` ask oracle "prompt" ${D}--provider openai${NC}`);
|
|
356
360
|
console.log(` agents List all 38 specialized agents`);
|
|
357
361
|
console.log(` agents info <name> Show agent capabilities & domain`);
|
|
362
|
+
console.log(` scan <path> Security scan a project with SABER + ZERO`);
|
|
363
|
+
console.log(` scan . ${D}--output report.md${NC} Save report to file`);
|
|
358
364
|
console.log(` run "prompt" Multi-agent collaboration (server-routed)`);
|
|
359
365
|
console.log(` run "prompt" ${D}--agents saber,zero${NC} Collaborate with specific agents\n`);
|
|
360
366
|
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nha scan <path> — Scan a project with SABER + ZERO agents.
|
|
3
|
+
* Reads files, builds context, runs security + vulnerability analysis.
|
|
4
|
+
* Zero server. Direct LLM calls with your API key.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { loadConfig } from '../config.mjs';
|
|
10
|
+
import { callAgent } from '../services/llm.mjs';
|
|
11
|
+
import { fail, info, ok, warn, C, G, Y, D, W, BOLD, NC, R } from '../ui.mjs';
|
|
12
|
+
|
|
13
|
+
const SCAN_EXTENSIONS = new Set([
|
|
14
|
+
'.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
|
|
15
|
+
'.py', '.rb', '.go', '.rs', '.java', '.kt',
|
|
16
|
+
'.php', '.c', '.cpp', '.h', '.cs', '.swift',
|
|
17
|
+
'.sh', '.bash', '.zsh',
|
|
18
|
+
'.json', '.yaml', '.yml', '.toml', '.ini', '.cfg',
|
|
19
|
+
'.env', '.env.example', '.env.local',
|
|
20
|
+
'.sql', '.prisma', '.graphql',
|
|
21
|
+
'.dockerfile', '.docker-compose.yml',
|
|
22
|
+
'.tf', '.hcl',
|
|
23
|
+
'.md', '.txt',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const IGNORE_DIRS = new Set([
|
|
27
|
+
'node_modules', '.git', '.next', 'dist', 'build', 'out',
|
|
28
|
+
'__pycache__', '.venv', 'venv', 'vendor', 'target',
|
|
29
|
+
'.cache', '.turbo', 'coverage', '.nyc_output',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const MAX_FILE_SIZE = 100_000; // 100KB per file
|
|
33
|
+
const MAX_TOTAL_CHARS = 200_000; // 200KB total context
|
|
34
|
+
|
|
35
|
+
function scanDirectory(dirPath, files = [], depth = 0) {
|
|
36
|
+
if (depth > 8) return files;
|
|
37
|
+
try {
|
|
38
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (entry.name.startsWith('.') && entry.name !== '.env' && entry.name !== '.env.example') continue;
|
|
41
|
+
if (IGNORE_DIRS.has(entry.name)) continue;
|
|
42
|
+
|
|
43
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
scanDirectory(fullPath, files, depth + 1);
|
|
46
|
+
} else if (entry.isFile()) {
|
|
47
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
48
|
+
if (SCAN_EXTENSIONS.has(ext) || entry.name === 'Dockerfile' || entry.name === 'Makefile') {
|
|
49
|
+
try {
|
|
50
|
+
const stat = fs.statSync(fullPath);
|
|
51
|
+
if (stat.size <= MAX_FILE_SIZE && stat.size > 0) {
|
|
52
|
+
files.push({ path: fullPath, size: stat.size, ext });
|
|
53
|
+
}
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch {}
|
|
59
|
+
return files;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildProjectContext(projectPath, files) {
|
|
63
|
+
let context = `Project: ${projectPath}\nFiles scanned: ${files.length}\n\n`;
|
|
64
|
+
let totalChars = context.length;
|
|
65
|
+
|
|
66
|
+
// Prioritize security-relevant files first
|
|
67
|
+
const prioritized = files.sort((a, b) => {
|
|
68
|
+
const securityFiles = ['.env', 'auth', 'login', 'password', 'secret', 'token', 'key', 'security', 'middleware'];
|
|
69
|
+
const aScore = securityFiles.some(s => a.path.toLowerCase().includes(s)) ? 0 : 1;
|
|
70
|
+
const bScore = securityFiles.some(s => b.path.toLowerCase().includes(s)) ? 0 : 1;
|
|
71
|
+
return aScore - bScore;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
for (const file of prioritized) {
|
|
75
|
+
if (totalChars >= MAX_TOTAL_CHARS) break;
|
|
76
|
+
try {
|
|
77
|
+
const content = fs.readFileSync(file.path, 'utf-8');
|
|
78
|
+
const relative = path.relative(projectPath, file.path);
|
|
79
|
+
const entry = `\n--- ${relative} (${file.size} bytes) ---\n${content.slice(0, MAX_FILE_SIZE)}\n`;
|
|
80
|
+
if (totalChars + entry.length > MAX_TOTAL_CHARS) {
|
|
81
|
+
const remaining = MAX_TOTAL_CHARS - totalChars;
|
|
82
|
+
context += entry.slice(0, remaining) + '\n[... truncated ...]\n';
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
context += entry;
|
|
86
|
+
totalChars += entry.length;
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return context;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function cmdScan(args) {
|
|
94
|
+
const projectPath = args[0] ? path.resolve(args[0]) : process.cwd();
|
|
95
|
+
|
|
96
|
+
if (!fs.existsSync(projectPath) || !fs.statSync(projectPath).isDirectory()) {
|
|
97
|
+
fail(`Not a directory: ${projectPath}`);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const config = loadConfig();
|
|
102
|
+
if (!config.llm.apiKey) {
|
|
103
|
+
fail('No API key configured. Run: nha config set key YOUR_KEY');
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Parse flags
|
|
108
|
+
let agents = ['saber', 'zero'];
|
|
109
|
+
let outputFile = null;
|
|
110
|
+
for (let i = 1; i < args.length; i++) {
|
|
111
|
+
if (args[i] === '--agents' && args[i + 1]) { agents = args[++i].split(','); continue; }
|
|
112
|
+
if (args[i] === '--output' && args[i + 1]) { outputFile = args[++i]; continue; }
|
|
113
|
+
if (args[i] === '-o' && args[i + 1]) { outputFile = args[++i]; continue; }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.log(`\n ${BOLD}NHA Project Scanner${NC}`);
|
|
117
|
+
console.log(` ${D}Path: ${projectPath}${NC}\n`);
|
|
118
|
+
|
|
119
|
+
// Scan files
|
|
120
|
+
info('Scanning project files...');
|
|
121
|
+
const files = scanDirectory(projectPath);
|
|
122
|
+
ok(`Found ${files.length} scannable files`);
|
|
123
|
+
|
|
124
|
+
if (files.length === 0) {
|
|
125
|
+
warn('No source files found.');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Show file breakdown
|
|
130
|
+
const extCounts = {};
|
|
131
|
+
for (const f of files) {
|
|
132
|
+
extCounts[f.ext] = (extCounts[f.ext] || 0) + 1;
|
|
133
|
+
}
|
|
134
|
+
const breakdown = Object.entries(extCounts).sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
135
|
+
console.log(` ${D}${breakdown.map(([e, c]) => `${e}: ${c}`).join(' | ')}${NC}\n`);
|
|
136
|
+
|
|
137
|
+
// Build context
|
|
138
|
+
info('Building project context...');
|
|
139
|
+
const context = buildProjectContext(projectPath, files);
|
|
140
|
+
ok(`Context: ${(context.length / 1024).toFixed(0)} KB from ${files.length} files`);
|
|
141
|
+
|
|
142
|
+
// Run agents
|
|
143
|
+
const results = {};
|
|
144
|
+
for (const agentName of agents) {
|
|
145
|
+
info(`Running ${agentName.toUpperCase()}...`);
|
|
146
|
+
const startTime = Date.now();
|
|
147
|
+
try {
|
|
148
|
+
const result = await callAgent(config, agentName,
|
|
149
|
+
`Perform a thorough analysis of this project. Focus on:\n` +
|
|
150
|
+
`- Security vulnerabilities (OWASP Top 10, CWE references)\n` +
|
|
151
|
+
`- Hardcoded secrets, API keys, passwords\n` +
|
|
152
|
+
`- Dependency risks, outdated patterns\n` +
|
|
153
|
+
`- Authentication/authorization issues\n` +
|
|
154
|
+
`- Input validation, injection vectors\n` +
|
|
155
|
+
`- Configuration security\n` +
|
|
156
|
+
`- Code quality issues that impact security\n\n` +
|
|
157
|
+
`PROJECT FILES:\n${context}`
|
|
158
|
+
);
|
|
159
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
160
|
+
results[agentName] = result;
|
|
161
|
+
ok(`${agentName.toUpperCase()} complete (${elapsed}s)`);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
warn(`${agentName.toUpperCase()} failed: ${e.message}`);
|
|
164
|
+
results[agentName] = `Error: ${e.message}`;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Display results
|
|
169
|
+
console.log(`\n${'='.repeat(60)}`);
|
|
170
|
+
for (const [agent, result] of Object.entries(results)) {
|
|
171
|
+
console.log(`\n ${BOLD}${C}${agent.toUpperCase()} REPORT${NC}\n`);
|
|
172
|
+
console.log(result);
|
|
173
|
+
console.log(`\n${'─'.repeat(60)}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Save to file if requested
|
|
177
|
+
if (outputFile) {
|
|
178
|
+
const report = Object.entries(results)
|
|
179
|
+
.map(([agent, result]) => `# ${agent.toUpperCase()} REPORT\n\n${result}`)
|
|
180
|
+
.join('\n\n---\n\n');
|
|
181
|
+
const header = `# NHA Security Scan Report\n\nProject: ${projectPath}\nDate: ${new Date().toISOString()}\nAgents: ${agents.join(', ')}\nFiles scanned: ${files.length}\n\n---\n\n`;
|
|
182
|
+
fs.writeFileSync(outputFile, header + report, 'utf-8');
|
|
183
|
+
ok(`Report saved to ${outputFile}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
console.log('');
|
|
187
|
+
}
|
package/src/commands/ui.mjs
CHANGED
|
@@ -597,7 +597,7 @@ export async function cmdUI(args) {
|
|
|
597
597
|
return;
|
|
598
598
|
}
|
|
599
599
|
|
|
600
|
-
// POST /api/ask
|
|
600
|
+
// POST /api/ask — agent call with personal context (email, calendar, tasks)
|
|
601
601
|
if (method === 'POST' && pathname === '/api/ask') {
|
|
602
602
|
const body = await parseBody(req);
|
|
603
603
|
if (!body.agent || !body.prompt) {
|
|
@@ -619,7 +619,55 @@ export async function cmdUI(args) {
|
|
|
619
619
|
}
|
|
620
620
|
|
|
621
621
|
try {
|
|
622
|
-
|
|
622
|
+
// Build personal context from Gmail + Calendar + Tasks
|
|
623
|
+
let context = '';
|
|
624
|
+
try {
|
|
625
|
+
const [emails, events] = await Promise.all([
|
|
626
|
+
getUnreadImportant(config, 15).catch(() => []),
|
|
627
|
+
getTodayEvents(config).catch(() => []),
|
|
628
|
+
]);
|
|
629
|
+
const tasks = getTasks();
|
|
630
|
+
|
|
631
|
+
if (emails.length > 0) {
|
|
632
|
+
context += '\n\n[USER EMAIL CONTEXT — real data from their Gmail]\n';
|
|
633
|
+
emails.slice(0, 10).forEach((e, i) => {
|
|
634
|
+
context += `${i + 1}. From: ${e.from} | Subject: ${e.subject} | Date: ${e.date}\n ${e.snippet.slice(0, 150)}\n URLs: ${e.urls.join(', ') || 'none'}\n`;
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (events.length > 0) {
|
|
639
|
+
context += '\n\n[USER CALENDAR — today]\n';
|
|
640
|
+
events.forEach(e => {
|
|
641
|
+
const time = e.isAllDay ? 'All day' : `${e.start} - ${e.end}`;
|
|
642
|
+
context += `${time}: ${e.summary}${e.location ? ' @ ' + e.location : ''}${e.attendees?.length ? ' with ' + e.attendees.map(a => a.name || a.email).join(', ') : ''}\n`;
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (tasks.length > 0) {
|
|
647
|
+
context += '\n\n[USER TASKS — today]\n';
|
|
648
|
+
tasks.forEach(t => {
|
|
649
|
+
context += `#${t.id} [${t.priority}] ${t.status === 'done' ? '[DONE] ' : ''}${t.description}\n`;
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
} catch { /* context loading failed, proceed without it */ }
|
|
653
|
+
|
|
654
|
+
// Attach file content if provided
|
|
655
|
+
let fileContext = '';
|
|
656
|
+
if (body.fileContent && body.fileName) {
|
|
657
|
+
const maxChars = 100000;
|
|
658
|
+
const content = String(body.fileContent).slice(0, maxChars);
|
|
659
|
+
fileContext = '\n\n--- Attached file: ' + body.fileName + ' ---\n' + content;
|
|
660
|
+
if (body.fileContent.length > maxChars) fileContext += '\n[... truncated at 100KB ...]';
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const enrichedPrompt = body.prompt + fileContext + (context
|
|
664
|
+
? '\n\nIMPORTANT CONTEXT: The data below is from the user\'s OWN accounts (their Gmail, their Google Calendar, their tasks). The user is the OWNER of these accounts. ' +
|
|
665
|
+
'Recent activity (npm publishes, Google login alerts, GitHub notifications) was done BY THE USER THEMSELVES as part of their normal work. ' +
|
|
666
|
+
'Do NOT flag the user\'s own legitimate activity as suspicious or compromised. ' +
|
|
667
|
+
'Only flag ACTUAL external threats: phishing emails from unknown senders, suspicious links, unauthorized access from unknown locations.\n' + context
|
|
668
|
+
: '');
|
|
669
|
+
|
|
670
|
+
const response = await callAgent(config, body.agent, enrichedPrompt);
|
|
623
671
|
sendJSON(res, 200, { response, agent: body.agent });
|
|
624
672
|
} catch (e) {
|
|
625
673
|
sendJSON(res, 200, { response: null, error: e.message });
|
package/src/services/web-ui.mjs
CHANGED
|
@@ -475,10 +475,11 @@ function openDayDetail(dateStr){
|
|
|
475
475
|
// Use the agent modal for day detail
|
|
476
476
|
document.getElementById('modalName').textContent=dayLabel;
|
|
477
477
|
document.getElementById('modalPrompt').style.display='none';
|
|
478
|
+
document.getElementById('fileDropZone').style.display='none';
|
|
479
|
+
document.getElementById('fileInfo').style.display='none';
|
|
478
480
|
document.getElementById('modalResponse').style.display='block';
|
|
479
481
|
document.getElementById('modalResponse').innerHTML=h;
|
|
480
482
|
document.getElementById('agentModal').classList.add('modal-overlay--open');
|
|
481
|
-
// Hide ask button
|
|
482
483
|
var sendBtn=document.getElementById('agentModal').querySelector('.btn--primary');
|
|
483
484
|
if(sendBtn)sendBtn.style.display='none';
|
|
484
485
|
}
|
|
@@ -495,12 +496,17 @@ function renderAgents(el){
|
|
|
495
496
|
}
|
|
496
497
|
function openAgent(name,display){
|
|
497
498
|
selectedAgent=name;
|
|
499
|
+
attachedFileContent=null;attachedFileName=null;
|
|
498
500
|
document.getElementById('modalName').textContent=display||name;
|
|
499
501
|
document.getElementById('modalPrompt').value='';
|
|
500
502
|
document.getElementById('modalPrompt').style.display='';
|
|
501
503
|
document.getElementById('modalResponse').style.display='none';
|
|
502
504
|
document.getElementById('modalResponse').textContent='';
|
|
503
505
|
document.getElementById('modalResponse').innerHTML='';
|
|
506
|
+
document.getElementById('fileInfo').style.display='none';
|
|
507
|
+
document.getElementById('fileDropZone').style.display='';
|
|
508
|
+
document.getElementById('fileDropZone').style.borderColor='var(--border2)';
|
|
509
|
+
document.getElementById('fileInput').value='';
|
|
504
510
|
var sendBtn=document.getElementById('agentModal').querySelector('.btn--primary');
|
|
505
511
|
if(sendBtn)sendBtn.style.display='';
|
|
506
512
|
document.getElementById('agentModal').classList.add('modal-overlay--open');
|
|
@@ -508,12 +514,56 @@ function openAgent(name,display){
|
|
|
508
514
|
function closeModal(){
|
|
509
515
|
document.getElementById('agentModal').classList.remove('modal-overlay--open');
|
|
510
516
|
}
|
|
517
|
+
var attachedFileContent = null;
|
|
518
|
+
var attachedFileName = null;
|
|
519
|
+
|
|
520
|
+
function handleFileDrop(e) {
|
|
521
|
+
var file = e.dataTransfer.files[0];
|
|
522
|
+
if (file) readFile(file);
|
|
523
|
+
}
|
|
524
|
+
function handleFileSelect(input) {
|
|
525
|
+
var file = input.files[0];
|
|
526
|
+
if (file) readFile(file);
|
|
527
|
+
}
|
|
528
|
+
function readFile(file) {
|
|
529
|
+
if (file.size > 500000) {
|
|
530
|
+
document.getElementById('fileInfo').style.display = 'block';
|
|
531
|
+
document.getElementById('fileInfo').textContent = 'File too large (max 500KB)';
|
|
532
|
+
document.getElementById('fileInfo').style.color = 'var(--red)';
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
var reader = new FileReader();
|
|
536
|
+
reader.onload = function(e) {
|
|
537
|
+
attachedFileContent = e.target.result;
|
|
538
|
+
attachedFileName = file.name;
|
|
539
|
+
var info = document.getElementById('fileInfo');
|
|
540
|
+
info.style.display = 'block';
|
|
541
|
+
info.style.color = 'var(--cyan)';
|
|
542
|
+
info.textContent = 'Attached: ' + file.name + ' (' + (file.size / 1024).toFixed(1) + ' KB)';
|
|
543
|
+
document.getElementById('fileDropZone').style.borderColor = 'var(--green)';
|
|
544
|
+
};
|
|
545
|
+
reader.readAsText(file);
|
|
546
|
+
}
|
|
547
|
+
|
|
511
548
|
function askAgent(){
|
|
512
549
|
var p=document.getElementById('modalPrompt').value.trim();if(!p||!selectedAgent)return;
|
|
513
550
|
var resp=document.getElementById('modalResponse');
|
|
514
551
|
resp.style.display='block';resp.textContent='Thinking...';
|
|
515
|
-
|
|
552
|
+
|
|
553
|
+
var payload = {agent:selectedAgent, prompt:p};
|
|
554
|
+
if (attachedFileContent) {
|
|
555
|
+
payload.fileContent = attachedFileContent;
|
|
556
|
+
payload.fileName = attachedFileName;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
apiPost('/api/ask', payload).then(function(r){
|
|
516
560
|
resp.textContent=(r&&r.response)||'Error: no response';
|
|
561
|
+
// Reset file after ask
|
|
562
|
+
attachedFileContent = null;
|
|
563
|
+
attachedFileName = null;
|
|
564
|
+
document.getElementById('fileInfo').style.display = 'none';
|
|
565
|
+
document.getElementById('fileDropZone').style.borderColor = 'var(--border2)';
|
|
566
|
+
document.getElementById('fileInput').value = '';
|
|
517
567
|
});
|
|
518
568
|
}
|
|
519
569
|
|
|
@@ -587,6 +637,11 @@ init();
|
|
|
587
637
|
</div>
|
|
588
638
|
<div class="modal__body">
|
|
589
639
|
<textarea id="modalPrompt" placeholder="Ask this agent something..."></textarea>
|
|
640
|
+
<div id="fileDropZone" style="border:2px dashed var(--border2);border-radius:6px;padding:12px;text-align:center;color:var(--dim);font-size:11px;cursor:pointer;margin-bottom:10px;transition:border-color .2s" onclick="document.getElementById('fileInput').click()" ondragover="event.preventDefault();this.style.borderColor='var(--green)'" ondragleave="this.style.borderColor='var(--border2)'" ondrop="event.preventDefault();this.style.borderColor='var(--border2)';handleFileDrop(event)">
|
|
641
|
+
Drop a file here or click to attach
|
|
642
|
+
<input type="file" id="fileInput" style="display:none" onchange="handleFileSelect(this)">
|
|
643
|
+
</div>
|
|
644
|
+
<div id="fileInfo" style="display:none;font-size:10px;color:var(--cyan);margin-bottom:8px"></div>
|
|
590
645
|
<div class="modal__response" id="modalResponse" style="display:none"></div>
|
|
591
646
|
</div>
|
|
592
647
|
<div class="modal__footer">
|