Skip to content
Merged
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
9 changes: 3 additions & 6 deletions plugin/notifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,24 +105,21 @@ export async function sendPermissionNotification({
tags: ['lock'],
actions: [
{
action: 'http',
action: 'view',
label: 'Allow Once',
url: `${callbackUrl}?nonce=${nonce}&response=once`,
method: 'POST',
clear: true,
},
{
action: 'http',
action: 'view',
label: 'Allow Always',
url: `${callbackUrl}?nonce=${nonce}&response=always`,
method: 'POST',
clear: true,
},
{
action: 'http',
action: 'view',
label: 'Reject',
url: `${callbackUrl}?nonce=${nonce}&response=reject`,
method: 'POST',
clear: true,
},
],
Expand Down
60 changes: 48 additions & 12 deletions service/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,40 @@ const sessions = new Map()
// Valid response types
const VALID_RESPONSES = ['once', 'always', 'reject']

/**
* Generate a simple HTML response page
* @param {string} title - Page title
* @param {string} message - Message to display
* @param {boolean} success - Whether the operation succeeded
* @returns {string} HTML content
*/
function htmlResponse(title, message, success) {
const color = success ? '#22c55e' : '#ef4444'
const icon = success ? '✓' : '✗'
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title} - opencode-ntfy</title>
<style>
body { font-family: -apple-system, system-ui, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #1a1a1a; color: #fff; }
.container { text-align: center; padding: 2rem; }
.icon { font-size: 4rem; color: ${color}; }
.message { font-size: 1.5rem; margin-top: 1rem; }
.hint { color: #888; margin-top: 1rem; font-size: 0.9rem; }
</style>
</head>
<body>
<div class="container">
<div class="icon">${icon}</div>
<div class="message">${message}</div>
<div class="hint">You can close this tab</div>
</div>
</body>
</html>`
}

/**
* Create a nonce for a permission request
* @param {string} sessionId - OpenCode session ID
Expand Down Expand Up @@ -141,41 +175,43 @@ function createCallbackServer(port) {
return
}

// POST /callback - Permission response from ntfy
if (req.method === 'POST' && url.pathname === '/callback') {
// GET or POST /callback - Permission response from ntfy
// GET is used by 'view' actions (opens in browser), POST by 'http' actions
if ((req.method === 'GET' || req.method === 'POST') && url.pathname === '/callback') {
const nonce = url.searchParams.get('nonce')
const response = url.searchParams.get('response')

// Validate required params
if (!nonce || !response) {
res.writeHead(400, { 'Content-Type': 'text/plain' })
res.end('Missing required parameters')
res.writeHead(400, { 'Content-Type': 'text/html' })
res.end(htmlResponse('Error', 'Missing required parameters', false))
return
}

// Validate response value
if (!VALID_RESPONSES.includes(response)) {
res.writeHead(400, { 'Content-Type': 'text/plain' })
res.end('Invalid response value')
res.writeHead(400, { 'Content-Type': 'text/html' })
res.end(htmlResponse('Error', 'Invalid response value', false))
return
}

// Validate and consume nonce
const payload = consumeNonce(nonce)
if (!payload) {
res.writeHead(401, { 'Content-Type': 'text/plain' })
res.end('Invalid or expired nonce')
res.writeHead(401, { 'Content-Type': 'text/html' })
res.end(htmlResponse('Error', 'Invalid or expired nonce', false))
return
}

// Forward to session
const sent = sendToSession(payload.sessionId, payload.permissionId, response)
if (sent) {
res.writeHead(200, { 'Content-Type': 'text/plain' })
res.end('OK')
const actionLabel = response === 'once' ? 'Allowed (once)' : response === 'always' ? 'Allowed (always)' : 'Rejected'
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(htmlResponse('Done', actionLabel, true))
} else {
res.writeHead(503, { 'Content-Type': 'text/plain' })
res.end('Session not connected')
res.writeHead(503, { 'Content-Type': 'text/html' })
res.end(htmlResponse('Error', 'Session not connected', false))
}
return
}
Expand Down
12 changes: 6 additions & 6 deletions test/test_notifier.bash
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,11 @@ test_permission_notification_has_reject_action() {
}
}

test_permission_notification_uses_http_action_type() {
# ntfy actions should be HTTP type for callbacks
grep -q '"http"' "$PLUGIN_DIR/notifier.js" || \
grep -q "'http'" "$PLUGIN_DIR/notifier.js" || {
echo "HTTP action type not found in notifier.js"
test_permission_notification_uses_view_action_type() {
# ntfy actions should be view type for callbacks (opens in browser, request comes from phone)
grep -q '"view"' "$PLUGIN_DIR/notifier.js" || \
grep -q "'view'" "$PLUGIN_DIR/notifier.js" || {
echo "view action type not found in notifier.js"
return 1
}
}
Expand Down Expand Up @@ -307,7 +307,7 @@ for test_func in \
test_permission_notification_has_allow_once_action \
test_permission_notification_has_allow_always_action \
test_permission_notification_has_reject_action \
test_permission_notification_uses_http_action_type \
test_permission_notification_uses_view_action_type \
test_permission_notification_uses_callback_url \
test_permission_notification_includes_nonce \
test_permission_notification_includes_response_param \
Expand Down