diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 402f6e0..c0e5729 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -1,12 +1,7 @@ import { NextResponse } from "next/server"; -import fs from "fs"; -import path from "path"; -import JSZip from "jszip"; -import { ManifestParser } from "@/lib/extract-tools/manifest"; -import slugify from "slugify"; +import { LocalFSAdapter } from "@/lib/adapters/LocalFSAdapter"; import { UPLOAD_DIR } from "@/constants"; -import { env } from "@/env"; -import { parsePlist } from "@/lib/extract-tools/plist-parse"; +import { AdapterError } from "@/lib/adapters/Errors"; const ALLOWED_EXTENSIONS = ["apk", "ipa"]; @@ -41,124 +36,15 @@ export async function PUT(req: Request) { ); } - const appSlug = `${slugify(appName, { - lower: true, - remove: /[*+~.()'"!:@\/]/g, - })}`; + try { + const adapter = new LocalFSAdapter({ uploadDir: UPLOAD_DIR }); - const artifactFile = await artifact.arrayBuffer(); - const artifactBuffer = Buffer.from(artifactFile); - - const dirPath = path.join(UPLOAD_DIR, appSlug); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } - - switch (extension) { - case "apk": { - const archive = await JSZip.loadAsync(artifactBuffer); - - const manifestBuffer = await archive - .file("AndroidManifest.xml") - ?.async("arraybuffer"); - if (!manifestBuffer) { - return NextResponse.json( - { message: "Invalid APK file, no AndroidManifest.xml found" }, - { status: 400 } - ); - } - - const manifest = new ManifestParser(Buffer.from(manifestBuffer)).parse(); - const versionCode = manifest.versionCode as number | undefined; - const packageName = manifest.package as string | undefined; - - if (!versionCode || !packageName) { - return NextResponse.json( - { message: "Invalid APK file, no versionCode or package name found" }, - { status: 400 } - ); - } - - // cleanup old apk files - const files = fs.readdirSync(dirPath); - for (const file of files) { - if (file.endsWith(".apk") || file.endsWith(".android.json")) { - fs.unlinkSync(path.join(dirPath, file)); - } - } - - // write new apk file - fs.writeFileSync(path.join(dirPath, `android.apk`), artifactBuffer); - // write metadata - fs.writeFileSync( - path.join(dirPath, `metadata.android.json`), - JSON.stringify(manifest, null, 2) - ); - - return NextResponse.json(`${env.HOST}/build/${appSlug}`); - } - - case "ipa": { - const archive = await JSZip.loadAsync(artifactBuffer); - - const rawInfoPlist = await archive - .file(/Payload\/[^/]+\/Info.plist/)[0] - ?.async("uint8array"); - - if (!rawInfoPlist) { - return NextResponse.json( - { message: "Invalid IPA file, no Info.plist found" }, - { status: 400 } - ); - } - - const plist = parsePlist(rawInfoPlist) as Record | undefined; - - if (typeof plist !== "object") { - return NextResponse.json( - { message: "Invalid IPA file, Info.plist is not a valid plist" }, - { status: 400 } - ); - } - - const version = plist.CFBundleVersion as string | undefined; - const bundleId = plist.CFBundleIdentifier as string | undefined; - - if (!version || !bundleId) { - return NextResponse.json( - { - message: - "Invalid IPA file, no CFBundleVersion or CFBundleIdentifier found", - }, - { status: 400 } - ); - } - - // cleanup old ipa files - const files = fs.readdirSync(dirPath); - for (const file of files) { - if (file.endsWith(".ipa") || file.endsWith(".ios.json")) { - fs.unlinkSync(path.join(dirPath, file)); - } - } - - // write new ipa file - fs.writeFileSync(path.join(dirPath, `ios.ipa`), artifactBuffer); - - // write metadata - fs.writeFileSync( - path.join(dirPath, `metadata.ios.json`), - JSON.stringify(plist, null, 2) - ); - - return NextResponse.json(`${env.HOST}/build/${appSlug}`); - } - - default: { - return NextResponse.json( - { message: "Invalid file extension, only .apk or .ipa are allowed" }, - { status: 400 } - ); - } + const result = await adapter.saveArtifact(appName, artifact); + return NextResponse.json(result, { status: 201 }); + } catch (err) { + const error = err as AdapterError; + return new Response(error.message, { + status: error.code || 500, + }); } } diff --git a/src/app/build/[app_slug]/[platform]/manifest/route.ts b/src/app/build/[app_slug]/[platform]/manifest/route.ts index bb7c99e..80b092c 100644 --- a/src/app/build/[app_slug]/[platform]/manifest/route.ts +++ b/src/app/build/[app_slug]/[platform]/manifest/route.ts @@ -1,8 +1,6 @@ import { UPLOAD_DIR } from "@/constants"; -import path from "path"; -import fs from "fs"; - -const HOST = process.env.HOST as string; +import { AdapterError } from "@/lib/adapters/Errors"; +import { LocalFSAdapter } from "@/lib/adapters/LocalFSAdapter"; type Params = { params: { @@ -11,84 +9,22 @@ type Params = { }; }; -export function GET(request: Request, { params }: Params) { - const directory = path.join(UPLOAD_DIR, params.app_slug); - - if (!fs.existsSync(directory)) { - return new Response("No Development build found", { status: 404 }); - } - - const files = fs.readdirSync(directory); - - switch (params.platform) { - case "android": { - const manifest = files.find((file) => file.endsWith(".android.json")); - if (!manifest) { - return new Response("No Android development build found", { - status: 404, - }); - } - - const content = fs.readFileSync(path.join(directory, manifest)); - return new Response(content, { - headers: { "Content-Type": "application/json" }, - }); - } - - case "ios": { - const infoPlist = files.find((file) => file.endsWith(".ios.json")); - - if (!infoPlist) { - return new Response("No iOS development build found", { status: 404 }); - } - - const content = fs - .readFileSync(path.join(directory, infoPlist)) - .toString(); - - const info = JSON.parse(content); - - const bundleIdentifier = info.CFBundleIdentifier; - const bundleVersion = info.CFBundleVersion; - const appName = info.CFBundleName; - - const url = `${HOST}/build/${params.app_slug}/ios`; - - const manifest = ` - - - items - - - assets - - - kind - software-package - url - ${url} - - - metadata - - bundle-identifier - ${bundleIdentifier} - bundle-version - ${bundleVersion} - kind - software - title - ${appName} - - - - - `.trim(); - - return new Response(manifest, { - headers: { "Content-Type": "application/xml" }, - }); - } +export async function GET(request: Request, { params }: Params) { + try { + const adapter = new LocalFSAdapter({ uploadDir: UPLOAD_DIR }); + const metadata = await adapter.getArtifactManifest( + params.app_slug, + params.platform + ); + return new Response(metadata.content, { + headers: { + "Content-Type": metadata.type, + }, + }); + } catch (err) { + const error = err as AdapterError; + return new Response(error.message, { + status: error.code || 500, + }); } } diff --git a/src/app/build/[app_slug]/[platform]/route.ts b/src/app/build/[app_slug]/[platform]/route.ts index 02b46e8..88c1b07 100644 --- a/src/app/build/[app_slug]/[platform]/route.ts +++ b/src/app/build/[app_slug]/[platform]/route.ts @@ -1,6 +1,6 @@ import { UPLOAD_DIR } from "@/constants"; -import path from "path"; -import fs from "fs"; +import { AdapterError } from "@/lib/adapters/Errors"; +import { LocalFSAdapter } from "@/lib/adapters/LocalFSAdapter"; type Params = { params: { @@ -9,49 +9,22 @@ type Params = { }; }; -export function GET(request: Request, { params }: Params) { - const directory = path.join(UPLOAD_DIR, params.app_slug); - - if (!fs.existsSync(directory)) { - return new Response("No Development build found", { status: 404 }); - } - - const files = fs.readdirSync(directory); - - const headers = new Headers(); - - headers.set("Content-Type", "application/octet-stream"); - - switch (params.platform) { - case "ios": { - const iosBuild = files.find((file) => file.endsWith(".ipa")); - if (!iosBuild) { - return new Response("No iOS development build found", { status: 404 }); - } - const size = fs.statSync(path.join(directory, iosBuild)).size; - headers.set("Content-Disposition", `attachment; filename=${iosBuild}`); - headers.set("Content-Length", size.toString()); - const content = fs.readFileSync(path.join(directory, iosBuild)); - - return new Response(content, { headers }); - } - case "android": { - const androidBuild = files.find((file) => file.endsWith(".apk")); - if (!androidBuild) { - return new Response("No Android development build found", { - status: 404, - }); - } - const size = fs.statSync(path.join(directory, androidBuild)).size; - headers.set( - "Content-Disposition", - `attachment; filename=${androidBuild}` - ); - headers.set("Content-Length", size.toString()); - const content = fs.readFileSync(path.join(directory, androidBuild)); - - return new Response(content, { headers }); - } +export async function GET(request: Request, { params }: Params) { + try { + const headers = new Headers(); + headers.set("Content-Type", "application/octet-stream"); + const adapter = new LocalFSAdapter({ uploadDir: UPLOAD_DIR }); + const metadata = await adapter.getArtifactFile( + params.app_slug, + params.platform + ); + headers.set("Content-Disposition", `attachment; filename=${metadata.name}`); + headers.set("Content-Length", metadata.size.toString()); + return new Response(metadata.content, { headers }); + } catch (err) { + const error = err as AdapterError; + return new Response(error.message, { + status: error.code || 500, + }); } - return new Response("Hello worker!", { status: 200 }); } diff --git a/src/app/build/[app_slug]/page.tsx b/src/app/build/[app_slug]/page.tsx index e133dee..874ee5e 100644 --- a/src/app/build/[app_slug]/page.tsx +++ b/src/app/build/[app_slug]/page.tsx @@ -1,11 +1,7 @@ import { UPLOAD_DIR } from "@/constants"; -import path from "path"; -import fs from "fs"; import { notFound } from "next/navigation"; -import qrcode from "qrcode"; -import { Artifacts } from "@/types"; import { Builds } from "@/components/Builds"; -import { env } from "@/env"; +import { LocalFSAdapter } from "@/lib/adapters/LocalFSAdapter"; type Props = { params: { @@ -14,66 +10,12 @@ type Props = { }; const getArtifacts = async (app_slug: string) => { - const directory = path.join(UPLOAD_DIR, app_slug); - - if (!fs.existsSync(directory)) { + try { + const adapter = new LocalFSAdapter({ uploadDir: UPLOAD_DIR }); + return await adapter.getArtifacts(app_slug); + } catch (error) { return null; } - - const artifacts: Artifacts = { - android: null, - ios: null, - }; - - const files = fs.readdirSync(directory); - - for await (const file of files) { - const ext = path.extname(file).toLowerCase(); - const size = fs.statSync(path.join(directory, file)).size; - const createdAt = fs.statSync(path.join(directory, file)).birthtime; - switch (ext) { - case ".apk": { - const metadata = files.find((file) => file.endsWith(".android.json")); - const metadataContent = fs - .readFileSync(path.join(directory, metadata!)) - .toString(); - - const qrCode = await qrcode.toDataURL( - `${env.HOST}/build/${app_slug}/android` - ); - artifacts.android = { - downloadUrl: `/build/${app_slug}/android`, - downloadQrCode: qrCode, - size, - uploadDate: createdAt.toISOString(), - metadata: JSON.parse(metadataContent), - }; - break; - } - case ".ipa": { - const metadata = files.find((file) => file.endsWith(".ios.json")); - const metadataContent = fs - .readFileSync(path.join(directory, metadata!)) - .toString(); - - const manifestUrl = `/build/${app_slug}/ios/manifest`; - const manifestQrCodeUrl = `itms-services://?action=download-manifest&url=${env.HOST}${manifestUrl}`; - const manifestQrCode = await qrcode.toDataURL(manifestQrCodeUrl); - artifacts.ios = { - downloadUrl: `/build/${app_slug}/ios`, - downloadManifestUrl: manifestUrl, - manifestQrCode: manifestQrCode, - manifestQrCodeUrl, - size, - uploadDate: createdAt.toISOString(), - metadata: JSON.parse(metadataContent), - }; - break; - } - } - } - - return artifacts; }; export default async function Page({ params }: Props) { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9ed5e72..58e85b4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -16,14 +16,10 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - + {children} diff --git a/src/app/page.tsx b/src/app/page.tsx index d3040ec..13717cc 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,15 +5,13 @@ import fs from "fs"; import { Badge } from "@/components/ui/badge"; import Link from "next/link"; import { env } from "@/env"; +import { LocalFSAdapter } from "@/lib/adapters/LocalFSAdapter"; export const dynamic = "force-dynamic"; const getArtifactNames = async () => { - const files = await fs.promises.readdir(UPLOAD_DIR); - - return files.filter((maybeDir) => - fs.lstatSync(path.join(UPLOAD_DIR, maybeDir)).isDirectory() - ); + const adapter = new LocalFSAdapter({ uploadDir: UPLOAD_DIR }); + return await adapter.getAllArtifactNames(); }; const getExampleCommand = () => { @@ -28,7 +26,14 @@ export default async function Home() { return (
- logo + logo

Artifacts Repository

diff --git a/src/lib/adapters/AdapterBase.ts b/src/lib/adapters/AdapterBase.ts new file mode 100644 index 0000000..cba7e09 --- /dev/null +++ b/src/lib/adapters/AdapterBase.ts @@ -0,0 +1,15 @@ +import { ArtifactFile, Artifacts, MetadataFile } from "@/types"; + +export interface AdapterBase { + saveArtifact(name: string, file: File): Promise; + getArtifacts(name: string): Promise; + getArtifactFile( + name: string, + platform: "ios" | "android" + ): Promise; + getArtifactManifest( + name: string, + platform: "ios" | "android" + ): Promise; + getAllArtifactNames(): Promise; +} diff --git a/src/lib/adapters/Errors.ts b/src/lib/adapters/Errors.ts new file mode 100644 index 0000000..b5a623f --- /dev/null +++ b/src/lib/adapters/Errors.ts @@ -0,0 +1,28 @@ +export class NotFoundError extends Error { + code: number = 404; + constructor(message: string) { + super(message); + this.name = "NotFoundError"; + } +} + +export class InvalidFileError extends Error { + code: number = 400; + constructor(message: string) { + super(message); + this.name = "InvalidFileError"; + } +} + +export class NotSupportedPlatformError extends Error { + code: number = 400; + constructor(message: string) { + super(message); + this.name = "NotSupportedPlatformError"; + } +} + +export type AdapterError = + | NotFoundError + | InvalidFileError + | NotSupportedPlatformError; diff --git a/src/lib/adapters/LocalFSAdapter.ts b/src/lib/adapters/LocalFSAdapter.ts new file mode 100644 index 0000000..c751b1b --- /dev/null +++ b/src/lib/adapters/LocalFSAdapter.ts @@ -0,0 +1,332 @@ +import { AdapterBase } from "./AdapterBase"; +import slugify from "slugify"; +import path from "path"; +import fs from "fs"; +import JSZip from "jszip"; +import { ManifestParser } from "@/lib/extract-tools/manifest"; +import { env } from "@/env"; +import { parsePlist } from "../extract-tools/plist-parse"; +import { ArtifactFile, Artifacts, MetadataFile } from "@/types"; +import qrcode from "qrcode"; +import { + InvalidFileError, + NotFoundError, + NotSupportedPlatformError, +} from "./Errors"; + +type LocalFSAdapterOptions = { + uploadDir: string; +}; +export class LocalFSAdapter implements AdapterBase { + private options: LocalFSAdapterOptions; + + constructor(options: LocalFSAdapterOptions) { + this.options = options; + } + + async saveArtifact(name: string, file: File): Promise { + const extension = file.name.split(".").pop(); + const appSlug = `${slugify(name, { + lower: true, + remove: /[*+~.()'"!:@\/]/g, + })}`; + + const artifactFile = await file.arrayBuffer(); + const artifactBuffer = Buffer.from(artifactFile); + + const dirPath = path.join(this.options.uploadDir, appSlug); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + + switch (extension) { + case "apk": { + const archive = await JSZip.loadAsync(artifactBuffer); + + const manifestBuffer = await archive + .file("AndroidManifest.xml") + ?.async("arraybuffer"); + if (!manifestBuffer) { + throw new InvalidFileError( + "Invalid APK file, no AndroidManifest.xml found" + ); + } + + const manifest = new ManifestParser( + Buffer.from(manifestBuffer) + ).parse(); + const versionCode = manifest.versionCode as number | undefined; + const packageName = manifest.package as string | undefined; + + if (!versionCode || !packageName) { + throw new InvalidFileError( + "Invalid APK file, no versionCode or package name found" + ); + } + + // cleanup old apk files + const files = fs.readdirSync(dirPath); + for (const file of files) { + if (file.endsWith(".apk") || file.endsWith(".android.json")) { + fs.unlinkSync(path.join(dirPath, file)); + } + } + + // write new apk file + fs.writeFileSync(path.join(dirPath, `android.apk`), artifactBuffer); + // write metadata + fs.writeFileSync( + path.join(dirPath, `metadata.android.json`), + JSON.stringify(manifest, null, 2) + ); + + return `${env.HOST}/build/${appSlug}`; + } + + case "ipa": { + const archive = await JSZip.loadAsync(artifactBuffer); + + const rawInfoPlist = await archive + .file(/Payload\/[^/]+\/Info.plist/)[0] + ?.async("uint8array"); + + if (!rawInfoPlist) { + throw new InvalidFileError("Invalid IPA file, no Info.plist found"); + } + + const plist = parsePlist(rawInfoPlist) as + | Record + | undefined; + + if (typeof plist !== "object") { + throw new InvalidFileError( + "Invalid IPA file, Info.plist is not a valid plist" + ); + } + + const version = plist.CFBundleVersion as string | undefined; + const bundleId = plist.CFBundleIdentifier as string | undefined; + + if (!version || !bundleId) { + throw new InvalidFileError( + "Invalid IPA file, no CFBundleVersion or CFBundleIdentifier found" + ); + } + + // cleanup old ipa files + const files = fs.readdirSync(dirPath); + for (const file of files) { + if (file.endsWith(".ipa") || file.endsWith(".ios.json")) { + fs.unlinkSync(path.join(dirPath, file)); + } + } + + // write new ipa file + fs.writeFileSync(path.join(dirPath, `ios.ipa`), artifactBuffer); + + // write metadata + fs.writeFileSync( + path.join(dirPath, `metadata.ios.json`), + JSON.stringify(plist, null, 2) + ); + + return `${env.HOST}/build/${appSlug}`; + } + + default: { + throw new NotSupportedPlatformError("Invalid file extension"); + } + } + } + + async getArtifacts(name: string): Promise { + const directory = path.join(this.options.uploadDir, name); + + if (!fs.existsSync(directory)) { + throw new NotFoundError("No Development build found"); + } + + const artifacts: Artifacts = { + android: null, + ios: null, + }; + + const files = fs.readdirSync(directory); + + for await (const file of files) { + const ext = path.extname(file).toLowerCase(); + const size = fs.statSync(path.join(directory, file)).size; + const createdAt = fs.statSync(path.join(directory, file)).birthtime; + switch (ext) { + case ".apk": { + const metadata = files.find((file) => file.endsWith(".android.json")); + const metadataContent = fs + .readFileSync(path.join(directory, metadata!)) + .toString(); + + const qrCode = await qrcode.toDataURL( + `${env.HOST}/build/${name}/android` + ); + artifacts.android = { + downloadUrl: `/build/${name}/android`, + downloadQrCode: qrCode, + size, + uploadDate: createdAt.toISOString(), + metadata: JSON.parse(metadataContent), + }; + break; + } + case ".ipa": { + const metadata = files.find((file) => file.endsWith(".ios.json")); + const metadataContent = fs + .readFileSync(path.join(directory, metadata!)) + .toString(); + + const manifestUrl = `/build/${name}/ios/manifest`; + const manifestQrCodeUrl = `itms-services://?action=download-manifest&url=${env.HOST}${manifestUrl}`; + const manifestQrCode = await qrcode.toDataURL(manifestQrCodeUrl); + artifacts.ios = { + downloadUrl: `/build/${name}/ios`, + downloadManifestUrl: manifestUrl, + manifestQrCode: manifestQrCode, + manifestQrCodeUrl, + size, + uploadDate: createdAt.toISOString(), + metadata: JSON.parse(metadataContent), + }; + break; + } + } + } + + return artifacts; + } + + getArtifactFile( + name: string, + platform: "ios" | "android" + ): Promise { + const directory = path.join(this.options.uploadDir, name); + + if (!fs.existsSync(directory)) { + throw new NotFoundError("No Development build found"); + } + + const files = fs.readdirSync(directory); + + switch (platform) { + case "ios": { + const iosBuild = files.find((file) => file.endsWith(".ipa")); + if (!iosBuild) { + throw new Error("No iOS development build found"); + } + const size = fs.statSync(path.join(directory, iosBuild)).size; + + const content = fs.readFileSync(path.join(directory, iosBuild)); + + return Promise.resolve({ content, size, name: iosBuild }); + } + case "android": { + const androidBuild = files.find((file) => file.endsWith(".apk")); + if (!androidBuild) { + throw new Error("No Android development build found"); + } + const size = fs.statSync(path.join(directory, androidBuild)).size; + + const content = fs.readFileSync(path.join(directory, androidBuild)); + + return Promise.resolve({ content, size, name: androidBuild }); + } + } + } + + getArtifactManifest( + name: string, + platform: "ios" | "android" + ): Promise { + const directory = path.join(this.options.uploadDir, name); + + if (!fs.existsSync(directory)) { + throw new NotFoundError("No Development build found"); + } + + const files = fs.readdirSync(directory); + + switch (platform) { + case "android": { + const manifest = files.find((file) => file.endsWith(".android.json")); + if (!manifest) { + throw new NotFoundError("No Android development build found"); + } + + const content = fs.readFileSync(path.join(directory, manifest)); + return Promise.resolve({ content, type: "application/json" }); + } + + case "ios": { + const infoPlist = files.find((file) => file.endsWith(".ios.json")); + + if (!infoPlist) { + throw new NotFoundError("No iOS development build found"); + } + + const content = fs + .readFileSync(path.join(directory, infoPlist)) + .toString(); + + const info = JSON.parse(content); + + const bundleIdentifier = info.CFBundleIdentifier; + const bundleVersion = info.CFBundleVersion; + const appName = info.CFBundleName; + + const url = `${env.HOST}/build/${name}/ios`; + + const manifest = ` + + + items + + + assets + + + kind + software-package + url + ${url} + + + metadata + + bundle-identifier + ${bundleIdentifier} + bundle-version + ${bundleVersion} + kind + software + title + ${appName} + + + + + `.trim(); + + return Promise.resolve({ + content: Buffer.from(manifest), + type: "application/xml", + }); + } + } + } + + async getAllArtifactNames() { + const files = await fs.promises.readdir(this.options.uploadDir); + + return files.filter((maybeDir) => + fs.lstatSync(path.join(this.options.uploadDir, maybeDir)).isDirectory() + ); + } +} diff --git a/src/types.ts b/src/types.ts index 4bc72e3..9220686 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,3 +20,14 @@ export type Artifacts = { android: AndroidArtifact | null; ios: IOSArtifact | null; }; + +export type ArtifactFile = { + name: string; + size: number; + content: Buffer; +}; + +export type MetadataFile = { + type: string; + content: Buffer; +};