Skip to content

Latest commit

 

History

History
1050 lines (855 loc) · 29.7 KB

File metadata and controls

1050 lines (855 loc) · 29.7 KB

MaxiSuite - Open Source Social Media Scheduler

Built in 4 hours. Saves $7,200/year vs Hootsuite.

A lightweight, self-hosted social media scheduler with password protection, multi-platform support (X/Twitter, LinkedIn, Nostr), and automated posting via cron.


Features

Multi-Platform Posting

  • X (Twitter) via OAuth 1.0a
  • LinkedIn via OAuth 2.0
  • Nostr via NIP-01

Security

  • Password-protected UI (SHA-256 hashing)
  • 24-hour session expiration
  • All pages require authentication

Queue Management

  • Schedule posts for specific dates/times
  • Filter by status (scheduled, posted, failed)
  • Filter by platform
  • Bulk import from calendar data

Calendar View

  • Month grid showing all scheduled posts
  • Click days to see post details
  • Visual indicators for multiple posts

Automated Posting

  • Cron-based scheduler (runs every 15 minutes)
  • Auto-posts at scheduled times
  • Updates post status automatically
  • Logs all activity

Architecture

Frontend: Static HTML + Vanilla JS (Tailwind CSS) Backend: Node.js scripts Storage: localStorage (UI) + JSON file (scheduler) Deployment: Netlify (UI) + Self-hosted (scheduler) Authentication: Client-side SHA-256 + session tokens


Setup Instructions

1. Clone Repository

git clone (your-repo-url)
cd maxisuite

2. Configure Credentials

Create credentials-config.md with your API keys:

## X (Twitter) OAuth 1.0a
- API Key: (insert your X API key)
- API Secret: (insert your X API secret)
- Access Token: (insert your X access token)
- Access Token Secret: (insert your X token secret)
- Account: @(insert your X handle)

## LinkedIn OAuth 2.0
- Client ID: (insert LinkedIn client ID)
- Client Secret: (insert LinkedIn client secret)
- Access Token: (insert LinkedIn access token)
- Redirect URI: https://(your-domain)/callback/linkedin
- User ID: (insert LinkedIn user ID from id_token)

## Nostr
- Private Key (nsec): (insert your Nostr private key)
- Public Key (npub): (insert your Nostr public key)
- Relays: wss://relay.damus.io, wss://relay.primal.net, (add more)

3. Set Password

Open set-password.html in browser:

  1. Enter desired password
  2. Click "Generate Hash"
  3. Copy the hash
  4. Update js/auth.js:
const VALID_PASSWORD_HASH = '(paste your hash here)';

4. Deploy UI to Netlify

# Connect to Netlify
netlify init

# Deploy
netlify deploy --prod

5. Setup Scheduler (Self-Hosted)

On your server (needs Node.js):

# Install dependencies (if any)
npm install

# Make scripts executable
chmod +x scheduler/*.mjs scheduler/*.sh

# Test scheduler
node scheduler/check-queue.mjs

# Add to cron (runs every 15 minutes)
crontab -e
# Add line:
*/15 * * * * cd /path/to/maxisuite && node scheduler/check-queue.mjs

File Structure

maxisuite/
├── index.html              # Composer (create posts)
├── queue.html              # Queue view (manage scheduled posts)
├── calendar.html           # Calendar view
├── login.html              # Login page
├── set-password.html       # Password setup utility
├── import-campaign.html    # Bulk import tool
│
├── js/
│   ├── auth.js            # Authentication logic
│   ├── queue.js           # Queue management
│   ├── calendar.js        # Calendar rendering
│   ├── auto-import.js     # Auto-import campaign data
│   └── campaign-status.js # Status banner
│
├── scheduler/
│   ├── check-queue.mjs    # Main scheduler (checks queue, posts)
│   ├── sync-queue.sh      # Initial queue sync
│   └── status.sh          # View queue status
│
├── api/
│   └── (platform-specific posting scripts)
│
└── netlify.toml           # Netlify config

Code: Authentication System

