/** * LogShip Mobile (Android) release channel — server-side storage + signed download URLs. * * Files live OUTSIDE the build in `data/mobile-releases/` on the host (git-ignored, preserved by * the deploy script's DATA_EXTRA list) so a new APK can be published at any time without an ERP * deployment — via the admin/service upload endpoint (server/api/settings/mobile-app/publish.post.ts) * or by simply dropping the files into the directory (read on every request, never cached). * * Layout (identical to the old logyou.de/logship web root so it can be seeded 1:1): * version.json { versionName, versionCode, apkUrl: 'logship-mobile.apk', releaseNotes, * releaseDate, minRequiredVersion, sha256?, size? } * history.json [ { versionName, versionCode, releaseDate, releaseNotes, apkUrl: 'history/…' } ] * logship-mobile.apk current build * history/logship-mobile--.apk archived builds * * Downloads: Android's DownloadManager (AppUpdatePlugin.java) sends NO auth header, so the download * URL itself is the credential — `/api/public/mobile/./`, HMAC-SHA256 over * `|` with MOBILE_APP_DOWNLOAD_SECRET. The manifest routes (session-gated) embed * such URLs with a short TTL; the QR install page gets a longer one. */ import { createHmac, createHash, timingSafeEqual } from 'crypto' import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, statSync, readdirSync } from 'fs' import path from 'path' import fetchHelper from './fetchHelper' import { isMobileWorkerUser } from './mobileWorkerHelper' export const CURRENT_APK = 'logship-mobile.apk' export const HISTORY_SUBDIR = 'history' export interface ReleaseManifest { versionName: string versionCode: number apkUrl: string releaseNotes?: string releaseDate?: string minRequiredVersion?: string sha256?: string size?: number } export interface HistoryEntry { versionName: string versionCode: number releaseDate?: string releaseNotes?: string apkUrl: string sha256?: string size?: number } export const releasesDir = () => path.join(process.cwd(), 'data', 'mobile-releases') export const historyDir = () => path.join(releasesDir(), HISTORY_SUBDIR) const readJson = (file: string, fallback: T): T => { try { if (!existsSync(file)) return fallback return JSON.parse(readFileSync(file, 'utf8')) as T } catch (err: any) { console.warn('[MobileReleases] cannot read', file, err?.message || err) return fallback } } export const readManifest = (): ReleaseManifest | null => { const m = readJson(path.join(releasesDir(), 'version.json'), null) if (!m || !m.versionName) return null return { ...m, versionCode: Number(m.versionCode) || 0, apkUrl: m.apkUrl || CURRENT_APK } } export const readHistory = (): HistoryEntry[] => { const list = readJson(path.join(releasesDir(), 'history.json'), []) return Array.isArray(list) ? list : [] } /** * Only two shapes are ever served: the current APK and archived ones in history/. * Returns the absolute path or null (also when the file does not exist). */ export const safeApkPath = (rel: string): string | null => { const r = String(rel || '').replace(/^\/+/, '') let abs: string | null = null if (r === CURRENT_APK) abs = path.join(releasesDir(), CURRENT_APK) else { const m = /^history\/([A-Za-z0-9._-]+\.apk)$/.exec(r) if (m && !m[1].includes('..')) abs = path.join(historyDir(), m[1]) } if (!abs || !existsSync(abs)) return null return abs } // -------------------------------------------------------------------------- // Signed URLs // -------------------------------------------------------------------------- const cfg = () => { const c: any = useRuntimeConfig() return { secret: String(c.mobileApp?.downloadSecret || ''), publicBase: String(c.mobileApp?.publicBase || 'https://app.logship.de').replace(/\/+$/, ''), keepHistory: Number(c.mobileApp?.keepHistory ?? 10), legacyFallback: c.mobileApp?.legacyFallback !== false } } const hmac = (secret: string, expires: number, rel: string) => createHmac('sha256', secret).update(`${expires}|${rel}`).digest('hex') export const isDownloadSecretConfigured = () => !!cfg().secret /** Absolute, expiring download URL for a relative APK path (`logship-mobile.apk` or `history/x.apk`). */ export const signDownloadUrl = (rel: string, ttlSec: number): string => { const { secret, publicBase } = cfg() if (!secret) throw new Error('MOBILE_APP_DOWNLOAD_SECRET is not configured') const expires = Math.floor(Date.now() / 1000) + Math.max(60, ttlSec) const token = `${expires}.${hmac(secret, expires, rel)}` return `${publicBase}/api/public/mobile/${token}/${rel}` } export const verifyDownloadToken = (token: string, rel: string): { ok: boolean, reason?: string } => { const { secret } = cfg() if (!secret) return { ok: false, reason: 'not configured' } const m = /^(\d{1,12})\.([a-f0-9]{64})$/.exec(String(token || '')) if (!m) return { ok: false, reason: 'malformed' } const expires = Number(m[1]) if (expires < Math.floor(Date.now() / 1000)) return { ok: false, reason: 'expired' } const expected = Buffer.from(hmac(secret, expires, rel), 'hex') const given = Buffer.from(m[2], 'hex') if (expected.length !== given.length || !timingSafeEqual(expected, given)) return { ok: false, reason: 'invalid' } return { ok: true } } export const TTL_MANIFEST = 6 * 3600 export const TTL_INSTALL_PAGE = 24 * 3600 export const manifestWithSignedUrl = (m: ReleaseManifest, ttl = TTL_MANIFEST): ReleaseManifest => ({ ...m, apkUrl: signDownloadUrl(CURRENT_APK, ttl) }) export const historyWithSignedUrls = (list: HistoryEntry[], ttl = TTL_MANIFEST): HistoryEntry[] => list .filter(e => e && e.apkUrl) .map(e => ({ ...e, apkUrl: safeApkPath(e.apkUrl) ? signDownloadUrl(String(e.apkUrl).replace(/^\/+/, ''), ttl) : e.apkUrl })) export const legacyFallbackEnabled = () => cfg().legacyFallback // -------------------------------------------------------------------------- // Publishing // -------------------------------------------------------------------------- export interface PublishInput { apk: Buffer versionName: string versionCode: number releaseNotes?: string releaseDate?: string minRequiredVersion?: string force?: boolean } let publishLock: Promise = Promise.resolve() const sha256Of = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex') const writeJsonAtomic = (file: string, data: any) => { const tmp = `${file}.${process.pid}.tmp` writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n') renameSync(tmp, file) } /** * Publish a new build: archive the current APK into history/ (+ history.json entry, pruned to * keepHistory), write the new APK, then version.json LAST so a device never sees a manifest * pointing at a half-written file. Serialised per process. */ export const publishRelease = (input: PublishInput): Promise<{ manifest: ReleaseManifest, archived: string | null }> => { const run = async () => { const versionName = String(input.versionName || '').trim() const versionCode = Number(input.versionCode) if (!/^\d+(\.\d+){1,3}$/.test(versionName)) throw new Error(`Invalid versionName "${versionName}" (expected e.g. 1.0.41)`) if (!Number.isInteger(versionCode) || versionCode <= 0) throw new Error('Invalid versionCode (positive integer required)') if (!input.apk || input.apk.length < 1024) throw new Error('APK file missing or empty') // APK = ZIP container → must start with the local-file-header magic "PK\x03\x04" if (!(input.apk[0] === 0x50 && input.apk[1] === 0x4b && input.apk[2] === 0x03 && input.apk[3] === 0x04)) { throw new Error('File is not an APK (ZIP signature missing)') } const dir = releasesDir() mkdirSync(historyDir(), { recursive: true }) const current = readManifest() if (current && !input.force) { if (versionCode <= current.versionCode) { throw new Error(`versionCode ${versionCode} must be greater than the published ${current.versionCode} (use force to override)`) } } // 1) write the new APK next to the current one const finalApk = path.join(dir, CURRENT_APK) const partApk = `${finalApk}.part` writeFileSync(partApk, input.apk) // 2) archive the current APK (if any and if it is a different version) let archived: string | null = null if (current && existsSync(finalApk) && current.versionCode !== versionCode) { const name = `logship-mobile-${current.versionName}-${current.versionCode}.apk` const dest = path.join(historyDir(), name) if (existsSync(dest)) unlinkSync(dest) renameSync(finalApk, dest) archived = name let hist = readHistory().filter(h => Number(h.versionCode) !== current.versionCode) hist.unshift({ versionName: current.versionName, versionCode: current.versionCode, releaseDate: current.releaseDate || '', releaseNotes: current.releaseNotes || '', apkUrl: `${HISTORY_SUBDIR}/${name}`, ...(current.sha256 ? { sha256: current.sha256 } : {}), ...(current.size ? { size: current.size } : {}) }) hist.sort((a, b) => (Number(b.versionCode) || 0) - (Number(a.versionCode) || 0)) const keep = cfg().keepHistory if (keep > 0 && hist.length > keep) { for (const dropped of hist.splice(keep)) { const f = safeApkPath(dropped.apkUrl) if (f) { try { unlinkSync(f) } catch {} } } } writeJsonAtomic(path.join(dir, 'history.json'), hist) } else if (existsSync(finalApk)) { unlinkSync(finalApk) // same version re-published (force) → just replace } if (!existsSync(path.join(dir, 'history.json'))) writeJsonAtomic(path.join(dir, 'history.json'), []) // 3) promote the new APK, 4) manifest last renameSync(partApk, finalApk) const manifest: ReleaseManifest = { versionName, versionCode, apkUrl: CURRENT_APK, releaseNotes: String(input.releaseNotes ?? current?.releaseNotes ?? ''), releaseDate: String(input.releaseDate || new Date().toISOString().slice(0, 10)), minRequiredVersion: String(input.minRequiredVersion || current?.minRequiredVersion || '1.0.0'), sha256: sha256Of(finalApk), size: statSync(finalApk).size } writeJsonAtomic(path.join(dir, 'version.json'), manifest) console.log(`[MobileReleases] published ${versionName} (code ${versionCode})${archived ? `, archived ${archived}` : ''}`) return { manifest, archived } } const p = publishLock.then(run, run) publishLock = p.catch(() => {}) return p } /** Fill in sha256/size for a seeded manifest that predates those fields (fail-soft, in memory only). */ export const enrichManifest = (m: ReleaseManifest): ReleaseManifest => { if (m.sha256 && m.size) return m const f = safeApkPath(CURRENT_APK) if (!f) return m try { return { ...m, size: m.size || statSync(f).size, sha256: m.sha256 || sha256Of(f) } } catch { return m } } export const listArchivedFiles = (): string[] => { try { return readdirSync(historyDir()).filter(f => f.endsWith('.apk')) } catch { return [] } } // -------------------------------------------------------------------------- // Access helpers // -------------------------------------------------------------------------- const parseCookieJson = (event: any, name: string): any => { try { const raw = getCookie(event, name) if (!raw) return null return typeof raw === 'string' ? JSON.parse(raw) : raw } catch { return null } } const truthy = (v: any) => v === true || v === 'Y' || v === 'true' || v === 1 || v === '1' /** Cookie-level admin flag (same trust level as the rest of the app's UI gating). */ export const isAdminCookie = (event: any): boolean => truthy(parseCookieJson(event, 'logship_role')?.IsClientAdministrator) /** May this session use the mobile app? IsMobileWorker on the user, or a client administrator. */ export const canUseMobileApp = (event: any): boolean => { const user = parseCookieJson(event, 'logship_user') return isMobileWorkerUser(user) || isAdminCookie(event) } /** `Authorization: Bearer ` (the non-expiring SuperUser token from .env) — used by the release script. */ export const isServiceTokenRequest = (event: any): boolean => { const c: any = useRuntimeConfig() const expected = String(c.api?.idempieretoken || '') const header = String(getHeader(event, 'authorization') || '') const given = header.replace(/^Bearer\s+/i, '').trim() if (!expected || !given) return false const a = Buffer.from(expected), b = Buffer.from(given) return a.length === b.length && timingSafeEqual(a, b) } const adminCache: Map = (globalThis as any).__mobileAdminCache ?? ((globalThis as any).__mobileAdminCache = new Map()) /** Server-authoritative admin check: the role's IsClientAdministrator resolved against iDempiere (10-min cache). */ export const isAdminVerified = async (event: any, token: string): Promise => { const roleId = getCookie(event, 'logship_role_id') if (!roleId || !token) return false const key = `${roleId}:${token.slice(-16)}` const hit = adminCache.get(key) if (hit && hit.expires > Date.now()) return hit.ok let ok = false try { const res: any = await fetchHelper(event, `models/ad_role/${roleId}?$select=IsClientAdministrator`, 'GET', token, null) ok = truthy(res?.IsClientAdministrator ?? res?.isClientAdministrator) } catch (err: any) { console.warn('[MobileReleases] admin check failed:', err?.data?.message || err?.message || err) } adminCache.set(key, { ok, expires: Date.now() + 10 * 60 * 1000 }) return ok }