mirror of
https://github.com/Luzifer/ots.git
synced 2025-08-04 20:24:19 -04:00
Port to Vue3 and TypeScript
Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
parent
b447417d0f
commit
e572d2f545
33 changed files with 2297 additions and 5930 deletions
151
.eslintrc.js
151
.eslintrc.js
|
@ -1,151 +0,0 @@
|
|||
/*
|
||||
* Hack to automatically load globally installed eslint modules
|
||||
* on Archlinux systems placed in /usr/lib/node_modules
|
||||
*
|
||||
* Source: https://github.com/eslint/eslint/issues/11914#issuecomment-569108633
|
||||
*/
|
||||
|
||||
const Module = require('module')
|
||||
|
||||
const hacks = [
|
||||
'babel-eslint',
|
||||
'eslint-plugin-vue',
|
||||
]
|
||||
|
||||
const ModuleFindPath = Module._findPath
|
||||
Module._findPath = (request, paths, isMain) => {
|
||||
const r = ModuleFindPath(request, paths, isMain)
|
||||
if (!r && hacks.includes(request)) {
|
||||
return require.resolve(`/usr/lib/node_modules/${request}`)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
/*
|
||||
* ESLint configuration derived as differences from eslint:recommended
|
||||
* with changes I found useful to ensure code quality and equal formatting
|
||||
* https://eslint.org/docs/user-guide/configuring
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
},
|
||||
|
||||
extends: [
|
||||
'plugin:vue/recommended',
|
||||
'eslint:recommended', // https://eslint.org/docs/rules/
|
||||
],
|
||||
|
||||
globals: {
|
||||
process: true,
|
||||
},
|
||||
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
parser: '@babel/eslint-parser',
|
||||
requireConfigFile: false,
|
||||
},
|
||||
|
||||
plugins: [
|
||||
// required to lint *.vue files
|
||||
'vue',
|
||||
],
|
||||
|
||||
reportUnusedDisableDirectives: true,
|
||||
|
||||
root: true,
|
||||
|
||||
rules: {
|
||||
'array-bracket-newline': ['error', { multiline: true }],
|
||||
'array-bracket-spacing': ['error'],
|
||||
'arrow-body-style': ['error', 'as-needed'],
|
||||
'arrow-parens': ['error', 'as-needed'],
|
||||
'arrow-spacing': ['error', { after: true, before: true }],
|
||||
'block-spacing': ['error'],
|
||||
'brace-style': ['error', '1tbs'],
|
||||
'comma-dangle': ['error', 'always-multiline'],
|
||||
'comma-spacing': ['error'],
|
||||
'comma-style': ['error', 'last'],
|
||||
'curly': ['error'],
|
||||
'default-case-last': ['error'],
|
||||
'default-param-last': ['error'],
|
||||
'dot-location': ['error', 'property'],
|
||||
'dot-notation': ['error'],
|
||||
'eol-last': ['error', 'always'],
|
||||
'eqeqeq': ['error', 'always', { null: 'ignore' }],
|
||||
'func-call-spacing': ['error', 'never'],
|
||||
'function-paren-newline': ['error', 'multiline'],
|
||||
'generator-star-spacing': ['off'], // allow async-await
|
||||
'implicit-arrow-linebreak': ['error'],
|
||||
'indent': ['error', 2],
|
||||
'key-spacing': ['error', { afterColon: true, beforeColon: false, mode: 'strict' }],
|
||||
'keyword-spacing': ['error'],
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
'lines-between-class-members': ['error'],
|
||||
'multiline-comment-style': ['warn'],
|
||||
'newline-per-chained-call': ['error'],
|
||||
'no-alert': ['error'],
|
||||
'no-console': ['off'],
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', // allow debugger during development
|
||||
'no-duplicate-imports': ['error'],
|
||||
'no-else-return': ['error'],
|
||||
'no-empty-function': ['error'],
|
||||
'no-extra-parens': ['error'],
|
||||
'no-implicit-coercion': ['error'],
|
||||
'no-lonely-if': ['error'],
|
||||
'no-multi-spaces': ['error'],
|
||||
'no-multiple-empty-lines': ['warn', { max: 2, maxBOF: 0, maxEOF: 0 }],
|
||||
'no-promise-executor-return': ['error'],
|
||||
'no-return-assign': ['error'],
|
||||
'no-script-url': ['error'],
|
||||
'no-template-curly-in-string': ['error'],
|
||||
'no-trailing-spaces': ['error'],
|
||||
'no-unneeded-ternary': ['error'],
|
||||
'no-unreachable-loop': ['error'],
|
||||
'no-unsafe-optional-chaining': ['error'],
|
||||
'no-useless-return': ['error'],
|
||||
'no-var': ['error'],
|
||||
'no-warning-comments': ['error'],
|
||||
'no-whitespace-before-property': ['error'],
|
||||
'object-curly-newline': ['error', { consistent: true }],
|
||||
'object-curly-spacing': ['error', 'always'],
|
||||
'object-shorthand': ['error'],
|
||||
'padded-blocks': ['error', 'never'],
|
||||
'prefer-arrow-callback': ['error'],
|
||||
'prefer-const': ['error'],
|
||||
'prefer-object-spread': ['error'],
|
||||
'prefer-rest-params': ['error'],
|
||||
'prefer-template': ['error'],
|
||||
'quote-props': ['error', 'consistent-as-needed', { keywords: false }],
|
||||
'quotes': ['error', 'single', { allowTemplateLiterals: true }],
|
||||
'require-atomic-updates': ['error'],
|
||||
'require-await': ['error'],
|
||||
'semi': ['error', 'never'],
|
||||
'sort-imports': ['error', { ignoreCase: true, ignoreDeclarationSort: false, ignoreMemberSort: false }],
|
||||
'sort-keys': ['error', 'asc', { caseSensitive: true, natural: false }],
|
||||
'space-before-blocks': ['error', 'always'],
|
||||
'space-before-function-paren': ['error', 'never'],
|
||||
'space-in-parens': ['error', 'never'],
|
||||
'space-infix-ops': ['error'],
|
||||
'space-unary-ops': ['error', { nonwords: false, words: true }],
|
||||
'spaced-comment': ['warn', 'always'],
|
||||
'switch-colon-spacing': ['error'],
|
||||
'template-curly-spacing': ['error', 'never'],
|
||||
'unicode-bom': ['error', 'never'],
|
||||
'vue/new-line-between-multi-line-property': ['error'],
|
||||
'vue/no-empty-component-block': ['error'],
|
||||
'vue/no-reserved-component-names': ['error'],
|
||||
'vue/no-template-target-blank': ['error'],
|
||||
'vue/no-unused-properties': ['error'],
|
||||
'vue/no-unused-refs': ['error'],
|
||||
'vue/no-useless-mustaches': ['error'],
|
||||
'vue/order-in-components': ['off'], // Collides with sort-keys
|
||||
'vue/require-name-property': ['error'],
|
||||
'vue/v-for-delimiter-style': ['error'],
|
||||
'vue/v-on-function-call': ['error'],
|
||||
'wrap-iife': ['error'],
|
||||
'yoda': ['error'],
|
||||
},
|
||||
}
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -8,6 +8,7 @@ frontend/css
|
|||
frontend/js
|
||||
frontend/locale/*.untranslated.json
|
||||
frontend/webfonts
|
||||
frontend/*.ttf
|
||||
frontend/*.woff2
|
||||
node_modules
|
||||
ots
|
||||
|
|
44
Makefile
44
Makefile
|
@ -1,49 +1,35 @@
|
|||
VER_FONTAWESOME:=6.4.0
|
||||
default: build-local
|
||||
|
||||
|
||||
default: generate download_libs
|
||||
|
||||
build-local: download_libs generate-inner generate-apidocs
|
||||
build-local: frontend generate-apidocs
|
||||
go build \
|
||||
-buildmode=pie \
|
||||
-ldflags "-s -w -X main.version=$(shell git describe --tags --always || echo dev)" \
|
||||
-mod=readonly \
|
||||
-trimpath
|
||||
|
||||
generate:
|
||||
docker run --rm -i -v $(CURDIR):$(CURDIR) -w $(CURDIR) node:22-alpine \
|
||||
sh -exc "apk add make && make generate-inner generate-apidocs && chown -R $(shell id -u) frontend node_modules"
|
||||
|
||||
generate-apidocs:
|
||||
npx --yes @redocly/cli build-docs docs/openapi.yaml --disableGoogleFont true -o /tmp/api.html
|
||||
mv /tmp/api.html frontend/
|
||||
|
||||
generate-inner:
|
||||
npm ci --include=dev
|
||||
node ./ci/build.mjs
|
||||
frontend_prod: export NODE_ENV=production
|
||||
frontend_prod: frontend
|
||||
|
||||
frontend: node_modules
|
||||
corepack yarn@1 node ci/build.mjs
|
||||
|
||||
frontend_lint: node_modules
|
||||
corepack yarn@1 eslint --fix src
|
||||
|
||||
node_modules:
|
||||
corepack yarn@1 install --production=false --frozen-lockfile
|
||||
|
||||
publish: export NODE_ENV=production
|
||||
publish: download_libs generate-inner generate-apidocs
|
||||
publish: frontend_prod generate-apidocs
|
||||
bash ./ci/build.sh
|
||||
|
||||
translate:
|
||||
cd ci/translate && go run . --write-issue-file
|
||||
|
||||
# -- Download / refresh external libraries --
|
||||
|
||||
clean_libs:
|
||||
rm -rf \
|
||||
frontend/css \
|
||||
frontend/js \
|
||||
frontend/webfonts
|
||||
|
||||
download_libs: clean_libs
|
||||
download_libs: fontawesome
|
||||
|
||||
fontawesome:
|
||||
curl -sSfL https://github.com/FortAwesome/Font-Awesome/archive/$(VER_FONTAWESOME).tar.gz | \
|
||||
tar -vC frontend -xz --strip-components=1 --wildcards --exclude='*/js-packages' '*/css' '*/webfonts'
|
||||
|
||||
# -- Vulnerability scanning --
|
||||
|
||||
trivy:
|
||||
|
@ -56,3 +42,5 @@ trivy:
|
|||
--scanners config,license,secret,vuln \
|
||||
--severity HIGH,CRITICAL \
|
||||
--skip-dirs docs
|
||||
|
||||
.PHONY: node_modules
|
||||
|
|
17
Tiltfile
17
Tiltfile
|
@ -1,16 +1,16 @@
|
|||
# Install Node deps on change of package.json
|
||||
local_resource(
|
||||
'npm',
|
||||
cmd='npm i',
|
||||
'yarn',
|
||||
cmd='corepack yarn@1 install', # Not using the make target to edit the lockfile
|
||||
deps=['package.json'],
|
||||
)
|
||||
|
||||
# Rebuild frontend if source files change
|
||||
local_resource(
|
||||
'frontend',
|
||||
cmd='node ./ci/build.mjs',
|
||||
cmd='make frontend',
|
||||
deps=['src'],
|
||||
resource_deps=['npm'],
|
||||
resource_deps=['yarn'],
|
||||
)
|
||||
|
||||
# Generate translation files on source change
|
||||
|
@ -33,7 +33,9 @@ local_resource(
|
|||
'tplFuncs.go',
|
||||
'go.mod', 'go.sum',
|
||||
],
|
||||
ignore=['ots', 'src'],
|
||||
ignore=[
|
||||
'src'
|
||||
],
|
||||
serve_cmd='go run . --listen=:15641',
|
||||
serve_env={
|
||||
'CUSTOMIZE': 'customize.yaml',
|
||||
|
@ -42,5 +44,8 @@ local_resource(
|
|||
http_get=http_get_action(15641, path='/api/healthz'),
|
||||
initial_delay_secs=1,
|
||||
),
|
||||
resource_deps=['frontend', 'translations'],
|
||||
resource_deps=[
|
||||
'frontend',
|
||||
'translations',
|
||||
],
|
||||
)
|
||||
|
|
15
ci/build.mjs
15
ci/build.mjs
|
@ -1,6 +1,6 @@
|
|||
import { sassPlugin } from 'esbuild-sass-plugin'
|
||||
import vuePlugin from 'esbuild-vue'
|
||||
import esbuild from 'esbuild'
|
||||
import { sassPlugin } from 'esbuild-sass-plugin'
|
||||
import vuePlugin from 'esbuild-plugin-vue3'
|
||||
|
||||
esbuild.build({
|
||||
assetNames: '[name]',
|
||||
|
@ -8,9 +8,10 @@ esbuild.build({
|
|||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'dev'),
|
||||
},
|
||||
entryPoints: ['src/main.js'],
|
||||
entryPoints: ['src/main.ts'],
|
||||
legalComments: 'none',
|
||||
loader: {
|
||||
'.ttf': 'empty', // Drop files, all targets below support woff2
|
||||
'.woff2': 'file',
|
||||
},
|
||||
minify: true,
|
||||
|
@ -20,10 +21,10 @@ esbuild.build({
|
|||
vuePlugin(),
|
||||
],
|
||||
target: [
|
||||
'chrome87',
|
||||
'edge87',
|
||||
'chrome109',
|
||||
'edge132',
|
||||
'es2020',
|
||||
'firefox84',
|
||||
'safari14',
|
||||
'firefox115',
|
||||
'safari16',
|
||||
],
|
||||
})
|
||||
|
|
126
eslint.config.mjs
Normal file
126
eslint.config.mjs
Normal file
|
@ -0,0 +1,126 @@
|
|||
import globals from 'globals'
|
||||
import js from '@eslint/js'
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin'
|
||||
import vue from 'eslint-plugin-vue'
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
...vue.configs['flat/recommended'],
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
process: true,
|
||||
},
|
||||
|
||||
parserOptions: {
|
||||
parser: '@typescript-eslint/parser',
|
||||
requireConfigFile: true,
|
||||
},
|
||||
},
|
||||
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: true,
|
||||
},
|
||||
|
||||
plugins: {
|
||||
'@typescript-eslint': typescriptEslint,
|
||||
vue,
|
||||
},
|
||||
|
||||
rules: {
|
||||
'array-bracket-newline': ['error', { multiline: true }],
|
||||
'array-bracket-spacing': ['error'],
|
||||
'arrow-body-style': ['error', 'as-needed'],
|
||||
'arrow-parens': ['error', 'as-needed'],
|
||||
'arrow-spacing': ['error', { after: true, before: true }],
|
||||
'block-spacing': ['error'],
|
||||
'brace-style': ['error', '1tbs'],
|
||||
'camelcase': ['warn'],
|
||||
'comma-dangle': ['error', 'always-multiline'],
|
||||
'comma-spacing': ['error'],
|
||||
'comma-style': ['error', 'last'],
|
||||
'curly': ['error'],
|
||||
'default-case-last': ['error'],
|
||||
'default-param-last': ['error'],
|
||||
'dot-location': ['error', 'property'],
|
||||
'dot-notation': ['error'],
|
||||
'eol-last': ['error', 'always'],
|
||||
'eqeqeq': ['error', 'always', { null: 'ignore' }],
|
||||
'func-call-spacing': ['error', 'never'],
|
||||
'function-paren-newline': ['error', 'multiline'],
|
||||
'generator-star-spacing': ['off'],
|
||||
'implicit-arrow-linebreak': ['error'],
|
||||
'indent': ['error', 2],
|
||||
'key-spacing': ['error', { afterColon: true, beforeColon: false, mode: 'strict' }],
|
||||
'keyword-spacing': ['error'],
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
'lines-between-class-members': ['error'],
|
||||
'multiline-comment-style': ['off'],
|
||||
'newline-per-chained-call': ['error'],
|
||||
'no-alert': ['error'],
|
||||
'no-console': ['off'],
|
||||
'no-debugger': 'off',
|
||||
'no-duplicate-imports': ['error'],
|
||||
'no-else-return': ['error'],
|
||||
'no-empty-function': ['error'],
|
||||
'no-extra-parens': ['error'],
|
||||
'no-implicit-coercion': ['error'],
|
||||
'no-lonely-if': ['error'],
|
||||
'no-multi-spaces': ['error'],
|
||||
'no-multiple-empty-lines': ['warn', { max: 2, maxBOF: 0, maxEOF: 0 }],
|
||||
'no-promise-executor-return': ['error'],
|
||||
'no-return-assign': ['error'],
|
||||
'no-script-url': ['error'],
|
||||
'no-template-curly-in-string': ['error'],
|
||||
'no-trailing-spaces': ['error'],
|
||||
'no-unneeded-ternary': ['error'],
|
||||
'no-unreachable-loop': ['error'],
|
||||
'no-unsafe-optional-chaining': ['error'],
|
||||
'no-useless-return': ['error'],
|
||||
'no-var': ['error'],
|
||||
'no-warning-comments': ['error'],
|
||||
'no-whitespace-before-property': ['error'],
|
||||
'object-curly-newline': ['error', { consistent: true }],
|
||||
'object-curly-spacing': ['error', 'always'],
|
||||
'object-shorthand': ['error'],
|
||||
'padded-blocks': ['error', 'never'],
|
||||
'prefer-arrow-callback': ['error'],
|
||||
'prefer-const': ['error'],
|
||||
'prefer-object-spread': ['error'],
|
||||
'prefer-rest-params': ['error'],
|
||||
'prefer-template': ['error'],
|
||||
'quote-props': ['error', 'consistent-as-needed', { keywords: false }],
|
||||
'quotes': ['error', 'single', { allowTemplateLiterals: true }],
|
||||
'require-atomic-updates': ['error'],
|
||||
'require-await': ['error'],
|
||||
'semi': ['error', 'never'],
|
||||
'sort-imports': ['error', { ignoreCase: true, ignoreDeclarationSort: false, ignoreMemberSort: false }],
|
||||
'sort-keys': ['error', 'asc', { caseSensitive: true, natural: false }],
|
||||
'space-before-blocks': ['error', 'always'],
|
||||
'space-before-function-paren': ['error', 'never'],
|
||||
'space-in-parens': ['error', 'never'],
|
||||
'space-infix-ops': ['error'],
|
||||
'space-unary-ops': ['error', { nonwords: false, words: true }],
|
||||
'spaced-comment': ['warn', 'always'],
|
||||
'switch-colon-spacing': ['error'],
|
||||
'template-curly-spacing': ['error', 'never'],
|
||||
'unicode-bom': ['error', 'never'],
|
||||
'vue/comment-directive': 'off',
|
||||
'vue/new-line-between-multi-line-property': ['error'],
|
||||
'vue/no-empty-component-block': ['error'],
|
||||
'vue/no-reserved-component-names': ['error'],
|
||||
'vue/no-template-target-blank': ['error'],
|
||||
'vue/no-unused-properties': ['error'],
|
||||
'vue/no-unused-refs': ['error'],
|
||||
'vue/no-useless-mustaches': ['error'],
|
||||
'vue/order-in-components': ['off'],
|
||||
'vue/require-name-property': ['error'],
|
||||
'vue/v-for-delimiter-style': ['error'],
|
||||
'wrap-iife': ['error'],
|
||||
'yoda': ['error'],
|
||||
},
|
||||
},
|
||||
]
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
|
||||
{{ range (list "webfonts/fa-solid-900.woff2" "webfonts/fa-brands-400.woff2" "lato-v20-latin-ext_latin-regular.woff2" "lato-v20-latin-ext_latin-700.woff2") }}
|
||||
{{ range (list "fa-solid-900.woff2" "fa-brands-400.woff2") }}
|
||||
<link
|
||||
as="font"
|
||||
crossorigin="anonymous"
|
||||
|
@ -21,13 +21,6 @@
|
|||
rel="stylesheet"
|
||||
>
|
||||
|
||||
<link
|
||||
crossorigin="anonymous"
|
||||
href="css/all.min.css"
|
||||
integrity="{{ assetSRI `css/all.min.css` }}"
|
||||
rel="stylesheet"
|
||||
>
|
||||
|
||||
<title>{{ .Customize.AppTitle }}</title>
|
||||
|
||||
<script nonce="{{ .InlineContentNonce }}">
|
||||
|
@ -56,8 +49,8 @@
|
|||
document.addEventListener('DOMContentLoaded', () => window.refreshTheme())
|
||||
|
||||
// Template variable from Golang process
|
||||
const maxSecretExpire = {{ .MaxSecretExpiry }}
|
||||
const version = "{{ .Version }}"
|
||||
window.maxSecretExpire = {{ .MaxSecretExpiry }}
|
||||
window.version = "{{ .Version }}"
|
||||
window.OTSCustomize = JSON.parse('{{ .Customize.ToJSON }}')
|
||||
window.useFormalLanguage = {{ .Customize.UseFormalLanguage | mustToJson }}
|
||||
</script>
|
||||
|
|
5369
package-lock.json
generated
5369
package-lock.json
generated
File diff suppressed because it is too large
Load diff
35
package.json
35
package.json
|
@ -1,21 +1,28 @@
|
|||
{
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "^7.26.8",
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild-sass-plugin": "^3.3.1",
|
||||
"esbuild-vue": "^1.2.2",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"vue-template-compiler": "^2.7.16"
|
||||
"@babel/eslint-parser": "7.27.1",
|
||||
"@types/bootstrap": "5.2.10",
|
||||
"@typescript-eslint/eslint-plugin": "8.31.1",
|
||||
"@typescript-eslint/parser": "8.31.1",
|
||||
"@vue/tsconfig": "0.7.0",
|
||||
"esbuild": "0.25.3",
|
||||
"esbuild-plugin-vue3": "0.4.2",
|
||||
"esbuild-sass-plugin": "3.3.1",
|
||||
"eslint": "9.25.1",
|
||||
"eslint-plugin-vue": "10.1.0",
|
||||
"typescript": "5.8.3",
|
||||
"vue-eslint-parser": "10.1.3"
|
||||
},
|
||||
"name": "ots",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"bootstrap": "^5.3.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^2.7.16",
|
||||
"vue-i18n": "^8.28.2",
|
||||
"vue-router": "^3.6.5"
|
||||
}
|
||||
"@fortawesome/fontawesome-free": "6.7.2",
|
||||
"base64-js": "1.5.1",
|
||||
"bootstrap": "5.3.5",
|
||||
"qrcode": "1.5.4",
|
||||
"vue": "3.5.13",
|
||||
"vue-i18n": "11.1.3",
|
||||
"vue-router": "4.5.1"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
80
src/app.vue
80
src/app.vue
|
@ -1,7 +1,9 @@
|
|||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div id="app">
|
||||
<app-navbar />
|
||||
<app-navbar
|
||||
v-model:theme="theme"
|
||||
@navigate="navigate"
|
||||
/>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div
|
||||
|
@ -19,7 +21,10 @@
|
|||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<router-view @error="displayError" />
|
||||
<router-view
|
||||
@error="displayError"
|
||||
@navigate="navigate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -28,15 +33,15 @@
|
|||
>
|
||||
<div class="col form-text text-center">
|
||||
<span
|
||||
v-if="!$root.customize.disablePoweredBy"
|
||||
v-if="!customize.disablePoweredBy"
|
||||
class="mx-2"
|
||||
>
|
||||
{{ $t('text-powered-by') }}
|
||||
<a href="https://github.com/Luzifer/ots"><i class="fab fa-github" /> OTS</a>
|
||||
{{ $root.version }}
|
||||
{{ version }}
|
||||
</span>
|
||||
<span
|
||||
v-for="link in $root.customize.footerLinks"
|
||||
v-for="link in customize.footerLinks"
|
||||
:key="link.url"
|
||||
class="mx-2"
|
||||
>
|
||||
|
@ -48,26 +53,39 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AppNavbar from './components/navbar.vue'
|
||||
<script lang="ts">
|
||||
import { isNavigationFailure, NavigationFailureType } from 'vue-router'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AppNavbar,
|
||||
import AppNavbar from './components/navbar.vue'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
components: { AppNavbar },
|
||||
|
||||
computed: {
|
||||
isSecureEnvironment(): boolean {
|
||||
return Boolean(window.crypto.subtle)
|
||||
},
|
||||
|
||||
version(): string {
|
||||
return window.version
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.$root.navigate('/')
|
||||
this.navigate('/')
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
error: '',
|
||||
customize: {} as any,
|
||||
error: '' as string | null,
|
||||
theme: 'auto',
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
displayError(error) {
|
||||
displayError(error: string | null) {
|
||||
this.error = error
|
||||
},
|
||||
|
||||
|
@ -80,13 +98,13 @@ export default {
|
|||
|
||||
const parts = hash.substring(1).split('|')
|
||||
const secretId = parts[0]
|
||||
let securePassword = null
|
||||
let securePassword = null as string | null
|
||||
|
||||
if (parts.length === 2) {
|
||||
securePassword = parts[1]
|
||||
}
|
||||
|
||||
this.$root.navigate({
|
||||
this.navigate({
|
||||
path: '/secret',
|
||||
query: {
|
||||
secretId,
|
||||
|
@ -94,18 +112,44 @@ export default {
|
|||
},
|
||||
})
|
||||
},
|
||||
|
||||
navigate(to: string | any): void {
|
||||
this.error = ''
|
||||
this.$router.replace(to)
|
||||
.catch(err => {
|
||||
if (isNavigationFailure(err, NavigationFailureType.duplicated)) {
|
||||
// Hide duplicate nav errors
|
||||
return
|
||||
}
|
||||
throw err
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
// Trigger initialization functions
|
||||
mounted() {
|
||||
this.customize = window.OTSCustomize
|
||||
|
||||
window.onhashchange = this.hashLoad
|
||||
this.hashLoad()
|
||||
|
||||
if (!this.$root.isSecureEnvironment) {
|
||||
if (!this.isSecureEnvironment) {
|
||||
this.error = this.$t('alert-insecure-environment')
|
||||
}
|
||||
|
||||
this.theme = window.getThemeFromStorage()
|
||||
window.matchMedia('(prefers-color-scheme: light)')
|
||||
.addEventListener('change', () => {
|
||||
window.refreshTheme()
|
||||
})
|
||||
},
|
||||
|
||||
name: 'App',
|
||||
}
|
||||
|
||||
watch: {
|
||||
theme(to): void {
|
||||
window.setTheme(to)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -8,10 +8,13 @@
|
|||
<i :class="{'fas fa-fw fa-clipboard': !copyToClipboardSuccess, 'fas fa-fw fa-circle-check': copyToClipboardSuccess}" />
|
||||
</button>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
computed: {
|
||||
hasClipboard() {
|
||||
hasClipboard(): boolean {
|
||||
return Boolean(navigator.clipboard && navigator.clipboard.writeText)
|
||||
},
|
||||
},
|
||||
|
@ -23,7 +26,7 @@ export default {
|
|||
},
|
||||
|
||||
methods: {
|
||||
copy() {
|
||||
copy(): void {
|
||||
navigator.clipboard.writeText(this.content)
|
||||
.then(() => {
|
||||
this.copyToClipboardSuccess = true
|
||||
|
@ -43,5 +46,5 @@ export default {
|
|||
type: String,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<!-- Creation disabled -->
|
||||
<div
|
||||
|
@ -36,11 +35,11 @@
|
|||
v-model="secret"
|
||||
class="form-control"
|
||||
:rows="2"
|
||||
@pasteFile="handlePasteFile"
|
||||
@paste-file="handlePasteFile"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!$root.customize.disableFileAttachment"
|
||||
v-if="!customize.disableFileAttachment"
|
||||
class="col-12 mb-3"
|
||||
>
|
||||
<label for="createSecretFiles">{{ $t('label-secret-files') }}</label>
|
||||
|
@ -50,7 +49,7 @@
|
|||
class="form-control"
|
||||
type="file"
|
||||
multiple
|
||||
:accept="$root.customize.acceptedFileTypes"
|
||||
:accept="customize.acceptedFileTypes"
|
||||
@change="handleSelectFiles"
|
||||
>
|
||||
<div class="form-text">
|
||||
|
@ -74,7 +73,7 @@
|
|||
:can-delete="true"
|
||||
:track-download="false"
|
||||
:files="attachedFiles"
|
||||
@fileClicked="deleteFile"
|
||||
@file-clicked="deleteFile"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-md-6 col-12 order-2 order-md-1">
|
||||
|
@ -93,7 +92,7 @@
|
|||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="!$root.customize.disableExpiryOverride"
|
||||
v-if="!customize.disableExpiryOverride"
|
||||
class="col-md-6 col-12 order-1 order-md-2"
|
||||
>
|
||||
<div class="row mb-3 justify-content-end">
|
||||
|
@ -108,7 +107,7 @@
|
|||
>
|
||||
<option
|
||||
v-for="opt in expiryChoices"
|
||||
:key="opt.value"
|
||||
:key="opt.value || 'null'"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.text }}
|
||||
|
@ -121,11 +120,11 @@
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
/* global maxSecretExpire */
|
||||
|
||||
import appCrypto from '../crypto.js'
|
||||
<script lang="ts">
|
||||
import appCrypto from '../crypto.ts'
|
||||
import { bytesToHuman } from '../helpers'
|
||||
import { defineComponent } from 'vue'
|
||||
import FilesDisplay from './fileDisplay.vue'
|
||||
import GrowArea from './growarea.vue'
|
||||
import OTSMeta from '../ots-meta'
|
||||
|
@ -157,30 +156,35 @@ const internalMaxFileSize = 64 * 1024 * 1024 // 64 MiB
|
|||
const passwordCharset = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
const passwordLength = 20
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
components: { FilesDisplay, GrowArea },
|
||||
|
||||
computed: {
|
||||
canCreate() {
|
||||
canCreate(): boolean {
|
||||
return (this.secret.trim().length > 0 || this.selectedFileMeta.length > 0) && !this.maxFileSizeExceeded && !this.invalidFilesSelected
|
||||
},
|
||||
|
||||
expiryChoices() {
|
||||
const choices = [{ text: this.$t('expire-default'), value: null }]
|
||||
for (const choice of this.$root.customize.expiryChoices || defaultExpiryChoices) {
|
||||
if (maxSecretExpire > 0 && choice > maxSecretExpire) {
|
||||
customize(): any {
|
||||
return window.OTSCustomize || {}
|
||||
},
|
||||
|
||||
expiryChoices(): Record<string, string | null>[] {
|
||||
const choices = [{ text: this.$t('expire-default'), value: null as string | null }]
|
||||
|
||||
for (const choice of this.customize.expiryChoices || defaultExpiryChoices) {
|
||||
if (window.maxSecretExpire > 0 && choice > window.maxSecretExpire) {
|
||||
continue
|
||||
}
|
||||
|
||||
const option = { value: choice }
|
||||
const option = { text: '', value: choice }
|
||||
if (choice >= 86400) {
|
||||
option.text = this.$tc('expire-n-days', Math.round(choice / 86400))
|
||||
option.text = this.$t('expire-n-days', Math.round(choice / 86400))
|
||||
} else if (choice >= 3600) {
|
||||
option.text = this.$tc('expire-n-hours', Math.round(choice / 3600))
|
||||
option.text = this.$t('expire-n-hours', Math.round(choice / 3600))
|
||||
} else if (choice >= 60) {
|
||||
option.text = this.$tc('expire-n-minutes', Math.round(choice / 60))
|
||||
option.text = this.$t('expire-n-minutes', Math.round(choice / 60))
|
||||
} else {
|
||||
option.text = this.$tc('expire-n-seconds', choice)
|
||||
option.text = this.$t('expire-n-seconds', choice)
|
||||
}
|
||||
|
||||
choices.push(option)
|
||||
|
@ -189,13 +193,13 @@ export default {
|
|||
return choices
|
||||
},
|
||||
|
||||
invalidFilesSelected() {
|
||||
if (this.$root.customize.acceptedFileTypes === '') {
|
||||
invalidFilesSelected(): boolean {
|
||||
if (this.customize.acceptedFileTypes === '') {
|
||||
// No limitation configured, no need to check
|
||||
return false
|
||||
}
|
||||
|
||||
const accepted = this.$root.customize.acceptedFileTypes.split(',')
|
||||
const accepted = this.customize.acceptedFileTypes.split(',')
|
||||
for (const fm of this.selectedFileMeta) {
|
||||
let isAccepted = false
|
||||
|
||||
|
@ -213,20 +217,24 @@ export default {
|
|||
return false
|
||||
},
|
||||
|
||||
maxFileSize() {
|
||||
return this.$root.customize.maxAttachmentSizeTotal === 0 ? internalMaxFileSize : Math.min(internalMaxFileSize, this.$root.customize.maxAttachmentSizeTotal)
|
||||
isSecureEnvironment(): boolean {
|
||||
return Boolean(window.crypto.subtle)
|
||||
},
|
||||
|
||||
maxFileSizeExceeded() {
|
||||
maxFileSize(): number {
|
||||
return this.customize.maxAttachmentSizeTotal === 0 ? internalMaxFileSize : Math.min(internalMaxFileSize, this.customize.maxAttachmentSizeTotal)
|
||||
},
|
||||
|
||||
maxFileSizeExceeded(): boolean {
|
||||
return this.fileSize > this.maxFileSize
|
||||
},
|
||||
|
||||
showCreateForm() {
|
||||
return this.canWrite && this.$root.isSecureEnvironment
|
||||
showCreateForm(): boolean {
|
||||
return this.canWrite && this.isSecureEnvironment
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
created(): void {
|
||||
this.checkWriteAccess()
|
||||
},
|
||||
|
||||
|
@ -243,11 +251,13 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
emits: ['error', 'navigate'],
|
||||
|
||||
methods: {
|
||||
bytesToHuman,
|
||||
|
||||
checkWriteAccess() {
|
||||
fetch('api/isWritable', {
|
||||
checkWriteAccess(): Promise<void> {
|
||||
return fetch('api/isWritable', {
|
||||
credentials: 'same-origin',
|
||||
method: 'GET',
|
||||
redirect: 'error',
|
||||
|
@ -264,9 +274,9 @@ export default {
|
|||
},
|
||||
|
||||
// createSecret executes the secret creation after encrypting the secret
|
||||
createSecret() {
|
||||
createSecret(): void {
|
||||
if (!this.canCreate) {
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
// Encoding large files takes a while, prevent duplicate click on "create"
|
||||
|
@ -309,7 +319,7 @@ export default {
|
|||
|
||||
resp.json()
|
||||
.then(data => {
|
||||
this.$root.navigate({
|
||||
this.$emit('navigate', {
|
||||
path: '/display-secret-url',
|
||||
query: {
|
||||
expiresAt: data.expires_at,
|
||||
|
@ -324,16 +334,14 @@ export default {
|
|||
this.$emit('error', this.$t('alert-something-went-wrong'))
|
||||
})
|
||||
})
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
deleteFile(fileId) {
|
||||
deleteFile(fileId: string): void {
|
||||
this.attachedFiles = [...this.attachedFiles].filter(file => file.id !== fileId)
|
||||
this.updateFileMeta()
|
||||
},
|
||||
|
||||
handlePasteFile(file) {
|
||||
handlePasteFile(file: File): void {
|
||||
this.attachedFiles.push({
|
||||
fileObj: file,
|
||||
id: window.crypto.randomUUID(),
|
||||
|
@ -344,7 +352,7 @@ export default {
|
|||
this.updateFileMeta()
|
||||
},
|
||||
|
||||
handleSelectFiles() {
|
||||
handleSelectFiles(): void {
|
||||
for (const file of this.$refs.createSecretFiles.files) {
|
||||
this.attachedFiles.push({
|
||||
fileObj: file,
|
||||
|
@ -359,7 +367,7 @@ export default {
|
|||
this.$refs.createSecretFiles.value = ''
|
||||
},
|
||||
|
||||
isAcceptedBy(fileMeta, accept) {
|
||||
isAcceptedBy(fileMeta: any, accept: string): boolean {
|
||||
if (/^(?:[a-z]+|\*)\/(?:[a-zA-Z0-9.+_-]+|\*)$/.test(accept)) {
|
||||
// That's likely supposed to be a mime-type
|
||||
return RegExp(`^${accept.replaceAll('*', '.*')}$`).test(fileMeta.type)
|
||||
|
@ -372,7 +380,7 @@ export default {
|
|||
return false
|
||||
},
|
||||
|
||||
updateFileMeta() {
|
||||
updateFileMeta(): void {
|
||||
let cumSize = 0
|
||||
for (const f of this.attachedFiles) {
|
||||
cumSize += f.size
|
||||
|
@ -387,5 +395,5 @@ export default {
|
|||
},
|
||||
|
||||
name: 'AppCreate',
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
type="text"
|
||||
readonly
|
||||
:value="secretUrl"
|
||||
@focus="$refs.secretUrl.select()"
|
||||
@focus="selectURL"
|
||||
>
|
||||
<app-clipboard-button
|
||||
:content="secretUrl"
|
||||
|
@ -46,14 +46,17 @@
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
||||
<script lang="ts">
|
||||
import appClipboardButton from './clipboard-button.vue'
|
||||
import appQrButton from './qr-button.vue'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
components: { appClipboardButton, appQrButton },
|
||||
|
||||
computed: {
|
||||
secretUrl() {
|
||||
secretUrl(): string {
|
||||
return [
|
||||
window.location.href.split('#')[0],
|
||||
encodeURIComponent([
|
||||
|
@ -72,20 +75,25 @@ export default {
|
|||
},
|
||||
|
||||
methods: {
|
||||
burnSecret() {
|
||||
burnSecret(): Promise<void> {
|
||||
return fetch(`api/get/${this.secretId}`)
|
||||
.then(() => {
|
||||
this.burned = true
|
||||
})
|
||||
},
|
||||
|
||||
selectURL(): void {
|
||||
this.$refs.secretUrl.select()
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
mounted(): void {
|
||||
// Give the interface a moment to transistion and focus
|
||||
window.setTimeout(() => this.$refs.secretUrl.focus(), 100)
|
||||
},
|
||||
|
||||
name: 'AppDisplayURL',
|
||||
|
||||
props: {
|
||||
expiresAt: {
|
||||
default: null,
|
||||
|
@ -103,5 +111,5 @@ export default {
|
|||
type: String,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div class="card border-primary-subtle mb-3">
|
||||
<div
|
||||
|
@ -8,7 +7,7 @@
|
|||
<div class="card-body">
|
||||
<ul>
|
||||
<li
|
||||
v-for="(explanation, idx) in $t('items-explanation')"
|
||||
v-for="(explanation, idx) in $tm('items-explanation')"
|
||||
:key="`idx${idx}`"
|
||||
>
|
||||
{{ explanation }}
|
||||
|
@ -17,6 +16,11 @@
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default { name: 'AppExplanation' }
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AppExplanation',
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -30,20 +30,23 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { bytesToHuman } from '../helpers'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
hasDownloaded: {},
|
||||
}
|
||||
},
|
||||
|
||||
emits: ['fileClicked'],
|
||||
|
||||
methods: {
|
||||
bytesToHuman,
|
||||
|
||||
fasFileType(type) {
|
||||
fasFileType(type: string): string {
|
||||
return [
|
||||
'fas',
|
||||
'fa-fw',
|
||||
|
@ -60,8 +63,8 @@ export default {
|
|||
].join(' ')
|
||||
},
|
||||
|
||||
handleClick(file) {
|
||||
this.$set(this.hasDownloaded, file.id, true)
|
||||
handleClick(file: any): void {
|
||||
this.hasDownloaded[file.id] = true
|
||||
this.$emit('fileClicked', file.id)
|
||||
},
|
||||
},
|
||||
|
@ -77,7 +80,7 @@ export default {
|
|||
|
||||
files: {
|
||||
required: true,
|
||||
type: Array,
|
||||
type: Array<any>,
|
||||
},
|
||||
|
||||
trackDownload: {
|
||||
|
@ -86,5 +89,5 @@ export default {
|
|||
type: Boolean,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -7,8 +7,10 @@
|
|||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
created() {
|
||||
this.data = this.value
|
||||
},
|
||||
|
@ -19,8 +21,14 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
emits: ['input', 'pasteFile'],
|
||||
|
||||
methods: {
|
||||
changeSize() {
|
||||
changeSize(): void {
|
||||
if (!this.$refs.area) {
|
||||
return
|
||||
}
|
||||
|
||||
const verticalBorderSize = this.getStyle('borderTopWidth') + this.getStyle('borderBottomWidth') || 0
|
||||
const verticalPaddingSize = this.getStyle('paddingTop') + this.getStyle('paddingBottom') || 0
|
||||
|
||||
|
@ -31,12 +39,12 @@ export default {
|
|||
this.$refs.area.style.height = `${newHeight}px`
|
||||
},
|
||||
|
||||
getStyle(name) {
|
||||
getStyle(name: string): number {
|
||||
return parseInt(getComputedStyle(this.$refs.area, null)[name])
|
||||
},
|
||||
|
||||
handlePaste(evt) {
|
||||
if ([...evt.clipboardData.items]
|
||||
handlePaste(evt: ClipboardEvent): void {
|
||||
if ([...evt.clipboardData?.items || []]
|
||||
.filter(item => item.kind !== 'string')
|
||||
.length === 0) {
|
||||
return
|
||||
|
@ -49,7 +57,7 @@ export default {
|
|||
evt.stopPropagation()
|
||||
evt.preventDefault()
|
||||
|
||||
for (const item of evt.clipboardData.items) {
|
||||
for (const item of evt.clipboardData?.items || []) {
|
||||
if (item.kind === 'string') {
|
||||
continue
|
||||
}
|
||||
|
@ -59,8 +67,7 @@ export default {
|
|||
},
|
||||
},
|
||||
|
||||
|
||||
mounted() {
|
||||
mounted(): void {
|
||||
this.changeSize()
|
||||
},
|
||||
|
||||
|
@ -92,5 +99,5 @@ export default {
|
|||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -4,18 +4,18 @@
|
|||
<a
|
||||
class="navbar-brand"
|
||||
href="#"
|
||||
@click.prevent="$root.navigate('/')"
|
||||
@click.prevent="$emit('navigate', '/')"
|
||||
>
|
||||
<i
|
||||
v-if="!appIcon"
|
||||
class="fas fa-user-secret mr-1"
|
||||
class="fas fa-user-secret me-1"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
class="mr-1"
|
||||
class="me-1"
|
||||
:src="appIcon"
|
||||
>
|
||||
<span v-if="!$root.customize.disableAppTitle">{{ $root.customize.appTitle }}</span>
|
||||
<span v-if="!customize.disableAppTitle">{{ customize.appTitle }}</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
|
@ -39,7 +39,7 @@
|
|||
<a
|
||||
class="nav-link"
|
||||
href="#"
|
||||
@click.prevent="$root.navigate('/explanation')"
|
||||
@click.prevent="$emit('navigate', '/explanation')"
|
||||
>
|
||||
<i class="fas fa-circle-info" /> {{ $t('btn-show-explanation') }}
|
||||
</a>
|
||||
|
@ -48,19 +48,19 @@
|
|||
<a
|
||||
class="nav-link"
|
||||
href="#"
|
||||
@click.prevent="$root.navigate('/')"
|
||||
@click.prevent="$emit('navigate', '/')"
|
||||
>
|
||||
<i class="fas fa-plus" /> {{ $t('btn-new-secret') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<form
|
||||
v-if="!$root.customize.disableThemeSwitcher"
|
||||
v-if="!customize.disableThemeSwitcher"
|
||||
class="d-flex align-items-center btn-group"
|
||||
>
|
||||
<input
|
||||
id="theme-light"
|
||||
v-model="$root.theme"
|
||||
v-model="intTheme"
|
||||
type="radio"
|
||||
name="theme"
|
||||
class="btn-check"
|
||||
|
@ -75,7 +75,7 @@
|
|||
|
||||
<input
|
||||
id="theme-auto"
|
||||
v-model="$root.theme"
|
||||
v-model="intTheme"
|
||||
type="radio"
|
||||
name="theme"
|
||||
class="btn-check"
|
||||
|
@ -90,7 +90,7 @@
|
|||
|
||||
<input
|
||||
id="theme-dark"
|
||||
v-model="$root.theme"
|
||||
v-model="intTheme"
|
||||
type="radio"
|
||||
name="theme"
|
||||
class="btn-check"
|
||||
|
@ -108,19 +108,62 @@
|
|||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
computed: {
|
||||
appIcon() {
|
||||
appIcon(): string {
|
||||
// Use specified icon or fall back to null
|
||||
const appIcon = this.$root.customize.appIcon || null
|
||||
const appIcon = this.$parent.customize.appIcon || null
|
||||
// Use specified icon or fall back to light-mode appIcon (which might be null)
|
||||
const darkIcon = this.$root.customize.appIconDark || appIcon
|
||||
const darkIcon = this.$parent.customize.appIconDark || appIcon
|
||||
|
||||
return this.$root.theme === 'dark' ? darkIcon : appIcon
|
||||
},
|
||||
|
||||
customize(): any {
|
||||
return this.$parent.customize || {}
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
intTheme: '',
|
||||
}
|
||||
},
|
||||
|
||||
emits: ['navigate', 'update:theme'],
|
||||
|
||||
mounted(): void {
|
||||
this.intTheme = this.theme
|
||||
},
|
||||
|
||||
name: 'AppNavbar',
|
||||
|
||||
props: {
|
||||
theme: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
intTheme(to: string, from: string): void {
|
||||
if (to === from) {
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit('update:theme', to)
|
||||
},
|
||||
|
||||
theme(to: string, from: string): void {
|
||||
if (to === from) {
|
||||
return
|
||||
}
|
||||
|
||||
this.intTheme = to
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<button
|
||||
v-if="!$root.customize.disableQRSupport"
|
||||
v-if="!customize.disableQRSupport"
|
||||
id="secret-url-qrcode"
|
||||
ref="qrButton"
|
||||
class="btn btn-secondary"
|
||||
|
@ -9,11 +9,19 @@
|
|||
<i class="fas fa-qrcode" />
|
||||
</button>
|
||||
</template>
|
||||
<script>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { Popover } from 'bootstrap'
|
||||
import qrcode from 'qrcode'
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
computed: {
|
||||
customize(): any {
|
||||
return window.OTSCustomize || {}
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
qrDataURL: null,
|
||||
|
@ -21,8 +29,8 @@ export default {
|
|||
},
|
||||
|
||||
methods: {
|
||||
generateQR() {
|
||||
if (this.$root.customize.disableQRSupport) {
|
||||
generateQR(): void {
|
||||
if (window.OTSCustomize.disableQRSupport) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -33,7 +41,7 @@ export default {
|
|||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
mounted(): void {
|
||||
this.generateQR()
|
||||
},
|
||||
|
||||
|
@ -51,7 +59,7 @@ export default {
|
|||
this.generateQR()
|
||||
},
|
||||
|
||||
qrDataURL(to) {
|
||||
qrDataURL(to: string): void {
|
||||
if (this.popover) {
|
||||
this.popover.dispose()
|
||||
}
|
||||
|
@ -69,5 +77,5 @@ export default {
|
|||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div class="card border-primary-subtle mb-3">
|
||||
<div
|
||||
|
@ -44,7 +43,7 @@
|
|||
/>
|
||||
<a
|
||||
class="btn btn-secondary"
|
||||
:href="secretContentBlobURL"
|
||||
:href="secretContentBlobURL || ''"
|
||||
download
|
||||
:title="$t('tooltip-download-as-file')"
|
||||
>
|
||||
|
@ -63,15 +62,17 @@
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
||||
<script lang="ts">
|
||||
import appClipboardButton from './clipboard-button.vue'
|
||||
import appCrypto from '../crypto.js'
|
||||
import appCrypto from '../crypto.ts'
|
||||
import appQrButton from './qr-button.vue'
|
||||
import { defineComponent } from 'vue'
|
||||
import FilesDisplay from './fileDisplay.vue'
|
||||
import GrowArea from './growarea.vue'
|
||||
import OTSMeta from '../ots-meta'
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
components: { FilesDisplay, GrowArea, appClipboardButton, appQrButton },
|
||||
|
||||
data() {
|
||||
|
@ -84,9 +85,11 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
emits: ['error'],
|
||||
|
||||
methods: {
|
||||
// requestSecret requests the encrypted secret from the backend
|
||||
requestSecret() {
|
||||
requestSecret(): void {
|
||||
this.secretLoading = true
|
||||
window.history.replaceState({}, '', window.location.href.split('#')[0])
|
||||
fetch(`api/get/${this.secretId}`)
|
||||
|
@ -163,5 +166,5 @@ export default {
|
|||
this.secretContentBlobURL = window.URL.createObjectURL(new Blob([to], { type: 'text/plain' }))
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
import base64 from 'base64-js'
|
||||
|
||||
const opensslBanner = new Uint8Array(new TextEncoder('utf8').encode('Salted__'))
|
||||
const opensslBanner = new Uint8Array(new TextEncoder().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
|
||||
* @param {string} cipherText Encrypted data in base64 encoded form
|
||||
* @param {string} passphrase Encryption passphrase used for key-derivation
|
||||
*/
|
||||
function dec(cipherText, passphrase) {
|
||||
function dec(cipherText: string, passphrase: string): Promise<string> {
|
||||
return decrypt(passphrase, cipherText)
|
||||
}
|
||||
/**
|
||||
|
@ -17,7 +16,7 @@ function dec(cipherText, passphrase) {
|
|||
* @param {String} passphrase Encryption passphrase used for key-derivation
|
||||
* @returns String
|
||||
*/
|
||||
function enc(plainText, passphrase) {
|
||||
function enc(plainText: string, passphrase: string): Promise<string> {
|
||||
return encrypt(passphrase, generateSalt(), plainText)
|
||||
}
|
||||
|
||||
|
@ -26,7 +25,7 @@ function enc(plainText, passphrase) {
|
|||
* @param {String} encData Encrypted data in base64 encoded form
|
||||
* @returns String
|
||||
*/
|
||||
function decrypt(passphrase, encData) {
|
||||
function decrypt(passphrase: string, encData: string): Promise<string> {
|
||||
const data = base64.toByteArray(encData)
|
||||
|
||||
return deriveKey(passphrase, data.slice(8, 16))
|
||||
|
@ -40,8 +39,8 @@ function decrypt(passphrase, encData) {
|
|||
* @param {Uint8Array} salt
|
||||
* @returns Object
|
||||
*/
|
||||
function deriveKey(passphrase, salt) {
|
||||
return window.crypto.subtle.importKey('raw', new TextEncoder('utf8').encode(passphrase), 'PBKDF2', false, ['deriveBits'])
|
||||
function deriveKey(passphrase: string, salt: Uint8Array): any {
|
||||
return window.crypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), 'PBKDF2', false, ['deriveBits'])
|
||||
.then(passwordKey => window.crypto.subtle.deriveBits({ ...pbkdf2Params, salt }, passwordKey, 384))
|
||||
.then(key => window.crypto.subtle.importKey('raw', key.slice(0, 32), { name: 'AES-CBC' }, false, ['encrypt', 'decrypt'])
|
||||
.then(aesKey => ({ iv: key.slice(32, 48), key: aesKey })))
|
||||
|
@ -53,9 +52,9 @@ function deriveKey(passphrase, salt) {
|
|||
* @param {String} plainData Data to encrypt
|
||||
* @returns String
|
||||
*/
|
||||
function encrypt(passphrase, salt, plainData) {
|
||||
function encrypt(passphrase: string, salt: Uint8Array, plainData: string): Promise<string> {
|
||||
return deriveKey(passphrase, salt)
|
||||
.then(({ iv, key }) => window.crypto.subtle.encrypt({ iv, name: 'AES-CBC' }, key, new TextEncoder('utf8').encode(plainData)))
|
||||
.then(({ iv, key }) => window.crypto.subtle.encrypt({ iv, name: 'AES-CBC' }, key, new TextEncoder().encode(plainData)))
|
||||
.then(encData => new Uint8Array([...opensslBanner, ...salt, ...new Uint8Array(encData)]))
|
||||
.then(data => base64.fromByteArray(data))
|
||||
}
|
||||
|
@ -65,7 +64,7 @@ function encrypt(passphrase, salt, plainData) {
|
|||
*
|
||||
* @returns Uint8Array
|
||||
*/
|
||||
function generateSalt() {
|
||||
function generateSalt(): Uint8Array {
|
||||
const salt = new Uint8Array(8) // Salt MUST consist of 8 byte
|
||||
return window.crypto.getRandomValues(salt)
|
||||
}
|
14
src/global.d.ts
vendored
Normal file
14
src/global.d.ts
vendored
Normal file
|
@ -0,0 +1,14 @@
|
|||
export { }
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
getTheme: () => string;
|
||||
getThemeFromStorage: () => string;
|
||||
maxSecretExpire: number;
|
||||
OTSCustomize: any;
|
||||
refreshTheme: () => void;
|
||||
setTheme: (string) => void;
|
||||
useFormalLanguage: boolean;
|
||||
version: string;
|
||||
}
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
/**
|
||||
* Converts number of bytes into human format (524288 -> "512.0 KiB")
|
||||
* @param {Number} bytes Byte amount to convert into human readable format
|
||||
* @returns String
|
||||
* @param {number} bytes Byte amount to convert into human readable format
|
||||
* @returns string
|
||||
*/
|
||||
function bytesToHuman(bytes) {
|
||||
function bytesToHuman(bytes: number): string {
|
||||
for (const t of [
|
||||
{ thresh: 1024 * 1024, unit: 'MiB' },
|
||||
{ thresh: 1024, unit: 'KiB' },
|
16
src/i18n.ts
Normal file
16
src/i18n.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { createI18n } from "vue-i18n";
|
||||
|
||||
import messages from './langs/langs.js'
|
||||
|
||||
const cookieSet = Object.fromEntries(document.cookie.split('; ')
|
||||
.map(el => el.split('=')
|
||||
.map(el => decodeURIComponent(el))))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
fallbackLocale: 'en',
|
||||
locale: cookieSet.lang || navigator?.language || 'en',
|
||||
messages,
|
||||
})
|
||||
|
||||
export default i18n
|
|
@ -1,21 +0,0 @@
|
|||
/* lato-regular - latin-ext_latin */
|
||||
@font-face {
|
||||
font-family: 'Lato';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('latofont/lato-v20-latin-ext_latin-regular.woff2') format('woff2'); /* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
}
|
||||
/* lato-italic - latin-ext_latin */
|
||||
@font-face {
|
||||
font-family: 'Lato';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
src: url('latofont/lato-v20-latin-ext_latin-italic.woff2') format('woff2'); /* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
}
|
||||
/* lato-700 - latin-ext_latin */
|
||||
@font-face {
|
||||
font-family: 'Lato';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url('latofont/lato-v20-latin-ext_latin-700.woff2') format('woff2'); /* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
}
|
|
@ -1,94 +0,0 @@
|
|||
Copyright (c) 2010-2015, Łukasz Dziedzic (dziedzic@typoland.com),
|
||||
with Reserved Font Name Lato.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
84
src/main.js
84
src/main.js
|
@ -1,84 +0,0 @@
|
|||
/* eslint-disable sort-imports */
|
||||
/* global version */
|
||||
|
||||
import Vue from 'vue'
|
||||
import VueI18n from 'vue-i18n'
|
||||
import VueRouter from 'vue-router'
|
||||
|
||||
import './style.scss'
|
||||
|
||||
import app from './app.vue'
|
||||
import messages from './langs/langs.js'
|
||||
import router from './router'
|
||||
|
||||
Vue.use(VueI18n)
|
||||
Vue.use(VueRouter)
|
||||
|
||||
const cookieSet = Object.fromEntries(document.cookie.split('; ')
|
||||
.map(el => el.split('=')
|
||||
.map(el => decodeURIComponent(el))))
|
||||
|
||||
const i18n = new VueI18n({
|
||||
fallbackLocale: 'en',
|
||||
locale: cookieSet.lang || navigator?.language || 'en',
|
||||
messages,
|
||||
})
|
||||
|
||||
Vue.mixin({
|
||||
beforeRouteLeave(_to, _from, next) {
|
||||
// Before leaving the component, reset the errors the component displayed
|
||||
this.$emit('error', null)
|
||||
next()
|
||||
},
|
||||
})
|
||||
|
||||
new Vue({
|
||||
components: { app },
|
||||
|
||||
computed: {
|
||||
isSecureEnvironment() {
|
||||
return Boolean(window.crypto.subtle)
|
||||
},
|
||||
},
|
||||
|
||||
data: {
|
||||
customize: {},
|
||||
theme: 'auto',
|
||||
version,
|
||||
},
|
||||
|
||||
el: '#app',
|
||||
i18n,
|
||||
|
||||
methods: {
|
||||
navigate(to) {
|
||||
this.$router.replace(to)
|
||||
.catch(err => {
|
||||
if (VueRouter.isNavigationFailure(err, VueRouter.NavigationFailureType.duplicated)) {
|
||||
// Hide duplicate nav errors
|
||||
return
|
||||
}
|
||||
throw err
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.customize = window.OTSCustomize
|
||||
this.theme = window.getThemeFromStorage()
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => {
|
||||
window.refreshTheme()
|
||||
})
|
||||
},
|
||||
|
||||
name: 'OTS',
|
||||
render: createElement => createElement('app'),
|
||||
router,
|
||||
|
||||
watch: {
|
||||
theme(to) {
|
||||
window.setTheme(to)
|
||||
},
|
||||
},
|
||||
})
|
18
src/main.ts
Normal file
18
src/main.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { createApp, h } from 'vue'
|
||||
|
||||
import './style.scss'
|
||||
import '@fortawesome/fontawesome-free/css/all.css' // All FA free icons
|
||||
import appView from './app.vue'
|
||||
|
||||
import i18n from './i18n.ts'
|
||||
import router from './router.ts'
|
||||
|
||||
const app = createApp({
|
||||
name: 'OTS',
|
||||
render() { return h(appView) },
|
||||
})
|
||||
|
||||
app.use(i18n)
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
|
@ -1,10 +1,10 @@
|
|||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import AppCreate from './components/create.vue'
|
||||
import AppDisplayURL from './components/display-url.vue'
|
||||
import AppExplanation from './components/explanation.vue'
|
||||
import AppSecretDisplay from './components/secret-display.vue'
|
||||
|
||||
import VueRouter from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
component: AppCreate,
|
||||
|
@ -37,8 +37,8 @@ const routes = [
|
|||
},
|
||||
]
|
||||
|
||||
const router = new VueRouter({
|
||||
mode: 'abstract',
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes,
|
||||
})
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
// Force local fonts
|
||||
$web-font-path: '';
|
||||
|
||||
@import "../node_modules/bootstrap/dist/css/bootstrap.css";
|
||||
@import "lato";
|
||||
@use "../node_modules/bootstrap/dist/css/bootstrap.css";
|
||||
|
||||
:root {
|
||||
textarea {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue