Skip to content

Commit d4af20c

Browse files
committed
Add icon and APK update support in admin app list
1 parent 0e17b0c commit d4af20c

3 files changed

Lines changed: 565 additions & 24 deletions

File tree

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { createClient } from "@supabase/supabase-js";
3+
import { createWriteStream, createReadStream } from "fs";
4+
import { unlink, stat } from "fs/promises";
5+
import * as os from "os";
6+
import * as path from "path";
7+
import Busboy from "busboy";
8+
import { Readable } from "stream";
9+
10+
const githubApiRequest = async (endpoint: string, options: RequestInit = {}) => {
11+
const githubToken = process.env.GITHUB_TOKEN;
12+
if (!githubToken) throw new Error("Missing GITHUB_TOKEN");
13+
14+
const res = await fetch(`https://api.github.com${endpoint}`, {
15+
...options,
16+
headers: {
17+
Accept: "application/vnd.github.v3+json",
18+
Authorization: `token ${githubToken}`,
19+
"X-GitHub-Api-Version": "2022-11-28",
20+
...options.headers,
21+
},
22+
});
23+
24+
if (!res.ok) {
25+
const errorText = await res.text();
26+
throw new Error(`GitHub API Error (${res.status}): ${errorText}`);
27+
}
28+
return res.json();
29+
};
30+
31+
function getSupabaseAdmin() {
32+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
33+
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
34+
if (!supabaseUrl || !supabaseServiceKey) {
35+
throw new Error("Missing Supabase credentials.");
36+
}
37+
return createClient(supabaseUrl, supabaseServiceKey, {
38+
auth: { autoRefreshToken: false, persistSession: false },
39+
});
40+
}
41+
42+
export async function POST(request: NextRequest) {
43+
const githubRepo = "zeeshanbage/ZeeshanAppHub";
44+
45+
if (!request.body) {
46+
return NextResponse.json({ error: "No request body." }, { status: 400 });
47+
}
48+
49+
const contentType = request.headers.get("content-type") || "";
50+
const bb = Busboy({ headers: { "content-type": contentType } });
51+
52+
const fields: Record<string, string> = {};
53+
let tempApkPath = "";
54+
55+
const parsePromise = new Promise<void>((resolve, reject) => {
56+
bb.on("field", (name, val) => {
57+
fields[name] = val;
58+
});
59+
60+
bb.on("file", (name, file) => {
61+
if (name === "apk") {
62+
const tempFileName = `update_${Date.now()}.apk`;
63+
tempApkPath = path.join(os.tmpdir(), tempFileName);
64+
65+
const writeStream = createWriteStream(tempApkPath);
66+
file.pipe(writeStream);
67+
68+
file.on("error", reject);
69+
writeStream.on("error", reject);
70+
} else {
71+
file.resume();
72+
}
73+
});
74+
75+
bb.on("finish", resolve);
76+
bb.on("error", reject);
77+
});
78+
79+
try {
80+
const nodeStream = Readable.fromWeb(request.body as any);
81+
nodeStream.pipe(bb);
82+
await parsePromise;
83+
} catch {
84+
return NextResponse.json({ error: "Failed to parse form data." }, { status: 400 });
85+
}
86+
87+
try {
88+
const { appId, appName, newVersion, oldApkUrl } = fields;
89+
if (!appId || !appName || !newVersion || !tempApkPath) {
90+
throw new Error("Missing required fields (appId, appName, newVersion) or APK file.");
91+
}
92+
93+
const supabase = getSupabaseAdmin();
94+
const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9-]/g, "");
95+
const releaseTag = `${sanitize(appName)}-v${sanitize(newVersion)}`;
96+
const apkFileName = `${sanitize(appName)}_v${sanitize(newVersion)}.apk`;
97+
98+
// 1. Delete old GitHub Release (best-effort)
99+
if (oldApkUrl && oldApkUrl.includes("github.com")) {
100+
try {
101+
const urlParts = oldApkUrl.split("/");
102+
const downloadIndex = urlParts.indexOf("download");
103+
if (downloadIndex !== -1 && urlParts[downloadIndex + 1]) {
104+
const oldTag = urlParts[downloadIndex + 1];
105+
const githubToken = process.env.GITHUB_TOKEN;
106+
107+
const releaseData = await githubApiRequest(`/repos/${githubRepo}/releases/tags/${oldTag}`);
108+
109+
await fetch(`https://api.github.com/repos/${githubRepo}/releases/${releaseData.id}`, {
110+
method: "DELETE",
111+
headers: {
112+
Authorization: `token ${githubToken}`,
113+
"X-GitHub-Api-Version": "2022-11-28",
114+
},
115+
});
116+
117+
await fetch(`https://api.github.com/repos/${githubRepo}/git/refs/tags/${oldTag}`, {
118+
method: "DELETE",
119+
headers: {
120+
Authorization: `token ${githubToken}`,
121+
"X-GitHub-Api-Version": "2022-11-28",
122+
},
123+
});
124+
125+
console.log(`Deleted old GitHub Release: ${oldTag}`);
126+
}
127+
} catch (e: any) {
128+
console.warn("Old release cleanup failed:", e.message);
129+
}
130+
}
131+
132+
// 2. Create new GitHub Release
133+
const releaseData = await githubApiRequest(`/repos/${githubRepo}/releases`, {
134+
method: "POST",
135+
body: JSON.stringify({
136+
tag_name: releaseTag,
137+
name: `${appName} - Version ${newVersion}`,
138+
body: `Updated release for ${appName} v${newVersion}.`,
139+
draft: false,
140+
prerelease: false,
141+
generate_release_notes: false,
142+
}),
143+
});
144+
145+
// 3. Upload APK to GitHub via stream
146+
const uploadUrl = releaseData.upload_url.replace("{?name,label}", `?name=${apkFileName}`);
147+
const githubToken = process.env.GITHUB_TOKEN;
148+
149+
const fileSize = await stat(tempApkPath).then(s => s.size);
150+
const fileStream = createReadStream(tempApkPath);
151+
const webStream = Readable.toWeb(fileStream);
152+
153+
const uploadRes = await fetch(uploadUrl, {
154+
method: "POST",
155+
headers: {
156+
Accept: "application/vnd.github.v3+json",
157+
Authorization: `token ${githubToken}`,
158+
"Content-Type": "application/vnd.android.package-archive",
159+
"X-GitHub-Api-Version": "2022-11-28",
160+
"Content-Length": String(fileSize),
161+
},
162+
body: webStream as any,
163+
duplex: "half",
164+
} as any);
165+
166+
if (!uploadRes.ok) {
167+
const errText = await uploadRes.text();
168+
throw new Error(`APK upload to GitHub failed: ${errText}`);
169+
}
170+
171+
const assetData = await uploadRes.json();
172+
const newApkUrl = assetData.browser_download_url;
173+
174+
// 4. Update DB with new version and apk_url
175+
const { error: dbErr } = await supabase
176+
.from("apps")
177+
.update({ version: newVersion, apk_url: newApkUrl })
178+
.eq("id", appId);
179+
if (dbErr) throw new Error(`DB update failed: ${dbErr.message}`);
180+
181+
return NextResponse.json({ success: true, apk_url: newApkUrl, version: newVersion });
182+
} catch (error: any) {
183+
console.error("Update APK Error:", error);
184+
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
185+
} finally {
186+
if (tempApkPath) {
187+
await unlink(tempApkPath).catch(() => {});
188+
}
189+
}
190+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { createClient } from "@supabase/supabase-js";
3+
import Busboy from "busboy";
4+
import { Readable } from "stream";
5+
6+
function getSupabaseAdmin() {
7+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
8+
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
9+
if (!supabaseUrl || !supabaseServiceKey) {
10+
throw new Error("Missing Supabase credentials.");
11+
}
12+
return createClient(supabaseUrl, supabaseServiceKey, {
13+
auth: { autoRefreshToken: false, persistSession: false },
14+
});
15+
}
16+
17+
export async function POST(request: NextRequest) {
18+
if (!request.body) {
19+
return NextResponse.json({ error: "No request body." }, { status: 400 });
20+
}
21+
22+
const contentType = request.headers.get("content-type") || "";
23+
const bb = Busboy({ headers: { "content-type": contentType } });
24+
25+
const fields: Record<string, string> = {};
26+
let iconChunks: Buffer[] = [];
27+
28+
const parsePromise = new Promise<void>((resolve, reject) => {
29+
bb.on("field", (name, val) => {
30+
fields[name] = val;
31+
});
32+
33+
bb.on("file", (name, file) => {
34+
if (name === "icon") {
35+
file.on("data", (chunk: Buffer) => {
36+
iconChunks.push(chunk);
37+
});
38+
} else {
39+
file.resume();
40+
}
41+
});
42+
43+
bb.on("finish", resolve);
44+
bb.on("error", reject);
45+
});
46+
47+
try {
48+
const nodeStream = Readable.fromWeb(request.body as any);
49+
nodeStream.pipe(bb);
50+
await parsePromise;
51+
} catch {
52+
return NextResponse.json({ error: "Failed to parse form data." }, { status: 400 });
53+
}
54+
55+
try {
56+
const { appId, oldIconUrl } = fields;
57+
if (!appId || iconChunks.length === 0) {
58+
return NextResponse.json({ error: "Missing appId or icon file." }, { status: 400 });
59+
}
60+
61+
const supabase = getSupabaseAdmin();
62+
const iconBuffer = Buffer.concat(iconChunks);
63+
iconChunks = []; // free memory
64+
65+
// 1. Delete old icon from Supabase Storage (best-effort)
66+
if (oldIconUrl && oldIconUrl.includes("/icons/")) {
67+
try {
68+
const iconPath = oldIconUrl.split("/icons/")[1]?.split("?")[0];
69+
if (iconPath) {
70+
await supabase.storage.from("icons").remove([iconPath]);
71+
}
72+
} catch (e) {
73+
console.warn("Old icon cleanup failed:", e);
74+
}
75+
}
76+
77+
// 2. Upload new icon
78+
const iconFileName = `${Date.now()}.png`;
79+
const { error: uploadErr } = await supabase.storage
80+
.from("icons")
81+
.upload(iconFileName, iconBuffer, { upsert: false, contentType: "image/png" });
82+
if (uploadErr) throw new Error(`Icon upload failed: ${uploadErr.message}`);
83+
84+
// 3. Get signed URL
85+
const { data: signed, error: signErr } = await supabase.storage
86+
.from("icons")
87+
.createSignedUrl(iconFileName, 60 * 60 * 24 * 365 * 100);
88+
if (signErr || !signed) throw new Error(`Icon sign failed: ${signErr?.message}`);
89+
90+
// 4. Update DB
91+
const { error: dbErr } = await supabase
92+
.from("apps")
93+
.update({ icon_url: signed.signedUrl })
94+
.eq("id", appId);
95+
if (dbErr) throw new Error(`DB update failed: ${dbErr.message}`);
96+
97+
return NextResponse.json({ success: true, icon_url: signed.signedUrl });
98+
} catch (error: any) {
99+
console.error("Update Icon Error:", error);
100+
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
101+
}
102+
}

0 commit comments

Comments
 (0)