初回コミット: AltStore PAL向けADPホスティングSaaS
- Fastify API・Node Worker・PostgreSQL・S3互換ストレージ構成
- ADP ZIPのマルチパートアップロードとManifest検証(パストラバーサル・ZIP爆弾等を拒否)
- manifest.json/signatureは再シリアライズせず元バイト列を保持
- Cloudflare風の運用向け管理ダッシュボード(shadcn/Radix・日本語UI・4ルート)
概要/Sources/アプリ/リリース + ルートランディングページ
- 汎用OIDC SSO・Workspace単位の認可・demo mode
- 匿名配布: /sources/{slug}/source.json, /artifacts/{releaseId}/manifest.json
- Docker Compose対応
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
NODE_ENV=development
|
||||||
|
PORT=4000
|
||||||
|
PUBLIC_BASE_URL=http://localhost:4000
|
||||||
|
WEB_ORIGIN=http://localhost:3000
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=http://localhost:4000
|
||||||
|
NEXT_PUBLIC_AUTH_MODE=demo
|
||||||
|
|
||||||
|
# Use PostgreSQL + MinIO in Docker Compose. Leave DATABASE_URL empty for memory mode.
|
||||||
|
DATABASE_URL=postgres://altdock:altdock@localhost:5432/altdock
|
||||||
|
STORAGE_MODE=s3
|
||||||
|
S3_ENDPOINT=http://localhost:9000
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_BUCKET=altdock
|
||||||
|
S3_ACCESS_KEY_ID=minioadmin
|
||||||
|
S3_SECRET_ACCESS_KEY=minioadmin
|
||||||
|
S3_FORCE_PATH_STYLE=true
|
||||||
|
|
||||||
|
# demo works without an identity provider; production should use generic OIDC.
|
||||||
|
AUTH_MODE=demo
|
||||||
|
SESSION_SECRET=replace-with-at-least-32-random-characters
|
||||||
|
OIDC_ISSUER_URL=
|
||||||
|
OIDC_CLIENT_ID=
|
||||||
|
OIDC_CLIENT_SECRET=
|
||||||
|
OIDC_REDIRECT_URI=http://localhost:4000/auth/callback
|
||||||
|
|
||||||
|
MAX_UPLOAD_BYTES=5368709120
|
||||||
|
MAX_ARCHIVE_ENTRIES=2048
|
||||||
|
MAX_EXPANDED_BYTES=8589934592
|
||||||
|
MAX_SOURCES_PER_WORKSPACE=3
|
||||||
|
MAX_APPS_PER_SOURCE=20
|
||||||
|
MAX_STORAGE_BYTES=21474836480
|
||||||
|
PROCESS_INLINE=true
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/.vinext/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
next-env.d.ts
|
||||||
|
/dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
/.wrangler/
|
||||||
|
/outputs/
|
||||||
|
/work/
|
||||||
|
/.data/
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"d1": null,
|
||||||
|
"r2": null
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# AltDock
|
||||||
|
|
||||||
|
AltDock is a self-hostable, multi-tenant hosting service for AltStore PAL
|
||||||
|
Alternative Distribution Packages (ADPs). Upload the original ADP ZIP, validate
|
||||||
|
its manifest and asset set, then publish a PAL-compatible Source JSON URL.
|
||||||
|
|
||||||
|
## Run locally without Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
npm run dev:api
|
||||||
|
```
|
||||||
|
|
||||||
|
With no `DATABASE_URL`, the API uses an in-memory store and local files under
|
||||||
|
`.data/storage`. Open the web app in another terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The dashboard is at `http://localhost:3000` and the API is at
|
||||||
|
`http://localhost:4000`.
|
||||||
|
|
||||||
|
## Run the full stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts PostgreSQL, MinIO, the API, the ADP worker, and the web app. The
|
||||||
|
MinIO console is available at `http://localhost:9001` with the credentials in
|
||||||
|
`docker-compose.yml`.
|
||||||
|
|
||||||
|
## Production identity
|
||||||
|
|
||||||
|
Set `AUTH_MODE=oidc` and configure `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID`,
|
||||||
|
`OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI`, and a random `SESSION_SECRET`. The
|
||||||
|
demo mode is intentionally enabled by default for local development only.
|
||||||
|
|
||||||
|
## ADP workflow
|
||||||
|
|
||||||
|
1. Submit the app for Notarization and download the completed ADP ZIP.
|
||||||
|
2. Upload the original ZIP to AltDock; do not prettify or rewrite its files.
|
||||||
|
3. Review the parsed bundle ID, Apple Item ID, version, build, and asset paths.
|
||||||
|
4. Publish the release and copy the Source URL into AltStore PAL.
|
||||||
|
|
||||||
|
Published artifacts are served at `/artifacts/{releaseId}/{relativePath}` and
|
||||||
|
the Source JSON is served at `/sources/{slug}/source.json`.
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
```
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export type ChatGPTUser = {
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
fullName: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
|
||||||
|
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
|
||||||
|
const USER_FULL_NAME_ENCODING_HEADER =
|
||||||
|
"oai-authenticated-user-full-name-encoding";
|
||||||
|
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
|
||||||
|
const SIGN_IN_PATH = "/signin-with-chatgpt";
|
||||||
|
const SIGN_OUT_PATH = "/signout-with-chatgpt";
|
||||||
|
const CALLBACK_PATH = "/callback";
|
||||||
|
|
||||||
|
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
const email = requestHeaders.get(USER_EMAIL_HEADER);
|
||||||
|
if (!email) return null;
|
||||||
|
|
||||||
|
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
|
||||||
|
const fullName =
|
||||||
|
encodedFullName &&
|
||||||
|
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
|
||||||
|
? safeDecodeURIComponent(encodedFullName)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayName: fullName ?? email,
|
||||||
|
email,
|
||||||
|
fullName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireChatGPTUser(
|
||||||
|
returnTo: string,
|
||||||
|
): Promise<ChatGPTUser> {
|
||||||
|
const user = await getChatGPTUser();
|
||||||
|
if (user) return user;
|
||||||
|
|
||||||
|
redirect(chatGPTSignInPath(returnTo));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chatGPTSignInPath(returnTo: string): string {
|
||||||
|
const safeReturnTo = safeRelativeReturnPath(returnTo);
|
||||||
|
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chatGPTSignOutPath(returnTo = "/"): string {
|
||||||
|
const safeReturnTo = safeRelativeReturnPath(returnTo);
|
||||||
|
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRelativeReturnPath(value: string): string {
|
||||||
|
if (!value.startsWith("/") || value.startsWith("//")) return "/";
|
||||||
|
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(value, "https://app.local");
|
||||||
|
} catch {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
if (url.origin !== "https://app.local") return "/";
|
||||||
|
if (isReservedAuthPath(url.pathname)) return "/";
|
||||||
|
|
||||||
|
return `${url.pathname}${url.search}${url.hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReservedAuthPath(pathname: string): boolean {
|
||||||
|
return (
|
||||||
|
pathname === SIGN_IN_PATH ||
|
||||||
|
pathname === SIGN_OUT_PATH ||
|
||||||
|
pathname === CALLBACK_PATH
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDecodeURIComponent(value: string): string | null {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { DashboardView } from "@/components/altdock/dashboard-view";
|
||||||
|
|
||||||
|
export default function AppsPage() {
|
||||||
|
return <DashboardView view="apps" />;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { DashboardProvider } from "@/components/altdock/dashboard-provider";
|
||||||
|
import { AdminShell } from "@/components/altdock/admin-shell";
|
||||||
|
|
||||||
|
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <DashboardProvider><AdminShell>{children}</AdminShell></DashboardProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { DashboardView } from "@/components/altdock/dashboard-view";
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return <DashboardView view="overview" />;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { DashboardView } from "@/components/altdock/dashboard-view";
|
||||||
|
|
||||||
|
export default function ReleasesPage() {
|
||||||
|
return <DashboardView view="releases" />;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowUpRight, KeyRound, ShieldCheck, SlidersHorizontal } from "lucide-react";
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
return <><div className="admin-page-heading"><div><div className="admin-eyebrow">WORKSPACE SETTINGS</div><h1>設定</h1><p>認証と配布上限は環境設定で管理します。</p></div></div><section className="admin-section-card settings-placeholder"><div className="settings-placeholder-icon"><SlidersHorizontal size={20} /></div><h2>設定画面は準備中です</h2><p>SSO、ストレージ、アップロード上限は現在の環境変数と管理者設定で制御されています。</p><div className="settings-placeholder-grid"><div><ShieldCheck size={17} /><strong>認証</strong><span>OIDC / demo mode</span></div><div><KeyRound size={17} /><strong>配布</strong><span>Workspace単位の認可</span></div></div><Link className="admin-link" href="https://faq.altstore.io/developers/distribute-with-altstore-pal" target="_blank">PALドキュメントを開く <ArrowUpRight size={14} /></Link></section></>;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { DashboardView } from "@/components/altdock/dashboard-view";
|
||||||
|
|
||||||
|
export default function SourcesPage() {
|
||||||
|
return <DashboardView view="sources" />;
|
||||||
|
}
|
||||||
|
|
||||||
+136
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
|
||||||
|
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "AltDock — Workspace dashboard",
|
||||||
|
description: "Manage AltStore PAL Sources, apps, and notarized ADP releases.",
|
||||||
|
icons: { icon: "/favicon.svg", shortcut: "/favicon.svg" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
return <html lang="ja"><body className={`${geistSans.variable} ${geistMono.variable}`}>{children}</body></html>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type { AppRecord, ReleaseRecord, SourceRecord } from "@/packages/core/src/index";
|
||||||
|
|
||||||
|
export type DashboardSource = {
|
||||||
|
source: SourceRecord;
|
||||||
|
apps: AppRecord[];
|
||||||
|
releases: ReleaseRecord[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardData = {
|
||||||
|
workspace: {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
sources: DashboardSource[];
|
||||||
|
usage: {
|
||||||
|
storageBytes: number;
|
||||||
|
sourceCount: number;
|
||||||
|
};
|
||||||
|
limits: {
|
||||||
|
maxSources: number;
|
||||||
|
maxAppsPerSource: number;
|
||||||
|
maxStorageBytes: number;
|
||||||
|
maxUploadBytes: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Notice = {
|
||||||
|
tone: "success" | "error" | "info";
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Visibility = SourceRecord["visibility"];
|
||||||
|
|
||||||
|
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:4000";
|
||||||
|
export const AUTH_MODE = process.env.NEXT_PUBLIC_AUTH_MODE || "demo";
|
||||||
|
export const DEMO_HEADERS = { "x-demo-user": "demo@altdock.local" };
|
||||||
|
|
||||||
|
export async function api<T>(path: string, init: RequestInit = {}) {
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
headers.set("accept", "application/json");
|
||||||
|
if (init.body) headers.set("content-type", "application/json");
|
||||||
|
Object.entries(DEMO_HEADERS).forEach(([key, value]) => headers.set(key, value));
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, { ...init, headers, credentials: "include" });
|
||||||
|
const body = (await response.json().catch(() => ({}))) as T & { error?: string; detail?: string };
|
||||||
|
if (!response.ok) throw new Error(body.detail || body.error || `Request failed (${response.status})`);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(value: number) {
|
||||||
|
if (!value) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
||||||
|
return `${(value / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("ja-JP", { year: "numeric", month: "short", day: "numeric" }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("ja-JP", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusLabel(status: ReleaseRecord["status"]) {
|
||||||
|
return {
|
||||||
|
uploaded: "アップロード済み",
|
||||||
|
processing: "処理中",
|
||||||
|
ready: "公開準備完了",
|
||||||
|
rejected: "要修正",
|
||||||
|
published: "公開中",
|
||||||
|
archived: "アーカイブ",
|
||||||
|
}[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusTone(status: ReleaseRecord["status"]) {
|
||||||
|
return {
|
||||||
|
uploaded: "neutral",
|
||||||
|
processing: "info",
|
||||||
|
ready: "warning",
|
||||||
|
rejected: "danger",
|
||||||
|
published: "success",
|
||||||
|
archived: "neutral",
|
||||||
|
}[status] as "neutral" | "info" | "warning" | "danger" | "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visibilityLabel(visibility: Visibility) {
|
||||||
|
return { draft: "下書き", unlisted: "限定公開", public: "公開" }[visibility];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visibilityTone(visibility: Visibility) {
|
||||||
|
return { draft: "neutral", unlisted: "warning", public: "success" }[visibility] as "neutral" | "warning" | "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenSources(dashboard: DashboardData) {
|
||||||
|
return dashboard.sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenApps(dashboard: DashboardData) {
|
||||||
|
return dashboard.sources.flatMap((entry) => entry.apps.map((app) => ({ app, source: entry.source, releases: entry.releases.filter((release) => release.appId === app.id) })));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenReleases(dashboard: DashboardData) {
|
||||||
|
return dashboard.sources.flatMap((entry) => entry.releases.map((release) => ({ release, source: entry.source, app: entry.apps.find((app) => app.id === release.appId) })));
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowRight, Boxes, FileArchive, Gauge, KeyRound, ShieldCheck, UploadCloud } from "lucide-react";
|
||||||
|
import { AUTH_MODE, API_BASE } from "@/app/lib/dashboard";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return <main className="landing">
|
||||||
|
<header className="landing-header">
|
||||||
|
<Link className="landing-brand" href="/"><span className="admin-brand-mark">A</span><span>AltDock</span><span className="landing-beta">BETA</span></Link>
|
||||||
|
<nav className="landing-nav">
|
||||||
|
<a href="https://faq.altstore.io/developers/distribute-with-altstore-pal" target="_blank" rel="noreferrer">PALドキュメント</a>
|
||||||
|
<a href="https://developer.apple.com/documentation/marketplacekit/ingesting-an-alternative-distribution-package" target="_blank" rel="noreferrer">ADP仕様</a>
|
||||||
|
{AUTH_MODE === "oidc" ? <a className="landing-signin" href={`${API_BASE}/auth/login`}>SSOでログイン</a> : <Link className="landing-signin" href="/dashboard">ダッシュボードを開く <ArrowRight size={14} /></Link>}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="landing-hero">
|
||||||
|
<div className="landing-hero-copy">
|
||||||
|
<div className="landing-eyebrow">ALTSTORE PAL · ADP HOSTING</div>
|
||||||
|
<h1>Notarization済みのADPを、<br />そのままPALへ届ける。</h1>
|
||||||
|
<p>Appleの検証チェーンを壊さず、ADP ZIPをアップロードするだけでAltStore PAL互換のSourceを公開できるホスティング基盤です。Manifestとsignatureは再シリアライズせず、元のバイト列を保持します。</p>
|
||||||
|
<div className="landing-hero-actions">
|
||||||
|
<Link className="ui-button ui-button-primary" href="/dashboard">ダッシュボードを開く <ArrowRight size={15} /></Link>
|
||||||
|
<a className="ui-button ui-button-outline" href="https://faq.altstore.io/developers/distribute-with-altstore-pal" target="_blank" rel="noreferrer">配布要件を見る</a>
|
||||||
|
</div>
|
||||||
|
<div className="landing-hero-meta"><Gauge size={14} />Demo mode で起動中 · API <code>{API_BASE}</code></div>
|
||||||
|
</div>
|
||||||
|
<aside className="landing-hero-card" aria-hidden="true">
|
||||||
|
<div className="landing-flow-step"><span className="landing-flow-num">1</span><span><UploadCloud size={16} />ADP ZIPをアップロード</span></div>
|
||||||
|
<div className="landing-flow-arrow" />
|
||||||
|
<div className="landing-flow-step"><span className="landing-flow-num">2</span><span><ShieldCheck size={16} />Manifest・署名を検証</span></div>
|
||||||
|
<div className="landing-flow-arrow" />
|
||||||
|
<div className="landing-flow-step done"><span className="landing-flow-num">3</span><span><Boxes size={16} />PAL Sourceを公開</span></div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="landing-features">
|
||||||
|
<article className="landing-feature"><FileArchive size={18} /><h2>元のパッケージを保持</h2><p>ADPの階層・ハッシュ・manifest.json・signatureを一切書き換えません。Appleの検証チェーンがそのまま通ります。</p></article>
|
||||||
|
<article className="landing-feature"><ShieldCheck size={18} /><h2>検証を自動化</h2><p>パストラバーサル、シンボリックリンク、ZIP爆弾、欠落・余分なファイルをWorkerで検査し、問題があれば拒否します。</p></article>
|
||||||
|
<article className="landing-feature"><KeyRound size={18} /><h2>Workspace単位の認可</h2><p>汎用OIDC SSOでユーザーを識別し、WorkspaceごとにSource・アプリ・ファイルを分離します。</p></article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="landing-footer">
|
||||||
|
<span>AltDock · AltStore PAL向け ADP ホスティング</span>
|
||||||
|
<div className="landing-footer-links">
|
||||||
|
<Link href="/dashboard">ダッシュボード</Link>
|
||||||
|
<a href="https://faq.altstore.io/developers/make-a-source" target="_blank" rel="noreferrer">Source仕様</a>
|
||||||
|
<a href={`${API_BASE}/healthz`} target="_blank" rel="noreferrer">APIステータス</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type SourceDocument = {
|
||||||
|
name: string;
|
||||||
|
subtitle?: string;
|
||||||
|
description?: string;
|
||||||
|
iconURL?: string;
|
||||||
|
tintColor?: string;
|
||||||
|
apps: Array<{ name: string; bundleIdentifier: string; developerName: string; subtitle?: string; localizedDescription: string; iconURL?: string; category: string; versions: Array<{ version: string; buildVersion: string; date: string; downloadURL: string; size: number }> }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:4000";
|
||||||
|
|
||||||
|
function formatBytes(value: number) {
|
||||||
|
if (!value) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
||||||
|
return `${(value / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PublicSourceView({ slug }: { slug: string }) {
|
||||||
|
const [document, setDocument] = useState<SourceDocument | null>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/sources/${slug}/source.json`)
|
||||||
|
.then((response) => response.ok ? response.json() : Promise.reject(new Error("not found")))
|
||||||
|
.then(setDocument)
|
||||||
|
.catch(() => setError(true));
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
if (error) return <main className="public-source-error"><span className="brand-mark">A</span><h1>Source not found</h1><p>This Source is still a draft or the URL is incorrect.</p><Link href="/">Open AltDock</Link></main>;
|
||||||
|
if (!document) return <main className="public-source-loading"><span className="brand-mark">A</span><p>Loading Source…</p></main>;
|
||||||
|
|
||||||
|
return <main className="public-source-page" style={{ "--source-tint": document.tintColor || "#E9694B" } as React.CSSProperties}>
|
||||||
|
<header className="public-source-header"><Link className="public-brand" href="/"><span className="brand-mark">A</span><span>AltDock</span></Link><span className="public-badge">ALTSTORE PAL SOURCE</span></header>
|
||||||
|
<section className="public-source-hero"><div className="public-source-avatar">{document.iconURL ? <img src={document.iconURL} alt="" /> : document.name.slice(0, 1)}</div><div><div className="section-kicker">SOURCE / {slug}</div><h1>{document.name}</h1><p>{document.subtitle}</p></div><button className="button primary" onClick={() => navigator.clipboard?.writeText(`${API_BASE}/sources/${slug}/source.json`)}>Copy Source URL</button></section>
|
||||||
|
{document.description && <p className="public-source-description">{document.description}</p>}
|
||||||
|
<section className="public-app-grid">{document.apps.length ? document.apps.map((app) => { const release = app.versions[0]; return <article className="public-app-card" key={app.bundleIdentifier}><div className="public-app-card-top"><span className="public-app-icon" style={{ background: document.tintColor || "#E9694B" }}>{app.iconURL ? <img src={app.iconURL} alt="" /> : app.name.slice(0, 1)}</span><span className="public-category">{app.category}</span></div><h2>{app.name}</h2><p className="public-developer">{app.developerName} · {app.bundleIdentifier}</p><p className="public-app-description">{app.localizedDescription || app.subtitle || "A PAL-ready iOS app."}</p>{release ? <div className="public-release"><span>Latest release</span><strong>v{release.version} <small>build {release.buildVersion}</small></strong><span>{formatBytes(release.size)}</span></div> : <div className="public-release"><span>No published release yet</span></div>}</article>; }) : <div className="public-no-apps"><strong>No published apps yet.</strong><p>Come back when the developer ships their first release.</p></div>}</section>
|
||||||
|
<footer className="public-source-footer"><span>Powered by AltDock</span><span>Source JSON is available for AltStore PAL.</span></footer>
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import PublicSourceView from "./PublicSourceView";
|
||||||
|
|
||||||
|
export default async function PublicSourcePage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const { slug } = await params;
|
||||||
|
return <PublicSourceView slug={slug} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { createRemoteJWKSet, jwtVerify, SignJWT } from "jose";
|
||||||
|
import type { Identity } from "../../../packages/core/src/index";
|
||||||
|
import type { AppConfig } from "./config";
|
||||||
|
|
||||||
|
const stateCookie = "altdock_oidc_state";
|
||||||
|
const verifierCookie = "altdock_oidc_verifier";
|
||||||
|
const nonceCookie = "altdock_oidc_nonce";
|
||||||
|
const sessionCookie = "altdock_session";
|
||||||
|
|
||||||
|
function base64Url(value: Buffer) {
|
||||||
|
return value.toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pkceChallenge(verifier: string) {
|
||||||
|
return createHash("sha256").update(verifier).digest("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession(identity: Identity, appConfig: AppConfig) {
|
||||||
|
return new SignJWT({ ...identity })
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime("7d")
|
||||||
|
.sign(new TextEncoder().encode(appConfig.SESSION_SECRET));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readSession(token: string | undefined, appConfig: AppConfig): Promise<Identity | null> {
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
const result = await jwtVerify(token, new TextEncoder().encode(appConfig.SESSION_SECRET), { algorithms: ["HS256"] });
|
||||||
|
const payload = result.payload;
|
||||||
|
if (typeof payload.subject !== "string" || typeof payload.email !== "string" || typeof payload.displayName !== "string") return null;
|
||||||
|
return { subject: payload.subject, email: payload.email, displayName: payload.displayName };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIdentity(request: FastifyRequest, appConfig: AppConfig): Promise<Identity | null> {
|
||||||
|
if (appConfig.AUTH_MODE === "demo") {
|
||||||
|
const email = String(request.headers["x-demo-user"] || "demo@altdock.local");
|
||||||
|
return { subject: `demo:${email}`, email, displayName: email.split("@")[0] || "Demo Developer" };
|
||||||
|
}
|
||||||
|
return readSession(request.cookies[sessionCookie], appConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSession(reply: FastifyReply, token: string) {
|
||||||
|
reply.setCookie(sessionCookie, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24 * 7,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function oidcDiscovery(appConfig: AppConfig) {
|
||||||
|
if (!appConfig.OIDC_ISSUER_URL) throw new Error("OIDC_ISSUER_URL_REQUIRED");
|
||||||
|
const response = await fetch(`${appConfig.OIDC_ISSUER_URL.replace(/\/$/, "")}/.well-known/openid-configuration`);
|
||||||
|
if (!response.ok) throw new Error(`OIDC_DISCOVERY_FAILED:${response.status}`);
|
||||||
|
return response.json() as Promise<{ authorization_endpoint: string; token_endpoint: string; jwks_uri: string; issuer: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerAuthRoutes(app: FastifyInstance, appConfig: AppConfig) {
|
||||||
|
app.get("/auth/login", async (_request, reply) => {
|
||||||
|
if (appConfig.AUTH_MODE === "demo") return reply.redirect(appConfig.WEB_ORIGIN);
|
||||||
|
if (!appConfig.OIDC_CLIENT_ID || !appConfig.OIDC_REDIRECT_URI) throw new Error("OIDC_CONFIGURATION_REQUIRED");
|
||||||
|
const provider = await oidcDiscovery(appConfig);
|
||||||
|
const state = base64Url(randomBytes(24));
|
||||||
|
const verifier = base64Url(randomBytes(32));
|
||||||
|
const nonce = base64Url(randomBytes(24));
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: appConfig.OIDC_CLIENT_ID,
|
||||||
|
redirect_uri: appConfig.OIDC_REDIRECT_URI,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid profile email",
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
code_challenge: pkceChallenge(verifier),
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
});
|
||||||
|
reply.setCookie(stateCookie, state, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/auth" });
|
||||||
|
reply.setCookie(verifierCookie, verifier, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/auth" });
|
||||||
|
reply.setCookie(nonceCookie, nonce, { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/auth" });
|
||||||
|
return reply.redirect(`${provider.authorization_endpoint}?${params.toString()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/auth/callback", async (request, reply) => {
|
||||||
|
if (appConfig.AUTH_MODE === "demo") return reply.redirect(appConfig.WEB_ORIGIN);
|
||||||
|
if (!appConfig.OIDC_CLIENT_ID || !appConfig.OIDC_REDIRECT_URI || !appConfig.OIDC_CLIENT_SECRET) throw new Error("OIDC_CONFIGURATION_REQUIRED");
|
||||||
|
const query = request.query as { code?: string; state?: string; error?: string };
|
||||||
|
if (query.error) return reply.code(400).send({ error: "OIDC_LOGIN_FAILED", detail: query.error });
|
||||||
|
if (!query.code || query.state !== request.cookies[stateCookie]) return reply.code(400).send({ error: "OIDC_STATE_INVALID" });
|
||||||
|
const verifier = request.cookies[verifierCookie];
|
||||||
|
const nonce = request.cookies[nonceCookie];
|
||||||
|
if (!verifier || !nonce) return reply.code(400).send({ error: "OIDC_VERIFIER_MISSING" });
|
||||||
|
const provider = await oidcDiscovery(appConfig);
|
||||||
|
const tokenResponse = await fetch(provider.token_endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({ grant_type: "authorization_code", code: query.code, redirect_uri: appConfig.OIDC_REDIRECT_URI, client_id: appConfig.OIDC_CLIENT_ID, client_secret: appConfig.OIDC_CLIENT_SECRET, code_verifier: verifier }),
|
||||||
|
});
|
||||||
|
if (!tokenResponse.ok) return reply.code(400).send({ error: "OIDC_TOKEN_EXCHANGE_FAILED" });
|
||||||
|
const tokens = await tokenResponse.json() as { id_token?: string; access_token?: string };
|
||||||
|
if (!tokens.id_token) return reply.code(400).send({ error: "OIDC_ID_TOKEN_MISSING" });
|
||||||
|
const jwks = createRemoteJWKSet(new URL(provider.jwks_uri));
|
||||||
|
const verified = await jwtVerify(tokens.id_token, jwks, { issuer: provider.issuer, audience: appConfig.OIDC_CLIENT_ID });
|
||||||
|
const claims = verified.payload;
|
||||||
|
if (claims.nonce !== nonce) return reply.code(400).send({ error: "OIDC_NONCE_INVALID" });
|
||||||
|
const subject = String(claims.sub || "");
|
||||||
|
if (!subject) return reply.code(400).send({ error: "OIDC_SUBJECT_MISSING" });
|
||||||
|
const email = String(claims.email || `${subject}@oidc.local`);
|
||||||
|
const displayName = String(claims.name || claims.preferred_username || email.split("@")[0] || "Developer");
|
||||||
|
setSession(reply, await createSession({ subject, email, displayName }, appConfig));
|
||||||
|
reply.clearCookie(stateCookie, { path: "/auth" });
|
||||||
|
reply.clearCookie(verifierCookie, { path: "/auth" });
|
||||||
|
reply.clearCookie(nonceCookie, { path: "/auth" });
|
||||||
|
return reply.redirect(appConfig.WEB_ORIGIN);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/auth/logout", async (_request, reply) => {
|
||||||
|
reply.clearCookie(sessionCookie, { path: "/" });
|
||||||
|
return reply.redirect(appConfig.WEB_ORIGIN);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const booleanFromEnv = z.preprocess((value) => {
|
||||||
|
if (typeof value !== "string") return value;
|
||||||
|
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
|
||||||
|
}, z.boolean());
|
||||||
|
|
||||||
|
const numberFromEnv = z.preprocess(
|
||||||
|
(value) => (typeof value === "string" ? Number(value) : value),
|
||||||
|
z.number().finite(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||||
|
HOST: z.string().default("0.0.0.0"),
|
||||||
|
PORT: numberFromEnv.default(4000),
|
||||||
|
PUBLIC_BASE_URL: z.string().url().default("http://localhost:4000"),
|
||||||
|
WEB_ORIGIN: z.string().url().default("http://localhost:3000"),
|
||||||
|
DATABASE_URL: z.string().optional(),
|
||||||
|
STORAGE_MODE: z.enum(["local", "s3"]).default("local"),
|
||||||
|
LOCAL_STORAGE_DIR: z.string().default(".data/storage"),
|
||||||
|
S3_ENDPOINT: z.string().url().optional(),
|
||||||
|
S3_REGION: z.string().default("auto"),
|
||||||
|
S3_BUCKET: z.string().default("altdock"),
|
||||||
|
S3_ACCESS_KEY_ID: z.string().optional(),
|
||||||
|
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
||||||
|
S3_FORCE_PATH_STYLE: booleanFromEnv.default(true),
|
||||||
|
AUTH_MODE: z.enum(["demo", "oidc"]).default("demo"),
|
||||||
|
SESSION_SECRET: z.string().min(32).default("altdock-local-session-secret-change-me-123456"),
|
||||||
|
OIDC_ISSUER_URL: z.string().url().optional(),
|
||||||
|
OIDC_CLIENT_ID: z.string().optional(),
|
||||||
|
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||||
|
OIDC_REDIRECT_URI: z.string().url().optional(),
|
||||||
|
MAX_UPLOAD_BYTES: numberFromEnv.default(5 * 1024 * 1024 * 1024),
|
||||||
|
MAX_ARCHIVE_ENTRIES: numberFromEnv.default(2048),
|
||||||
|
MAX_EXPANDED_BYTES: numberFromEnv.default(8 * 1024 * 1024 * 1024),
|
||||||
|
MAX_SOURCES_PER_WORKSPACE: numberFromEnv.default(3),
|
||||||
|
MAX_APPS_PER_SOURCE: numberFromEnv.default(20),
|
||||||
|
MAX_STORAGE_BYTES: numberFromEnv.default(20 * 1024 * 1024 * 1024),
|
||||||
|
PROCESS_INLINE: booleanFromEnv.default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AppConfig = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||||
|
return schema.parse(env);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = loadConfig();
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { posix } from "node:path";
|
||||||
|
import { Transform } from "node:stream";
|
||||||
|
import unzipper from "unzipper";
|
||||||
|
import type { ManifestSummary, ReleaseAsset } from "../../../packages/core/src/index";
|
||||||
|
import type { StorageAdapter } from "./storage";
|
||||||
|
|
||||||
|
interface ZipEntryLike {
|
||||||
|
path: string;
|
||||||
|
type: string;
|
||||||
|
uncompressedSize?: number;
|
||||||
|
buffer(): Promise<Buffer>;
|
||||||
|
stream(): NodeJS.ReadableStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdpInspection {
|
||||||
|
manifestBytes: Buffer;
|
||||||
|
signatureBytes: Buffer;
|
||||||
|
manifest: Record<string, any>;
|
||||||
|
summary: ManifestSummary;
|
||||||
|
expectedPaths: string[];
|
||||||
|
entries: Map<string, ZipEntryLike>;
|
||||||
|
archiveSizeBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeZipPath(value: string) {
|
||||||
|
const normalized = value.replaceAll("\\", "/");
|
||||||
|
if (!normalized || normalized.startsWith("/") || normalized.includes("\0")) {
|
||||||
|
throw new Error("ADP_PATH_INVALID");
|
||||||
|
}
|
||||||
|
const clean = posix.normalize(normalized);
|
||||||
|
if (clean === "." || clean.startsWith("../") || clean.includes("/../") || clean === "..") {
|
||||||
|
throw new Error("ADP_PATH_TRAVERSAL");
|
||||||
|
}
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectAssetPaths(value: unknown, output: Set<string>) {
|
||||||
|
if (!value || typeof value !== "object") return;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) collectAssetPaths(item, output);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [key, child] of Object.entries(value)) {
|
||||||
|
if (["assetPath", "sourcePath", "deltaPath"].includes(key) && typeof child === "string") {
|
||||||
|
output.add(safeZipPath(child));
|
||||||
|
}
|
||||||
|
collectAssetPaths(child, output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringRecord(value: unknown) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||||
|
return Object.fromEntries(Object.entries(value).filter(([, item]) => typeof item === "string")) as Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryFromManifest(manifest: Record<string, any>): ManifestSummary {
|
||||||
|
const variantPaths = new Set<string>();
|
||||||
|
const deltaPaths = new Set<string>();
|
||||||
|
collectAssetPaths(manifest.variants, variantPaths);
|
||||||
|
collectAssetPaths(manifest.deltas, deltaPaths);
|
||||||
|
return {
|
||||||
|
distributionPackageRevision: typeof manifest.distributionPackageRevision === "number" ? manifest.distributionPackageRevision : undefined,
|
||||||
|
appleItemId: String(manifest.appleItemId || ""),
|
||||||
|
bundleId: String(manifest.bundleId || ""),
|
||||||
|
shortVersionString: String(manifest.shortVersionString || ""),
|
||||||
|
bundleVersion: String(manifest.bundleVersion || ""),
|
||||||
|
appleVersionId: manifest.appleVersionId ? String(manifest.appleVersionId) : undefined,
|
||||||
|
platforms: Array.isArray(manifest.platforms) ? manifest.platforms.map(String) : [],
|
||||||
|
minimumSystemVersions: stringRecord(manifest.minimumSystemVersions),
|
||||||
|
variantPaths: [...variantPaths],
|
||||||
|
deltaPaths: [...deltaPaths],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inspectAdpArchive(
|
||||||
|
archivePath: string,
|
||||||
|
maxEntries: number,
|
||||||
|
maxExpandedBytes: number,
|
||||||
|
): Promise<AdpInspection> {
|
||||||
|
const directory = await unzipper.Open.file(archivePath);
|
||||||
|
if (directory.files.length > maxEntries) throw new Error("ADP_TOO_MANY_FILES");
|
||||||
|
const entries = new Map<string, ZipEntryLike>();
|
||||||
|
let expandedBytes = 0;
|
||||||
|
for (const rawEntry of directory.files as unknown as ZipEntryLike[]) {
|
||||||
|
const path = safeZipPath(rawEntry.path);
|
||||||
|
if (rawEntry.type !== "File" && rawEntry.type !== "Directory") throw new Error("ADP_LINK_ENTRY");
|
||||||
|
if (rawEntry.type === "Directory" || path.endsWith("/")) continue;
|
||||||
|
expandedBytes += Number(rawEntry.uncompressedSize || 0);
|
||||||
|
if (expandedBytes > maxExpandedBytes) throw new Error("ADP_EXPANDED_SIZE_LIMIT");
|
||||||
|
if (entries.has(path)) throw new Error("ADP_DUPLICATE_PATH");
|
||||||
|
entries.set(path, rawEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestEntry = entries.get("manifest.json");
|
||||||
|
const signatureEntry = entries.get("signature");
|
||||||
|
if (!manifestEntry || !signatureEntry) throw new Error("ADP_REQUIRED_FILE_MISSING");
|
||||||
|
const manifestBytes = await manifestEntry.buffer();
|
||||||
|
let manifest: Record<string, any>;
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(manifestBytes.toString("utf8"));
|
||||||
|
} catch {
|
||||||
|
throw new Error("ADP_MANIFEST_INVALID_JSON");
|
||||||
|
}
|
||||||
|
const assetPaths = new Set<string>();
|
||||||
|
collectAssetPaths(manifest.variants, assetPaths);
|
||||||
|
collectAssetPaths(manifest.deltas, assetPaths);
|
||||||
|
const expectedPaths = ["manifest.json", "signature", ...assetPaths].sort();
|
||||||
|
const actualPaths = [...entries.keys()].sort();
|
||||||
|
if (expectedPaths.join("\n") !== actualPaths.join("\n")) {
|
||||||
|
const missing = expectedPaths.filter((path) => !entries.has(path));
|
||||||
|
const extra = actualPaths.filter((path) => !expectedPaths.includes(path));
|
||||||
|
const reason = missing.length ? `missing:${missing.join(",")}` : `extra:${extra.join(",")}`;
|
||||||
|
throw new Error(`ADP_FILE_SET_MISMATCH:${reason}`);
|
||||||
|
}
|
||||||
|
const summary = summaryFromManifest(manifest);
|
||||||
|
if (!summary.appleItemId || !summary.bundleId || !summary.shortVersionString || !summary.bundleVersion) {
|
||||||
|
throw new Error("ADP_MANIFEST_METADATA_MISSING");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
manifestBytes,
|
||||||
|
signatureBytes: await signatureEntry.buffer(),
|
||||||
|
manifest,
|
||||||
|
summary,
|
||||||
|
expectedPaths,
|
||||||
|
entries,
|
||||||
|
archiveSizeBytes: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentTypeFor(path: string) {
|
||||||
|
if (path === "manifest.json") return "application/json; charset=utf-8";
|
||||||
|
if (path.endsWith(".ipa")) return "application/octet-stream";
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
class HashTransform extends Transform {
|
||||||
|
readonly hash = createHash("sha256");
|
||||||
|
sizeBytes = 0;
|
||||||
|
|
||||||
|
_transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null, data?: Buffer) => void) {
|
||||||
|
this.hash.update(chunk);
|
||||||
|
this.sizeBytes += chunk.length;
|
||||||
|
callback(null, chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeAdpAssets(
|
||||||
|
archivePath: string,
|
||||||
|
releaseId: string,
|
||||||
|
inspection: AdpInspection,
|
||||||
|
storage: StorageAdapter,
|
||||||
|
): Promise<{ assets: ReleaseAsset[]; totalBytes: number }> {
|
||||||
|
const assets: ReleaseAsset[] = [];
|
||||||
|
const freshDirectory = await unzipper.Open.file(archivePath);
|
||||||
|
const freshEntries = new Map<string, ZipEntryLike>();
|
||||||
|
for (const rawEntry of freshDirectory.files as unknown as ZipEntryLike[]) {
|
||||||
|
if (rawEntry.type !== "File" && rawEntry.type !== "Directory") throw new Error("ADP_LINK_ENTRY");
|
||||||
|
if (rawEntry.type === "Directory" || rawEntry.path.endsWith("/")) continue;
|
||||||
|
freshEntries.set(safeZipPath(rawEntry.path), rawEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const path of inspection.expectedPaths) {
|
||||||
|
const entry = freshEntries.get(path);
|
||||||
|
if (!entry) throw new Error("ADP_REQUIRED_FILE_MISSING");
|
||||||
|
const objectKey = `artifacts/${releaseId}/${path}`;
|
||||||
|
if (path === "manifest.json") {
|
||||||
|
await storage.writeObject(objectKey, inspection.manifestBytes, "application/json; charset=utf-8");
|
||||||
|
assets.push({ releaseId, path, objectKey, sizeBytes: inspection.manifestBytes.length, sha256: createHash("sha256").update(inspection.manifestBytes).digest("hex"), contentType: contentTypeFor(path) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (path === "signature") {
|
||||||
|
await storage.writeObject(objectKey, inspection.signatureBytes, "application/octet-stream");
|
||||||
|
assets.push({ releaseId, path, objectKey, sizeBytes: inspection.signatureBytes.length, sha256: createHash("sha256").update(inspection.signatureBytes).digest("hex"), contentType: contentTypeFor(path) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const transform = new HashTransform();
|
||||||
|
await storage.writeStream(objectKey, transform, contentTypeFor(path), Number(entry.uncompressedSize || 0), entry.stream());
|
||||||
|
assets.push({ releaseId, path, objectKey, sizeBytes: transform.sizeBytes, sha256: transform.hash.digest("hex"), contentType: contentTypeFor(path) });
|
||||||
|
}
|
||||||
|
return { assets, totalBytes: assets.reduce((sum, asset) => sum + asset.sizeBytes, 0) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { loadConfig } from "./config";
|
||||||
|
import { createRuntime } from "./runtime";
|
||||||
|
|
||||||
|
const appConfig = loadConfig();
|
||||||
|
if (!appConfig.DATABASE_URL) {
|
||||||
|
console.log("DATABASE_URL is not configured; memory mode does not need a migration.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
const runtime = await createRuntime(appConfig);
|
||||||
|
await runtime.pool?.end();
|
||||||
|
console.log("AltDock database schema is ready.");
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import type { AppMetadata } from "../../../packages/core/src/index";
|
||||||
|
import { inspectAdpArchive, storeAdpAssets } from "./manifest";
|
||||||
|
import type { AppConfig } from "./config";
|
||||||
|
import type { Store } from "./store";
|
||||||
|
import type { StorageAdapter } from "./storage";
|
||||||
|
|
||||||
|
export async function processUpload(uploadId: string, store: Store, storage: StorageAdapter, appConfig: AppConfig) {
|
||||||
|
const upload = await store.getUpload(uploadId);
|
||||||
|
if (!upload) throw new Error("UPLOAD_NOT_FOUND");
|
||||||
|
await store.updateUpload(uploadId, { status: "processing", errorCode: undefined, errorMessage: undefined });
|
||||||
|
const workDir = await mkdtemp(join(tmpdir(), "altdock-upload-"));
|
||||||
|
const archivePath = join(workDir, "package.zip");
|
||||||
|
try {
|
||||||
|
await storage.downloadToFile(upload.objectKey, archivePath);
|
||||||
|
const inspection = await inspectAdpArchive(archivePath, appConfig.MAX_ARCHIVE_ENTRIES, appConfig.MAX_EXPANDED_BYTES);
|
||||||
|
const source = await store.getSource(upload.sourceId);
|
||||||
|
if (!source) throw new Error("SOURCE_NOT_FOUND");
|
||||||
|
const existingApp = upload.appId ? await store.getApp(upload.appId) : null;
|
||||||
|
const metadata: Partial<AppMetadata> = existingApp || {
|
||||||
|
name: source.name,
|
||||||
|
developerName: "AltDock Developer",
|
||||||
|
localizedDescription: `Release ${inspection.summary.shortVersionString} (${inspection.summary.bundleVersion})`,
|
||||||
|
subtitle: "",
|
||||||
|
tintColor: source.tintColor,
|
||||||
|
category: "other",
|
||||||
|
screenshots: [],
|
||||||
|
appPermissions: { entitlements: [], privacy: {} },
|
||||||
|
};
|
||||||
|
const app = await store.upsertApp(upload.sourceId, {
|
||||||
|
bundleIdentifier: inspection.summary.bundleId,
|
||||||
|
marketplaceID: inspection.summary.appleItemId,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
await store.updateUpload(uploadId, { appId: app.id });
|
||||||
|
const releaseId = randomUUID();
|
||||||
|
const release = await store.createRelease({
|
||||||
|
id: releaseId,
|
||||||
|
appId: app.id,
|
||||||
|
uploadId,
|
||||||
|
version: inspection.summary.shortVersionString,
|
||||||
|
buildVersion: inspection.summary.bundleVersion,
|
||||||
|
appleItemId: inspection.summary.appleItemId,
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
localizedDescription: metadata.localizedDescription || "",
|
||||||
|
minOSVersion: inspection.summary.minimumSystemVersions.ios,
|
||||||
|
sizeBytes: 0,
|
||||||
|
manifest: inspection.summary,
|
||||||
|
});
|
||||||
|
const result = await storeAdpAssets(archivePath, release.id, inspection, storage);
|
||||||
|
await store.addReleaseAssets(result.assets);
|
||||||
|
const completed = await store.updateRelease(release.id, { sizeBytes: result.totalBytes, status: "ready" });
|
||||||
|
await store.updateUpload(uploadId, { status: "completed", receivedSize: upload.receivedSize || upload.expectedSize });
|
||||||
|
return completed;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "ADP_PROCESSING_FAILED";
|
||||||
|
console.error(`[altdock] upload ${uploadId} failed: ${message}`);
|
||||||
|
await store.updateUpload(uploadId, { status: "failed", errorCode: message.split(":")[0], errorMessage: message });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await rm(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { AppConfig } from "./config";
|
||||||
|
import { createStore } from "./store";
|
||||||
|
import { createStorage } from "./storage";
|
||||||
|
|
||||||
|
export async function createRuntime(appConfig: AppConfig) {
|
||||||
|
const { store, pool } = await createStore(appConfig);
|
||||||
|
await store.init();
|
||||||
|
return { store, storage: createStorage(appConfig), pool };
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
export const schemaSql = `
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
subject TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workspaces (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sources (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
subtitle TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
icon_url TEXT,
|
||||||
|
header_url TEXT,
|
||||||
|
website TEXT,
|
||||||
|
tint_color TEXT NOT NULL DEFAULT '#E9694B',
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'draft' CHECK (visibility IN ('draft', 'unlisted', 'public')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS apps (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
bundle_identifier TEXT NOT NULL,
|
||||||
|
marketplace_id TEXT NOT NULL,
|
||||||
|
developer_name TEXT NOT NULL,
|
||||||
|
subtitle TEXT NOT NULL DEFAULT '',
|
||||||
|
localized_description TEXT NOT NULL DEFAULT '',
|
||||||
|
icon_url TEXT,
|
||||||
|
tint_color TEXT NOT NULL DEFAULT '#E9694B',
|
||||||
|
category TEXT NOT NULL DEFAULT 'other',
|
||||||
|
screenshots JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
app_permissions JSONB NOT NULL DEFAULT '{"entitlements":[],"privacy":{}}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(source_id, bundle_identifier)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uploads (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
||||||
|
app_id TEXT REFERENCES apps(id) ON DELETE SET NULL,
|
||||||
|
object_key TEXT NOT NULL UNIQUE,
|
||||||
|
multipart_upload_id TEXT,
|
||||||
|
original_filename TEXT NOT NULL,
|
||||||
|
expected_size BIGINT NOT NULL,
|
||||||
|
received_size BIGINT NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'created',
|
||||||
|
error_code TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS releases (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
upload_id TEXT NOT NULL REFERENCES uploads(id) ON DELETE CASCADE,
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
build_version TEXT NOT NULL,
|
||||||
|
apple_item_id TEXT NOT NULL,
|
||||||
|
date TIMESTAMPTZ NOT NULL,
|
||||||
|
localized_description TEXT NOT NULL DEFAULT '',
|
||||||
|
min_os_version TEXT,
|
||||||
|
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'processing',
|
||||||
|
error_code TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
manifest JSONB NOT NULL,
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(app_id, version, build_version)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS release_assets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
release_id TEXT NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
object_key TEXT NOT NULL UNIQUE,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL,
|
||||||
|
UNIQUE(release_id, path)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
entity_id TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS sources_workspace_idx ON sources(workspace_id);
|
||||||
|
ALTER TABLE uploads ADD COLUMN IF NOT EXISTS multipart_upload_id TEXT;
|
||||||
|
CREATE INDEX IF NOT EXISTS apps_source_idx ON apps(source_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS releases_app_idx ON releases(app_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS uploads_queue_idx ON uploads(status, created_at);
|
||||||
|
`;
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { posix } from "node:path";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
|
||||||
|
import cookie from "@fastify/cookie";
|
||||||
|
import cors from "@fastify/cors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { buildSourceDocument } from "../../../packages/core/src/index";
|
||||||
|
import type { Identity } from "../../../packages/core/src/index";
|
||||||
|
import { loadConfig } from "./config";
|
||||||
|
import { getIdentity, registerAuthRoutes } from "./auth";
|
||||||
|
import { processUpload } from "./processor";
|
||||||
|
import { createRuntime } from "./runtime";
|
||||||
|
|
||||||
|
const sourceInput = z.object({
|
||||||
|
name: z.string().trim().min(1).max(80),
|
||||||
|
subtitle: z.string().max(160).default(""),
|
||||||
|
description: z.string().max(4000).default(""),
|
||||||
|
visibility: z.enum(["draft", "unlisted", "public"]).default("draft"),
|
||||||
|
iconURL: z.string().url().optional(),
|
||||||
|
headerURL: z.string().url().optional(),
|
||||||
|
website: z.string().url().optional(),
|
||||||
|
tintColor: z.string().regex(/^#?[0-9a-f]{6}$/i).default("#E9694B"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadInput = z.object({
|
||||||
|
sourceId: z.string().min(1),
|
||||||
|
appId: z.string().optional(),
|
||||||
|
filename: z.string().trim().min(1).max(180),
|
||||||
|
sizeBytes: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const appInput = z.object({
|
||||||
|
sourceId: z.string().min(1),
|
||||||
|
name: z.string().trim().min(1).max(80).optional(),
|
||||||
|
developerName: z.string().max(120).optional(),
|
||||||
|
subtitle: z.string().max(160).optional(),
|
||||||
|
localizedDescription: z.string().max(4000).optional(),
|
||||||
|
iconURL: z.string().url().optional(),
|
||||||
|
tintColor: z.string().regex(/^#?[0-9a-f]{6}$/i).optional(),
|
||||||
|
category: z.string().max(32).optional(),
|
||||||
|
screenshots: z.array(z.object({ imageURL: z.string().url(), width: z.number().int().positive().optional(), height: z.number().int().positive().optional() })).max(12).optional(),
|
||||||
|
appPermissions: z.object({ entitlements: z.array(z.string()).max(200), privacy: z.record(z.string(), z.string()).default({}) }).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const multipartPartsInput = z.object({
|
||||||
|
parts: z.array(z.object({ partNumber: z.number().int().positive(), etag: z.string().min(1) })).max(10000).optional(),
|
||||||
|
}).default({});
|
||||||
|
|
||||||
|
function safePath(value: string) {
|
||||||
|
const decoded = decodeURIComponent(value).replaceAll("\\", "/");
|
||||||
|
const normalized = posix.normalize(decoded);
|
||||||
|
if (!normalized || normalized.startsWith("/") || normalized === "." || normalized.startsWith("../") || normalized.includes("/../")) return null;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireIdentity(request: FastifyRequest, reply: FastifyReply, appConfig: ReturnType<typeof loadConfig>) {
|
||||||
|
const identity = await getIdentity(request, appConfig);
|
||||||
|
if (!identity) {
|
||||||
|
await reply.code(401).send({ error: "AUTHENTICATION_REQUIRED" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireOwnedSource(id: string, identity: Identity, runtime: Awaited<ReturnType<typeof createRuntime>>, appConfig: ReturnType<typeof loadConfig>) {
|
||||||
|
const workspace = await runtime.store.getWorkspace(identity);
|
||||||
|
const source = await runtime.store.getSource(id, workspace.id);
|
||||||
|
if (!source) throw new Error("SOURCE_NOT_FOUND");
|
||||||
|
const usage = await runtime.store.getUsage(workspace.id);
|
||||||
|
return { workspace, source, usage, appConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createServer() {
|
||||||
|
const appConfig = loadConfig();
|
||||||
|
const runtime = await createRuntime(appConfig);
|
||||||
|
const app = Fastify({ logger: appConfig.NODE_ENV !== "test", bodyLimit: appConfig.MAX_UPLOAD_BYTES });
|
||||||
|
app.addContentTypeParser(["application/zip", "application/octet-stream"], { parseAs: "buffer" }, (_request, payload, done) => done(null, payload));
|
||||||
|
await app.register(cookie);
|
||||||
|
// The control plane (web :3000) and API (:4000) are cross-origin, so allow
|
||||||
|
// the write methods the dashboard uses; @fastify/cors otherwise defaults to
|
||||||
|
// GET/HEAD/POST and blocks PATCH/PUT in the browser.
|
||||||
|
await app.register(cors, { origin: true, credentials: true, methods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] });
|
||||||
|
await registerAuthRoutes(app, appConfig);
|
||||||
|
|
||||||
|
app.get("/healthz", async () => ({ ok: true, service: "altdock-api", storageMode: appConfig.STORAGE_MODE }));
|
||||||
|
|
||||||
|
app.get("/api/v1/me", async (request, reply) => {
|
||||||
|
const identity = await requireIdentity(request, reply, appConfig);
|
||||||
|
if (!identity) return;
|
||||||
|
const workspace = await runtime.store.getWorkspace(identity);
|
||||||
|
return { identity, workspace };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/dashboard", async (request, reply) => {
|
||||||
|
const identity = await requireIdentity(request, reply, appConfig);
|
||||||
|
if (!identity) return;
|
||||||
|
const workspace = await runtime.store.getWorkspace(identity);
|
||||||
|
let sources = await runtime.store.listSources(workspace.id);
|
||||||
|
if (appConfig.AUTH_MODE === "demo" && sources.length === 0) {
|
||||||
|
await runtime.store.createSource(workspace.id, { name: "Demo Source", subtitle: "A PAL-ready app shelf", description: "Upload a notarized ADP ZIP to publish your first app.", visibility: "draft", tintColor: "#E9694B" });
|
||||||
|
sources = await runtime.store.listSources(workspace.id);
|
||||||
|
}
|
||||||
|
const enriched = await Promise.all(sources.map(async (source) => ({ source, apps: await runtime.store.listApps(source.id), releases: await runtime.store.listReleasesForSource(source.id) })));
|
||||||
|
return { workspace, sources: enriched, usage: await runtime.store.getUsage(workspace.id), limits: { maxSources: appConfig.MAX_SOURCES_PER_WORKSPACE, maxAppsPerSource: appConfig.MAX_APPS_PER_SOURCE, maxStorageBytes: appConfig.MAX_STORAGE_BYTES, maxUploadBytes: appConfig.MAX_UPLOAD_BYTES } };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/sources", async (request, reply) => {
|
||||||
|
const identity = await requireIdentity(request, reply, appConfig);
|
||||||
|
if (!identity) return;
|
||||||
|
const input = sourceInput.parse(request.body);
|
||||||
|
const workspace = await runtime.store.getWorkspace(identity);
|
||||||
|
const existing = await runtime.store.listSources(workspace.id);
|
||||||
|
if (existing.length >= appConfig.MAX_SOURCES_PER_WORKSPACE) return reply.code(409).send({ error: "SOURCE_QUOTA_REACHED" });
|
||||||
|
const source = await runtime.store.createSource(workspace.id, input);
|
||||||
|
return reply.code(201).send({ source });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/v1/sources/:id", async (request, reply) => {
|
||||||
|
const identity = await requireIdentity(request, reply, appConfig);
|
||||||
|
if (!identity) return;
|
||||||
|
const input = sourceInput.partial().parse(request.body);
|
||||||
|
const sourceId = (request.params as { id: string }).id;
|
||||||
|
const workspace = await runtime.store.getWorkspace(identity);
|
||||||
|
const source = await runtime.store.updateSource(sourceId, workspace.id, input);
|
||||||
|
if (!source) return reply.code(404).send({ error: "SOURCE_NOT_FOUND" });
|
||||||
|
return { source };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/apps", async (request, reply) => {
|
||||||
|
const identity = await requireIdentity(request, reply, appConfig);
|
||||||
|
if (!identity) return;
|
||||||
|
const input = appInput.parse(request.body);
|
||||||
|
const { source } = await requireOwnedSource(input.sourceId, identity, runtime, appConfig);
|
||||||
|
const apps = await runtime.store.listApps(source.id);
|
||||||
|
if (apps.length >= appConfig.MAX_APPS_PER_SOURCE) return reply.code(409).send({ error: "APP_QUOTA_REACHED" });
|
||||||
|
if (!input.name) return reply.code(400).send({ error: "APP_NAME_REQUIRED_FOR_MANUAL_APP" });
|
||||||
|
const appRecord = await runtime.store.upsertApp(source.id, { bundleIdentifier: `manual.${source.slug}.${Date.now()}`, marketplaceID: "", metadata: input });
|
||||||
|
return reply.code(201).send({ app: appRecord });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/v1/apps/:id", async (request, reply) => {
|
||||||
|
const identity = await requireIdentity(request, reply, appConfig);
|
||||||
|
if (!identity) return;
|
||||||
|
const input = appInput.omit({ sourceId: true }).partial().parse(request.body);
|
||||||
|
const appId = (request.params as { id: string }).id;
|
||||||
|
const appRecord = await runtime.store.getApp(appId);
|
||||||
|
if (!appRecord) return reply.code(404).send({ error: "APP_NOT_FOUND" });
|
||||||
|
await requireOwnedSource(appRecord.sourceId, identity, runtime, appConfig);
|
||||||
|
const updated = await runtime.store.updateApp(appId, input);
|
||||||
|
return { app: updated };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/uploads", async (request, reply) => {
|
||||||
|
const identity = await requireIdentity(request, reply, appConfig);
|
||||||
|
if (!identity) return;
|
||||||
|
const input = uploadInput.parse(request.body);
|
||||||
|
if (input.sizeBytes > appConfig.MAX_UPLOAD_BYTES) return reply.code(413).send({ error: "UPLOAD_SIZE_LIMIT", limitBytes: appConfig.MAX_UPLOAD_BYTES });
|
||||||
|
const { workspace, source } = await requireOwnedSource(input.sourceId, identity, runtime, appConfig);
|
||||||
|
const usage = await runtime.store.getUsage(workspace.id);
|
||||||
|
if (usage.storageBytes + input.sizeBytes > appConfig.MAX_STORAGE_BYTES) return reply.code(413).send({ error: "STORAGE_QUOTA_REACHED" });
|
||||||
|
if (input.appId) {
|
||||||
|
const appRecord = await runtime.store.getApp(input.appId);
|
||||||
|
if (!appRecord || appRecord.sourceId !== source.id) return reply.code(400).send({ error: "APP_NOT_IN_SOURCE" });
|
||||||
|
}
|
||||||
|
const uploadId = randomUUID();
|
||||||
|
const safeFilename = input.filename.replace(/[^a-zA-Z0-9._-]/g, "-");
|
||||||
|
const objectKey = `uploads/${workspace.id}/${uploadId}/${safeFilename}`;
|
||||||
|
const uploadPlan = await runtime.storage.createUploadPlan(objectKey, "application/zip", input.sizeBytes);
|
||||||
|
const upload = await runtime.store.createUpload({ id: uploadId, workspaceId: workspace.id, sourceId: source.id, appId: input.appId, objectKey, multipartUploadId: uploadPlan.mode === "multipart" ? uploadPlan.uploadId : undefined, originalFilename: input.filename, expectedSize: input.sizeBytes });
|
||||||
|
if (uploadPlan.mode === "single" && uploadPlan.uploadUrl.startsWith("/")) uploadPlan.uploadUrl = `${appConfig.PUBLIC_BASE_URL}${uploadPlan.uploadUrl}`;
|
||||||
|
return reply.code(201).send({ upload, uploadPlan, mode: appConfig.STORAGE_MODE });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/v1/uploads/content/:encodedKey", async (request, reply) => {
|
||||||
|
if (appConfig.STORAGE_MODE !== "local") return reply.code(404).send({ error: "DIRECT_UPLOAD_USES_STORAGE_URL" });
|
||||||
|
const key = decodeURIComponent((request.params as { encodedKey: string }).encodedKey);
|
||||||
|
if (!key.startsWith("uploads/") || key.includes("..")) return reply.code(400).send({ error: "UPLOAD_KEY_INVALID" });
|
||||||
|
const uploadId = key.split("/")[2];
|
||||||
|
const upload = await runtime.store.getUpload(uploadId);
|
||||||
|
if (!upload || upload.objectKey !== key) return reply.code(404).send({ error: "UPLOAD_NOT_FOUND" });
|
||||||
|
const body = Buffer.isBuffer(request.body) ? Readable.from(request.body) : request.body as NodeJS.ReadableStream;
|
||||||
|
const receivedSize = await runtime.storage.writeUploadFromStream(key, body);
|
||||||
|
if (receivedSize !== upload.expectedSize) return reply.code(400).send({ error: "UPLOAD_SIZE_MISMATCH", expectedSize: upload.expectedSize, receivedSize });
|
||||||
|
await runtime.store.updateUpload(upload.id, { receivedSize, status: "uploaded" });
|
||||||
|
return { ok: true, receivedSize };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/uploads/:id/complete", async (request, reply) => {
|
||||||
|
const identity = await requireIdentity(request, reply, appConfig);
|
||||||
|
if (!identity) return;
|
||||||
|
const uploadId = (request.params as { id: string }).id;
|
||||||
|
const workspace = await runtime.store.getWorkspace(identity);
|
||||||
|
const upload = await runtime.store.getUpload(uploadId, workspace.id);
|
||||||
|
if (!upload) return reply.code(404).send({ error: "UPLOAD_NOT_FOUND" });
|
||||||
|
const completeInput = multipartPartsInput.parse(request.body);
|
||||||
|
if (upload.multipartUploadId) await runtime.storage.completeMultipartUpload(upload.objectKey, upload.multipartUploadId, completeInput.parts);
|
||||||
|
const head = await runtime.storage.headObject(upload.objectKey);
|
||||||
|
if (!head || head.sizeBytes !== upload.expectedSize) return reply.code(400).send({ error: "UPLOAD_INCOMPLETE", expectedSize: upload.expectedSize, receivedSize: head?.sizeBytes || 0 });
|
||||||
|
await runtime.store.updateUpload(upload.id, { receivedSize: head.sizeBytes, status: "queued" });
|
||||||
|
if (appConfig.PROCESS_INLINE) {
|
||||||
|
try {
|
||||||
|
await processUpload(upload.id, runtime.store, runtime.storage, appConfig);
|
||||||
|
} catch {
|
||||||
|
// The failed upload and user-facing error are stored by the processor.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reply.code(202).send({ upload: await runtime.store.getUpload(upload.id), release: (await runtime.store.listReleasesForSource(upload.sourceId)).find((release) => release.uploadId === upload.id) || null });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/uploads/:id", async (request, reply) => {
|
||||||
|
const identity = await requireIdentity(request, reply, appConfig);
|
||||||
|
if (!identity) return;
|
||||||
|
const workspace = await runtime.store.getWorkspace(identity);
|
||||||
|
const upload = await runtime.store.getUpload((request.params as { id: string }).id, workspace.id);
|
||||||
|
if (!upload) return reply.code(404).send({ error: "UPLOAD_NOT_FOUND" });
|
||||||
|
const release = (await runtime.store.listReleasesForSource(upload.sourceId)).find((item) => item.uploadId === upload.id) || null;
|
||||||
|
return { upload, release };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/releases/:id", async (request, reply) => {
|
||||||
|
const identity = await requireIdentity(request, reply, appConfig);
|
||||||
|
if (!identity) return;
|
||||||
|
const release = await runtime.store.getRelease((request.params as { id: string }).id);
|
||||||
|
if (!release) return reply.code(404).send({ error: "RELEASE_NOT_FOUND" });
|
||||||
|
const appRecord = await runtime.store.getApp(release.appId);
|
||||||
|
if (!appRecord) return reply.code(404).send({ error: "APP_NOT_FOUND" });
|
||||||
|
await requireOwnedSource(appRecord.sourceId, identity, runtime, appConfig);
|
||||||
|
return { release, assets: await runtime.store.listReleaseAssets(release.id) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/v1/releases/:id/publish", async (request, reply) => {
|
||||||
|
const identity = await requireIdentity(request, reply, appConfig);
|
||||||
|
if (!identity) return;
|
||||||
|
const release = await runtime.store.getRelease((request.params as { id: string }).id);
|
||||||
|
if (!release) return reply.code(404).send({ error: "RELEASE_NOT_FOUND" });
|
||||||
|
const appRecord = await runtime.store.getApp(release.appId);
|
||||||
|
if (!appRecord) return reply.code(404).send({ error: "APP_NOT_FOUND" });
|
||||||
|
await requireOwnedSource(appRecord.sourceId, identity, runtime, appConfig);
|
||||||
|
if (release.status !== "ready" && release.status !== "published") return reply.code(409).send({ error: "RELEASE_NOT_READY", status: release.status, detail: release.errorMessage });
|
||||||
|
const updated = await runtime.store.updateRelease(release.id, { status: "published", publishedAt: new Date().toISOString() });
|
||||||
|
return { release: updated };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/sources/:slug/source.json", async (request, reply) => {
|
||||||
|
const source = await runtime.store.getSourceBySlug((request.params as { slug: string }).slug);
|
||||||
|
if (!source || source.visibility === "draft") return reply.code(404).send({ error: "SOURCE_NOT_FOUND" });
|
||||||
|
const apps = await runtime.store.listApps(source.id);
|
||||||
|
const releases = await runtime.store.listReleasesForSource(source.id);
|
||||||
|
const document = buildSourceDocument(source, apps, releases, appConfig.PUBLIC_BASE_URL);
|
||||||
|
return reply.header("cache-control", "public, max-age=60, must-revalidate").header("access-control-allow-origin", "*").type("application/json").send(document);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/sources/:slug", async (request, reply) => {
|
||||||
|
const source = await runtime.store.getSourceBySlug((request.params as { slug: string }).slug);
|
||||||
|
if (!source || source.visibility === "draft") return reply.code(404).send({ error: "SOURCE_NOT_FOUND" });
|
||||||
|
return { source, apps: await runtime.store.listApps(source.id), releases: (await runtime.store.listReleasesForSource(source.id)).filter((release) => release.status === "published") };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.route({ method: ["GET", "HEAD"], url: "/artifacts/:releaseId/*", handler: async (request, reply) => {
|
||||||
|
const params = request.params as { releaseId: string; "*": string };
|
||||||
|
const release = await runtime.store.getRelease(params.releaseId);
|
||||||
|
if (!release || release.status !== "published") return reply.code(404).send({ error: "ARTIFACT_NOT_FOUND" });
|
||||||
|
const appRecord = await runtime.store.getApp(release.appId);
|
||||||
|
if (!appRecord) return reply.code(404).send({ error: "ARTIFACT_NOT_FOUND" });
|
||||||
|
const source = await runtime.store.getSource(appRecord.sourceId);
|
||||||
|
if (!source || source.visibility === "draft") return reply.code(404).send({ error: "ARTIFACT_NOT_FOUND" });
|
||||||
|
const assetPath = safePath(params["*"]);
|
||||||
|
if (!assetPath) return reply.code(400).send({ error: "ASSET_PATH_INVALID" });
|
||||||
|
const asset = await runtime.store.getReleaseAsset(release.id, assetPath);
|
||||||
|
if (!asset) return reply.code(404).send({ error: "ASSET_NOT_FOUND" });
|
||||||
|
reply.header("content-type", asset.contentType).header("content-length", String(asset.sizeBytes)).header("cache-control", "public, max-age=31536000, immutable").header("etag", `\"${asset.sha256}\"`).header("accept-ranges", "bytes").header("access-control-allow-origin", "*");
|
||||||
|
if (request.method === "HEAD") return reply.code(200).send();
|
||||||
|
const object = await runtime.storage.getObject(asset.objectKey);
|
||||||
|
if (!object) return reply.code(404).send({ error: "ASSET_NOT_FOUND" });
|
||||||
|
return reply.send(object.body);
|
||||||
|
}});
|
||||||
|
|
||||||
|
app.setErrorHandler((error, request, reply) => {
|
||||||
|
const message = errorMessage(error);
|
||||||
|
request.log.error({ err: error }, "request failed");
|
||||||
|
const status = error instanceof z.ZodError ? 400 : (error as { statusCode?: number }).statusCode || (message.includes("_NOT_FOUND") ? 404 : 500);
|
||||||
|
return reply.code(status).send({ error: status === 500 ? "INTERNAL_ERROR" : message, detail: status === 500 ? undefined : message });
|
||||||
|
});
|
||||||
|
return { app, runtime, appConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
const { app, runtime, appConfig } = await createServer();
|
||||||
|
await app.listen({ host: appConfig.HOST, port: appConfig.PORT });
|
||||||
|
const shutdown = async () => {
|
||||||
|
await app.close();
|
||||||
|
await runtime.pool?.end();
|
||||||
|
};
|
||||||
|
process.once("SIGINT", shutdown);
|
||||||
|
process.once("SIGTERM", shutdown);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { createReadStream, createWriteStream } from "node:fs";
|
||||||
|
import { mkdir, stat, writeFile } from "node:fs/promises";
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { dirname, normalize, relative, resolve } from "node:path";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import {
|
||||||
|
CompleteMultipartUploadCommand,
|
||||||
|
CreateMultipartUploadCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
HeadObjectCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
S3Client,
|
||||||
|
UploadPartCommand,
|
||||||
|
} from "@aws-sdk/client-s3";
|
||||||
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||||
|
import type { AppConfig } from "./config";
|
||||||
|
|
||||||
|
export interface StoredObject {
|
||||||
|
body: Readable;
|
||||||
|
sizeBytes: number;
|
||||||
|
contentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UploadPlan =
|
||||||
|
| { mode: "single"; uploadUrl: string }
|
||||||
|
| { mode: "multipart"; uploadId: string; partSizeBytes: number; parts: Array<{ partNumber: number; uploadUrl: string }> };
|
||||||
|
|
||||||
|
export interface StorageAdapter {
|
||||||
|
createUploadPlan(key: string, contentType: string, sizeBytes: number): Promise<UploadPlan>;
|
||||||
|
completeMultipartUpload(key: string, uploadId: string | undefined, parts: Array<{ partNumber: number; etag: string }> | undefined): Promise<void>;
|
||||||
|
writeUploadFromStream(key: string, stream: NodeJS.ReadableStream): Promise<number>;
|
||||||
|
writeObject(key: string, body: Buffer, contentType: string): Promise<void>;
|
||||||
|
writeStream(key: string, transform: NodeJS.ReadWriteStream, contentType: string, sizeBytes: number, source: NodeJS.ReadableStream): Promise<void>;
|
||||||
|
downloadToFile(key: string, filePath: string): Promise<void>;
|
||||||
|
getObject(key: string): Promise<StoredObject | null>;
|
||||||
|
headObject(key: string): Promise<{ sizeBytes: number; contentType: string } | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentTypeForPath(path: string) {
|
||||||
|
if (path.endsWith(".json")) return "application/json; charset=utf-8";
|
||||||
|
if (path.endsWith(".ipa")) return "application/octet-stream";
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LocalStorage implements StorageAdapter {
|
||||||
|
private readonly root: string;
|
||||||
|
|
||||||
|
constructor(root: string) {
|
||||||
|
this.root = resolve(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private pathFor(key: string) {
|
||||||
|
const candidate = resolve(this.root, normalize(key));
|
||||||
|
const relativePath = relative(this.root, candidate);
|
||||||
|
if (!relativePath || relativePath.startsWith("..") || relativePath.includes("..")) {
|
||||||
|
throw new Error("STORAGE_PATH_INVALID");
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUploadPlan(key: string) {
|
||||||
|
return { mode: "single" as const, uploadUrl: `/api/v1/uploads/content/${encodeURIComponent(key)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeMultipartUpload() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeUploadFromStream(key: string, stream: NodeJS.ReadableStream) {
|
||||||
|
const filePath = this.pathFor(key);
|
||||||
|
await mkdir(dirname(filePath), { recursive: true });
|
||||||
|
await pipeline(stream, createWriteStream(filePath, { flags: "w" }));
|
||||||
|
return Number((await stat(filePath)).size);
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeObject(key: string, body: Buffer) {
|
||||||
|
const filePath = this.pathFor(key);
|
||||||
|
await mkdir(dirname(filePath), { recursive: true });
|
||||||
|
await writeFile(filePath, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeStream(key: string, transform: NodeJS.ReadWriteStream, _contentType: string, _sizeBytes: number, source: NodeJS.ReadableStream) {
|
||||||
|
const filePath = this.pathFor(key);
|
||||||
|
await mkdir(dirname(filePath), { recursive: true });
|
||||||
|
await pipeline(source, transform, createWriteStream(filePath, { flags: "w" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadToFile(key: string, filePath: string) {
|
||||||
|
await mkdir(dirname(filePath), { recursive: true });
|
||||||
|
await pipeline(createReadStream(this.pathFor(key)), createWriteStream(filePath, { flags: "w" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getObject(key: string) {
|
||||||
|
const filePath = this.pathFor(key);
|
||||||
|
try {
|
||||||
|
const fileStat = await stat(filePath);
|
||||||
|
return { body: createReadStream(filePath), sizeBytes: Number(fileStat.size), contentType: contentTypeForPath(key) };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async headObject(key: string) {
|
||||||
|
try {
|
||||||
|
const fileStat = await stat(this.pathFor(key));
|
||||||
|
return { sizeBytes: Number(fileStat.size), contentType: contentTypeForPath(key) };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class S3Storage implements StorageAdapter {
|
||||||
|
private readonly client: S3Client;
|
||||||
|
|
||||||
|
constructor(private readonly appConfig: AppConfig) {
|
||||||
|
this.client = new S3Client({
|
||||||
|
region: appConfig.S3_REGION,
|
||||||
|
endpoint: appConfig.S3_ENDPOINT,
|
||||||
|
forcePathStyle: appConfig.S3_FORCE_PATH_STYLE,
|
||||||
|
credentials:
|
||||||
|
appConfig.S3_ACCESS_KEY_ID && appConfig.S3_SECRET_ACCESS_KEY
|
||||||
|
? { accessKeyId: appConfig.S3_ACCESS_KEY_ID, secretAccessKey: appConfig.S3_SECRET_ACCESS_KEY }
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUploadPlan(key: string, contentType: string, sizeBytes: number) {
|
||||||
|
const partSizeBytes = 16 * 1024 * 1024;
|
||||||
|
const partCount = Math.ceil(sizeBytes / partSizeBytes);
|
||||||
|
if (partCount > 10000) throw new Error("UPLOAD_PART_COUNT_LIMIT");
|
||||||
|
const multipart = await this.client.send(new CreateMultipartUploadCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, ContentType: contentType }));
|
||||||
|
if (!multipart.UploadId) throw new Error("MULTIPART_UPLOAD_ID_MISSING");
|
||||||
|
const parts = await Promise.all(Array.from({ length: partCount }, async (_, index) => ({ partNumber: index + 1, uploadUrl: await getSignedUrl(this.client, new UploadPartCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, UploadId: multipart.UploadId, PartNumber: index + 1 }), { expiresIn: 900 }) })));
|
||||||
|
return { mode: "multipart" as const, uploadId: multipart.UploadId, partSizeBytes, parts };
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeMultipartUpload(key: string, uploadId: string | undefined, parts: Array<{ partNumber: number; etag: string }> | undefined) {
|
||||||
|
if (!uploadId || !parts?.length) throw new Error("MULTIPART_PARTS_REQUIRED");
|
||||||
|
await this.client.send(new CompleteMultipartUploadCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, UploadId: uploadId, MultipartUpload: { Parts: parts.sort((a, b) => a.partNumber - b.partNumber).map((part) => ({ PartNumber: part.partNumber, ETag: part.etag })) } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeUploadFromStream(key: string, stream: NodeJS.ReadableStream) {
|
||||||
|
await this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: stream as any }));
|
||||||
|
const metadata = await this.headObject(key);
|
||||||
|
return metadata?.sizeBytes || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeObject(key: string, body: Buffer, contentType: string) {
|
||||||
|
await this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: body, ContentType: contentType }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeStream(key: string, transform: NodeJS.ReadWriteStream, contentType: string, sizeBytes: number, source: NodeJS.ReadableStream) {
|
||||||
|
const upload = this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: transform as any, ContentType: contentType, ContentLength: sizeBytes || undefined }));
|
||||||
|
await pipeline(source, transform);
|
||||||
|
await upload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadToFile(key: string, filePath: string) {
|
||||||
|
const response = await this.client.send(new GetObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key }));
|
||||||
|
if (!response.Body) throw new Error("STORAGE_OBJECT_EMPTY");
|
||||||
|
await mkdir(dirname(filePath), { recursive: true });
|
||||||
|
const body = response.Body instanceof Readable ? response.Body : Readable.fromWeb(response.Body as any);
|
||||||
|
await pipeline(body, createWriteStream(filePath, { flags: "w" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getObject(key: string) {
|
||||||
|
try {
|
||||||
|
const response = await this.client.send(new GetObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key }));
|
||||||
|
if (!response.Body) return null;
|
||||||
|
const body = response.Body instanceof Readable ? response.Body : Readable.fromWeb(response.Body as any);
|
||||||
|
return { body, sizeBytes: Number(response.ContentLength || 0), contentType: response.ContentType || contentTypeForPath(key) };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async headObject(key: string) {
|
||||||
|
try {
|
||||||
|
const response = await this.client.send(new HeadObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key }));
|
||||||
|
return { sizeBytes: Number(response.ContentLength || 0), contentType: response.ContentType || contentTypeForPath(key) };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStorage(appConfig: AppConfig): StorageAdapter {
|
||||||
|
return appConfig.STORAGE_MODE === "s3" ? new S3Storage(appConfig) : new LocalStorage(appConfig.LOCAL_STORAGE_DIR);
|
||||||
|
}
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import type {
|
||||||
|
AppMetadata,
|
||||||
|
AppRecord,
|
||||||
|
Identity,
|
||||||
|
ReleaseAsset,
|
||||||
|
ReleaseRecord,
|
||||||
|
SourceRecord,
|
||||||
|
UploadRecord,
|
||||||
|
WorkspaceRecord,
|
||||||
|
} from "../../../packages/core/src/index";
|
||||||
|
import { schemaSql } from "./schema";
|
||||||
|
import type { AppConfig } from "./config";
|
||||||
|
|
||||||
|
export interface Store {
|
||||||
|
init(): Promise<void>;
|
||||||
|
getWorkspace(identity: Identity): Promise<WorkspaceRecord>;
|
||||||
|
listSources(workspaceId: string): Promise<SourceRecord[]>;
|
||||||
|
getSource(id: string, workspaceId?: string): Promise<SourceRecord | null>;
|
||||||
|
getSourceBySlug(slug: string): Promise<SourceRecord | null>;
|
||||||
|
createSource(
|
||||||
|
workspaceId: string,
|
||||||
|
input: Pick<SourceRecord, "name" | "subtitle" | "description" | "visibility"> &
|
||||||
|
Partial<Pick<SourceRecord, "iconURL" | "headerURL" | "website" | "tintColor">>,
|
||||||
|
): Promise<SourceRecord>;
|
||||||
|
updateSource(
|
||||||
|
id: string,
|
||||||
|
workspaceId: string,
|
||||||
|
patch: Partial<Pick<SourceRecord, "name" | "subtitle" | "description" | "visibility" | "iconURL" | "headerURL" | "website" | "tintColor">>,
|
||||||
|
): Promise<SourceRecord | null>;
|
||||||
|
listApps(sourceId: string): Promise<AppRecord[]>;
|
||||||
|
getApp(id: string): Promise<AppRecord | null>;
|
||||||
|
upsertApp(
|
||||||
|
sourceId: string,
|
||||||
|
input: {
|
||||||
|
bundleIdentifier: string;
|
||||||
|
marketplaceID: string;
|
||||||
|
metadata: Partial<AppMetadata>;
|
||||||
|
},
|
||||||
|
): Promise<AppRecord>;
|
||||||
|
updateApp(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<AppMetadata>,
|
||||||
|
): Promise<AppRecord | null>;
|
||||||
|
createUpload(input: Omit<UploadRecord, "status" | "receivedSize" | "createdAt">): Promise<UploadRecord>;
|
||||||
|
getUpload(id: string, workspaceId?: string): Promise<UploadRecord | null>;
|
||||||
|
updateUpload(id: string, patch: Partial<UploadRecord>): Promise<UploadRecord | null>;
|
||||||
|
createRelease(input: Omit<ReleaseRecord, "createdAt" | "status">): Promise<ReleaseRecord>;
|
||||||
|
getRelease(id: string): Promise<ReleaseRecord | null>;
|
||||||
|
updateRelease(id: string, patch: Partial<ReleaseRecord>): Promise<ReleaseRecord | null>;
|
||||||
|
listReleasesForSource(sourceId: string): Promise<ReleaseRecord[]>;
|
||||||
|
addReleaseAssets(assets: ReleaseAsset[]): Promise<void>;
|
||||||
|
listReleaseAssets(releaseId: string): Promise<ReleaseAsset[]>;
|
||||||
|
getReleaseAsset(releaseId: string, path: string): Promise<ReleaseAsset | null>;
|
||||||
|
getUsage(workspaceId: string): Promise<{ storageBytes: number; sourceCount: number }>;
|
||||||
|
listQueuedUploads(limit: number): Promise<UploadRecord[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function now() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value: string) {
|
||||||
|
const result = value
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
.slice(0, 48);
|
||||||
|
return result || `source-${randomUUID().slice(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultMetadata(metadata: Partial<AppMetadata>): AppMetadata {
|
||||||
|
return {
|
||||||
|
name: metadata.name || "Untitled App",
|
||||||
|
developerName: metadata.developerName || "AltDock Developer",
|
||||||
|
subtitle: metadata.subtitle || "",
|
||||||
|
localizedDescription: metadata.localizedDescription || "",
|
||||||
|
iconURL: metadata.iconURL,
|
||||||
|
tintColor: metadata.tintColor || "#E9694B",
|
||||||
|
category: metadata.category || "other",
|
||||||
|
screenshots: metadata.screenshots || [],
|
||||||
|
appPermissions: metadata.appPermissions || { entitlements: [], privacy: {} },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MemoryStore implements Store {
|
||||||
|
private readonly workspaces = new Map<string, WorkspaceRecord>();
|
||||||
|
private readonly workspaceBySubject = new Map<string, string>();
|
||||||
|
private readonly sources = new Map<string, SourceRecord>();
|
||||||
|
private readonly apps = new Map<string, AppRecord>();
|
||||||
|
private readonly uploads = new Map<string, UploadRecord>();
|
||||||
|
private readonly releases = new Map<string, ReleaseRecord>();
|
||||||
|
private readonly assets = new Map<string, ReleaseAsset>();
|
||||||
|
|
||||||
|
async init() {}
|
||||||
|
|
||||||
|
async getWorkspace(identity: Identity) {
|
||||||
|
const existingId = this.workspaceBySubject.get(identity.subject);
|
||||||
|
if (existingId) return this.workspaces.get(existingId)!;
|
||||||
|
const id = randomUUID();
|
||||||
|
const workspace: WorkspaceRecord = {
|
||||||
|
id,
|
||||||
|
name: `${identity.displayName || "Developer"}'s Workspace`,
|
||||||
|
slug: slugify(identity.displayName || identity.email),
|
||||||
|
ownerSubject: identity.subject,
|
||||||
|
ownerEmail: identity.email,
|
||||||
|
createdAt: now(),
|
||||||
|
};
|
||||||
|
this.workspaces.set(id, workspace);
|
||||||
|
this.workspaceBySubject.set(identity.subject, id);
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSources(workspaceId: string) {
|
||||||
|
return [...this.sources.values()]
|
||||||
|
.filter((source) => source.workspaceId === workspaceId)
|
||||||
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSource(id: string, workspaceId?: string) {
|
||||||
|
const source = this.sources.get(id);
|
||||||
|
return source && (!workspaceId || source.workspaceId === workspaceId) ? source : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSourceBySlug(slug: string) {
|
||||||
|
return [...this.sources.values()].find((source) => source.slug === slug) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSource(workspaceId: string, input: Pick<SourceRecord, "name" | "subtitle" | "description" | "visibility"> & Partial<Pick<SourceRecord, "iconURL" | "headerURL" | "website" | "tintColor">>) {
|
||||||
|
const createdAt = now();
|
||||||
|
const source: SourceRecord = {
|
||||||
|
id: randomUUID(),
|
||||||
|
workspaceId,
|
||||||
|
slug: `${slugify(input.name)}-${randomUUID().slice(0, 6)}`,
|
||||||
|
name: input.name,
|
||||||
|
subtitle: input.subtitle || "",
|
||||||
|
description: input.description || "",
|
||||||
|
iconURL: input.iconURL,
|
||||||
|
headerURL: input.headerURL,
|
||||||
|
website: input.website,
|
||||||
|
tintColor: input.tintColor || "#E9694B",
|
||||||
|
visibility: input.visibility,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
};
|
||||||
|
this.sources.set(source.id, source);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSource(id: string, workspaceId: string, patch: Partial<Pick<SourceRecord, "name" | "subtitle" | "description" | "visibility" | "iconURL" | "headerURL" | "website" | "tintColor">>) {
|
||||||
|
const source = await this.getSource(id, workspaceId);
|
||||||
|
if (!source) return null;
|
||||||
|
Object.assign(source, patch, { updatedAt: now() });
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listApps(sourceId: string) {
|
||||||
|
return [...this.apps.values()].filter((app) => app.sourceId === sourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getApp(id: string) {
|
||||||
|
return this.apps.get(id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertApp(sourceId: string, input: { bundleIdentifier: string; marketplaceID: string; metadata: Partial<AppMetadata> }) {
|
||||||
|
const existing = [...this.apps.values()].find((app) => app.sourceId === sourceId && app.bundleIdentifier === input.bundleIdentifier);
|
||||||
|
const metadata = defaultMetadata(input.metadata);
|
||||||
|
if (existing) {
|
||||||
|
Object.assign(existing, metadata, { marketplaceID: input.marketplaceID || existing.marketplaceID, updatedAt: now() });
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const app: AppRecord = {
|
||||||
|
id: randomUUID(),
|
||||||
|
sourceId,
|
||||||
|
bundleIdentifier: input.bundleIdentifier,
|
||||||
|
marketplaceID: input.marketplaceID,
|
||||||
|
...metadata,
|
||||||
|
createdAt: now(),
|
||||||
|
updatedAt: now(),
|
||||||
|
};
|
||||||
|
this.apps.set(app.id, app);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateApp(id: string, patch: Partial<AppMetadata>) {
|
||||||
|
const app = this.apps.get(id);
|
||||||
|
if (!app) return null;
|
||||||
|
Object.assign(app, patch, { updatedAt: now() });
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUpload(input: Omit<UploadRecord, "status" | "receivedSize" | "createdAt">) {
|
||||||
|
const upload: UploadRecord = { ...input, status: "created", receivedSize: 0, createdAt: now() };
|
||||||
|
this.uploads.set(upload.id, upload);
|
||||||
|
return upload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUpload(id: string, workspaceId?: string) {
|
||||||
|
const upload = this.uploads.get(id);
|
||||||
|
return upload && (!workspaceId || upload.workspaceId === workspaceId) ? upload : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUpload(id: string, patch: Partial<UploadRecord>) {
|
||||||
|
const upload = this.uploads.get(id);
|
||||||
|
if (!upload) return null;
|
||||||
|
Object.assign(upload, patch);
|
||||||
|
return upload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRelease(input: Omit<ReleaseRecord, "createdAt" | "status">) {
|
||||||
|
const release: ReleaseRecord = { ...input, status: "processing", createdAt: now() };
|
||||||
|
this.releases.set(release.id, release);
|
||||||
|
return release;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRelease(id: string) {
|
||||||
|
return this.releases.get(id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRelease(id: string, patch: Partial<ReleaseRecord>) {
|
||||||
|
const release = this.releases.get(id);
|
||||||
|
if (!release) return null;
|
||||||
|
Object.assign(release, patch);
|
||||||
|
return release;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listReleasesForSource(sourceId: string) {
|
||||||
|
const appIds = new Set((await this.listApps(sourceId)).map((app) => app.id));
|
||||||
|
return [...this.releases.values()].filter((release) => appIds.has(release.appId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async addReleaseAssets(assets: ReleaseAsset[]) {
|
||||||
|
for (const asset of assets) this.assets.set(`${asset.releaseId}:${asset.path}`, asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listReleaseAssets(releaseId: string) {
|
||||||
|
return [...this.assets.values()].filter((asset) => asset.releaseId === releaseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReleaseAsset(releaseId: string, path: string) {
|
||||||
|
return this.assets.get(`${releaseId}:${path}`) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUsage(workspaceId: string) {
|
||||||
|
const sourceCount = (await this.listSources(workspaceId)).length;
|
||||||
|
const sourceIds = new Set((await this.listSources(workspaceId)).map((source) => source.id));
|
||||||
|
const appIds = new Set([...this.apps.values()].filter((app) => sourceIds.has(app.sourceId)).map((app) => app.id));
|
||||||
|
const releaseIds = new Set([...this.releases.values()].filter((release) => appIds.has(release.appId)).map((release) => release.id));
|
||||||
|
const storageBytes = [...this.assets.values()].filter((asset) => releaseIds.has(asset.releaseId)).reduce((sum, asset) => sum + asset.sizeBytes, 0);
|
||||||
|
return { storageBytes, sourceCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listQueuedUploads(limit: number) {
|
||||||
|
return [...this.uploads.values()].filter((upload) => upload.status === "queued").slice(0, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Queryable = Pick<Pool, "query">;
|
||||||
|
|
||||||
|
function mapSource(row: any): SourceRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
workspaceId: row.workspace_id,
|
||||||
|
slug: row.slug,
|
||||||
|
name: row.name,
|
||||||
|
subtitle: row.subtitle,
|
||||||
|
description: row.description,
|
||||||
|
iconURL: row.icon_url || undefined,
|
||||||
|
headerURL: row.header_url || undefined,
|
||||||
|
website: row.website || undefined,
|
||||||
|
tintColor: row.tint_color,
|
||||||
|
visibility: row.visibility,
|
||||||
|
createdAt: new Date(row.created_at).toISOString(),
|
||||||
|
updatedAt: new Date(row.updated_at).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapApp(row: any): AppRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
sourceId: row.source_id,
|
||||||
|
name: row.name,
|
||||||
|
bundleIdentifier: row.bundle_identifier,
|
||||||
|
marketplaceID: row.marketplace_id,
|
||||||
|
developerName: row.developer_name,
|
||||||
|
subtitle: row.subtitle,
|
||||||
|
localizedDescription: row.localized_description,
|
||||||
|
iconURL: row.icon_url || undefined,
|
||||||
|
tintColor: row.tint_color,
|
||||||
|
category: row.category,
|
||||||
|
screenshots: row.screenshots || [],
|
||||||
|
appPermissions: row.app_permissions || { entitlements: [], privacy: {} },
|
||||||
|
createdAt: new Date(row.created_at).toISOString(),
|
||||||
|
updatedAt: new Date(row.updated_at).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUpload(row: any): UploadRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
workspaceId: row.workspace_id,
|
||||||
|
sourceId: row.source_id,
|
||||||
|
appId: row.app_id || undefined,
|
||||||
|
objectKey: row.object_key,
|
||||||
|
multipartUploadId: row.multipart_upload_id || undefined,
|
||||||
|
originalFilename: row.original_filename,
|
||||||
|
expectedSize: Number(row.expected_size),
|
||||||
|
receivedSize: Number(row.received_size),
|
||||||
|
status: row.status,
|
||||||
|
errorCode: row.error_code || undefined,
|
||||||
|
errorMessage: row.error_message || undefined,
|
||||||
|
createdAt: new Date(row.created_at).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRelease(row: any): ReleaseRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
appId: row.app_id,
|
||||||
|
uploadId: row.upload_id,
|
||||||
|
version: row.version,
|
||||||
|
buildVersion: row.build_version,
|
||||||
|
appleItemId: row.apple_item_id,
|
||||||
|
date: new Date(row.date).toISOString(),
|
||||||
|
localizedDescription: row.localized_description,
|
||||||
|
minOSVersion: row.min_os_version || undefined,
|
||||||
|
sizeBytes: Number(row.size_bytes),
|
||||||
|
status: row.status,
|
||||||
|
errorCode: row.error_code || undefined,
|
||||||
|
errorMessage: row.error_message || undefined,
|
||||||
|
manifest: row.manifest,
|
||||||
|
createdAt: new Date(row.created_at).toISOString(),
|
||||||
|
publishedAt: row.published_at ? new Date(row.published_at).toISOString() : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PostgresStore implements Store {
|
||||||
|
constructor(private readonly pool: Queryable) {}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.pool.query(schemaSql);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWorkspace(identity: Identity) {
|
||||||
|
const user = await this.pool.query<any>(
|
||||||
|
`INSERT INTO users (id, subject, email, display_name)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (subject) DO UPDATE SET email = EXCLUDED.email, display_name = EXCLUDED.display_name
|
||||||
|
RETURNING *`,
|
||||||
|
[randomUUID(), identity.subject, identity.email, identity.displayName],
|
||||||
|
);
|
||||||
|
const userRow = user.rows[0];
|
||||||
|
const workspace = await this.pool.query<any>(
|
||||||
|
`INSERT INTO workspaces (id, owner_user_id, name, slug)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
|
||||||
|
RETURNING *`,
|
||||||
|
[randomUUID(), userRow.id, `${identity.displayName || "Developer"}'s Workspace`, `${slugify(identity.displayName || identity.email)}-${userRow.id.slice(0, 6)}`],
|
||||||
|
);
|
||||||
|
const row = workspace.rows[0];
|
||||||
|
return { id: row.id, name: row.name, slug: row.slug, ownerSubject: identity.subject, ownerEmail: identity.email, createdAt: new Date(row.created_at).toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSources(workspaceId: string) {
|
||||||
|
const result = await this.pool.query(`SELECT * FROM sources WHERE workspace_id = $1 ORDER BY updated_at DESC`, [workspaceId]);
|
||||||
|
return result.rows.map(mapSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSource(id: string, workspaceId?: string) {
|
||||||
|
const result = await this.pool.query(`SELECT * FROM sources WHERE id = $1 ${workspaceId ? "AND workspace_id = $2" : ""}`, workspaceId ? [id, workspaceId] : [id]);
|
||||||
|
return result.rows[0] ? mapSource(result.rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSourceBySlug(slug: string) {
|
||||||
|
const result = await this.pool.query(`SELECT * FROM sources WHERE slug = $1`, [slug]);
|
||||||
|
return result.rows[0] ? mapSource(result.rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSource(workspaceId: string, input: Pick<SourceRecord, "name" | "subtitle" | "description" | "visibility"> & Partial<Pick<SourceRecord, "iconURL" | "headerURL" | "website" | "tintColor">>) {
|
||||||
|
const result = await this.pool.query(`INSERT INTO sources (id, workspace_id, slug, name, subtitle, description, icon_url, header_url, website, tint_color, visibility) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`, [randomUUID(), workspaceId, `${slugify(input.name)}-${randomUUID().slice(0, 6)}`, input.name, input.subtitle || "", input.description || "", input.iconURL || null, input.headerURL || null, input.website || null, input.tintColor || "#E9694B", input.visibility]);
|
||||||
|
return mapSource(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSource(id: string, workspaceId: string, patch: Partial<Pick<SourceRecord, "name" | "subtitle" | "description" | "visibility" | "iconURL" | "headerURL" | "website" | "tintColor">>) {
|
||||||
|
const source = await this.getSource(id, workspaceId);
|
||||||
|
if (!source) return null;
|
||||||
|
const next = { ...source, ...patch };
|
||||||
|
const result = await this.pool.query(`UPDATE sources SET name=$1, subtitle=$2, description=$3, icon_url=$4, header_url=$5, website=$6, tint_color=$7, visibility=$8, updated_at=NOW() WHERE id=$9 AND workspace_id=$10 RETURNING *`, [next.name, next.subtitle, next.description, next.iconURL || null, next.headerURL || null, next.website || null, next.tintColor, next.visibility, id, workspaceId]);
|
||||||
|
return result.rows[0] ? mapSource(result.rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listApps(sourceId: string) {
|
||||||
|
const result = await this.pool.query(`SELECT * FROM apps WHERE source_id = $1 ORDER BY created_at ASC`, [sourceId]);
|
||||||
|
return result.rows.map(mapApp);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getApp(id: string) {
|
||||||
|
const result = await this.pool.query(`SELECT * FROM apps WHERE id = $1`, [id]);
|
||||||
|
return result.rows[0] ? mapApp(result.rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertApp(sourceId: string, input: { bundleIdentifier: string; marketplaceID: string; metadata: Partial<AppMetadata> }) {
|
||||||
|
const metadata = defaultMetadata(input.metadata);
|
||||||
|
const result = await this.pool.query(`INSERT INTO apps (id, source_id, name, bundle_identifier, marketplace_id, developer_name, subtitle, localized_description, icon_url, tint_color, category, screenshots, app_permissions) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT (source_id, bundle_identifier) DO UPDATE SET marketplace_id=EXCLUDED.marketplace_id, name=EXCLUDED.name, developer_name=EXCLUDED.developer_name, subtitle=EXCLUDED.subtitle, localized_description=EXCLUDED.localized_description, icon_url=EXCLUDED.icon_url, tint_color=EXCLUDED.tint_color, category=EXCLUDED.category, screenshots=EXCLUDED.screenshots, app_permissions=EXCLUDED.app_permissions, updated_at=NOW() RETURNING *`, [randomUUID(), sourceId, metadata.name, input.bundleIdentifier, input.marketplaceID, metadata.developerName, metadata.subtitle, metadata.localizedDescription, metadata.iconURL || null, metadata.tintColor, metadata.category, JSON.stringify(metadata.screenshots), JSON.stringify(metadata.appPermissions)]);
|
||||||
|
return mapApp(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateApp(id: string, patch: Partial<AppMetadata>) {
|
||||||
|
const app = await this.getApp(id);
|
||||||
|
if (!app) return null;
|
||||||
|
const next = { ...app, ...patch };
|
||||||
|
const result = await this.pool.query(`UPDATE apps SET name=$1, developer_name=$2, subtitle=$3, localized_description=$4, icon_url=$5, tint_color=$6, category=$7, screenshots=$8, app_permissions=$9, updated_at=NOW() WHERE id=$10 RETURNING *`, [next.name, next.developerName, next.subtitle, next.localizedDescription, next.iconURL || null, next.tintColor, next.category, JSON.stringify(next.screenshots), JSON.stringify(next.appPermissions), id]);
|
||||||
|
return result.rows[0] ? mapApp(result.rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUpload(input: Omit<UploadRecord, "status" | "receivedSize" | "createdAt">) {
|
||||||
|
const result = await this.pool.query(`INSERT INTO uploads (id, workspace_id, source_id, app_id, object_key, multipart_upload_id, original_filename, expected_size) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, [input.id, input.workspaceId, input.sourceId, input.appId || null, input.objectKey, input.multipartUploadId || null, input.originalFilename, input.expectedSize]);
|
||||||
|
return mapUpload(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUpload(id: string, workspaceId?: string) {
|
||||||
|
const result = await this.pool.query(`SELECT * FROM uploads WHERE id=$1 ${workspaceId ? "AND workspace_id=$2" : ""}`, workspaceId ? [id, workspaceId] : [id]);
|
||||||
|
return result.rows[0] ? mapUpload(result.rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUpload(id: string, patch: Partial<UploadRecord>) {
|
||||||
|
const current = await this.getUpload(id);
|
||||||
|
if (!current) return null;
|
||||||
|
const next = { ...current, ...patch };
|
||||||
|
const result = await this.pool.query(`UPDATE uploads SET app_id=$1, multipart_upload_id=$2, received_size=$3, status=$4, error_code=$5, error_message=$6 WHERE id=$7 RETURNING *`, [next.appId || null, next.multipartUploadId || null, next.receivedSize, next.status, next.errorCode || null, next.errorMessage || null, id]);
|
||||||
|
return result.rows[0] ? mapUpload(result.rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRelease(input: Omit<ReleaseRecord, "createdAt" | "status">) {
|
||||||
|
const result = await this.pool.query(`INSERT INTO releases (id, app_id, upload_id, version, build_version, apple_item_id, date, localized_description, min_os_version, size_bytes, status, manifest) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'processing',$11) RETURNING *`, [input.id, input.appId, input.uploadId, input.version, input.buildVersion, input.appleItemId, input.date, input.localizedDescription, input.minOSVersion || null, input.sizeBytes, JSON.stringify(input.manifest)]);
|
||||||
|
return mapRelease(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRelease(id: string) {
|
||||||
|
const result = await this.pool.query(`SELECT * FROM releases WHERE id=$1`, [id]);
|
||||||
|
return result.rows[0] ? mapRelease(result.rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRelease(id: string, patch: Partial<ReleaseRecord>) {
|
||||||
|
const current = await this.getRelease(id);
|
||||||
|
if (!current) return null;
|
||||||
|
const next = { ...current, ...patch };
|
||||||
|
const result = await this.pool.query(`UPDATE releases SET status=$1, error_code=$2, error_message=$3, size_bytes=$4, localized_description=$5, published_at=$6 WHERE id=$7 RETURNING *`, [next.status, next.errorCode || null, next.errorMessage || null, next.sizeBytes, next.localizedDescription, next.publishedAt || null, id]);
|
||||||
|
return result.rows[0] ? mapRelease(result.rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listReleasesForSource(sourceId: string) {
|
||||||
|
const result = await this.pool.query(`SELECT releases.* FROM releases JOIN apps ON apps.id = releases.app_id WHERE apps.source_id=$1 ORDER BY releases.date DESC, releases.build_version DESC`, [sourceId]);
|
||||||
|
return result.rows.map(mapRelease);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addReleaseAssets(assets: ReleaseAsset[]) {
|
||||||
|
for (const asset of assets) {
|
||||||
|
await this.pool.query(`INSERT INTO release_assets (id, release_id, path, object_key, size_bytes, sha256, content_type) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (release_id, path) DO UPDATE SET object_key=EXCLUDED.object_key, size_bytes=EXCLUDED.size_bytes, sha256=EXCLUDED.sha256, content_type=EXCLUDED.content_type`, [randomUUID(), asset.releaseId, asset.path, asset.objectKey, asset.sizeBytes, asset.sha256, asset.contentType]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listReleaseAssets(releaseId: string) {
|
||||||
|
const result = await this.pool.query(`SELECT release_id, path, object_key, size_bytes, sha256, content_type FROM release_assets WHERE release_id=$1 ORDER BY path`, [releaseId]);
|
||||||
|
return result.rows.map((row) => ({ releaseId: row.release_id, path: row.path, objectKey: row.object_key, sizeBytes: Number(row.size_bytes), sha256: row.sha256, contentType: row.content_type }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReleaseAsset(releaseId: string, path: string) {
|
||||||
|
const result = await this.pool.query(`SELECT release_id, path, object_key, size_bytes, sha256, content_type FROM release_assets WHERE release_id=$1 AND path=$2`, [releaseId, path]);
|
||||||
|
const row = result.rows[0];
|
||||||
|
return row ? { releaseId: row.release_id, path: row.path, objectKey: row.object_key, sizeBytes: Number(row.size_bytes), sha256: row.sha256, contentType: row.content_type } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUsage(workspaceId: string) {
|
||||||
|
const result = await this.pool.query(`SELECT COUNT(DISTINCT s.id)::int AS source_count, COALESCE(SUM(u.received_size),0)::bigint AS storage_bytes FROM sources s LEFT JOIN uploads u ON u.workspace_id=s.workspace_id WHERE s.workspace_id=$1`, [workspaceId]);
|
||||||
|
return { storageBytes: Number(result.rows[0]?.storage_bytes || 0), sourceCount: Number(result.rows[0]?.source_count || 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listQueuedUploads(limit: number) {
|
||||||
|
const result = await this.pool.query(`SELECT * FROM uploads WHERE status='queued' ORDER BY created_at LIMIT $1`, [limit]);
|
||||||
|
return result.rows.map(mapUpload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStore(appConfig: AppConfig): Promise<{ store: Store; pool?: Pool }> {
|
||||||
|
if (!appConfig.DATABASE_URL) return { store: new MemoryStore() };
|
||||||
|
const pool = new Pool({ connectionString: appConfig.DATABASE_URL });
|
||||||
|
return { store: new PostgresStore(pool), pool };
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { loadConfig } from "../../api/src/config";
|
||||||
|
import { processUpload } from "../../api/src/processor";
|
||||||
|
import { createRuntime } from "../../api/src/runtime";
|
||||||
|
|
||||||
|
const appConfig = loadConfig();
|
||||||
|
const runtime = await createRuntime(appConfig);
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
const uploads = await runtime.store.listQueuedUploads(2);
|
||||||
|
for (const upload of uploads) {
|
||||||
|
try {
|
||||||
|
await processUpload(upload.id, runtime.store, runtime.storage, appConfig);
|
||||||
|
console.log(`[worker] processed ${upload.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[worker] failed ${upload.id}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`AltDock worker listening for queued uploads (${appConfig.STORAGE_MODE} storage).`);
|
||||||
|
await tick();
|
||||||
|
const interval = setInterval(() => void tick(), 2500);
|
||||||
|
|
||||||
|
const shutdown = async () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
await runtime.pool?.end();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.once("SIGINT", shutdown);
|
||||||
|
process.once("SIGTERM", shutdown);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { access, cp, mkdir, rm } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import type { Plugin } from "vite";
|
||||||
|
|
||||||
|
async function exists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(path);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Packages Sites metadata and migrations after Vite finishes compiling.
|
||||||
|
export function sites(): Plugin {
|
||||||
|
let root = process.cwd();
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "sites",
|
||||||
|
apply: "build",
|
||||||
|
configResolved(config) {
|
||||||
|
root = config.root;
|
||||||
|
},
|
||||||
|
async closeBundle() {
|
||||||
|
const outputDirectory = resolve(root, "dist", ".openai");
|
||||||
|
const hostingConfig = resolve(root, ".openai", "hosting.json");
|
||||||
|
const drizzleSource = resolve(root, "drizzle");
|
||||||
|
|
||||||
|
await rm(outputDirectory, { recursive: true, force: true });
|
||||||
|
await mkdir(outputDirectory, { recursive: true });
|
||||||
|
|
||||||
|
if (await exists(hostingConfig)) {
|
||||||
|
await cp(hostingConfig, resolve(outputDirectory, "hosting.json"));
|
||||||
|
}
|
||||||
|
if (await exists(drizzleSource)) {
|
||||||
|
await cp(drizzleSource, resolve(outputDirectory, "drizzle"), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as Toast from "@radix-ui/react-toast";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useMemo, useState, type ComponentType, type PropsWithChildren } from "react";
|
||||||
|
import { AppWindow, BookOpen, Boxes, ChevronDown, ExternalLink, FileArchive, Gauge, Menu, PanelLeftClose, PanelLeftOpen, Plus, Search, Settings2, UploadCloud, X } from "lucide-react";
|
||||||
|
import { API_BASE, AUTH_MODE, formatBytes } from "@/app/lib/dashboard";
|
||||||
|
import { useDashboard } from "./dashboard-provider";
|
||||||
|
|
||||||
|
type NavItem = { href: string; label: string; icon: ComponentType<{ size?: number; strokeWidth?: number }> };
|
||||||
|
|
||||||
|
const primaryNav: NavItem[] = [
|
||||||
|
{ href: "/dashboard", label: "概要", icon: Gauge },
|
||||||
|
{ href: "/dashboard/sources", label: "Sources", icon: Boxes },
|
||||||
|
{ href: "/dashboard/apps", label: "アプリ", icon: AppWindow },
|
||||||
|
{ href: "/dashboard/releases", label: "リリース", icon: FileArchive },
|
||||||
|
];
|
||||||
|
|
||||||
|
const secondaryNav: NavItem[] = [
|
||||||
|
{ href: "https://faq.altstore.io/developers/distribute-with-altstore-pal", label: "PALドキュメント", icon: BookOpen },
|
||||||
|
{ href: "/dashboard/settings", label: "設定", icon: Settings2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function isActive(pathname: string, href: string) {
|
||||||
|
if (href === "/dashboard") return pathname === "/dashboard";
|
||||||
|
return pathname.startsWith(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SideNavigation({ collapsed, onNavigate }: { collapsed?: boolean; onNavigate?: () => void }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const { dashboard } = useDashboard();
|
||||||
|
|
||||||
|
const renderItem = (item: NavItem) => {
|
||||||
|
const external = item.href.startsWith("http");
|
||||||
|
const Icon = item.icon;
|
||||||
|
const className = `admin-nav-item${isActive(pathname, item.href) ? " active" : ""}`;
|
||||||
|
const content = <><Icon size={16} strokeWidth={1.8} /><span>{item.label}</span>{external && <ExternalLink className="admin-nav-external" size={12} />}</>;
|
||||||
|
if (external) return <a key={item.href} className={className} href={item.href} target="_blank" rel="noreferrer" onClick={onNavigate}>{content}</a>;
|
||||||
|
return <Link key={item.href} className={className} href={item.href} onClick={onNavigate}>{content}</Link>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const storagePercent = dashboard ? Math.min(100, (dashboard.usage.storageBytes / Math.max(1, dashboard.limits.maxStorageBytes)) * 100) : 0;
|
||||||
|
|
||||||
|
return <div className="admin-sidebar-inner">
|
||||||
|
<button className="admin-brand" onClick={() => router.push("/dashboard")} aria-label="AltDock 概要へ移動"><span className="admin-brand-mark">A</span><span className={collapsed ? "sr-only" : "admin-brand-name"}>AltDock</span><span className={collapsed ? "sr-only" : "admin-brand-beta"}>BETA</span></button>
|
||||||
|
{!collapsed && <div className="admin-workspace-switcher"><span className="admin-workspace-avatar">{dashboard?.workspace.name.slice(0, 1).toUpperCase() || "D"}</span><span className="admin-workspace-copy"><small>WORKSPACE</small><strong>{dashboard?.workspace.name || "Developer Workspace"}</strong></span><ChevronDown size={14} /></div>}
|
||||||
|
<div className={collapsed ? "sr-only" : "admin-nav-label"}>管理</div>
|
||||||
|
<nav className="admin-nav" aria-label="管理画面ナビゲーション">{primaryNav.map(renderItem)}</nav>
|
||||||
|
<div className={collapsed ? "sr-only" : "admin-nav-label admin-nav-label-secondary"}>その他</div>
|
||||||
|
<nav className="admin-nav admin-nav-secondary" aria-label="その他のナビゲーション">{secondaryNav.map(renderItem)}</nav>
|
||||||
|
<div className="admin-sidebar-spacer" />
|
||||||
|
{!collapsed && <div className="admin-storage-card"><div className="admin-storage-row"><span>保存容量</span><strong>{formatBytes(dashboard?.usage.storageBytes || 0)} / {formatBytes(dashboard?.limits.maxStorageBytes || 0)}</strong></div><div className="admin-progress"><span style={{ width: `${storagePercent}%` }} /></div><small>Free workspace plan</small></div>}
|
||||||
|
{!collapsed && <div className="admin-user-card"><span className="admin-user-avatar">D</span><span><strong>Demo Developer</strong><small>demo@altdock.local</small></span><span className="admin-user-menu">···</span></div>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandPalette({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const commands = useMemo(() => primaryNav.map((item) => ({ ...item, action: () => router.push(item.href) })), [router]);
|
||||||
|
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent className="command-dialog"><DialogTitle className="sr-only">画面を検索</DialogTitle><DialogDescription className="sr-only">移動先を検索できます。</DialogDescription><Command label="画面を検索"><CommandInput placeholder="画面や操作を検索…" autoFocus /><CommandList><CommandEmpty>該当する画面がありません。</CommandEmpty><CommandGroup heading="移動"><CommandItem onSelect={() => { commands[0].action(); onOpenChange(false); }}><Gauge size={15} />概要<span className="command-shortcut">G O</span></CommandItem><CommandItem onSelect={() => { commands[1].action(); onOpenChange(false); }}><Boxes size={15} />Sources<span className="command-shortcut">G S</span></CommandItem><CommandItem onSelect={() => { commands[2].action(); onOpenChange(false); }}><AppWindow size={15} />アプリ<span className="command-shortcut">G A</span></CommandItem><CommandItem onSelect={() => { commands[3].action(); onOpenChange(false); }}><FileArchive size={15} />リリース<span className="command-shortcut">G R</span></CommandItem></CommandGroup><CommandGroup heading="操作"><CommandItem onSelect={() => { router.push("/dashboard/releases?upload=1"); onOpenChange(false); }}><UploadCloud size={15} />ADPをアップロード</CommandItem></CommandGroup></CommandList></Command></DialogContent></Dialog>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminShell({ children }: PropsWithChildren) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [commandOpen, setCommandOpen] = useState(false);
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { notice, dismissNotice, dashboard } = useDashboard();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleShortcut = (event: KeyboardEvent) => {
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { event.preventDefault(); setCommandOpen(true); }
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleShortcut);
|
||||||
|
return () => window.removeEventListener("keydown", handleShortcut);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pageLabel = pathname === "/dashboard" ? "概要" : pathname.includes("/sources") ? "Sources" : pathname.includes("/apps") ? "アプリ" : pathname.includes("/releases") ? "リリース" : "設定";
|
||||||
|
|
||||||
|
return <Toast.Provider swipeDirection="right">
|
||||||
|
<div className="admin-shell">
|
||||||
|
<aside className={`admin-sidebar ${collapsed ? "is-collapsed" : ""}`}><SideNavigation collapsed={collapsed} /></aside>
|
||||||
|
<Dialog open={mobileOpen} onOpenChange={setMobileOpen}><DialogContent className="mobile-nav-dialog"><DialogTitle className="sr-only">管理画面ナビゲーション</DialogTitle><DialogDescription className="sr-only">管理画面の移動先を選択します。</DialogDescription><button className="mobile-nav-close" onClick={() => setMobileOpen(false)} aria-label="ナビゲーションを閉じる"><X size={17} /></button><SideNavigation onNavigate={() => setMobileOpen(false)} /></DialogContent></Dialog>
|
||||||
|
<section className="admin-main-column">
|
||||||
|
<header className="admin-topbar"><div className="admin-topbar-left"><Button className="mobile-menu-button" variant="ghost" size="icon" onClick={() => setMobileOpen(true)} aria-label="ナビゲーションを開く"><Menu size={18} /></Button><div className="admin-breadcrumb"><span>Workspace</span><span>/</span><strong>{pageLabel}</strong></div></div><div className="admin-topbar-actions"><button className="admin-search-trigger" onClick={() => setCommandOpen(true)}><Search size={15} /><span>検索</span><kbd>⌘ K</kbd></button><Link className="admin-topbar-action" href="/dashboard/releases?upload=1"><Plus size={15} />新しいリリース</Link>{AUTH_MODE === "oidc" ? <a className="admin-auth-link" href={`${API_BASE}/auth/login`}>SSOでログイン</a> : <a className="admin-auth-link" href={`${API_BASE}/healthz`} target="_blank" rel="noreferrer">API status</a>}</div></header>
|
||||||
|
<div className="admin-content">{children}</div>
|
||||||
|
</section>
|
||||||
|
<button className="admin-collapse-toggle" onClick={() => setCollapsed((value) => !value)} aria-label={collapsed ? "サイドバーを展開" : "サイドバーを折りたたむ"}>{collapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}</button>
|
||||||
|
</div>
|
||||||
|
{notice && <Toast.Root open onOpenChange={(open) => { if (!open) dismissNotice(); }} className={`admin-toast toast-${notice.tone}`}><Toast.Title>{notice.tone === "error" ? "処理に失敗しました" : notice.tone === "info" ? "処理中" : "完了"}</Toast.Title><Toast.Description>{notice.text}</Toast.Description><Toast.Close aria-label="通知を閉じる">×</Toast.Close></Toast.Root>}
|
||||||
|
<Toast.Viewport className="admin-toast-viewport" />
|
||||||
|
<CommandPalette open={commandOpen} onOpenChange={setCommandOpen} />
|
||||||
|
</Toast.Provider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type PropsWithChildren } from "react";
|
||||||
|
import type { AppRecord, ReleaseRecord, SourceRecord } from "@/packages/core/src/index";
|
||||||
|
import { API_BASE, api, type DashboardData, type Notice, type Visibility } from "@/app/lib/dashboard";
|
||||||
|
|
||||||
|
type SourceInput = Pick<SourceRecord, "name" | "subtitle" | "description" | "visibility"> & Partial<Pick<SourceRecord, "iconURL" | "headerURL" | "website" | "tintColor">>;
|
||||||
|
|
||||||
|
type UploadPlan =
|
||||||
|
| { mode: "single"; uploadUrl: string }
|
||||||
|
| { mode: "multipart"; uploadId: string; partSizeBytes: number; parts: Array<{ partNumber: number; uploadUrl: string }> };
|
||||||
|
|
||||||
|
type AppPatch = Partial<Pick<AppRecord, "name" | "developerName" | "subtitle" | "localizedDescription" | "iconURL" | "category" | "appPermissions">>;
|
||||||
|
|
||||||
|
type DashboardContextValue = {
|
||||||
|
dashboard: DashboardData | null;
|
||||||
|
loading: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
notice: Notice | null;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
dismissNotice: () => void;
|
||||||
|
createSource: (input: SourceInput) => Promise<void>;
|
||||||
|
updateSource: (id: string, input: Partial<SourceInput>) => Promise<void>;
|
||||||
|
changeVisibility: (id: string, visibility: Visibility) => Promise<void>;
|
||||||
|
uploadRelease: (sourceId: string, file: File, appId?: string) => Promise<ReleaseRecord | null>;
|
||||||
|
saveApp: (id: string, patch: AppPatch) => Promise<void>;
|
||||||
|
publishRelease: (id: string) => Promise<void>;
|
||||||
|
copySourceUrl: (source: SourceRecord) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DashboardContext = createContext<DashboardContextValue | null>(null);
|
||||||
|
|
||||||
|
function messageFor(error: unknown, fallback: string) {
|
||||||
|
return error instanceof Error ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardProvider({ children }: PropsWithChildren) {
|
||||||
|
const [dashboard, setDashboard] = useState<DashboardData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [notice, setNotice] = useState<Notice | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setDashboard(await api<DashboardData>("/api/v1/dashboard"));
|
||||||
|
} catch (error) {
|
||||||
|
setNotice({ tone: "error", text: `APIに接続できません。${messageFor(error, "APIを起動してください。")}` });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The provider owns the external dashboard request and refreshes it on mount.
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
useEffect(() => { void refresh(); }, [refresh]);
|
||||||
|
|
||||||
|
const withBusy = useCallback(async (work: () => Promise<void>, success: string, failure: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await work();
|
||||||
|
setNotice({ tone: "success", text: success });
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
setNotice({ tone: "error", text: messageFor(error, failure) });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const createSource = useCallback(async (input: SourceInput) => {
|
||||||
|
await withBusy(
|
||||||
|
async () => { await api("/api/v1/sources", { method: "POST", body: JSON.stringify(input) }); },
|
||||||
|
"Sourceを作成しました。",
|
||||||
|
"Sourceを作成できませんでした。",
|
||||||
|
);
|
||||||
|
}, [withBusy]);
|
||||||
|
|
||||||
|
const updateSource = useCallback(async (id: string, input: Partial<SourceInput>) => {
|
||||||
|
await withBusy(
|
||||||
|
async () => { await api(`/api/v1/sources/${id}`, { method: "PATCH", body: JSON.stringify(input) }); },
|
||||||
|
"Sourceを更新しました。",
|
||||||
|
"Sourceを更新できませんでした。",
|
||||||
|
);
|
||||||
|
}, [withBusy]);
|
||||||
|
|
||||||
|
const changeVisibility = useCallback(async (id: string, visibility: Visibility) => {
|
||||||
|
await withBusy(
|
||||||
|
async () => { await api(`/api/v1/sources/${id}`, { method: "PATCH", body: JSON.stringify({ visibility }) }); },
|
||||||
|
"Sourceの公開状態を更新しました。",
|
||||||
|
"公開状態を変更できませんでした。",
|
||||||
|
);
|
||||||
|
}, [withBusy]);
|
||||||
|
|
||||||
|
const uploadRelease = useCallback(async (sourceId: string, file: File, appId?: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
setNotice({ tone: "info", text: "ADPをアップロードし、Manifestとアセットを検証しています。" });
|
||||||
|
try {
|
||||||
|
const init = await api<{ upload: { id: string }; uploadPlan: UploadPlan }>("/api/v1/uploads", { method: "POST", body: JSON.stringify({ sourceId, appId, filename: file.name, sizeBytes: file.size }) });
|
||||||
|
const uploadedParts: Array<{ partNumber: number; etag: string }> = [];
|
||||||
|
if (init.uploadPlan.mode === "multipart") {
|
||||||
|
for (const part of init.uploadPlan.parts) {
|
||||||
|
const start = (part.partNumber - 1) * init.uploadPlan.partSizeBytes;
|
||||||
|
const response = await fetch(part.uploadUrl, { method: "PUT", body: file.slice(start, Math.min(file.size, start + init.uploadPlan.partSizeBytes)) });
|
||||||
|
if (!response.ok) throw new Error(`ADPパート${part.partNumber}の転送に失敗しました。`);
|
||||||
|
const etag = response.headers.get("etag");
|
||||||
|
if (!etag) throw new Error(`ADPパート${part.partNumber}のETagを取得できませんでした。`);
|
||||||
|
uploadedParts.push({ partNumber: part.partNumber, etag });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const response = await fetch(init.uploadPlan.uploadUrl, { method: "PUT", headers: { "content-type": "application/zip" }, body: file });
|
||||||
|
if (!response.ok) throw new Error("ADPファイルの転送に失敗しました。");
|
||||||
|
}
|
||||||
|
|
||||||
|
let complete = await api<{ release?: ReleaseRecord }>(`/api/v1/uploads/${init.upload.id}/complete`, { method: "POST", body: JSON.stringify({ parts: uploadedParts.length ? uploadedParts : undefined }) });
|
||||||
|
if (!complete.release) {
|
||||||
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||||
|
const status = await api<{ release?: ReleaseRecord }>(`/api/v1/uploads/${init.upload.id}`);
|
||||||
|
if (status.release) { complete = status; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setNotice({ tone: complete.release?.status === "ready" ? "success" : "info", text: complete.release ? `v${complete.release.version}(build ${complete.release.buildVersion})の検証が完了しました。` : "アップロードをキューに追加しました。処理完了後に更新してください。" });
|
||||||
|
await refresh();
|
||||||
|
return complete.release || null;
|
||||||
|
} catch (error) {
|
||||||
|
setNotice({ tone: "error", text: messageFor(error, "ADPを処理できませんでした。") });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const saveApp = useCallback(async (id: string, patch: AppPatch) => {
|
||||||
|
await withBusy(
|
||||||
|
async () => { await api(`/api/v1/apps/${id}`, { method: "PATCH", body: JSON.stringify(patch) }); },
|
||||||
|
"アプリのSourceメタデータを更新しました。",
|
||||||
|
"アプリ情報を更新できませんでした。",
|
||||||
|
);
|
||||||
|
}, [withBusy]);
|
||||||
|
|
||||||
|
const publishRelease = useCallback(async (id: string) => {
|
||||||
|
await withBusy(
|
||||||
|
async () => { await api(`/api/v1/releases/${id}/publish`, { method: "POST", body: "{}" }); },
|
||||||
|
"リリースを公開しました。",
|
||||||
|
"リリースを公開できませんでした。",
|
||||||
|
);
|
||||||
|
}, [withBusy]);
|
||||||
|
|
||||||
|
const copySourceUrl = useCallback(async (source: SourceRecord) => {
|
||||||
|
const url = `${API_BASE.replace(/\/$/, "")}/sources/${source.slug}/source.json`;
|
||||||
|
await navigator.clipboard?.writeText(url);
|
||||||
|
setNotice({ tone: "success", text: "Source URLをコピーしました。AltStore PALのSource追加画面に貼り付けられます。" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<DashboardContextValue>(() => ({ dashboard, loading, busy, notice, refresh, dismissNotice: () => setNotice(null), createSource, updateSource, changeVisibility, uploadRelease, saveApp, publishRelease, copySourceUrl }), [dashboard, loading, busy, notice, refresh, createSource, updateSource, changeVisibility, uploadRelease, saveApp, publishRelease, copySourceUrl]);
|
||||||
|
|
||||||
|
return <DashboardContext.Provider value={value}>{children}</DashboardContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDashboard() {
|
||||||
|
const context = useContext(DashboardContext);
|
||||||
|
if (!context) throw new Error("useDashboard must be used inside DashboardProvider");
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, type FormEvent, type ReactNode } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Activity, ArrowUpRight, ChevronRight, Clipboard, Code2, Copy, Database, ExternalLink, Filter, HardDrive, Pencil, Plus, RefreshCw, Search, ShieldCheck, UploadCloud, XCircle, type LucideIcon } from "lucide-react";
|
||||||
|
import type { AppRecord, ReleaseRecord, SourceRecord } from "@/packages/core/src/index";
|
||||||
|
import { API_BASE, flattenApps, flattenReleases, formatBytes, formatDate, formatDateTime, statusLabel, statusTone, visibilityLabel, visibilityTone, type DashboardData, type DashboardSource, type Visibility } from "@/app/lib/dashboard";
|
||||||
|
import { useDashboard } from "./dashboard-provider";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogForm, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
|
type ViewName = "overview" | "sources" | "apps" | "releases";
|
||||||
|
|
||||||
|
function PageHeading({ eyebrow, title, description, actions }: { eyebrow?: string; title: string; description: string; actions?: ReactNode }) {
|
||||||
|
return <div className="admin-page-heading"><div><div className="admin-eyebrow">{eyebrow || "ALTDock WORKSPACE"}</div><h1>{title}</h1><p>{description}</p></div><div className="admin-page-actions">{actions}</div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({ label, value, detail, icon: Icon, tone = "neutral" }: { label: string; value: string | number; detail: string; icon: LucideIcon; tone?: "neutral" | "orange" | "blue" | "green" }) {
|
||||||
|
return <article className={`admin-metric-card metric-${tone}`}><div className="admin-metric-top"><span>{label}</span><Icon size={16} strokeWidth={1.8} /></div><strong>{value}</strong><small>{detail}</small></article>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionCard({ title, description, action, children, className = "" }: { title: string; description?: string; action?: ReactNode; children: ReactNode; className?: string }) {
|
||||||
|
return <section className={`admin-section-card ${className}`}><div className="admin-section-card-header"><div><h2>{title}</h2>{description && <p>{description}</p>}</div>{action}</div>{children}</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ icon: Icon, title, description, action }: { icon: LucideIcon; title: string; description: string; action?: ReactNode }) {
|
||||||
|
return <div className="admin-empty-state"><span className="admin-empty-icon"><Icon size={20} /></span><strong>{title}</strong><p>{description}</p>{action}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingState() {
|
||||||
|
return <div className="admin-loading-state"><span className="admin-spinner" /><strong>Workspaceを読み込んでいます</strong><p>APIから最新の配布状況を取得中です。</p></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InlineStatus({ status }: { status: ReleaseRecord["status"] }) {
|
||||||
|
return <Badge tone={statusTone(status)}><span className="admin-status-dot-small" />{statusLabel(status)}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InlineVisibility({ visibility }: { visibility: Visibility }) {
|
||||||
|
return <Badge tone={visibilityTone(visibility)}><span className="admin-status-dot-small" />{visibilityLabel(visibility)}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceDialog({ open, onOpenChange, source, busy, onSubmit }: { open: boolean; onOpenChange: (open: boolean) => void; source?: SourceRecord; busy: boolean; onSubmit: (input: { name: string; subtitle: string; description: string; visibility: Visibility }) => Promise<void> }) {
|
||||||
|
const [form, setForm] = useState({ name: source?.name || "", subtitle: source?.subtitle || "", description: source?.description || "", visibility: source?.visibility || "draft" as Visibility });
|
||||||
|
const submit = async (event: FormEvent) => { event.preventDefault(); await onSubmit(form); onOpenChange(false); };
|
||||||
|
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><div className="dialog-kicker">{source ? "SOURCE SETTINGS" : "NEW SOURCE"}</div><DialogTitle>{source ? "Sourceを編集" : "Sourceを作成"}</DialogTitle><DialogDescription>AltStore PALに表示する配布面の基本情報を設定します。</DialogDescription></DialogHeader><DialogForm onSubmit={submit}><label className="admin-field">Source名<input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="My Apps" /></label><label className="admin-field">Subtitle<input value={form.subtitle} onChange={(event) => setForm({ ...form, subtitle: event.target.value })} placeholder="iOSアプリの配布ページ" /></label><label className="admin-field">説明<textarea rows={4} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} placeholder="このSourceについて説明します。" /></label><label className="admin-field">公開状態<select value={form.visibility} onChange={(event) => setForm({ ...form, visibility: event.target.value as Visibility })}><option value="draft">下書き — 外部から取得不可</option><option value="unlisted">限定公開 — URLを知っている人だけ</option><option value="public">公開 — 公開ページで案内</option></select></label><DialogFooter><Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>キャンセル</Button><Button disabled={busy}>{busy ? "保存中…" : source ? "変更を保存" : "Sourceを作成"}</Button></DialogFooter></DialogForm></DialogContent></Dialog>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UploadDialog({ open, onOpenChange, sources, busy, maxUploadBytes, onSubmit }: { open: boolean; onOpenChange: (open: boolean) => void; sources: DashboardSource[]; busy: boolean; maxUploadBytes: number; onSubmit: (sourceId: string, file: File, appId?: string) => Promise<void> }) {
|
||||||
|
const firstSource = sources[0];
|
||||||
|
const [sourceId, setSourceId] = useState(firstSource?.source.id || "");
|
||||||
|
const [appId, setAppId] = useState("");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const selectedSource = sources.find((entry) => entry.source.id === sourceId) || firstSource;
|
||||||
|
const submit = async (event: FormEvent) => { event.preventDefault(); if (!selectedSource || !file) return; await onSubmit(selectedSource.source.id, file, appId || undefined); setFile(null); setAppId(""); onOpenChange(false); };
|
||||||
|
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><div className="dialog-kicker">NEW RELEASE</div><DialogTitle>ADPをアップロード</DialogTitle><DialogDescription>Notarization済みのADP ZIPをそのまま選択してください。Manifestとsignatureは書き換えません。</DialogDescription></DialogHeader><DialogForm onSubmit={submit}><label className="admin-field">アップロード先のSource<select value={sourceId} onChange={(event) => { setSourceId(event.target.value); setAppId(""); }}>{sources.map((entry) => <option key={entry.source.id} value={entry.source.id}>{entry.source.name}</option>)}</select></label>{selectedSource && selectedSource.apps.length > 0 && <label className="admin-field">対象アプリ<span className="admin-field-hint">空欄ならManifestから新しいアプリを作成</span><select value={appId} onChange={(event) => setAppId(event.target.value)}><option value="">新しいアプリ</option>{selectedSource.apps.map((app) => <option key={app.id} value={app.id}>{app.name} · {app.bundleIdentifier}</option>)}</select></label>}<div className="admin-dropzone"><input id="adp-file-new" type="file" accept=".zip,application/zip" onChange={(event) => setFile(event.target.files?.[0] || null)} /><label htmlFor="adp-file-new"><span className="admin-dropzone-icon"><UploadCloud size={22} /></span><strong>{file?.name || "ADP ZIPを選択"}</strong><small>{file ? formatBytes(file.size) : `最大 ${formatBytes(maxUploadBytes)} · manifest.json + signature required`}</small></label></div><div className="admin-info-callout"><ShieldCheck size={16} /><span>パストラバーサル、シンボリックリンク、ZIP爆弾、欠落・余分なファイルをWorkerで検査します。</span></div><DialogFooter><Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>キャンセル</Button><Button disabled={!file || !selectedSource || busy}>{busy ? "処理中…" : "アップロードして検証"}</Button></DialogFooter></DialogForm></DialogContent></Dialog>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppDialog({ open, onOpenChange, app, busy, onSubmit }: { open: boolean; onOpenChange: (open: boolean) => void; app: AppRecord | null; busy: boolean; onSubmit: (id: string, patch: Partial<AppRecord>) => Promise<void> }) {
|
||||||
|
const [form, setForm] = useState({ name: app?.name || "", developerName: app?.developerName || "", subtitle: app?.subtitle || "", localizedDescription: app?.localizedDescription || "", iconURL: app?.iconURL || "", category: app?.category || "other", entitlements: app?.appPermissions?.entitlements.join("\n") || "", privacy: JSON.stringify(app?.appPermissions?.privacy || {}, null, 2) });
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
if (!app) return null;
|
||||||
|
const submit = async (event: FormEvent) => { event.preventDefault(); setError(""); try { const privacy = JSON.parse(form.privacy) as Record<string, string>; await onSubmit(app.id, { name: form.name, developerName: form.developerName, subtitle: form.subtitle, localizedDescription: form.localizedDescription, iconURL: form.iconURL || undefined, category: form.category, appPermissions: { entitlements: form.entitlements.split("\n").map((value) => value.trim()).filter(Boolean), privacy } }); onOpenChange(false); } catch (reason) { setError(reason instanceof Error ? reason.message : "JSON形式を確認してください。"); } };
|
||||||
|
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent className="wide-dialog"><DialogHeader><div className="dialog-kicker">APP METADATA</div><DialogTitle>アプリ情報を編集</DialogTitle><DialogDescription>Source JSONに出力する表示情報と権限情報を確認します。</DialogDescription></DialogHeader><DialogForm onSubmit={submit}><div className="admin-form-grid"><label className="admin-field">アプリ名<input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></label><label className="admin-field">Developer<input value={form.developerName} onChange={(event) => setForm({ ...form, developerName: event.target.value })} /></label></div><div className="admin-form-grid"><label className="admin-field">Subtitle<input value={form.subtitle} onChange={(event) => setForm({ ...form, subtitle: event.target.value })} /></label><label className="admin-field">カテゴリ<select value={form.category} onChange={(event) => setForm({ ...form, category: event.target.value })}><option value="developer">Developer</option><option value="entertainment">Entertainment</option><option value="games">Games</option><option value="lifestyle">Lifestyle</option><option value="other">Other</option><option value="photo-video">Photo & Video</option><option value="social">Social</option><option value="utilities">Utilities</option></select></label></div><label className="admin-field">説明<textarea rows={4} value={form.localizedDescription} onChange={(event) => setForm({ ...form, localizedDescription: event.target.value })} /></label><label className="admin-field">Icon URL<input type="url" value={form.iconURL} onChange={(event) => setForm({ ...form, iconURL: event.target.value })} placeholder="https://…" /></label><div className="admin-form-grid"><label className="admin-field">Bundle ID<input readOnly value={app.bundleIdentifier} /></label><label className="admin-field">Apple Item ID<input readOnly value={app.marketplaceID || "—"} /></label></div><label className="admin-field">Entitlements<span className="admin-field-hint">1行に1つ</span><textarea rows={3} value={form.entitlements} onChange={(event) => setForm({ ...form, entitlements: event.target.value })} /></label><label className="admin-field">Privacy UsageDescription JSON<textarea rows={4} value={form.privacy} onChange={(event) => setForm({ ...form, privacy: event.target.value })} /></label>{error && <div className="admin-form-error">{error}</div>}<DialogFooter><Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>キャンセル</Button><Button disabled={busy}>{busy ? "保存中…" : "変更を保存"}</Button></DialogFooter></DialogForm></DialogContent></Dialog>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function JsonPreviewDialog({ source, open, onOpenChange }: { source: SourceRecord | null; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
|
const [payload, setPayload] = useState<string>("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !source) return;
|
||||||
|
setPayload(""); setError("");
|
||||||
|
fetch(`${API_BASE}/sources/${source.slug}/source.json`).then(async (response) => { if (!response.ok) throw new Error("公開済みSourceだけがJSONを取得できます。"); return response.json(); }).then((value) => setPayload(JSON.stringify(value, null, 2))).catch((reason) => setError(reason instanceof Error ? reason.message : "Source JSONを取得できませんでした。"));
|
||||||
|
}, [open, source]);
|
||||||
|
if (!source) return null;
|
||||||
|
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent className="wide-dialog"><DialogHeader><div className="dialog-kicker">SOURCE JSON</div><DialogTitle>{source.name}</DialogTitle><DialogDescription>{source.visibility === "draft" ? "下書きのため匿名Source JSONはまだ取得できません。" : `${API_BASE}/sources/${source.slug}/source.json`}</DialogDescription></DialogHeader><Tabs defaultValue="summary"><TabsList><TabsTrigger value="summary">概要</TabsTrigger><TabsTrigger value="json">JSONプレビュー</TabsTrigger></TabsList><TabsContent value="summary"><div className="json-summary-grid"><div><span>公開状態</span><strong><InlineVisibility visibility={source.visibility} /></strong></div><div><span>Source slug</span><code>{source.slug}</code></div><div><span>公開URL</span><code>{`${API_BASE}/sources/${source.slug}/source.json`}</code></div></div></TabsContent><TabsContent value="json">{error ? <div className="admin-form-error">{error}</div> : payload ? <pre className="json-preview">{payload}</pre> : <div className="admin-loading-inline"><span className="admin-spinner" />JSONを取得しています…</div>}</TabsContent></Tabs><DialogFooter><Button variant="ghost" onClick={() => onOpenChange(false)}>閉じる</Button></DialogFooter></DialogContent></Dialog>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReleaseDetailsDialog({ item, open, onOpenChange }: { item: { release: ReleaseRecord; app?: AppRecord; source: SourceRecord } | null; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
|
if (!item) return null;
|
||||||
|
const { release, app, source } = item;
|
||||||
|
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent className="wide-dialog"><DialogHeader><div className="dialog-kicker">RELEASE DETAILS</div><DialogTitle>{app?.name || "アプリリリース"} <span className="admin-title-muted">v{release.version}</span></DialogTitle><DialogDescription>{source.name} · build {release.buildVersion} · {formatDateTime(release.date)}</DialogDescription></DialogHeader><div className="release-detail-grid"><div><span>状態</span><strong><InlineStatus status={release.status} /></strong></div><div><span>容量</span><strong>{formatBytes(release.sizeBytes)}</strong></div><div><span>Bundle ID</span><code>{release.manifest.bundleId}</code></div><div><span>Apple Item ID</span><code>{release.manifest.appleItemId}</code></div><div><span>最低OS</span><strong>{release.minOSVersion || "—"}</strong></div><div><span>公開日</span><strong>{release.publishedAt ? formatDateTime(release.publishedAt) : "未公開"}</strong></div></div>{release.errorMessage && <div className="admin-form-error">{release.errorMessage}</div>}<div className="asset-path-panel"><h3>Manifest参照パス</h3><p>元のManifestに記載されたassetPath / sourcePath / deltaPathを確認できます。</p>{release.manifest.variantPaths.length || release.manifest.deltaPaths.length ? <ul>{[...release.manifest.variantPaths.map((path) => `variant · ${path}`), ...release.manifest.deltaPaths.map((path) => `delta · ${path}`)].map((path) => <li key={path}><code>{path}</code></li>)}</ul> : <div className="admin-table-empty">参照パスはありません。</div>}</div><DialogFooter><Button variant="ghost" onClick={() => onOpenChange(false)}>閉じる</Button></DialogFooter></DialogContent></Dialog>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Overview({ dashboard, onCreateSource, onUpload, onOpenSource, onOpenRelease }: { dashboard: DashboardData; onCreateSource: () => void; onUpload: () => void; onOpenSource: (source: SourceRecord) => void; onOpenRelease: (item: { release: ReleaseRecord; app?: AppRecord; source: SourceRecord }) => void }) {
|
||||||
|
const flattened = flattenReleases(dashboard).sort((a, b) => b.release.date.localeCompare(a.release.date));
|
||||||
|
const appCount = flattenApps(dashboard).length;
|
||||||
|
const published = flattened.filter((item) => item.release.status === "published").length;
|
||||||
|
const processing = flattened.filter((item) => item.release.status === "processing").length;
|
||||||
|
const needsAttention = flattened.filter((item) => item.release.status === "rejected").length;
|
||||||
|
const usagePercent = Math.min(100, dashboard.usage.storageBytes / Math.max(1, dashboard.limits.maxStorageBytes) * 100);
|
||||||
|
return <>
|
||||||
|
<PageHeading eyebrow="WORKSPACE OVERVIEW" title="概要" description="Source、アプリ、リリースの現在の状態を確認します。" actions={<><Button variant="outline" onClick={onUpload}><UploadCloud size={15} />ADPをアップロード</Button><Button onClick={onCreateSource}><Plus size={15} />Sourceを作成</Button></>} />
|
||||||
|
<div className="admin-metric-grid"><MetricCard label="SOURCES" value={dashboard.usage.sourceCount} detail={`${dashboard.limits.maxSources}件中`} icon={Database} tone="orange" /><MetricCard label="アプリ" value={appCount} detail={`${published}件の公開リリース`} icon={Code2} tone="blue" /><MetricCard label="保存容量" value={formatBytes(dashboard.usage.storageBytes)} detail={`${formatBytes(dashboard.limits.maxStorageBytes)}まで`} icon={HardDrive} tone="green" /><MetricCard label="要確認" value={needsAttention} detail={processing ? `${processing}件を処理中` : "現在なし"} icon={Activity} tone={needsAttention ? "orange" : "neutral"} /></div>
|
||||||
|
<div className="admin-overview-grid"><SectionCard title="配布状態" description="Workspace全体のリリース状態"><div className="health-list"><div><span className="health-label"><i className="health-dot success" />公開中</span><strong>{published}</strong></div><div><span className="health-label"><i className="health-dot info" />処理中</span><strong>{processing}</strong></div><div><span className="health-label"><i className="health-dot warning" />公開準備完了</span><strong>{flattened.filter((item) => item.release.status === "ready").length}</strong></div><div><span className="health-label"><i className="health-dot danger" />要修正</span><strong>{needsAttention}</strong></div></div><div className="quota-summary"><div><span>Storage quota</span><strong>{usagePercent.toFixed(0)}%</strong></div><div className="admin-progress"><span style={{ width: `${usagePercent}%` }} /></div></div></SectionCard><SectionCard title="クイック操作" description="よく使う管理操作"><button className="admin-action-row" onClick={onUpload}><span className="admin-action-icon orange"><UploadCloud size={17} /></span><span><strong>ADPをアップロード</strong><small>Manifestを検証してリリースを作成</small></span><ChevronRight size={16} /></button><button className="admin-action-row" onClick={onCreateSource}><span className="admin-action-icon blue"><Plus size={17} /></span><span><strong>Sourceを作成</strong><small>PALへ公開する配布面を追加</small></span><ChevronRight size={16} /></button><a className="admin-action-row" href="https://faq.altstore.io/developers/distribute-with-altstore-pal" target="_blank" rel="noreferrer"><span className="admin-action-icon neutral"><Clipboard size={17} /></span><span><strong>PALの配布要件を確認</strong><small>AltStore公式ドキュメント</small></span><ExternalLink size={15} /></a></SectionCard></div>
|
||||||
|
<SectionCard title="Sources" description="公開面ごとの状態とアプリ数" action={<a className="admin-link" href="/dashboard/sources">すべて表示 <ArrowUpRight size={14} /></a>}><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Source</th><th>状態</th><th>アプリ</th><th>更新日</th><th /></tr></thead><tbody>{dashboard.sources.length ? dashboard.sources.map((entry) => <tr key={entry.source.id}><td><button className="table-primary-link" onClick={() => onOpenSource(entry.source)}><span className="table-avatar" style={{ background: entry.source.tintColor }}>{entry.source.name.slice(0, 1).toUpperCase()}</span><span><strong>{entry.source.name}</strong><small>{entry.source.slug}</small></span></button></td><td><InlineVisibility visibility={entry.source.visibility} /></td><td>{entry.apps.length}</td><td>{formatDate(entry.source.updatedAt)}</td><td><ChevronRight size={15} className="table-chevron" /></td></tr>) : <tr><td colSpan={5}><EmptyState icon={Boxes} title="Sourceがありません" description="最初の配布面を作成してください。" action={<Button size="sm" onClick={onCreateSource}>Sourceを作成</Button>} /></td></tr>}</tbody></table></div></SectionCard>
|
||||||
|
<SectionCard title="最近のリリース" description="最新5件の処理結果" action={<a className="admin-link" href="/dashboard/releases">リリース一覧 <ArrowUpRight size={14} /></a>}><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>アプリ / Version</th><th>Source</th><th>状態</th><th>容量</th><th>日付</th></tr></thead><tbody>{flattened.slice(0, 5).map((item) => <tr key={item.release.id} onClick={() => onOpenRelease(item)} className="admin-clickable-row"><td><span className="table-primary-link"><span className="table-avatar table-avatar-small" style={{ background: item.app?.tintColor || item.source.tintColor }}>{item.app?.name.slice(0, 1).toUpperCase() || "A"}</span><span><strong>{item.app?.name || "App release"}</strong><small>v{item.release.version} · build {item.release.buildVersion}</small></span></span></td><td>{item.source.name}</td><td><InlineStatus status={item.release.status} /></td><td>{formatBytes(item.release.sizeBytes)}</td><td>{formatDate(item.release.date)}</td></tr>)}{!flattened.length && <tr><td colSpan={5}><div className="admin-table-empty">アップロードされたリリースはここに表示されます。</div></td></tr>}</tbody></table></div></SectionCard>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourcesView({ dashboard, onCreateSource, onEditSource, onOpenJson, onCopyUrl }: { dashboard: DashboardData; onCreateSource: () => void; onEditSource: (source: SourceRecord) => void; onOpenJson: (source: SourceRecord) => void; onCopyUrl: (source: SourceRecord) => void }) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const rows = dashboard.sources.filter((entry) => `${entry.source.name} ${entry.source.slug} ${entry.source.subtitle}`.toLowerCase().includes(query.toLowerCase()));
|
||||||
|
return <><PageHeading eyebrow="DISTRIBUTION SURFACES" title="Sources" description="AltStore PALへ公開する配布面を管理します。" actions={<Button onClick={onCreateSource}><Plus size={15} />Sourceを作成</Button>} /><SectionCard title="Source一覧" description={`${rows.length} / ${dashboard.sources.length}件`}><div className="admin-toolbar"><label className="admin-search-field"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Source名・slugを検索" /></label></div><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>Source</th><th>公開状態</th><th>アプリ</th><th>最終更新</th><th>操作</th></tr></thead><tbody>{rows.map((entry) => <tr key={entry.source.id}><td><div className="table-primary-link"><span className="table-avatar" style={{ background: entry.source.tintColor }}>{entry.source.name.slice(0, 1).toUpperCase()}</span><span><strong>{entry.source.name}</strong><small>{entry.source.subtitle || entry.source.slug}</small></span></div></td><td><InlineVisibility visibility={entry.source.visibility} /></td><td>{entry.apps.length}件</td><td>{formatDate(entry.source.updatedAt)}</td><td><div className="table-actions"><Button size="icon" variant="ghost" onClick={() => onOpenJson(entry.source)} aria-label={`${entry.source.name}のJSONをプレビュー`}><Code2 size={15} /></Button><Button size="icon" variant="ghost" onClick={() => onCopyUrl(entry.source)} aria-label={`${entry.source.name}のURLをコピー`}><Copy size={15} /></Button><Button size="icon" variant="ghost" onClick={() => onEditSource(entry.source)} aria-label={`${entry.source.name}を編集`}><Pencil size={15} /></Button></div></td></tr>)}{!rows.length && <tr><td colSpan={5}><div className="admin-table-empty">一致するSourceがありません。</div></td></tr>}</tbody></table></div></SectionCard></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppsView({ dashboard, onEditApp }: { dashboard: DashboardData; onEditApp: (app: AppRecord) => void }) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const rows = flattenApps(dashboard).filter(({ app, source }) => `${app.name} ${app.bundleIdentifier} ${source.name}`.toLowerCase().includes(query.toLowerCase()));
|
||||||
|
return <><PageHeading eyebrow="APP CATALOG" title="アプリ" description="Manifestから抽出した識別子と、Sourceに表示するメタデータを管理します。" /><SectionCard title="アプリ一覧" description={`${rows.length}件`}><div className="admin-toolbar"><label className="admin-search-field"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="アプリ名・Bundle IDを検索" /></label></div><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>アプリ</th><th>Bundle ID</th><th>Source</th><th>最新リリース</th><th>カテゴリ</th><th>操作</th></tr></thead><tbody>{rows.map(({ app, source, releases }) => { const latest = releases.slice().sort((a, b) => b.date.localeCompare(a.date))[0]; return <tr key={app.id}><td><div className="table-primary-link"><span className="table-avatar" style={{ background: app.tintColor || source.tintColor }}>{app.name.slice(0, 1).toUpperCase()}</span><span><strong>{app.name}</strong><small>{app.developerName || "Developer未設定"}</small></span></div></td><td><code className="table-code">{app.bundleIdentifier}</code></td><td>{source.name}</td><td>{latest ? <span className="version-cell"><strong>v{latest.version}</strong><small>build {latest.buildVersion}</small></span> : "—"}</td><td><span className="category-label">{app.category}</span></td><td><Button size="icon" variant="ghost" onClick={() => onEditApp(app)} aria-label={`${app.name}を編集`}><Pencil size={15} /></Button></td></tr>; })}{!rows.length && <tr><td colSpan={6}><EmptyState icon={Code2} title="アプリがありません" description="ADPをアップロードするとManifestからアプリが作成されます。" /></td></tr>}</tbody></table></div></SectionCard></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReleasesView({ dashboard, onUpload, onOpenRelease, onPublish }: { dashboard: DashboardData; onUpload: () => void; onOpenRelease: (item: { release: ReleaseRecord; app?: AppRecord; source: SourceRecord }) => void; onPublish: (id: string) => Promise<void> }) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
|
const rows = flattenReleases(dashboard).filter(({ release, app, source }) => { const matchesQuery = `${app?.name || ""} ${source.name} ${release.version} ${release.buildVersion} ${release.manifest.bundleId}`.toLowerCase().includes(query.toLowerCase()); return matchesQuery && (statusFilter === "all" || release.status === statusFilter); }).sort((a, b) => b.release.date.localeCompare(a.release.date));
|
||||||
|
return <><PageHeading eyebrow="PACKAGE PIPELINE" title="リリース" description="ADPの検証結果、Manifest参照パス、公開状態を確認します。" actions={<Button onClick={onUpload}><UploadCloud size={15} />ADPをアップロード</Button>} /><SectionCard title="リリース一覧" description={`${rows.length}件`}><div className="admin-toolbar"><label className="admin-search-field"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="アプリ名・version・Bundle IDを検索" /></label><label className="admin-filter-field"><Filter size={14} /><select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}><option value="all">すべての状態</option><option value="processing">処理中</option><option value="ready">公開準備完了</option><option value="published">公開中</option><option value="rejected">要修正</option><option value="archived">アーカイブ</option></select></label><Button variant="outline" size="sm" onClick={() => window.location.reload()}><RefreshCw size={14} />更新</Button></div><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>アプリ / Version</th><th>Source</th><th>状態</th><th>Manifest</th><th>容量</th><th>日付</th><th /></tr></thead><tbody>{rows.map((item) => <tr key={item.release.id} onClick={() => onOpenRelease(item)} className="admin-clickable-row"><td><div className="table-primary-link"><span className="table-avatar table-avatar-small" style={{ background: item.app?.tintColor || item.source.tintColor }}>{item.app?.name.slice(0, 1).toUpperCase() || "A"}</span><span><strong>{item.app?.name || "App release"}</strong><small>v{item.release.version} · build {item.release.buildVersion}</small></span></div></td><td>{item.source.name}</td><td><InlineStatus status={item.release.status} /></td><td><code className="table-code">{item.release.manifest.bundleId}</code><small className="table-subline">{item.release.manifest.variantPaths.length} variant · {item.release.manifest.deltaPaths.length} delta</small></td><td>{formatBytes(item.release.sizeBytes)}</td><td>{formatDate(item.release.date)}</td><td>{item.release.status === "ready" ? <Button size="sm" onClick={(event) => { event.stopPropagation(); void onPublish(item.release.id); }}>公開</Button> : <ChevronRight size={15} className="table-chevron" />}</td></tr>)}{!rows.length && <tr><td colSpan={7}><div className="admin-table-empty">一致するリリースがありません。</div></td></tr>}</tbody></table></div></SectionCard></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardView({ view }: { view: ViewName }) {
|
||||||
|
const { dashboard, loading, busy, createSource, updateSource, uploadRelease, saveApp, publishRelease, copySourceUrl } = useDashboard();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [sourceDialogOpen, setSourceDialogOpen] = useState(false);
|
||||||
|
const [editingSource, setEditingSource] = useState<SourceRecord | undefined>();
|
||||||
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
|
const [editingApp, setEditingApp] = useState<AppRecord | null>(null);
|
||||||
|
const [jsonSource, setJsonSource] = useState<SourceRecord | null>(null);
|
||||||
|
const [releaseItem, setReleaseItem] = useState<{ release: ReleaseRecord; app?: AppRecord; source: SourceRecord } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (view === "releases" && searchParams.get("upload") === "1") {
|
||||||
|
setUploadOpen(true);
|
||||||
|
router.replace("/dashboard/releases");
|
||||||
|
}
|
||||||
|
}, [router, searchParams, view]);
|
||||||
|
|
||||||
|
if (loading && !dashboard) return <LoadingState />;
|
||||||
|
if (!dashboard) return <div className="admin-error-state"><XCircle size={22} /><strong>Workspaceを読み込めませんでした</strong><p>APIが起動しているか確認して、ページを更新してください。</p></div>;
|
||||||
|
|
||||||
|
const openCreateSource = () => { setEditingSource(undefined); setSourceDialogOpen(true); };
|
||||||
|
const openEditSource = (source: SourceRecord) => { setEditingSource(source); setSourceDialogOpen(true); };
|
||||||
|
const submitSource = async (input: { name: string; subtitle: string; description: string; visibility: Visibility }) => { if (editingSource) await updateSource(editingSource.id, input); else await createSource(input); };
|
||||||
|
const handleUpload = async (sourceId: string, file: File, appId?: string) => { await uploadRelease(sourceId, file, appId); };
|
||||||
|
const handleAppSave = async (id: string, patch: Partial<AppRecord>) => { await saveApp(id, { name: patch.name, developerName: patch.developerName, subtitle: patch.subtitle, localizedDescription: patch.localizedDescription, iconURL: patch.iconURL, category: patch.category, appPermissions: patch.appPermissions }); };
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{view === "overview" && <Overview dashboard={dashboard} onCreateSource={openCreateSource} onUpload={() => setUploadOpen(true)} onOpenSource={openEditSource} onOpenRelease={setReleaseItem} />}
|
||||||
|
{view === "sources" && <SourcesView dashboard={dashboard} onCreateSource={openCreateSource} onEditSource={openEditSource} onOpenJson={setJsonSource} onCopyUrl={(source) => void copySourceUrl(source)} />}
|
||||||
|
{view === "apps" && <AppsView dashboard={dashboard} onEditApp={setEditingApp} />}
|
||||||
|
{view === "releases" && <ReleasesView dashboard={dashboard} onUpload={() => setUploadOpen(true)} onOpenRelease={setReleaseItem} onPublish={publishRelease} />}
|
||||||
|
<SourceDialog key={editingSource?.id || "new-source"} open={sourceDialogOpen} onOpenChange={setSourceDialogOpen} source={editingSource} busy={busy} onSubmit={submitSource} />
|
||||||
|
<UploadDialog open={uploadOpen} onOpenChange={setUploadOpen} sources={dashboard.sources} busy={busy} maxUploadBytes={dashboard.limits.maxUploadBytes} onSubmit={handleUpload} />
|
||||||
|
<AppDialog key={editingApp?.id || "app-editor"} open={Boolean(editingApp)} onOpenChange={(open) => { if (!open) setEditingApp(null); }} app={editingApp} busy={busy} onSubmit={handleAppSave} />
|
||||||
|
<JsonPreviewDialog source={jsonSource} open={Boolean(jsonSource)} onOpenChange={(open) => { if (!open) setJsonSource(null); }} />
|
||||||
|
<ReleaseDetailsDialog item={releaseItem} open={Boolean(releaseItem)} onOpenChange={(open) => { if (!open) setReleaseItem(null); }} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
export function Badge({ className, tone = "neutral", ...props }: HTMLAttributes<HTMLSpanElement> & { tone?: "neutral" | "info" | "warning" | "danger" | "success" }) {
|
||||||
|
return <span className={cn("ui-badge", `ui-badge-${tone}`, className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { ButtonHTMLAttributes } from "react";
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
type ButtonVariant = "primary" | "secondary" | "outline" | "ghost" | "danger";
|
||||||
|
|
||||||
|
export function Button({ className, variant = "primary", size = "default", ...props }: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant; size?: "sm" | "default" | "icon" }) {
|
||||||
|
return <button className={cn("ui-button", `ui-button-${variant}`, `ui-button-${size}`, className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Command as CommandPrimitive } from "cmdk";
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
export function Command({ className, ...props }: ComponentPropsWithoutRef<typeof CommandPrimitive>) {
|
||||||
|
return <CommandPrimitive className={cn("ui-command", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandInput({ className, ...props }: ComponentPropsWithoutRef<typeof CommandPrimitive.Input>) {
|
||||||
|
return <CommandPrimitive.Input className={cn("ui-command-input", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandList({ className, ...props }: ComponentPropsWithoutRef<typeof CommandPrimitive.List>) {
|
||||||
|
return <CommandPrimitive.List className={cn("ui-command-list", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandEmpty({ className, ...props }: ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>) {
|
||||||
|
return <CommandPrimitive.Empty className={cn("ui-command-empty", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandGroup({ className, ...props }: ComponentPropsWithoutRef<typeof CommandPrimitive.Group>) {
|
||||||
|
return <CommandPrimitive.Group className={cn("ui-command-group", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandItem({ className, ...props }: ComponentPropsWithoutRef<typeof CommandPrimitive.Item>) {
|
||||||
|
return <CommandPrimitive.Item className={cn("ui-command-item", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import type { ComponentPropsWithoutRef, PropsWithChildren } from "react";
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
export const Dialog = DialogPrimitive.Root;
|
||||||
|
export const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
export const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
export function DialogContent({ className, children, ...props }: ComponentPropsWithoutRef<typeof DialogPrimitive.Content>) {
|
||||||
|
return <DialogPrimitive.Portal><DialogPrimitive.Overlay className="ui-dialog-overlay" /><DialogPrimitive.Content className={cn("ui-dialog-content", className)} {...props}>{children}<DialogPrimitive.Close className="ui-dialog-close" aria-label="閉じる"><X size={16} /></DialogPrimitive.Close></DialogPrimitive.Content></DialogPrimitive.Portal>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogHeader({ className, ...props }: ComponentPropsWithoutRef<"div">) {
|
||||||
|
return <div className={cn("ui-dialog-header", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogTitle({ className, ...props }: ComponentPropsWithoutRef<typeof DialogPrimitive.Title>) {
|
||||||
|
return <DialogPrimitive.Title className={cn("ui-dialog-title", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogDescription({ className, ...props }: ComponentPropsWithoutRef<typeof DialogPrimitive.Description>) {
|
||||||
|
return <DialogPrimitive.Description className={cn("ui-dialog-description", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogFooter({ className, ...props }: ComponentPropsWithoutRef<"div">) {
|
||||||
|
return <div className={cn("ui-dialog-footer", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogForm({ children, ...props }: PropsWithChildren<ComponentPropsWithoutRef<"form">>) {
|
||||||
|
return <form className="ui-dialog-form" {...props}>{children}</form>;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
export const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
|
||||||
|
export function DropdownMenuContent({ className, sideOffset = 6, ...props }: ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return <DropdownMenuPrimitive.Portal><DropdownMenuPrimitive.Content sideOffset={sideOffset} className={cn("ui-dropdown-content", className)} {...props} /></DropdownMenuPrimitive.Portal>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownMenuItem({ className, ...props }: ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>) {
|
||||||
|
return <DropdownMenuPrimitive.Item className={cn("ui-dropdown-item", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownMenuSeparator({ className, ...props }: ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return <DropdownMenuPrimitive.Separator className={cn("ui-dropdown-separator", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
export const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
|
export function TabsList({ className, ...props }: ComponentPropsWithoutRef<typeof TabsPrimitive.List>) {
|
||||||
|
return <TabsPrimitive.List className={cn("ui-tabs-list", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabsTrigger({ className, ...props }: ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return <TabsPrimitive.Trigger className={cn("ui-tabs-trigger", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabsContent({ className, ...props }: ComponentPropsWithoutRef<typeof TabsPrimitive.Content>) {
|
||||||
|
return <TabsPrimitive.Content className={cn("ui-tabs-content", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: altdock
|
||||||
|
POSTGRES_USER: altdock
|
||||||
|
POSTGRES_PASSWORD: altdock
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U altdock -d altdock"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
volumes:
|
||||||
|
- minio-data:/data
|
||||||
|
|
||||||
|
minio-init:
|
||||||
|
image: minio/mc:latest
|
||||||
|
depends_on:
|
||||||
|
- minio
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
command: >-
|
||||||
|
"until mc alias set local http://minio:9000 minioadmin minioadmin; do sleep 1; done;
|
||||||
|
mc mb --ignore-existing local/altdock"
|
||||||
|
|
||||||
|
api:
|
||||||
|
image: node:22-bookworm
|
||||||
|
working_dir: /workspace
|
||||||
|
command: sh -c "npm install --no-audit --no-fund && npm run dev:api"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
PORT: 4000
|
||||||
|
PUBLIC_BASE_URL: http://localhost:4000
|
||||||
|
WEB_ORIGIN: http://localhost:3000
|
||||||
|
DATABASE_URL: postgres://altdock:altdock@postgres:5432/altdock
|
||||||
|
STORAGE_MODE: s3
|
||||||
|
S3_ENDPOINT: http://minio:9000
|
||||||
|
S3_REGION: us-east-1
|
||||||
|
S3_BUCKET: altdock
|
||||||
|
S3_ACCESS_KEY_ID: minioadmin
|
||||||
|
S3_SECRET_ACCESS_KEY: minioadmin
|
||||||
|
S3_FORCE_PATH_STYLE: "true"
|
||||||
|
AUTH_MODE: demo
|
||||||
|
PROCESS_INLINE: "false"
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
volumes:
|
||||||
|
- .:/workspace
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
minio-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
|
worker:
|
||||||
|
image: node:22-bookworm
|
||||||
|
working_dir: /workspace
|
||||||
|
command: sh -c "npm install --no-audit --no-fund && npm run dev:worker"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
DATABASE_URL: postgres://altdock:altdock@postgres:5432/altdock
|
||||||
|
STORAGE_MODE: s3
|
||||||
|
S3_ENDPOINT: http://minio:9000
|
||||||
|
S3_REGION: us-east-1
|
||||||
|
S3_BUCKET: altdock
|
||||||
|
S3_ACCESS_KEY_ID: minioadmin
|
||||||
|
S3_SECRET_ACCESS_KEY: minioadmin
|
||||||
|
S3_FORCE_PATH_STYLE: "true"
|
||||||
|
AUTH_MODE: demo
|
||||||
|
PROCESS_INLINE: "false"
|
||||||
|
volumes:
|
||||||
|
- .:/workspace
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: node:22-bookworm
|
||||||
|
working_dir: /workspace
|
||||||
|
command: sh -c "npm install --no-audit --no-fund && npm run dev -- --host 0.0.0.0"
|
||||||
|
environment:
|
||||||
|
NEXT_PUBLIC_API_BASE_URL: http://localhost:4000
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- .:/workspace
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
minio-data:
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+12538
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"name": "altdock",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.13.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev",
|
||||||
|
"dev:api": "tsx apps/api/src/server.ts",
|
||||||
|
"dev:worker": "tsx apps/worker/src/worker.ts",
|
||||||
|
"build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
|
||||||
|
"build:api": "tsc -p apps/api/tsconfig.json",
|
||||||
|
"build:worker": "tsc -p apps/worker/tsconfig.json",
|
||||||
|
"start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start",
|
||||||
|
"start:api": "node dist/apps/api/src/server.js",
|
||||||
|
"start:worker": "node dist/apps/worker/src/worker.js",
|
||||||
|
"test": "npm run build && npm run build:api && npm run build:worker && node --import tsx --test tests/*.test.mjs",
|
||||||
|
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next",
|
||||||
|
"db:migrate": "tsx apps/api/src/migrate.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1101.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.1101.0",
|
||||||
|
"@fastify/cookie": "^11.1.2",
|
||||||
|
"@fastify/cors": "^11.3.0",
|
||||||
|
"@fastify/multipart": "^10.1.0",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.20",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.23",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||||
|
"@radix-ui/react-select": "^2.3.7",
|
||||||
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.21",
|
||||||
|
"@radix-ui/react-toast": "^1.2.23",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"fastify": "^5.11.0",
|
||||||
|
"jose": "^6.2.7",
|
||||||
|
"lucide-react": "^1.28.0",
|
||||||
|
"next": "16.2.6",
|
||||||
|
"pg": "^8.22.0",
|
||||||
|
"react": "19.2.6",
|
||||||
|
"react-dom": "19.2.6",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"unzipper": "^0.12.5",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/vite-plugin": "1.37.1",
|
||||||
|
"@tailwindcss/postcss": "4.2.1",
|
||||||
|
"@types/node": "22.19.19",
|
||||||
|
"@types/pg": "^8.20.3",
|
||||||
|
"@types/react": "19.2.14",
|
||||||
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@types/unzipper": "^0.10.11",
|
||||||
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
|
"@vitejs/plugin-rsc": "0.5.26",
|
||||||
|
"eslint": "9.39.4",
|
||||||
|
"eslint-config-next": "16.2.6",
|
||||||
|
"react-server-dom-webpack": "19.2.6",
|
||||||
|
"tailwindcss": "4.2.1",
|
||||||
|
"tsx": "^4.23.4",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"vinext": "0.0.50",
|
||||||
|
"vite": "8.0.13",
|
||||||
|
"wrangler": "4.92.0"
|
||||||
|
},
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./source";
|
||||||
|
export * from "./types";
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type {
|
||||||
|
AppRecord,
|
||||||
|
ReleaseRecord,
|
||||||
|
SourceRecord,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export interface AltStoreSourceDocument {
|
||||||
|
name: string;
|
||||||
|
subtitle?: string;
|
||||||
|
description?: string;
|
||||||
|
iconURL?: string;
|
||||||
|
headerURL?: string;
|
||||||
|
website?: string;
|
||||||
|
tintColor?: string;
|
||||||
|
featuredApps: string[];
|
||||||
|
apps: Array<{
|
||||||
|
name: string;
|
||||||
|
bundleIdentifier: string;
|
||||||
|
marketplaceID: string;
|
||||||
|
developerName: string;
|
||||||
|
subtitle?: string;
|
||||||
|
localizedDescription: string;
|
||||||
|
iconURL?: string;
|
||||||
|
tintColor?: string;
|
||||||
|
category: string;
|
||||||
|
screenshots: Array<{ imageURL: string; width?: number; height?: number }>;
|
||||||
|
versions: Array<{
|
||||||
|
version: string;
|
||||||
|
buildVersion: string;
|
||||||
|
date: string;
|
||||||
|
localizedDescription: string;
|
||||||
|
downloadURL: string;
|
||||||
|
size: number;
|
||||||
|
minOSVersion?: string;
|
||||||
|
}>;
|
||||||
|
appPermissions: {
|
||||||
|
entitlements: string[];
|
||||||
|
privacy: Record<string, string>;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
news: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSourceDocument(
|
||||||
|
source: SourceRecord,
|
||||||
|
apps: AppRecord[],
|
||||||
|
releases: ReleaseRecord[],
|
||||||
|
publicBaseUrl: string,
|
||||||
|
): AltStoreSourceDocument {
|
||||||
|
const sourceApps = apps
|
||||||
|
.map((app) => {
|
||||||
|
const appReleases = releases
|
||||||
|
.filter(
|
||||||
|
(release) =>
|
||||||
|
release.appId === app.id && release.status === "published",
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const dateOrder = b.date.localeCompare(a.date);
|
||||||
|
if (dateOrder !== 0) return dateOrder;
|
||||||
|
return b.buildVersion.localeCompare(a.buildVersion, undefined, {
|
||||||
|
numeric: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: app.name,
|
||||||
|
bundleIdentifier: app.bundleIdentifier,
|
||||||
|
marketplaceID: app.marketplaceID,
|
||||||
|
developerName: app.developerName,
|
||||||
|
subtitle: app.subtitle || undefined,
|
||||||
|
localizedDescription: app.localizedDescription,
|
||||||
|
iconURL: app.iconURL,
|
||||||
|
tintColor: app.tintColor,
|
||||||
|
category: app.category,
|
||||||
|
screenshots: app.screenshots,
|
||||||
|
appPermissions: app.appPermissions,
|
||||||
|
versions: appReleases.map((release) => ({
|
||||||
|
version: release.version,
|
||||||
|
buildVersion: release.buildVersion,
|
||||||
|
date: release.date,
|
||||||
|
localizedDescription: release.localizedDescription,
|
||||||
|
downloadURL: `${publicBaseUrl.replace(/\/$/, "")}/artifacts/${release.id}/manifest.json`,
|
||||||
|
size: release.sizeBytes,
|
||||||
|
minOSVersion: release.minOSVersion,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((app) => app.versions.length > 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: source.name,
|
||||||
|
subtitle: source.subtitle || undefined,
|
||||||
|
description: source.description || undefined,
|
||||||
|
iconURL: source.iconURL || sourceApps[0]?.iconURL,
|
||||||
|
headerURL: source.headerURL,
|
||||||
|
website: source.website,
|
||||||
|
tintColor: source.tintColor,
|
||||||
|
featuredApps: sourceApps.slice(0, 5).map((app) => app.bundleIdentifier),
|
||||||
|
apps: sourceApps,
|
||||||
|
news: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
export type Visibility = "draft" | "unlisted" | "public";
|
||||||
|
|
||||||
|
export type ReleaseStatus =
|
||||||
|
| "uploaded"
|
||||||
|
| "processing"
|
||||||
|
| "ready"
|
||||||
|
| "rejected"
|
||||||
|
| "published"
|
||||||
|
| "archived";
|
||||||
|
|
||||||
|
export interface WorkspaceRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
ownerSubject: string;
|
||||||
|
ownerEmail: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceRecord {
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
subtitle: string;
|
||||||
|
description: string;
|
||||||
|
iconURL?: string;
|
||||||
|
headerURL?: string;
|
||||||
|
website?: string;
|
||||||
|
tintColor: string;
|
||||||
|
visibility: Visibility;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppMetadata {
|
||||||
|
name: string;
|
||||||
|
developerName: string;
|
||||||
|
subtitle: string;
|
||||||
|
localizedDescription: string;
|
||||||
|
iconURL?: string;
|
||||||
|
tintColor: string;
|
||||||
|
category: string;
|
||||||
|
screenshots: Array<{ imageURL: string; width?: number; height?: number }>;
|
||||||
|
appPermissions: {
|
||||||
|
entitlements: string[];
|
||||||
|
privacy: Record<string, string>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppRecord extends AppMetadata {
|
||||||
|
id: string;
|
||||||
|
sourceId: string;
|
||||||
|
bundleIdentifier: string;
|
||||||
|
marketplaceID: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManifestSummary {
|
||||||
|
distributionPackageRevision?: number;
|
||||||
|
appleItemId: string;
|
||||||
|
bundleId: string;
|
||||||
|
shortVersionString: string;
|
||||||
|
bundleVersion: string;
|
||||||
|
appleVersionId?: string;
|
||||||
|
platforms: string[];
|
||||||
|
minimumSystemVersions: Record<string, string>;
|
||||||
|
variantPaths: string[];
|
||||||
|
deltaPaths: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseRecord {
|
||||||
|
id: string;
|
||||||
|
appId: string;
|
||||||
|
uploadId: string;
|
||||||
|
version: string;
|
||||||
|
buildVersion: string;
|
||||||
|
appleItemId: string;
|
||||||
|
date: string;
|
||||||
|
localizedDescription: string;
|
||||||
|
minOSVersion?: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
status: ReleaseStatus;
|
||||||
|
errorCode?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
manifest: ManifestSummary;
|
||||||
|
createdAt: string;
|
||||||
|
publishedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadRecord {
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
sourceId: string;
|
||||||
|
appId?: string;
|
||||||
|
objectKey: string;
|
||||||
|
multipartUploadId?: string;
|
||||||
|
originalFilename: string;
|
||||||
|
expectedSize: number;
|
||||||
|
receivedSize: number;
|
||||||
|
status: "created" | "uploaded" | "queued" | "processing" | "completed" | "failed";
|
||||||
|
errorCode?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseAsset {
|
||||||
|
releaseId: string;
|
||||||
|
path: string;
|
||||||
|
objectKey: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
sha256: string;
|
||||||
|
contentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Identity {
|
||||||
|
subject: string;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="32" height="32" rx="10" fill="#172321"/>
|
||||||
|
<path d="M8 23.5L14.2 8.5H18.1L24 23.5H20.2L18.9 20H13.1L11.8 23.5H8ZM14.2 17.1H17.9L16.1 12.2L14.2 17.1Z" fill="#E9694B"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 285 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,92 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
process.env.AUTH_MODE = "demo";
|
||||||
|
process.env.STORAGE_MODE = "local";
|
||||||
|
process.env.PUBLIC_BASE_URL = "http://localhost:4000";
|
||||||
|
process.env.WEB_ORIGIN = "http://localhost:3000";
|
||||||
|
process.env.LOCAL_STORAGE_DIR = ".data/test-storage";
|
||||||
|
process.env.PROCESS_INLINE = "true";
|
||||||
|
delete process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
const { createServer } = await import("../apps/api/src/server.ts");
|
||||||
|
const runtime = await createServer();
|
||||||
|
const app = runtime.app;
|
||||||
|
|
||||||
|
test.after(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates a source and returns a dashboard in demo mode", async () => {
|
||||||
|
const sourceResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/sources",
|
||||||
|
headers: { "x-demo-user": "test@example.com", "content-type": "application/json" },
|
||||||
|
payload: { name: "Test Source", subtitle: "Test apps", visibility: "draft" },
|
||||||
|
});
|
||||||
|
assert.equal(sourceResponse.statusCode, 201);
|
||||||
|
const source = sourceResponse.json().source;
|
||||||
|
assert.equal(source.visibility, "draft");
|
||||||
|
|
||||||
|
const dashboardResponse = await app.inject({ method: "GET", url: "/api/v1/dashboard", headers: { "x-demo-user": "test@example.com" } });
|
||||||
|
assert.equal(dashboardResponse.statusCode, 200);
|
||||||
|
const dashboard = dashboardResponse.json();
|
||||||
|
assert.ok(dashboard.sources.some((entry) => entry.source.id === source.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not expose draft sources publicly", async () => {
|
||||||
|
const response = await app.inject({ method: "GET", url: "/sources/test-source/source.json" });
|
||||||
|
assert.equal(response.statusCode, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validates, stores, publishes, and serves an ADP package", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "altdock-test-adp-"));
|
||||||
|
const fixtureRoot = join(root, "fixture");
|
||||||
|
await (await import("node:fs/promises")).mkdir(join(fixtureRoot, "variant"), { recursive: true });
|
||||||
|
const manifest = Buffer.from(JSON.stringify({
|
||||||
|
distributionPackageRevision: 1,
|
||||||
|
appleItemId: "123456789",
|
||||||
|
bundleId: "com.example.fixture",
|
||||||
|
shortVersionString: "1.0",
|
||||||
|
bundleVersion: "1",
|
||||||
|
platforms: ["ios"],
|
||||||
|
minimumSystemVersions: { ios: "17.4" },
|
||||||
|
variants: [{ assetPath: "variant/fixture.ipa", installTargets: [] }],
|
||||||
|
deltas: [],
|
||||||
|
}));
|
||||||
|
const signature = Buffer.from("signed-fixture");
|
||||||
|
const ipa = Buffer.from("not-an-installable-ipa-fixture");
|
||||||
|
await writeFile(join(fixtureRoot, "manifest.json"), manifest);
|
||||||
|
await writeFile(join(fixtureRoot, "signature"), signature);
|
||||||
|
await writeFile(join(fixtureRoot, "variant/fixture.ipa"), ipa);
|
||||||
|
const archivePath = join(root, "fixture.zip");
|
||||||
|
await promisify(execFile)("zip", ["-q", "-r", archivePath, "manifest.json", "signature", "variant"], { cwd: fixtureRoot });
|
||||||
|
const archive = await readFile(archivePath);
|
||||||
|
const sourceResponse = await app.inject({ method: "POST", url: "/api/v1/sources", headers: { "x-demo-user": "fixture@example.com", "content-type": "application/json" }, payload: { name: "Fixture Source", visibility: "public" } });
|
||||||
|
const source = sourceResponse.json().source;
|
||||||
|
const uploadResponse = await app.inject({ method: "POST", url: "/api/v1/uploads", headers: { "x-demo-user": "fixture@example.com", "content-type": "application/json" }, payload: { sourceId: source.id, filename: "fixture.zip", sizeBytes: archive.length } });
|
||||||
|
assert.equal(uploadResponse.statusCode, 201);
|
||||||
|
const upload = uploadResponse.json();
|
||||||
|
const putResponse = await app.inject({ method: "PUT", url: new URL(upload.uploadPlan.uploadUrl).pathname, headers: { "content-type": "application/zip" }, payload: archive });
|
||||||
|
assert.equal(putResponse.statusCode, 200, putResponse.body);
|
||||||
|
const completeResponse = await app.inject({ method: "POST", url: `/api/v1/uploads/${upload.upload.id}/complete`, headers: { "x-demo-user": "fixture@example.com", "content-type": "application/json" }, payload: {} });
|
||||||
|
assert.equal(completeResponse.statusCode, 202, completeResponse.body);
|
||||||
|
const release = completeResponse.json().release;
|
||||||
|
assert.equal(release.status, "ready");
|
||||||
|
const publishResponse = await app.inject({ method: "POST", url: `/api/v1/releases/${release.id}/publish`, headers: { "x-demo-user": "fixture@example.com" } });
|
||||||
|
assert.equal(publishResponse.statusCode, 200, publishResponse.body);
|
||||||
|
const sourceJsonResponse = await app.inject({ method: "GET", url: `/sources/${source.slug}/source.json` });
|
||||||
|
assert.equal(sourceJsonResponse.statusCode, 200, sourceJsonResponse.body);
|
||||||
|
const sourceJson = sourceJsonResponse.json();
|
||||||
|
assert.equal(sourceJson.apps[0].bundleIdentifier, "com.example.fixture");
|
||||||
|
assert.match(sourceJson.apps[0].versions[0].downloadURL, new RegExp(`/artifacts/${release.id}/manifest\\.json$`));
|
||||||
|
const artifactResponse = await app.inject({ method: "GET", url: `/artifacts/${release.id}/manifest.json` });
|
||||||
|
assert.equal(artifactResponse.statusCode, 200, artifactResponse.body);
|
||||||
|
assert.deepEqual(artifactResponse.rawPayload, manifest);
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { buildSourceDocument } from "../packages/core/src/index.ts";
|
||||||
|
|
||||||
|
test("builds a PAL source with published releases only", () => {
|
||||||
|
const source = {
|
||||||
|
id: "source-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
slug: "my-apps",
|
||||||
|
name: "My Apps",
|
||||||
|
subtitle: "Small iOS experiments",
|
||||||
|
description: "A test source",
|
||||||
|
tintColor: "#E9694B",
|
||||||
|
visibility: "public",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
const app = {
|
||||||
|
id: "app-1",
|
||||||
|
sourceId: "source-1",
|
||||||
|
name: "Orbit",
|
||||||
|
bundleIdentifier: "com.example.orbit",
|
||||||
|
marketplaceID: "123",
|
||||||
|
developerName: "Example",
|
||||||
|
subtitle: "",
|
||||||
|
localizedDescription: "An orbit app",
|
||||||
|
tintColor: "#E9694B",
|
||||||
|
category: "utilities",
|
||||||
|
screenshots: [],
|
||||||
|
appPermissions: { entitlements: [], privacy: {} },
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
const published = {
|
||||||
|
id: "release-1",
|
||||||
|
appId: "app-1",
|
||||||
|
uploadId: "upload-1",
|
||||||
|
version: "1.0",
|
||||||
|
buildVersion: "1",
|
||||||
|
appleItemId: "123",
|
||||||
|
date: "2026-02-01T00:00:00.000Z",
|
||||||
|
localizedDescription: "First release",
|
||||||
|
sizeBytes: 1024,
|
||||||
|
status: "published",
|
||||||
|
manifest: { bundleId: "com.example.orbit", appleItemId: "123", shortVersionString: "1.0", bundleVersion: "1", variantPaths: [], deltaPaths: [], platforms: [], minimumSystemVersions: {} },
|
||||||
|
createdAt: "2026-02-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
const draft = { ...published, id: "release-2", version: "2.0", status: "ready" };
|
||||||
|
const document = buildSourceDocument(source, [app], [published, draft], "https://example.test");
|
||||||
|
assert.equal(document.apps.length, 1);
|
||||||
|
assert.equal(document.apps[0].versions.length, 1);
|
||||||
|
assert.equal(document.apps[0].versions[0].downloadURL, "https://example.test/artifacts/release-1/manifest.json");
|
||||||
|
assert.deepEqual(document.featuredApps, ["com.example.orbit"]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import vinext from "vinext";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import hostingConfig from "./.openai/hosting.json";
|
||||||
|
import { sites } from "./build/sites-vite-plugin";
|
||||||
|
|
||||||
|
const SITE_CREATOR_PLACEHOLDER_DATABASE_ID =
|
||||||
|
"00000000-0000-4000-8000-000000000000";
|
||||||
|
|
||||||
|
const { d1, r2 } = hostingConfig;
|
||||||
|
|
||||||
|
// macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
|
||||||
|
const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
|
||||||
|
|
||||||
|
const localBindingConfig = {
|
||||||
|
main: "./worker/index.ts",
|
||||||
|
compatibility_flags: ["nodejs_compat"],
|
||||||
|
d1_databases: d1
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
binding: d1,
|
||||||
|
database_name: "site-creator-d1",
|
||||||
|
database_id: SITE_CREATOR_PLACEHOLDER_DATABASE_ID,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
r2_buckets: r2
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
binding: r2,
|
||||||
|
bucket_name: "site-creator-r2",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineConfig(async () => {
|
||||||
|
// Keep Wrangler and Miniflare state project-local. These are non-secret tool
|
||||||
|
// settings; application environment belongs in ignored `.env*` files.
|
||||||
|
process.env.WRANGLER_WRITE_LOGS ??= "false";
|
||||||
|
process.env.WRANGLER_LOG_PATH ??= ".wrangler/logs";
|
||||||
|
process.env.MINIFLARE_REGISTRY_PATH ??= ".wrangler/registry";
|
||||||
|
|
||||||
|
// Wrangler snapshots its log path while the Cloudflare plugin is imported.
|
||||||
|
const { cloudflare } = await import("@cloudflare/vite-plugin");
|
||||||
|
|
||||||
|
return {
|
||||||
|
server: isCodexSeatbeltSandbox
|
||||||
|
? { watch: { useFsEvents: false, usePolling: true } }
|
||||||
|
: undefined,
|
||||||
|
plugins: [
|
||||||
|
vinext(),
|
||||||
|
sites(),
|
||||||
|
cloudflare({
|
||||||
|
viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
|
||||||
|
config: localBindingConfig,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/** Cloudflare Worker entry point for the vinext-starter template. */
|
||||||
|
import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
|
||||||
|
import handler from "vinext/server/app-router-entry";
|
||||||
|
|
||||||
|
interface Env {
|
||||||
|
ASSETS: Fetcher;
|
||||||
|
DB: D1Database;
|
||||||
|
IMAGES: {
|
||||||
|
input(stream: ReadableStream): {
|
||||||
|
transform(options: Record<string, unknown>): {
|
||||||
|
output(options: { format: string; quality: number }): Promise<{ response(): Response }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExecutionContext {
|
||||||
|
waitUntil(promise: Promise<unknown>): void;
|
||||||
|
passThroughOnException(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image security config. SVG sources with .svg extension auto-skip the
|
||||||
|
// optimization endpoint on the client side (served directly, no proxy).
|
||||||
|
// To route SVGs through the optimizer (with security headers), set
|
||||||
|
// dangerouslyAllowSVG: true in next.config.js and uncomment below:
|
||||||
|
// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true };
|
||||||
|
|
||||||
|
const worker = {
|
||||||
|
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.pathname === "/_vinext/image") {
|
||||||
|
const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
|
||||||
|
return handleImageOptimization(request, {
|
||||||
|
fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))),
|
||||||
|
transformImage: async (body, { width, format, quality }) => {
|
||||||
|
const result = await env.IMAGES.input(body).transform(width > 0 ? { width } : {}).output({ format, quality });
|
||||||
|
return result.response();
|
||||||
|
},
|
||||||
|
}, allowedWidths);
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler.fetch(request, env, ctx);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default worker;
|
||||||
Reference in New Issue
Block a user