login.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>MaxiSuite - Login</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen flex items-center justify-center">
    <div class="max-w-md w-full mx-4">
        <div class="bg-gray-800 rounded-lg p-8 border border-gray-700">
            <div class="text-center mb-8">
                <span class="text-5xl">(your logo emoji)</span>
                <h1 class="text-3xl font-bold text-yellow-500 mt-2">MaxiSuite</h1>
                <p class="text-gray-400 mt-2">Social Media Scheduler</p>
            </div>

            <form id="login-form" class="space-y-4">
                <div>
                    <label class="block text-sm font-medium mb-2">Password</label>
                    <input 
                        type="password" 
                        id="password" 
                        class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-3 text-white focus:outline-none focus:border-yellow-500"
                        placeholder="Enter password"
                        autofocus
                    >
                </div>

                <button 
                    type="submit"
                    class="w-full bg-yellow-500 hover:bg-yellow-600 text-gray-900 font-bold py-3 rounded-lg transition"
                >
                    Login
                </button>

                <div id="error" class="hidden bg-red-900 border border-red-700 text-red-200 px-4 py-3 rounded text-sm">
                    Invalid password
                </div>
            </form>
        </div>
    </div>

    <script src="js/auth.js"></script>
</body>
</html>

js/auth.js

// MaxiSuite - Authentication
const AUTH_KEY = 'maxisuite-auth';

// Password hash (SHA-256 of the actual password)
// Generate via set-password.html
const VALID_PASSWORD_HASH = '(insert your password hash here)';

// Hash password using SHA-256
async function hashPassword(password) {
    const msgBuffer = new TextEncoder().encode(password);
    const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
    const hashArray = Array.from(new Uint8Array(hashBuffer));
    return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}

// Check if user is authenticated
function isAuthenticated() {
    const auth = localStorage.getItem(AUTH_KEY);
    if (!auth) return false;
    
    try {
        const data = JSON.parse(auth);
        // Session expires after 24 hours
        if (Date.now() - data.timestamp > 24 * 60 * 60 * 1000) {
            localStorage.removeItem(AUTH_KEY);
            return false;
        }
        return data.authenticated === true;
    } catch {
        return false;
    }
}

// Require authentication (call at top of every page)
function requireAuth() {
    if (!isAuthenticated() && !window.location.pathname.includes('login.html')) {
        window.location.href = 'login.html';
    }
}

// Handle login form
document.getElementById('login-form')?.addEventListener('submit', async (e) => {
    e.preventDefault();
    
    const password = document.getElementById('password').value;
    const hash = await hashPassword(password);
    
    if (hash === VALID_PASSWORD_HASH) {
        // Store authentication
        localStorage.setItem(AUTH_KEY, JSON.stringify({
            authenticated: true,
            timestamp: Date.now()
        }));
        
        // Redirect to dashboard
        window.location.href = 'index.html';
    } else {
        // Show error
        document.getElementById('error').classList.remove('hidden');
        document.getElementById('password').value = '';
        document.getElementById('password').focus();
    }
});

// Logout function
window.logout = function() {
    localStorage.removeItem(AUTH_KEY);
    window.location.href = 'login.html';
};

set-password.html (Password Setup Utility)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Set MaxiSuite Password</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-900 text-gray-100 p-8">
    <div class="max-w-2xl mx-auto">
        <h1 class="text-2xl font-bold mb-4">Set MaxiSuite Password</h1>
        <p class="text-gray-400 mb-6">Enter your desired password and click "Generate Hash"</p>
        
        <input 
            type="password" 
            id="password" 
            placeholder="Enter password"
            class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-3 text-white mb-4"
        >
        
        <button 
            onclick="generateHash()"
            class="bg-yellow-500 hover:bg-yellow-600 text-gray-900 font-bold px-6 py-3 rounded"
        >
            Generate Hash
        </button>
        
        <div id="output" class="hidden mt-6">
            <p class="text-gray-300 mb-2">Copy this hash and replace VALID_PASSWORD_HASH in js/auth.js:</p>
            <pre id="hash" class="bg-gray-800 p-4 rounded border border-gray-700 text-yellow-500 font-mono text-sm overflow-x-auto"></pre>
        </div>
    </div>
    
    <script>
        async function generateHash() {
            const password = document.getElementById('password').value;
            if (!password) {
                alert('Enter a password first!');
                return;
            }
            
            const msgBuffer = new TextEncoder().encode(password);
            const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
            const hashArray = Array.from(new Uint8Array(hashBuffer));
            const hash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
            
            document.getElementById('hash').textContent = hash;
            document.getElementById('output').classList.remove('hidden');
        }
    </script>
