[#115] Implement Binary File Attachments (#116)

This commit is contained in:
Knut Ahlers 2023-10-02 21:52:24 +02:00 committed by GitHub
parent a098395daf
commit c5124731f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 330 additions and 31 deletions

View File

@ -81,6 +81,22 @@ overlayFSPath: /path/to/ots-customization
# Languages not having a formal version will still display the normal
# translations in the respective language.
useFormalLanguage: false
# Define which file types are selectable by the user when uploading
# files to attach. This fuels the `accept` attribute of the file
# select and requires the same format. Pay attention this is not
# suited as a security measure as this is purely a frontend
# implementation and can be circumvented.
# https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept
acceptedFileTypes: ''
# Disable the file attachment functionality alltogether
disableFileAttachment: false
# Define how big all attachments might be in bytes. Leave it set to
# zero to use the internal limit of 64 MiB (which is there to ensure
# the encrypted object does not cause the frontend to break).
maxAttachmentSizeTotal: 0
```
To override the styling of the application have a look at the [`src/style.scss`](./src/style.scss) file how the theme of the application is built and present the compiled `app.css` in the `overlayFSPath`.

View File

@ -22,7 +22,7 @@ SECRET=${1:-}
pass=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 20 || true)
# Encrypt the secret
ciphertext=$(echo "${SECRET}" | openssl aes-256-cbc -base64 -pass "pass:${pass}" -iter 300000 -md sha512 2>/dev/null)
ciphertext=$(echo "${SECRET}" | openssl aes-256-cbc -base64 -A -pass "pass:${pass}" -iter 300000 -md sha512 2>/dev/null)
# Create a secret and extract the secret ID
id=$(

View File

@ -25,4 +25,4 @@ geturl="${host}/api/get/${id}"
# fetch secret and decrypt to STDOUT
curl -sSf "${geturl}" | jq -r ".secret" |
openssl aes-256-cbc -base64 -pass "pass:${pass}" -iter 300000 -md sha512 -d 2>/dev/null
openssl aes-256-cbc -base64 -A -pass "pass:${pass}" -iter 300000 -md sha512 -d

View File

@ -12,16 +12,22 @@ import (
type (
customize struct {
AppIcon string `json:"appIcon,omitempty" yaml:"appIcon"`
AppTitle string `json:"appTitle,omitempty" yaml:"appTitle"`
DisableAppTitle bool `json:"disableAppTitle,omitempty" yaml:"disableAppTitle"`
AppIcon string `json:"appIcon,omitempty" yaml:"appIcon"`
AppTitle string `json:"appTitle,omitempty" yaml:"appTitle"`
DisableAppTitle bool `json:"disableAppTitle,omitempty" yaml:"disableAppTitle"`
DisablePoweredBy bool `json:"disablePoweredBy,omitempty" yaml:"disablePoweredBy"`
DisableQRSupport bool `json:"disableQRSupport,omitempty" yaml:"disableQRSupport"`
DisableThemeSwitcher bool `json:"disableThemeSwitcher,omitempty" yaml:"disableThemeSwitcher"`
DisableExpiryOverride bool `json:"disableExpiryOverride,omitempty" yaml:"disableExpiryOverride"`
DisablePoweredBy bool `json:"disablePoweredBy,omitempty" yaml:"disablePoweredBy"`
DisableQRSupport bool `json:"disableQRSupport,omitempty" yaml:"disableQRSupport"`
DisableThemeSwitcher bool `json:"disableThemeSwitcher,omitempty" yaml:"disableThemeSwitcher"`
ExpiryChoices []int64 `json:"expiryChoices,omitempty" yaml:"expiryChoices"`
OverlayFSPath string `json:"-" yaml:"overlayFSPath"`
UseFormalLanguage bool `json:"-" yaml:"useFormalLanguage"`
AcceptedFileTypes string `json:"acceptedFileTypes" yaml:"acceptedFileTypes"`
DisableFileAttachment bool `json:"disableFileAttachment" yaml:"disableFileAttachment"`
MaxAttachmentSizeTotal int64 `json:"maxAttachmentSizeTotal" yaml:"maxAttachmentSizeTotal"`
OverlayFSPath string `json:"-" yaml:"overlayFSPath"`
UseFormalLanguage bool `json:"-" yaml:"useFormalLanguage"`
}
)

View File

@ -5,8 +5,10 @@ reference:
alert-secret-not-found: This is not the secret you are looking for… - If you expected the secret to be here it might be compromised as someone else might have opened the link already.
alert-something-went-wrong: Something went wrong. I'm very sorry about this…
btn-create-secret: Create the secret!
btn-create-secret-processing: Secret is being created…
btn-new-secret: New Secret
btn-reveal-secret: Show me the secret!
btn-reveal-secret-processing: Secret is being decrypted…
btn-show-explanation: How does this work?
expire-default: Default Expiry
expire-n-days: '{n} day | {n} days'
@ -23,9 +25,13 @@ reference:
- After the encrypted secret has been retrieved once, it is deleted from the server
label-expiry: 'Expire in:'
label-secret-data: 'Secret data:'
label-secret-files: 'Attach Files:'
text-attached-files: The sender attached files to the secret. Make sure you trust the sender as the files were not checked!
text-burn-hint: Please remember not to go to this URL yourself as that would destroy the secret. Just pass it to someone else!
text-burn-time: 'If not viewed before, this secret will automatically be deleted:'
text-hint-burned: <strong>Attention:</strong> You're only seeing this once. As soon as you reload the page the secret will be gone so maybe copy it now&hellip;
text-max-filesize: 'Maximum size: {maxSize}'
text-max-filesize-exceeded: 'The file(s) you chose are too big to attach: {curSize} / {maxSize}'
text-powered-by: Powered by
text-pre-reveal-hint: To reveal the secret click this button but be aware doing so will destroy the secret. You can only view it once!
text-pre-url: 'Your secret was created and stored using this URL:'
@ -77,8 +83,10 @@ translations:
alert-secret-not-found: Das ist nicht das Secret, was du suchst&hellip; - Falls du diesen Link noch nicht selbst geöffnet hast, könnte das Secret kompromittiert sein, da jemand anderes den Link geöffnet haben könnte.
alert-something-went-wrong: Irgendwas ging schief. Entschuldigung&hellip;
btn-create-secret: Secret erstellen!
btn-create-secret-processing: Secret wird erstellt…
btn-new-secret: Neues Secret
btn-reveal-secret: Zeig mir das Secret!
btn-reveal-secret-processing: Secret wird entschlüsselt…
btn-show-explanation: Wie funktioniert das?
expire-default: Server-Standard
expire-n-days: '{n} Tag | {n} Tage'
@ -95,9 +103,13 @@ translations:
- Wenn das verschlüsselte Secret das erste Mal abgerufen wurde, wird es automatisch vom Server gelöscht
label-expiry: 'Ablauf in:'
label-secret-data: 'Inhalt des Secrets:'
label-secret-files: 'Dateien Anhängen:'
text-attached-files: Der Absender hat Dateien an das Secret angehängt. Stell sicher, dass du dem Absender vertraust, da die Dateien nicht geprüft wurden!
text-burn-hint: Bitte rufe die URL nicht selbst auf, da das Secret dadurch zerstört würde. Gib sie einfach weiter!
text-burn-time: 'Wenn es vorher nicht eingesehen wurde, wird dieses Secret automatisch gelöscht:'
text-hint-burned: <strong>Achtung:</strong> Du kannst das nur einmal ansehen! Sobald du die Seite neu lädst, ist das Secret verschwunden, also besser direkt kopieren und sicher abspeichern&hellip;
text-max-filesize: 'Maximale Größe: {maxSize}'
text-max-filesize-exceeded: 'Die ausgewählten Dateien übersteigen die maximale Größe: {curSize} / {maxSize}'
text-powered-by: Läuft mit
text-pre-reveal-hint: Um das Secret anzuzeigen klicke diesen Button aber denk dran, dass das Secret nur einmal angezeigt und dabei gelöscht wird.
text-pre-url: 'Dein Secret wurde angelegt und unter folgender URL gespeichert:'
@ -118,6 +130,7 @@ translations:
- Sie geben die angezeigte URL, welche die ID und das Passwort des Secrets enthält, an den Empfänger
- 'Der Empfänger kann das Secret einmalig abrufen: Funktioniert das nicht, könnte jemand anderes es abgerufen haben!'
- Wenn das verschlüsselte Secret das erste Mal abgerufen wurde, wird es automatisch vom Server gelöscht
text-attached-files: Der Absender hat Dateien an das Secret angehängt. Stellen Sie sicher, dass Sie dem Absender vertrauen, da die Dateien nicht geprüft wurden!
text-burn-hint: Bitte rufen Sie die URL nicht selbst auf, da das Secret dadurch zerstört würde.
text-hint-burned: <strong>Achtung:</strong> Sie können das Secret nur einmal ansehen! Sobald Sie die Seite neu laden, kann das Secret nicht erneut abgerufen werden, also besser direkt kopieren und sicher abspeichern&hellip;
text-pre-reveal-hint: Klicken Sie auf diesen Button um das Secret anzuzeigen, bedenken Sie aber, dass das Secret nur einmal angezeigt und dabei gelöscht wird.

2
package-lock.json generated
View File

@ -6,6 +6,7 @@
"": {
"name": "ots",
"dependencies": {
"base64-js": "^1.5.1",
"bootstrap": "^5.3.2",
"qrcode": "^1.5.3",
"vue": "^2.7.14",
@ -1166,7 +1167,6 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [
{
"type": "github",

View File

@ -11,6 +11,7 @@
"name": "ots",
"private": true,
"dependencies": {
"base64-js": "^1.5.1",
"bootstrap": "^5.3.2",
"qrcode": "^1.5.3",
"vue": "^2.7.14",

View File

@ -29,7 +29,7 @@
class="row"
@submit.prevent="createSecret"
>
<div class="col-12 mb-3 order-0">
<div class="col-12 mb-3">
<label for="createSecretData">{{ $t('label-secret-data') }}</label>
<textarea
id="createSecretData"
@ -38,13 +38,43 @@
rows="5"
/>
</div>
<div
v-if="!$root.customize.disableFileAttachment"
class="col-12 mb-3"
>
<label for="createSecretFiles">{{ $t('label-secret-files') }}</label>
<input
id="createSecretFiles"
ref="createSecretFiles"
class="form-control"
type="file"
multiple
:accept="$root.customize.acceptedFileTypes"
@change="updateFileSize"
>
<div class="form-text">
{{ $t('text-max-filesize', { maxSize: bytesToHuman(maxFileSize) }) }}
</div>
<div
v-if="maxFileSizeExceeded"
class="alert alert-danger"
>
{{ $t('text-max-filesize-exceeded', { curSize: bytesToHuman(fileSize), maxSize: bytesToHuman(maxFileSize) }) }}
</div>
</div>
<div class="col-md-6 col-12 order-2 order-md-1">
<button
type="submit"
class="btn btn-success"
:disabled="secret.trim().length < 1"
:disabled="secret.trim().length < 1 || maxFileSizeExceeded || createRunning"
>
{{ $t('btn-create-secret') }}
<template v-if="!createRunning">
{{ $t('btn-create-secret') }}
</template>
<template v-else>
<i class="fa-solid fa-spinner fa-spin-pulse" />
{{ $t('btn-create-secret-processing') }}
</template>
</button>
</div>
<div
@ -80,6 +110,8 @@
/* global maxSecretExpire */
import appCrypto from '../crypto.js'
import { bytesToHuman } from '../helpers'
import OTSMeta from '../ots-meta'
const defaultExpiryChoices = [
90 * 86400, // 90 days
@ -94,6 +126,17 @@ const defaultExpiryChoices = [
5 * 60, // 5 minutes
]
/*
* We define an internal max file-size which cannot get exceeded even
* though the server might accept more: at around 70 MiB the base64
* encoding broke and nothing works anymore. This might be fixed by
* changing how the base64 implementation works (maybe use a WASM
* object?) or switching to a browser-native implementation in case
* that will appear somewhen in the future but for now we just "fix"
* the issue by disallowing bigger files.
*/
const internalMaxFileSize = 64 * 1024 * 1024 // 64 MiB
const passwordCharset = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
const passwordLength = 20
@ -122,6 +165,14 @@ export default {
return choices
},
maxFileSize() {
return this.$root.customize.maxAttachmentSizeTotal === 0 ? internalMaxFileSize : Math.min(internalMaxFileSize, this.$root.customize.maxAttachmentSizeTotal)
},
maxFileSizeExceeded() {
return this.fileSize > this.maxFileSize
},
},
created() {
@ -131,6 +182,8 @@ export default {
data() {
return {
canWrite: null,
createRunning: false,
fileSize: 0,
secret: '',
securePassword: null,
selectedExpiry: null,
@ -138,6 +191,8 @@ export default {
},
methods: {
bytesToHuman,
checkWriteAccess() {
fetch('api/isWritable', {
credentials: 'same-origin',
@ -157,14 +212,28 @@ export default {
// createSecret executes the secret creation after encrypting the secret
createSecret() {
if (this.secret.trim().length < 1) {
if (this.secret.trim().length < 1 || this.maxFileSizeExceeded) {
return false
}
// Encoding large files takes a while, prevent duplicate click on "create"
this.createRunning = true
this.securePassword = [...window.crypto.getRandomValues(new Uint8Array(passwordLength))]
.map(n => passwordCharset[n % passwordCharset.length])
.join('')
appCrypto.enc(this.secret, this.securePassword)
const meta = new OTSMeta()
meta.secret = this.secret
if (this.$refs.createSecretFiles) {
for (const f of [...this.$refs.createSecretFiles.files]) {
meta.files.push(f)
}
}
meta.serialize()
.then(secret => appCrypto.enc(secret, this.securePassword))
.then(secret => {
let reqURL = 'api/create'
if (this.selectedExpiry !== null) {
@ -205,6 +274,15 @@ export default {
return false
},
updateFileSize() {
let cumSize = 0
for (const f of [...this.$refs.createSecretFiles.files]) {
cumSize += f.size
}
this.fileSize = cumSize
},
},
name: 'AppCreate',

View File

@ -10,9 +10,16 @@
<p v-html="$t('text-pre-reveal-hint')" />
<button
class="btn btn-success"
:disabled="secretLoading"
@click="requestSecret"
>
{{ $t('btn-reveal-secret') }}
<template v-if="!secretLoading">
{{ $t('btn-reveal-secret') }}
</template>
<template v-else>
<i class="fa-solid fa-spinner fa-spin-pulse" />
{{ $t('btn-reveal-secret-processing') }}
</template>
</button>
</template>
<template v-else>
@ -41,6 +48,22 @@
</div>
</div>
<p v-html="$t('text-hint-burned')" />
<template v-if="files.length > 0">
<p v-html="$t('text-attached-files')" />
<ul>
<li
v-for="file in files"
:key="file.name"
class="font-monospace"
>
<a
:href="file.url"
:download="file.name"
>{{ file.name }}</a>
({{ bytesToHuman(file.size) }})
</li>
</ul>
</template>
</template>
</div>
</div>
@ -49,21 +72,28 @@
import appClipboardButton from './clipboard-button.vue'
import appCrypto from '../crypto.js'
import appQrButton from './qr-button.vue'
import { bytesToHuman } from '../helpers'
import OTSMeta from '../ots-meta'
export default {
components: { appClipboardButton, appQrButton },
data() {
return {
files: [],
popover: null,
secret: null,
secretContentBlobURL: null,
secretLoading: false,
}
},
methods: {
bytesToHuman,
// requestSecret requests the encrypted secret from the backend
requestSecret() {
this.secretLoading = true
window.history.replaceState({}, '', window.location.href.split('#')[0])
fetch(`api/get/${this.secretId}`)
.then(resp => {
@ -89,11 +119,19 @@ export default {
appCrypto.dec(secret, this.securePassword)
.then(secret => {
this.secret = secret
})
.catch(() => {
this.$emit('error', this.$t('alert-something-went-wrong'))
const meta = new OTSMeta(secret)
this.secret = meta.secret
meta.files.forEach(file => {
file.arrayBuffer()
.then(ab => {
const blobURL = window.URL.createObjectURL(new Blob([ab], { type: file.type }))
this.files.push({ name: file.name, size: ab.byteLength, url: blobURL })
})
})
this.secretLoading = false
})
.catch(() => this.$emit('error', this.$t('alert-something-went-wrong')))
})
})
.catch(() => {

View File

@ -1,15 +1,45 @@
import base64 from 'base64-js'
const opensslBanner = new Uint8Array(new TextEncoder('utf8').encode('Salted__'))
const pbkdf2Params = { hash: 'SHA-512', iterations: 300000, name: 'PBKDF2' }
/**
* @param {String} cipherText Encrypted data in base64 encoded form
* @param {String} passphrase Encryption passphrase used for key-derivation
* @returns String
*/
function dec(cipherText, passphrase) {
return decrypt(passphrase, cipherText)
}
/**
*
* @param {String} plainText Data to encrypt
* @param {String} passphrase Encryption passphrase used for key-derivation
* @returns String
*/
function enc(plainText, passphrase) {
return encrypt(passphrase, generateSalt(), plainText)
}
/**
* @param {String} passphrase Encryption passphrase used for key-derivation
* @param {String} encData Encrypted data in base64 encoded form
* @returns String
*/
function decrypt(passphrase, encData) {
const data = new Uint8Array(atob(encData).split('')
.map(c => c.charCodeAt(0)))
const data = base64.toByteArray(encData)
return deriveKey(passphrase, data.slice(8, 16))
.then(({ iv, key }) => window.crypto.subtle.decrypt({ iv, name: 'AES-CBC' }, key, data.slice(16)))
.then(data => new TextDecoder('utf8').decode(data))
}
/**
*
* @param {String} passphrase
* @param {Uint8Array} salt
* @returns Object
*/
function deriveKey(passphrase, salt) {
return window.crypto.subtle.importKey('raw', new TextEncoder('utf8').encode(passphrase), 'PBKDF2', false, ['deriveBits'])
.then(passwordKey => window.crypto.subtle.deriveBits({ ...pbkdf2Params, salt }, passwordKey, 384))
@ -17,19 +47,27 @@ function deriveKey(passphrase, salt) {
.then(aesKey => ({ iv: key.slice(32, 48), key: aesKey })))
}
/**
* @param {String} passphrase Encryption passphrase used for key-derivation
* @param {Uint8Array} salt Cryptographically random salt of 8 byte length
* @param {String} plainData Data to encrypt
* @returns String
*/
function encrypt(passphrase, salt, plainData) {
return deriveKey(passphrase, salt)
.then(({ iv, key }) => window.crypto.subtle.encrypt({ iv, name: 'AES-CBC' }, key, new TextEncoder('utf8').encode(plainData)))
.then(encData => new Uint8Array([...opensslBanner, ...salt, ...new Uint8Array(encData)]))
.then(data => btoa(String.fromCharCode.apply(null, data)))
.then(data => base64.fromByteArray(data))
}
/**
* Generates a cryptographically secure random salt
*
* @returns Uint8Array
*/
function generateSalt() {
const salt = new Uint8Array(8) // Salt MUST consist of 8 byte
return window.crypto.getRandomValues(salt)
}
export default {
dec: (cipherText, passphrase) => decrypt(passphrase, cipherText),
enc: (plainText, passphrase) => encrypt(passphrase, generateSalt(), plainText),
}
export default { dec, enc }

21
src/helpers.js Normal file
View File

@ -0,0 +1,21 @@
/**
* Converts number of bytes into human format (524288 -> "512.0 KiB")
* @param {Number} bytes Byte amount to convert into human readable format
* @returns String
*/
function bytesToHuman(bytes) {
for (const t of [
{ thresh: 1024 * 1024, unit: 'MiB' },
{ thresh: 1024, unit: 'KiB' },
]) {
if (bytes > t.thresh) {
return `${(bytes / t.thresh).toFixed(1)} ${t.unit}`
}
}
return `${bytes} B`
}
export {
bytesToHuman,
}

View File

@ -6,11 +6,11 @@ export default {
'ca': JSON.parse('{"alert-secret-not-found":"Aquest no és el secret que busques\u0026hellip; - Si esperaves que el secret estiguera ací, és possible que s\'haja vist compromés, ja que una altra persona podria haver obert l\'enllaç en comptes de tu.","alert-something-went-wrong":"Alguna cosa ha eixit malament. Ens sap molt greu\u0026hellip;","btn-create-secret":"Crea el secret!","btn-new-secret":"Nou secret","btn-reveal-secret":"Mostra\'m el secret!","btn-show-explanation":"Com funciona?","expire-default":"Caducitat predeterminada","expire-n-days":"{n} dia | {n} dies","expire-n-hours":"{n} hora | {n} hores","expire-n-minutes":"{n} minut | {n} minuts","expire-n-seconds":"{n} segon | {n} segons","items-explanation":["Introduïx un secret en el formulari que hi ha en aquesta pàgina","El teu navegador xifra el secret utilitzant una contrasenya generada","Únicament s\'envia al servidor el secret xifrat (mai s\'envien ni el secret sense xifrar ni la contrasenya!)","El servidor emmagatzema el secret xifrat durant un temps limitat","Envia al destinatari l\'enllaç mostrat, que conté l\'identificador del secret i la contrasenya de desxifrat","El destinatari pot veure el secret una sola vegada: si no pot, el secret podria haver sigut vist per una altra persona!","Quan s\'ha obtingut per primera i única vegada el secret xifrat, s\'elimina del servidor"],"label-expiry":"Caduca en:","label-secret-data":"Informació secreta:","text-burn-hint":"Per favor, recorda no accedir a aquest enllaç tu mateix, ja que això destruiria el secret. Només has de passar-li\'l a una altra persona!","text-burn-time":"Si no es mostra abans, aquest secret s\'eliminarà automàticament:","text-hint-burned":"\u003cstrong\u003eAtenció:\u003c/strong\u003e Només veuràs això una vegada. Quan recarregues la pàgina, el secret desapareixerà, així que copia\'l ja\u0026hellip;","text-powered-by":"Funciona amb","text-pre-reveal-hint":"Per a mostrar el secret prem aquest botó, però tingues en compte que en fer-ho es destruirà. Només pots veure\'l una vegada!","text-pre-url":"El teu secret ha sigut creat i emmagatzemat en el següent enllaç:","text-secret-create-disabled":"La creació de nous secrets està desactivada en aquesta instància.","title-explanation":"Així és com funciona\u0026hellip;","title-new-secret":"Crea un nou secret","title-reading-secret":"Obtenint el teu secret\u0026hellip;","title-secret-create-disabled":"S\'ha desactivat la creació de secrets...","title-secret-created":"Secret creat!"}'),
'de': switchFormal(
JSON.parse('{"alert-secret-not-found":"Dieses Secret existiert nicht. - Falls Sie diesen Link noch nicht selbst geöffnet haben, könnte der Inhalt kompromittiert sein, da jemand anderes den Link geöffnet haben könnte.","alert-something-went-wrong":"Irgendwas ging schief. Entschuldigung\u0026hellip;","btn-create-secret":"Secret erstellen!","btn-new-secret":"Neues Secret","btn-reveal-secret":"Secret anzeigen","btn-show-explanation":"Wie funktioniert das?","expire-default":"Server-Standard","expire-n-days":"{n} Tag | {n} Tage","expire-n-hours":"{n} Stunde | {n} Stunden","expire-n-minutes":"{n} Minute | {n} Minuten","expire-n-seconds":"{n} Sekunde | {n} Sekunden","items-explanation":["Sie geben ein Secret auf dieser Seite ein","Ihr Browser verschlüsselt das Secret mit einem generierten Passwort","Nur das verschlüsselte Secret wird an den Server geschickt (das Passwort oder das Secret im Klartext werden niemals übertragen!)","Der Server speichert das verschlüsselte Secret für eine Weile","Sie geben die angezeigte URL, welche die ID und das Passwort des Secrets enthält, an den Empfänger","Der Empfänger kann das Secret einmalig abrufen: Funktioniert das nicht, könnte jemand anderes es abgerufen haben!","Wenn das verschlüsselte Secret das erste Mal abgerufen wurde, wird es automatisch vom Server gelöscht"],"label-expiry":"Ablauf in:","label-secret-data":"Inhalt des Secrets:","text-burn-hint":"Bitte rufen Sie die URL nicht selbst auf, da das Secret dadurch zerstört würde.","text-burn-time":"Wenn es vorher nicht eingesehen wurde, wird dieses Secret automatisch gelöscht:","text-hint-burned":"\u003cstrong\u003eAchtung:\u003c/strong\u003e Sie können das Secret nur einmal ansehen! Sobald Sie die Seite neu laden, kann das Secret nicht erneut abgerufen werden, also besser direkt kopieren und sicher abspeichern\u0026hellip;","text-powered-by":"Läuft mit","text-pre-reveal-hint":"Klicken Sie auf diesen Button um das Secret anzuzeigen, bedenken Sie aber, dass das Secret nur einmal angezeigt und dabei gelöscht wird.","text-pre-url":"Das Secret wurde angelegt und unter folgender URL gespeichert:","text-secret-create-disabled":"Auf dieser Instanz wurde das Erstellen neuer Secrets deaktiviert.","title-explanation":"So funktioniert es\u0026hellip;","title-new-secret":"Ein neues Secret erstellen","title-reading-secret":"Secret auslesen\u0026hellip;","title-secret-create-disabled":"Erstellen von Secrets deaktiviert…","title-secret-created":"Secret erstellt!"}'),
JSON.parse('{"alert-secret-not-found":"Das ist nicht das Secret, was du suchst\u0026hellip; - Falls du diesen Link noch nicht selbst geöffnet hast, könnte das Secret kompromittiert sein, da jemand anderes den Link geöffnet haben könnte.","alert-something-went-wrong":"Irgendwas ging schief. Entschuldigung\u0026hellip;","btn-create-secret":"Secret erstellen!","btn-new-secret":"Neues Secret","btn-reveal-secret":"Zeig mir das Secret!","btn-show-explanation":"Wie funktioniert das?","expire-default":"Server-Standard","expire-n-days":"{n} Tag | {n} Tage","expire-n-hours":"{n} Stunde | {n} Stunden","expire-n-minutes":"{n} Minute | {n} Minuten","expire-n-seconds":"{n} Sekunde | {n} Sekunden","items-explanation":["Du gibst ein Secret auf dieser Seite ein","Dein Browser verschlüsselt das Secret mit einem generierten Passwort","Nur das verschlüsselte Secret wird an den Server geschickt (das Passwort oder das Secret im Klartext werden niemals übertragen!)","Der Server speichert das verschlüsselte Secret für eine Weile","Du gibst die angezeigte URL, welche die ID und das Passwort des Secrets enthält, an den Empfänger","Der Empfänger kann das Secret einmalig abrufen: Funktioniert das nicht, könnte jemand anderes es abgerufen haben!","Wenn das verschlüsselte Secret das erste Mal abgerufen wurde, wird es automatisch vom Server gelöscht"],"label-expiry":"Ablauf in:","label-secret-data":"Inhalt des Secrets:","text-burn-hint":"Bitte rufe die URL nicht selbst auf, da das Secret dadurch zerstört würde. Gib sie einfach weiter!","text-burn-time":"Wenn es vorher nicht eingesehen wurde, wird dieses Secret automatisch gelöscht:","text-hint-burned":"\u003cstrong\u003eAchtung:\u003c/strong\u003e Du kannst das nur einmal ansehen! Sobald du die Seite neu lädst, ist das Secret verschwunden, also besser direkt kopieren und sicher abspeichern\u0026hellip;","text-powered-by":"Läuft mit","text-pre-reveal-hint":"Um das Secret anzuzeigen klicke diesen Button aber denk dran, dass das Secret nur einmal angezeigt und dabei gelöscht wird.","text-pre-url":"Dein Secret wurde angelegt und unter folgender URL gespeichert:","text-secret-create-disabled":"Auf dieser Instanz wurde das Erstellen neuer Secrets deaktiviert.","title-explanation":"So funktioniert es\u0026hellip;","title-new-secret":"Erstelle ein neues Secret","title-reading-secret":"Secret auslesen\u0026hellip;","title-secret-create-disabled":"Erstellen von Secrets deaktiviert…","title-secret-created":"Secret erstellt!"}'),
JSON.parse('{"alert-secret-not-found":"Dieses Secret existiert nicht. - Falls Sie diesen Link noch nicht selbst geöffnet haben, könnte der Inhalt kompromittiert sein, da jemand anderes den Link geöffnet haben könnte.","alert-something-went-wrong":"Irgendwas ging schief. Entschuldigung\u0026hellip;","btn-create-secret":"Secret erstellen!","btn-create-secret-processing":"Secret wird erstellt…","btn-new-secret":"Neues Secret","btn-reveal-secret":"Secret anzeigen","btn-reveal-secret-processing":"Secret wird entschlüsselt…","btn-show-explanation":"Wie funktioniert das?","expire-default":"Server-Standard","expire-n-days":"{n} Tag | {n} Tage","expire-n-hours":"{n} Stunde | {n} Stunden","expire-n-minutes":"{n} Minute | {n} Minuten","expire-n-seconds":"{n} Sekunde | {n} Sekunden","items-explanation":["Sie geben ein Secret auf dieser Seite ein","Ihr Browser verschlüsselt das Secret mit einem generierten Passwort","Nur das verschlüsselte Secret wird an den Server geschickt (das Passwort oder das Secret im Klartext werden niemals übertragen!)","Der Server speichert das verschlüsselte Secret für eine Weile","Sie geben die angezeigte URL, welche die ID und das Passwort des Secrets enthält, an den Empfänger","Der Empfänger kann das Secret einmalig abrufen: Funktioniert das nicht, könnte jemand anderes es abgerufen haben!","Wenn das verschlüsselte Secret das erste Mal abgerufen wurde, wird es automatisch vom Server gelöscht"],"label-expiry":"Ablauf in:","label-secret-data":"Inhalt des Secrets:","label-secret-files":"Dateien Anhängen:","text-attached-files":"Der Absender hat Dateien an das Secret angehängt. Stellen Sie sicher, dass Sie dem Absender vertrauen, da die Dateien nicht geprüft wurden!","text-burn-hint":"Bitte rufen Sie die URL nicht selbst auf, da das Secret dadurch zerstört würde.","text-burn-time":"Wenn es vorher nicht eingesehen wurde, wird dieses Secret automatisch gelöscht:","text-hint-burned":"\u003cstrong\u003eAchtung:\u003c/strong\u003e Sie können das Secret nur einmal ansehen! Sobald Sie die Seite neu laden, kann das Secret nicht erneut abgerufen werden, also besser direkt kopieren und sicher abspeichern\u0026hellip;","text-max-filesize":"Maximale Größe: {maxSize}","text-max-filesize-exceeded":"Die ausgewählten Dateien übersteigen die maximale Größe: {curSize} / {maxSize}","text-powered-by":"Läuft mit","text-pre-reveal-hint":"Klicken Sie auf diesen Button um das Secret anzuzeigen, bedenken Sie aber, dass das Secret nur einmal angezeigt und dabei gelöscht wird.","text-pre-url":"Das Secret wurde angelegt und unter folgender URL gespeichert:","text-secret-create-disabled":"Auf dieser Instanz wurde das Erstellen neuer Secrets deaktiviert.","title-explanation":"So funktioniert es\u0026hellip;","title-new-secret":"Ein neues Secret erstellen","title-reading-secret":"Secret auslesen\u0026hellip;","title-secret-create-disabled":"Erstellen von Secrets deaktiviert…","title-secret-created":"Secret erstellt!"}'),
JSON.parse('{"alert-secret-not-found":"Das ist nicht das Secret, was du suchst\u0026hellip; - Falls du diesen Link noch nicht selbst geöffnet hast, könnte das Secret kompromittiert sein, da jemand anderes den Link geöffnet haben könnte.","alert-something-went-wrong":"Irgendwas ging schief. Entschuldigung\u0026hellip;","btn-create-secret":"Secret erstellen!","btn-create-secret-processing":"Secret wird erstellt…","btn-new-secret":"Neues Secret","btn-reveal-secret":"Zeig mir das Secret!","btn-reveal-secret-processing":"Secret wird entschlüsselt…","btn-show-explanation":"Wie funktioniert das?","expire-default":"Server-Standard","expire-n-days":"{n} Tag | {n} Tage","expire-n-hours":"{n} Stunde | {n} Stunden","expire-n-minutes":"{n} Minute | {n} Minuten","expire-n-seconds":"{n} Sekunde | {n} Sekunden","items-explanation":["Du gibst ein Secret auf dieser Seite ein","Dein Browser verschlüsselt das Secret mit einem generierten Passwort","Nur das verschlüsselte Secret wird an den Server geschickt (das Passwort oder das Secret im Klartext werden niemals übertragen!)","Der Server speichert das verschlüsselte Secret für eine Weile","Du gibst die angezeigte URL, welche die ID und das Passwort des Secrets enthält, an den Empfänger","Der Empfänger kann das Secret einmalig abrufen: Funktioniert das nicht, könnte jemand anderes es abgerufen haben!","Wenn das verschlüsselte Secret das erste Mal abgerufen wurde, wird es automatisch vom Server gelöscht"],"label-expiry":"Ablauf in:","label-secret-data":"Inhalt des Secrets:","label-secret-files":"Dateien Anhängen:","text-attached-files":"Der Absender hat Dateien an das Secret angehängt. Stell sicher, dass du dem Absender vertraust, da die Dateien nicht geprüft wurden!","text-burn-hint":"Bitte rufe die URL nicht selbst auf, da das Secret dadurch zerstört würde. Gib sie einfach weiter!","text-burn-time":"Wenn es vorher nicht eingesehen wurde, wird dieses Secret automatisch gelöscht:","text-hint-burned":"\u003cstrong\u003eAchtung:\u003c/strong\u003e Du kannst das nur einmal ansehen! Sobald du die Seite neu lädst, ist das Secret verschwunden, also besser direkt kopieren und sicher abspeichern\u0026hellip;","text-max-filesize":"Maximale Größe: {maxSize}","text-max-filesize-exceeded":"Die ausgewählten Dateien übersteigen die maximale Größe: {curSize} / {maxSize}","text-powered-by":"Läuft mit","text-pre-reveal-hint":"Um das Secret anzuzeigen klicke diesen Button aber denk dran, dass das Secret nur einmal angezeigt und dabei gelöscht wird.","text-pre-url":"Dein Secret wurde angelegt und unter folgender URL gespeichert:","text-secret-create-disabled":"Auf dieser Instanz wurde das Erstellen neuer Secrets deaktiviert.","title-explanation":"So funktioniert es\u0026hellip;","title-new-secret":"Erstelle ein neues Secret","title-reading-secret":"Secret auslesen\u0026hellip;","title-secret-create-disabled":"Erstellen von Secrets deaktiviert…","title-secret-created":"Secret erstellt!"}'),
),
'en': JSON.parse('{"alert-secret-not-found":"This is not the secret you are looking for\u0026hellip; - If you expected the secret to be here it might be compromised as someone else might have opened the link already.","alert-something-went-wrong":"Something went wrong. I\'m very sorry about this\u0026hellip;","btn-create-secret":"Create the secret!","btn-new-secret":"New Secret","btn-reveal-secret":"Show me the secret!","btn-show-explanation":"How does this work?","expire-default":"Default Expiry","expire-n-days":"{n} day | {n} days","expire-n-hours":"{n} hour | {n} hours","expire-n-minutes":"{n} minute | {n} minutes","expire-n-seconds":"{n} second | {n} seconds","items-explanation":["You enter a secret into the field on this page","Your browser encrypts the secret using a generated password","Only the encrypted secret is sent to the server (neither the plain secret nor the password are ever sent!)","The server stores the encrypted secret for a certain time","You pass the displayed URL containing the ID and the decryption password to the recipient","The recipient can view the secret exactly once: If they can\'t, the secret might have been viewed by someone else!","After the encrypted secret has been retrieved once, it is deleted from the server"],"label-expiry":"Expire in:","label-secret-data":"Secret data:","text-burn-hint":"Please remember not to go to this URL yourself as that would destroy the secret. Just pass it to someone else!","text-burn-time":"If not viewed before, this secret will automatically be deleted:","text-hint-burned":"\u003cstrong\u003eAttention:\u003c/strong\u003e You\'re only seeing this once. As soon as you reload the page the secret will be gone so maybe copy it now\u0026hellip;","text-powered-by":"Powered by","text-pre-reveal-hint":"To reveal the secret click this button but be aware doing so will destroy the secret. You can only view it once!","text-pre-url":"Your secret was created and stored using this URL:","text-secret-create-disabled":"The creation of new secrets is disabled in this instance.","title-explanation":"This is how it works\u0026hellip;","title-new-secret":"Create a new secret","title-reading-secret":"Reading your secret\u0026hellip;","title-secret-create-disabled":"Secret creation disabled…","title-secret-created":"Secret created!"}'),
'en': JSON.parse('{"alert-secret-not-found":"This is not the secret you are looking for\u0026hellip; - If you expected the secret to be here it might be compromised as someone else might have opened the link already.","alert-something-went-wrong":"Something went wrong. I\'m very sorry about this\u0026hellip;","btn-create-secret":"Create the secret!","btn-create-secret-processing":"Secret is being created…","btn-new-secret":"New Secret","btn-reveal-secret":"Show me the secret!","btn-reveal-secret-processing":"Secret is being decrypted…","btn-show-explanation":"How does this work?","expire-default":"Default Expiry","expire-n-days":"{n} day | {n} days","expire-n-hours":"{n} hour | {n} hours","expire-n-minutes":"{n} minute | {n} minutes","expire-n-seconds":"{n} second | {n} seconds","items-explanation":["You enter a secret into the field on this page","Your browser encrypts the secret using a generated password","Only the encrypted secret is sent to the server (neither the plain secret nor the password are ever sent!)","The server stores the encrypted secret for a certain time","You pass the displayed URL containing the ID and the decryption password to the recipient","The recipient can view the secret exactly once: If they can\'t, the secret might have been viewed by someone else!","After the encrypted secret has been retrieved once, it is deleted from the server"],"label-expiry":"Expire in:","label-secret-data":"Secret data:","label-secret-files":"Attach Files:","text-attached-files":"The sender attached files to the secret. Make sure you trust the sender as the files were not checked!","text-burn-hint":"Please remember not to go to this URL yourself as that would destroy the secret. Just pass it to someone else!","text-burn-time":"If not viewed before, this secret will automatically be deleted:","text-hint-burned":"\u003cstrong\u003eAttention:\u003c/strong\u003e You\'re only seeing this once. As soon as you reload the page the secret will be gone so maybe copy it now\u0026hellip;","text-max-filesize":"Maximum size: {maxSize}","text-max-filesize-exceeded":"The file(s) you chose are too big to attach: {curSize} / {maxSize}","text-powered-by":"Powered by","text-pre-reveal-hint":"To reveal the secret click this button but be aware doing so will destroy the secret. You can only view it once!","text-pre-url":"Your secret was created and stored using this URL:","text-secret-create-disabled":"The creation of new secrets is disabled in this instance.","title-explanation":"This is how it works\u0026hellip;","title-new-secret":"Create a new secret","title-reading-secret":"Reading your secret\u0026hellip;","title-secret-create-disabled":"Secret creation disabled…","title-secret-created":"Secret created!"}'),
'es': JSON.parse('{"alert-secret-not-found":"Este no es el secreto que buscas\u0026hellip; - Si esperabas que el secreto estuviera aquí, es posible que se haya visto comprometido, ya que otra persona podría haber abierto el enlace en tu lugar.","alert-something-went-wrong":"Algo ha salido mal. Lo sentimos mucho\u0026hellip;","btn-create-secret":"¡Crea el secreto!","btn-new-secret":"Nuevo secreto","btn-reveal-secret":"¡Muéstrame el secreto!","btn-show-explanation":"¿Cómo funciona?","expire-default":"Caducidad predeterminada","expire-n-days":"{n} día | {n} días","expire-n-hours":"{n} hora | {n} horas","expire-n-minutes":"{n} minuto | {n} minutos","expire-n-seconds":"{n} segundo | {n} segundos","items-explanation":["Introduce un secreto en el formulario que hay en esta página","Tu navegador cifra el secreto utilizando una contraseña generada","Únicamente se envía al servidor el secreto cifrado (¡nunca se envían ni el secreto sin cifrar ni la contraseña!)","El servidor almacena el secreto cifrado durante un tiempo limitado","Envía al destinatario el enlace mostrado, que contiene el identificador del secreto y la contraseña de descifrado","El destinatario puede ver el secreto una sola vez: si no puede, ¡el secreto podría haber sido visto por otra persona!","Cuando se ha obtenido por primera y única vez el secreto cifrado, se elimina del servidor"],"label-expiry":"Caduca en:","label-secret-data":"Información secreta:","text-burn-hint":"Por favor, recuerda no acceder a este enlace tú mismo, ya que esto destruiría el secreto. ¡Solo tienes que pasárselo a otra persona!","text-burn-time":"Si no se muestra antes, este secreto se eliminará automáticamente:","text-hint-burned":"\u003cstrong\u003eAtención:\u003c/strong\u003e Solo verás esto una vez. En cuanto recargues la página, el secreto desaparecerá, así que cópialo ya\u0026hellip;","text-powered-by":"Funciona con","text-pre-reveal-hint":"Para mostrar el secreto pulsa este botón, pero ten en cuenta que al hacerlo se destruirá. ¡Solo puedes verlo una vez!","text-pre-url":"Tu secreto ha sido creado y almacenado en el siguiente enlace:","text-secret-create-disabled":"La creación de nuevos secretos está desactivada en esta instancia.","title-explanation":"Así es como funciona\u0026hellip;","title-new-secret":"Crea un nuevo secreto","title-reading-secret":"Obteniendo tu secreto\u0026hellip;","title-secret-create-disabled":"Creación de secretos desactivada...","title-secret-created":"¡Secreto creado!"}'),

88
src/ots-meta.js Normal file
View File

@ -0,0 +1,88 @@
import base64 from 'base64-js'
/**
* OTSMeta defines the structure of (de-)serializing stored payload for secrets
*/
class OTSMeta {
/** @type File[] */
#files = []
/** @type String */
#secret = ''
/** @type Number */
#version = 1.0
/**
* @param {String | null} jsonString JSON string representation of OTSMeta created by serialize
*/
constructor(jsonString = null) {
if (jsonString === null) {
return
}
if (!jsonString.startsWith('OTSMeta')) {
// Looks like we got a plain string, we assume that to be a secret only
this.#secret = jsonString
return
}
const data = JSON.parse(jsonString.replace(/^OTSMeta/, ''))
this.#secret = data.secret
this.#version = data.v
for (const f of data.attachments || []) {
const content = base64.toByteArray(f.data)
this.#files.push(new File([content], f.name, { type: f.type }))
}
}
get files() {
return this.#files
}
get secret() {
return this.#secret
}
set secret(secret) {
this.#secret = secret
}
/**
* @returns {Promise<string>}
*/
serialize() {
const output = {
secret: this.#secret,
v: this.#version,
}
if (this.#files.length === 0) {
/*
* We got no attachments, therefore we do a simple fallback to
* the old "just the secret"-format
*/
return new Promise(resolve => {
resolve(this.#secret)
})
}
const encodes = []
output.attachments = []
for (const f of this.#files) {
encodes.push(f.arrayBuffer()
.then(ab => {
const data = base64.fromByteArray(new Uint8Array(ab))
output.attachments.push({ data, name: f.name, type: f.type })
}))
}
return Promise.all(encodes)
.then(() => `OTSMeta${JSON.stringify(output)}`)
}
}
export default OTSMeta