Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.git
.env
config.json
data/
*.db
test/
dashboard/node_modules/
extension/
docs/
*.zip
node_modules/
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ RUN mkdir -p /data
ENV CLAWMARK_PORT=3458
ENV CLAWMARK_DATA_DIR=/data

RUN addgroup -S clawmark && adduser -S clawmark -G clawmark
RUN chown -R clawmark:clawmark /app /data

EXPOSE 3458

USER clawmark

CMD ["node", "server/index.js"]
8 changes: 4 additions & 4 deletions dashboard/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -222,16 +222,16 @@ export async function detectExtension() {
}

/**
* Try to get auth token from the extension.
* Returns { token, user } or null if extension not available.
* Try to get auth state from the extension.
* Returns { user } or null if extension not available or not authenticated.
*/
export async function getAuthFromExtension() {
const extId = await detectExtension();
if (!extId) return null;
try {
const resp = await chrome.runtime.sendMessage(extId, { type: 'GET_AUTH_STATE' });
if (resp?.authToken && resp?.authUser) {
return { token: resp.authToken, user: resp.authUser };
if (resp?.authenticated && resp?.authUser) {
return { user: resp.authUser };
}
} catch {
// Extension unavailable
Expand Down
37 changes: 19 additions & 18 deletions dashboard/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,8 @@ import { startGoogleLogin, extractAuthCode, getRedirectUri, clearUrlParams } fro
// ------------------------------------------------------------------ init

async function init() {
// Try extension auth first
const extAuth = await getAuthFromExtension();
if (extAuth) {
setAuth(extAuth.token, extAuth.user);
showApp(extAuth.user);
return;
}
// Check if extension is authenticated (token is no longer shared for security)
// Dashboard must obtain its own token via OAuth login flow

// Handle OAuth callback
const code = extractAuthCode();
Expand All @@ -46,13 +41,8 @@ async function init() {

const isWelcome = location.hash === '#welcome';

// If not logged in locally, try syncing auth from the Chrome extension
if (!isLoggedIn()) {
const extAuth = await getAuthFromExtension();
if (extAuth) {
setAuth(extAuth.authToken, extAuth.authUser);
}
}
// Extension no longer shares the auth token for security.
// Dashboard relies on its own OAuth login flow.

if (isLoggedIn()) {
// Verify token is still valid
Expand Down Expand Up @@ -248,7 +238,7 @@ function renderTopPages(topUrls) {
<div class="activity-title">${escHtml(page.source_title || page.source_url)}</div>
<div class="activity-meta">${Number(page.count) || 0} annotation${Number(page.count) !== 1 ? 's' : ''}</div>
</div>`;
if (page.source_url) {
if (page.source_url && /^https?:\/\//i.test(page.source_url)) {
el.addEventListener('click', () => window.open(page.source_url, '_blank'));
}
listEl.appendChild(el);
Expand Down Expand Up @@ -277,7 +267,15 @@ async function loadItemsList(typeFilter) {
for (const item of items.slice(0, 50)) {
const icon = item.type === 'issue' ? '\ud83d\udc1b' : '\ud83d\udcac';
const time = item.created_at ? new Date(item.created_at).toLocaleString() : '';
const sourceLabel = item.source_title || (item.source_url ? new URL(item.source_url).hostname + new URL(item.source_url).pathname.substring(0, 30) : '');
let sourceLabel = item.source_title || '';
if (!sourceLabel && item.source_url) {
try {
const parsed = new URL(item.source_url);
sourceLabel = parsed.hostname + parsed.pathname.substring(0, 30);
} catch {
sourceLabel = item.source_url.substring(0, 40);
}
}
const el = document.createElement('div');
el.className = 'activity-item' + (item.source_url ? ' activity-item-link' : '');
el.innerHTML = `
Expand All @@ -288,7 +286,7 @@ async function loadItemsList(typeFilter) {
${renderItemDispatches(item.dispatches)}
<div class="activity-meta">${time ? escHtml(time) : ''}</div>
</div>`;
if (item.source_url) {
if (item.source_url && /^https?:\/\//i.test(item.source_url)) {
el.addEventListener('click', (e) => {
if (e.target.closest('.dispatch-ext-link')) return;
window.open(item.source_url, '_blank');
Expand Down Expand Up @@ -881,7 +879,10 @@ function renderAuthsTable() {

for (const auth of allAuths) {
const creds = typeof auth.credentials === 'string' ? JSON.parse(auth.credentials) : (auth.credentials || {});
const credSummary = Object.entries(creds).map(([k, v]) => `${k}: ${v}`).join(', ');
const credSummary = Object.entries(creds).map(([k, v]) => {
const masked = typeof v === 'string' && v.length > 4 ? '••••' + v.slice(-4) : '••••';
return `${k}: ${masked}`;
}).join(', ');
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${escHtml(auth.name)}</td>
Expand Down
22 changes: 19 additions & 3 deletions extension/background/service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -342,15 +342,31 @@ async function handleExternalMessage(message, sender) {
switch (message.type) {
case 'GET_AUTH_STATE': {
const state = await getAuthState();
const authenticated = !!(state.authToken && state.authUser);
return {
authToken: state.authToken || '',
authUser: state.authUser || null,
authenticated,
authUser: authenticated
? { name: state.authUser.name, email: state.authUser.email }
: null,
};
}
case 'DASHBOARD_LOGIN': {
// Dashboard completed OAuth and wants to sync token to extension
if (!message.token || !message.user) return { error: 'Missing token or user' };
await setAuthState(message.token, message.user);
// Validate token with the server before accepting
try {
const config = await getConfig();
const serverUrl = config.serverUrl.replace(/\/$/, '');
const verifyResp = await fetch(`${serverUrl}/api/v2/auth/me`, {
headers: { 'Authorization': `Bearer ${message.token}` },
});
if (!verifyResp.ok) return { error: 'Invalid or expired token' };
const data = await verifyResp.json();
const verifiedUser = data.user || data;
await setAuthState(message.token, verifiedUser);
} catch {
return { error: 'Token validation failed' };
}
chrome.runtime.sendMessage({ type: 'AUTH_STATE_CHANGED' }).catch(() => {});
return { success: true };
}
Expand Down
2 changes: 1 addition & 1 deletion extension/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"matches": [
"*://*.coco.site/*",
"*://*.coco.xyz/*",
"*://localhost/*"
"*://localhost:3462/*"
]
},
"background": {
Expand Down
20 changes: 10 additions & 10 deletions extension/sidepanel/panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ async function loadItems(skipCache = false) {
updateCounts();
renderItems();
} catch (err) {
itemsContainer.innerHTML = `<div class="error-msg">${err.message}</div>`;
itemsContainer.innerHTML = `<div class="error-msg">${escapeHtml(err.message)}</div>`;
}
}

Expand All @@ -134,7 +134,7 @@ async function loadThread(itemId) {
renderThread(response);
showThreadView();
} catch (err) {
threadMessages.innerHTML = `<div class="error-msg">${err.message}</div>`;
threadMessages.innerHTML = `<div class="error-msg">${escapeHtml(err.message)}</div>`;
}
}

Expand Down Expand Up @@ -191,18 +191,18 @@ function renderItems() {
const sourceHost = item.source_url ? (() => { try { return new URL(item.source_url).hostname; } catch { return ''; } })() : '';

return `
<div class="item-card" data-id="${item.id}">
<div class="item-card" data-id="${escapeHtml(String(item.id))}">
<div class="item-header">
<span class="item-type ${item.type}">${item.type}</span>
${item.priority !== 'normal' ? `<span class="item-priority ${priorityClass}">${item.priority}</span>` : ''}
<span class="item-priority">${item.status}</span>
<span class="item-type ${escapeHtml(item.type)}">${escapeHtml(item.type)}</span>
${item.priority !== 'normal' ? `<span class="item-priority ${priorityClass}">${escapeHtml(item.priority)}</span>` : ''}
<span class="item-priority">${escapeHtml(item.status)}</span>
</div>
${item.title ? `<div class="item-title">${escapeHtml(item.title)}</div>` : ''}
${item.quote ? `<div class="item-quote">${escapeHtml(item.quote)}</div>` : ''}
${sourceHost ? `<div class="item-source" title="${escapeHtml(item.source_url)}"><span class="source-icon">\ud83d\udcc4</span> ${escapeHtml(item.source_title || sourceHost)}</div>` : ''}
${renderDispatchBadges(item.dispatches)}
<div class="item-meta">
<span>${item.created_by}</span>
<span>${escapeHtml(item.created_by)}</span>
<span>${time}</span>
</div>
${tags.length > 0 ? `<div class="item-tags">${tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
Expand All @@ -224,8 +224,8 @@ function renderThread(item) {
threadHeader.innerHTML = `
<div class="item-card" style="cursor:default;margin-bottom:0;">
<div class="item-header">
<span class="item-type ${item.type}">${item.type}</span>
<span class="item-priority">${item.status}</span>
<span class="item-type ${escapeHtml(item.type)}">${escapeHtml(item.type)}</span>
<span class="item-priority">${escapeHtml(item.status)}</span>
</div>
${item.title ? `<div class="item-title">${escapeHtml(item.title)}</div>` : ''}
${item.quote ? `<div class="item-quote">${escapeHtml(item.quote)}</div>` : ''}
Expand Down Expand Up @@ -302,7 +302,7 @@ function formatTime(isoString) {

function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

// ------------------------------------------------------------------ dispatch display (#115)
Expand Down
44 changes: 31 additions & 13 deletions server/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -624,15 +624,15 @@ function initDb(dataDir) {
}

function getItemsByTag({ app_id, tag }) {
// SQLite JSON: tags is stored as '["bug","ui"]', search with LIKE
// Use json_each() for proper JSON array searching (avoids LIKE injection)
if (app_id) {
return db.prepare(
`SELECT * FROM items WHERE app_id = ? AND tags LIKE ? ORDER BY created_at DESC`
).all(app_id, `%"${tag}"%`);
`SELECT i.* FROM items i, json_each(i.tags) t WHERE i.app_id = ? AND t.value = ? ORDER BY i.created_at DESC`
).all(app_id, tag);
}
return db.prepare(
`SELECT * FROM items WHERE tags LIKE ? ORDER BY created_at DESC`
).all(`%"${tag}"%`);
`SELECT i.* FROM items i, json_each(i.tags) t WHERE t.value = ? ORDER BY i.created_at DESC`
).all(tag);
}

function getDistinctUrls(app_id = 'default') {
Expand Down Expand Up @@ -835,6 +835,19 @@ function initDb(dataDir) {

// ---------------------------------------------------- endpoint methods

/** Decrypt config field in an endpoints row (in-place). */
function decryptEndpointRow(row) {
if (!row) return row;
if (isEncrypted(row.config)) {
try {
row.config = decrypt(row.config);
} catch (err) {
throw new Error(`Failed to decrypt config for endpoint ${row.id}: ${err.message}. Check CLAWMARK_ENCRYPTION_KEY.`);
}
}
return row;
}

function createEndpoint({ user_name, name, type, config, is_default = 0 }) {
const now = new Date().toISOString();
const id = genId('ep');
Expand All @@ -850,36 +863,41 @@ function initDb(dataDir) {

endpointStmts.insertEndpoint.run({
id, user_name, name, type,
config: configStr,
config: encrypt(configStr),
is_default: shouldDefault,
created_at: now, updated_at: now,
});
return { id, user_name, name, type, config: configStr, is_default: shouldDefault, created_at: now, updated_at: now };
}

function getEndpoints(user_name) {
return endpointStmts.getEndpointsByUser.all(user_name);
return endpointStmts.getEndpointsByUser.all(user_name).map(decryptEndpointRow);
}

function getEndpoint(id) {
return endpointStmts.getEndpointById.get(id) || null;
return decryptEndpointRow(endpointStmts.getEndpointById.get(id)) || null;
}

function updateEndpoint(id, updates) {
const existing = endpointStmts.getEndpointById.get(id);
if (!existing) return null;
const now = new Date().toISOString();
let configStr;
if (updates.config) {
configStr = typeof updates.config === 'string' ? updates.config : JSON.stringify(updates.config);
configStr = encrypt(configStr);
} else {
configStr = existing.config; // already encrypted (or plaintext legacy)
}
const merged = {
id,
name: updates.name ?? existing.name,
type: updates.type ?? existing.type,
config: updates.config
? (typeof updates.config === 'string' ? updates.config : JSON.stringify(updates.config))
: existing.config,
config: configStr,
updated_at: now,
};
endpointStmts.updateEndpoint.run(merged);
return endpointStmts.getEndpointById.get(id);
return decryptEndpointRow(endpointStmts.getEndpointById.get(id));
}

function deleteEndpoint(id) {
Expand All @@ -903,7 +921,7 @@ function initDb(dataDir) {
const now = new Date().toISOString();
endpointStmts.clearDefaultForUser.run(now, existing.user_name);
endpointStmts.setDefault.run(now, id);
return endpointStmts.getEndpointById.get(id);
return decryptEndpointRow(endpointStmts.getEndpointById.get(id));
}

// ---------------------------------------------------------- app methods
Expand Down
Loading