Secure send: the pages a person actually touches
This commit is contained in:
23
plugins/tools/frontend/api.js
Normal file
23
plugins/tools/frontend/api.js
Normal file
@@ -0,0 +1,23 @@
|
||||
// Tech Tools API client.
|
||||
//
|
||||
// Only secure send needs one - every other tool runs entirely in the browser
|
||||
// and talks to nothing.
|
||||
import api from '@/api'
|
||||
|
||||
export const toolsApi = {
|
||||
createSecret(payload) {
|
||||
return api.post('/tools/secrets', payload)
|
||||
},
|
||||
|
||||
// Is the link still live? Spends nothing, so it is safe for the read page to
|
||||
// ask before anyone clicks.
|
||||
peekSecret(token) {
|
||||
return api.get(`/tools/secrets/${token}`)
|
||||
},
|
||||
|
||||
// POST, not GET, and that is deliberate: chat and mail clients prefetch GET
|
||||
// urls to build previews, and a preview must not consume a one-view secret.
|
||||
redeemSecret(token) {
|
||||
return api.post(`/tools/secrets/${token}`)
|
||||
},
|
||||
}
|
||||
@@ -23,4 +23,26 @@ export default [
|
||||
component: () => import('./views/CodeGenerator.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'tools' }
|
||||
},
|
||||
{
|
||||
path: 'tools/secure-send',
|
||||
name: 'tools-secure-send',
|
||||
component: () => import('./views/SecureSendCreate.vue'),
|
||||
// Creating a secret needs a login AND tools.secrets. The route guard covers
|
||||
// the login; the server enforces the permission, and the page surfaces its
|
||||
// refusal rather than pretending the form worked.
|
||||
meta: { requiresAuth: true, plugin: 'tools' }
|
||||
},
|
||||
]
|
||||
|
||||
// PUBLIC and OUTSIDE AppLayout. The recipient of a secret usually has no account
|
||||
// here, so this page carries no navigation and asks for no login - the
|
||||
// unguessable token in the path, and the key in the fragment, are what let them
|
||||
// read it. Short path because the whole link gets pasted into a chat message.
|
||||
export const toplevel = [
|
||||
{
|
||||
path: '/s/:token',
|
||||
name: 'secure-send-read',
|
||||
component: () => import('./views/SecureSendRead.vue'),
|
||||
meta: { plugin: 'tools' }
|
||||
},
|
||||
]
|
||||
|
||||
136
plugins/tools/frontend/securesend.js
Normal file
136
plugins/tools/frontend/securesend.js
Normal file
@@ -0,0 +1,136 @@
|
||||
// Client-side crypto for secure send.
|
||||
//
|
||||
// THE KEY NEVER LEAVES THIS FILE'S PROCESS. It is generated here, put in the
|
||||
// URL fragment, and read back out of the fragment on the other side. Browsers
|
||||
// do not transmit fragments, so it reaches the recipient's browser and nowhere
|
||||
// else - not the server, not its logs, not a proxy in between. That is what
|
||||
// makes the stored ciphertext useless to anyone who reads the database.
|
||||
//
|
||||
// AES-GCM because it authenticates as well as encrypts: a tampered ciphertext
|
||||
// fails to decrypt rather than producing plausible rubbish.
|
||||
//
|
||||
// Web Crypto only, no dependency. It is available on every browser the fleet
|
||||
// runs and requires a secure context, which is the right constraint anyway -
|
||||
// the page and its scripts still cross the network even though the key does not.
|
||||
|
||||
const ALGORITHM = 'AES-GCM'
|
||||
const KEY_BITS = 256
|
||||
const IV_BYTES = 12
|
||||
|
||||
function toBase64(bytes) {
|
||||
let binary = ''
|
||||
for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte)
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
function fromBase64(text) {
|
||||
const binary = atob(text)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
|
||||
return bytes
|
||||
}
|
||||
|
||||
// base64url for the fragment: '+' and '/' are legal in a fragment but survive
|
||||
// copy-paste and link rewriting far less reliably than the url-safe alphabet.
|
||||
function toBase64Url(bytes) {
|
||||
return toBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
function fromBase64Url(text) {
|
||||
const padded = text.replace(/-/g, '+').replace(/_/g, '/')
|
||||
return fromBase64(padded + '='.repeat((4 - (padded.length % 4)) % 4))
|
||||
}
|
||||
|
||||
export function cryptoAvailable() {
|
||||
return typeof crypto !== 'undefined' && !!crypto.subtle
|
||||
}
|
||||
|
||||
/** Encrypt text. Returns the payload for the server AND the key for the link. */
|
||||
export async function sealSecret(plaintext) {
|
||||
const key = await crypto.subtle.generateKey(
|
||||
{ name: ALGORITHM, length: KEY_BITS }, true, ['encrypt', 'decrypt'])
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES))
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: ALGORITHM, iv }, key, new TextEncoder().encode(plaintext))
|
||||
const raw = await crypto.subtle.exportKey('raw', key)
|
||||
return {
|
||||
ciphertext: toBase64(ciphertext),
|
||||
iv: toBase64(iv),
|
||||
// Goes in the fragment. Never sent anywhere.
|
||||
fragmentKey: toBase64Url(raw),
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrypt what the server handed back, using the key from the fragment. */
|
||||
export async function openSecret({ ciphertext, iv, fragmentKey }) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw', fromBase64Url(fragmentKey),
|
||||
{ name: ALGORITHM, length: KEY_BITS }, false, ['decrypt'])
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: ALGORITHM, iv: fromBase64(iv) }, key, fromBase64(ciphertext))
|
||||
return new TextDecoder().decode(plaintext)
|
||||
}
|
||||
|
||||
// -- Keeping this from becoming a document transfer ---------------------------
|
||||
//
|
||||
// The server can only bound SIZE, because it cannot read what it holds. These
|
||||
// run here, where the plaintext is still visible, and are advisory: they guide
|
||||
// somebody who is about to do the wrong thing without realising. A determined
|
||||
// person can POST directly, which is why the size limit on the server is the
|
||||
// control that actually enforces.
|
||||
|
||||
// Matches MAX_CIPHERTEXT server-side, minus room for base64 expansion and the
|
||||
// GCM tag, so the browser refuses before a round trip rather than after one.
|
||||
export const MAX_PLAINTEXT = 2800
|
||||
|
||||
const MAX_LINES = 20
|
||||
|
||||
// A pasted file, recognised by how it starts. Cheap, high signal.
|
||||
const FILE_SIGNATURES = [
|
||||
['%PDF', 'a PDF'],
|
||||
['PK\x03\x04', 'a zip or Office file'],
|
||||
['{\\rtf', 'an RTF document'],
|
||||
['\x89PNG', 'a PNG image'],
|
||||
// JPEG magic bytes as escapes rather than literals: the naming gate bans
|
||||
// non-ASCII source, and these are bytes rather than text anyway.
|
||||
['\xff\xd8\xff', 'a JPEG image'],
|
||||
['data:', 'a data URI'],
|
||||
]
|
||||
|
||||
/** Why this text should not be sent, or null when it is fine. */
|
||||
export function refuseReason(text) {
|
||||
const value = text || ''
|
||||
if (!value.trim()) return 'Nothing to send yet.'
|
||||
|
||||
if (value.length > MAX_PLAINTEXT) {
|
||||
return `That is ${value.length} characters. This is for passwords and short `
|
||||
+ `text, not documents - the limit is ${MAX_PLAINTEXT}.`
|
||||
}
|
||||
|
||||
for (const [signature, description] of FILE_SIGNATURES) {
|
||||
if (value.startsWith(signature)) {
|
||||
return `That looks like ${description} pasted as text. Secure send is for `
|
||||
+ 'credentials, not file transfer.'
|
||||
}
|
||||
}
|
||||
|
||||
const lines = value.split('\n').length
|
||||
if (lines > MAX_LINES) {
|
||||
return `That is ${lines} lines. A password is one - this looks like a `
|
||||
+ 'document, and secure send is not a file transfer.'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a paste that carries FILES rather than text.
|
||||
*
|
||||
* Removes the easiest wrong path - dragging a PDF in, or Ctrl+V on a copied
|
||||
* file - without anyone having to read a rule about it.
|
||||
*/
|
||||
export function pasteCarriesFiles(event) {
|
||||
const items = event?.clipboardData?.items
|
||||
if (!items) return false
|
||||
return Array.from(items).some(item => item.kind === 'file')
|
||||
}
|
||||
@@ -24,11 +24,21 @@ export const TOOLS = [
|
||||
standalone: true,
|
||||
keywords: ['qr', 'barcode', 'code128', 'label', 'zebra', 'sticker', 'csv', 'print'],
|
||||
},
|
||||
{
|
||||
id: 'secure-send',
|
||||
name: 'Secure Send',
|
||||
description: 'Hand somebody a password by a link that works once and then destroys it.',
|
||||
category: 'security',
|
||||
route: '/tools/secure-send',
|
||||
keywords: ['password', 'secret', 'credential', 'share', 'link', 'expire',
|
||||
'one time', 'burn', 'pusher'],
|
||||
},
|
||||
]
|
||||
|
||||
export const CATEGORY_LABELS = {
|
||||
labels: 'Labels & Printing',
|
||||
convert: 'Conversion',
|
||||
security: 'Secrets & Access',
|
||||
network: 'Network',
|
||||
}
|
||||
|
||||
|
||||
190
plugins/tools/frontend/views/SecureSendCreate.vue
Normal file
190
plugins/tools/frontend/views/SecureSendCreate.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Secure Send</h1>
|
||||
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
|
||||
</div>
|
||||
|
||||
<div class="card intro">
|
||||
<p>
|
||||
Hand somebody a password without leaving it in a chat window. The text is
|
||||
encrypted in this browser and the key travels in the link, never to the
|
||||
server - so nobody here can read what you send, including an administrator.
|
||||
</p>
|
||||
<p class="muted">
|
||||
The link works once, then the secret is destroyed. For passwords and short
|
||||
text only, not documents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!supported" class="card control-note">
|
||||
This browser cannot encrypt in page (Web Crypto is unavailable, which
|
||||
usually means the site is not being served over HTTPS). Secure send needs
|
||||
it, because the whole point is that the server never sees your text.
|
||||
</div>
|
||||
|
||||
<template v-else-if="!link">
|
||||
<div class="card">
|
||||
<label class="field">
|
||||
<span class="field-label">Secret</span>
|
||||
<textarea
|
||||
v-model="plaintext"
|
||||
class="form-control"
|
||||
rows="4"
|
||||
placeholder="Password, connection string, short note..."
|
||||
@paste="onPaste"
|
||||
></textarea>
|
||||
</label>
|
||||
<p class="counter" :class="{ over: plaintext.length > MAX_PLAINTEXT }">
|
||||
{{ plaintext.length }} / {{ MAX_PLAINTEXT }} characters
|
||||
</p>
|
||||
<p v-if="refusal" class="control-note">{{ refusal }}</p>
|
||||
|
||||
<div class="options">
|
||||
<label class="field">
|
||||
<span class="field-label">Expires after</span>
|
||||
<select v-model.number="expiryhours" class="form-control">
|
||||
<option :value="1">1 hour</option>
|
||||
<option :value="8">8 hours</option>
|
||||
<option :value="24">1 day</option>
|
||||
<option :value="72">3 days</option>
|
||||
<option :value="168">7 days</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Can be opened</span>
|
||||
<select v-model.number="views" class="form-control">
|
||||
<option :value="1">Once</option>
|
||||
<option :value="2">Twice</option>
|
||||
<option :value="3">3 times</option>
|
||||
<option :value="5">5 times</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" :disabled="busy || !!refusal" @click="createLink">
|
||||
{{ busy ? 'Encrypting...' : 'Create link' }}
|
||||
</button>
|
||||
<p v-if="error" class="control-note">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<h3>Your link</h3>
|
||||
<p class="muted">
|
||||
Send this to the person who needs it. It opens
|
||||
{{ views === 1 ? 'once' : views + ' times' }} and expires {{ expiryLabel }}.
|
||||
</p>
|
||||
<div class="link-row">
|
||||
<input ref="linkInput" class="form-control mono" :value="link" readonly @focus="selectAll" />
|
||||
<button class="btn btn-primary" @click="copyLink">{{ copied ? 'Copied' : 'Copy' }}</button>
|
||||
</div>
|
||||
<p class="control-hint">
|
||||
This is the only time the link is shown - it is not stored anywhere you can
|
||||
look it up, and the key in it exists nowhere else.
|
||||
</p>
|
||||
<button class="btn btn-secondary" @click="reset">Send another</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { toolsApi } from '../api'
|
||||
import {
|
||||
sealSecret, refuseReason, pasteCarriesFiles, cryptoAvailable, MAX_PLAINTEXT,
|
||||
} from '../securesend'
|
||||
|
||||
const supported = cryptoAvailable()
|
||||
|
||||
const plaintext = ref('')
|
||||
const expiryhours = ref(24)
|
||||
const views = ref(1)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const link = ref('')
|
||||
const copied = ref(false)
|
||||
const linkInput = ref(null)
|
||||
|
||||
// Only nag once there is something to nag about: an empty box is not a mistake.
|
||||
const refusal = computed(() => (plaintext.value ? refuseReason(plaintext.value) : null))
|
||||
|
||||
const expiryLabel = computed(() => {
|
||||
const hours = expiryhours.value
|
||||
if (hours < 24) return `in ${hours} hour${hours === 1 ? '' : 's'}`
|
||||
const days = hours / 24
|
||||
return `in ${days} day${days === 1 ? '' : 's'}`
|
||||
})
|
||||
|
||||
function onPaste(event) {
|
||||
// A pasted FILE is the easiest wrong path into a tool that is not a file
|
||||
// transfer. Refuse it here rather than letting its bytes become "text".
|
||||
if (pasteCarriesFiles(event)) {
|
||||
event.preventDefault()
|
||||
error.value = 'Secure send takes text, not files. Paste the password itself.'
|
||||
}
|
||||
}
|
||||
|
||||
async function createLink() {
|
||||
error.value = ''
|
||||
busy.value = true
|
||||
try {
|
||||
const sealed = await sealSecret(plaintext.value)
|
||||
const response = await toolsApi.createSecret({
|
||||
ciphertext: sealed.ciphertext,
|
||||
iv: sealed.iv,
|
||||
expiryhours: expiryhours.value,
|
||||
views: views.value,
|
||||
})
|
||||
const token = response.data.data.token
|
||||
// The key rides in the FRAGMENT. Browsers never send it, so it reaches the
|
||||
// recipient and nowhere else.
|
||||
link.value = `${window.location.origin}/s/${token}#${sealed.fragmentKey}`
|
||||
// Drop the plaintext as soon as it is sealed: no reason to keep it in memory
|
||||
// behind the link that replaced it.
|
||||
plaintext.value = ''
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.data?.error?.message || 'Could not create the link.'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link.value)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch (err) {
|
||||
// Clipboard access is refused in some hardened configurations. Select the
|
||||
// text instead so Ctrl+C still works rather than leaving a dead button.
|
||||
selectAll()
|
||||
}
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
linkInput.value?.select()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
link.value = ''
|
||||
error.value = ''
|
||||
copied.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.intro p { margin: 0 0 0.5rem; }
|
||||
.muted { color: var(--text-light); font-size: 0.9rem; }
|
||||
.field { display: block; margin-bottom: 1rem; }
|
||||
.field-label { display: block; font-size: 0.8125rem; color: var(--text-light); margin-bottom: 4px; }
|
||||
.options { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||
.options .field { flex: 1 1 12rem; }
|
||||
.counter { font-size: 0.8125rem; color: var(--text-light); margin: 0 0 0.5rem; }
|
||||
.counter.over { color: var(--warning); font-weight: 600; }
|
||||
.control-note { color: var(--warning); font-size: 0.875rem; margin: 8px 0; }
|
||||
.control-hint { color: var(--text-light); font-size: 0.8125rem; margin: 8px 0 12px; }
|
||||
.link-row { display: flex; gap: 8px; align-items: center; margin: 0.5rem 0; }
|
||||
.link-row .form-control { flex: 1; }
|
||||
.mono { font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 0.875rem; }
|
||||
</style>
|
||||
154
plugins/tools/frontend/views/SecureSendRead.vue
Normal file
154
plugins/tools/frontend/views/SecureSendRead.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="reveal-page">
|
||||
<div class="reveal-card card">
|
||||
<h1>A secret was shared with you</h1>
|
||||
|
||||
<!-- CHECKING, not revealing. The page asks whether the link is still live
|
||||
before anyone clicks, so it can say what will happen rather than
|
||||
spending the only view to find out. -->
|
||||
<p v-if="state === 'checking'" class="muted">Checking the link...</p>
|
||||
|
||||
<template v-else-if="state === 'ready'">
|
||||
<p>
|
||||
Opening this destroys it. Make sure you can save or use the secret now -
|
||||
it cannot be shown twice, and nobody can recover it for you.
|
||||
</p>
|
||||
<!-- An explicit click, never automatic. Mail and chat scanners fetch
|
||||
links to check them; a page that revealed on load would burn the
|
||||
secret before its recipient ever saw it. -->
|
||||
<button class="btn btn-primary" :disabled="busy" @click="reveal">
|
||||
{{ busy ? 'Opening...' : 'Show me the secret' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="state === 'revealed'">
|
||||
<p class="muted">Here it is. It is gone from the server now.</p>
|
||||
<pre class="secret">{{ plaintext }}</pre>
|
||||
<button class="btn btn-primary" @click="copySecret">
|
||||
{{ copied ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="state === 'nokey'">
|
||||
<p class="problem">This link is missing its key.</p>
|
||||
<p class="muted">
|
||||
The part after the # is what decrypts the secret, and it is not here.
|
||||
Some chat and mail systems trim it when they rewrite links. Ask the
|
||||
sender to share the link again - by a route that keeps it whole, or
|
||||
pasted as plain text - and to create a new secret if this one has since
|
||||
expired.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="state === 'undecryptable'">
|
||||
<p class="problem">The secret could not be decrypted.</p>
|
||||
<p class="muted">
|
||||
The link opened, but the key in it does not match. That usually means
|
||||
the link was altered in transit. The secret has now been used up, so
|
||||
ask the sender for a new one.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="problem">This link is not valid.</p>
|
||||
<p class="muted">
|
||||
It may have been opened already, or it may have expired. Secrets are
|
||||
destroyed once read, so a link only works the number of times the
|
||||
sender chose. Ask them for a new one.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { toolsApi } from '../api'
|
||||
import { openSecret, cryptoAvailable } from '../securesend'
|
||||
|
||||
const route = useRoute()
|
||||
const state = ref('checking')
|
||||
const plaintext = ref('')
|
||||
const busy = ref(false)
|
||||
const copied = ref(false)
|
||||
|
||||
const token = route.params.token
|
||||
// The key lives in the fragment, which the router does not parse and the server
|
||||
// never receives. Read it off the raw location.
|
||||
const fragmentKey = (window.location.hash || '').replace(/^#/, '')
|
||||
|
||||
onMounted(async () => {
|
||||
if (!cryptoAvailable()) {
|
||||
state.value = 'undecryptable'
|
||||
return
|
||||
}
|
||||
if (!fragmentKey) {
|
||||
state.value = 'nokey'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await toolsApi.peekSecret(token)
|
||||
state.value = response.data.data.available ? 'ready' : 'gone'
|
||||
} catch (err) {
|
||||
state.value = 'gone'
|
||||
}
|
||||
})
|
||||
|
||||
async function reveal() {
|
||||
busy.value = true
|
||||
try {
|
||||
const response = await toolsApi.redeemSecret(token)
|
||||
const { ciphertext, iv } = response.data.data
|
||||
plaintext.value = await openSecret({ ciphertext, iv, fragmentKey })
|
||||
state.value = 'revealed'
|
||||
} catch (err) {
|
||||
// A failed DECRYPT is different from a missing secret, and the difference
|
||||
// matters to the person reading: one means the link was mangled, the other
|
||||
// means it was already used. The secret is spent either way.
|
||||
state.value = err.response ? 'gone' : 'undecryptable'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copySecret() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(plaintext.value)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch (err) {
|
||||
copied.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Standalone: this page is opened by somebody who may have no account here, so
|
||||
it carries no navigation and nothing to click into. */
|
||||
.reveal-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.reveal-card { max-width: 32rem; width: 100%; padding: 2rem; }
|
||||
.reveal-card h1 { font-size: 1.25rem; margin: 0 0 1rem; }
|
||||
.muted { color: var(--text-light); font-size: 0.9rem; }
|
||||
.problem { color: var(--warning); font-weight: 600; margin: 0 0 0.5rem; }
|
||||
|
||||
.secret {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.95rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user