</body>
</html>

Code: Queue Management

queue.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Post Queue - MaxiSuite</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-900 text-gray-100">
    <nav class="bg-gray-800 border-b border-gray-700 px-6 py-4">
        <div class="max-w-7xl mx-auto flex items-center justify-between">
            <div class="flex items-center space-x-2">
                <span class="text-2xl">(logo)</span>
                <h1 class="text-xl font-bold text-yellow-500">MaxiSuite</h1>
            </div>
            <div class="flex space-x-6">
                <a href="index.html" class="text-gray-400 hover:text-white">Compose</a>
                <a href="queue.html" class="text-yellow-500 font-medium">Queue</a>
                <a href="calendar.html" class="text-gray-400 hover:text-white">Calendar</a>
                <button onclick="logout()" class="text-gray-400 hover:text-red-400 text-sm">Logout</button>
            </div>
        </div>
    </nav>

    <main class="max-w-7xl mx-auto px-6 py-8">
        <div class="flex items-center justify-between mb-6">
            <h2 class="text-2xl font-bold">Scheduled Posts</h2>
            <a href="index.html" class="bg-yellow-500 hover:bg-yellow-600 text-gray-900 font-bold px-4 py-2 rounded-lg">
                + New Post
            </a>
        </div>

        <!-- Filters -->
        <div class="bg-gray-800 rounded-lg p-4 mb-6">
            <div class="flex flex-wrap gap-4">
                <div>
                    <label class="text-sm text-gray-400 mb-1 block">Status</label>
                    <select id="filter-status" class="bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white">
                        <option value="all">All Statuses</option>
                        <option value="pending">Pending Approval</option>
                        <option value="scheduled" selected>Scheduled</option>
                        <option value="posted">Posted</option>
                        <option value="failed">Failed</option>
                    </select>
                </div>
                <div>
                    <label class="text-sm text-gray-400 mb-1 block">Platform</label>
                    <select id="filter-platform" class="bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white">
                        <option value="all">All Platforms</option>
                        <option value="x">X (Twitter)</option>
                        <option value="linkedin">LinkedIn</option>
                        <option value="nostr">Nostr</option>
                    </select>
                </div>
            </div>
        </div>

        <!-- Post Queue -->
        <div id="post-queue" class="space-y-4">
            <!-- Posts will be loaded here -->
        </div>
    </main>

    <script src="js/auth.js"></script>
    <script src="js/queue.js"></script>
    <script>requireAuth();</script>
</body>
</html>

js/queue.js

// MaxiSuite - Queue Management
const QUEUE_KEY = 'maxisuite-queue';

// Load queue from localStorage
function loadQueue() {
    const stored = localStorage.getItem(QUEUE_KEY);
    return stored ? JSON.parse(stored) : [];
}

// Save queue to localStorage
function saveQueue(queue) {
    localStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
}

