feat: email change UI, Swagger UI improvements, key recovery link on landing page
- Email change modal: API key + new email → verification code → confirmed - Swagger UI with proper OpenAPI spec (public/openapi.json + swagger-ui assets) - Key recovery link prominently on landing page hero section - Footer link for email change - Updated docs.html to use swagger-ui bundle
This commit is contained in:
parent
efa39661cf
commit
d859e9fa60
9 changed files with 741 additions and 383 deletions
0
@
Normal file
0
@
Normal file
|
|
@ -18,8 +18,8 @@ services:
|
||||||
- SMTP_HOST=host.docker.internal
|
- SMTP_HOST=host.docker.internal
|
||||||
- SMTP_PORT=25
|
- SMTP_PORT=25
|
||||||
- POOL_SIZE=15
|
- POOL_SIZE=15
|
||||||
- BROWSER_COUNT=2
|
- BROWSER_COUNT=1
|
||||||
- PAGES_PER_BROWSER=8
|
- PAGES_PER_BROWSER=15
|
||||||
volumes:
|
volumes:
|
||||||
- docfast-data:/app/data
|
- docfast-data:/app/data
|
||||||
mem_limit: 2560m
|
mem_limit: 2560m
|
||||||
|
|
|
||||||
17
package-lock.json
generated
17
package-lock.json
generated
|
|
@ -15,7 +15,8 @@
|
||||||
"nanoid": "^5.0.0",
|
"nanoid": "^5.0.0",
|
||||||
"nodemailer": "^8.0.1",
|
"nodemailer": "^8.0.1",
|
||||||
"puppeteer": "^24.0.0",
|
"puppeteer": "^24.0.0",
|
||||||
"stripe": "^20.3.1"
|
"stripe": "^20.3.1",
|
||||||
|
"swagger-ui-dist": "^5.31.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
|
@ -892,6 +893,12 @@
|
||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@scarf/scarf": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
|
||||||
|
"hasInstallScript": true
|
||||||
|
},
|
||||||
"node_modules/@tootallnate/quickjs-emscripten": {
|
"node_modules/@tootallnate/quickjs-emscripten": {
|
||||||
"version": "0.23.0",
|
"version": "0.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
|
||||||
|
|
@ -3414,6 +3421,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/swagger-ui-dist": {
|
||||||
|
"version": "5.31.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz",
|
||||||
|
"integrity": "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@scarf/scarf": "=1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tar-fs": {
|
"node_modules/tar-fs": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@
|
||||||
"nanoid": "^5.0.0",
|
"nanoid": "^5.0.0",
|
||||||
"nodemailer": "^8.0.1",
|
"nodemailer": "^8.0.1",
|
||||||
"puppeteer": "^24.0.0",
|
"puppeteer": "^24.0.0",
|
||||||
"stripe": "^20.3.1"
|
"stripe": "^20.3.1",
|
||||||
|
"swagger-ui-dist": "^5.31.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
|
|
||||||
140
public/app.js
140
public/app.js
|
|
@ -298,3 +298,143 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Email Change ---
|
||||||
|
var emailChangeApiKey = '';
|
||||||
|
var emailChangeNewEmail = '';
|
||||||
|
|
||||||
|
function showEmailChangeState(state) {
|
||||||
|
['emailChangeInitial', 'emailChangeLoading', 'emailChangeVerify', 'emailChangeResult'].forEach(function(id) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el) el.classList.remove('active');
|
||||||
|
});
|
||||||
|
document.getElementById(state).classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEmailChange() {
|
||||||
|
closeSignup();
|
||||||
|
closeRecover();
|
||||||
|
document.getElementById('emailChangeModal').classList.add('active');
|
||||||
|
showEmailChangeState('emailChangeInitial');
|
||||||
|
var errEl = document.getElementById('emailChangeError');
|
||||||
|
if (errEl) errEl.style.display = 'none';
|
||||||
|
var verifyErrEl = document.getElementById('emailChangeVerifyError');
|
||||||
|
if (verifyErrEl) verifyErrEl.style.display = 'none';
|
||||||
|
document.getElementById('emailChangeApiKey').value = '';
|
||||||
|
document.getElementById('emailChangeNewEmail').value = '';
|
||||||
|
document.getElementById('emailChangeCode').value = '';
|
||||||
|
emailChangeApiKey = '';
|
||||||
|
emailChangeNewEmail = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEmailChange() {
|
||||||
|
document.getElementById('emailChangeModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEmailChange() {
|
||||||
|
var errEl = document.getElementById('emailChangeError');
|
||||||
|
var btn = document.getElementById('emailChangeBtn');
|
||||||
|
var apiKey = document.getElementById('emailChangeApiKey').value.trim();
|
||||||
|
var newEmail = document.getElementById('emailChangeNewEmail').value.trim();
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
errEl.textContent = 'Please enter your API key.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!newEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
|
||||||
|
errEl.textContent = 'Please enter a valid email address.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errEl.style.display = 'none';
|
||||||
|
btn.disabled = true;
|
||||||
|
showEmailChangeState('emailChangeLoading');
|
||||||
|
|
||||||
|
try {
|
||||||
|
var res = await fetch('/v1/email-change', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ apiKey: apiKey, newEmail: newEmail })
|
||||||
|
});
|
||||||
|
var data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
showEmailChangeState('emailChangeInitial');
|
||||||
|
errEl.textContent = data.error || 'Something went wrong.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emailChangeApiKey = apiKey;
|
||||||
|
emailChangeNewEmail = newEmail;
|
||||||
|
document.getElementById('emailChangeEmailDisplay').textContent = newEmail;
|
||||||
|
showEmailChangeState('emailChangeVerify');
|
||||||
|
document.getElementById('emailChangeCode').focus();
|
||||||
|
btn.disabled = false;
|
||||||
|
} catch (err) {
|
||||||
|
showEmailChangeState('emailChangeInitial');
|
||||||
|
errEl.textContent = 'Network error. Please try again.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEmailChangeVerify() {
|
||||||
|
var errEl = document.getElementById('emailChangeVerifyError');
|
||||||
|
var btn = document.getElementById('emailChangeVerifyBtn');
|
||||||
|
var code = document.getElementById('emailChangeCode').value.trim();
|
||||||
|
|
||||||
|
if (!code || !/^\d{6}$/.test(code)) {
|
||||||
|
errEl.textContent = 'Please enter a 6-digit code.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errEl.style.display = 'none';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
var res = await fetch('/v1/email-change/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ apiKey: emailChangeApiKey, newEmail: emailChangeNewEmail, code: code })
|
||||||
|
});
|
||||||
|
var data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
errEl.textContent = data.error || 'Verification failed.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('emailChangeNewDisplay').textContent = data.newEmail || emailChangeNewEmail;
|
||||||
|
showEmailChangeState('emailChangeResult');
|
||||||
|
} catch (err) {
|
||||||
|
errEl.textContent = 'Network error. Please try again.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listeners for email change (append to DOMContentLoaded)
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var closeBtn = document.getElementById('btn-close-email-change');
|
||||||
|
if (closeBtn) closeBtn.addEventListener('click', closeEmailChange);
|
||||||
|
|
||||||
|
var changeBtn = document.getElementById('emailChangeBtn');
|
||||||
|
if (changeBtn) changeBtn.addEventListener('click', submitEmailChange);
|
||||||
|
|
||||||
|
var verifyBtn = document.getElementById('emailChangeVerifyBtn');
|
||||||
|
if (verifyBtn) verifyBtn.addEventListener('click', submitEmailChangeVerify);
|
||||||
|
|
||||||
|
var modal = document.getElementById('emailChangeModal');
|
||||||
|
if (modal) modal.addEventListener('click', function(e) { if (e.target === this) closeEmailChange(); });
|
||||||
|
|
||||||
|
document.querySelectorAll('.open-email-change').forEach(function(el) {
|
||||||
|
el.addEventListener('click', function(e) { e.preventDefault(); openEmailChange(); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
490
public/docs.html
490
public/docs.html
|
|
@ -4,393 +4,125 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>DocFast API Documentation</title>
|
<title>DocFast API Documentation</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
|
||||||
|
<link rel="stylesheet" href="/swagger-ui/swagger-ui.css">
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
html { box-sizing: border-box; overflow-y: scroll; }
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #e0e0e0; line-height: 1.6; }
|
*, *:before, *:after { box-sizing: inherit; }
|
||||||
a { color: #6c9fff; text-decoration: none; }
|
body { margin: 0; background: #1a1a2e; font-family: 'Inter', -apple-system, system-ui, sans-serif; }
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
.container { max-width: 900px; margin: 0 auto; padding: 2rem; }
|
/* Top bar */
|
||||||
|
.topbar-wrapper { display: flex; align-items: center; }
|
||||||
|
.swagger-ui .topbar { background: #0b0d11; border-bottom: 1px solid #1e2433; padding: 12px 0; }
|
||||||
|
.swagger-ui .topbar .topbar-wrapper { max-width: 1200px; margin: 0 auto; padding: 0 24px; }
|
||||||
|
.swagger-ui .topbar a { font-size: 0; }
|
||||||
|
.swagger-ui .topbar .topbar-wrapper::before {
|
||||||
|
content: '⚡ DocFast API';
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #e4e7ed;
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.swagger-ui .topbar .topbar-wrapper::after {
|
||||||
|
content: '← Back to docfast.dev';
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #34d399;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.swagger-ui .topbar .topbar-wrapper { cursor: default; }
|
||||||
|
|
||||||
header { border-bottom: 1px solid #222; padding-bottom: 1.5rem; margin-bottom: 2rem; }
|
/* Dark theme overrides */
|
||||||
header h1 { font-size: 2rem; margin-bottom: 0.5rem; }
|
.swagger-ui { color: #c8ccd4; }
|
||||||
header h1 a { color: #fff; }
|
.swagger-ui .wrapper { max-width: 1200px; padding: 0 24px; }
|
||||||
header p { color: #888; font-size: 1.1rem; }
|
.swagger-ui .scheme-container { background: #151922; border: 1px solid #1e2433; border-radius: 8px; margin: 16px 0; box-shadow: none; }
|
||||||
.base-url { background: #1a1a2e; border: 1px solid #333; border-radius: 6px; padding: 0.75rem 1rem; font-family: monospace; font-size: 0.95rem; margin-top: 1rem; color: #6c9fff; }
|
.swagger-ui .opblock-tag { color: #e4e7ed !important; border-bottom: 1px solid #1e2433; }
|
||||||
|
.swagger-ui .opblock-tag:hover { background: rgba(52,211,153,0.04); }
|
||||||
|
.swagger-ui .opblock { background: #151922; border: 1px solid #1e2433 !important; border-radius: 8px !important; margin-bottom: 12px; box-shadow: none !important; }
|
||||||
|
.swagger-ui .opblock .opblock-summary { border: none; }
|
||||||
|
.swagger-ui .opblock .opblock-summary-method { border-radius: 6px; font-size: 0.75rem; min-width: 70px; }
|
||||||
|
.swagger-ui .opblock.opblock-post .opblock-summary-method { background: #34d399; }
|
||||||
|
.swagger-ui .opblock.opblock-get .opblock-summary-method { background: #60a5fa; }
|
||||||
|
.swagger-ui .opblock.opblock-post { border-color: rgba(52,211,153,0.3) !important; background: rgba(52,211,153,0.03); }
|
||||||
|
.swagger-ui .opblock.opblock-get { border-color: rgba(96,165,250,0.3) !important; background: rgba(96,165,250,0.03); }
|
||||||
|
.swagger-ui .opblock .opblock-summary-path { color: #e4e7ed; }
|
||||||
|
.swagger-ui .opblock .opblock-summary-description { color: #7a8194; }
|
||||||
|
.swagger-ui .opblock-body { background: #0b0d11; }
|
||||||
|
.swagger-ui .opblock-description-wrapper p,
|
||||||
|
.swagger-ui .opblock-external-docs-wrapper p { color: #9ca3af; }
|
||||||
|
.swagger-ui table thead tr th { color: #7a8194; border-bottom: 1px solid #1e2433; }
|
||||||
|
.swagger-ui table tbody tr td { color: #c8ccd4; border-bottom: 1px solid #1e2433; }
|
||||||
|
.swagger-ui .parameter__name { color: #e4e7ed; }
|
||||||
|
.swagger-ui .parameter__type { color: #60a5fa; }
|
||||||
|
.swagger-ui .parameter__name.required::after { color: #f87171; }
|
||||||
|
.swagger-ui input[type=text], .swagger-ui textarea, .swagger-ui select {
|
||||||
|
background: #0b0d11; color: #e4e7ed; border: 1px solid #1e2433; border-radius: 6px;
|
||||||
|
}
|
||||||
|
.swagger-ui .btn { border-radius: 6px; }
|
||||||
|
.swagger-ui .btn.execute { background: #34d399; color: #0b0d11; border: none; }
|
||||||
|
.swagger-ui .btn.execute:hover { background: #5eead4; }
|
||||||
|
.swagger-ui .responses-inner { background: transparent; }
|
||||||
|
.swagger-ui .response-col_status { color: #34d399; }
|
||||||
|
.swagger-ui .response-col_description { color: #9ca3af; }
|
||||||
|
.swagger-ui .model-box, .swagger-ui section.models { background: #151922; border: 1px solid #1e2433; border-radius: 8px; }
|
||||||
|
.swagger-ui section.models h4 { color: #e4e7ed; border-bottom: 1px solid #1e2433; }
|
||||||
|
.swagger-ui .model { color: #c8ccd4; }
|
||||||
|
.swagger-ui .model-title { color: #e4e7ed; }
|
||||||
|
.swagger-ui .prop-type { color: #60a5fa; }
|
||||||
|
.swagger-ui .info .title { color: #e4e7ed; font-family: 'Inter', system-ui, sans-serif; }
|
||||||
|
.swagger-ui .info .description p { color: #9ca3af; }
|
||||||
|
.swagger-ui .info a { color: #34d399; }
|
||||||
|
.swagger-ui .info h1, .swagger-ui .info h2, .swagger-ui .info h3 { color: #e4e7ed; }
|
||||||
|
.swagger-ui .info .base-url { color: #7a8194; }
|
||||||
|
.swagger-ui .scheme-container .schemes > label { color: #7a8194; }
|
||||||
|
.swagger-ui .loading-container .loading::after { color: #7a8194; }
|
||||||
|
.swagger-ui .highlight-code, .swagger-ui .microlight { background: #0b0d11 !important; color: #c8ccd4 !important; border-radius: 6px; }
|
||||||
|
.swagger-ui .copy-to-clipboard { right: 10px; top: 10px; }
|
||||||
|
.swagger-ui .auth-wrapper .authorize { color: #34d399; border-color: #34d399; }
|
||||||
|
.swagger-ui .auth-wrapper .authorize svg { fill: #34d399; }
|
||||||
|
.swagger-ui .dialog-ux .modal-ux { background: #151922; border: 1px solid #1e2433; }
|
||||||
|
.swagger-ui .dialog-ux .modal-ux-header h3 { color: #e4e7ed; }
|
||||||
|
.swagger-ui .dialog-ux .modal-ux-content p { color: #9ca3af; }
|
||||||
|
.swagger-ui .model-box-control:focus, .swagger-ui .models-control:focus { outline: none; }
|
||||||
|
.swagger-ui .servers > label select { background: #0b0d11; color: #e4e7ed; border: 1px solid #1e2433; }
|
||||||
|
.swagger-ui .markdown code, .swagger-ui .renderedMarkdown code { background: rgba(52,211,153,0.1); color: #34d399; padding: 2px 6px; border-radius: 4px; }
|
||||||
|
.swagger-ui .markdown p, .swagger-ui .renderedMarkdown p { color: #9ca3af; }
|
||||||
|
.swagger-ui .opblock-tag small { color: #7a8194; }
|
||||||
|
|
||||||
nav { background: #111; border: 1px solid #222; border-radius: 8px; padding: 1.25rem; margin-bottom: 2rem; }
|
/* Hide validator */
|
||||||
nav h3 { margin-bottom: 0.75rem; color: #888; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
.swagger-ui .errors-wrapper { display: none; }
|
||||||
nav ul { list-style: none; }
|
|
||||||
nav li { margin-bottom: 0.4rem; }
|
|
||||||
nav a { font-size: 0.95rem; }
|
|
||||||
nav .method { font-family: monospace; font-size: 0.8rem; font-weight: 600; padding: 2px 6px; border-radius: 3px; margin-right: 0.5rem; }
|
|
||||||
.method-post { background: #1a3a1a; color: #4caf50; }
|
|
||||||
.method-get { background: #1a2a3a; color: #6c9fff; }
|
|
||||||
|
|
||||||
section { margin-bottom: 3rem; }
|
/* Link back */
|
||||||
section h2 { font-size: 1.5rem; border-bottom: 1px solid #222; padding-bottom: 0.5rem; margin-bottom: 1rem; }
|
.back-link { position: fixed; top: 14px; right: 24px; z-index: 100; color: #34d399; text-decoration: none; font-size: 0.85rem; font-family: 'Inter', system-ui, sans-serif; }
|
||||||
section h3 { font-size: 1.2rem; margin: 2rem 0 0.75rem; color: #fff; }
|
.back-link:hover { color: #5eead4; }
|
||||||
|
|
||||||
.endpoint { background: #111; border: 1px solid #222; border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem; }
|
|
||||||
.endpoint-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem; }
|
|
||||||
.endpoint-header .method { font-family: monospace; font-weight: 700; font-size: 0.85rem; padding: 4px 10px; border-radius: 4px; }
|
|
||||||
.endpoint-header .path { font-family: monospace; font-size: 1.05rem; color: #fff; }
|
|
||||||
|
|
||||||
table { width: 100%; border-collapse: collapse; margin: 0.75rem 0; }
|
|
||||||
th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #1a1a1a; font-size: 0.9rem; }
|
|
||||||
th { color: #888; font-weight: 600; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; }
|
|
||||||
td code { background: #1a1a2e; padding: 2px 6px; border-radius: 3px; font-size: 0.85rem; }
|
|
||||||
.required { color: #ff6b6b; font-size: 0.75rem; }
|
|
||||||
|
|
||||||
pre { background: #0d0d1a; border: 1px solid #222; border-radius: 6px; padding: 1rem; overflow-x: auto; font-size: 0.85rem; line-height: 1.5; margin: 0.75rem 0; }
|
|
||||||
code { font-family: 'SF Mono', 'Fira Code', monospace; }
|
|
||||||
.comment { color: #666; }
|
|
||||||
.string { color: #a5d6a7; }
|
|
||||||
.key { color: #90caf9; }
|
|
||||||
|
|
||||||
.note { background: #1a1a2e; border-left: 3px solid #6c9fff; padding: 0.75rem 1rem; border-radius: 0 6px 6px 0; margin: 1rem 0; font-size: 0.9rem; }
|
|
||||||
.warning { background: #2a1a1a; border-left: 3px solid #ff6b6b; }
|
|
||||||
|
|
||||||
.status-codes { margin-top: 0.5rem; }
|
|
||||||
.status-codes span { display: inline-block; background: #1a1a2e; padding: 2px 8px; border-radius: 3px; font-family: monospace; font-size: 0.8rem; margin: 2px 4px 2px 0; }
|
|
||||||
.status-ok { color: #4caf50; }
|
|
||||||
.status-err { color: #ff6b6b; }
|
|
||||||
|
|
||||||
footer { border-top: 1px solid #222; padding-top: 1.5rem; margin-top: 3rem; text-align: center; color: #555; font-size: 0.85rem; }
|
|
||||||
</style>
|
</style>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<a href="/" class="back-link">← Back to docfast.dev</a>
|
||||||
<header>
|
<div id="swagger-ui"></div>
|
||||||
<h1><a href="/">DocFast</a> API Documentation</h1>
|
<script src="/swagger-ui/swagger-ui-bundle.js"></script>
|
||||||
<p>Convert HTML, Markdown, and URLs to PDF. Built-in invoice & receipt templates.</p>
|
<script>
|
||||||
<div class="base-url">Base URL: https://docfast.dev</div>
|
SwaggerUIBundle({
|
||||||
</header>
|
url: '/openapi.json',
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
<nav>
|
deepLinking: true,
|
||||||
<h3>Endpoints</h3>
|
presets: [
|
||||||
<ul>
|
SwaggerUIBundle.presets.apis,
|
||||||
<li><a href="#auth">Authentication</a></li>
|
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||||
<li><a href="#html"><span class="method method-post">POST</span>/v1/convert/html</a></li>
|
|
||||||
<li><a href="#markdown"><span class="method method-post">POST</span>/v1/convert/markdown</a></li>
|
|
||||||
<li><a href="#url"><span class="method method-post">POST</span>/v1/convert/url</a></li>
|
|
||||||
<li><a href="#templates-list"><span class="method method-get">GET</span>/v1/templates</a></li>
|
|
||||||
<li><a href="#templates-render"><span class="method method-post">POST</span>/v1/templates/:id/render</a></li>
|
|
||||||
<li><a href="#signup"><span class="method method-post">POST</span>/v1/signup/free</a></li>
|
|
||||||
<li><a href="#errors">Error Handling</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<section id="auth">
|
|
||||||
<h2>Authentication</h2>
|
|
||||||
<p>All conversion and template endpoints require an API key. Pass it using either method:</p>
|
|
||||||
<pre>Authorization: Bearer df_free_your_api_key_here</pre>
|
|
||||||
<p>Or use the <code>X-API-Key</code> header:</p>
|
|
||||||
<pre>X-API-Key: df_free_your_api_key_here</pre>
|
|
||||||
<p style="margin-top:0.75rem">Get a free API key instantly — no credit card required:</p>
|
|
||||||
<pre>curl -X POST https://docfast.dev/v1/signup/free \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"email": "you@example.com"}'</pre>
|
|
||||||
<div class="note">Free tier: <strong>100 PDFs/month</strong>. Pro ($9/mo): <strong>10,000 PDFs/month</strong>. Upgrade anytime at <a href="https://docfast.dev">docfast.dev</a>.</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="html">
|
|
||||||
<h2>Convert HTML to PDF</h2>
|
|
||||||
<div class="endpoint">
|
|
||||||
<div class="endpoint-header">
|
|
||||||
<span class="method method-post">POST</span>
|
|
||||||
<span class="path">/v1/convert/html</span>
|
|
||||||
</div>
|
|
||||||
<p>Convert raw HTML (with optional CSS) to a PDF document.</p>
|
|
||||||
|
|
||||||
<h3>Request Body</h3>
|
|
||||||
<table>
|
|
||||||
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
|
|
||||||
<tr><td><code>html</code> <span class="required">required</span></td><td>string</td><td>HTML content to convert</td></tr>
|
|
||||||
<tr><td><code>css</code></td><td>string</td><td>Additional CSS to inject</td></tr>
|
|
||||||
<tr><td><code>format</code></td><td>string</td><td>Page size: <code>A4</code> (default), <code>Letter</code>, <code>Legal</code>, <code>A3</code></td></tr>
|
|
||||||
<tr><td><code>landscape</code></td><td>boolean</td><td>Landscape orientation (default: false)</td></tr>
|
|
||||||
<tr><td><code>margin</code></td><td>object</td><td><code>{top, right, bottom, left}</code> in CSS units (default: 20mm each)</td></tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h3>Example</h3>
|
|
||||||
<pre>curl -X POST https://docfast.dev/v1/convert/html \
|
|
||||||
-H "Authorization: Bearer YOUR_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"html": "<h1>Hello World</h1><p>Generated by DocFast.</p>",
|
|
||||||
"css": "h1 { color: navy; }",
|
|
||||||
"format": "A4"
|
|
||||||
}' \
|
|
||||||
-o output.pdf</pre>
|
|
||||||
|
|
||||||
<h3>Response</h3>
|
|
||||||
<p><code>200 OK</code> — Returns the PDF as <code>application/pdf</code> binary stream.</p>
|
|
||||||
<div class="status-codes">
|
|
||||||
<span class="status-ok">200</span> PDF generated
|
|
||||||
<span class="status-err">400</span> Missing <code>html</code> field
|
|
||||||
<span class="status-err">401</span> Invalid/missing API key
|
|
||||||
<span class="status-err">429</span> Rate limited
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="markdown">
|
|
||||||
<h2>Convert Markdown to PDF</h2>
|
|
||||||
<div class="endpoint">
|
|
||||||
<div class="endpoint-header">
|
|
||||||
<span class="method method-post">POST</span>
|
|
||||||
<span class="path">/v1/convert/markdown</span>
|
|
||||||
</div>
|
|
||||||
<p>Convert Markdown to a styled PDF with syntax highlighting for code blocks.</p>
|
|
||||||
|
|
||||||
<h3>Request Body</h3>
|
|
||||||
<table>
|
|
||||||
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
|
|
||||||
<tr><td><code>markdown</code> <span class="required">required</span></td><td>string</td><td>Markdown content</td></tr>
|
|
||||||
<tr><td><code>css</code></td><td>string</td><td>Additional CSS to inject</td></tr>
|
|
||||||
<tr><td><code>format</code></td><td>string</td><td>Page size (default: A4)</td></tr>
|
|
||||||
<tr><td><code>landscape</code></td><td>boolean</td><td>Landscape orientation</td></tr>
|
|
||||||
<tr><td><code>margin</code></td><td>object</td><td>Custom margins</td></tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h3>Example</h3>
|
|
||||||
<pre>curl -X POST https://docfast.dev/v1/convert/markdown \
|
|
||||||
-H "Authorization: Bearer YOUR_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"markdown": "# Monthly Report\n\n## Summary\n\nRevenue increased by **15%** this quarter.\n\n| Metric | Value |\n|--------|-------|\n| Users | 1,234 |\n| MRR | $5,670 |"
|
|
||||||
}' \
|
|
||||||
-o report.pdf</pre>
|
|
||||||
|
|
||||||
<h3>Response</h3>
|
|
||||||
<p><code>200 OK</code> — Returns <code>application/pdf</code>.</p>
|
|
||||||
<div class="status-codes">
|
|
||||||
<span class="status-ok">200</span> PDF generated
|
|
||||||
<span class="status-err">400</span> Missing <code>markdown</code> field
|
|
||||||
<span class="status-err">401</span> Invalid/missing API key
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="url">
|
|
||||||
<h2>Convert URL to PDF</h2>
|
|
||||||
<div class="endpoint">
|
|
||||||
<div class="endpoint-header">
|
|
||||||
<span class="method method-post">POST</span>
|
|
||||||
<span class="path">/v1/convert/url</span>
|
|
||||||
</div>
|
|
||||||
<p>Navigate to a URL and convert the rendered page to PDF. Supports JavaScript-rendered pages.</p>
|
|
||||||
|
|
||||||
<h3>Request Body</h3>
|
|
||||||
<table>
|
|
||||||
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
|
|
||||||
<tr><td><code>url</code> <span class="required">required</span></td><td>string</td><td>URL to convert (must start with http:// or https://)</td></tr>
|
|
||||||
<tr><td><code>waitUntil</code></td><td>string</td><td><code>load</code> (default), <code>domcontentloaded</code>, <code>networkidle0</code>, <code>networkidle2</code></td></tr>
|
|
||||||
<tr><td><code>format</code></td><td>string</td><td>Page size (default: A4)</td></tr>
|
|
||||||
<tr><td><code>landscape</code></td><td>boolean</td><td>Landscape orientation</td></tr>
|
|
||||||
<tr><td><code>margin</code></td><td>object</td><td>Custom margins</td></tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h3>Example</h3>
|
|
||||||
<pre>curl -X POST https://docfast.dev/v1/convert/url \
|
|
||||||
-H "Authorization: Bearer YOUR_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"url": "https://example.com",
|
|
||||||
"waitUntil": "networkidle0",
|
|
||||||
"format": "Letter"
|
|
||||||
}' \
|
|
||||||
-o page.pdf</pre>
|
|
||||||
|
|
||||||
<h3>Response</h3>
|
|
||||||
<p><code>200 OK</code> — Returns <code>application/pdf</code>.</p>
|
|
||||||
<div class="status-codes">
|
|
||||||
<span class="status-ok">200</span> PDF generated
|
|
||||||
<span class="status-err">400</span> Missing or invalid URL
|
|
||||||
<span class="status-err">401</span> Invalid/missing API key
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="templates-list">
|
|
||||||
<h2>List Templates</h2>
|
|
||||||
<div class="endpoint">
|
|
||||||
<div class="endpoint-header">
|
|
||||||
<span class="method method-get">GET</span>
|
|
||||||
<span class="path">/v1/templates</span>
|
|
||||||
</div>
|
|
||||||
<p>List all available document templates with their field definitions.</p>
|
|
||||||
|
|
||||||
<h3>Example</h3>
|
|
||||||
<pre>curl https://docfast.dev/v1/templates \
|
|
||||||
-H "Authorization: Bearer YOUR_KEY"</pre>
|
|
||||||
|
|
||||||
<h3>Response</h3>
|
|
||||||
<pre>{
|
|
||||||
"templates": [
|
|
||||||
{
|
|
||||||
"id": "invoice",
|
|
||||||
"name": "Invoice",
|
|
||||||
"description": "Professional invoice with line items, taxes, and payment details",
|
|
||||||
"fields": [
|
|
||||||
{"name": "invoiceNumber", "type": "string", "required": true},
|
|
||||||
{"name": "date", "type": "string", "required": true},
|
|
||||||
{"name": "from", "type": "object", "required": true, "description": "Sender: {name, address?, email?, phone?, vatId?}"},
|
|
||||||
{"name": "to", "type": "object", "required": true, "description": "Recipient: {name, address?, email?, vatId?}"},
|
|
||||||
{"name": "items", "type": "array", "required": true, "description": "Line items: [{description, quantity, unitPrice, taxRate?}]"},
|
|
||||||
{"name": "currency", "type": "string", "required": false},
|
|
||||||
{"name": "notes", "type": "string", "required": false},
|
|
||||||
{"name": "paymentDetails", "type": "string", "required": false}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "receipt",
|
|
||||||
"name": "Receipt",
|
|
||||||
"description": "Simple receipt for payments received",
|
|
||||||
"fields": [ ... ]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}</pre>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="templates-render">
|
|
||||||
<h2>Render Template</h2>
|
|
||||||
<div class="endpoint">
|
|
||||||
<div class="endpoint-header">
|
|
||||||
<span class="method method-post">POST</span>
|
|
||||||
<span class="path">/v1/templates/:id/render</span>
|
|
||||||
</div>
|
|
||||||
<p>Render a template with your data and get a PDF. No HTML needed — just pass structured data.</p>
|
|
||||||
|
|
||||||
<h3>Path Parameters</h3>
|
|
||||||
<table>
|
|
||||||
<tr><th>Param</th><th>Description</th></tr>
|
|
||||||
<tr><td><code>:id</code></td><td>Template ID (<code>invoice</code> or <code>receipt</code>)</td></tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h3>Request Body</h3>
|
|
||||||
<table>
|
|
||||||
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
|
|
||||||
<tr><td><code>data</code> <span class="required">required</span></td><td>object</td><td>Template data (see field definitions from <code>/v1/templates</code>)</td></tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h3>Invoice Example</h3>
|
|
||||||
<pre>curl -X POST https://docfast.dev/v1/templates/invoice/render \
|
|
||||||
-H "Authorization: Bearer YOUR_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"data": {
|
|
||||||
"invoiceNumber": "INV-2026-001",
|
|
||||||
"date": "2026-02-14",
|
|
||||||
"dueDate": "2026-03-14",
|
|
||||||
"from": {
|
|
||||||
"name": "Acme Corp",
|
|
||||||
"address": "123 Main St, Vienna",
|
|
||||||
"email": "billing@acme.com",
|
|
||||||
"vatId": "ATU12345678"
|
|
||||||
},
|
|
||||||
"to": {
|
|
||||||
"name": "Client Inc",
|
|
||||||
"address": "456 Oak Ave, Berlin",
|
|
||||||
"email": "accounts@client.com"
|
|
||||||
},
|
|
||||||
"items": [
|
|
||||||
{"description": "Web Development", "quantity": 40, "unitPrice": 95, "taxRate": 20},
|
|
||||||
{"description": "Hosting (monthly)", "quantity": 1, "unitPrice": 29}
|
|
||||||
],
|
],
|
||||||
"currency": "€",
|
layout: 'BaseLayout',
|
||||||
"notes": "Payment due within 30 days.",
|
defaultModelsExpandDepth: 1,
|
||||||
"paymentDetails": "IBAN: AT12 3456 7890 1234 5678"
|
defaultModelExpandDepth: 2,
|
||||||
}
|
docExpansion: 'list',
|
||||||
}' \
|
filter: true,
|
||||||
-o invoice.pdf</pre>
|
tryItOutEnabled: true,
|
||||||
|
requestSnippetsEnabled: true,
|
||||||
<h3>Response</h3>
|
persistAuthorization: true,
|
||||||
<p><code>200 OK</code> — Returns <code>application/pdf</code>.</p>
|
syntaxHighlight: { theme: 'monokai' }
|
||||||
<div class="status-codes">
|
});
|
||||||
<span class="status-ok">200</span> PDF generated
|
</script>
|
||||||
<span class="status-err">400</span> Missing <code>data</code> field
|
|
||||||
<span class="status-err">404</span> Template not found
|
|
||||||
<span class="status-err">401</span> Invalid/missing API key
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="signup">
|
|
||||||
<h2>Sign Up (Get API Key)</h2>
|
|
||||||
<div class="endpoint">
|
|
||||||
<div class="endpoint-header">
|
|
||||||
<span class="method method-post">POST</span>
|
|
||||||
<span class="path">/v1/signup/free</span>
|
|
||||||
</div>
|
|
||||||
<p>Get a free API key instantly. No authentication required.</p>
|
|
||||||
|
|
||||||
<h3>Request Body</h3>
|
|
||||||
<table>
|
|
||||||
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
|
|
||||||
<tr><td><code>email</code> <span class="required">required</span></td><td>string</td><td>Your email address</td></tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h3>Example</h3>
|
|
||||||
<pre>curl -X POST https://docfast.dev/v1/signup/free \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"email": "dev@example.com"}'</pre>
|
|
||||||
|
|
||||||
<h3>Response</h3>
|
|
||||||
<pre>{
|
|
||||||
"message": "Welcome to DocFast! 🚀",
|
|
||||||
"apiKey": "df_free_abc123...",
|
|
||||||
"tier": "free",
|
|
||||||
"limit": "100 PDFs/month",
|
|
||||||
"docs": "https://docfast.dev/#endpoints"
|
|
||||||
}</pre>
|
|
||||||
<div class="note warning">Save your API key securely. You can recover it via email at POST /v1/recover</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="errors">
|
|
||||||
<h2>Error Handling</h2>
|
|
||||||
<p>All errors return JSON with an <code>error</code> field:</p>
|
|
||||||
<pre>{
|
|
||||||
"error": "Missing 'html' field"
|
|
||||||
}</pre>
|
|
||||||
|
|
||||||
<h3>Status Codes</h3>
|
|
||||||
<table>
|
|
||||||
<tr><th>Code</th><th>Meaning</th></tr>
|
|
||||||
<tr><td><code>200</code></td><td>Success — PDF returned as binary stream</td></tr>
|
|
||||||
<tr><td><code>400</code></td><td>Bad request — missing or invalid parameters</td></tr>
|
|
||||||
<tr><td><code>401</code></td><td>Unauthorized — missing or invalid API key</td></tr>
|
|
||||||
<tr><td><code>404</code></td><td>Not found — invalid endpoint or template ID</td></tr>
|
|
||||||
<tr><td><code>429</code></td><td>Rate limited — too many requests (100/min)</td></tr>
|
|
||||||
<tr><td><code>500</code></td><td>Server error — PDF generation failed</td></tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h3>Common Mistakes</h3>
|
|
||||||
<pre><span class="comment"># ❌ Missing Authorization header</span>
|
|
||||||
curl -X POST https://docfast.dev/v1/convert/html \
|
|
||||||
-d '{"html": "test"}'
|
|
||||||
<span class="comment"># → {"error": "Missing API key. Use: Authorization: Bearer <key>"}</span>
|
|
||||||
|
|
||||||
<span class="comment"># ❌ Wrong Content-Type</span>
|
|
||||||
curl -X POST https://docfast.dev/v1/convert/html \
|
|
||||||
-H "Authorization: Bearer YOUR_KEY" \
|
|
||||||
-d '{"html": "test"}'
|
|
||||||
<span class="comment"># → Make sure to include -H "Content-Type: application/json"</span>
|
|
||||||
|
|
||||||
<span class="comment"># ✅ Correct request</span>
|
|
||||||
curl -X POST https://docfast.dev/v1/convert/html \
|
|
||||||
-H "Authorization: Bearer YOUR_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"html": "<h1>Hello</h1>"}' \
|
|
||||||
-o output.pdf</pre>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<p><a href="/">← Back to DocFast</a> | Questions? Email <a href="mailto:support@docfast.dev">support@docfast.dev</a></p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,13 @@ html, body {
|
||||||
#recoverLoading.active { display: flex; flex-direction: column; align-items: center; padding: 40px 0; text-align: center; }
|
#recoverLoading.active { display: flex; flex-direction: column; align-items: center; padding: 40px 0; text-align: center; }
|
||||||
#recoverResult.active { display: block; }
|
#recoverResult.active { display: block; }
|
||||||
#recoverVerify.active { display: block; }
|
#recoverVerify.active { display: block; }
|
||||||
|
|
||||||
|
/* Email change modal states */
|
||||||
|
#emailChangeInitial, #emailChangeLoading, #emailChangeVerify, #emailChangeResult { display: none; }
|
||||||
|
#emailChangeInitial.active { display: block; }
|
||||||
|
#emailChangeLoading.active { display: flex; flex-direction: column; align-items: center; padding: 40px 0; text-align: center; }
|
||||||
|
#emailChangeResult.active { display: block; }
|
||||||
|
#emailChangeVerify.active { display: block; }
|
||||||
</style>
|
</style>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
|
@ -222,6 +229,7 @@ html, body {
|
||||||
<button class="btn btn-primary" id="btn-signup">Get Free API Key →</button>
|
<button class="btn btn-primary" id="btn-signup">Get Free API Key →</button>
|
||||||
<a href="/docs" class="btn btn-secondary">Read the Docs</a>
|
<a href="/docs" class="btn btn-secondary">Read the Docs</a>
|
||||||
</div>
|
</div>
|
||||||
|
<p style="margin-top:16px;color:var(--muted);font-size:0.9rem;">Already have an account? <a href="#" class="open-recover" style="color:var(--accent);">Lost your API key? Recover it →</a></p>
|
||||||
|
|
||||||
<div class="code-section">
|
<div class="code-section">
|
||||||
<div class="code-header">
|
<div class="code-header">
|
||||||
|
|
@ -341,6 +349,7 @@ html, body {
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a href="/docs">Docs</a>
|
<a href="/docs">Docs</a>
|
||||||
<a href="/health">API Status</a>
|
<a href="/health">API Status</a>
|
||||||
|
<a href="#" class="open-email-change">Change Email</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
@ -432,6 +441,44 @@ html, body {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Email Change Modal -->
|
||||||
|
<div class="modal-overlay" id="emailChangeModal">
|
||||||
|
<div class="modal">
|
||||||
|
<button class="close" id="btn-close-email-change">×</button>
|
||||||
|
|
||||||
|
<div id="emailChangeInitial" class="active">
|
||||||
|
<h2>Change your email</h2>
|
||||||
|
<p>Enter your API key and new email address.</p>
|
||||||
|
<div class="signup-error" id="emailChangeError"></div>
|
||||||
|
<input type="text" id="emailChangeApiKey" placeholder="Your API key (df_free_... or df_pro_...)" style="width:100%;padding:12px;border:1px solid var(--border);background:var(--bg);color:var(--fg);border-radius:8px;font-size:0.9rem;margin-bottom:12px;font-family:monospace;" required>
|
||||||
|
<input type="email" id="emailChangeNewEmail" placeholder="new.email@example.com" style="width:100%;padding:12px;border:1px solid var(--border);background:var(--bg);color:var(--fg);border-radius:8px;font-size:0.9rem;margin-bottom:16px;" required>
|
||||||
|
<button class="btn btn-primary" style="width:100%" id="emailChangeBtn">Send Verification Code →</button>
|
||||||
|
<p style="margin-top:16px;color:var(--muted);font-size:0.8rem;text-align:center;">A verification code will be sent to your new email</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="emailChangeLoading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p style="color:var(--muted);margin:0">Sending verification code…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="emailChangeVerify">
|
||||||
|
<h2>Enter verification code</h2>
|
||||||
|
<p>We sent a 6-digit code to <strong id="emailChangeEmailDisplay"></strong></p>
|
||||||
|
<div class="signup-error" id="emailChangeVerifyError"></div>
|
||||||
|
<input type="text" id="emailChangeCode" placeholder="123456" maxlength="6" pattern="[0-9]{6}" inputmode="numeric" style="width:100%;padding:14px;border:1px solid var(--border);background:var(--bg);color:var(--fg);border-radius:8px;font-size:1.4rem;letter-spacing:0.3em;text-align:center;margin-bottom:16px;font-family:monospace;" required>
|
||||||
|
<button class="btn btn-primary" style="width:100%" id="emailChangeVerifyBtn">Verify →</button>
|
||||||
|
<p style="margin-top:16px;color:var(--muted);font-size:0.8rem;text-align:center;">Code expires in 15 minutes</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="emailChangeResult">
|
||||||
|
<h2>✅ Email updated!</h2>
|
||||||
|
<p>Your account email has been changed to <strong id="emailChangeNewDisplay"></strong></p>
|
||||||
|
<p style="margin-top:20px;color:var(--muted);font-size:0.9rem;"><a href="/docs">Read the docs →</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
422
public/openapi.json
Normal file
422
public/openapi.json
Normal file
|
|
@ -0,0 +1,422 @@
|
||||||
|
{
|
||||||
|
"openapi": "3.0.3",
|
||||||
|
"info": {
|
||||||
|
"title": "DocFast API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates.\n\n## Authentication\nAll conversion and template endpoints require an API key via `Authorization: Bearer <key>` or `X-API-Key: <key>` header.\n\n## Rate Limits\n- Free tier: 100 PDFs/month, 10 req/min\n- Pro tier: 10,000 PDFs/month\n\n## Getting Started\n1. Sign up at [docfast.dev](https://docfast.dev) or via `POST /v1/signup/free`\n2. Verify your email with the 6-digit code\n3. Use your API key to convert documents",
|
||||||
|
"contact": { "name": "DocFast", "url": "https://docfast.dev" }
|
||||||
|
},
|
||||||
|
"servers": [{ "url": "https://docfast.dev", "description": "Production" }],
|
||||||
|
"tags": [
|
||||||
|
{ "name": "Conversion", "description": "Convert HTML, Markdown, or URLs to PDF" },
|
||||||
|
{ "name": "Templates", "description": "Built-in document templates" },
|
||||||
|
{ "name": "Account", "description": "Signup, key recovery, and email management" },
|
||||||
|
{ "name": "Billing", "description": "Stripe-powered subscription management" },
|
||||||
|
{ "name": "System", "description": "Health checks and usage stats" }
|
||||||
|
],
|
||||||
|
"components": {
|
||||||
|
"securitySchemes": {
|
||||||
|
"BearerAuth": { "type": "http", "scheme": "bearer", "description": "API key as Bearer token" },
|
||||||
|
"ApiKeyHeader": { "type": "apiKey", "in": "header", "name": "X-API-Key", "description": "API key via X-API-Key header" }
|
||||||
|
},
|
||||||
|
"schemas": {
|
||||||
|
"PdfOptions": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"format": { "type": "string", "enum": ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"], "default": "A4", "description": "Page size" },
|
||||||
|
"landscape": { "type": "boolean", "default": false, "description": "Landscape orientation" },
|
||||||
|
"margin": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"top": { "type": "string", "example": "20mm" },
|
||||||
|
"bottom": { "type": "string", "example": "20mm" },
|
||||||
|
"left": { "type": "string", "example": "15mm" },
|
||||||
|
"right": { "type": "string", "example": "15mm" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"printBackground": { "type": "boolean", "default": true, "description": "Print background graphics" },
|
||||||
|
"filename": { "type": "string", "default": "document.pdf", "description": "Suggested filename for the PDF" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Error": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"error": { "type": "string", "description": "Error message" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"/v1/convert/html": {
|
||||||
|
"post": {
|
||||||
|
"tags": ["Conversion"],
|
||||||
|
"summary": "Convert HTML to PDF",
|
||||||
|
"description": "Renders HTML content as a PDF. Supports full CSS including flexbox, grid, and custom fonts. Bare HTML fragments are auto-wrapped.",
|
||||||
|
"security": [{ "BearerAuth": [] }, { "ApiKeyHeader": [] }],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["html"],
|
||||||
|
"properties": {
|
||||||
|
"html": { "type": "string", "description": "HTML content to convert", "example": "<h1>Hello World</h1><p>Your first PDF</p>" },
|
||||||
|
"css": { "type": "string", "description": "Optional CSS to inject (for HTML fragments)" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ "$ref": "#/components/schemas/PdfOptions" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "PDF file", "content": { "application/pdf": { "schema": { "type": "string", "format": "binary" } } } },
|
||||||
|
"400": { "description": "Invalid request", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||||
|
"401": { "description": "Missing or invalid API key" },
|
||||||
|
"415": { "description": "Unsupported Content-Type" },
|
||||||
|
"429": { "description": "Rate limit exceeded or server busy" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/convert/markdown": {
|
||||||
|
"post": {
|
||||||
|
"tags": ["Conversion"],
|
||||||
|
"summary": "Convert Markdown to PDF",
|
||||||
|
"description": "Converts Markdown content to a beautifully styled PDF with syntax highlighting.",
|
||||||
|
"security": [{ "BearerAuth": [] }, { "ApiKeyHeader": [] }],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["markdown"],
|
||||||
|
"properties": {
|
||||||
|
"markdown": { "type": "string", "description": "Markdown content to convert", "example": "# Hello World\n\nThis is **bold** and this is *italic*.\n\n- Item 1\n- Item 2" },
|
||||||
|
"css": { "type": "string", "description": "Optional custom CSS" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ "$ref": "#/components/schemas/PdfOptions" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "PDF file", "content": { "application/pdf": { "schema": { "type": "string", "format": "binary" } } } },
|
||||||
|
"400": { "description": "Invalid request" },
|
||||||
|
"401": { "description": "Missing or invalid API key" },
|
||||||
|
"429": { "description": "Rate limit exceeded" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/convert/url": {
|
||||||
|
"post": {
|
||||||
|
"tags": ["Conversion"],
|
||||||
|
"summary": "Convert URL to PDF",
|
||||||
|
"description": "Fetches a URL and converts the rendered page to PDF. Only http/https URLs are supported. Private/reserved IPs are blocked (SSRF protection).",
|
||||||
|
"security": [{ "BearerAuth": [] }, { "ApiKeyHeader": [] }],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["url"],
|
||||||
|
"properties": {
|
||||||
|
"url": { "type": "string", "format": "uri", "description": "URL to convert", "example": "https://example.com" },
|
||||||
|
"waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle0", "networkidle2"], "default": "networkidle0", "description": "When to consider navigation complete" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ "$ref": "#/components/schemas/PdfOptions" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "PDF file", "content": { "application/pdf": { "schema": { "type": "string", "format": "binary" } } } },
|
||||||
|
"400": { "description": "Invalid URL or DNS failure" },
|
||||||
|
"401": { "description": "Missing or invalid API key" },
|
||||||
|
"429": { "description": "Rate limit exceeded" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/templates": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["Templates"],
|
||||||
|
"summary": "List available templates",
|
||||||
|
"description": "Returns all available document templates with their field definitions.",
|
||||||
|
"security": [{ "BearerAuth": [] }, { "ApiKeyHeader": [] }],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Template list",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"templates": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string", "example": "invoice" },
|
||||||
|
"name": { "type": "string", "example": "Invoice" },
|
||||||
|
"description": { "type": "string" },
|
||||||
|
"fields": { "type": "array", "items": { "type": "string" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/templates/{id}/render": {
|
||||||
|
"post": {
|
||||||
|
"tags": ["Templates"],
|
||||||
|
"summary": "Render a template to PDF",
|
||||||
|
"description": "Renders a template with the provided data and returns a PDF.",
|
||||||
|
"security": [{ "BearerAuth": [] }, { "ApiKeyHeader": [] }],
|
||||||
|
"parameters": [
|
||||||
|
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Template ID (e.g., 'invoice', 'receipt')" }
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": { "type": "object", "description": "Template data fields", "example": { "company": "Acme Corp", "items": [{ "description": "Widget", "quantity": 5, "price": 9.99 }] } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "PDF file", "content": { "application/pdf": { "schema": { "type": "string", "format": "binary" } } } },
|
||||||
|
"404": { "description": "Template not found" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/signup/free": {
|
||||||
|
"post": {
|
||||||
|
"tags": ["Account"],
|
||||||
|
"summary": "Request a free API key",
|
||||||
|
"description": "Sends a 6-digit verification code to your email. Use `/v1/signup/verify` to complete signup.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["email"],
|
||||||
|
"properties": {
|
||||||
|
"email": { "type": "string", "format": "email", "example": "you@example.com" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Verification code sent",
|
||||||
|
"content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "example": "verification_required" }, "message": { "type": "string" } } } } }
|
||||||
|
},
|
||||||
|
"409": { "description": "Email already registered" },
|
||||||
|
"429": { "description": "Too many signup attempts" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/signup/verify": {
|
||||||
|
"post": {
|
||||||
|
"tags": ["Account"],
|
||||||
|
"summary": "Verify email and get API key",
|
||||||
|
"description": "Verify your email with the 6-digit code to receive your API key.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["email", "code"],
|
||||||
|
"properties": {
|
||||||
|
"email": { "type": "string", "format": "email" },
|
||||||
|
"code": { "type": "string", "example": "123456", "description": "6-digit verification code" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "API key issued",
|
||||||
|
"content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "example": "verified" }, "apiKey": { "type": "string", "example": "df_free_abc123..." }, "tier": { "type": "string", "example": "free" } } } } }
|
||||||
|
},
|
||||||
|
"400": { "description": "Invalid code" },
|
||||||
|
"410": { "description": "Code expired" },
|
||||||
|
"429": { "description": "Too many attempts" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/recover": {
|
||||||
|
"post": {
|
||||||
|
"tags": ["Account"],
|
||||||
|
"summary": "Request API key recovery",
|
||||||
|
"description": "Sends a verification code to your registered email. Returns the same response whether or not the email exists (prevents enumeration).",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["email"],
|
||||||
|
"properties": {
|
||||||
|
"email": { "type": "string", "format": "email" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Recovery code sent (if account exists)" },
|
||||||
|
"429": { "description": "Too many recovery attempts" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/recover/verify": {
|
||||||
|
"post": {
|
||||||
|
"tags": ["Account"],
|
||||||
|
"summary": "Verify recovery code and get API key",
|
||||||
|
"description": "Verify the recovery code to retrieve your API key. The key is shown only in the response — never sent via email.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["email", "code"],
|
||||||
|
"properties": {
|
||||||
|
"email": { "type": "string", "format": "email" },
|
||||||
|
"code": { "type": "string", "example": "123456" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "API key recovered",
|
||||||
|
"content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "example": "recovered" }, "apiKey": { "type": "string" }, "tier": { "type": "string" } } } } }
|
||||||
|
},
|
||||||
|
"400": { "description": "Invalid code" },
|
||||||
|
"410": { "description": "Code expired" },
|
||||||
|
"429": { "description": "Too many attempts" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/email-change": {
|
||||||
|
"post": {
|
||||||
|
"tags": ["Account"],
|
||||||
|
"summary": "Request email change",
|
||||||
|
"description": "Change the email associated with your API key. Sends a verification code to the new email address.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["apiKey", "newEmail"],
|
||||||
|
"properties": {
|
||||||
|
"apiKey": { "type": "string", "description": "Your current API key" },
|
||||||
|
"newEmail": { "type": "string", "format": "email", "description": "New email address" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Verification code sent to new email" },
|
||||||
|
"400": { "description": "Invalid input" },
|
||||||
|
"401": { "description": "Invalid API key" },
|
||||||
|
"409": { "description": "Email already in use" },
|
||||||
|
"429": { "description": "Too many attempts" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/email-change/verify": {
|
||||||
|
"post": {
|
||||||
|
"tags": ["Account"],
|
||||||
|
"summary": "Verify email change",
|
||||||
|
"description": "Verify the code sent to your new email to complete the change.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["apiKey", "newEmail", "code"],
|
||||||
|
"properties": {
|
||||||
|
"apiKey": { "type": "string" },
|
||||||
|
"newEmail": { "type": "string", "format": "email" },
|
||||||
|
"code": { "type": "string", "example": "123456" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Email updated", "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "example": "updated" }, "newEmail": { "type": "string" } } } } } },
|
||||||
|
"400": { "description": "Invalid code" },
|
||||||
|
"401": { "description": "Invalid API key" },
|
||||||
|
"410": { "description": "Code expired" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/billing/checkout": {
|
||||||
|
"post": {
|
||||||
|
"tags": ["Billing"],
|
||||||
|
"summary": "Start Pro subscription checkout",
|
||||||
|
"description": "Creates a Stripe Checkout session for the Pro plan ($9/mo). Returns a URL to redirect the user to.",
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Checkout URL", "content": { "application/json": { "schema": { "type": "object", "properties": { "url": { "type": "string", "format": "uri" } } } } } },
|
||||||
|
"500": { "description": "Checkout creation failed" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/v1/usage": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["System"],
|
||||||
|
"summary": "Get usage statistics",
|
||||||
|
"description": "Returns your API usage statistics for the current billing period.",
|
||||||
|
"security": [{ "BearerAuth": [] }, { "ApiKeyHeader": [] }],
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Usage stats" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/health": {
|
||||||
|
"get": {
|
||||||
|
"tags": ["System"],
|
||||||
|
"summary": "Health check",
|
||||||
|
"description": "Returns service health status. No authentication required.",
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Service is healthy" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/swagger-ui
Symbolic link
1
public/swagger-ui
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
/opt/docfast/node_modules/swagger-ui-dist
|
||||||
Loading…
Add table
Add a link
Reference in a new issue