Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7751ebe9b3 |
No files matched your search
Generated
+6
-6
@@ -436,7 +436,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "da-core"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"base32",
|
||||
@@ -461,7 +461,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "da-discord"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"da-core",
|
||||
@@ -478,7 +478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "da-http-api"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -500,7 +500,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "da-render"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"image",
|
||||
"resvg",
|
||||
@@ -512,7 +512,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "da-webui"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
@@ -542,7 +542,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "discord-authenticator"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
edition = "2021"
|
||||
rust-version = "1.90"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
# Discord Authenticator
|
||||
<p align="center">
|
||||
<img src="docs/brand/icon-256.png" width="128" height="128" alt="Discord Authenticator" />
|
||||
</p>
|
||||
|
||||
A self-hosted TOTP authenticator whose codes appear in Discord as **live
|
||||
messages that rewrite themselves** as each code rolls.
|
||||
<h1 align="center">Discord Authenticator</h1>
|
||||
|
||||
<p align="center">
|
||||
A self-hosted TOTP authenticator whose codes appear in Discord as
|
||||
<strong>live messages that rewrite themselves</strong> as each code rolls.
|
||||
</p>
|
||||
|
||||
The web UI is what you'd expect from Google or Microsoft Authenticator: a
|
||||
searchable list of accounts, each with its six digits and a countdown ring.
|
||||
@@ -209,9 +215,17 @@ and `=`-padded secrets, and where the issuer in the path disagrees with the
|
||||
`?issuer=` parameter — which Microsoft does — it takes the query parameter
|
||||
rather than refusing the account.
|
||||
|
||||
**In the list**, click any code to copy it. `/` focuses the search box. The
|
||||
gear on a row renames the account or pauses its Discord push; pausing removes
|
||||
its tile from the channel.
|
||||
**In the list**, click any code to copy it. `/` focuses the search box.
|
||||
|
||||
The pencil on a row opens **Edit account**. The *Name* is what shows in the
|
||||
list and on the Discord tile; the *Account* field underneath (usually the
|
||||
username or email the code belongs to) is left alone unless you change it —
|
||||
it's editable, since providers sometimes put something unhelpful there. The
|
||||
same sheet pauses Discord push for that account, or removes it entirely.
|
||||
|
||||
A rename reaches Discord within a second or two. It doesn't wait for the code
|
||||
to roll: the push loop normally skips any account whose current code it has
|
||||
already sent, so renaming clears that marker to force one refresh.
|
||||
|
||||
---
|
||||
|
||||
@@ -271,7 +285,7 @@ JetBrains Mono. Light and dark both ship.
|
||||
|
||||
### Testing
|
||||
|
||||
124 tests, including:
|
||||
126 tests, including:
|
||||
|
||||
- The **RFC 6238 test vectors** for SHA-1, SHA-256 and SHA-512.
|
||||
- Real **QR round-trips** — clean, 1-pixel-per-module, oversized, rotated, JPEG,
|
||||
@@ -284,7 +298,8 @@ JetBrains Mono. Light and dark both ship.
|
||||
- The **live push cycle against a mock Discord**: that the first call carries
|
||||
`?wait=true`, that later passes PATCH the same message rather than posting
|
||||
again, that a deleted tile is reposted, that a dead webhook is surfaced
|
||||
instead of hammered, and that concurrent rollovers are paced apart.
|
||||
instead of hammered, that concurrent rollovers are paced apart, and that a
|
||||
rename refreshes the tile immediately without re-posting it or looping.
|
||||
|
||||
```bash
|
||||
cargo test --workspace
|
||||
|
||||
@@ -354,16 +354,20 @@ impl Vault {
|
||||
.iter_mut()
|
||||
.find(|a| a.id == id)
|
||||
.ok_or_else(|| Error::not_found("account"))?;
|
||||
|
||||
let mut renamed = false;
|
||||
if let Some(v) = issuer.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
if v.len() > 128 {
|
||||
return Err(Error::invalid("issuer must be 128 characters or fewer"));
|
||||
}
|
||||
renamed |= a.issuer != v;
|
||||
a.issuer = v.to_string();
|
||||
}
|
||||
if let Some(v) = label.map(str::trim) {
|
||||
if v.len() > 128 {
|
||||
return Err(Error::invalid("account must be 128 characters or fewer"));
|
||||
}
|
||||
renamed |= a.label != v;
|
||||
a.label = v.to_string();
|
||||
}
|
||||
if let Some(v) = push_enabled {
|
||||
@@ -374,6 +378,17 @@ impl Vault {
|
||||
}
|
||||
a.push_enabled = v;
|
||||
}
|
||||
|
||||
// A rename changes what the Discord tile should say, but not the
|
||||
// code — and the push loop skips any account whose code it has
|
||||
// already sent. Without this the new name would sit invisible
|
||||
// until the code next rolled, up to a full period later. Clearing
|
||||
// the last-sent code makes the very next tick treat the tile as
|
||||
// stale, so the rename shows up in about a second.
|
||||
if renamed {
|
||||
a.discord.last_code = None;
|
||||
}
|
||||
|
||||
AccountPublic::from(&*a)
|
||||
};
|
||||
self.persist();
|
||||
|
||||
@@ -385,6 +385,87 @@ async fn a_dead_webhook_is_surfaced_and_not_hammered() {
|
||||
assert!(vault.push_targets()[0].message_id.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn renaming_an_account_refreshes_its_tile_without_waiting_for_the_code() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (url, mock) = spawn_mock().await;
|
||||
// A 30-second period: the code will NOT roll during this test, so any
|
||||
// update we see must have been triggered by the rename itself.
|
||||
let (vault, settings) = fixture(dir.path(), &url, "embed", 30);
|
||||
let engine = PushEngine::new(vault.clone(), settings);
|
||||
|
||||
engine.sync_once().await;
|
||||
let message_id = vault.push_targets()[0].message_id.clone().unwrap();
|
||||
assert_eq!(mock.log.lock().unwrap().len(), 1);
|
||||
|
||||
// Nothing changed yet, so a second pass must stay silent.
|
||||
engine.sync_once().await;
|
||||
assert_eq!(
|
||||
mock.log.lock().unwrap().len(),
|
||||
1,
|
||||
"an idle pass must cost nothing"
|
||||
);
|
||||
|
||||
// Rename, then run one pass — no sleeping, no code rollover.
|
||||
let id = vault.list()[0].id.clone();
|
||||
vault
|
||||
.update(&id, Some("Cloudflare Work"), Some("[email protected]"), None)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
engine.sync_once().await,
|
||||
1,
|
||||
"the rename should trigger a push"
|
||||
);
|
||||
|
||||
{
|
||||
let log = mock.log.lock().unwrap();
|
||||
assert_eq!(log.len(), 2);
|
||||
assert_eq!(
|
||||
log[1].method, "PATCH",
|
||||
"the same tile is edited, not replaced"
|
||||
);
|
||||
assert!(log[1].path.ends_with(&format!("/messages/{message_id}")));
|
||||
|
||||
// The edited message must carry the new naming.
|
||||
let body = log[1].json();
|
||||
assert_eq!(body["embeds"][0]["title"], "Cloudflare Work");
|
||||
let desc = body["embeds"][0]["description"].as_str().unwrap();
|
||||
assert!(
|
||||
desc.contains("[email protected]"),
|
||||
"new label missing: {desc}"
|
||||
);
|
||||
assert!(
|
||||
!desc.contains("[email protected]"),
|
||||
"old label still present: {desc}"
|
||||
);
|
||||
}
|
||||
|
||||
// And it settles: a further pass sends nothing more.
|
||||
engine.sync_once().await;
|
||||
assert_eq!(
|
||||
mock.log.lock().unwrap().len(),
|
||||
2,
|
||||
"rename must not re-push forever"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn toggling_push_alone_does_not_rewrite_the_tile() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (url, mock) = spawn_mock().await;
|
||||
let (vault, settings) = fixture(dir.path(), &url, "embed", 30);
|
||||
let engine = PushEngine::new(vault.clone(), settings);
|
||||
|
||||
engine.sync_once().await;
|
||||
let id = vault.list()[0].id.clone();
|
||||
|
||||
// Re-affirming the same push state is not a rename and changes nothing
|
||||
// the tile displays, so it should not cost a request.
|
||||
vault.update(&id, None, None, Some(true)).unwrap();
|
||||
engine.sync_once().await;
|
||||
assert_eq!(mock.log.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pausing_an_account_removes_its_tile() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Rasterise the brand icon to PNG at the sizes the README, the Gitea repo
|
||||
//! avatar and the Unraid template need:
|
||||
//! `cargo run -p da-render --example brand -- docs/brand`
|
||||
fn main() {
|
||||
let dir = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| "docs/brand".into());
|
||||
let svg = std::fs::read_to_string(format!("{dir}/icon.svg")).expect("read icon.svg");
|
||||
for size in [512u32, 256, 128] {
|
||||
let png = da_render::rasterise_svg(&svg, size, size).expect("render");
|
||||
let path = format!("{dir}/icon-{size}.png");
|
||||
std::fs::write(&path, &png).expect("write");
|
||||
println!("wrote {path} ({} bytes)", png.len());
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,41 @@ pub fn tile_png(tile: &Tile<'_>) -> Result<Vec<u8>> {
|
||||
Ok(out.into_inner())
|
||||
}
|
||||
|
||||
/// Rasterise arbitrary SVG source to a PNG at a given size.
|
||||
///
|
||||
/// Exposed so the brand assets (README image, Gitea avatar, Unraid template
|
||||
/// icon) are generated from the same `icon.svg` the app ships, rather than
|
||||
/// being hand-exported and drifting out of sync with it.
|
||||
pub fn rasterise_svg(svg: &str, width: u32, height: u32) -> Result<Vec<u8>> {
|
||||
let tree = usvg::Tree::from_str(svg, options())?;
|
||||
let mut pixmap =
|
||||
tiny_skia::Pixmap::new(width, height).ok_or(RenderError::Pixmap(width, height))?;
|
||||
|
||||
// Scale the document to fill the requested pixmap.
|
||||
let size = tree.size();
|
||||
let scale = (width as f32 / size.width()).min(height as f32 / size.height());
|
||||
resvg::render(
|
||||
&tree,
|
||||
tiny_skia::Transform::from_scale(scale, scale),
|
||||
&mut pixmap.as_mut(),
|
||||
);
|
||||
|
||||
let mut rgba = Vec::with_capacity((width * height * 4) as usize);
|
||||
for px in pixmap.pixels() {
|
||||
let c = px.demultiply();
|
||||
rgba.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]);
|
||||
}
|
||||
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
image::codecs::png::PngEncoder::new(&mut out).write_image(
|
||||
&rgba,
|
||||
width,
|
||||
height,
|
||||
image::ExtendedColorType::Rgba8,
|
||||
)?;
|
||||
Ok(out.into_inner())
|
||||
}
|
||||
|
||||
fn rasterise(tile: &Tile<'_>) -> Result<Vec<u8>> {
|
||||
let svg = build_svg(tile);
|
||||
let tree = usvg::Tree::from_str(&svg, options())?;
|
||||
|
||||
@@ -160,15 +160,39 @@ a:hover { text-decoration: underline; }
|
||||
|
||||
.shell { display: flex; flex-direction: column; min-height: 100vh; }
|
||||
|
||||
/* Three columns: the search occupies the middle one, so it lands on the same
|
||||
centre line as the account tiles below no matter how wide the brand or the
|
||||
action cluster get. The outer columns are equal-width and allowed to
|
||||
shrink, which keeps the centring exact rather than approximate. */
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 30;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 420px) minmax(0, 1fr);
|
||||
align-items: center; gap: 12px;
|
||||
height: var(--topbar-h);
|
||||
padding: 0 16px;
|
||||
background: color-mix(in oklab, var(--page) 82%, transparent);
|
||||
backdrop-filter: saturate(1.6) blur(12px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.topbar .brand { justify-self: start; min-width: 0; }
|
||||
.topbar .brand strong { overflow: hidden; text-overflow: ellipsis; }
|
||||
.topbar .actions {
|
||||
justify-self: end;
|
||||
display: flex; align-items: center; gap: 2px;
|
||||
}
|
||||
|
||||
/* True centring needs room for all three: the brand wordmark (~210px), the
|
||||
420px search, the action cluster (~110px), plus gaps and padding — about
|
||||
800px in total. Below that the grid starts clipping "Discord Authenticator",
|
||||
so fall back to a plain row where the brand keeps its space and the search
|
||||
takes whatever is left. */
|
||||
@media (max-width: 820px) {
|
||||
.topbar { display: flex; }
|
||||
.topbar .brand { flex: 0 0 auto; }
|
||||
.topbar .search { flex: 1 1 auto; justify-self: auto; }
|
||||
.topbar .actions { margin-left: auto; flex: 0 0 auto; }
|
||||
}
|
||||
|
||||
.brand { display: flex; align-items: center; gap: 9px; flex: none; }
|
||||
.brand svg { width: 26px; height: 26px; display: block; }
|
||||
@@ -192,8 +216,9 @@ a:hover { text-decoration: underline; }
|
||||
|
||||
.search {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
justify-self: center;
|
||||
}
|
||||
.search svg {
|
||||
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
|
||||
@@ -483,7 +508,7 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: max(20px, calc(50vw - 344px));
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 25;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
|
||||
+38
-24
@@ -89,6 +89,7 @@ const ICON = {
|
||||
back: 'M19 12H5m7 7-7-7 7-7',
|
||||
key: ['M12.7 11.3a5 5 0 1 0-4 4L10 14h2v-2h2l-1.3-1.3Z'],
|
||||
send: ['M22 2 11 13', 'M22 2l-7 20-4-9-9-4 20-7z'],
|
||||
edit: ['M12 20h9', 'M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z'],
|
||||
};
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
@@ -349,27 +350,31 @@ function renderShell() {
|
||||
|
||||
const logoImg = h('img', { src: '/assets/logo.svg', alt: '', width: 26, height: 26 });
|
||||
|
||||
// Three columns so the search sits on the page's centre line, matching the
|
||||
// centred account tiles below it, regardless of how wide the brand or the
|
||||
// action cluster happen to be.
|
||||
const topbar = h('header.topbar', {}, [
|
||||
h('div.brand', { style: 'cursor:pointer', onclick: () => go('vault') }, [
|
||||
logoImg, h('strong', { text: 'Authenticator' }),
|
||||
logoImg, h('strong', { text: 'Discord Authenticator' }),
|
||||
]),
|
||||
searchWrap,
|
||||
h('div.spacer'),
|
||||
h('button.icon-btn.theme-toggle', {
|
||||
type: 'button', 'aria-label': 'Toggle theme', title: 'Toggle theme',
|
||||
onclick: toggleTheme,
|
||||
}, [
|
||||
icon(ICON.sun, { className: 't-sun', width: 2 }),
|
||||
icon(ICON.moon, { className: 't-moon', width: 2 }),
|
||||
h('div.actions', {}, [
|
||||
h('button.icon-btn.theme-toggle', {
|
||||
type: 'button', 'aria-label': 'Toggle theme', title: 'Toggle theme',
|
||||
onclick: toggleTheme,
|
||||
}, [
|
||||
icon(ICON.sun, { className: 't-sun', width: 2 }),
|
||||
icon(ICON.moon, { className: 't-moon', width: 2 }),
|
||||
]),
|
||||
h('button.icon-btn', {
|
||||
type: 'button', 'aria-label': 'Settings', title: 'Settings',
|
||||
onclick: () => go('settings'),
|
||||
}, [icon(ICON.settings, { width: 1.7 })]),
|
||||
h('button.icon-btn', {
|
||||
type: 'button', 'aria-label': 'Sign out', title: 'Sign out',
|
||||
onclick: async () => { await api('/api/logout', { method: 'POST' }); stopTicker(); boot(); },
|
||||
}, [icon(ICON.logout, { width: 2 })]),
|
||||
]),
|
||||
h('button.icon-btn', {
|
||||
type: 'button', 'aria-label': 'Settings', title: 'Settings',
|
||||
onclick: () => go('settings'),
|
||||
}, [icon(ICON.settings, { width: 1.7 })]),
|
||||
h('button.icon-btn', {
|
||||
type: 'button', 'aria-label': 'Sign out', title: 'Sign out',
|
||||
onclick: async () => { await api('/api/logout', { method: 'POST' }); stopTicker(); boot(); },
|
||||
}, [icon(ICON.logout, { width: 2 })]),
|
||||
]);
|
||||
|
||||
root().replaceChildren(h('div.shell', {}, [topbar, h('main.main', { id: 'view' })]));
|
||||
@@ -480,9 +485,9 @@ function acctRow(a) {
|
||||
codeBtn,
|
||||
ring,
|
||||
h('button.icon-btn', {
|
||||
type: 'button', 'aria-label': `Options for ${a.issuer}`, title: 'Options',
|
||||
type: 'button', 'aria-label': `Edit ${a.issuer}`, title: 'Edit',
|
||||
onclick: () => openAccountSheet(a),
|
||||
}, [icon(ICON.settings, { width: 1.7 })]),
|
||||
}, [icon(ICON.edit, { width: 1.9 })]),
|
||||
]),
|
||||
]);
|
||||
return row;
|
||||
@@ -1057,15 +1062,21 @@ function openAccountSheet(a) {
|
||||
const label = h('input.input', { value: a.label || '' });
|
||||
const push = h('input', { type: 'checkbox', checked: !!a.push_enabled });
|
||||
|
||||
const dlg = openModal(a.issuer || 'Account', [
|
||||
const dlg = openModal('Edit account', [
|
||||
a.push_error ? h('div.alert.err', {}, [icon(ICON.alert), h('span', { text: a.push_error })]) : null,
|
||||
h('div.field', {}, [h('label', { text: 'Issuer' }), issuer]),
|
||||
h('div.field', {}, [h('label', { text: 'Account' }), label]),
|
||||
h('div.field', {}, [
|
||||
h('label', { text: 'Name' }), issuer,
|
||||
h('div.hint', { text: 'What this account is called — shown in the list and on the Discord tile.' }),
|
||||
]),
|
||||
h('div.field', {}, [
|
||||
h('label', { text: 'Account' }), label,
|
||||
h('div.hint', { text: 'Usually the username or email the code belongs to. Left as-is unless you change it.' }),
|
||||
]),
|
||||
h('label.switch', { style: 'margin:16px 0 4px' }, [
|
||||
push, h('span.track'),
|
||||
h('span.switch-label', { text: 'Stream this code to Discord' }),
|
||||
]),
|
||||
h('p.hint', { text: 'When on, this account keeps one live message in your Discord channel, edited in place every time the code rolls.' }),
|
||||
h('p.hint', { text: 'When on, this account keeps one live message in your Discord channel, edited in place every time the code rolls. Renaming updates that message within a second or two.' }),
|
||||
], [
|
||||
h('button.btn.btn-danger', { onclick: async () => {
|
||||
if (!confirm(`Remove ${a.issuer}? Its secret is deleted permanently and its Discord message is removed.`)) return;
|
||||
@@ -1075,14 +1086,17 @@ function openAccountSheet(a) {
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
} }, [icon(ICON.trash, { width: 1.9 }), 'Remove']),
|
||||
h('div', { style: 'flex:1' }),
|
||||
h('button.btn', { onclick: dlg.close, text: 'Cancel' }),
|
||||
// Deferred: `dlg` is still in its temporal dead zone while these
|
||||
// arguments are being built, so `onclick: dlg.close` throws and the whole
|
||||
// sheet fails to open. Every other handler here is already a closure.
|
||||
h('button.btn', { onclick: () => dlg.close(), text: 'Cancel' }),
|
||||
h('button.btn.btn-primary', { onclick: async () => {
|
||||
try {
|
||||
await api('/api/accounts/' + encodeURIComponent(a.id), {
|
||||
method: 'PATCH',
|
||||
body: { issuer: issuer.value.trim(), label: label.value.trim(), push_enabled: push.checked },
|
||||
});
|
||||
dlg.close(); toast('Saved'); await reloadAll();
|
||||
dlg.close(); toast('Saved — the Discord tile updates shortly'); await reloadAll();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}, text: 'Save' }),
|
||||
]);
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<Category>Tools:Utilities Security:</Category>
|
||||
<WebUI>http://[IP]:[PORT:8710]/</WebUI>
|
||||
<TemplateURL/>
|
||||
<Icon>https://gitea.milesward.dev/mward4/Discord-Authenticator/raw/branch/main/crates/webui/src/logo.svg</Icon>
|
||||
<Icon>https://gitea.milesward.dev/mward4/Discord-Authenticator/raw/branch/main/docs/brand/icon-256.png</Icon>
|
||||
<ExtraParams/>
|
||||
<PostArgs/>
|
||||
<CPUset/>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512"
|
||||
role="img" aria-label="Discord Authenticator">
|
||||
<!-- App icon: the shield-and-arc mark on the product's own dark surface.
|
||||
Colours are literal rather than currentColor so this renders correctly
|
||||
as a favicon, a Gitea repo avatar and an Unraid template icon, none of
|
||||
which supply a CSS context. -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#232833"/>
|
||||
<stop offset="1" stop-color="#161a22"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="mark" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#6ea3ff"/>
|
||||
<stop offset="1" stop-color="#3d78e8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="512" height="512" rx="114" fill="url(#bg)"/>
|
||||
<rect x="1" y="1" width="510" height="510" rx="113"
|
||||
fill="none" stroke="#ffffff" stroke-opacity="0.09" stroke-width="2"/>
|
||||
|
||||
<g transform="translate(96 96) scale(10)">
|
||||
<path d="M16 2.6 27 6.3v9.1c0 6.6-4.4 12.2-11 14.1-6.6-1.9-11-7.5-11-14.1V6.3L16 2.6Z"
|
||||
fill="url(#mark)" fill-opacity=".18"/>
|
||||
<path d="M16 2.6 27 6.3v9.1c0 6.6-4.4 12.2-11 14.1-6.6-1.9-11-7.5-11-14.1V6.3L16 2.6Z"
|
||||
fill="none" stroke="url(#mark)" stroke-width="1.9" stroke-linejoin="round"/>
|
||||
<path d="M20.9 12.6a5.9 5.9 0 1 0 1 3.3"
|
||||
fill="none" stroke="url(#mark)" stroke-width="2.1" stroke-linecap="round"/>
|
||||
<circle cx="16" cy="15.9" r="1.85" fill="url(#mark)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
Reference in new issue
Block a user