// Render queue
function renderQueue() {
    const queue = loadQueue();
    const container = document.getElementById('post-queue');
    
    // Filter by status and platform
    const statusFilter = document.getElementById('filter-status').value;
    const platformFilter = document.getElementById('filter-platform').value;
    
    const filtered = queue.filter(post => {
        if (statusFilter !== 'all' && post.status !== statusFilter) return false;
        if (platformFilter !== 'all') {
            const hasPlatform = post.platforms[platformFilter];
            if (!hasPlatform) return false;
        }
        return true;
    });

    if (filtered.length === 0) {
        container.innerHTML = `
            <div class="text-center py-12 text-gray-500">
                <p class="text-lg">No posts match your filters</p>
            </div>
        `;
        return;
    }

    container.innerHTML = filtered.map(post => `
        <div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
            <div class="flex items-center justify-between mb-4">
                <div class="flex items-center space-x-2">
                    ${getStatusBadge(post.status)}
                    ${getPlatformBadges(post.platforms)}
                </div>
                <div class="text-sm text-gray-400">
                    ${formatDate(post.scheduledFor)}
                </div>
            </div>

            <p class="text-white mb-4">${escapeHtml(post.content)}</p>

            <div class="flex space-x-3">
                ${post.status === 'scheduled' ? `
                    <button onclick="postNow('${post.id}')" class="bg-yellow-600 hover:bg-yellow-500 text-gray-900 px-4 py-2 rounded text-sm font-medium">
                        Post Now
                    </button>
                ` : ''}
                <button onclick="deletePost('${post.id}')" class="bg-red-900 hover:bg-red-800 text-red-200 px-4 py-2 rounded text-sm font-medium">
                    Delete
                </button>
            </div>
        </div>
    `).join('');
}

// Status badge
function getStatusBadge(status) {
    const badges = {
        scheduled: '<span class="bg-blue-900 text-blue-200 px-3 py-1 rounded-full text-xs font-medium">🔵 SCHEDULED</span>',
        posted: '<span class="bg-green-900 text-green-200 px-3 py-1 rounded-full text-xs font-medium">✅ POSTED</span>',
        failed: '<span class="bg-red-900 text-red-200 px-3 py-1 rounded-full text-xs font-medium">❌ FAILED</span>'
    };
    return badges[status] || '';
}

// Platform badges
function getPlatformBadges(platforms) {
    const badges = [];
    if (platforms.x) badges.push('<span class="text-blue-400 text-sm">𝕏</span>');
    if (platforms.linkedin) badges.push('<span class="text-blue-500 text-sm">in</span>');
    if (platforms.nostr) badges.push('<span class="text-purple-400 text-sm">⚡</span>');
    return badges.join(' ');
}

// Format date
function formatDate(isoString) {
    const date = new Date(isoString);
    return date.toLocaleString('en-US', {
        month: 'short',
        day: 'numeric',
        hour: 'numeric',
        minute: '2-digit',
        hour12: true
    });
}

// Escape HTML
function escapeHtml(text) {
    const div = document.createElement('div');
    div.textContent = text;
    return div.innerHTML;
}

// Delete post
window.deletePost = function(id) {
    if (confirm('Delete this post permanently?')) {
        const queue = loadQueue();
        const updated = queue.filter(p => p.id !== id);
        saveQueue(updated);
        renderQueue();
    }
};

// Post now (placeholder - needs backend integration)
window.postNow = function(id) {
    alert('Post Now: Integrate with your backend scheduler');
};

// Event listeners
document.getElementById('filter-status')?.addEventListener('change', renderQueue);
document.getElementById('filter-platform')?.addEventListener('change', renderQueue);

// Initial render
renderQueue();

Code: Automated Scheduler

scheduler/check-queue.mjs

#!/usr/bin/env node
/**
 * MaxiSuite Scheduler - Check queue and post due items
 * Run via cron every 15 minutes
 */

import { readFileSync, writeFileSync, existsSync } from 'fs';
import { execSync } from 'child_process';
import { resolve } from 'path';

const QUEUE_FILE = resolve(process.env.HOME, '(path-to-your-queue-file)/maxisuite-queue.json');
const LOG_FILE = resolve(process.env.HOME, '(path-to-your-log-file)/maxisuite-scheduler.log');

function log(message) {
    const timestamp = new Date().toISOString();
    const logLine = `[${timestamp}] ${message}\n`;
    console.log(message);
    
    try {
        writeFileSync(LOG_FILE, logLine, { flag: 'a' });
    } catch (err) {
        console.error('Failed to write log:', err.message);
    }
}

