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:
@@ -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]}`
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user