4bnode 4.1.3 → 4.1.5
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/add-docs.js +18 -5
- package/app-1/.4bnode/.passkey +1 -1
- package/app-1/.4bnode/assets/js/components/Sidebar.js +2 -2
- package/app-1/.4bnode/assets/js/pages/MailPage.js +144 -54
- package/app-1/.4bnode/assets/js/pages/RoutesPage.js +51 -2
- package/app-1/.4bnode/assets/js/pages/SecurityPage.js +1 -1
- package/app-1/.4bnode/codegen.js +247 -56
- package/app-1/.4bnode/dev-api.js +131 -5
- package/app-1/.env.example +11 -0
- package/app-1/index.js +49 -6
- package/app-1/package.json +2 -0
- package/app-1/src/docs.js +1 -1
- package/app-1/src/middleware/apiKey.js +36 -0
- package/app-1/src/middleware/auth.js +17 -0
- package/app-1/src/models/user.js +11 -0
- package/app-1/src/openapi.json +23 -5
- package/app-1/src/routes/upload-resume.js +21 -0
- package/app-1/src/services/mailer.js +52 -0
- package/app-1/src/validators/upload-resume.js +8 -0
- package/lib/codegen.js +247 -56
- package/lib/codegen.test.js +37 -1
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -2
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +144 -54
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +51 -2
- package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +1 -1
- package/skeleton/.4bnode/codegen.js +247 -56
- package/skeleton/.4bnode/dev-api.js +131 -5
- package/skeleton/.env +6 -0
- package/skeleton/.env.example +11 -0
- package/app-1/src/models/login.js +0 -9
- package/app-1/src/routes/products.js +0 -26
- package/app-1/src/validators/products.js +0 -7
- package/app-1/src/validators/user.js +0 -7
package/app-1/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { createInterface } from "readline";
|
|
|
10
10
|
import { notFound, errorHandler } from "./src/middleware/errorHandler.js";
|
|
11
11
|
import connectDB from './src/db.js';
|
|
12
12
|
import docsRouter from './src/docs.js';
|
|
13
|
-
import
|
|
13
|
+
import uploadResumeRouter from './src/routes/upload-resume.js';
|
|
14
14
|
|
|
15
15
|
// ── Environment ─────────────────────────────────────
|
|
16
16
|
dotenv.config();
|
|
@@ -49,17 +49,48 @@ if (dashboardEnabled) {
|
|
|
49
49
|
// ── Security (built-in by default) ───────────────────
|
|
50
50
|
// Mounted AFTER the /_dev dashboard so its CDN/inline scripts aren't blocked by
|
|
51
51
|
// helmet's CSP and the dashboard isn't throttled by the rate limiter.
|
|
52
|
-
|
|
52
|
+
// helmet() defaults to a strict Content-Security-Policy (script-src 'self',
|
|
53
|
+
// etc.) that blocks CDN-loaded scripts, inline <script> blocks, and cross-origin
|
|
54
|
+
// assets. We widen the policy so a typical frontend/docs page works while keeping
|
|
55
|
+
// the rest of helmet's protections. Tighten these directives for production.
|
|
56
|
+
app.use(helmet({
|
|
57
|
+
contentSecurityPolicy: {
|
|
58
|
+
directives: {
|
|
59
|
+
defaultSrc: ["'self'"],
|
|
60
|
+
scriptSrc: ["'self'", 'https:', "'unsafe-inline'"],
|
|
61
|
+
styleSrc: ["'self'", 'https:', "'unsafe-inline'"],
|
|
62
|
+
imgSrc: ["'self'", 'data:', 'https:'],
|
|
63
|
+
connectSrc: ["'self'"],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
crossOriginEmbedderPolicy: false,
|
|
67
|
+
// Allow other origins (e.g. a separate frontend host) to load assets served
|
|
68
|
+
// by this app. Default is 'same-origin'. Tighten to 'same-site' for production.
|
|
69
|
+
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
|
70
|
+
}));
|
|
53
71
|
|
|
54
72
|
// ── Rate limiting ────────────────────────────────────
|
|
55
|
-
//
|
|
73
|
+
// Window + cap are read from .env (RATE_LIMIT_WINDOW_MS / RATE_LIMIT_MAX) so the
|
|
74
|
+
// dashboard's Security page can tune them; fall back to 15 min / 500 requests.
|
|
56
75
|
const apiLimiter = rateLimit({
|
|
57
76
|
windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
|
|
58
|
-
max: Number(process.env.RATE_LIMIT_MAX) ||
|
|
77
|
+
max: Number(process.env.RATE_LIMIT_MAX) || 500,
|
|
59
78
|
standardHeaders: true,
|
|
60
79
|
legacyHeaders: false,
|
|
61
80
|
});
|
|
62
|
-
|
|
81
|
+
// Strict cap for authentication routes — defends against password brute-forcing.
|
|
82
|
+
// Point this at your real auth routes (login / signup / password-reset).
|
|
83
|
+
const authLimiter = rateLimit({
|
|
84
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
85
|
+
max: 10, // 10 attempts per IP per window
|
|
86
|
+
standardHeaders: true,
|
|
87
|
+
legacyHeaders: false,
|
|
88
|
+
});
|
|
89
|
+
// Only throttle in production — in dev, repeated/hot-reload requests shouldn't hit 429.
|
|
90
|
+
if (process.env.NODE_ENV === 'production') {
|
|
91
|
+
app.use('/api/auth', authLimiter); // strict limiter first, on auth routes
|
|
92
|
+
app.use(apiLimiter); // generous global limiter for everything else
|
|
93
|
+
}
|
|
63
94
|
|
|
64
95
|
// ── Static Files ────────────────────────────────────
|
|
65
96
|
app.use(express.static(path.join(__dirname, "public")));
|
|
@@ -104,9 +135,21 @@ function askPort(busyPort) {
|
|
|
104
135
|
}
|
|
105
136
|
|
|
106
137
|
|
|
138
|
+
|
|
107
139
|
app.use('/docs', docsRouter);
|
|
108
140
|
|
|
109
|
-
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
app.use('/api/upload-resume', uploadResumeRouter);
|
|
110
153
|
|
|
111
154
|
// ── Error handling (must be last) ──
|
|
112
155
|
app.use(notFound);
|
package/app-1/package.json
CHANGED
package/app-1/src/docs.js
CHANGED
|
@@ -61,7 +61,7 @@ router.get('/openapi.json', (req, res) => {
|
|
|
61
61
|
res.sendFile(SPEC);
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
const DOCS_PAGE = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<title>API Documentation</title>\n<style>\n * { box-sizing: border-box; }\n :root {\n --bg:#0f1117; --surface:#11141e; --card:#1a1d2b; --card-hover:#21253a; --input:#13161f;\n --border:rgba(255,255,255,0.08); --text:#eceef4; --muted:#8b8fa3; --faint:#626880;\n --accent:#0079ff; --accent-light:#3daefd; --accent-glow:rgba(0,121,255,0.14);\n }\n * { scrollbar-width: thin; }\n html, body { margin:0; padding:0; height:100%; }\n body { background:var(--bg); color:var(--text); line-height:1.5; height:100vh;\n display:flex; flex-direction:column; overflow:hidden;\n font-family:\"Noto Sans\",-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,sans-serif; }\n .topbar { flex:none; display:flex; align-items:center; gap:14px;\n background:var(--surface); border-bottom:1px solid var(--border); padding:14px 24px; }\n .topbar h1 { font-size:17px; font-weight:700; margin:0;\n background:linear-gradient(135deg,#3daefd,#0079ff); -webkit-background-clip:text;\n background-clip:text; -webkit-text-fill-color:transparent; }\n .pill-ver { font-size:12px; color:var(--accent-light); background:rgba(0,121,255,0.12);\n border:1px solid rgba(0,121,255,0.25); padding:3px 9px; border-radius:999px; }\n .layout { flex:1; display:flex; min-height:0; }\n .sidebar { width:330px; flex:none; border-right:1px solid var(--border);\n overflow-y:auto; padding:14px 12px; }\n .search { width:100%; background:var(--input); border:1px solid var(--border);\n color:var(--text); border-radius:9px; padding:8px 12px; font-size:13px; outline:none; margin-bottom:12px; }\n .search:focus { border-color:var(--accent); }\n .group-title { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0.6px;\n color:var(--faint); margin:14px 6px 6px; }\n .nav-item { display:flex; align-items:center; gap:9px; padding:8px 10px; border-radius:8px;\n cursor:pointer; border:1px solid transparent; }\n .nav-item:hover { background:var(--card-hover); }\n .nav-item.active { background:var(--accent-glow); border-color:rgba(0,121,255,0.35); }\n .nav-method { font-size:9.5px; font-weight:700; letter-spacing:0.4px; text-transform:uppercase;\n color:#fff; padding:3px 0; border-radius:5px; width:46px; flex:none; text-align:center; }\n .nav-path { font-family:ui-monospace,Menlo,monospace; font-size:12.5px; color:var(--text);\n overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }\n .main { flex:1; overflow-y:auto; padding:30px 40px; }\n .main-empty { color:var(--faint); display:flex; height:100%; align-items:center; justify-content:center; }\n .detail-head { display:flex; align-items:center; gap:14px; margin-bottom:6px; }\n .method-lg { font-size:13px; font-weight:700; letter-spacing:0.5px; text-transform:uppercase;\n color:#fff; padding:6px 12px; border-radius:8px; }\n .path-lg { font-family:ui-monospace,Menlo,monospace; font-size:18px; color:var(--text); word-break:break-all; }\n .detail-sum { color:var(--muted); margin:0 0 8px; }\n .lock { color:var(--faint); font-size:12px; margin-left:8px; white-space:nowrap; }\n .sec { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0.5px;\n color:var(--faint); margin:22px 0 8px; }\n .row { display:flex; gap:10px; align-items:center; padding:8px 0;\n border-bottom:1px solid var(--border); font-size:13px; }\n .row:last-child { border-bottom:none; }\n .fname { font-family:ui-monospace,Menlo,monospace; color:var(--text); min-width:150px; }\n .ftype { color:var(--accent-light); font-size:12px; }\n .req { color:#ef4444; font-size:11px; font-weight:700; }\n .opt { color:var(--faint); font-size:11px; }\n .resp-code { font-family:ui-monospace,Menlo,monospace; color:#22c55e; font-weight:700; }\n input.f, textarea.f { width:100%; max-width:540px; background:var(--input); border:1px solid var(--border);\n color:var(--text); border-radius:8px; padding:8px 10px; font-size:13px; outline:none;\n font-family:ui-monospace,Menlo,monospace; }\n input.f:focus, textarea.f:focus { border-color:var(--accent); }\n textarea.f { min-height:110px; resize:vertical; }\n .try-field { margin-bottom:10px; }\n .try-label { font-size:12px; color:var(--muted); margin-bottom:4px; display:block; }\n .btn { background:var(--accent); color:#fff; border:none; border-radius:8px; padding:9px 18px;\n font-size:13px; font-weight:600; cursor:pointer; }\n .btn:hover { filter:brightness(1.08); }\n .btn:disabled { opacity:.5; cursor:not-allowed; }\n pre.out { background:#0a0c12; border:1px solid var(--border); border-radius:8px; padding:12px;\n font-size:12.5px; color:#d5d8e3; overflow:auto; max-height:360px; max-width:540px; white-space:pre-wrap;\n word-break:break-word; margin-top:10px; }\n .out-status { font-family:ui-monospace,Menlo,monospace; font-size:12px; margin-top:10px; }\n ::-webkit-scrollbar { width:9px; height:9px; }\n ::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.12); border-radius:6px; }\n</style>\n</head>\n<body>\n<div class=\"topbar\">\n <h1 id=\"apiTitle\">API</h1>\n <span class=\"pill-ver\" id=\"apiVersion\">v1.0.0</span>\n</div>\n<div class=\"layout\">\n <aside class=\"sidebar\">\n <input class=\"search\" id=\"search\" placeholder=\"Filter endpoints...\" />\n <div id=\"nav\"></div>\n </aside>\n <main class=\"main\" id=\"main\"><div class=\"main-empty\">Select an endpoint to view its details.</div></main>\n</div>\n<script>\n(function(){\n var SPEC_URL='/docs/openapi.json';\n var COLORS={get:'#0079ff',post:'#22c55e',put:'#f59e0b',patch:'#a855f7',delete:'#ef4444',head:'#64748b',options:'#64748b'};\n var uid=0; var ITEMS=[];\n function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\"/g,'"');}\n function typeName(sc){if(!sc)return'';if(sc.format==='binary')return'file';if(sc.$ref)return sc.$ref.split('/').pop();return sc.type||'string';}\n function paramsOf(op,where){return (op.parameters||[]).filter(function(p){return p.in===where;});}\n function bodyOf(op){if(!op.requestBody)return null;var c=op.requestBody.content||{};var mime=Object.keys(c)[0];if(!mime)return null;return {mime:mime,schema:(c[mime]||{}).schema||{}};}\n function sampleVal(sc){var t=typeName(sc);if(t==='integer'||t==='number')return 0;if(t==='boolean')return false;return'';}\n function bodySkeleton(schema){var o={};var p=(schema&&schema.properties)||{};Object.keys(p).forEach(function(k){o[k]=sampleVal(p[k]);});return JSON.stringify(o,null,2);}\n function groupKey(p){return (p.split('/').filter(Boolean)[0]||'general').replace(/[{}]/g,'');}\n function collect(spec){var out=[];var paths=spec.paths||{};Object.keys(paths).forEach(function(p){var item=paths[p];Object.keys(item).forEach(function(m){out.push({path:p,method:m,op:item[m]});});});return out;}\n\n function detailsHtml(e,id){\n var html='';\n var pp=paramsOf(e.op,'path'),qp=paramsOf(e.op,'query'),body=bodyOf(e.op);\n if(pp.length||qp.length){\n html+='<div class=\"sec\">Parameters</div>';\n pp.concat(qp).forEach(function(p){\n html+='<div class=\"row\"><span class=\"fname\">'+esc(p.name)+'</span><span class=\"ftype\">'+esc(typeName(p.schema)||'string')+' · '+esc(p.in)+'</span>'+(p.required?'<span class=\"req\">required</span>':'<span class=\"opt\">optional</span>')+'</div>';\n });\n }\n if(body){\n html+='<div class=\"sec\">Request body <span class=\"opt\">('+esc(body.mime)+')</span></div>';\n var props=(body.schema&&body.schema.properties)||{};var reqd=(body.schema&&body.schema.required)||[];\n Object.keys(props).forEach(function(k){\n html+='<div class=\"row\"><span class=\"fname\">'+esc(k)+'</span><span class=\"ftype\">'+esc(typeName(props[k]))+'</span>'+(reqd.indexOf(k)>=0?'<span class=\"req\">required</span>':'<span class=\"opt\">optional</span>')+'</div>';\n });\n }\n html+='<div class=\"sec\">Responses</div>';\n var resp=e.op.responses||{};\n Object.keys(resp).forEach(function(code){\n html+='<div class=\"row\"><span class=\"resp-code\">'+esc(code)+'</span><span class=\"detail-sum\" style=\"margin:0\">'+esc((resp[code]||{}).description||'')+'</span></div>';\n });\n html+='<div class=\"sec\">Try it</div>';\n pp.forEach(function(p){html+='<div class=\"try-field\"><label class=\"try-label\">'+esc(p.name)+' (path)</label><input class=\"f\" data-kind=\"path\" data-name=\"'+esc(p.name)+'\" /></div>';});\n qp.forEach(function(p){html+='<div class=\"try-field\"><label class=\"try-label\">'+esc(p.name)+' (query)</label><input class=\"f\" data-kind=\"query\" data-name=\"'+esc(p.name)+'\" /></div>';});\n if(e.op.security){html+='<div class=\"try-field\"><label class=\"try-label\">Authorization (Bearer token)</label><input class=\"f\" id=\"'+id+'_auth\" placeholder=\"token\" /></div>';}\n if(body){html+='<div class=\"try-field\"><label class=\"try-label\">Body (JSON)</label><textarea class=\"f\" id=\"'+id+'_body\">'+esc(bodySkeleton(body.schema))+'</textarea></div>';}\n html+='<button class=\"btn\" id=\"'+id+'_send\">Send request</button>';\n html+='<div class=\"out-status\" id=\"'+id+'_status\"></div>';\n html+='<pre class=\"out\" id=\"'+id+'_out\" style=\"display:none\"></pre>';\n return html;\n }\n\n function wireTry(e,id,root){\n var btn=root.querySelector('#'+id+'_send');if(!btn)return;\n // Auto-grow the body textarea to fit its content (capped, then scrolls).\n var bodyTa=root.querySelector('#'+id+'_body');\n if(bodyTa){\n var autosize=function(){ bodyTa.style.height='auto'; bodyTa.style.height=Math.min(bodyTa.scrollHeight+2,600)+'px'; };\n bodyTa.addEventListener('input',autosize);\n autosize();\n }\n btn.addEventListener('click',function(){\n var url=e.path;\n root.querySelectorAll('[data-kind=path]').forEach(function(inp){url=url.replace('{'+inp.getAttribute('data-name')+'}',encodeURIComponent(inp.value||''));});\n var qs=[];\n root.querySelectorAll('[data-kind=query]').forEach(function(inp){if(inp.value)qs.push(encodeURIComponent(inp.getAttribute('data-name'))+'='+encodeURIComponent(inp.value));});\n if(qs.length)url+=(url.indexOf('?')>=0?'&':'?')+qs.join('&');\n var opts={method:e.method.toUpperCase(),headers:{}};\n var auth=root.querySelector('#'+id+'_auth');if(auth&&auth.value)opts.headers['Authorization']='Bearer '+auth.value;\n var bodyEl=root.querySelector('#'+id+'_body');\n if(bodyEl&&['post','put','patch'].indexOf(e.method)>=0){opts.headers['Content-Type']='application/json';opts.body=bodyEl.value;}\n var statusEl=root.querySelector('#'+id+'_status');var outEl=root.querySelector('#'+id+'_out');\n btn.disabled=true;statusEl.textContent='Sending...';var t0=Date.now();\n fetch(url,opts).then(function(r){var ct=r.headers.get('content-type')||'';return r.text().then(function(txt){return {r:r,txt:txt,ct:ct};});}).then(function(d){\n var color=d.r.ok?'#22c55e':'#ef4444';\n statusEl.innerHTML='<span style=\"color:'+color+'\">'+d.r.status+' '+esc(d.r.statusText)+'</span> · '+(Date.now()-t0)+'ms';\n var out=d.txt;if(d.ct.indexOf('application/json')>=0){try{out=JSON.stringify(JSON.parse(d.txt),null,2);}catch(err){}}\n outEl.style.display='block';outEl.textContent=out;\n }).catch(function(err){statusEl.innerHTML='<span style=\"color:#ef4444\">Request failed</span>';outEl.style.display='block';outEl.textContent=String(err);}).then(function(){btn.disabled=false;});\n });\n }\n\n function showDetail(e,navEl){\n document.querySelectorAll('.nav-item').forEach(function(n){n.classList.remove('active');});\n if(navEl)navEl.classList.add('active');\n var id='ep'+(uid++);\n var main=document.getElementById('main');\n var color=COLORS[e.method]||'#64748b';\n var lock=e.op.security?'<span class=\"lock\">🔒 requires auth</span>':'';\n var html='<div class=\"detail-head\"><span class=\"method-lg\" style=\"background:'+color+'\">'+esc(e.method)+'</span><span class=\"path-lg\">'+esc(e.path)+'</span></div>';\n html+='<div class=\"detail-sum\">'+esc(e.op.summary||'')+lock+'</div>';\n html+=detailsHtml(e,id);\n main.innerHTML=html;main.scrollTop=0;\n wireTry(e,id,main);\n }\n\n function render(spec){\n document.getElementById('apiTitle').textContent=(spec.info&&spec.info.title)||'API';\n document.getElementById('apiVersion').textContent='v'+((spec.info&&spec.info.version)||'1.0.0');\n ITEMS=collect(spec);\n var nav=document.getElementById('nav');nav.innerHTML='';\n if(!ITEMS.length){\n nav.innerHTML='<div class=\"group-title\">No endpoints</div>';\n document.getElementById('main').innerHTML='<div class=\"main-empty\">No endpoints in the spec yet. Add routes, then regenerate the docs from the dashboard (API Docs).</div>';\n return;\n }\n var groups={};ITEMS.forEach(function(e){var k=groupKey(e.path);(groups[k]=groups[k]||[]).push(e);});\n var first=null;\n Object.keys(groups).sort().forEach(function(k){\n var gt=document.createElement('div');gt.className='group-title';gt.textContent=k;nav.appendChild(gt);\n groups[k].forEach(function(e){\n var color=COLORS[e.method]||'#64748b';\n var item=document.createElement('div');item.className='nav-item';\n item.setAttribute('data-search',(e.method+' '+e.path+' '+(e.op.summary||'')).toLowerCase());\n item.innerHTML='<span class=\"nav-method\" style=\"background:'+color+'\">'+esc(e.method)+'</span><span class=\"nav-path\">'+esc(e.path)+'</span>';\n item.addEventListener('click',function(){showDetail(e,item);});\n nav.appendChild(item);\n if(!first)first={e:e,item:item};\n });\n });\n if(first)showDetail(first.e,first.item);\n }\n\n document.getElementById('search').addEventListener('input',function(ev){\n var q=ev.target.value.toLowerCase();\n document.querySelectorAll('.nav-item').forEach(function(c){c.style.display=c.getAttribute('data-search').indexOf(q)>=0?'':'none';});\n });\n\n // The server inlines the spec below (so a protected page needs no second\n // authenticated request — avoids cross-browser cookie quirks on fetch). If it\n // isn't inlined (e.g. the page is served statically), fall back to fetching.\n var SPEC_DATA=/*__SPEC__*/null;\n if(SPEC_DATA&&typeof SPEC_DATA==='object'){\n render(SPEC_DATA);\n } else {\n fetch(SPEC_URL,{credentials:'same-origin'}).then(function(r){\n if(!r.ok){throw new Error('the server returned HTTP '+r.status+'. Regenerate the docs from the dashboard (API Docs).');}\n return r.json();\n }).then(render).catch(function(err){\n document.getElementById('main').innerHTML='<div class=\"main-empty\">Could not load the API spec — '+esc(String((err&&err.message)||err))+'</div>';\n });\n }\n})();\n</script>\n</body>\n</html>";
|
|
64
|
+
const DOCS_PAGE = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<title>API Documentation</title>\n<style>\n * { box-sizing: border-box; }\n :root {\n --bg:#0f1117; --surface:#11141e; --card:#1a1d2b; --card-hover:#21253a; --input:#13161f;\n --border:rgba(255,255,255,0.08); --text:#eceef4; --muted:#8b8fa3; --faint:#626880;\n --accent:#0079ff; --accent-light:#3daefd; --accent-glow:rgba(0,121,255,0.14);\n }\n * { scrollbar-width: thin; }\n html, body { margin:0; padding:0; height:100%; }\n body { background:var(--bg); color:var(--text); line-height:1.5; height:100vh;\n display:flex; flex-direction:column; overflow:hidden;\n font-family:\"Noto Sans\",-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,sans-serif; }\n .topbar { flex:none; display:flex; align-items:center; gap:14px;\n background:var(--surface); border-bottom:1px solid var(--border); padding:14px 24px; }\n .topbar h1 { font-size:17px; font-weight:700; margin:0;\n background:linear-gradient(135deg,#3daefd,#0079ff); -webkit-background-clip:text;\n background-clip:text; -webkit-text-fill-color:transparent; }\n .pill-ver { font-size:12px; color:var(--accent-light); background:rgba(0,121,255,0.12);\n border:1px solid rgba(0,121,255,0.25); padding:3px 9px; border-radius:999px; }\n .layout { flex:1; display:flex; min-height:0; }\n .sidebar { width:330px; flex:none; border-right:1px solid var(--border);\n overflow-y:auto; padding:14px 12px; }\n .search { width:100%; background:var(--input); border:1px solid var(--border);\n color:var(--text); border-radius:9px; padding:8px 12px; font-size:13px; outline:none; margin-bottom:12px; }\n .search:focus { border-color:var(--accent); }\n .group-title { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0.6px;\n color:var(--faint); margin:14px 6px 6px; }\n .nav-item { display:flex; align-items:center; gap:9px; padding:8px 10px; border-radius:8px;\n cursor:pointer; border:1px solid transparent; }\n .nav-item:hover { background:var(--card-hover); }\n .nav-item.active { background:var(--accent-glow); border-color:rgba(0,121,255,0.35); }\n .nav-method { font-size:9.5px; font-weight:700; letter-spacing:0.4px; text-transform:uppercase;\n color:#fff; padding:3px 0; border-radius:5px; width:46px; flex:none; text-align:center; }\n .nav-path { font-family:ui-monospace,Menlo,monospace; font-size:12.5px; color:var(--text);\n overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }\n .main { flex:1; overflow-y:auto; padding:30px 40px; }\n .main-empty { color:var(--faint); display:flex; height:100%; align-items:center; justify-content:center; }\n .detail-head { display:flex; align-items:center; gap:14px; margin-bottom:6px; }\n .method-lg { font-size:13px; font-weight:700; letter-spacing:0.5px; text-transform:uppercase;\n color:#fff; padding:6px 12px; border-radius:8px; }\n .path-lg { font-family:ui-monospace,Menlo,monospace; font-size:18px; color:var(--text); word-break:break-all; }\n .detail-sum { color:var(--muted); margin:0 0 8px; }\n .lock { color:var(--faint); font-size:12px; margin-left:8px; white-space:nowrap; }\n .sec { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0.5px;\n color:var(--faint); margin:22px 0 8px; }\n .row { display:flex; gap:10px; align-items:center; padding:8px 0;\n border-bottom:1px solid var(--border); font-size:13px; }\n .row:last-child { border-bottom:none; }\n .fname { font-family:ui-monospace,Menlo,monospace; color:var(--text); min-width:150px; }\n .ftype { color:var(--accent-light); font-size:12px; }\n .req { color:#ef4444; font-size:11px; font-weight:700; }\n .opt { color:var(--faint); font-size:11px; }\n .resp-code { font-family:ui-monospace,Menlo,monospace; color:#22c55e; font-weight:700; }\n .tabs { display:flex; gap:4px; flex-wrap:wrap; }\n .tab { background:var(--input); color:var(--muted); border:1px solid var(--border); border-bottom:none;\n border-radius:8px 8px 0 0; padding:6px 13px; font-size:12px; cursor:pointer; }\n .tab:hover { color:var(--text); }\n .tab.active { background:#0a0c12; color:var(--accent-light); }\n .code-wrap { position:relative; max-width:560px; }\n pre.code { background:#0a0c12; border:1px solid var(--border); border-radius:0 8px 8px 8px; padding:14px;\n font-size:12.5px; color:#d5d8e3; overflow:auto; white-space:pre; margin:0;\n font-family:ui-monospace,Menlo,monospace; }\n pre.code.hidden { display:none; }\n .copy-btn { position:absolute; top:8px; right:8px; background:var(--card); color:var(--muted);\n border:1px solid var(--border); border-radius:6px; padding:4px 10px; font-size:11px; cursor:pointer; z-index:2; }\n .copy-btn:hover { color:var(--text); }\n ::-webkit-scrollbar { width:9px; height:9px; }\n ::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.12); border-radius:6px; }\n</style>\n</head>\n<body>\n<div class=\"topbar\">\n <h1 id=\"apiTitle\">API</h1>\n <span class=\"pill-ver\" id=\"apiVersion\">v1.0.0</span>\n</div>\n<div class=\"layout\">\n <aside class=\"sidebar\">\n <input class=\"search\" id=\"search\" placeholder=\"Filter endpoints...\" />\n <div id=\"nav\"></div>\n </aside>\n <main class=\"main\" id=\"main\"><div class=\"main-empty\">Select an endpoint to view its details.</div></main>\n</div>\n<script>\n(function(){\n var SPEC_URL='/docs/openapi.json';\n var COLORS={get:'#0079ff',post:'#22c55e',put:'#f59e0b',patch:'#a855f7',delete:'#ef4444',head:'#64748b',options:'#64748b'};\n var uid=0; var ITEMS=[];\n function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\"/g,'"');}\n function typeName(sc){if(!sc)return'';if(sc.format==='binary')return'file';if(sc.$ref)return sc.$ref.split('/').pop();return sc.type||'string';}\n function paramsOf(op,where){return (op.parameters||[]).filter(function(p){return p.in===where;});}\n function bodyOf(op){if(!op.requestBody)return null;var c=op.requestBody.content||{};var mime=Object.keys(c)[0];if(!mime)return null;return {mime:mime,schema:(c[mime]||{}).schema||{}};}\n function sampleVal(sc){var t=typeName(sc);if(t==='integer'||t==='number')return 0;if(t==='boolean')return false;return'';}\n function bodySkeleton(schema){var o={};var p=(schema&&schema.properties)||{};Object.keys(p).forEach(function(k){o[k]=sampleVal(p[k]);});return JSON.stringify(o,null,2);}\n function groupKey(p){return (p.split('/').filter(Boolean)[0]||'general').replace(/[{}]/g,'');}\n function collect(spec){var out=[];var paths=spec.paths||{};Object.keys(paths).forEach(function(p){var item=paths[p];Object.keys(item).forEach(function(m){out.push({path:p,method:m,op:item[m]});});});return out;}\n\n // ── Client code samples (generated from the spec — no AI, no network) ──\n function sampleUrl(e){return (location.origin||'')+e.path;}\n function bodyForSample(e){var b=bodyOf(e.op);if(b&&['post','put','patch'].indexOf(e.method)>=0)return b;return null;}\n function indentBody(str,extra){var NL=String.fromCharCode(10);return str.split(NL).map(function(ln,i){return i===0?ln:extra+ln;}).join(NL);}\n function isMultipart(b){return !!(b&&b.mime==='multipart/form-data');}\n // Body fields with a flag for binary (file) fields, used to build multipart samples.\n function fieldList(b){var p=(b&&b.schema&&b.schema.properties)||{};return Object.keys(p).map(function(k){return {name:k,file:!!(p[k]&&p[k].format==='binary')};});}\n // FormData lines for a multipart body: files become a File object, others a string.\n function formLines(b){\n var out=[\"const form = new FormData();\"];\n fieldList(b).forEach(function(f){\n out.push(\"form.append('\"+f.name+\"', \"+(f.file?\"fileInput.files[0]\":\"''\")+\");\"+(f.file?\" // a File from an <input type=file>\":\"\"));\n });\n return out;\n }\n function curlSample(e){\n var NL=String.fromCharCode(10),BS=String.fromCharCode(92);var b=bodyForSample(e);var L=[];\n L.push(\"curl -X \"+e.method.toUpperCase()+\" '\"+sampleUrl(e)+\"'\");\n if(e.op.security)L.push(\"-H 'Authorization: Bearer <token>'\");\n if(b&&isMultipart(b)){\n // curl sets the multipart Content-Type (with boundary) itself; -F per field.\n fieldList(b).forEach(function(f){L.push(\"-F '\"+f.name+\"=\"+(f.file?\"@/path/to/file\":\"\")+\"'\");});\n } else if(b){\n L.push(\"-H 'Content-Type: application/json'\");\n var c;try{c=JSON.stringify(JSON.parse(bodySkeleton(b.schema)));}catch(x){c=bodySkeleton(b.schema);}\n L.push(\"-d '\"+c+\"'\");\n }\n return L.join(\" \"+BS+NL+\" \");\n }\n function fetchSample(e){\n var NL=String.fromCharCode(10);var b=bodyForSample(e);var mp=b&&isMultipart(b);var L=[];\n if(mp)formLines(b).forEach(function(ln){L.push(ln);});\n L.push(\"const res = await fetch('\"+sampleUrl(e)+\"', {\");\n L.push(\" method: '\"+e.method.toUpperCase()+\"',\");\n var h=[];\n // For multipart, never set Content-Type by hand — the browser adds the boundary.\n if(b&&!mp)h.push(\" 'Content-Type': 'application/json'\");\n if(e.op.security)h.push(\" 'Authorization': 'Bearer <token>'\");\n if(h.length){L.push(\" headers: {\");L.push(h.join(\",\"+NL));L.push(\" },\");}\n if(mp)L.push(\" body: form\");\n else if(b)L.push(\" body: JSON.stringify(\"+indentBody(bodySkeleton(b.schema),\" \")+\")\");\n L.push(\"});\");\n L.push(\"const data = await res.json();\");\n L.push(\"console.log(data);\");\n return L.join(NL);\n }\n function axiosSample(e){\n var NL=String.fromCharCode(10);var b=bodyForSample(e);var mp=b&&isMultipart(b);var L=[];\n if(mp)formLines(b).forEach(function(ln){L.push(ln);});\n L.push(\"const res = await axios({\");\n L.push(\" method: '\"+e.method.toLowerCase()+\"',\");\n L.push(\" url: '\"+sampleUrl(e)+\"',\");\n if(e.op.security)L.push(\" headers: { 'Authorization': 'Bearer <token>' },\");\n if(mp)L.push(\" data: form\");\n else if(b)L.push(\" data: \"+indentBody(bodySkeleton(b.schema),\" \"));\n L.push(\"});\");\n L.push(\"console.log(res.data);\");\n return L.join(NL);\n }\n function jquerySample(e){\n var NL=String.fromCharCode(10);var b=bodyForSample(e);var mp=b&&isMultipart(b);var L=[];\n if(mp)formLines(b).forEach(function(ln){L.push(ln);});\n L.push(\"$.ajax({\");\n L.push(\" url: '\"+sampleUrl(e)+\"',\");\n L.push(\" method: '\"+e.method.toUpperCase()+\"',\");\n if(mp){L.push(\" data: form,\");L.push(\" processData: false,\");L.push(\" contentType: false,\");}\n else if(b)L.push(\" contentType: 'application/json',\");\n if(e.op.security)L.push(\" headers: { 'Authorization': 'Bearer <token>' },\");\n if(b&&!mp)L.push(\" data: JSON.stringify(\"+indentBody(bodySkeleton(b.schema),\" \")+\"),\");\n L.push(\" success: function(data) {\");\n L.push(\" console.log(data);\");\n L.push(\" }\");\n L.push(\"});\");\n return L.join(NL);\n }\n function buildSamples(e){\n return [\n {lang:'curl',label:'cURL',code:curlSample(e)},\n {lang:'fetch',label:'JavaScript (fetch)',code:fetchSample(e)},\n {lang:'axios',label:'axios',code:axiosSample(e)},\n {lang:'jquery',label:'jQuery',code:jquerySample(e)}\n ];\n }\n\n function detailsHtml(e,id){\n var html='';\n var pp=paramsOf(e.op,'path'),qp=paramsOf(e.op,'query'),body=bodyOf(e.op);\n if(pp.length||qp.length){\n html+='<div class=\"sec\">Parameters</div>';\n pp.concat(qp).forEach(function(p){\n html+='<div class=\"row\"><span class=\"fname\">'+esc(p.name)+'</span><span class=\"ftype\">'+esc(typeName(p.schema)||'string')+' · '+esc(p.in)+'</span>'+(p.required?'<span class=\"req\">required</span>':'<span class=\"opt\">optional</span>')+'</div>';\n });\n }\n if(body){\n html+='<div class=\"sec\">Request body <span class=\"opt\">('+esc(body.mime)+')</span></div>';\n var props=(body.schema&&body.schema.properties)||{};var reqd=(body.schema&&body.schema.required)||[];\n Object.keys(props).forEach(function(k){\n html+='<div class=\"row\"><span class=\"fname\">'+esc(k)+'</span><span class=\"ftype\">'+esc(typeName(props[k]))+'</span>'+(reqd.indexOf(k)>=0?'<span class=\"req\">required</span>':'<span class=\"opt\">optional</span>')+'</div>';\n });\n }\n html+='<div class=\"sec\">Responses</div>';\n var resp=e.op.responses||{};\n Object.keys(resp).forEach(function(code){\n html+='<div class=\"row\"><span class=\"resp-code\">'+esc(code)+'</span><span class=\"detail-sum\" style=\"margin:0\">'+esc((resp[code]||{}).description||'')+'</span></div>';\n });\n var samples=buildSamples(e);\n html+='<div class=\"sec\">Code samples</div>';\n html+='<div class=\"tabs\" id=\"'+id+'_tabs\">';\n samples.forEach(function(s,i){html+='<button class=\"tab'+(i===0?' active':'')+'\" data-lang=\"'+s.lang+'\">'+esc(s.label)+'</button>';});\n html+='</div>';\n html+='<div class=\"code-wrap\"><button class=\"copy-btn\">Copy</button>';\n samples.forEach(function(s,i){html+='<pre class=\"code'+(i===0?'':' hidden')+'\" data-lang=\"'+s.lang+'\">'+esc(s.code)+'</pre>';});\n html+='</div>';\n return html;\n }\n\n function wireSamples(id,root){\n var tabs=root.querySelector('#'+id+'_tabs');if(!tabs)return;\n var btns=tabs.querySelectorAll('.tab');\n btns.forEach(function(b){\n b.addEventListener('click',function(){\n var lang=b.getAttribute('data-lang');\n btns.forEach(function(x){x.classList.remove('active');});b.classList.add('active');\n root.querySelectorAll('pre.code[data-lang]').forEach(function(p){p.classList.toggle('hidden',p.getAttribute('data-lang')!==lang);});\n });\n });\n var copy=root.querySelector('.copy-btn');\n if(copy)copy.addEventListener('click',function(){\n var active=root.querySelector('pre.code[data-lang]:not(.hidden)');if(!active)return;\n var txt=active.textContent;\n if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(txt);}\n else{var ta=document.createElement('textarea');ta.value=txt;document.body.appendChild(ta);ta.select();try{document.execCommand('copy');}catch(x){}document.body.removeChild(ta);}\n copy.textContent='Copied';setTimeout(function(){copy.textContent='Copy';},1200);\n });\n }\n\n function showDetail(e,navEl){\n document.querySelectorAll('.nav-item').forEach(function(n){n.classList.remove('active');});\n if(navEl)navEl.classList.add('active');\n var id='ep'+(uid++);\n var main=document.getElementById('main');\n var color=COLORS[e.method]||'#64748b';\n var lock=e.op.security?'<span class=\"lock\">🔒 requires auth</span>':'';\n var html='<div class=\"detail-head\"><span class=\"method-lg\" style=\"background:'+color+'\">'+esc(e.method)+'</span><span class=\"path-lg\">'+esc(e.path)+'</span></div>';\n html+='<div class=\"detail-sum\">'+esc(e.op.summary||'')+lock+'</div>';\n html+=detailsHtml(e,id);\n main.innerHTML=html;main.scrollTop=0;\n wireSamples(id,main);\n }\n\n function render(spec){\n document.getElementById('apiTitle').textContent=(spec.info&&spec.info.title)||'API';\n document.getElementById('apiVersion').textContent='v'+((spec.info&&spec.info.version)||'1.0.0');\n ITEMS=collect(spec);\n var nav=document.getElementById('nav');nav.innerHTML='';\n if(!ITEMS.length){\n nav.innerHTML='<div class=\"group-title\">No endpoints</div>';\n document.getElementById('main').innerHTML='<div class=\"main-empty\">No endpoints in the spec yet. Add routes, then regenerate the docs from the dashboard (API Docs).</div>';\n return;\n }\n var groups={};ITEMS.forEach(function(e){var k=groupKey(e.path);(groups[k]=groups[k]||[]).push(e);});\n var first=null;\n Object.keys(groups).sort().forEach(function(k){\n var gt=document.createElement('div');gt.className='group-title';gt.textContent=k;nav.appendChild(gt);\n groups[k].forEach(function(e){\n var color=COLORS[e.method]||'#64748b';\n var item=document.createElement('div');item.className='nav-item';\n item.setAttribute('data-search',(e.method+' '+e.path+' '+(e.op.summary||'')).toLowerCase());\n item.innerHTML='<span class=\"nav-method\" style=\"background:'+color+'\">'+esc(e.method)+'</span><span class=\"nav-path\">'+esc(e.path)+'</span>';\n item.addEventListener('click',function(){showDetail(e,item);});\n nav.appendChild(item);\n if(!first)first={e:e,item:item};\n });\n });\n if(first)showDetail(first.e,first.item);\n }\n\n document.getElementById('search').addEventListener('input',function(ev){\n var q=ev.target.value.toLowerCase();\n document.querySelectorAll('.nav-item').forEach(function(c){c.style.display=c.getAttribute('data-search').indexOf(q)>=0?'':'none';});\n });\n\n // The server inlines the spec below (so a protected page needs no second\n // authenticated request — avoids cross-browser cookie quirks on fetch). If it\n // isn't inlined (e.g. the page is served statically), fall back to fetching.\n var SPEC_DATA=/*__SPEC__*/null;\n if(SPEC_DATA&&typeof SPEC_DATA==='object'){\n render(SPEC_DATA);\n } else {\n fetch(SPEC_URL,{credentials:'same-origin'}).then(function(r){\n if(!r.ok){throw new Error('the server returned HTTP '+r.status+'. Regenerate the docs from the dashboard (API Docs).');}\n return r.json();\n }).then(render).catch(function(err){\n document.getElementById('main').innerHTML='<div class=\"main-empty\">Could not load the API spec — '+esc(String((err&&err.message)||err))+'</div>';\n });\n }\n})();\n</script>\n</body>\n</html>";
|
|
65
65
|
|
|
66
66
|
router.get('/', (req, res) => {
|
|
67
67
|
// Inline the spec into the page so a password-protected page doesn't need a
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// API key authentication. Named keys are managed by the 4bnode dashboard and
|
|
2
|
+
// stored in src/api-keys.json. Send a key in the `x-api-key` request header:
|
|
3
|
+
// import apiKey from './src/middleware/apiKey.js';
|
|
4
|
+
// app.use('/api/private', apiKey);
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
|
|
9
|
+
const KEYS_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'api-keys.json');
|
|
10
|
+
let _cache = { mtimeMs: -1, keys: [] };
|
|
11
|
+
|
|
12
|
+
// Read the keys file fresh when it changes (mtime-cached), so keys generated or
|
|
13
|
+
// revoked in the dashboard take effect immediately — no restart needed.
|
|
14
|
+
function loadKeys() {
|
|
15
|
+
try {
|
|
16
|
+
const { mtimeMs } = fs.statSync(KEYS_FILE);
|
|
17
|
+
if (mtimeMs !== _cache.mtimeMs) {
|
|
18
|
+
const list = JSON.parse(fs.readFileSync(KEYS_FILE, 'utf8'));
|
|
19
|
+
_cache = { mtimeMs, keys: (Array.isArray(list) ? list : []).map((k) => k && k.key).filter(Boolean) };
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
_cache = { mtimeMs: -1, keys: [] };
|
|
23
|
+
}
|
|
24
|
+
return _cache.keys;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const apiKey = (req, res, next) => {
|
|
28
|
+
const provided = req.header('x-api-key');
|
|
29
|
+
const valid = loadKeys();
|
|
30
|
+
if (!provided || !valid.includes(provided)) {
|
|
31
|
+
return res.status(401).json({ message: 'Invalid or missing API key' });
|
|
32
|
+
}
|
|
33
|
+
next();
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default apiKey;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
|
|
3
|
+
const auth = (req, res, next) => {
|
|
4
|
+
const token = req.header('Authorization')?.replace('Bearer ', '');
|
|
5
|
+
if (!token) {
|
|
6
|
+
return res.status(401).json({ message: 'No token, authorization denied' });
|
|
7
|
+
}
|
|
8
|
+
try {
|
|
9
|
+
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
10
|
+
req.user = decoded;
|
|
11
|
+
next();
|
|
12
|
+
} catch (err) {
|
|
13
|
+
res.status(401).json({ message: 'Token is not valid' });
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default auth;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import mongoose from 'mongoose';
|
|
2
|
+
|
|
3
|
+
const UserSchema = new mongoose.Schema({
|
|
4
|
+
first_name: { type: String, required: true },
|
|
5
|
+
last_name: { type: String, required: true },
|
|
6
|
+
resume: { type: String, required: true }
|
|
7
|
+
}, { timestamps: true });
|
|
8
|
+
|
|
9
|
+
const User = mongoose.model('User', UserSchema);
|
|
10
|
+
|
|
11
|
+
export default User;
|
package/app-1/src/openapi.json
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"paths": {
|
|
13
|
-
"/api/
|
|
13
|
+
"/api/upload-resume": {
|
|
14
14
|
"post": {
|
|
15
|
-
"summary": "POST /api/
|
|
15
|
+
"summary": "POST /api/upload-resume",
|
|
16
16
|
"responses": {
|
|
17
17
|
"200": {
|
|
18
18
|
"description": "Success"
|
|
@@ -20,14 +20,26 @@
|
|
|
20
20
|
},
|
|
21
21
|
"requestBody": {
|
|
22
22
|
"content": {
|
|
23
|
-
"
|
|
23
|
+
"multipart/form-data": {
|
|
24
24
|
"schema": {
|
|
25
25
|
"type": "object",
|
|
26
26
|
"properties": {
|
|
27
27
|
"first_name": {
|
|
28
28
|
"type": "string"
|
|
29
|
+
},
|
|
30
|
+
"userId": {
|
|
31
|
+
"type": "string"
|
|
32
|
+
},
|
|
33
|
+
"file": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"format": "binary"
|
|
29
36
|
}
|
|
30
|
-
}
|
|
37
|
+
},
|
|
38
|
+
"required": [
|
|
39
|
+
"first_name",
|
|
40
|
+
"userId",
|
|
41
|
+
"file"
|
|
42
|
+
]
|
|
31
43
|
}
|
|
32
44
|
}
|
|
33
45
|
}
|
|
@@ -49,11 +61,17 @@
|
|
|
49
61
|
}
|
|
50
62
|
},
|
|
51
63
|
"schemas": {
|
|
52
|
-
"
|
|
64
|
+
"User": {
|
|
53
65
|
"type": "object",
|
|
54
66
|
"properties": {
|
|
55
67
|
"first_name": {
|
|
56
68
|
"type": "string"
|
|
69
|
+
},
|
|
70
|
+
"last_name": {
|
|
71
|
+
"type": "string"
|
|
72
|
+
},
|
|
73
|
+
"resume": {
|
|
74
|
+
"type": "string"
|
|
57
75
|
}
|
|
58
76
|
}
|
|
59
77
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import auth from '../middleware/auth.js';
|
|
2
|
+
import { uploadResumeSchema } from '../validators/upload-resume.js';
|
|
3
|
+
import validate from '../middleware/validate.js';
|
|
4
|
+
import express from 'express';
|
|
5
|
+
import User from '../models/user.js';
|
|
6
|
+
import multer from 'multer';
|
|
7
|
+
|
|
8
|
+
const router = express.Router();
|
|
9
|
+
|
|
10
|
+
const upload = multer({ dest: 'uploads/' });
|
|
11
|
+
|
|
12
|
+
router.post('/', auth, upload.single('file'), validate(uploadResumeSchema), async (req, res) => {
|
|
13
|
+
const { first_name, userId } = req.body;
|
|
14
|
+
const file = req.file;
|
|
15
|
+
|
|
16
|
+
// TODO: Add your logic here
|
|
17
|
+
|
|
18
|
+
res.json({ msg: 'upload-resume POST endpoint works' });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export default router;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer';
|
|
2
|
+
|
|
3
|
+
// Transport is configured from SMTP_* env vars (see .env). Works with Brevo,
|
|
4
|
+
// SendGrid, Mailgun, Gmail, Mailtrap, or any SMTP server.
|
|
5
|
+
const transporter = nodemailer.createTransport({
|
|
6
|
+
host: process.env.SMTP_HOST,
|
|
7
|
+
port: Number(process.env.SMTP_PORT) || 587,
|
|
8
|
+
secure: process.env.SMTP_SECURE === 'true', // true for port 465
|
|
9
|
+
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// Wrap content in a simple, responsive HTML email.
|
|
13
|
+
export function emailTemplate({ title = '', body = '' } = {}) {
|
|
14
|
+
return `<!doctype html>
|
|
15
|
+
<html>
|
|
16
|
+
<body style="margin:0;background:#f4f5f7;font-family:Arial,Helvetica,sans-serif;">
|
|
17
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="padding:24px 0;">
|
|
18
|
+
<tr><td align="center">
|
|
19
|
+
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:10px;overflow:hidden;">
|
|
20
|
+
<tr><td style="background:#0079ff;padding:18px 28px;color:#fff;font-size:18px;font-weight:bold;">${title}</td></tr>
|
|
21
|
+
<tr><td style="padding:28px;color:#333;font-size:15px;line-height:1.6;">${body}</td></tr>
|
|
22
|
+
<tr><td style="padding:16px 28px;color:#9aa0ab;font-size:12px;border-top:1px solid #eee;">Sent by your app</td></tr>
|
|
23
|
+
</table>
|
|
24
|
+
</td></tr>
|
|
25
|
+
</table>
|
|
26
|
+
</body>
|
|
27
|
+
</html>`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Send an email. Pass html or text (or use emailTemplate for html).
|
|
31
|
+
// attachments: [{ filename, path }] or [{ filename, content }] (nodemailer format).
|
|
32
|
+
export async function sendMail({ to, subject, html, text, from, cc, bcc, replyTo, attachments } = {}) {
|
|
33
|
+
if (!to || !subject) throw new Error('sendMail requires { to, subject }');
|
|
34
|
+
return transporter.sendMail({
|
|
35
|
+
from: from || process.env.SMTP_FROM || process.env.SMTP_USER,
|
|
36
|
+
to,
|
|
37
|
+
cc,
|
|
38
|
+
bcc,
|
|
39
|
+
replyTo,
|
|
40
|
+
subject,
|
|
41
|
+
text,
|
|
42
|
+
html,
|
|
43
|
+
attachments,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Verify the SMTP connection/credentials.
|
|
48
|
+
export function verifyMailer() {
|
|
49
|
+
return transporter.verify();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default { sendMail, emailTemplate, verifyMailer, transporter };
|