function loadQueue() {
    if (!existsSync(QUEUE_FILE)) {
        log('Queue file does not exist');
        return [];
    }
    
    try {
        const data = readFileSync(QUEUE_FILE, 'utf-8');
        return JSON.parse(data);
    } catch (err) {
        log(`ERROR loading queue: ${err.message}`);
        return [];
    }
}

function saveQueue(queue) {
    try {
        writeFileSync(QUEUE_FILE, JSON.stringify(queue, null, 2));
        log('Queue saved successfully');
    } catch (err) {
        log(`ERROR saving queue: ${err.message}`);
    }
}

function shouldPost(scheduledTime) {
    const now = new Date();
    const scheduled = new Date(scheduledTime);
    
    // Post if scheduled time is within the last 15 minutes or is now/past
    const fifteenMinutesAgo = new Date(now.getTime() - 15 * 60 * 1000);
    
    return scheduled >= fifteenMinutesAgo && scheduled <= now;
}

function postToX(content) {
    try {
        // Replace with your X posting script
        const result = execSync(
            `node (path-to-x-post-script) "${content.replace(/"/g, '\\"')}"`,
            { encoding: 'utf-8', timeout: 30000 }
        );
        return { success: true, output: result };
    } catch (err) {
        return { success: false, error: err.message };
    }
}

function postToNostr(content) {
    try {
        // Replace with your Nostr posting script
        const result = execSync(
            `node (path-to-nostr-post-script) "${content.replace(/"/g, '\\"')}"`,
            { encoding: 'utf-8', timeout: 30000 }
        );
        return { success: true, output: result };
    } catch (err) {
        return { success: false, error: err.message };
    }
}

function postToLinkedIn(content) {
    try {
        // Replace with your LinkedIn posting script
        // Implementation varies based on your auth setup
        return { success: true };
    } catch (err) {
        return { success: false, error: err.message };
    }
}

// Main execution
log('=== MaxiSuite Scheduler Check ===');

const queue = loadQueue();
const dueNow = queue.filter(post => post.status === 'scheduled' && shouldPost(post.scheduledFor));

log(`Checked queue: ${queue.length} total posts, ${dueNow.length} due now`);

if (dueNow.length === 0) {
    log('No posts due, exiting');
    process.exit(0);
}

// Process each due post
for (const post of dueNow) {
    log(`Processing post ${post.id}: "${post.content.substring(0, 50)}..."`);
    
    const results = {};
    let allSucceeded = true;
    
    // Post to each platform
    if (post.platforms.x) {
        log('  → Posting to X...');
        const result = postToX(post.content);
        results.x = result;
        if (result.success) {
            log('  ✅ X posted successfully');
        } else {
            log(`  ❌ X failed: ${result.error}`);
            allSucceeded = false;
        }
    }
    
    if (post.platforms.nostr) {
        log('  → Posting to Nostr...');
        const result = postToNostr(post.content);
        results.nostr = result;
        if (result.success) {
            log('  ✅ Nostr posted successfully');
        } else {
            log(`  ❌ Nostr failed: ${result.error}`);
            allSucceeded = false;
        }
    }
    
    if (post.platforms.linkedin) {
        log('  → Posting to LinkedIn...');
        const result = postToLinkedIn(post.content);
        results.linkedin = result;
        if (result.success) {
            log('  ✅ LinkedIn posted successfully');
        } else {
            log(`  ❌ LinkedIn failed: ${result.error}`);
            allSucceeded = false;
        }
    }
    
    // Update post status
    post.status = allSucceeded ? 'posted' : 'failed';
    post.postedAt = new Date().toISOString();
    post.results = results;
}

// Save updated queue
saveQueue(queue);

log(`=== Scheduler Complete: ${dueNow.length} posts processed ===`);

Platform Integration Examples

X (Twitter) OAuth 1.0a

// x-post.mjs
import crypto from 'crypto';
import https from 'https';

