commit ad2850d6648c25d450c9b876d500cd53058381e2 Author: trey Date: Sat Apr 11 11:40:44 2026 -0500 Initial commit: iOS OTA app store diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f7c0555 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +data +.env +.env.example +.git diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b5a7712 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Web UI password +ADMIN_PASSWORD=changeme + +# API upload token (use for CLI/automation uploads) +API_TOKEN=changeme-generate-a-real-token + +# Session secret +SESSION_SECRET=changeme-random-string + +# Base URL (your domain with https) +BASE_URL=https://appstore.example.com + +# Port +PORT=3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f77d70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +data/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b2ee929 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM node:20-alpine + +RUN apk add --no-cache python3 make g++ vips-dev + +WORKDIR /app + +COPY package.json ./ +RUN npm install --production + +COPY src/ ./src/ +COPY public/ ./public/ +COPY views/ ./views/ + +ENV DATA_DIR=/data +ENV PORT=3000 + +EXPOSE 3000 + +CMD ["node", "src/server.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..781c7e0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + app: + build: . + container_name: ios-appstore + restart: unless-stopped + ports: + - "3080:3000" + environment: + - ADMIN_PASSWORD=${ADMIN_PASSWORD} + - API_TOKEN=${API_TOKEN} + - SESSION_SECRET=${SESSION_SECRET} + - BASE_URL=https://appstore.treytartt.com + - DATA_DIR=/data + volumes: + - /mnt/user/downloads/ios-appstore:/data diff --git a/package.json b/package.json new file mode 100644 index 0000000..982eca0 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "ios-appstore", + "version": "1.0.0", + "description": "Self-hosted iOS OTA app distribution server", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js" + }, + "dependencies": { + "better-sqlite3": "^11.7.0", + "express": "^4.21.0", + "express-session": "^1.18.1", + "multer": "^1.4.5-lts.1", + "plist": "^3.1.0", + "unzipper": "^0.12.3", + "bcrypt": "^5.1.1", + "uuid": "^10.0.0", + "sharp": "^0.33.5" + } +} diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..1f7109b --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,374 @@ +* { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0a0a0a; + --surface: #1a1a1a; + --surface-hover: #222; + --border: #333; + --text: #f5f5f5; + --text-muted: #888; + --accent: #007AFF; + --accent-hover: #0066d6; + --danger: #ff3b30; + --success: #30d158; + --radius: 12px; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', system-ui, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + -webkit-font-smoothing: antialiased; +} + +/* Header */ +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-bottom: 1px solid var(--border); + background: var(--surface); + position: sticky; + top: 0; + z-index: 10; +} + +header h1 { font-size: 20px; font-weight: 700; } + +nav { display: flex; gap: 8px; } + +nav a { + color: var(--text-muted); + text-decoration: none; + padding: 8px 16px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + transition: all 0.15s; +} + +nav a:hover, nav a.active { + color: var(--text); + background: var(--bg); +} + +nav a.logout { color: var(--danger); } +nav a.logout:hover { background: rgba(255,59,48,0.1); } + +/* Main */ +main { max-width: 900px; margin: 0 auto; padding: 24px; } + +/* App Grid */ +.app-grid { display: flex; flex-direction: column; gap: 12px; } + +.app-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + display: flex; + align-items: center; + gap: 16px; + cursor: pointer; + transition: all 0.15s; +} + +.app-card:hover { background: var(--surface-hover); border-color: #444; } + +.app-icon { + width: 60px; + height: 60px; + border-radius: 14px; + background: #2a2a2a; + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + flex-shrink: 0; + overflow: hidden; +} + +.app-icon img { width: 100%; height: 100%; object-fit: cover; } + +.app-info { flex: 1; min-width: 0; } +.app-name { font-weight: 600; font-size: 16px; } +.app-bundle { color: var(--text-muted); font-size: 13px; margin-top: 2px; } +.app-version { color: var(--text-muted); font-size: 13px; margin-top: 4px; } + +.app-action { flex-shrink: 0; } + +.btn, button[type="submit"] { + background: var(--accent); + color: white; + border: none; + padding: 10px 24px; + border-radius: 20px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; + text-decoration: none; + display: inline-block; +} + +.btn:hover, button[type="submit"]:hover { background: var(--accent-hover); } +.btn-sm { padding: 6px 16px; font-size: 13px; } +.btn-danger { background: var(--danger); } +.btn-danger:hover { background: #e0342a; } + +.install-btn { + background: var(--accent); + color: white; + border: none; + padding: 8px 20px; + border-radius: 20px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + text-decoration: none; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 80px 20px; + color: var(--text-muted); +} + +.empty-state p { font-size: 18px; margin-bottom: 16px; } + +/* Login */ +.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; +} + +.login-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 40px; + width: 320px; + text-align: center; +} + +.login-icon { font-size: 48px; margin-bottom: 12px; } +.login-card h1 { font-size: 24px; margin-bottom: 4px; } +.login-card .subtitle { color: var(--text-muted); margin-bottom: 24px; font-size: 14px; } + +.login-card input { + width: 100%; + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); + color: var(--text); + font-size: 16px; + margin-bottom: 12px; + outline: none; +} + +.login-card input:focus { border-color: var(--accent); } + +.login-card button { + width: 100%; + padding: 12px; + border-radius: 10px; +} + +.login-card .error { color: var(--danger); font-size: 14px; margin-bottom: 8px; } + +/* Upload */ +.upload-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; +} + +.upload-card h2 { margin-bottom: 16px; font-size: 20px; } + +.drop-zone { + border: 2px dashed var(--border); + border-radius: var(--radius); + padding: 48px 24px; + text-align: center; + cursor: pointer; + transition: all 0.15s; + position: relative; +} + +.drop-zone:hover, .drop-zone.drag-over { + border-color: var(--accent); + background: rgba(0,122,255,0.05); +} + +.drop-zone input { position: absolute; inset: 0; opacity: 0; cursor: pointer; } +.drop-icon { font-size: 32px; color: var(--text-muted); margin-bottom: 8px; } +.drop-zone p { color: var(--text-muted); font-size: 14px; } + +.file-info { + display: flex; + justify-content: space-between; + padding: 12px 0; + font-size: 14px; + color: var(--text-muted); +} + +textarea { + width: 100%; + padding: 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); + color: var(--text); + font-size: 14px; + font-family: inherit; + resize: vertical; + margin-bottom: 12px; + outline: none; +} + +textarea:focus { border-color: var(--accent); } + +.upload-card button[type="submit"] { width: 100%; } + +.progress { + height: 4px; + background: var(--border); + border-radius: 2px; + margin-top: 12px; + overflow: hidden; +} + +.progress-bar { + height: 100%; + background: var(--accent); + width: 0; + transition: width 0.2s; +} + +.result { margin-top: 16px; padding: 12px; border-radius: 10px; font-size: 14px; } +.result.success { background: rgba(48,209,88,0.1); color: var(--success); } +.result.error { background: rgba(255,59,48,0.1); color: var(--danger); } +.result a { color: var(--accent); } + +.api-info { + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid var(--border); +} + +.api-info h3 { font-size: 14px; color: var(--text-muted); margin-bottom: 8px; } + +.api-info pre { + background: var(--bg); + padding: 12px; + border-radius: 8px; + overflow-x: auto; + font-size: 12px; + color: var(--text-muted); + line-height: 1.5; +} + +/* Modal */ +.modal { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: 24px; +} + +.modal-content { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + max-width: 500px; + width: 100%; + max-height: 80vh; + overflow-y: auto; + padding: 24px; + position: relative; +} + +.modal-close { + position: absolute; + top: 12px; + right: 16px; + background: none; + border: none; + color: var(--text-muted); + font-size: 24px; + cursor: pointer; + padding: 4px; +} + +.modal-close:hover { color: var(--text); } + +.modal-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; +} + +.modal-header .app-icon { width: 64px; height: 64px; } + +.build-list { display: flex; flex-direction: column; gap: 8px; } + +.build-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + background: var(--bg); + border-radius: 10px; +} + +.build-meta { flex: 1; } +.build-version { font-weight: 600; font-size: 14px; } +.build-date { color: var(--text-muted); font-size: 12px; margin-top: 2px; } +.build-notes { color: var(--text-muted); font-size: 12px; margin-top: 4px; font-style: italic; } +.build-size { color: var(--text-muted); font-size: 12px; } + +.build-actions { display: flex; gap: 8px; align-items: center; } + +.delete-btn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 16px; + padding: 4px 8px; + border-radius: 6px; +} + +.delete-btn:hover { color: var(--danger); background: rgba(255,59,48,0.1); } + +.modal-footer { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border); + text-align: right; +} + +/* Responsive */ +@media (max-width: 600px) { + header { padding: 12px 16px; } + header h1 { font-size: 17px; } + nav a { padding: 6px 10px; font-size: 13px; } + main { padding: 16px; } + .app-card { padding: 12px; gap: 12px; } + .app-icon { width: 48px; height: 48px; border-radius: 11px; font-size: 22px; } + .app-name { font-size: 15px; } + .upload-card { padding: 16px; } + .drop-zone { padding: 32px 16px; } +} diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..45cde3a --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,121 @@ +document.addEventListener('DOMContentLoaded', loadApps); + +async function loadApps() { + const res = await fetch('/api/apps'); + if (res.status === 401) { location.href = '/login'; return; } + const apps = await res.json(); + + const grid = document.getElementById('apps'); + const empty = document.getElementById('empty'); + + if (apps.length === 0) { + grid.style.display = 'none'; + empty.style.display = 'block'; + return; + } + + grid.innerHTML = apps.map(app => ` +
+
+ ${app.latest_icon + ? `${app.name}` + : app.name.charAt(0).toUpperCase()} +
+
+
${esc(app.name)}
+
${esc(app.bundle_id)}
+
v${esc(app.latest_version || '?')}${app.latest_build_number ? ` (${esc(app.latest_build_number)})` : ''} · ${timeAgo(app.latest_uploaded_at)}
+
+
+ Install +
+
+ `).join(''); +} + +async function showApp(appId) { + const res = await fetch(`/api/apps/${appId}`); + const app = await res.json(); + + const modal = document.getElementById('modal'); + const body = document.getElementById('modal-body'); + + const icon = app.builds[0]?.icon_filename; + + body.innerHTML = ` + +

