Initial commit: iOS OTA app store
This commit is contained in:
42
src/auth.js
Normal file
42
src/auth.js
Normal 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
39
src/db.js
Normal 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
78
src/ipa-parser.js
Normal 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
36
src/manifest.js
Normal 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
259
src/server.js
Normal 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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user