Initial commit: iOS OTA app store

This commit is contained in:
trey
2026-04-11 11:40:44 -05:00
commit ad2850d664
16 changed files with 1232 additions and 0 deletions

42
src/auth.js Normal file
View File

@@ -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 };

39
src/db.js Normal file
View File

@@ -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;

78
src/ipa-parser.js Normal file
View File

@@ -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 };

36
src/manifest.js Normal file
View File

@@ -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 };

259
src/server.js Normal file
View File

@@ -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}`);
});