implement first draft of sprockets

This commit is contained in:
2025-10-09 19:09:37 +01:00
parent 09b00c76ed
commit d2d0821d19
20 changed files with 3075 additions and 4 deletions

View File

@@ -36,6 +36,16 @@
// Events filter toggle
let showOnlyMyEvents = false;
// Sprocket management state
let sprocketScript = '';
let sprocketStatus = null;
let sprocketVersions = [];
let isLoadingSprocket = false;
let sprocketMessage = '';
let sprocketMessageType = 'info';
let sprocketEnabled = false;
let sprocketUploadFile = null;
// Kind name mapping based on repository kind definitions
const kindNames = {
0: "ProfileMetadata",
@@ -261,6 +271,9 @@
// Load persistent app state
loadPersistentState();
// Load sprocket configuration
loadSprocketConfig();
}
function savePersistentState() {
@@ -356,6 +369,287 @@
savePersistentState();
}
// Sprocket management functions
async function loadSprocketConfig() {
try {
const response = await fetch('/api/sprocket/config', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
const config = await response.json();
sprocketEnabled = config.enabled;
}
} catch (error) {
console.error('Error loading sprocket config:', error);
}
}
async function loadSprocketStatus() {
if (!isLoggedIn || userRole !== 'owner' || !sprocketEnabled) return;
try {
isLoadingSprocket = true;
const response = await fetch('/api/sprocket/status', {
method: 'GET',
headers: {
'Authorization': `Nostr ${await createNIP98Auth('GET', '/api/sprocket/status')}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
sprocketStatus = await response.json();
} else {
showSprocketMessage('Failed to load sprocket status', 'error');
}
} catch (error) {
showSprocketMessage(`Error loading sprocket status: ${error.message}`, 'error');
} finally {
isLoadingSprocket = false;
}
}
async function loadSprocket() {
if (!isLoggedIn || userRole !== 'owner') return;
try {
isLoadingSprocket = true;
const response = await fetch('/api/sprocket/status', {
method: 'GET',
headers: {
'Authorization': `Nostr ${await createNIP98Auth('GET', '/api/sprocket/status')}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const status = await response.json();
sprocketScript = status.script_content || '';
sprocketStatus = status;
showSprocketMessage('Script loaded successfully', 'success');
} else {
showSprocketMessage('Failed to load script', 'error');
}
} catch (error) {
showSprocketMessage(`Error loading script: ${error.message}`, 'error');
} finally {
isLoadingSprocket = false;
}
}
async function saveSprocket() {
if (!isLoggedIn || userRole !== 'owner') return;
try {
isLoadingSprocket = true;
const response = await fetch('/api/sprocket/update', {
method: 'POST',
headers: {
'Authorization': `Nostr ${await createNIP98Auth('POST', '/api/sprocket/update')}`,
'Content-Type': 'text/plain'
},
body: sprocketScript
});
if (response.ok) {
showSprocketMessage('Script saved and updated successfully', 'success');
await loadSprocketStatus();
await loadVersions();
} else {
const errorText = await response.text();
showSprocketMessage(`Failed to save script: ${errorText}`, 'error');
}
} catch (error) {
showSprocketMessage(`Error saving script: ${error.message}`, 'error');
} finally {
isLoadingSprocket = false;
}
}
async function restartSprocket() {
if (!isLoggedIn || userRole !== 'owner') return;
try {
isLoadingSprocket = true;
const response = await fetch('/api/sprocket/restart', {
method: 'POST',
headers: {
'Authorization': `Nostr ${await createNIP98Auth('POST', '/api/sprocket/restart')}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
showSprocketMessage('Sprocket restarted successfully', 'success');
await loadSprocketStatus();
} else {
const errorText = await response.text();
showSprocketMessage(`Failed to restart sprocket: ${errorText}`, 'error');
}
} catch (error) {
showSprocketMessage(`Error restarting sprocket: ${error.message}`, 'error');
} finally {
isLoadingSprocket = false;
}
}
async function deleteSprocket() {
if (!isLoggedIn || userRole !== 'owner') return;
if (!confirm('Are you sure you want to delete the sprocket script? This will stop the current process.')) {
return;
}
try {
isLoadingSprocket = true;
const response = await fetch('/api/sprocket/update', {
method: 'POST',
headers: {
'Authorization': `Nostr ${await createNIP98Auth('POST', '/api/sprocket/update')}`,
'Content-Type': 'text/plain'
},
body: '' // Empty body deletes the script
});
if (response.ok) {
sprocketScript = '';
showSprocketMessage('Sprocket script deleted successfully', 'success');
await loadSprocketStatus();
await loadVersions();
} else {
const errorText = await response.text();
showSprocketMessage(`Failed to delete script: ${errorText}`, 'error');
}
} catch (error) {
showSprocketMessage(`Error deleting script: ${error.message}`, 'error');
} finally {
isLoadingSprocket = false;
}
}
async function loadVersions() {
if (!isLoggedIn || userRole !== 'owner') return;
try {
isLoadingSprocket = true;
const response = await fetch('/api/sprocket/versions', {
method: 'GET',
headers: {
'Authorization': `Nostr ${await createNIP98Auth('GET', '/api/sprocket/versions')}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
sprocketVersions = await response.json();
} else {
showSprocketMessage('Failed to load versions', 'error');
}
} catch (error) {
showSprocketMessage(`Error loading versions: ${error.message}`, 'error');
} finally {
isLoadingSprocket = false;
}
}
async function loadVersion(version) {
if (!isLoggedIn || userRole !== 'owner') return;
sprocketScript = version.content;
showSprocketMessage(`Loaded version: ${version.name}`, 'success');
}
async function deleteVersion(filename) {
if (!isLoggedIn || userRole !== 'owner') return;
if (!confirm(`Are you sure you want to delete version ${filename}?`)) {
return;
}
try {
isLoadingSprocket = true;
const response = await fetch('/api/sprocket/delete-version', {
method: 'POST',
headers: {
'Authorization': `Nostr ${await createNIP98Auth('POST', '/api/sprocket/delete-version')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ filename })
});
if (response.ok) {
showSprocketMessage(`Version ${filename} deleted successfully`, 'success');
await loadVersions();
} else {
const errorText = await response.text();
showSprocketMessage(`Failed to delete version: ${errorText}`, 'error');
}
} catch (error) {
showSprocketMessage(`Error deleting version: ${error.message}`, 'error');
} finally {
isLoadingSprocket = false;
}
}
function showSprocketMessage(message, type = 'info') {
sprocketMessage = message;
sprocketMessageType = type;
// Auto-hide message after 5 seconds
setTimeout(() => {
sprocketMessage = '';
}, 5000);
}
function handleSprocketFileSelect(event) {
sprocketUploadFile = event.target.files[0];
}
async function uploadSprocketScript() {
if (!isLoggedIn || userRole !== 'owner' || !sprocketUploadFile) return;
try {
isLoadingSprocket = true;
// Read the file content
const fileContent = await sprocketUploadFile.text();
// Upload the script
const response = await fetch('/api/sprocket/update', {
method: 'POST',
headers: {
'Authorization': `Nostr ${await createNIP98Auth('POST', '/api/sprocket/update')}`,
'Content-Type': 'text/plain'
},
body: fileContent
});
if (response.ok) {
sprocketScript = fileContent;
showSprocketMessage('Script uploaded and updated successfully', 'success');
await loadSprocketStatus();
await loadVersions();
} else {
const errorText = await response.text();
showSprocketMessage(`Failed to upload script: ${errorText}`, 'error');
}
} catch (error) {
showSprocketMessage(`Error uploading script: ${error.message}`, 'error');
} finally {
isLoadingSprocket = false;
sprocketUploadFile = null;
// Clear the file input
const fileInput = document.getElementById('sprocket-upload-file');
if (fileInput) {
fileInput.value = '';
}
}
}
const baseTabs = [
{id: 'export', icon: '📤', label: 'Export'},
{id: 'import', icon: '💾', label: 'Import', requiresAdmin: true},
@@ -371,6 +665,10 @@
if (tab.requiresOwner && (!isLoggedIn || userRole !== 'owner')) {
return false;
}
// Hide sprocket tab if not enabled
if (tab.id === 'sprocket' && !sprocketEnabled) {
return false;
}
return true;
});
@@ -379,6 +677,11 @@
function selectTab(tabId) {
selectedTab = tabId;
// Load sprocket data when switching to sprocket tab
if (tabId === 'sprocket' && isLoggedIn && userRole === 'owner' && sprocketEnabled) {
loadSprocketStatus();
loadVersions();
}
savePersistentState();
}
@@ -883,6 +1186,50 @@
return `Nostr ${base64Event}`;
}
// NIP-98 authentication helper (for sprocket functions)
async function createNIP98Auth(method, url) {
if (!isLoggedIn || !userPubkey) {
throw new Error('Not logged in');
}
// Create NIP-98 auth event
const authEvent = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', window.location.origin + url],
['method', method.toUpperCase()]
],
content: '',
pubkey: userPubkey
};
let signedEvent;
if (userSigner && authMethod === 'extension') {
// Use the signer from the extension
try {
signedEvent = await userSigner.signEvent(authEvent);
} catch (error) {
throw new Error('Failed to sign with extension: ' + error.message);
}
} else if (authMethod === 'nsec') {
// For nsec method, we need to implement proper signing
// For now, create a mock signature (in production, use proper crypto)
authEvent.id = 'mock-id-' + Date.now();
authEvent.sig = 'mock-signature-' + Date.now();
signedEvent = authEvent;
} else {
throw new Error('No valid signer available');
}
// Encode as base64
const eventJson = JSON.stringify(signedEvent);
const base64Event = btoa(eventJson);
return base64Event;
}
</script>
<!-- Header -->
@@ -1088,6 +1435,132 @@
</div>
{/if}
</div>
{:else if selectedTab === 'sprocket'}
<div class="sprocket-view">
<h2>Sprocket Script Management</h2>
{#if isLoggedIn && userRole === 'owner'}
<div class="sprocket-section">
<div class="sprocket-header">
<h3>Script Editor</h3>
<div class="sprocket-controls">
<button class="sprocket-btn restart-btn" on:click={restartSprocket} disabled={isLoadingSprocket}>
🔄 Restart
</button>
<button class="sprocket-btn delete-btn" on:click={deleteSprocket} disabled={isLoadingSprocket || !sprocketStatus?.script_exists}>
🗑️ Delete Script
</button>
</div>
</div>
<div class="sprocket-upload-section">
<h4>Upload Script</h4>
<div class="upload-controls">
<input
type="file"
id="sprocket-upload-file"
accept=".sh,.bash"
on:change={handleSprocketFileSelect}
disabled={isLoadingSprocket}
/>
<button
class="sprocket-btn upload-btn"
on:click={uploadSprocketScript}
disabled={isLoadingSprocket || !sprocketUploadFile}
>
📤 Upload & Update
</button>
</div>
</div>
<div class="sprocket-status">
<div class="status-item">
<span class="status-label">Status:</span>
<span class="status-value" class:running={sprocketStatus?.is_running}>
{sprocketStatus?.is_running ? '🟢 Running' : '🔴 Stopped'}
</span>
</div>
{#if sprocketStatus?.pid}
<div class="status-item">
<span class="status-label">PID:</span>
<span class="status-value">{sprocketStatus.pid}</span>
</div>
{/if}
<div class="status-item">
<span class="status-label">Script:</span>
<span class="status-value">{sprocketStatus?.script_exists ? '✅ Exists' : '❌ Not found'}</span>
</div>
</div>
<div class="script-editor-container">
<textarea
class="script-editor"
bind:value={sprocketScript}
placeholder="#!/bin/bash&#10;# Enter your sprocket script here..."
disabled={isLoadingSprocket}
></textarea>
</div>
<div class="script-actions">
<button class="sprocket-btn save-btn" on:click={saveSprocket} disabled={isLoadingSprocket}>
💾 Save & Update
</button>
<button class="sprocket-btn load-btn" on:click={loadSprocket} disabled={isLoadingSprocket}>
📥 Load Current
</button>
</div>
{#if sprocketMessage}
<div class="sprocket-message" class:error={sprocketMessageType === 'error'}>
{sprocketMessage}
</div>
{/if}
</div>
<div class="sprocket-section">
<h3>Script Versions</h3>
<div class="versions-list">
{#each sprocketVersions as version}
<div class="version-item" class:current={version.is_current}>
<div class="version-info">
<div class="version-name">{version.name}</div>
<div class="version-date">
{new Date(version.modified).toLocaleString()}
{#if version.is_current}
<span class="current-badge">Current</span>
{/if}
</div>
</div>
<div class="version-actions">
<button class="version-btn load-btn" on:click={() => loadVersion(version)} disabled={isLoadingSprocket}>
📥 Load
</button>
{#if !version.is_current}
<button class="version-btn delete-btn" on:click={() => deleteVersion(version.name)} disabled={isLoadingSprocket}>
🗑️ Delete
</button>
{/if}
</div>
</div>
{/each}
</div>
<button class="sprocket-btn refresh-btn" on:click={loadVersions} disabled={isLoadingSprocket}>
🔄 Refresh Versions
</button>
</div>
{:else if isLoggedIn}
<div class="permission-denied">
<p>❌ Owner permission required for sprocket management.</p>
<p>To enable sprocket functionality, set the <code>ORLY_OWNERS</code> environment variable with your npub when starting the relay.</p>
<p>Current user role: <strong>{userRole || 'none'}</strong></p>
</div>
{:else}
<div class="login-prompt">
<p>Please log in to access sprocket management.</p>
<button class="login-btn" on:click={openLoginModal}>Log In</button>
</div>
{/if}
</div>
{:else}
<div class="welcome-message">
{#if isLoggedIn}
@@ -1443,8 +1916,314 @@
.welcome-message p {
font-size: 1.2rem;
margin: 0;
}
/* Sprocket Styles */
.sprocket-view {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.sprocket-section {
background-color: var(--card-bg);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid var(--border-color);
}
.sprocket-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.sprocket-controls {
display: flex;
gap: 0.5rem;
}
.sprocket-upload-section {
margin-bottom: 1rem;
padding: 1rem;
background-color: var(--bg-color);
border-radius: 6px;
border: 1px solid var(--border-color);
}
.sprocket-upload-section h4 {
margin: 0 0 0.75rem 0;
color: var(--text-color);
font-size: 1rem;
font-weight: 500;
}
.upload-controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
.upload-controls input[type="file"] {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-color);
color: var(--text-color);
font-size: 0.9rem;
}
.sprocket-btn.upload-btn {
background-color: #8b5cf6;
color: white;
}
.sprocket-btn.upload-btn:hover:not(:disabled) {
background-color: #7c3aed;
}
.sprocket-status {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
padding: 0.75rem;
background-color: var(--bg-color);
border-radius: 6px;
border: 1px solid var(--border-color);
}
.status-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.status-label {
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 500;
}
.status-value {
font-size: 0.9rem;
font-weight: 600;
}
.status-value.running {
color: #22c55e;
}
.script-editor-container {
margin-bottom: 1rem;
}
.script-editor {
width: 100%;
height: 300px;
padding: 1rem;
border: 1px solid var(--border-color);
border-radius: 6px;
background-color: var(--bg-color);
color: var(--text-color);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9rem;
line-height: 1.4;
resize: vertical;
outline: none;
}
.script-editor:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.script-editor:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.script-actions {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.sprocket-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.sprocket-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.sprocket-btn.save-btn {
background-color: #22c55e;
color: white;
}
.sprocket-btn.save-btn:hover:not(:disabled) {
background-color: #16a34a;
}
.sprocket-btn.load-btn {
background-color: #3b82f6;
color: white;
}
.sprocket-btn.load-btn:hover:not(:disabled) {
background-color: #2563eb;
}
.sprocket-btn.restart-btn {
background-color: #f59e0b;
color: white;
}
.sprocket-btn.restart-btn:hover:not(:disabled) {
background-color: #d97706;
}
.sprocket-btn.delete-btn {
background-color: #ef4444;
color: white;
}
.sprocket-btn.delete-btn:hover:not(:disabled) {
background-color: #dc2626;
}
.sprocket-btn.refresh-btn {
background-color: #6b7280;
color: white;
}
.sprocket-btn.refresh-btn:hover:not(:disabled) {
background-color: #4b5563;
}
.sprocket-message {
padding: 0.75rem;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
background-color: #dbeafe;
color: #1e40af;
border: 1px solid #93c5fd;
}
.sprocket-message.error {
background-color: #fee2e2;
color: #dc2626;
border-color: #fca5a5;
}
.versions-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
.version-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background-color: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 6px;
transition: all 0.2s ease;
}
.version-item.current {
border-color: var(--primary-color);
background-color: rgba(59, 130, 246, 0.05);
}
.version-item:hover {
border-color: var(--primary-color);
}
.version-info {
flex: 1;
}
.version-name {
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.version-date {
font-size: 0.8rem;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 0.5rem;
}
.current-badge {
background-color: var(--primary-color);
color: white;
padding: 0.125rem 0.5rem;
border-radius: 12px;
font-size: 0.7rem;
font-weight: 500;
}
.version-actions {
display: flex;
gap: 0.5rem;
}
.version-btn {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.25rem;
}
.version-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.version-btn.load-btn {
background-color: #3b82f6;
color: white;
}
.version-btn.load-btn:hover:not(:disabled) {
background-color: #2563eb;
}
.version-btn.delete-btn {
background-color: #ef4444;
color: white;
}
.version-btn.delete-btn:hover:not(:disabled) {
background-color: #dc2626;
}
@media (max-width: 640px) {