Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#115] Implement Binary File Attachments #116

Merged
merged 14 commits into from
Oct 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion cli_create.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=$(
Expand Down
2 changes: 1 addition & 1 deletion cli_get.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 14 additions & 8 deletions customize.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
)

Expand Down
13 changes: 13 additions & 0 deletions i18n.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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:'
Expand Down Expand Up @@ -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'
Expand All @@ -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:'
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
88 changes: 83 additions & 5 deletions src/components/create.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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() {
Expand All @@ -131,13 +182,17 @@ export default {
data() {
return {
canWrite: null,
createRunning: false,
fileSize: 0,
secret: '',
securePassword: null,
selectedExpiry: null,
}
},

methods: {
bytesToHuman,

checkWriteAccess() {
fetch('api/isWritable', {
credentials: 'same-origin',
Expand All @@ -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) {
Expand Down Expand Up @@ -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',
Expand Down
Loading