feat: added a login page with Authentik authentication
This commit is contained in:
@@ -9,3 +9,21 @@ AWS_REGION=us-east-1
|
||||
AWS_ACCESS_KEY_ID=test
|
||||
AWS_SECRET_ACCESS_KEY=test
|
||||
PORT=4501
|
||||
|
||||
# Console authentication. Generate a hash with:
|
||||
# cd packages/api && bun run scripts/hash-password.ts 'choose-a-long-password'
|
||||
FLOCI_UI_ADMIN_USER=admin
|
||||
FLOCI_UI_ADMIN_PASSWORD_HASH=
|
||||
# At least 32 random characters, for example: openssl rand -base64 48
|
||||
FLOCI_UI_SESSION_SECRET=
|
||||
FLOCI_UI_SESSION_IDLE_TIMEOUT_SECONDS=1800
|
||||
FLOCI_UI_SESSION_ABSOLUTE_TIMEOUT_SECONDS=28800
|
||||
# Set false only for local HTTP development. Keep true behind HTTPS reverse proxies.
|
||||
FLOCI_UI_SECURE_COOKIES=true
|
||||
|
||||
# Optional Authentik / OIDC mode. These are server-only values.
|
||||
# FLOCI_UI_AUTH_MODE=oidc
|
||||
# FLOCI_UI_OIDC_ISSUER=https://authentik.example.com/application/o/the-cloud
|
||||
# FLOCI_UI_OIDC_CLIENT_ID=
|
||||
# FLOCI_UI_OIDC_CLIENT_SECRET=
|
||||
# FLOCI_UI_PUBLIC_BASE_URL=https://thecloud.example.com
|
||||
|
||||
@@ -40,6 +40,14 @@ docker compose --profile multicloud up
|
||||
|
||||
Open [http://localhost:4500](http://localhost:4500).
|
||||
|
||||
## Exposing To The Internet
|
||||
|
||||
Floci UI requires an administrator username, an Argon2id password hash, and a session secret. Copy `.env.example` to `.env`, generate the hash with `cd packages/api && bun run scripts/hash-password.ts '<password>'`, and generate a session secret with `openssl rand -base64 48`. Do not put plaintext passwords in `.env`.
|
||||
|
||||
Put only Floci UI behind Nginx Proxy Manager, terminating TLS there and forwarding to port `4500`. Keep `FLOCI_UI_SECURE_COOKIES=true` in that deployment. Bind Floci core (`4566`) to the Docker network or localhost only: it must not be publicly reachable, because the authenticated UI proxy is the intended access path. Configure Nginx Proxy Manager to pass `X-Forwarded-Proto` and `X-Forwarded-For`, and place Cloudflare or another edge proxy in front of it for public deployments.
|
||||
|
||||
Set `FLOCI_UI_AUTH_MODE=oidc` to use an OIDC provider such as Authentik. Configure `FLOCI_UI_OIDC_ISSUER`, `FLOCI_UI_OIDC_CLIENT_ID`, `FLOCI_UI_OIDC_CLIENT_SECRET`, and `FLOCI_UI_PUBLIC_BASE_URL`; the callback is `${FLOCI_UI_PUBLIC_BASE_URL}/api/auth/oidc/callback`.
|
||||
|
||||
## What The UI Actually Exposes Today
|
||||
|
||||
The sidebar and Console Home are rendered from `GET /api/clouds/:cloud/services`,
|
||||
|
||||
@@ -37,6 +37,15 @@ services:
|
||||
FLOCI_GCP_ENDPOINT: http://floci-gcp:4588
|
||||
FLOCI_GCP_PROJECT: floci-local
|
||||
PORT: "4501"
|
||||
FLOCI_UI_ADMIN_USER: ${FLOCI_UI_ADMIN_USER:-}
|
||||
FLOCI_UI_ADMIN_PASSWORD_HASH: ${FLOCI_UI_ADMIN_PASSWORD_HASH:-}
|
||||
FLOCI_UI_SESSION_SECRET: ${FLOCI_UI_SESSION_SECRET:-}
|
||||
FLOCI_UI_SECURE_COOKIES: ${FLOCI_UI_SECURE_COOKIES:-false}
|
||||
FLOCI_UI_AUTH_MODE: ${FLOCI_UI_AUTH_MODE:-local}
|
||||
FLOCI_UI_OIDC_ISSUER: ${FLOCI_UI_OIDC_ISSUER:-}
|
||||
FLOCI_UI_OIDC_CLIENT_ID: ${FLOCI_UI_OIDC_CLIENT_ID:-}
|
||||
FLOCI_UI_OIDC_CLIENT_SECRET: ${FLOCI_UI_OIDC_CLIENT_SECRET:-}
|
||||
FLOCI_UI_PUBLIC_BASE_URL: ${FLOCI_UI_PUBLIC_BASE_URL:-}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
|
||||
@@ -27,6 +27,8 @@ RUN apk add --no-cache ca-certificates libstdc++
|
||||
WORKDIR /app
|
||||
COPY --from=api-build /app/server ./server
|
||||
COPY --from=frontend-build /app/packages/frontend/dist ./public
|
||||
COPY TheCloud.png ./public/TheCloud.png
|
||||
COPY svgAsset/authentik.svg ./public/authentik.svg
|
||||
ENV PORT=4500
|
||||
EXPOSE 4500
|
||||
CMD ["./server"]
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"start": "bun run src/index.ts",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "bun test"
|
||||
"test": "FLOCI_UI_ADMIN_USER=admin FLOCI_UI_ADMIN_PASSWORD_HASH='$argon2id$v=19$m=65536,t=2,p=1$I5n/tqYyZO0S+4z++KcAlpAJVu442Ivi7KxPl2CXoO4$mRSyN9R5ACFosgnfnd6S5ePoKrz2XBfVf4DTfG7T6LQ' FLOCI_UI_SESSION_SECRET=01234567890123456789012345678901 FLOCI_UI_SECURE_COOKIES=false bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-api-gateway": "^3.1076.0",
|
||||
@@ -45,6 +45,7 @@
|
||||
"@aws-sdk/util-dynamodb": "^3.996.7",
|
||||
"dotenv": "^17.4.2",
|
||||
"hono": "^4.12.27",
|
||||
"jose": "^6.2.12",
|
||||
"kafkajs": "^2.2.4",
|
||||
"mssql": "^12.7.0",
|
||||
"pg": "^8.22.0",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
const password = process.argv[2]
|
||||
|
||||
if (!password) {
|
||||
throw new Error('Usage: bun run scripts/hash-password.ts <password>')
|
||||
}
|
||||
|
||||
console.log(await Bun.password.hash(password, {algorithm: 'argon2id'}))
|
||||
@@ -0,0 +1,223 @@
|
||||
import {timingSafeEqual} from 'node:crypto'
|
||||
import {createRemoteJWKSet, jwtVerify} from 'jose'
|
||||
import type {Context, MiddlewareHandler} from 'hono'
|
||||
import {deleteCookie, getCookie, setCookie} from 'hono/cookie'
|
||||
import {renderLoginPage} from './loginPage.ts'
|
||||
|
||||
const SESSION_COOKIE = 'floci_ui_session'
|
||||
const CSRF_COOKIE = 'floci_ui_csrf'
|
||||
const LOGIN_WINDOW_MS = 15 * 60_000
|
||||
const LOGIN_ATTEMPTS = 5
|
||||
|
||||
type AuthConfig = {
|
||||
mode: 'local' | 'oidc'
|
||||
adminUser?: string
|
||||
passwordHash?: string
|
||||
secret: string
|
||||
idleTimeoutMs: number
|
||||
absoluteTimeoutMs: number
|
||||
secureCookies: boolean
|
||||
oidc?: {issuer: string; clientId: string; clientSecret: string; publicBaseUrl: string}
|
||||
}
|
||||
|
||||
type Session = {username: string; createdAt: number; lastSeenAt: number; csrfToken: string}
|
||||
type Attempt = {count: number; firstAt: number; lockedUntil: number}
|
||||
|
||||
const sessions = new Map<string, Session>()
|
||||
const attempts = new Map<string, Attempt>()
|
||||
const oidcRequests = new Map<string, {nonce: string; verifier: string; returnTo: string; expiresAt: number}>()
|
||||
|
||||
export function authConfig(env = process.env): AuthConfig {
|
||||
const mode = (env.FLOCI_UI_AUTH_MODE ?? 'local') as 'local' | 'oidc'
|
||||
if (mode !== 'local' && mode !== 'oidc') throw new Error('FLOCI_UI_AUTH_MODE must be local or oidc')
|
||||
const secret = required(env.FLOCI_UI_SESSION_SECRET, 'FLOCI_UI_SESSION_SECRET')
|
||||
if (secret.length < 32) throw new Error('FLOCI_UI_SESSION_SECRET must be at least 32 characters')
|
||||
const idleTimeoutMs = seconds(env.FLOCI_UI_SESSION_IDLE_TIMEOUT_SECONDS, 1800)
|
||||
const absoluteTimeoutMs = seconds(env.FLOCI_UI_SESSION_ABSOLUTE_TIMEOUT_SECONDS, 28_800)
|
||||
if (idleTimeoutMs > absoluteTimeoutMs) throw new Error('FLOCI_UI_SESSION_IDLE_TIMEOUT_SECONDS cannot exceed FLOCI_UI_SESSION_ABSOLUTE_TIMEOUT_SECONDS')
|
||||
if (mode === 'oidc') {
|
||||
const issuer = `${required(env.FLOCI_UI_OIDC_ISSUER, 'FLOCI_UI_OIDC_ISSUER').replace(/\/$/, '')}/`
|
||||
return {mode, secret, idleTimeoutMs, absoluteTimeoutMs, secureCookies: env.FLOCI_UI_SECURE_COOKIES !== 'false', oidc: {issuer, clientId: required(env.FLOCI_UI_OIDC_CLIENT_ID, 'FLOCI_UI_OIDC_CLIENT_ID'), clientSecret: required(env.FLOCI_UI_OIDC_CLIENT_SECRET, 'FLOCI_UI_OIDC_CLIENT_SECRET'), publicBaseUrl: required(env.FLOCI_UI_PUBLIC_BASE_URL, 'FLOCI_UI_PUBLIC_BASE_URL').replace(/\/$/, '')}}
|
||||
}
|
||||
const adminUser = required(env.FLOCI_UI_ADMIN_USER, 'FLOCI_UI_ADMIN_USER')
|
||||
const passwordHash = required(env.FLOCI_UI_ADMIN_PASSWORD_HASH, 'FLOCI_UI_ADMIN_PASSWORD_HASH')
|
||||
if (!passwordHash.startsWith('$argon2id$') && !passwordHash.startsWith('$2')) throw new Error('FLOCI_UI_ADMIN_PASSWORD_HASH must be an argon2id or bcrypt hash')
|
||||
return {mode, adminUser, passwordHash, secret, idleTimeoutMs, absoluteTimeoutMs, secureCookies: env.FLOCI_UI_SECURE_COOKIES !== 'false'}
|
||||
}
|
||||
|
||||
export function authMiddleware(config: AuthConfig): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const styleNonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('base64url')
|
||||
c.set('styleNonce', styleNonce)
|
||||
applySecurityHeaders(c, config, styleNonce)
|
||||
if (c.req.method === 'OPTIONS') return next()
|
||||
if (isPublic(c.req.path)) return next()
|
||||
const session = await sessionFor(c, config)
|
||||
if (!session) return unauthenticated(c)
|
||||
if (stateChanging(c.req.method) && !csrfValid(c, session)) return c.json({error: 'CSRF validation failed'}, 403)
|
||||
await next()
|
||||
}
|
||||
}
|
||||
|
||||
export function authRoutes(config: AuthConfig): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
if (c.req.path === '/login' && c.req.method === 'GET') return renderLogin(c, config)
|
||||
if (c.req.path === '/api/auth/login' && c.req.method === 'POST') return login(c, config)
|
||||
if (c.req.path === '/api/auth/oidc/login' && c.req.method === 'GET') return oidcLogin(c, config)
|
||||
if (c.req.path === '/api/auth/oidc/callback' && c.req.method === 'GET') return oidcCallback(c, config)
|
||||
if (c.req.path === '/api/auth/logout' && c.req.method === 'POST') return logout(c, config)
|
||||
if (c.req.path === '/api/auth/session' && c.req.method === 'GET') {
|
||||
const session = await sessionFor(c, config)
|
||||
return session ? c.json({authenticated: true, username: session.username, csrfToken: session.csrfToken}) : c.json({authenticated: false}, 401)
|
||||
}
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
async function login(c: Context, config: AuthConfig): Promise<Response> {
|
||||
if (config.mode !== 'local') return c.json({error: 'Local login is disabled'}, 404)
|
||||
const form = await c.req.parseBody()
|
||||
const username = typeof form.username === 'string' ? form.username : ''
|
||||
const password = typeof form.password === 'string' ? form.password : ''
|
||||
const csrf = typeof form.csrf === 'string' ? form.csrf : c.req.header('x-floci-csrf') ?? ''
|
||||
if (!sameToken(getCookie(c, CSRF_COOKIE), csrf)) return loginFailure(c, 403)
|
||||
const key = `${clientIp(c)}:${username.toLowerCase()}`
|
||||
if (locked(key)) return loginFailure(c, 429)
|
||||
const usernameMatches = sameToken(username, config.adminUser)
|
||||
const passwordMatches = await Bun.password.verify(password, config.passwordHash!)
|
||||
if (!usernameMatches || !passwordMatches) {
|
||||
recordFailure(key)
|
||||
return loginFailure(c, 401)
|
||||
}
|
||||
attempts.delete(key)
|
||||
await createSession(c, config, config.adminUser!)
|
||||
const redirectTo = safeReturnTo(typeof form.returnTo === 'string' ? form.returnTo : undefined)
|
||||
return acceptsHtml(c) ? c.redirect(redirectTo, 302) : c.json({ok: true, redirectTo})
|
||||
}
|
||||
|
||||
async function oidcLogin(c: Context, config: AuthConfig): Promise<Response> {
|
||||
if (!config.oidc) return c.notFound()
|
||||
const discovery = await oidcDiscovery(config.oidc.issuer)
|
||||
const state = crypto.randomUUID()
|
||||
const nonce = crypto.randomUUID()
|
||||
const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url')
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
|
||||
const challenge = Buffer.from(digest).toString('base64url')
|
||||
oidcRequests.set(state, {nonce, verifier, returnTo: safeReturnTo(c.req.query('returnTo')), expiresAt: Date.now() + 10 * 60_000})
|
||||
const url = new URL(discovery.authorization_endpoint)
|
||||
url.search = new URLSearchParams({response_type: 'code', client_id: config.oidc.clientId, redirect_uri: `${config.oidc.publicBaseUrl}/api/auth/oidc/callback`, scope: 'openid profile email', state, nonce, code_challenge: challenge, code_challenge_method: 'S256'}).toString()
|
||||
return c.redirect(url.toString(), 302)
|
||||
}
|
||||
|
||||
async function oidcCallback(c: Context, config: AuthConfig): Promise<Response> {
|
||||
if (!config.oidc) return c.notFound()
|
||||
const state = c.req.query('state')
|
||||
const code = c.req.query('code')
|
||||
const request = state ? oidcRequests.get(state) : undefined
|
||||
if (!state || !code || !request || request.expiresAt < Date.now()) return c.json({error: 'Invalid OIDC callback'}, 400)
|
||||
oidcRequests.delete(state)
|
||||
const discovery = await oidcDiscovery(config.oidc.issuer)
|
||||
const tokenResponse = await fetch(discovery.token_endpoint, {method: 'POST', headers: {'content-type': 'application/x-www-form-urlencoded'}, body: new URLSearchParams({grant_type: 'authorization_code', code, redirect_uri: `${config.oidc.publicBaseUrl}/api/auth/oidc/callback`, client_id: config.oidc.clientId, client_secret: config.oidc.clientSecret, code_verifier: request.verifier})})
|
||||
const tokens = await tokenResponse.json() as {id_token?: string}
|
||||
if (!tokenResponse.ok || !tokens.id_token) return c.json({error: 'OIDC token exchange failed'}, 401)
|
||||
const jwks = createRemoteJWKSet(new URL(discovery.jwks_uri))
|
||||
const verified = await jwtVerify(tokens.id_token, jwks, {issuer: config.oidc.issuer, audience: config.oidc.clientId})
|
||||
if (verified.payload.nonce !== request.nonce) return c.json({error: 'OIDC nonce validation failed'}, 401)
|
||||
const username = String(verified.payload.preferred_username ?? verified.payload.email ?? verified.payload.sub ?? '')
|
||||
if (!username) return c.json({error: 'OIDC identity has no usable subject'}, 401)
|
||||
await createSession(c, config, username)
|
||||
return c.redirect(request.returnTo, 302)
|
||||
}
|
||||
|
||||
async function createSession(c: Context, config: AuthConfig, username: string): Promise<void> {
|
||||
const id = crypto.randomUUID()
|
||||
const csrfToken = crypto.randomUUID()
|
||||
sessions.set(id, {username, createdAt: Date.now(), lastSeenAt: Date.now(), csrfToken})
|
||||
setCookie(c, SESSION_COOKIE, `${id}.${await signature(id, config.secret)}`, cookieOptions(config))
|
||||
setCookie(c, CSRF_COOKIE, csrfToken, {...cookieOptions(config), httpOnly: false})
|
||||
}
|
||||
|
||||
async function logout(c: Context, config: AuthConfig): Promise<Response> {
|
||||
const raw = getCookie(c, SESSION_COOKIE)
|
||||
const id = raw?.split('.', 1)[0]
|
||||
if (id) sessions.delete(id)
|
||||
deleteCookie(c, SESSION_COOKIE, {path: '/', secure: config.secureCookies, sameSite: 'Lax'})
|
||||
deleteCookie(c, CSRF_COOKIE, {path: '/', secure: config.secureCookies, sameSite: 'Lax'})
|
||||
return c.json({ok: true})
|
||||
}
|
||||
|
||||
async function sessionFor(c: Context, config: AuthConfig): Promise<Session | null> {
|
||||
const raw = getCookie(c, SESSION_COOKIE)
|
||||
if (!raw) return null
|
||||
const [id, signed] = raw.split('.')
|
||||
if (!id || !signed || !sameToken(signed, await signature(id, config.secret))) return null
|
||||
const session = sessions.get(id)
|
||||
if (!session) return null
|
||||
const now = Date.now()
|
||||
if (now - session.lastSeenAt > config.idleTimeoutMs || now - session.createdAt > config.absoluteTimeoutMs) {
|
||||
sessions.delete(id)
|
||||
return null
|
||||
}
|
||||
session.lastSeenAt = now
|
||||
return session
|
||||
}
|
||||
|
||||
function csrfValid(c: Context, session: Session): boolean {
|
||||
return sameToken(getCookie(c, CSRF_COOKIE), session.csrfToken) && sameToken(c.req.header('x-floci-csrf'), session.csrfToken)
|
||||
}
|
||||
|
||||
function renderLogin(c: Context, config: AuthConfig): Response {
|
||||
const csrf = getCookie(c, CSRF_COOKIE) ?? crypto.randomUUID()
|
||||
if (!getCookie(c, CSRF_COOKIE)) setCookie(c, CSRF_COOKIE, csrf, {...cookieOptions(config), httpOnly: false})
|
||||
const returnTo = safeReturnTo(c.req.query('returnTo'))
|
||||
return c.html(renderLoginPage({
|
||||
mode: config.mode,
|
||||
csrf,
|
||||
returnTo,
|
||||
error: loginErrorMessage(c.req.query('error')),
|
||||
styleNonce: String(c.get('styleNonce')),
|
||||
}))
|
||||
}
|
||||
|
||||
function loginPage(c: Context, config: AuthConfig): Response {
|
||||
const csrf = getCookie(c, CSRF_COOKIE) ?? crypto.randomUUID()
|
||||
if (!getCookie(c, CSRF_COOKIE)) setCookie(c, CSRF_COOKIE, csrf, {...cookieOptions(config), httpOnly: false})
|
||||
const returnTo = safeReturnTo(c.req.query('returnTo'))
|
||||
const content = config.mode === 'oidc'
|
||||
? `<form method="get" action="/api/auth/oidc/login"><input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}"><button class="sso-button" type="submit"><span class="ready"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 4 5v6c0 5 3.4 9.5 8 11 4.6-1.5 8-6 8-11V5l-8-3Zm0 4 4 1.5V11c0 3.2-1.9 6.2-4 7.4C9.9 17.2 8 14.2 8 11V7.5L12 6Z"/></svg>Sign in with Authentik</span><span class="loading"><i></i>Redirecting...</span></button></form>`
|
||||
: `<form method="post" action="/api/auth/login"><input type="hidden" name="csrf" value="${csrf}"><input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}"><label for="username">Username</label><input id="username" name="username" autocomplete="username" required autofocus><label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required><button class="sso-button" type="submit">Sign in</button></form>`
|
||||
const error = loginErrorMessage(c.req.query('error'))
|
||||
return c.html(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Welcome back | TheCloud</title><style>:root{color-scheme:dark;--bg:#0f141a;--surface:#161d26;--raised:#232b37;--border:#424650;--text:#c6c6cd;--bright:#f9f9fa;--muted:#a4a4ad;--accent:#42b4ff;--danger:#ff9b93}@media(prefers-color-scheme:light){:root{color-scheme:light;--bg:#efe7d2;--surface:#fffaf0;--raised:#ece4cf;--border:rgba(21,20,15,.16);--text:#2a2620;--bright:#15140f;--muted:#5a5448;--accent:#ed6f5c;--danger:#bd3f30}}*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh;display:grid;place-items:center;padding:28px 20px;color:var(--text);font:14px Sora,Inter,system-ui,sans-serif;background:radial-gradient(circle at 14% 12%,color-mix(in srgb,var(--accent) 16%,transparent),transparent 28rem),linear-gradient(135deg,var(--bg),var(--raised));}.shell{width:min(420px,100%);animation:enter .45s ease-out}.brand{display:flex;align-items:center;gap:12px;margin:0 0 20px 8px;color:var(--bright)}.brand svg{width:34px;height:34px;fill:none;stroke:var(--accent);stroke-width:1.6}.brand strong{display:block;font-size:16px;letter-spacing:-.02em}.brand small{display:block;margin-top:2px;color:var(--muted);font-size:11px}.card{padding:30px;border:1px solid var(--border);border-radius:14px;background:color-mix(in srgb,var(--surface) 94%,transparent);box-shadow:0 24px 64px #0005}.eyebrow{margin:0 0 8px;color:var(--accent);font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}h1{margin:0;color:var(--bright);font-size:26px;letter-spacing:-.04em}p{margin:9px 0 24px;color:var(--muted);line-height:1.55}.alert{display:flex;gap:9px;margin:0 0 18px;padding:11px 12px;border:1px solid color-mix(in srgb,var(--danger) 55%,var(--border));border-radius:8px;color:var(--danger);background:color-mix(in srgb,var(--danger) 10%,transparent);line-height:1.4}.sso-button{display:grid;place-items:center;width:100%;min-height:46px;border:0;border-radius:8px;background:var(--accent);color:#06131d;font:700 14px inherit;cursor:pointer;transition:transform .15s,filter .15s,box-shadow .15s}.sso-button:hover{filter:brightness(1.08);box-shadow:0 8px 20px color-mix(in srgb,var(--accent) 28%,transparent)}.sso-button:active{transform:translateY(1px)}.sso-button:focus-visible{outline:3px solid color-mix(in srgb,var(--accent) 58%,white);outline-offset:3px}.ready,.loading{display:flex;align-items:center;gap:9px}.ready svg{width:19px;height:19px;fill:currentColor;stroke:none}.loading{display:none}.sso-button:active .ready{display:none}.sso-button:active .loading{display:flex}.loading i{width:15px;height:15px;border:2px solid #06131d55;border-top-color:#06131d;border-radius:50%;animation:spin .7s linear infinite}.footer{margin:19px 0 0;text-align:center;font-size:12px;color:var(--muted)}label{display:block;margin:14px 0 6px}input{width:100%;padding:10px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--bright)}@keyframes enter{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}@keyframes spin{to{transform:rotate(360deg)}}@media(max-width:380px){body{padding:18px 14px}.card{padding:24px 20px}h1{font-size:23px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}}</style></head><body><main class="shell" aria-labelledby="welcome"><header class="brand"><svg viewBox="0 0 40 40" aria-hidden="true"><path d="M7 25c-3-7 3-13 10-11 4-8 16-5 16 4 5 1 6 8 1 10H12c-2 0-4-1-5-3Z"/><path d="M13 31h14"/></svg><span><strong>Floci DevTools</strong><small>Local AWS, on your own infrastructure.</small></span></header><section class="card" aria-label="Sign in"><div class="eyebrow">Secure console</div><h1 id="welcome">Welcome back</h1><p>Sign in with your account to access the console.</p>${error ? `<div class="alert" role="alert">${error}</div>` : ''}${content}<div class="footer">Protected by Authentik SSO</div></section></main></body></html>`)
|
||||
}
|
||||
|
||||
function loginFailure(c: Context, status: 401 | 403 | 429): Response {
|
||||
if (acceptsHtml(c)) return c.redirect('/login?error=1', 302)
|
||||
return c.json({error: status === 429 ? 'Too many sign-in attempts. Try again later.' : 'Invalid username or password.'}, status)
|
||||
}
|
||||
|
||||
function loginErrorMessage(code: string | undefined): string | null {
|
||||
if (!code) return null
|
||||
if (code === 'denied') return 'Your sign-in was cancelled. Please try again when you are ready.'
|
||||
if (code === 'expired') return 'Your sign-in session expired. Please start again.'
|
||||
return 'We could not complete your sign-in. Please try again.'
|
||||
}
|
||||
|
||||
function isPublic(path: string): boolean { return path === '/login' || path === '/TheCloud.png' || path === '/authentik.svg' || path === '/api/auth/login' || path === '/api/auth/oidc/login' || path === '/api/auth/oidc/callback' || path === '/api/auth/session' || path === '/api/health' }
|
||||
function unauthenticated(c: Context): Response { return c.req.path.startsWith('/api/') ? c.json({error: 'Authentication required'}, 401) : c.redirect(`/login?returnTo=${encodeURIComponent(safeReturnTo(c.req.path + new URL(c.req.url).search))}`, 302) }
|
||||
function safeReturnTo(value?: string): string { return value && value.startsWith('/') && !value.startsWith('//') ? value : '/console/aws' }
|
||||
function escapeHtml(value: string): string { return value.replace(/[&<>'"]/g, (character) => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[character] ?? character)) }
|
||||
function acceptsHtml(c: Context): boolean { return c.req.header('accept')?.includes('text/html') ?? false }
|
||||
function stateChanging(method: string): boolean { return !['GET', 'HEAD', 'OPTIONS'].includes(method) }
|
||||
function cookieOptions(config: AuthConfig) { return {httpOnly: true, secure: config.secureCookies, sameSite: 'Lax' as const, path: '/', maxAge: Math.floor(config.absoluteTimeoutMs / 1000)} }
|
||||
function seconds(value: string | undefined, fallback: number): number { const parsed = Number(value ?? fallback); if (!Number.isInteger(parsed) || parsed < 60) throw new Error('Session timeout values must be whole seconds of at least 60'); return parsed * 1000 }
|
||||
function required(value: string | undefined, name: string): string { if (!value?.trim()) throw new Error(`${name} is required`); return value }
|
||||
async function oidcDiscovery(issuer: string): Promise<{authorization_endpoint: string; token_endpoint: string; jwks_uri: string}> { const response = await fetch(`${issuer}.well-known/openid-configuration`); if (!response.ok) throw new Error('Unable to load OIDC discovery document'); return response.json() }
|
||||
async function signature(value: string, secret: string): Promise<string> { const bytes = await crypto.subtle.sign('HMAC', await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), {name: 'HMAC', hash: 'SHA-256'}, false, ['sign']), new TextEncoder().encode(value)); return Buffer.from(bytes).toString('base64url') }
|
||||
function sameToken(left: string | undefined, right: string | undefined): boolean { if (!left || !right) return false; const a = Buffer.from(left); const b = Buffer.from(right); return a.length === b.length && timingSafeEqual(a, b) }
|
||||
function locked(key: string): boolean { const attempt = attempts.get(key); return Boolean(attempt && attempt.lockedUntil > Date.now()) }
|
||||
function recordFailure(key: string): void { const now = Date.now(); const attempt = attempts.get(key); const current = !attempt || now - attempt.firstAt > LOGIN_WINDOW_MS ? {count: 0, firstAt: now, lockedUntil: 0} : attempt; current.count++; if (current.count >= LOGIN_ATTEMPTS) current.lockedUntil = now + Math.min(15 * 60_000, 30_000 * 2 ** (current.count - LOGIN_ATTEMPTS)); attempts.set(key, current) }
|
||||
// Hono's portable Request API does not expose the peer address. Do not trust a
|
||||
// client-supplied X-Forwarded-For value until the Bun server is wired to verify
|
||||
// it against an explicit proxy address allow-list.
|
||||
function clientIp(_c: Context): string { return 'unknown' }
|
||||
function applySecurityHeaders(c: Context, config: AuthConfig, styleNonce: string): void { c.header('Content-Security-Policy', `default-src 'self'; script-src 'self' 'nonce-${styleNonce}' 'sha256-mgwziK4YXYUt2nNB711bt/nNzs3je0rpErGfX/6eqmc='; style-src 'self' 'nonce-${styleNonce}' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'; object-src 'none'`); c.header('X-Frame-Options', 'DENY'); c.header('X-Content-Type-Options', 'nosniff'); c.header('Referrer-Policy', 'same-origin'); if (config.secureCookies) c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains') }
|
||||
@@ -47,9 +47,9 @@ describe('credentialed route CORS', () => {
|
||||
body,
|
||||
}))
|
||||
|
||||
expect(trusted.status).toBe(400)
|
||||
expect(trusted.status).toBe(401)
|
||||
expect(trusted.headers.get('access-control-allow-origin')).toBe(TRUSTED_ORIGIN)
|
||||
expect(untrusted.status).toBe(400)
|
||||
expect(untrusted.status).toBe(401)
|
||||
expect(untrusted.headers.get('access-control-allow-origin')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -63,9 +63,9 @@ describe('credentialed route CORS', () => {
|
||||
headers: {origin: UNTRUSTED_ORIGIN},
|
||||
}))
|
||||
|
||||
expect(sameOrigin.status).toBe(400)
|
||||
expect(sameOrigin.status).toBe(401)
|
||||
expect(sameOrigin.headers.get('access-control-allow-origin')).toBeNull()
|
||||
expect(unrelated.status).toBe(200)
|
||||
expect(unrelated.status).toBe(401)
|
||||
expect(unrelated.headers.get('access-control-allow-origin')).toBe('*')
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ describe('credentialed route CORS', () => {
|
||||
headers: {origin: UNTRUSTED_ORIGIN},
|
||||
}))
|
||||
|
||||
expect(schema.status).toBe(200)
|
||||
expect(schema.status).toBe(401)
|
||||
expect(schema.headers.get('access-control-allow-origin')).toBe('*')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { cors } from "hono/cors";
|
||||
import { logger } from "hono/logger";
|
||||
import {authConfig, authMiddleware, authRoutes} from './auth';
|
||||
import eks from "./routes/eks";
|
||||
import ec2 from "./routes/ec2";
|
||||
import secretsmanager from "./routes/secretsmanager";
|
||||
@@ -12,6 +13,7 @@ import mskConnect from "./routes/mskConnect";
|
||||
import ses from "./routes/ses";
|
||||
import elasticache from "./routes/elasticache";
|
||||
const app = new Hono();
|
||||
const authentication = authConfig();
|
||||
|
||||
// Secrets Manager and KMS crypto routes handle sensitive values with server-side
|
||||
// AWS credentials, so an unrestricted `cors()` would let any web page in the
|
||||
@@ -42,6 +44,8 @@ app.use(
|
||||
}),
|
||||
);
|
||||
app.use("*", logger());
|
||||
app.use("*", authMiddleware(authentication));
|
||||
app.use("*", authRoutes(authentication));
|
||||
|
||||
app.route("/api/eks", eks);
|
||||
app.route("/api/ec2", ec2);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
type LoginPageOptions = {
|
||||
mode: 'local' | 'oidc'
|
||||
csrf: string
|
||||
returnTo: string
|
||||
error: string | null
|
||||
styleNonce: string
|
||||
}
|
||||
|
||||
export function renderLoginPage(options: LoginPageOptions): string {
|
||||
const destination = options.returnTo === '/'
|
||||
? ''
|
||||
: `<div class="dest mono on">Landing on <span>${escapeHtml(options.returnTo)}</span></div>`
|
||||
const alert = options.error ? `<div class="alert on" role="alert">${escapeHtml(options.error)}</div>` : ''
|
||||
const signIn = options.mode === 'oidc'
|
||||
? `<form method="get" action="/api/auth/oidc/login" data-sso-form><input type="hidden" name="returnTo" value="${escapeHtml(options.returnTo)}"><button class="btn" type="submit"><img class="authentik-icon" src="/authentik.svg" alt="" aria-hidden="true"><span class="sp" aria-hidden="true"></span><span id="label">Sign in with Authentik</span></button></form>`
|
||||
: `<form method="post" action="/api/auth/login" class="local-form"><input type="hidden" name="csrf" value="${escapeHtml(options.csrf)}"><input type="hidden" name="returnTo" value="${escapeHtml(options.returnTo)}"><label for="username">Username</label><input id="username" name="username" autocomplete="username" required autofocus><label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required><button class="btn" type="submit">Sign in</button></form>`
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>Sign in · TheCloud</title>
|
||||
<style nonce="${options.styleNonce}">
|
||||
:root[data-theme=dark]{--bg:#07111d;--el:#0c1a2b;--fg:#e8f1fa;--dim:#bdd0e3;--mut:#8aa3bc;--ac:#6cc4ff;--acr:108,196,255;--ac2:#ffb547;--bd:rgba(232,241,250,.09);--bd2:rgba(232,241,250,.2);--card:#0d1c2f;--code:#050d17;--on:#06223a;--err:#ff9a9a;--errbg:#3a1822}
|
||||
:root[data-theme=light]{--bg:#eaf2fa;--el:#f6faff;--fg:#0e2238;--dim:#2f4660;--mut:#4a627a;--ac:#0a6fc2;--acr:10,111,194;--ac2:#c26a00;--bd:rgba(14,34,56,.12);--bd2:rgba(14,34,56,.26);--card:#fff;--code:#0e2238;--on:#fff;--err:#a4262c;--errbg:#fdeaea}
|
||||
:root[data-theme=cyber]{--bg:#02060a;--el:#050d10;--fg:#4eff96;--dim:#8affb8;--mut:#2f9a68;--ac:#ffea00;--acr:255,234,0;--ac2:#ff10f0;--bd:rgba(78,255,150,.2);--bd2:rgba(78,255,150,.42);--card:#050d10;--code:#000;--on:#02060a;--err:#ff8da1;--errbg:#2a0a14}
|
||||
*{box-sizing:border-box;margin:0}html{scroll-behavior:smooth}body{background:var(--bg);color:var(--fg);font-family:system-ui,"Segoe UI",sans-serif;overflow-x:hidden;-webkit-font-smoothing:antialiased}.mono{font-family:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace}.serif{font-family:ui-serif,"Iowan Old Style","Palatino Linotype",Georgia,serif}[data-theme=cyber] body::before{content:"";position:fixed;inset:0;pointer-events:none;z-index:90;background:repeating-linear-gradient(0deg,transparent 0 2px,rgba(78,255,150,.03) 2px 3px)}body.fade,body.fade *{transition:background-color .5s,color .5s,border-color .5s!important}.bar{position:fixed;top:0;left:0;height:3px;width:0;z-index:100;background:linear-gradient(90deg,var(--ac),var(--ac2))}.wrap{max-width:1180px;margin:0 auto;padding:0 24px}
|
||||
nav{position:fixed;inset:0 0 auto;z-index:50;backdrop-filter:blur(14px);border-bottom:1px solid var(--bd);background:rgba(var(--acr),.03)}nav .wrap{display:flex;align-items:center;justify-content:space-between;height:64px}.logo{font-weight:700;font-size:18px;color:var(--fg);text-decoration:none;display:flex;align-items:center}.logo i{font-style:normal;color:var(--ac)}.blk{display:inline-block;width:.5em;height:1em;margin-left:4px;background:var(--ac);animation:bl 1.1s steps(1) infinite}@keyframes bl{50%{opacity:0}}.links{display:flex;gap:30px;font-size:14px}.links a{color:var(--dim);text-decoration:none;position:relative}.links a:hover{color:var(--fg)}.links a::after{content:"";position:absolute;left:0;bottom:-5px;height:1px;width:0;background:var(--ac);transition:width .3s}.links a:hover::after{width:100%}.tg{display:flex;gap:2px;padding:3px;border:1px solid var(--bd2);border-radius:99px;background:var(--el)}.tg button{width:32px;height:32px;border:0;border-radius:99px;background:none;color:var(--mut);cursor:pointer;display:grid;place-items:center;transition:.3s}.tg button:hover{color:var(--fg)}.tg button.on{background:var(--ac);color:var(--on);box-shadow:0 0 14px rgba(var(--acr),.5)}.tg button:focus-visible,.btn:focus-visible,.cp:focus-visible{outline:2px solid var(--fg);outline-offset:2px}
|
||||
.hero{position:relative;min-height:100vh;display:flex;align-items:center;padding:110px 0 70px;overflow:hidden;background-image:linear-gradient(var(--bd) 1px,transparent 1px),linear-gradient(90deg,var(--bd) 1px,transparent 1px);background-size:56px 56px;background-position:center}.dot{position:absolute;border-radius:50%;filter:blur(70px);opacity:.22;animation:dr 16s ease-in-out infinite;pointer-events:none}.d1{width:480px;height:480px;top:4%;left:-140px;background:var(--ac)}.d2{width:560px;height:560px;bottom:-200px;right:-180px;background:var(--ac2);animation-delay:-5s}@keyframes dr{33%{transform:translate(60px,-40px) scale(1.1)}66%{transform:translate(-30px,50px) scale(.95)}}.glow{position:absolute;width:680px;height:680px;border-radius:50%;background:radial-gradient(circle,rgba(var(--acr),.13),transparent 60%);transform:translate(-50%,-50%);pointer-events:none;opacity:0;transition:opacity .4s}.grid{position:relative;z-index:2;display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,.75fr);gap:56px;align-items:center}.tag{display:inline-block;font:500 11px ui-monospace,Menlo,Consolas,monospace;letter-spacing:.1em;text-transform:uppercase;color:var(--ac);padding:4px 9px;border:1px solid rgba(var(--acr),.5);border-radius:2px;background:rgba(var(--acr),.08)}.meta{display:flex;gap:12px;flex-wrap:wrap;align-items:center;font-size:12px;color:var(--mut);margin-bottom:28px}.live{width:6px;height:6px;border-radius:50%;background:var(--ac2);box-shadow:0 0 8px var(--ac2);animation:pl 2s infinite}@keyframes pl{50%{opacity:.3}}h1{font-size:clamp(2rem,5.2vw,4.4rem);line-height:1.08;letter-spacing:-.04em;margin-bottom:26px;min-height:2.2em}h1 .p{color:var(--mut)}#tw::after{content:"▌";color:var(--ac);margin-left:4px;animation:bl 1s steps(1) infinite}.sub{font-size:clamp(1.2rem,2.2vw,1.7rem);line-height:1.35;font-style:italic;margin-bottom:34px;max-width:30em}.stats{display:flex;gap:40px;flex-wrap:wrap}.stats b{display:block;font-size:30px;color:var(--ac)}.stats span{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mut)}
|
||||
.card{background:var(--card);border:1px solid var(--bd2);border-radius:8px;padding:32px;position:relative;box-shadow:0 30px 60px -34px rgba(0,0,0,.55)}.card::before{content:"";position:absolute;inset:0 0 auto;height:2px;background:linear-gradient(90deg,var(--ac),var(--ac2))}.card h2{font-size:26px;letter-spacing:-.02em;margin-bottom:8px}.lead{color:var(--mut);font-size:14.5px;line-height:1.55}.dest{display:none;margin-top:16px;padding:9px 12px;border:1px dashed var(--bd2);border-radius:6px;font-size:12.5px;color:var(--mut);overflow-wrap:anywhere}.dest.on{display:block}.dest span{color:var(--fg)}.alert{display:none;margin-top:18px;padding:12px 14px;border:1px solid currentColor;border-radius:6px;background:var(--errbg);color:var(--err);font-size:14px;line-height:1.45}.alert.on{display:block}.btn{margin-top:22px;width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:15px 18px;border:0;border-radius:4px;background:var(--ac);color:var(--on);font:600 13px ui-monospace,Menlo,Consolas,monospace;letter-spacing:.08em;text-transform:uppercase;text-decoration:none;cursor:pointer;position:relative;overflow:hidden;transition:.3s}.btn::before{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,.28),transparent);transform:translateX(-100%);transition:transform .6s}.btn:hover{transform:translateY(-2px);box-shadow:0 12px 30px -8px rgba(var(--acr),.6)}.btn:hover::before{transform:translateX(100%)}.btn:disabled{pointer-events:none;filter:saturate(.6)}.authentik-icon{position:relative;z-index:1;width:20px;height:20px;object-fit:contain}.sp{display:none;width:14px;height:14px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:spin .7s linear infinite}.btn.is-loading .sp{display:block}.btn.is-loading .authentik-icon{display:none}@keyframes spin{to{transform:rotate(360deg)}}.nx{list-style:none;padding:22px 0 0;margin-top:26px;border-top:1px solid var(--bd);display:grid;gap:10px;font-size:14px;color:var(--mut)}.nx li::before{content:"→";color:var(--ac);margin-right:10px}.fine{margin-top:20px;font-size:12.5px;color:var(--mut);line-height:1.55}.local-form{display:grid;gap:8px;margin-top:18px}.local-form label{margin-top:6px;color:var(--mut);font:11px ui-monospace,Menlo,monospace;text-transform:uppercase;letter-spacing:.08em}.local-form input:not([type=hidden]){width:100%;min-height:43px;padding:0 12px;border:1px solid var(--bd2);border-radius:4px;outline:0;color:var(--fg);background:var(--bg)}.local-form input:focus{border-color:var(--ac);box-shadow:0 0 0 3px rgba(var(--acr),.14)}
|
||||
.mq{overflow:hidden;white-space:nowrap;padding:20px 0;border-block:1px solid var(--bd);background:var(--el)}.mq div{display:inline-flex;gap:56px;padding-right:56px;animation:sc 40s linear infinite;font-size:13px;letter-spacing:.14em;text-transform:uppercase;color:var(--mut)}@keyframes sc{to{transform:translateX(-50%)}}
|
||||
.qs{padding:100px 0;display:grid;grid-template-columns:minmax(0,.4fr) minmax(0,.6fr);gap:48px;align-items:start}.qs h2{font-size:clamp(2rem,4vw,3.2rem);line-height:1.05;letter-spacing:-.03em;margin:14px 0 20px}.qs p{color:var(--mut);line-height:1.65}.win{background:var(--code);border:1px solid var(--bd2);border-radius:8px;overflow:hidden;box-shadow:0 30px 60px -34px rgba(0,0,0,.5)}.win header{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;border-bottom:1px solid var(--bd);background:rgba(255,255,255,.04);font-size:12px;color:#8aa3bc}.win header span{display:flex;align-items:center;gap:8px}.win header .r{background:#ff5f57}.win header .y{background:#febc2e}.win header .g{background:#28c840}.pr{color:var(--ac)}.win header i{width:11px;height:11px;border-radius:50%;display:inline-block}.cp{background:none;border:1px solid var(--bd2);color:#8aa3bc;border-radius:3px;padding:5px 11px;font:500 11px ui-monospace,Menlo,monospace;letter-spacing:.08em;text-transform:uppercase;cursor:pointer;transition:.25s}.cp:hover{color:var(--ac);border-color:var(--ac)}.cp.ok{background:var(--ac);color:var(--on);border-color:var(--ac)}pre{padding:22px 24px;overflow-x:auto;font-size:13px;line-height:1.75;color:#e8f1fa}.k{color:#ff7b72}.s{color:#a5d6ff}.f{color:#d2a8ff}.c{color:#6e7681;font-style:italic}.o{color:#ffa657}[data-theme=cyber] .k{color:#ff10f0}[data-theme=cyber] .s{color:#ffea00}[data-theme=cyber] .f{color:#8affb8}[data-theme=cyber] pre{color:#4eff96}
|
||||
footer{border-top:1px solid var(--bd);padding:36px 0;font-size:13px;color:var(--mut)}footer .wrap{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap}kbd{border:1px solid var(--bd2);border-radius:3px;padding:1px 6px;color:var(--fg)}::selection{background:var(--ac);color:var(--on)}
|
||||
@media(max-width:900px){.links{display:none}.grid,.qs{grid-template-columns:1fr}.grid{gap:36px}.hero{padding-top:96px}.stats{gap:26px}.qs{padding:64px 24px}}
|
||||
@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bar" id="bar"></div>
|
||||
<nav><div class="wrap"><a class="logo mono" href="#top"><i>The</i>Cloud<span class="blk"></span></a><div class="links mono"><a href="#top">sign in</a><a href="#quickstart">quickstart</a></div><div class="tg" role="group" aria-label="Theme"><button data-t="dark" aria-label="Dark theme"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M21 13.5A9 9 0 1 1 10.5 3a7 7 0 0 0 10.5 10.5z"/></svg></button><button data-t="light" aria-label="Light theme"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg></button><button data-t="cyber" aria-label="Cyber theme"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l6 6-6 6M12 19h8"/></svg></button></div></div></nav>
|
||||
<section class="hero" id="top"><div class="dot d1"></div><div class="dot d2"></div><div class="glow" id="glow"></div><div class="wrap grid"><div><div class="meta mono"><span class="tag">powered by Floci</span><span>local AWS, your hardware</span></div><h1 class="mono"><span class="p">$</span> <span id="tw"></span></h1><p class="sub serif">Lambda, SQS, SNS, DynamoDB and Step Functions, running on your own network.</p><div class="stats mono"><div><b>7</b><span>workflow integrations</span></div><div><b>$0</b><span>aws bill</span></div><div><b>1</b><span>sign-in, sso + mfa</span></div></div></div><main class="card"><h2>Sign in</h2><p class="lead">Use your Authentik account to open the TheCloud console.</p>${destination}${alert}${signIn}<ul class="nx"><li>Browse and manage local AWS resources</li><li>Design state machines in Workflow Studio</li><li>Run executions and inspect every step</li></ul><p class="fine">TheCloud never sees your password. Sessions end after a period of inactivity.</p></main></div></section>
|
||||
<div class="mq mono" aria-hidden="true"><div id="mq"><span>Lambda</span><span>S3</span><span>SQS</span><span>SNS</span><span>DynamoDB</span><span>Step Functions</span><span>EventBridge</span><span>ECS</span><span>EC2</span><span>CloudWatch</span></div></div>
|
||||
<section class="wrap qs" id="quickstart"><div><span class="tag">quickstart</span><h2 class="serif">Same CLI. Different cloud.</h2><p>Point any AWS SDK or the AWS CLI at your TheCloud endpoint and your code runs unchanged, with nothing leaving your network.</p></div><div class="win mono"><header><span><i class="r"></i><i class="y"></i><i class="g"></i>quickstart.sh</span><button class="cp" id="cp" type="button">copy</button></header><pre><code id="code"><span class="c"># point the AWS CLI at TheCloud (sample values)</span>
|
||||
<span class="k">export</span> AWS_ENDPOINT_URL=<span class="s">http://localhost:4566</span>
|
||||
<span class="k">export</span> AWS_ACCESS_KEY_ID=<span class="s">test</span> AWS_SECRET_ACCESS_KEY=<span class="s">test</span>
|
||||
<span class="k">export</span> AWS_DEFAULT_REGION=<span class="s">us-east-1</span>
|
||||
|
||||
<span class="f">aws</span> s3 mb s3://demo-bucket
|
||||
<span class="f">aws</span> sqs create-queue <span class="o">--queue-name</span> fulfilment-queue
|
||||
<span class="f">aws</span> stepfunctions list-state-machines</code></pre></div></section>
|
||||
<footer class="mono"><div class="wrap"><span><span class="pr">$</span> TheCloud, powered by Floci</span><span>press <kbd>T</kbd> to switch theme</span></div></footer>
|
||||
<script nonce="${options.styleNonce}">(function(){var root=document.documentElement,btns=document.querySelectorAll('.tg button'),still=matchMedia('(prefers-reduced-motion: reduce)').matches;function setTheme(t,anim){if(anim){document.body.classList.add('fade');setTimeout(function(){document.body.classList.remove('fade')},600)}root.setAttribute('data-theme',t);try{localStorage.setItem('thecloud-theme',t)}catch(e){}btns.forEach(function(b){b.classList.toggle('on',b.dataset.t===t)})}var t0=matchMedia('(prefers-color-scheme: light)').matches?'light':'dark';try{var sv=localStorage.getItem('thecloud-theme');if(sv==='dark'||sv==='light'||sv==='cyber')t0=sv}catch(e){}setTheme(t0,false);btns.forEach(function(b){b.addEventListener('click',function(){setTheme(b.dataset.t,true)})});document.addEventListener('keydown',function(e){if((e.key==='t'||e.key==='T')&&!e.metaKey&&!e.ctrlKey&&!e.altKey&&!/INPUT|TEXTAREA/.test(e.target.tagName)){var o=['dark','light','cyber'];setTheme(o[(o.indexOf(root.getAttribute('data-theme'))+1)%3],true)}});var lines=['hello, engineer.','aws s3 mb s3://my-bucket','your own AWS, on your own hardware.','sfn start-execution --local','welcome to TheCloud.'],el=document.getElementById('tw'),g=0,c=0,del=false;if(still){el.textContent=lines[0]}else{(function tick(){var w=lines[g];if(del){c--;el.textContent=w.slice(0,c);if(c===0){del=false;g=(g+1)%lines.length;return setTimeout(tick,400)}return setTimeout(tick,35)}c++;el.textContent=w.slice(0,c);if(c===w.length){del=true;return setTimeout(tick,2200)}setTimeout(tick,75+Math.random()*50)})()}var bar=document.getElementById('bar');function prog(){var h=document.documentElement.scrollHeight-innerHeight;bar.style.width=(h>0?scrollY/h*100:0)+'%'}addEventListener('scroll',prog,{passive:true});prog();var hero=document.getElementById('top'),gl=document.getElementById('glow');hero.addEventListener('mousemove',function(e){var r=hero.getBoundingClientRect();gl.style.left=(e.clientX-r.left)+'px';gl.style.top=(e.clientY-r.top)+'px';gl.style.opacity='1'});hero.addEventListener('mouseleave',function(){gl.style.opacity='0'});var mq=document.getElementById('mq');mq.innerHTML+=mq.innerHTML;var cp=document.getElementById('cp');cp.addEventListener('click',function(){var txt=document.getElementById('code').innerText;function done(){cp.classList.add('ok');cp.textContent='copied';setTimeout(function(){cp.classList.remove('ok');cp.textContent='copy'},1800)}if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(txt).then(done,done)}else{done()}});var form=document.querySelector('[data-sso-form]');if(form){form.addEventListener('submit',function(){var go=form.querySelector('.btn'),label=document.getElementById('label');if(go.disabled)return;go.disabled=true;go.classList.add('is-loading');label.textContent='Redirecting to Authentik'})}addEventListener('pageshow',function(){var go=document.querySelector('[data-sso-form] .btn'),label=document.getElementById('label');if(go){go.disabled=false;go.classList.remove('is-loading')}if(label)label.textContent='Sign in with Authentik'})})();</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>'"]/g, (character) => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[character] ?? character))
|
||||
}
|
||||
@@ -1217,6 +1217,13 @@ export function createApiClient(
|
||||
return { url, init };
|
||||
});
|
||||
|
||||
client.addRequestInterceptor((url, init) => {
|
||||
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(init.method ?? 'GET')) return {url, init};
|
||||
const csrf = document.cookie.split('; ').find((item) => item.startsWith('floci_ui_csrf='))?.split('=')[1];
|
||||
if (csrf) (init.headers as Record<string, string>)['X-Floci-CSRF'] = decodeURIComponent(csrf);
|
||||
return {url, init};
|
||||
});
|
||||
|
||||
// Auth interceptor — attaches Bearer token if available
|
||||
if (getToken) {
|
||||
client.addRequestInterceptor(async (url, init) => {
|
||||
@@ -1237,12 +1244,13 @@ export function createApiClient(
|
||||
});
|
||||
}
|
||||
|
||||
// client.addResponseInterceptor((res) => {
|
||||
// if (res.status === 401) {
|
||||
// handleUnauthorizedApiResponse();
|
||||
// }
|
||||
// return res;
|
||||
// });
|
||||
client.addResponseInterceptor((res) => {
|
||||
if (res.status === 401 && !res.url.includes('/api/auth/')) {
|
||||
const returnTo = `${window.location.pathname}${window.location.search}`;
|
||||
window.location.assign(`/login?returnTo=${encodeURIComponent(returnTo)}`);
|
||||
}
|
||||
return res;
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function EcrImageBrowser({resource, runtimeReachable}: EcrImageBrowserPro
|
||||
{imagesQuery.isLoading && <div className="muted padded">Loading images</div>}
|
||||
{imagesQuery.isSuccess && images.length === 0 && <div className="empty compact"><h3>No images</h3><p>Push an image to this repository to see it here.</p></div>}
|
||||
{images.length > 0 && <div className="cosmos-items-table">
|
||||
<table>
|
||||
<table className="table ecr-images-table">
|
||||
<thead><tr><th>Tags</th><th>Digest</th><th>Pushed</th><th>Size</th><th/></tr></thead>
|
||||
<tbody>{images.map((image) => {
|
||||
const tags = Array.isArray(image.body.imageTags) ? image.body.imageTags.filter((tag): tag is string => typeof tag === 'string') : []
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {NavLink, Outlet, useLocation, useSearchParams} from 'react-router-dom'
|
||||
import {AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, Moon, Search, Sun} from 'lucide-react'
|
||||
import {AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun} from 'lucide-react'
|
||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import theCloud from '../../../../TheCloud.png'
|
||||
import {useTheme} from '@/lib/useTheme'
|
||||
@@ -220,6 +220,9 @@ export function Layout() {
|
||||
</button>
|
||||
<div id="topbar-status" className="topbar-status"/>
|
||||
<AccountSwitcher/>
|
||||
<button className="icon-btn" type="button" title="Sign out" aria-label="Sign out" onClick={() => void signOut()}>
|
||||
<LogOut size={14}/>
|
||||
</button>
|
||||
<div className={`connection ${isConnected ? 'connected' : 'disconnected'}`}>
|
||||
<span className={connectionDotClass(status)} aria-hidden="true"/>
|
||||
<span className="connection-state">{connectionLabel}</span>
|
||||
@@ -234,6 +237,12 @@ export function Layout() {
|
||||
)
|
||||
}
|
||||
|
||||
async function signOut(): Promise<void> {
|
||||
const csrf = document.cookie.split('; ').find((item) => item.startsWith('floci_ui_csrf='))?.split('=')[1]
|
||||
await fetch('/api/auth/logout', {method: 'POST', headers: csrf ? {'X-Floci-CSRF': decodeURIComponent(csrf)} : {}})
|
||||
window.location.assign('/login')
|
||||
}
|
||||
|
||||
/**
|
||||
* Topbar search bar.
|
||||
*
|
||||
|
||||
@@ -98,3 +98,23 @@ test('shows an object extension instead of a generic object type', async () => {
|
||||
|
||||
expect(await screen.findByText('JSON file')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('opens the object action with copy selected', async () => {
|
||||
apiMocks.listStorageObjects.mockResolvedValue({prefix: '', objects: [{
|
||||
key: 'orders/order.json', name: 'order.json', type: 'object', size: 10, lastModified: null, metadata: {},
|
||||
}]})
|
||||
apiMocks.listCloudResources.mockResolvedValue([bucket])
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<StorageObjectBrowser cloud="aws" resource={bucket} runtimeReachable capabilities={['copy']} onSelectObject={() => {}}/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: 'Copy or move order.json'}))
|
||||
|
||||
const copyTab = screen.getAllByRole('button', {name: 'Copy'}).find((button) => button.classList.contains('drawer-tab'))
|
||||
expect(copyTab).toHaveClass('active')
|
||||
})
|
||||
|
||||
@@ -524,7 +524,7 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
|
||||
</a>
|
||||
)}
|
||||
{copyCapability && (
|
||||
<button className="icon-btn" disabled={!canCopy} title={copyCapability.reason ?? `Move / Copy ${object.name}`} onClick={() => setCopyObject(object)}>
|
||||
<button className="icon-btn" disabled={!canCopy} title={copyCapability.reason ?? `Copy or move ${object.name}`} aria-label={`Copy or move ${object.name}`} onClick={() => setCopyObject(object)}>
|
||||
<Copy size={13}/>
|
||||
</button>
|
||||
)}
|
||||
@@ -606,7 +606,7 @@ function MoveOrCopyModal({
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const resourceLabel = cloud === 'azure' ? 'container' : 'bucket'
|
||||
const [mode, setMode] = useState<'move' | 'copy'>('move')
|
||||
const [mode, setMode] = useState<'move' | 'copy'>('copy')
|
||||
const [destKey, setDestKey] = useState(() => {
|
||||
const parts = srcObject.key.split('/')
|
||||
const filename = parts.pop() ?? srcObject.key
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
/* ── AWS dark palette ── */
|
||||
--bg: #0f141a;
|
||||
--surface: #161d26;
|
||||
--elevated: #1b232d;
|
||||
--raised: #232b37;
|
||||
--raised-hover: #333843;
|
||||
--border: #424650;
|
||||
--border-faint: rgba(101, 104, 113, 0.55);
|
||||
--border-lighter: rgba(101, 104, 113, 0.3);
|
||||
--border-hover: #75cfff;
|
||||
--active-nav-bg: #001129;
|
||||
/* ── TheCloud dark palette ── */
|
||||
--bg: #07111d;
|
||||
--surface: #0c1a2b;
|
||||
--elevated: #0d1c2f;
|
||||
--raised: #10243a;
|
||||
--raised-hover: #16304b;
|
||||
--border: rgba(232, 241, 250, 0.16);
|
||||
--border-faint: rgba(232, 241, 250, 0.1);
|
||||
--border-lighter: rgba(232, 241, 250, 0.06);
|
||||
--border-hover: #6cc4ff;
|
||||
--active-nav-bg: #0d2943;
|
||||
|
||||
--text: #c6c6cd;
|
||||
--text-bright: #f9f9fa;
|
||||
--text-2: #b4b4bb;
|
||||
--text-3: #a4a4ad;
|
||||
--text: #bdd0e3;
|
||||
--text-bright: #e8f1fa;
|
||||
--text-2: #c9d9e8;
|
||||
--text-3: #8aa3bc;
|
||||
--text-muted: var(--text-3);
|
||||
--text-nav: #c6c6cd;
|
||||
|
||||
--status-ok: #2bb534;
|
||||
--status-warn: #fbd332;
|
||||
--status-error: #ff7a7a;
|
||||
--status-ok: #5ce0b0;
|
||||
--status-warn: #ffb547;
|
||||
--status-error: #ff9a9a;
|
||||
|
||||
--accent: #42b4ff;
|
||||
--accent-tint: rgba(66, 180, 255, 0.16);
|
||||
--accent-tint-faint: rgba(66, 180, 255, 0.07);
|
||||
--accent-tint-border: rgba(66, 180, 255, 0.35);
|
||||
--accent: #6cc4ff;
|
||||
--accent-tint: rgba(108, 196, 255, 0.16);
|
||||
--accent-tint-faint: rgba(108, 196, 255, 0.07);
|
||||
--accent-tint-border: rgba(108, 196, 255, 0.35);
|
||||
|
||||
--link: #42b4ff;
|
||||
--link-tint: rgba(66, 180, 255, 0.08);
|
||||
--link-tint-hover: rgba(66, 180, 255, 0.14);
|
||||
--link-tint-border: rgba(66, 180, 255, 0.3);
|
||||
--link: #6cc4ff;
|
||||
--link-tint: rgba(108, 196, 255, 0.08);
|
||||
--link-tint-hover: rgba(108, 196, 255, 0.14);
|
||||
--link-tint-border: rgba(108, 196, 255, 0.3);
|
||||
|
||||
--hover: rgba(255, 255, 255, 0.04);
|
||||
--hover-md: rgba(255, 255, 255, 0.06);
|
||||
--hover-lg: rgba(255, 255, 255, 0.07);
|
||||
/* 3:1 vs raised so inputs/buttons meet non-text contrast. */
|
||||
--input-border: #656871;
|
||||
--input-bg: #161d26;
|
||||
--connection-bg: #131920;
|
||||
--kbd-border: #424650;
|
||||
--scrollbar-thumb: #656871;
|
||||
--service-card-bg: #161d26;
|
||||
--service-card-hover: #1b232d;
|
||||
--fn-card-hover: #232b37;
|
||||
--bulk-bar-bg: #001129;
|
||||
--bulk-bar-border: #42b4ff;
|
||||
--bulk-bar-text: #f9f9fa;
|
||||
--code-bg: #282c34;
|
||||
--input-border: rgba(232, 241, 250, 0.28);
|
||||
--input-bg: #0c1a2b;
|
||||
--connection-bg: #091624;
|
||||
--kbd-border: rgba(232, 241, 250, 0.2);
|
||||
--scrollbar-thumb: #48627c;
|
||||
--service-card-bg: #0c1a2b;
|
||||
--service-card-hover: #0d1c2f;
|
||||
--fn-card-hover: #10243a;
|
||||
--bulk-bar-bg: #0d2943;
|
||||
--bulk-bar-border: #6cc4ff;
|
||||
--bulk-bar-text: #e8f1fa;
|
||||
--code-bg: #050d17;
|
||||
|
||||
/* Workflow Studio: keep canvas surfaces readable independently of state accents. */
|
||||
--workflow-save-border: #4e628f;
|
||||
@@ -2300,6 +2300,14 @@ pre,
|
||||
background: var(--link-tint);
|
||||
}
|
||||
|
||||
.ecr-images-table { min-width: 680px; }
|
||||
.ecr-images-table th, .ecr-images-table td { padding: 13px 16px; vertical-align: middle; }
|
||||
.ecr-images-table th:nth-child(1) { width: 22%; }
|
||||
.ecr-images-table th:nth-child(2) { width: 27%; }
|
||||
.ecr-images-table th:nth-child(3) { width: 31%; }
|
||||
.ecr-images-table th:nth-child(4) { width: 14%; }
|
||||
.ecr-images-table th:last-child { width: 48px; }
|
||||
|
||||
.logs-query-panel {
|
||||
display: grid;
|
||||
margin-top: 12px;
|
||||
|
||||
Generated
+8
@@ -115,6 +115,9 @@ importers:
|
||||
hono:
|
||||
specifier: ^4.12.27
|
||||
version: 4.12.27
|
||||
jose:
|
||||
specifier: ^6.2.12
|
||||
version: 6.2.12
|
||||
kafkajs:
|
||||
specifier: ^2.2.4
|
||||
version: 2.2.4
|
||||
@@ -2009,6 +2012,9 @@ packages:
|
||||
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
||||
hasBin: true
|
||||
|
||||
jose@6.2.12:
|
||||
resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==}
|
||||
|
||||
js-md4@0.3.2:
|
||||
resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==}
|
||||
|
||||
@@ -5342,6 +5348,8 @@ snapshots:
|
||||
jiti@1.21.7:
|
||||
optional: true
|
||||
|
||||
jose@6.2.12: {}
|
||||
|
||||
js-md4@0.3.2: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="-0.03 59.9 512.03 392.1"><style>.st0{fill:#fd4b2d}</style><path d="M279.9 141h17.9v51.2h-17.9zm46.6-2.2h17.9v40h-17.9zM65.3 197.3c-24 0-46 13.2-57.4 34.3h30.4c13.5-11.6 33-15 47.1 0h32.2c-12.6-17.1-31.4-34.3-52.3-34.3" class="st0"/><path d="M108.7 262.4C66.8 350-6.6 275.3 38.3 231.5H7.9C-15.9 273 17 329 65.3 327.8c37.4 0 68.2-55.5 68.2-65.3 0-4.3-6-17.6-16-31H85.4c10.7 9.7 20 23.7 23.3 30.9m1.1-2.6" class="st0"/><path d="M512 140.3v231.3c0 44.3-36.1 80.4-80.4 80.4h-34.1v-78.8h-163V452h-34.1c-44.4 0-80.4-36.1-80.4-80.4v-72.8h258.4v-139H253.6V238H119.9v-97.6c0-3.1.2-6.2.5-9.2.4-3.7 1.1-7.3 2-10.8.3-1.1.6-2.3 1-3.4.1-.3.2-.6.3-.8.2-.6.4-1.1.5-1.7.2-.5.4-1.1.6-1.7s.5-1.2.7-1.8.5-1.2.8-1.8c2-4.7 4.4-9.3 7.3-13.6l.1-.1c.7-1.1 1.5-2.1 2.3-3.2.7-.9 1.3-1.7 2-2.6.8-.9 1.6-1.9 2.4-2.8s1.6-1.8 2.4-2.6l.1-.1c.4-.5.9-.9 1.4-1.4 3-2.9 6.2-5.6 9.6-8 .9-.7 1.9-1.3 2.8-1.9 1.1-.7 2.2-1.4 3.3-2 2.1-1.2 4.2-2.4 6.5-3.4.7-.3 1.4-.7 2.1-1 3.1-1.3 6.2-2.5 9.4-3.4 1.2-.4 2.5-.7 3.7-1 .6-.2 1.2-.3 1.8-.4 3.6-.8 7.2-1.3 10.9-1.6l1.6-.1h.8c1.2-.1 2.4-.1 3.7-.1h231.3c1.2 0 2.5 0 3.7.1h.8l1.6.1c3.7.3 7.3.8 10.9 1.6.6.1 1.2.3 1.8.4 1.3.3 2.5.6 3.7 1 3.2.9 6.3 2.1 9.4 3.4.7.3 1.4.6 2.1 1 2.2 1 4.4 2.2 6.5 3.4 1.1.7 2.2 1.3 3.3 2 1 .6 1.9 1.3 2.8 1.9 3.9 2.8 7.6 6 11 9.4.8.8 1.7 1.7 2.4 2.6.8.9 1.6 1.9 2.4 2.8.7.8 1.3 1.7 2 2.6.8 1.1 1.5 2.1 2.3 3.2l.1.1c2.9 4.3 5.3 8.8 7.3 13.6.2.6.5 1.2.8 1.8.2.6.5 1.2.7 1.8.2.5.4 1.1.6 1.7s.4 1.1.5 1.7c.1.3.2.6.3.8.3 1.1.7 2.3 1 3.4.9 3.6 1.6 7.2 2 10.8 0 3.1.2 6.1.2 9.2" class="st0"/><path d="M498.3 95.5H133.5c14.9-22.2 40-35.6 66.7-35.6h231.3c26.9 0 51.9 13.4 66.8 35.6m13.2 35.6H120.4c1.4-12.8 6-25 13.1-35.6h364.8c7.2 10.6 11.7 22.9 13.2 35.6m.5 9.2v26.4H378.3v-6.9H253.6v6.9H119.9v-26.4c0-3.1.2-6.2.5-9.2h391.1c.3 3.1.5 6.1.5 9.2M119.9 166.7h133.7v35.6H119.9zm258.4 0H512v35.6H378.3zm-258.4 35.6h133.7v35.6H119.9zm258.4 0H512v35.6H378.3z" class="st0"/></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
Reference in New Issue
Block a user