const config = {
    apiKey: '(your X API key)',
    apiSecret: '(your X API secret)',
    accessToken: '(your X access token)',
    accessTokenSecret: '(your X token secret)'
};

function generateOAuthSignature(method, url, params, consumerSecret, tokenSecret) {
    const sortedParams = Object.keys(params).sort().map(key => 
        `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`
    ).join('&');
    
    const baseString = `${method}&${encodeURIComponent(url)}&${encodeURIComponent(sortedParams)}`;
    const signingKey = `${encodeURIComponent(consumerSecret)}&${encodeURIComponent(tokenSecret)}`;
    
    return crypto.createHmac('sha1', signingKey).update(baseString).digest('base64');
}

function postTweet(text) {
    const url = 'https://api.twitter.com/2/tweets';
    const timestamp = Math.floor(Date.now() / 1000);
    const nonce = crypto.randomBytes(32).toString('hex');
    
    const oauthParams = {
        oauth_consumer_key: config.apiKey,
        oauth_token: config.accessToken,
        oauth_signature_method: 'HMAC-SHA1',
        oauth_timestamp: timestamp,
        oauth_nonce: nonce,
        oauth_version: '1.0'
    };
    
    const signature = generateOAuthSignature('POST', url, oauthParams, config.apiSecret, config.accessTokenSecret);
    oauthParams.oauth_signature = signature;
    
    const authHeader = 'OAuth ' + Object.keys(oauthParams).map(key => 
        `${encodeURIComponent(key)}="${encodeURIComponent(oauthParams[key])}"`
    ).join(', ');
    
    const postData = JSON.stringify({ text });
    
    return new Promise((resolve, reject) => {
        const req = https.request(url, {
            method: 'POST',
            headers: {
                'Authorization': authHeader,
                'Content-Type': 'application/json',
                'Content-Length': Buffer.byteLength(postData)
            }
        }, (res) => {
            let data = '';
            res.on('data', chunk => data += chunk);
            res.on('end', () => {
                if (res.statusCode === 201) {
                    resolve(JSON.parse(data));
                } else {
                    reject(new Error(`Failed: ${data}`));
                }
            });
        });
        
        req.on('error', reject);
        req.write(postData);
        req.end();
    });
}

// CLI usage
const text = process.argv[2];
if (!text) {
    console.error('Usage: node x-post.mjs "Your tweet text"');
    process.exit(1);
}

postTweet(text)
    .then(result => console.log('✅ Posted:', result))
    .catch(err => console.error('❌ Error:', err.message));

Nostr NIP-01

// nostr-post.mjs
import { Relay } from 'nostr-tools/relay';
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';

const config = {
    privateKey: '(your Nostr nsec as hex)',
    relays: [
        'wss://relay.damus.io',
        'wss://relay.primal.net',
        'wss://nos.lol'
    ]
};

async function postToNostr(content) {
    const event = {
        kind: 1,
        created_at: Math.floor(Date.now() / 1000),
        tags: [],
        content: content,
        pubkey: getPublicKey(config.privateKey)
    };
    
    const signedEvent = finalizeEvent(event, config.privateKey);
    
    const results = [];
    for (const relayUrl of config.relays) {
        try {
            const relay = await Relay.connect(relayUrl);
            await relay.publish(signedEvent);
            relay.close();
            results.push({ relay: relayUrl, success: true });
        } catch (err) {
            results.push({ relay: relayUrl, success: false, error: err.message });
        }
    }
    
    return results;
}

// CLI usage
const content = process.argv[2];
if (!content) {
    console.error('Usage: node nostr-post.mjs "Your note text"');
    process.exit(1);
}

postToNostr(content)
    .then(results => console.log('✅ Posted:', results))
    .catch(err => console.error('❌ Error:', err.message));

Deployment: Netlify Configuration

netlify.toml

[build]
  publish = "."
  command = "echo 'MaxiSuite MVP'"

# Redirect for LinkedIn OAuth callback
[[redirects]]
  from = "/callback/linkedin"
  to = "/callback/linkedin.html"
  status = 200