Builds (${app.builds.length})

+
+ ${app.builds.map(b => ` +
+
+
v${esc(b.version)} (${esc(b.build_number || '?')})
+
${new Date(b.uploaded_at + 'Z').toLocaleDateString()} · ${formatSize(b.size)}
+ ${b.notes ? `
${esc(b.notes)}
` : ''} +
+
+ Install + +
+
+ `).join('')} +
+ + `; + + modal.style.display = 'flex'; + modal.onclick = (e) => { if (e.target === modal) closeModal(); }; +} + +function closeModal() { + document.getElementById('modal').style.display = 'none'; +} + +async function deleteBuild(buildId, appId) { + if (!confirm('Delete this build?')) return; + await fetch(`/api/builds/${buildId}`, { method: 'DELETE' }); + closeModal(); + loadApps(); +} + +async function deleteApp(appId) { + if (!confirm('Delete this app and all its builds?')) return; + await fetch(`/api/apps/${appId}`, { method: 'DELETE' }); + closeModal(); + loadApps(); +} + +function esc(s) { + if (!s) return ''; + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; +} + +function formatSize(bytes) { + if (!bytes) return ''; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; +} + +function timeAgo(dateStr) { + if (!dateStr) return ''; + const d = new Date(dateStr + 'Z'); + const now = new Date(); + const diff = (now - d) / 1000; + if (diff < 60) return 'just now'; + if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; + if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; + if (diff < 604800) return Math.floor(diff / 86400) + 'd ago'; + return d.toLocaleDateString(); +} diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..d160cc2 --- /dev/null +++ b/src/auth.js @@ -0,0 +1,42 @@ +const bcrypt = require('bcrypt'); + +const API_TOKEN = process.env.API_TOKEN; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; + +// Middleware: require valid session (web UI) +function requireLogin(req, res, next) { + if (req.session && req.session.authenticated) { + return next(); + } + if (req.headers.accept?.includes('json')) { + return res.status(401).json({ error: 'Not authenticated' }); + } + res.redirect('/login'); +} + +// Middleware: require API token (CLI uploads) +function requireToken(req, res, next) { + const token = req.headers['x-api-token'] || req.query.token; + if (token && token === API_TOKEN) { + return next(); + } + res.status(401).json({ error: 'Invalid or missing API token' }); +} + +// Middleware: require either session or API token +function requireAuth(req, res, next) { + const token = req.headers['x-api-token'] || req.query.token; + if (token && token === API_TOKEN) { + return next(); + } + if (req.session && req.session.authenticated) { + return next(); + } + res.status(401).json({ error: 'Not authenticated' }); +} + +function validatePassword(password) { + return password === ADMIN_PASSWORD; +} + +module.exports = { requireLogin, requireToken, requireAuth, validatePassword }; diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..43c5d28 --- /dev/null +++ b/src/db.js @@ -0,0 +1,39 @@ +const Database = require('better-sqlite3'); +const path = require('path'); + +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data'); +const db = new Database(path.join(DATA_DIR, 'appstore.db')); + +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +db.exec(` + CREATE TABLE IF NOT EXISTS apps ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + bundle_id TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS builds ( + id TEXT PRIMARY KEY, + app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, + version TEXT NOT NULL, + build_number TEXT, + min_os_version TEXT, + filename TEXT NOT NULL, + size INTEGER, + icon_filename TEXT, + notes TEXT, + uploaded_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS devices ( + udid TEXT PRIMARY KEY, + name TEXT, + model TEXT, + added_at TEXT DEFAULT (datetime('now')) + ); +`); + +module.exports = db; diff --git a/src/ipa-parser.js b/src/ipa-parser.js new file mode 100644 index 0000000..5f3aa63 --- /dev/null +++ b/src/ipa-parser.js @@ -0,0 +1,78 @@ +const unzipper = require('unzipper'); +const plist = require('plist'); +const fs = require('fs'); +const path = require('path'); +const sharp = require('sharp'); + +async function parseIPA(ipaPath, outputDir) { + const directory = await unzipper.Open.file(ipaPath); + + // Find Info.plist inside the .app bundle + const infoPlistEntry = directory.files.find(f => + /^Payload\/[^/]+\.app\/Info\.plist$/.test(f.path) && f.type === 'File' + ); + + if (!infoPlistEntry) { + throw new Error('Could not find Info.plist in IPA'); + } + + const plistBuffer = await infoPlistEntry.buffer(); + let info; + try { + info = plist.parse(plistBuffer.toString('utf-8')); + } catch { + // Binary plist — try to parse differently + throw new Error('Binary plist detected. Please ensure IPA contains XML plist.'); + } + + const metadata = { + bundleId: info.CFBundleIdentifier, + name: info.CFBundleDisplayName || info.CFBundleName || 'Unknown', + version: info.CFBundleShortVersionString || '1.0', + buildNumber: info.CFBundleVersion || '1', + minOSVersion: info.MinimumOSVersion || '15.0', + }; + + // Try to extract app icon + let iconFilename = null; + const appPrefix = infoPlistEntry.path.replace('Info.plist', ''); + + // Look for icon files in order of preference + const iconNames = []; + const iconFiles = info.CFBundleIcons?.CFBundlePrimaryIcon?.CFBundleIconFiles || []; + for (const name of iconFiles) { + iconNames.push(`${name}@3x.png`, `${name}@2x.png`, `${name}.png`); + } + iconNames.push('AppIcon60x60@3x.png', 'AppIcon60x60@2x.png', 'AppIcon76x76@2x.png'); + + for (const iconName of iconNames) { + const iconEntry = directory.files.find(f => + f.path === `${appPrefix}${iconName}` && f.type === 'File' + ); + if (iconEntry) { + try { + const iconBuffer = await iconEntry.buffer(); + iconFilename = `icon_${Date.now()}.png`; + const outputPath = path.join(outputDir, iconFilename); + + // iOS icons use CgBI format — sharp can usually handle them + // If it fails, save raw and it'll still work in most browsers + try { + await sharp(iconBuffer) + .resize(180, 180) + .png() + .toFile(outputPath); + } catch { + fs.writeFileSync(outputPath, iconBuffer); + } + break; + } catch { + iconFilename = null; + } + } + } + + return { metadata, iconFilename }; +} + +module.exports = { parseIPA }; diff --git a/src/manifest.js b/src/manifest.js new file mode 100644 index 0000000..48fcb84 --- /dev/null +++ b/src/manifest.js @@ -0,0 +1,36 @@ +const plist = require('plist'); + +function generateManifest({ baseUrl, build, app }) { + const manifest = { + items: [{ + assets: [ + { + kind: 'software-package', + url: `${baseUrl}/api/download/${build.id}`, + }, + ], + metadata: { + 'bundle-identifier': app.bundle_id, + 'bundle-version': build.version, + kind: 'software', + title: app.name, + }, + }], + }; + + // Add icon asset if available + if (build.icon_filename) { + manifest.items[0].assets.push({ + kind: 'display-image', + url: `${baseUrl}/icons/${build.icon_filename}`, + }); + manifest.items[0].assets.push({ + kind: 'full-size-image', + url: `${baseUrl}/icons/${build.icon_filename}`, + }); + } + + return plist.build(manifest); +} + +module.exports = { generateManifest }; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..b7a4ba5 --- /dev/null +++ b/src/server.js @@ -0,0 +1,259 @@ +const express = require('express'); +const session = require('express-session'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const { v4: uuidv4 } = require('uuid'); + +const db = require('./db'); +const { parseIPA } = require('./ipa-parser'); +const { generateManifest } = require('./manifest'); +const { requireLogin, requireToken, requireAuth, validatePassword } = require('./auth'); + +const app = express(); +const PORT = process.env.PORT || 3000; +const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`; +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data'); +const IPA_DIR = path.join(DATA_DIR, 'ipas'); +const ICON_DIR = path.join(DATA_DIR, 'icons'); + +// Ensure directories exist +[IPA_DIR, ICON_DIR].forEach(dir => fs.mkdirSync(dir, { recursive: true })); + +// Multer config +const upload = multer({ + dest: path.join(DATA_DIR, 'tmp'), + limits: { fileSize: 500 * 1024 * 1024 }, // 500MB + fileFilter: (req, file, cb) => { + if (path.extname(file.originalname).toLowerCase() === '.ipa') { + cb(null, true); + } else { + cb(new Error('Only .ipa files are allowed')); + } + }, +}); + +// Middleware +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(session({ + secret: process.env.SESSION_SECRET || 'dev-secret-change-me', + resave: false, + saveUninitialized: false, + cookie: { maxAge: 7 * 24 * 60 * 60 * 1000 }, // 7 days +})); +app.use(express.static(path.join(__dirname, '..', 'public'))); +app.use('/icons', express.static(ICON_DIR)); + +// --- Auth Routes --- + +app.get('/login', (req, res) => { + if (req.session.authenticated) return res.redirect('/'); + res.sendFile(path.join(__dirname, '..', 'views', 'login.html')); +}); + +app.post('/login', (req, res) => { + if (validatePassword(req.body.password)) { + req.session.authenticated = true; + res.redirect('/'); + } else { + res.redirect('/login?error=1'); + } +}); + +app.get('/logout', (req, res) => { + req.session.destroy(); + res.redirect('/login'); +}); + +// --- Web UI Routes --- + +app.get('/', requireLogin, (req, res) => { + res.sendFile(path.join(__dirname, '..', 'views', 'index.html')); +}); + +app.get('/upload', requireLogin, (req, res) => { + res.sendFile(path.join(__dirname, '..', 'views', 'upload.html')); +}); + +// --- API Routes --- + +// List all apps with their latest build +app.get('/api/apps', requireAuth, (req, res) => { + const apps = db.prepare(` + SELECT a.*, + b.id as latest_build_id, b.version as latest_version, + b.build_number as latest_build_number, b.uploaded_at as latest_uploaded_at, + b.icon_filename as latest_icon, b.notes as latest_notes + FROM apps a + LEFT JOIN builds b ON b.id = ( + SELECT id FROM builds WHERE app_id = a.id ORDER BY uploaded_at DESC LIMIT 1 + ) + ORDER BY a.name + `).all(); + res.json(apps); +}); + +// Get single app with all builds +app.get('/api/apps/:id', requireAuth, (req, res) => { + const app = db.prepare('SELECT * FROM apps WHERE id = ?').get(req.params.id); + if (!app) return res.status(404).json({ error: 'App not found' }); + + const builds = db.prepare( + 'SELECT * FROM builds WHERE app_id = ? ORDER BY uploaded_at DESC' + ).all(req.params.id); + + res.json({ ...app, builds }); +}); + +// Delete app and all its builds +app.delete('/api/apps/:id', requireAuth, (req, res) => { + const builds = db.prepare('SELECT * FROM builds WHERE app_id = ?').all(req.params.id); + for (const build of builds) { + const ipaPath = path.join(IPA_DIR, build.filename); + if (fs.existsSync(ipaPath)) fs.unlinkSync(ipaPath); + if (build.icon_filename) { + const iconPath = path.join(ICON_DIR, build.icon_filename); + if (fs.existsSync(iconPath)) fs.unlinkSync(iconPath); + } + } + db.prepare('DELETE FROM apps WHERE id = ?').run(req.params.id); + res.json({ success: true }); +}); + +// Upload IPA — works from both web UI and CLI +app.post('/api/upload', requireAuth, upload.single('ipa'), async (req, res) => { + if (!req.file) { + return res.status(400).json({ error: 'No IPA file provided' }); + } + + try { + const { metadata, iconFilename } = await parseIPA(req.file.path, ICON_DIR); + const buildId = uuidv4(); + const ipaFilename = `${buildId}.ipa`; + + // Move IPA to storage + fs.renameSync(req.file.path, path.join(IPA_DIR, ipaFilename)); + + // Find or create app + let app = db.prepare('SELECT * FROM apps WHERE bundle_id = ?').get(metadata.bundleId); + if (!app) { + const appId = uuidv4(); + db.prepare('INSERT INTO apps (id, name, bundle_id) VALUES (?, ?, ?)').run( + appId, metadata.name, metadata.bundleId + ); + app = { id: appId, name: metadata.name, bundle_id: metadata.bundleId }; + } + + // Update app name if it changed + if (app.name !== metadata.name) { + db.prepare('UPDATE apps SET name = ? WHERE id = ?').run(metadata.name, app.id); + } + + // Create build record + const notes = req.body.notes || null; + const fileSize = fs.statSync(path.join(IPA_DIR, ipaFilename)).size; + + db.prepare(` + INSERT INTO builds (id, app_id, version, build_number, min_os_version, filename, size, icon_filename, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(buildId, app.id, metadata.version, metadata.buildNumber, metadata.minOSVersion, ipaFilename, fileSize, iconFilename, notes); + + res.json({ + success: true, + app: { id: app.id, name: app.name, bundle_id: app.bundle_id }, + build: { + id: buildId, + version: metadata.version, + build_number: metadata.buildNumber, + install_url: `itms-services://?action=download-manifest&url=${encodeURIComponent(BASE_URL + '/api/manifest/' + buildId)}`, + }, + }); + } catch (err) { + // Clean up temp file on error + if (fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path); + console.error('Upload error:', err); + res.status(500).json({ error: err.message }); + } +}); + +// Generate manifest plist for a build (must be publicly accessible for iOS to fetch) +app.get('/api/manifest/:buildId', (req, res) => { + const build = db.prepare('SELECT * FROM builds WHERE id = ?').get(req.params.buildId); + if (!build) return res.status(404).send('Build not found'); + + const app = db.prepare('SELECT * FROM apps WHERE id = ?').get(build.app_id); + if (!app) return res.status(404).send('App not found'); + + const xml = generateManifest({ baseUrl: BASE_URL, build, app }); + res.set('Content-Type', 'text/xml'); + res.send(xml); +}); + +// Download IPA file (must be accessible for iOS install) +app.get('/api/download/:buildId', (req, res) => { + const build = db.prepare('SELECT * FROM builds WHERE id = ?').get(req.params.buildId); + if (!build) return res.status(404).json({ error: 'Build not found' }); + + const ipaPath = path.join(IPA_DIR, build.filename); + if (!fs.existsSync(ipaPath)) return res.status(404).json({ error: 'IPA file missing' }); + + res.download(ipaPath, `${build.filename}`); +}); + +// Delete a specific build +app.delete('/api/builds/:id', requireAuth, (req, res) => { + const build = db.prepare('SELECT * FROM builds WHERE id = ?').get(req.params.id); + if (!build) return res.status(404).json({ error: 'Build not found' }); + + const ipaPath = path.join(IPA_DIR, build.filename); + if (fs.existsSync(ipaPath)) fs.unlinkSync(ipaPath); + if (build.icon_filename) { + const iconPath = path.join(ICON_DIR, build.icon_filename); + if (fs.existsSync(iconPath)) fs.unlinkSync(iconPath); + } + + db.prepare('DELETE FROM builds WHERE id = ?').run(req.params.id); + + // Delete app if no builds remain + const remaining = db.prepare('SELECT COUNT(*) as count FROM builds WHERE app_id = ?').get(build.app_id); + if (remaining.count === 0) { + db.prepare('DELETE FROM apps WHERE id = ?').run(build.app_id); + } + + res.json({ success: true }); +}); + +// --- Device Management --- + +app.get('/api/devices', requireAuth, (req, res) => { + const devices = db.prepare('SELECT * FROM devices ORDER BY added_at DESC').all(); + res.json(devices); +}); + +app.post('/api/devices', requireAuth, (req, res) => { + const { udid, name, model } = req.body; + if (!udid) return res.status(400).json({ error: 'UDID is required' }); + + db.prepare(` + INSERT INTO devices (udid, name, model) VALUES (?, ?, ?) + ON CONFLICT(udid) DO UPDATE SET name = excluded.name, model = excluded.model + `).run(udid, name || null, model || null); + + res.json({ success: true }); +}); + +app.delete('/api/devices/:udid', requireAuth, (req, res) => { + db.prepare('DELETE FROM devices WHERE udid = ?').run(req.params.udid); + res.json({ success: true }); +}); + +// --- Health --- +app.get('/api/health', (req, res) => { + res.json({ status: 'ok', version: '1.0.0' }); +}); + +app.listen(PORT, '0.0.0.0', () => { + console.log(`iOS App Store running on port ${PORT}`); + console.log(`Base URL: ${BASE_URL}`); +}); diff --git a/views/index.html b/views/index.html new file mode 100644 index 0000000..0f55a48 --- /dev/null +++ b/views/index.html @@ -0,0 +1,39 @@ + + + + + + App Store + + + +
+
+

App Store

+
+ +
+ +
+
+ + + + +
+ + + + diff --git a/views/login.html b/views/login.html new file mode 100644 index 0000000..f3f7800 --- /dev/null +++ b/views/login.html @@ -0,0 +1,29 @@ + + + + + + App Store - Login + + + +
+ +

App Store

+

OTA Distribution

+
+ + +
+ +
+ + diff --git a/views/upload.html b/views/upload.html new file mode 100644 index 0000000..27aa2e2 --- /dev/null +++ b/views/upload.html @@ -0,0 +1,138 @@ + + + + + + Upload - App Store + + + +
+
+

App Store

+
+ +
+ +
+
+

Upload IPA

+
+
+
+
+

Drop .ipa file here or tap to browse

+ +
+ + + + + +
+ +
+

CLI Upload

+
curl -X POST ${location.origin}/api/upload \
+  -H "X-Api-Token: YOUR_TOKEN" \
+  -F "ipa=@path/to/app.ipa" \
+  -F "notes=Build notes"
+
+
+
+ + + +