@fyresmith/hive-server 4.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -17
- package/assets/plugin/hive/main.js +16384 -0
- package/assets/plugin/hive/manifest.json +9 -0
- package/assets/plugin/hive/styles.css +1040 -0
- package/assets/template-vault/.obsidian/app.json +1 -0
- package/assets/template-vault/.obsidian/appearance.json +1 -0
- package/assets/template-vault/.obsidian/core-plugins.json +33 -0
- package/assets/template-vault/.obsidian/graph.json +22 -0
- package/assets/template-vault/.obsidian/workspace.json +206 -0
- package/assets/template-vault/Welcome.md +5 -0
- package/cli/commands/env.js +4 -4
- package/cli/commands/managed.js +22 -17
- package/cli/commands/root.js +48 -2
- package/cli/commands/tunnel.js +1 -16
- package/cli/constants.js +1 -13
- package/cli/core/context.js +3 -22
- package/cli/env-file.js +15 -33
- package/cli/flows/doctor.js +1 -6
- package/cli/flows/setup.js +106 -33
- package/cli/tunnel.js +1 -4
- package/index.js +92 -39
- package/lib/accountState.js +189 -0
- package/lib/authTokens.js +75 -0
- package/lib/bundleBuilder.js +169 -0
- package/lib/dashboardAuth.js +80 -0
- package/lib/managedState.js +262 -55
- package/lib/setupOrchestrator.js +76 -0
- package/lib/yjsServer.js +12 -11
- package/package.json +3 -2
- package/routes/auth.js +403 -78
- package/routes/dashboard.js +590 -0
- package/routes/managed.js +0 -163
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { randomUUID, scrypt as scryptCb, timingSafeEqual } from 'crypto';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'fs/promises';
|
|
4
|
+
import { dirname, join, resolve } from 'path';
|
|
5
|
+
import { promisify } from 'util';
|
|
6
|
+
|
|
7
|
+
const scrypt = promisify(scryptCb);
|
|
8
|
+
|
|
9
|
+
const STATE_VERSION = 1;
|
|
10
|
+
const STATE_REL_PATH = join('.hive', 'accounts-state.json');
|
|
11
|
+
|
|
12
|
+
function nowIso() {
|
|
13
|
+
return new Date().toISOString();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeEmail(value) {
|
|
17
|
+
return String(value ?? '').trim().toLowerCase();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseEmail(value) {
|
|
21
|
+
const email = String(value ?? '').trim();
|
|
22
|
+
const emailNorm = normalizeEmail(email);
|
|
23
|
+
if (!emailNorm || !emailNorm.includes('@')) {
|
|
24
|
+
throw new Error('A valid email is required');
|
|
25
|
+
}
|
|
26
|
+
return { email, emailNorm };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeDisplayName(value) {
|
|
30
|
+
const displayName = String(value ?? '').trim();
|
|
31
|
+
if (!displayName || displayName.length > 32) {
|
|
32
|
+
throw new Error('Display name must be 1-32 characters');
|
|
33
|
+
}
|
|
34
|
+
return displayName;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeAccount(raw) {
|
|
38
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
39
|
+
const id = String(raw.id ?? '').trim();
|
|
40
|
+
const email = String(raw.email ?? '').trim();
|
|
41
|
+
const emailNorm = normalizeEmail(raw.emailNorm ?? email);
|
|
42
|
+
const displayName = String(raw.displayName ?? '').trim();
|
|
43
|
+
const passwordHash = String(raw.passwordHash ?? '').trim();
|
|
44
|
+
const passwordSalt = String(raw.passwordSalt ?? '').trim();
|
|
45
|
+
const createdAt = String(raw.createdAt ?? '').trim();
|
|
46
|
+
const updatedAt = String(raw.updatedAt ?? '').trim();
|
|
47
|
+
|
|
48
|
+
if (!id || !emailNorm || !displayName || !passwordHash || !passwordSalt || !createdAt || !updatedAt) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
id,
|
|
54
|
+
email,
|
|
55
|
+
emailNorm,
|
|
56
|
+
displayName,
|
|
57
|
+
passwordHash,
|
|
58
|
+
passwordSalt,
|
|
59
|
+
createdAt,
|
|
60
|
+
updatedAt,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeState(raw) {
|
|
65
|
+
const source = raw && typeof raw === 'object' ? raw : {};
|
|
66
|
+
const accountsRaw = source.accounts && typeof source.accounts === 'object' ? source.accounts : {};
|
|
67
|
+
|
|
68
|
+
const accounts = {};
|
|
69
|
+
for (const [id, value] of Object.entries(accountsRaw)) {
|
|
70
|
+
const normalized = normalizeAccount(value);
|
|
71
|
+
if (normalized) {
|
|
72
|
+
accounts[id] = normalized;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
version: STATE_VERSION,
|
|
78
|
+
accounts,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function hashPassword(password, salt) {
|
|
83
|
+
const input = String(password ?? '');
|
|
84
|
+
if (input.length < 8) {
|
|
85
|
+
throw new Error('Password must be at least 8 characters');
|
|
86
|
+
}
|
|
87
|
+
const derived = await scrypt(input, salt, 64);
|
|
88
|
+
return Buffer.from(derived).toString('hex');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getAccountsStatePath(vaultPath) {
|
|
92
|
+
return join(resolve(vaultPath), STATE_REL_PATH);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function loadAccountsState(vaultPath) {
|
|
96
|
+
const filePath = getAccountsStatePath(vaultPath);
|
|
97
|
+
if (!existsSync(filePath)) {
|
|
98
|
+
return normalizeState(null);
|
|
99
|
+
}
|
|
100
|
+
const raw = await readFile(filePath, 'utf-8');
|
|
101
|
+
const parsed = JSON.parse(raw);
|
|
102
|
+
return normalizeState(parsed);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function saveAccountsState(vaultPath, state) {
|
|
106
|
+
const filePath = getAccountsStatePath(vaultPath);
|
|
107
|
+
const normalized = normalizeState(state);
|
|
108
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
109
|
+
await writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf-8');
|
|
110
|
+
return normalized;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function createAccount({ vaultPath, email, password, displayName }) {
|
|
114
|
+
const { emailNorm } = parseEmail(email);
|
|
115
|
+
const nextDisplayName = normalizeDisplayName(displayName);
|
|
116
|
+
|
|
117
|
+
const state = await loadAccountsState(vaultPath);
|
|
118
|
+
const existing = Object.values(state.accounts).find((row) => row.emailNorm === emailNorm);
|
|
119
|
+
if (existing) {
|
|
120
|
+
throw new Error('An account already exists for this email');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const id = randomUUID();
|
|
124
|
+
const passwordSalt = randomUUID();
|
|
125
|
+
const passwordHash = await hashPassword(password, passwordSalt);
|
|
126
|
+
const ts = nowIso();
|
|
127
|
+
|
|
128
|
+
state.accounts[id] = {
|
|
129
|
+
id,
|
|
130
|
+
email: String(email ?? '').trim(),
|
|
131
|
+
emailNorm,
|
|
132
|
+
displayName: nextDisplayName,
|
|
133
|
+
passwordHash,
|
|
134
|
+
passwordSalt,
|
|
135
|
+
createdAt: ts,
|
|
136
|
+
updatedAt: ts,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
await saveAccountsState(vaultPath, state);
|
|
140
|
+
return toPublicAccount(state.accounts[id]);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function createOwnerAccount({ vaultPath, email, displayName, password }) {
|
|
144
|
+
return createAccount({
|
|
145
|
+
vaultPath,
|
|
146
|
+
email,
|
|
147
|
+
password,
|
|
148
|
+
displayName,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function authenticateAccount({ vaultPath, email, password }) {
|
|
153
|
+
const { emailNorm } = parseEmail(email);
|
|
154
|
+
const state = await loadAccountsState(vaultPath);
|
|
155
|
+
const account = Object.values(state.accounts).find((row) => row.emailNorm === emailNorm);
|
|
156
|
+
if (!account) {
|
|
157
|
+
throw new Error('Invalid email or password');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const derived = await hashPassword(password, account.passwordSalt);
|
|
161
|
+
const actual = Buffer.from(account.passwordHash, 'hex');
|
|
162
|
+
const incoming = Buffer.from(derived, 'hex');
|
|
163
|
+
if (actual.length !== incoming.length || !timingSafeEqual(actual, incoming)) {
|
|
164
|
+
throw new Error('Invalid email or password');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return toPublicAccount(account);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function getAccountById(vaultPath, accountId) {
|
|
171
|
+
const id = String(accountId ?? '').trim();
|
|
172
|
+
if (!id) return null;
|
|
173
|
+
|
|
174
|
+
const state = await loadAccountsState(vaultPath);
|
|
175
|
+
const account = state.accounts[id];
|
|
176
|
+
if (!account) return null;
|
|
177
|
+
return toPublicAccount(account);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function toPublicAccount(account) {
|
|
181
|
+
return {
|
|
182
|
+
id: account.id,
|
|
183
|
+
email: account.email,
|
|
184
|
+
emailNorm: account.emailNorm,
|
|
185
|
+
displayName: account.displayName,
|
|
186
|
+
createdAt: account.createdAt,
|
|
187
|
+
updatedAt: account.updatedAt,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'crypto';
|
|
2
|
+
import jwt from 'jsonwebtoken';
|
|
3
|
+
|
|
4
|
+
const PURPOSE_CLAIM_SESSION = 'claim-session';
|
|
5
|
+
|
|
6
|
+
function getJwtSecret() {
|
|
7
|
+
const secret = String(process.env.JWT_SECRET ?? '').trim();
|
|
8
|
+
if (!secret) {
|
|
9
|
+
throw new Error('JWT_SECRET is required');
|
|
10
|
+
}
|
|
11
|
+
return secret;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parsePositiveInt(value, fallback) {
|
|
15
|
+
const parsed = parseInt(String(value ?? '').trim(), 10);
|
|
16
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
17
|
+
return fallback;
|
|
18
|
+
}
|
|
19
|
+
return parsed;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function downloadTicketTtlMinutes() {
|
|
23
|
+
return parsePositiveInt(process.env.HIVE_BUNDLE_GRANT_TTL_MINUTES, 15);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function bootstrapTokenTtlHours() {
|
|
27
|
+
return parsePositiveInt(process.env.HIVE_BOOTSTRAP_TOKEN_TTL_HOURS, 24);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function hashToken(token) {
|
|
31
|
+
return createHash('sha256').update(String(token ?? ''), 'utf-8').digest('hex');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function signClaimSessionToken(account) {
|
|
35
|
+
const payload = {
|
|
36
|
+
purpose: PURPOSE_CLAIM_SESSION,
|
|
37
|
+
accountId: account.id,
|
|
38
|
+
displayName: account.displayName,
|
|
39
|
+
emailNorm: account.emailNorm,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return jwt.sign(payload, getJwtSecret(), { expiresIn: '7d' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function verifyClaimSessionToken(token) {
|
|
46
|
+
const decoded = jwt.verify(String(token ?? ''), getJwtSecret());
|
|
47
|
+
if (!decoded || decoded.purpose !== PURPOSE_CLAIM_SESSION) {
|
|
48
|
+
throw new Error('Invalid session token');
|
|
49
|
+
}
|
|
50
|
+
return decoded;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function issueDownloadTicket() {
|
|
54
|
+
const ttlMinutes = downloadTicketTtlMinutes();
|
|
55
|
+
const ttlMs = ttlMinutes * 60 * 1000;
|
|
56
|
+
const token = randomBytes(32).toString('hex');
|
|
57
|
+
return {
|
|
58
|
+
token,
|
|
59
|
+
tokenHash: hashToken(token),
|
|
60
|
+
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function issueBootstrapToken({ memberId, vaultId }) {
|
|
65
|
+
const ttlHours = bootstrapTokenTtlHours();
|
|
66
|
+
const ttlMs = ttlHours * 60 * 60 * 1000;
|
|
67
|
+
const token = randomBytes(32).toString('hex');
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
token,
|
|
71
|
+
memberId,
|
|
72
|
+
vaultId,
|
|
73
|
+
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { readdir, readFile } from 'fs/promises';
|
|
3
|
+
import { createRequire } from 'module';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { basename, join, resolve } from 'path';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const { ZipFile } = require('yazl');
|
|
9
|
+
|
|
10
|
+
const PLUGIN_ID = 'hive';
|
|
11
|
+
const DEFAULT_DENY_PATHS = ['.git', '.hive', '.hive-quarantine', '.DS_Store', 'Thumbs.db'];
|
|
12
|
+
|
|
13
|
+
const ASSETS_ROOT = resolve(fileURLToPath(new URL('../assets/', import.meta.url)));
|
|
14
|
+
const TEMPLATE_ROOT = join(ASSETS_ROOT, 'template-vault');
|
|
15
|
+
const PLUGIN_ROOT = join(ASSETS_ROOT, 'plugin', PLUGIN_ID);
|
|
16
|
+
|
|
17
|
+
function requireAssetPath(path, label) {
|
|
18
|
+
if (!existsSync(path)) {
|
|
19
|
+
throw new Error(`Missing packaged ${label} at ${path}`);
|
|
20
|
+
}
|
|
21
|
+
return path;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function readPluginAsset(filename) {
|
|
25
|
+
const root = requireAssetPath(PLUGIN_ROOT, 'plugin assets');
|
|
26
|
+
const abs = join(root, filename);
|
|
27
|
+
if (!existsSync(abs)) {
|
|
28
|
+
throw new Error(`Missing plugin asset at ${abs}`);
|
|
29
|
+
}
|
|
30
|
+
return readFile(abs);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readTemplateFiles() {
|
|
34
|
+
const root = requireAssetPath(TEMPLATE_ROOT, 'template vault');
|
|
35
|
+
const files = [];
|
|
36
|
+
|
|
37
|
+
async function walk(currentAbs, relPrefix) {
|
|
38
|
+
const entries = await readdir(currentAbs, { withFileTypes: true });
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
const abs = join(currentAbs, entry.name);
|
|
41
|
+
const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
|
|
42
|
+
if (entry.isDirectory()) {
|
|
43
|
+
await walk(abs, rel);
|
|
44
|
+
} else if (entry.isFile()) {
|
|
45
|
+
const content = await readFile(abs);
|
|
46
|
+
files.push({ relPath: rel.replace(/\\/g, '/'), content });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await walk(root, '');
|
|
52
|
+
return files;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sanitizeVaultName(name) {
|
|
56
|
+
const safe = String(name ?? '').trim().replace(/[/\\:*?"<>|]/g, '').replace(/\s+/g, ' ').trim();
|
|
57
|
+
return safe || 'Hive Vault';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderReadme(serverUrl, vaultName) {
|
|
61
|
+
return [
|
|
62
|
+
`# ${sanitizeVaultName(vaultName)}`,
|
|
63
|
+
'',
|
|
64
|
+
'1. Extract this zip to a folder.',
|
|
65
|
+
'2. Open that folder as a vault in Obsidian desktop.',
|
|
66
|
+
'3. Hive will complete secure first-run bootstrap and sync automatically.',
|
|
67
|
+
'',
|
|
68
|
+
`Hive server: ${serverUrl}`,
|
|
69
|
+
].join('\n');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getBundleDenyPaths() {
|
|
73
|
+
const custom = String(process.env.HIVE_BUNDLE_DENY_PATHS ?? '')
|
|
74
|
+
.split(',')
|
|
75
|
+
.map((value) => value.trim())
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
return new Set([...DEFAULT_DENY_PATHS, ...custom]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isDeniedPath(path, denyPaths) {
|
|
81
|
+
const normalized = String(path ?? '').replace(/\\/g, '/');
|
|
82
|
+
for (const deny of denyPaths) {
|
|
83
|
+
if (normalized === deny || normalized.startsWith(`${deny}/`)) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function sendInviteShellBundle(res, {
|
|
91
|
+
serverUrl,
|
|
92
|
+
vaultId,
|
|
93
|
+
bootstrapToken,
|
|
94
|
+
vaultName,
|
|
95
|
+
}) {
|
|
96
|
+
const templateFiles = await readTemplateFiles();
|
|
97
|
+
const mainJs = await readPluginAsset('main.js');
|
|
98
|
+
const manifestJson = await readPluginAsset('manifest.json');
|
|
99
|
+
const stylesCss = await readPluginAsset('styles.css');
|
|
100
|
+
|
|
101
|
+
const zip = new ZipFile();
|
|
102
|
+
const denyPaths = getBundleDenyPaths();
|
|
103
|
+
|
|
104
|
+
const safeName = sanitizeVaultName(vaultName);
|
|
105
|
+
const rootPrefix = `${safeName}/`;
|
|
106
|
+
|
|
107
|
+
const binding = {
|
|
108
|
+
version: 1,
|
|
109
|
+
managed: true,
|
|
110
|
+
serverUrl,
|
|
111
|
+
vaultId,
|
|
112
|
+
createdAt: new Date().toISOString(),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const pluginData = {
|
|
116
|
+
serverUrl,
|
|
117
|
+
token: null,
|
|
118
|
+
bootstrapToken,
|
|
119
|
+
user: null,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const appConfig = {
|
|
123
|
+
communityPluginEnabled: true,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const pluginRoot = `${rootPrefix}.obsidian/plugins/${PLUGIN_ID}`;
|
|
127
|
+
|
|
128
|
+
const addBuffer = (buffer, targetPath) => {
|
|
129
|
+
if (isDeniedPath(targetPath, denyPaths)) {
|
|
130
|
+
throw new Error(`Bundle output path denied by policy: ${targetPath}`);
|
|
131
|
+
}
|
|
132
|
+
zip.addBuffer(buffer, targetPath);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const overwritePaths = new Set([
|
|
136
|
+
'.obsidian/app.json',
|
|
137
|
+
'.obsidian/community-plugins.json',
|
|
138
|
+
'.obsidian/hive-managed.json',
|
|
139
|
+
'README.md',
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
for (const file of templateFiles) {
|
|
143
|
+
if (overwritePaths.has(file.relPath)) continue;
|
|
144
|
+
const target = `${rootPrefix}${file.relPath}`;
|
|
145
|
+
addBuffer(file.content, target);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
addBuffer(Buffer.from(`${JSON.stringify(binding, null, 2)}\n`, 'utf-8'), `${rootPrefix}.obsidian/hive-managed.json`);
|
|
149
|
+
addBuffer(mainJs, `${pluginRoot}/main.js`);
|
|
150
|
+
addBuffer(manifestJson, `${pluginRoot}/manifest.json`);
|
|
151
|
+
addBuffer(stylesCss, `${pluginRoot}/styles.css`);
|
|
152
|
+
addBuffer(Buffer.from(`${JSON.stringify(pluginData, null, 2)}\n`, 'utf-8'), `${pluginRoot}/data.json`);
|
|
153
|
+
addBuffer(Buffer.from(`${JSON.stringify([PLUGIN_ID], null, 2)}\n`, 'utf-8'), `${rootPrefix}.obsidian/community-plugins.json`);
|
|
154
|
+
addBuffer(Buffer.from(`${JSON.stringify(appConfig, null, 2)}\n`, 'utf-8'), `${rootPrefix}.obsidian/app.json`);
|
|
155
|
+
addBuffer(Buffer.from(`${renderReadme(serverUrl, vaultName)}\n`, 'utf-8'), `${rootPrefix}README.md`);
|
|
156
|
+
|
|
157
|
+
const name = `${safeName}.zip`;
|
|
158
|
+
res.setHeader('Content-Type', 'application/zip');
|
|
159
|
+
res.setHeader('Content-Disposition', `attachment; filename="${basename(name)}"`);
|
|
160
|
+
|
|
161
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
162
|
+
zip.outputStream.on('error', rejectPromise);
|
|
163
|
+
res.on('error', rejectPromise);
|
|
164
|
+
res.on('finish', resolvePromise);
|
|
165
|
+
|
|
166
|
+
zip.outputStream.pipe(res);
|
|
167
|
+
zip.end();
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
|
|
3
|
+
export const COOKIE_NAME = 'hive_dashboard_session';
|
|
4
|
+
const PURPOSE = 'dashboard-session';
|
|
5
|
+
|
|
6
|
+
function getJwtSecret() {
|
|
7
|
+
const secret = String(process.env.JWT_SECRET ?? '').trim();
|
|
8
|
+
if (!secret) throw new Error('JWT_SECRET is required');
|
|
9
|
+
return secret;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function parseCookies(req) {
|
|
13
|
+
const raw = String(req.headers.cookie ?? '');
|
|
14
|
+
const cookies = {};
|
|
15
|
+
for (const chunk of raw.split(';')) {
|
|
16
|
+
const [name, ...rest] = chunk.split('=');
|
|
17
|
+
const key = String(name ?? '').trim();
|
|
18
|
+
if (!key) continue;
|
|
19
|
+
cookies[key] = decodeURIComponent(rest.join('=').trim());
|
|
20
|
+
}
|
|
21
|
+
return cookies;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isSecureRequest(req) {
|
|
25
|
+
if (req.secure) return true;
|
|
26
|
+
const xf = String(req.headers['x-forwarded-proto'] ?? '').toLowerCase();
|
|
27
|
+
return xf.includes('https');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function signDashboardSessionToken(accountId) {
|
|
31
|
+
return jwt.sign(
|
|
32
|
+
{ purpose: PURPOSE, accountId, role: 'owner' },
|
|
33
|
+
getJwtSecret(),
|
|
34
|
+
{ expiresIn: '24h' },
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function verifyDashboardSessionToken(token) {
|
|
39
|
+
const decoded = jwt.verify(String(token ?? ''), getJwtSecret());
|
|
40
|
+
if (!decoded || decoded.purpose !== PURPOSE) {
|
|
41
|
+
throw new Error('Invalid dashboard session token');
|
|
42
|
+
}
|
|
43
|
+
return decoded;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getDashboardSession(req) {
|
|
47
|
+
const cookies = parseCookies(req);
|
|
48
|
+
const token = String(cookies[COOKIE_NAME] ?? '').trim();
|
|
49
|
+
if (!token) return null;
|
|
50
|
+
try {
|
|
51
|
+
return verifyDashboardSessionToken(token);
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function setDashboardCookie(req, res, token) {
|
|
58
|
+
const secure = isSecureRequest(req) ? '; Secure' : '';
|
|
59
|
+
res.setHeader(
|
|
60
|
+
'Set-Cookie',
|
|
61
|
+
`${COOKIE_NAME}=${encodeURIComponent(token)}; Path=/dashboard; HttpOnly; SameSite=Lax; Max-Age=86400${secure}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function clearDashboardCookie(req, res) {
|
|
66
|
+
const secure = isSecureRequest(req) ? '; Secure' : '';
|
|
67
|
+
res.setHeader(
|
|
68
|
+
'Set-Cookie',
|
|
69
|
+
`${COOKIE_NAME}=; Path=/dashboard; HttpOnly; SameSite=Lax; Max-Age=0${secure}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function requireDashboardAuth(req, res, next) {
|
|
74
|
+
const session = getDashboardSession(req);
|
|
75
|
+
if (!session?.accountId) {
|
|
76
|
+
return res.redirect('/dashboard/login');
|
|
77
|
+
}
|
|
78
|
+
req.dashboardSession = session;
|
|
79
|
+
next();
|
|
80
|
+
}
|