Initial open-source release (MIT): image2api AI gateway

Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider
account pools, billing/admin, Docker one-command deploy with auto HTTPS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 22:59:04 +08:00
co-authored by Claude Opus 4.8
commit 606caaf047
142 changed files with 33648 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
// Date/relative-time formatting helpers ported from admin.html.
const CN_LOCALE = 'zh-CN'
const CN_TZ_OPTS = { timeZone: 'Asia/Shanghai', hour12: false }
/** Format a unix-seconds timestamp in Asia/Shanghai. */
export function fmtTs(sec) {
sec = Number(sec)
if (!sec || Number.isNaN(sec)) return '—'
try {
return new Date(sec * 1000).toLocaleString(CN_LOCALE, {
...CN_TZ_OPTS, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
} catch { return '—' }
}
/** Format an ISO-8601 string in Asia/Shanghai. */
export function fmtIso(iso) {
if (!iso) return '—'
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
try {
return d.toLocaleString(CN_LOCALE, {
...CN_TZ_OPTS, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
} catch { return iso }
}
/** Human-friendly "5m 后 / 3h 前" relative time from unix seconds.
* Floors to integer seconds so floating-point ts (e.g. `time.time()` on the
* server) never leaks "15.88s 前" into the UI. */
export function fmtRelative(ts) {
ts = Number(ts)
if (!ts || Number.isNaN(ts)) return '—'
const diff = Math.round(ts - Date.now() / 1000)
const abs = Math.abs(diff)
const u = (n, s) => `${n}${s}`
let txt
if (abs < 60) txt = u(abs, 's')
else if (abs < 3600) txt = u(Math.floor(abs / 60), 'm')
else if (abs < 86400) txt = u(Math.floor(abs / 3600), 'h')
else txt = u(Math.floor(abs / 86400), 'd')
return diff >= 0 ? `${txt}` : `${txt}`
}
// Accepts either unix-seconds (number or numeric string) or an ISO-8601 string,
// returning a Date in either case (null when unparseable). Lets the stacked
// date/time cells below work for both the unix timestamps (created_at, etc.)
// and the ISO reset_after string without callers caring which they hold.
function toDate(v) {
if (v === null || v === undefined || v === '') return null
if (typeof v === 'number' || /^\d+(\.\d+)?$/.test(String(v))) {
const n = Number(v)
return n ? new Date(n * 1000) : null
}
const d = new Date(v)
return isNaN(d.getTime()) ? null : d
}
/** Date part only — "2026/06/18". Pair with fmtClock for a compact 2-line cell. */
export function fmtDate(v) {
const d = toDate(v)
if (!d) return '—'
try {
return d.toLocaleDateString(CN_LOCALE, { ...CN_TZ_OPTS, year: 'numeric', month: '2-digit', day: '2-digit' })
} catch { return '—' }
}
/** Time part only — "00:31:09". Empty string when there's no timestamp. */
export function fmtClock(v) {
const d = toDate(v)
if (!d) return ''
try {
return d.toLocaleTimeString(CN_LOCALE, { ...CN_TZ_OPTS, hour: '2-digit', minute: '2-digit', second: '2-digit' })
} catch { return '' }
}
// Rank a resolution tier for ascending sort. Handles both video ("720p"/"1080p"
// /"4k") and image ("1K"/"2K"/"4K"): the "k" suffix scales ×1000 so "4k"/"4K"
// rank ABOVE "1080p" (a plain parseFloat would put "4k"=4 first, which is wrong).
function resRank(r) {
const s = String(r).trim()
const n = parseFloat(s) || 0
return /k$/i.test(s) ? n * 1000 : n
}
/** Sort resolution tiers ascending (720p before 1080p; 1K before 2K before 4K). */
export function sortResolutions(list) {
return [...(list || [])].sort((a, b) => resRank(a) - resRank(b))
}
export function nowTime() {
return new Date().toLocaleTimeString(CN_LOCALE, CN_TZ_OPTS)
}
/** Human-readable byte size — "86 MB", "5.3 MB", "512 KB". Rounds to a whole
* number at ≥10 units, one decimal below, so the same byte count reads
* identically everywhere (overview KPI, 图片管理, lightbox…). */
export function fmtSize(bytes) {
bytes = Number(bytes)
if (!bytes || Number.isNaN(bytes)) return '0 B'
const u = ['B', 'KB', 'MB', 'GB', 'TB']
let i = 0; let v = bytes
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ }
return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${u[i]}`
}
+115
View File
@@ -0,0 +1,115 @@
// Smart parsing of pasted credentials (Adobe cookies / ChatGPT JWTs),
// ported verbatim from admin.html so import behaviour is unchanged.
export function looksLikeJwt(s) {
s = (s || '').replace(/^Bearer\s+/i, '').trim()
const parts = s.split('.')
if (parts.length !== 3) return false
return parts.every((p) => /^[A-Za-z0-9_-]+$/.test(p) && p.length > 4)
}
function decodeJwtPayload(s) {
try {
let p = (s || '').replace(/^Bearer\s+/i, '').trim().split('.')[1]
if (!p) return null
p = p.replace(/-/g, '+').replace(/_/g, '/')
p += '='.repeat((4 - (p.length % 4)) % 4)
return JSON.parse(atob(p))
} catch (_) { return null }
}
// Runway JWTs carry a top-level numeric `id` plus an `sso` claim and, crucially,
// no OpenAI (https://api.openai.com/*) claims — that's what distinguishes them
// from a ChatGPT JWT, which is otherwise also an opaque three-part token.
export function looksLikeRunwayJwt(s) {
const claims = decodeJwtPayload(s)
if (!claims || typeof claims !== 'object') return false
if (Object.keys(claims).some((k) => k.startsWith('https://api.openai.com/'))) return false
return 'sso' in claims && claims.id != null
}
// Leonardo cookies carry the better-auth session cookie — that's what tells them
// apart from an Adobe cookie (both are otherwise opaque cookie strings).
export function looksLikeLeonardoCookie(s) {
return /better-auth\.session_token/.test(s || '') || /better-auth\.session_data/.test(s || '')
}
// Krea cookies carry the Supabase auth cookie.
export function looksLikeKreaCookie(s) {
return /sb-superb-auth-token/.test(s || '')
}
// An Imagine.art credential is a JSON object { token, refreshToken } (both JWTs).
function isImagineObj(o) {
return !!o && typeof o === 'object' &&
typeof o.token === 'string' && looksLikeJwt(o.token) &&
typeof o.refreshToken === 'string' && looksLikeJwt(o.refreshToken)
}
// String form (a pasted JSON object on a line).
export function looksLikeImagineToken(s) {
try { return isImagineObj(JSON.parse(s)) } catch (_) { return false }
}
// Classify an opaque credential string by its distinctive shape. Imagine is
// JSON-shaped, so it must be checked before the cookie heuristics.
function cookieType(v) {
if (looksLikeImagineToken(v)) return 'imagine'
if (looksLikeKreaCookie(v)) return 'krea'
if (looksLikeLeonardoCookie(v)) return 'leonardo'
return 'adobe'
}
function cookieFromAny(item) {
if (typeof item === 'string') return item.trim()
if (item && typeof item === 'object') {
if (typeof item.cookie === 'string') return item.cookie.trim()
if (typeof item.value === 'string' && !('name' in item)) return item.value.trim()
if (Array.isArray(item.cookies)) {
return item.cookies.filter((c) => c && c.name).map((c) => `${c.name}=${c.value}`).join('; ')
}
}
return ''
}
/** Returns a list of { type: 'adobe' | 'openai' | 'runway' | 'leonardo', value }. */
export function parseImportInput(text) {
text = (text || '').trim()
if (!text) return []
// Try JSON first.
try {
const j = JSON.parse(text)
if (Array.isArray(j) && j.length > 0) {
// Chrome cookie export: array of {name,value} → one cookie account.
if (j.every((it) => it && typeof it === 'object' && 'name' in it && 'value' in it)) {
const joined = j.filter((c) => c && c.name).map((c) => `${c.name}=${c.value}`).join('; ')
return joined ? [{ type: cookieType(joined), value: joined }] : []
}
// Otherwise treat as multiple accounts. An Imagine credential is itself a
// JSON object {token,refreshToken} — keep it as its JSON string value.
return j.map((it) => {
if (isImagineObj(it)) return { type: 'imagine', value: JSON.stringify(it) }
const v = cookieFromAny(it)
return { type: cookieType(v), value: v }
}).filter((x) => x.value)
}
if (j && typeof j === 'object') {
if (isImagineObj(j)) return [{ type: 'imagine', value: JSON.stringify(j) }]
const v = cookieFromAny(j)
return v ? [{ type: cookieType(v), value: v }] : []
}
} catch (_) { /* not JSON */ }
// Not JSON → split per line, identify each. A JWT is either a Runway token
// (top-level id+sso, no openai claims) or a ChatGPT token; anything else is
// treated as an Adobe cookie string.
const lines = text.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
return lines.map((line) => {
if (looksLikeJwt(line)) {
const value = line.replace(/^Bearer\s+/i, '')
return looksLikeRunwayJwt(value)
? { type: 'runway', value }
: { type: 'openai', value }
}
return { type: cookieType(line), value: line }
})
}