# API endpoints (if using Netlify Functions)
[[redirects]]
  from = "/api/*"
  to = "/.netlify/functions/:splat"
  status = 200

# Security headers
[[headers]]
  for = "/*"
  [headers.values]
    X-Frame-Options = "DENY"
    X-Content-Type-Options = "nosniff"
    X-XSS-Protection = "1; mode=block"

Security Considerations

1. Password Protection

  • SHA-256 hashing (client-side)
  • 24-hour session expiration
  • No plaintext passwords stored
  • requireAuth() on all pages

2. API Keys

  • Never commit credentials to git
  • Use environment variables or secure config files
  • Add credentials file to .gitignore

3. HTTPS Only

  • Deploy UI on Netlify (automatic HTTPS)
  • Use HTTPS for all API calls
  • Validate SSL certificates

4. Rate Limiting

  • Twitter: 50 tweets per 24h (user context)
  • LinkedIn: Varies by product tier
  • Nostr: No rate limits (but relay-dependent)

5. Data Storage

  • localStorage (browser) for UI state
  • JSON file (server) for scheduler queue
  • Keep credentials separate from queue data

Customization Guide

1. Branding

Replace placeholders:

  • (your logo emoji) → Your logo
  • (insert company name) → Your company
  • Color scheme in Tailwind classes

2. Platforms

Add/remove platforms in:

  • queue.html filters
  • scheduler/check-queue.mjs posting logic
  • Platform badges in queue.js

3. Scheduling Frequency

Adjust cron interval in crontab:

  • Every 15 min: */15 * * * *
  • Every hour: 0 * * * *
  • Every 30 min: */30 * * * *

4. Queue Storage

Options:

  • localStorage (current): Simple, client-side only
  • JSON file (scheduler): Server-side, persistent
  • Database: PostgreSQL, MongoDB for scale
  • Cloud storage: S3, Firebase for distributed teams

Troubleshooting

Login Not Working

  • Check password hash matches in js/auth.js
  • Verify set-password.html generated correct hash
  • Clear browser localStorage and try again

Posts Not Auto-Posting

  • Check cron job is running: crontab -l
  • Verify scheduler has execute permissions: chmod +x scheduler/*.mjs
  • Check logs: tail -f (log-file-path)
  • Test manually: node scheduler/check-queue.mjs

Platform API Errors

  • X: Check API tier limits, verify OAuth credentials
  • LinkedIn: Ensure "Sign In with LinkedIn" product added
  • Nostr: Verify relays are reachable, check private key format

Queue Not Syncing

  • UI (localStorage) and scheduler (JSON) are separate
  • Manual sync needed or build API bridge
  • Consider using API endpoints to share queue state

Cost Comparison

Hootsuite Professional:

  • $99/month × 3 users = $297/month
  • $3,564/year

MaxiSuite Self-Hosted:

  • Server: $5-20/month (DigitalOcean, Linode)
  • Netlify: Free tier
  • Total: ~$240/year

Savings: $3,324/year (for 3-user setup)


Roadmap / Future Features

  • Auto-cleanup (delete old posts after 30 days)
  • Multi-account support per platform
  • Thread/carousel support (X)
  • Image upload support
  • Analytics dashboard
  • WhatsApp approval workflow
  • Real-time queue sync (websockets)
  • Team collaboration features
  • Post templates
  • AI content suggestions

Contributing

This project was built in 4 hours as a Hootsuite replacement. It's functional but rough around the edges.

Contributions welcome:

  • Bug fixes
  • Platform integrations
  • UI improvements
  • Documentation

License

(Choose your license: MIT, Apache 2.0, GPL, etc.)


Credits

Built by: (your name/organization) Inspired by: The need for affordable, self-hosted social media scheduling Built with: Node.js, Vanilla JS, Tailwind CSS, ☕


Support

For questions, issues, or contributions:

  • Email: (your email)
  • GitHub: (your repo)
  • Twitter: (your handle)

Build in hours. Save thousands. Own your infrastructure.