mirror of
https://github.com/cupcakearmy/cryptgeon.git
synced 2025-09-05 17:00:39 +00:00
CLI (#84)
* move to packages * update deps * update deps * actions maintenance * don't use blob * cli * fix default import * use synthetic default imports * remove comment * cli packaging * node 18 guard * packages * build system * testing * test pipeline * pipelines * changelog * version bump * update locales * update deps * update deps * update dependecies
This commit is contained in:
461
packages/backend/Cargo.lock
generated
461
packages/backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cryptgeon"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0-beta.4"
|
||||
authors = ["cupcakearmy <hi@nicco.io>"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -8,6 +8,9 @@ edition = "2021"
|
||||
name = "cryptgeon"
|
||||
path = "src/main.rs"
|
||||
|
||||
[registries.crates-io]
|
||||
protocol = "sparse"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"private": true,
|
||||
"name": "@cryptgeon/backend",
|
||||
"scripts": {
|
||||
"dev": "cargo watch -x 'run --bin cryptgeon'",
|
||||
"build": "cargo build --release",
|
||||
|
37
packages/cli/package.json
Normal file
37
packages/cli/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"version": "2.3.0-beta.4",
|
||||
"name": "cryptgeon",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "./scripts/build.js --watch",
|
||||
"build": "./scripts/build.js",
|
||||
"package": "./scripts/package.js",
|
||||
"bin": "run-s build package",
|
||||
"prepublishOnly": "run-s build"
|
||||
},
|
||||
"main": "./dist/index.cjs",
|
||||
"bin": {
|
||||
"cryptgeon": "./dist/index.cjs"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@commander-js/extra-typings": "^10.0.3",
|
||||
"@cryptgeon/shared": "workspace:*",
|
||||
"@types/inquirer": "^9.0.3",
|
||||
"@types/mime": "^3.0.1",
|
||||
"@types/node": "^20.1.3",
|
||||
"commander": "^10.0.1",
|
||||
"esbuild": "^0.17.19",
|
||||
"inquirer": "^9.2.2",
|
||||
"mime": "^3.0.0",
|
||||
"occulto": "^2.0.1",
|
||||
"pkg": "^5.8.1",
|
||||
"pretty-bytes": "^6.1.0",
|
||||
"typescript": "^5.0.4"
|
||||
}
|
||||
}
|
17
packages/cli/scripts/build.js
Executable file
17
packages/cli/scripts/build.js
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { build, context } from 'esbuild'
|
||||
import pkg from '../package.json' assert { type: 'json' }
|
||||
|
||||
const options = {
|
||||
entryPoints: ['./src/index.ts'],
|
||||
bundle: true,
|
||||
minify: true,
|
||||
platform: 'node',
|
||||
outfile: './dist/index.cjs',
|
||||
define: { VERSION: `"${pkg.version}"` },
|
||||
}
|
||||
|
||||
const watch = process.argv.slice(2)[0] === '--watch'
|
||||
if (watch) (await context(options)).watch()
|
||||
else await build(options)
|
17
packages/cli/scripts/package.js
Executable file
17
packages/cli/scripts/package.js
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { exec } from 'pkg'
|
||||
|
||||
const targets = [
|
||||
'node18-macos-arm64',
|
||||
'node18-macos-x64',
|
||||
'node18-linux-arm64',
|
||||
'node18-linux-x64',
|
||||
'node18-win-arm64',
|
||||
'node18-win-x64',
|
||||
]
|
||||
|
||||
for (const target of targets) {
|
||||
console.log(`🚀 Building ${target}`)
|
||||
await exec(['./dist/index.cjs', '--target', target, '--output', `./bin/${target.replace('node18', 'cryptgeon')}`])
|
||||
}
|
62
packages/cli/src/download.ts
Normal file
62
packages/cli/src/download.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Adapters, get, info, setBase } from '@cryptgeon/shared'
|
||||
import inquirer from 'inquirer'
|
||||
import { access, constants, writeFile } from 'node:fs/promises'
|
||||
import { basename, resolve } from 'node:path'
|
||||
import { Hex } from 'occulto'
|
||||
import pretty from 'pretty-bytes'
|
||||
|
||||
import { exit } from './utils'
|
||||
|
||||
export async function download(url: URL) {
|
||||
setBase(url.origin)
|
||||
const id = url.pathname.split('/')[2]
|
||||
await info(id).catch(() => exit('Note does not exist or is expired'))
|
||||
const note = await get(id)
|
||||
|
||||
const password = url.hash.slice(1)
|
||||
const key = Hex.decode(password)
|
||||
|
||||
const couldNotDecrypt = () => exit('Could not decrypt note. Probably an invalid password')
|
||||
switch (note.meta.type) {
|
||||
case 'file':
|
||||
const files = await Adapters.Files.decrypt(note.contents, key).catch(couldNotDecrypt)
|
||||
if (!files) {
|
||||
exit('No files found in note')
|
||||
return
|
||||
}
|
||||
const { names } = await inquirer.prompt([
|
||||
{
|
||||
type: 'checkbox',
|
||||
message: 'What files should be saved?',
|
||||
name: 'names',
|
||||
choices: files.map((file) => ({
|
||||
value: file.name,
|
||||
name: `${file.name} - ${file.type} - ${pretty(file.size, { binary: true })}`,
|
||||
checked: true,
|
||||
})),
|
||||
},
|
||||
])
|
||||
|
||||
const selected = files.filter((file) => names.includes(file.name))
|
||||
|
||||
if (!selected.length) exit('No files selected')
|
||||
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
let filename = resolve(file.name)
|
||||
try {
|
||||
// If exists -> prepend timestamp to not overwrite the current file
|
||||
await access(filename, constants.R_OK)
|
||||
filename = resolve(`${Date.now()}-${file.name}`)
|
||||
} catch {}
|
||||
await writeFile(filename, file.contents)
|
||||
console.log(`Saved: ${basename(filename)}`)
|
||||
})
|
||||
)
|
||||
break
|
||||
case 'text':
|
||||
const plaintext = await Adapters.Text.decrypt(note.contents, key).catch(couldNotDecrypt)
|
||||
console.log(plaintext)
|
||||
break
|
||||
}
|
||||
}
|
93
packages/cli/src/index.ts
Normal file
93
packages/cli/src/index.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Argument, Option, program } from '@commander-js/extra-typings'
|
||||
import { setBase, status } from '@cryptgeon/shared'
|
||||
import prettyBytes from 'pretty-bytes'
|
||||
|
||||
import { download } from './download.js'
|
||||
import { parseFile, parseNumber } from './parsers.js'
|
||||
import { uploadFiles, uploadText } from './upload.js'
|
||||
import { exit } from './utils.js'
|
||||
|
||||
const defaultServer = process.env['CRYPTGEON_SERVER'] || 'https://cryptgeon.org'
|
||||
const server = new Option('-s --server <url>', 'the cryptgeon server to use').default(defaultServer)
|
||||
const files = new Argument('<file...>', 'Files to be sent').argParser(parseFile)
|
||||
const text = new Argument('<text>', 'Text content of the note')
|
||||
const password = new Option('-p --password <string>', 'manually set a password')
|
||||
const url = new Argument('<url>', 'The url to open')
|
||||
const views = new Option('-v --views <number>', 'Amount of views before getting destroyed').argParser(parseNumber)
|
||||
const minutes = new Option('-m --minutes <number>', 'Minutes before the note expires').argParser(parseNumber)
|
||||
|
||||
// Node 18 guard
|
||||
parseInt(process.version.slice(1).split(',')[0]) < 18 && exit('Node 18 or higher is required')
|
||||
|
||||
// @ts-ignore
|
||||
const version: string = VERSION
|
||||
|
||||
async function checkConstrains(constrains: { views?: number; minutes?: number }) {
|
||||
const { views, minutes } = constrains
|
||||
if (views && minutes) exit('cannot set view and minutes constrains simultaneously')
|
||||
if (!views && !minutes) constrains.views = 1
|
||||
|
||||
const response = await status()
|
||||
if (views && views > response.max_views)
|
||||
exit(`Only a maximum of ${response.max_views} views allowed. ${views} given.`)
|
||||
if (minutes && minutes > response.max_expiration)
|
||||
exit(`Only a maximum of ${response.max_expiration} minutes allowed. ${minutes} given.`)
|
||||
}
|
||||
|
||||
program.name('cryptgeon').version(version).configureHelp({ showGlobalOptions: true })
|
||||
|
||||
program
|
||||
.command('info')
|
||||
.addOption(server)
|
||||
.action(async (options) => {
|
||||
setBase(options.server)
|
||||
const response = await status()
|
||||
const formatted = {
|
||||
...response,
|
||||
max_size: prettyBytes(response.max_size),
|
||||
}
|
||||
for (const key of Object.keys(formatted)) {
|
||||
if (key.startsWith('theme_')) delete formatted[key as keyof typeof formatted]
|
||||
}
|
||||
console.table(formatted)
|
||||
})
|
||||
|
||||
const send = program.command('send')
|
||||
send
|
||||
.command('file')
|
||||
.addArgument(files)
|
||||
.addOption(server)
|
||||
.addOption(views)
|
||||
.addOption(minutes)
|
||||
.action(async (files, options) => {
|
||||
setBase(options.server!)
|
||||
await checkConstrains(options)
|
||||
await uploadFiles(files, { views: options.views, expiration: options.minutes })
|
||||
})
|
||||
send
|
||||
.command('text')
|
||||
.addArgument(text)
|
||||
.addOption(server)
|
||||
.addOption(views)
|
||||
.addOption(minutes)
|
||||
.action(async (text, options) => {
|
||||
setBase(options.server!)
|
||||
await checkConstrains(options)
|
||||
await uploadText(text, { views: options.views, expiration: options.minutes })
|
||||
})
|
||||
|
||||
program
|
||||
.command('open')
|
||||
.addArgument(url)
|
||||
.action(async (note, options) => {
|
||||
try {
|
||||
const url = new URL(note)
|
||||
await download(url)
|
||||
} catch {
|
||||
exit('Invalid URL')
|
||||
}
|
||||
})
|
||||
|
||||
program.parse()
|
27
packages/cli/src/parsers.ts
Normal file
27
packages/cli/src/parsers.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { InvalidArgumentError, InvalidOptionArgumentError } from '@commander-js/extra-typings'
|
||||
import { accessSync, constants } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
export function parseFile(value: string, before: string[] = []) {
|
||||
try {
|
||||
const file = resolve(value)
|
||||
accessSync(file, constants.R_OK)
|
||||
return [...before, file]
|
||||
} catch {
|
||||
throw new InvalidArgumentError('cannot access file')
|
||||
}
|
||||
}
|
||||
|
||||
export function parseURL(value: string, _: URL): URL {
|
||||
try {
|
||||
return new URL(value)
|
||||
} catch {
|
||||
throw new InvalidArgumentError('is not a valid url')
|
||||
}
|
||||
}
|
||||
|
||||
export function parseNumber(value: string, _: number): number {
|
||||
const n = parseInt(value, 10)
|
||||
if (isNaN(n)) throw new InvalidOptionArgumentError('invalid number')
|
||||
return n
|
||||
}
|
48
packages/cli/src/upload.ts
Normal file
48
packages/cli/src/upload.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { basename } from 'node:path'
|
||||
|
||||
import { Adapters, BASE, create, FileDTO, Note } from '@cryptgeon/shared'
|
||||
import mime from 'mime'
|
||||
import { AES, Hex, TypedArray } from 'occulto'
|
||||
|
||||
import { exit } from './utils.js'
|
||||
|
||||
type UploadOptions = Pick<Note, 'views' | 'expiration'>
|
||||
|
||||
export async function upload(key: TypedArray, note: Note) {
|
||||
try {
|
||||
const result = await create(note)
|
||||
const password = Hex.encode(key)
|
||||
const url = `${BASE}/note/${result.id}#${password}`
|
||||
console.log(`Note created under:\n\n${url}`)
|
||||
} catch {
|
||||
exit('Could not create note')
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadFiles(paths: string[], options: UploadOptions) {
|
||||
const key = await AES.generateKey()
|
||||
const files: FileDTO[] = await Promise.all(
|
||||
paths.map(async (path) => {
|
||||
const data = new Uint8Array(await readFile(path))
|
||||
const stats = await stat(path)
|
||||
const extension = path.substring(path.indexOf('.') + 1)
|
||||
const type = mime.getType(extension) ?? 'application/octet-stream'
|
||||
return {
|
||||
name: basename(path),
|
||||
size: stats.size,
|
||||
contents: data,
|
||||
type,
|
||||
} satisfies FileDTO
|
||||
})
|
||||
)
|
||||
|
||||
const contents = await Adapters.Files.encrypt(files, key)
|
||||
await upload(key, { ...options, contents, meta: { type: 'file' } })
|
||||
}
|
||||
|
||||
export async function uploadText(text: string, options: UploadOptions) {
|
||||
const key = await AES.generateKey()
|
||||
const contents = await Adapters.Text.encrypt(text, key)
|
||||
await upload(key, { ...options, contents, meta: { type: 'text' } })
|
||||
}
|
6
packages/cli/src/utils.ts
Normal file
6
packages/cli/src/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { exit as exitNode } from 'node:process'
|
||||
|
||||
export function exit(message: string) {
|
||||
console.error(message)
|
||||
exitNode(1)
|
||||
}
|
10
packages/cli/tsconfig.json
Normal file
10
packages/cli/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"module": "es2022",
|
||||
"moduleResolution": "node",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
}
|
||||
}
|
@@ -1 +0,0 @@
|
||||
engine-strict=true
|
@@ -41,7 +41,7 @@
|
||||
},
|
||||
"explanation": "pulse abajo para mostrar y borrar la nota si el contador ha llegado a su límite",
|
||||
"show_note": "mostrar nota",
|
||||
"warning_will_not_see_again": " <b>no</b> tendrás la oportunidad de volver a ver la nota.",
|
||||
"warning_will_not_see_again": "<b>no</b> tendrás la oportunidad de volver a ver la nota.",
|
||||
"download_all": "descargar todo"
|
||||
},
|
||||
"file_upload": {
|
||||
|
@@ -41,7 +41,7 @@
|
||||
},
|
||||
"explanation": "clicca sotto per mostrare e cancellare la nota se il contatore ha raggiunto il suo limite",
|
||||
"show_note": "mostra la nota",
|
||||
"warning_will_not_see_again": " <b>non</b> avrete la possibilità di rivedere la nota.",
|
||||
"warning_will_not_see_again": "<b>non</b> avrete la possibilità di rivedere la nota.",
|
||||
"download_all": "scarica tutti"
|
||||
},
|
||||
"file_upload": {
|
||||
|
51
packages/frontend/locales/ja.json
Normal file
51
packages/frontend/locales/ja.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"common": {
|
||||
"note": "新しいメモ",
|
||||
"file": "ファイル",
|
||||
"advanced": "アドバンスド",
|
||||
"create": "作成",
|
||||
"loading": "読み込み中",
|
||||
"mode": "モード",
|
||||
"views": "{n, plural, =0 {表示可能な時間} =1 { 1 ビュー} other {#ビュー}}",
|
||||
"minutes": "{n, plural, =0 {有効期間(分)} =1 {1 分} other {# 分}}",
|
||||
"max": "マックス",
|
||||
"share_link": "共有リンク",
|
||||
"copy_clipboard": "クリップボードにコピーする",
|
||||
"copied_to_clipboard": "クリップボードにコピーされました",
|
||||
"encrypting": "暗号化",
|
||||
"decrypting": "復号化",
|
||||
"uploading": "アップロード中",
|
||||
"downloading": "ダウンロード中",
|
||||
"qr_code": "QRコード"
|
||||
},
|
||||
"home": {
|
||||
"intro": "<i>完全に暗号化された</i> 、安全なメモやファイルをワンクリックで簡単に送信できます。メモを作成してリンクを共有するだけです。",
|
||||
"explanation": "メモは{type}後に期限切れになり、破棄されます。",
|
||||
"new_note": "新しいメモ",
|
||||
"new_note_notice": "<b>可用性: </b> <br />すべてが RAM に保持されるため、メモが保存されるとは限りません。いっぱいになると、最も古いメモが削除されます。 <br /> (大丈夫だと思いますが、ご了承ください。)",
|
||||
"errors": {
|
||||
"note_to_big": "メモを作成できませんでした。メモが大きすぎる",
|
||||
"note_error": "メモを作成できませんでした。もう一度お試しください。",
|
||||
"max": "最大ファイルサイズ: {n}",
|
||||
"empty_content": "メモは空です。"
|
||||
},
|
||||
"messages": {
|
||||
"note_created": "メモが作成されました。"
|
||||
}
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
"not_found": "メモが見つからないか、既に削除されています。",
|
||||
"decryption_failed": "パスワードエラー!不完全なリンクを貼り付けたり、暗号を解読しようとしたりしている可能性があります!しかし、いずれにしても、この暗号は破棄されました!",
|
||||
"unsupported_type": "サポートされていないメモ タイプです。"
|
||||
},
|
||||
"explanation": "カウンターが上限に達した場合、ノートの表示と削除を行うには、以下をクリックします。",
|
||||
"show_note": "メモを表示",
|
||||
"warning_will_not_see_again": "あなた <b>できません</b> このノートをもう一度見る",
|
||||
"download_all": "すべてダウンロード"
|
||||
},
|
||||
"file_upload": {
|
||||
"selected_files": "選択したファイル",
|
||||
"no_files_selected": "ファイルが選択されていません"
|
||||
}
|
||||
}
|
@@ -1,51 +1,51 @@
|
||||
{
|
||||
"common": {
|
||||
"note": "заметка",
|
||||
"file": "файл",
|
||||
"advanced": "расширенные",
|
||||
"create": "создать",
|
||||
"loading": "загрузка",
|
||||
"mode": "режим",
|
||||
"views": "{n, plural, =0 {просмотры} =1 {1 просмотр} other {# просмотры}}",
|
||||
"minutes": "{n, plural, =0 {минут} =1 {1 минута} other {# минуты}}",
|
||||
"max": "макс",
|
||||
"share_link": "поделиться ссылкой",
|
||||
"copy_clipboard": "скопировать в буфер обмена",
|
||||
"copied_to_clipboard": "скопировано в буфер обмена",
|
||||
"encrypting": "шифрование",
|
||||
"decrypting": "расшифровка",
|
||||
"uploading": "загрузка",
|
||||
"downloading": "скачивание",
|
||||
"qr_code": "qr код"
|
||||
},
|
||||
"home": {
|
||||
"intro": "Легко отправляйте <i>полностью зашифрованные</i> защищенные заметки или файлы одним щелчком мыши. Просто создайте заметку и поделитесь ссылкой.",
|
||||
"explanation": "заметка истечет и будет уничтожена после {type}.",
|
||||
"new_note": "новая заметка",
|
||||
"new_note_notice": "<b>availability:</b><br />the note is not guaranteed to be stored as everything is kept in ram, if it fills up the oldest notes will be removed.<br />(you probably will be fine, just be warned.)",
|
||||
"errors": {
|
||||
"note_to_big": "нельзя создать новую заметку. заметка слишком большая",
|
||||
"note_error": "нельзя создать новую заметку. пожалйста попробуйте позднее.",
|
||||
"max": "макс: {n}",
|
||||
"empty_content": "пустая заметка."
|
||||
},
|
||||
"messages": {
|
||||
"note_created": "заметка создана."
|
||||
}
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
"not_found": "заметка не найдена или была удалена.",
|
||||
"decryption_failed": "неправильный пароль. не смог расшифровать. возможно ссылка битая. записка уничтожена.",
|
||||
"unsupported_type": "неподдерживаемый тип заметки."
|
||||
},
|
||||
"explanation": "щелкните ниже, чтобы показать и удалить примечание, если счетчик достиг предела",
|
||||
"show_note": "показать заметку",
|
||||
"warning_will_not_see_again": "вы <b>не сможете</b> больше просмотреть заметку.",
|
||||
"download_all": "скачать всё"
|
||||
},
|
||||
"file_upload": {
|
||||
"selected_files": "Выбранные файлы",
|
||||
"no_files_selected": "Файлы не выбраны"
|
||||
}
|
||||
}
|
||||
{
|
||||
"common": {
|
||||
"note": "заметка",
|
||||
"file": "файл",
|
||||
"advanced": "расширенные",
|
||||
"create": "создать",
|
||||
"loading": "загрузка",
|
||||
"mode": "режим",
|
||||
"views": "{n, plural, =0 {просмотры} =1 {1 просмотр} other {# просмотры}}",
|
||||
"minutes": "{n, plural, =0 {минут} =1 {1 минута} other {# минуты}}",
|
||||
"max": "макс",
|
||||
"share_link": "поделиться ссылкой",
|
||||
"copy_clipboard": "скопировать в буфер обмена",
|
||||
"copied_to_clipboard": "скопировано в буфер обмена",
|
||||
"encrypting": "шифрование",
|
||||
"decrypting": "расшифровка",
|
||||
"uploading": "загрузка",
|
||||
"downloading": "скачивание",
|
||||
"qr_code": "qr код"
|
||||
},
|
||||
"home": {
|
||||
"intro": "Легко отправляйте <i>полностью зашифрованные</i> защищенные заметки или файлы одним щелчком мыши. Просто создайте заметку и поделитесь ссылкой.",
|
||||
"explanation": "заметка истечет и будет уничтожена после {type}.",
|
||||
"new_note": "новая заметка",
|
||||
"new_note_notice": "<b>availability:</b><br />the note is not guaranteed to be stored as everything is kept in ram, if it fills up the oldest notes will be removed.<br />(you probably will be fine, just be warned.)",
|
||||
"errors": {
|
||||
"note_to_big": "нельзя создать новую заметку. заметка слишком большая",
|
||||
"note_error": "нельзя создать новую заметку. пожалйста попробуйте позднее.",
|
||||
"max": "макс: {n}",
|
||||
"empty_content": "пустая заметка."
|
||||
},
|
||||
"messages": {
|
||||
"note_created": "заметка создана."
|
||||
}
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
"not_found": "заметка не найдена или была удалена.",
|
||||
"decryption_failed": "неправильный пароль. не смог расшифровать. возможно ссылка битая. записка уничтожена.",
|
||||
"unsupported_type": "неподдерживаемый тип заметки."
|
||||
},
|
||||
"explanation": "щелкните ниже, чтобы показать и удалить примечание, если счетчик достиг предела",
|
||||
"show_note": "показать заметку",
|
||||
"warning_will_not_see_again": "вы <b>не сможете</b> больше просмотреть заметку.",
|
||||
"download_all": "скачать всё"
|
||||
},
|
||||
"file_upload": {
|
||||
"selected_files": "Выбранные файлы",
|
||||
"no_files_selected": "Файлы не выбраны"
|
||||
}
|
||||
}
|
||||
|
@@ -48,4 +48,4 @@
|
||||
"selected_files": "已选中的文件",
|
||||
"no_files_selected": "没有文件被选中"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "@cryptgeon/web",
|
||||
"scripts": {
|
||||
"postinstall": "svelte-kit sync",
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
@@ -11,29 +13,29 @@
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@lokalise/node-api": "^9.5.0",
|
||||
"@sveltejs/adapter-static": "^1.0.2",
|
||||
"@sveltejs/kit": "^1.1.0",
|
||||
"@types/dompurify": "^2.4.0",
|
||||
"@lokalise/node-api": "^9.8.0",
|
||||
"@sveltejs/adapter-static": "^2.0.2",
|
||||
"@sveltejs/kit": "^1.16.3",
|
||||
"@types/dompurify": "^3.0.2",
|
||||
"@types/file-saver": "^2.0.5",
|
||||
"@zerodevx/svelte-toast": "^0.7.2",
|
||||
"@zerodevx/svelte-toast": "^0.9.3",
|
||||
"adm-zip": "^0.5.10",
|
||||
"dotenv": "^16.0.3",
|
||||
"svelte": "^3.55.1",
|
||||
"svelte-check": "^2.10.3",
|
||||
"svelte-intl-precompile": "^0.10.1",
|
||||
"svelte-preprocess": "^4.10.7",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^4.9.4",
|
||||
"vite": "^4.0.4"
|
||||
"svelte": "^3.59.1",
|
||||
"svelte-check": "^3.3.2",
|
||||
"svelte-intl-precompile": "^0.12.1",
|
||||
"tslib": "^2.5.0",
|
||||
"typescript": "^5.0.4",
|
||||
"vite": "^4.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cryptgeon/shared": "workspace:*",
|
||||
"@fontsource/fira-mono": "^4.5.10",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"dompurify": "^2.4.3",
|
||||
"dompurify": "^3.0.3",
|
||||
"file-saver": "^2.0.5",
|
||||
"occulto": "2.0.0",
|
||||
"pretty-bytes": "^6.0.0",
|
||||
"occulto": "^2.0.1",
|
||||
"pretty-bytes": "^6.1.0",
|
||||
"qrious": "^4.0.2"
|
||||
}
|
||||
}
|
||||
|
@@ -5,10 +5,15 @@ import https from 'https'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
function exit(msg) {
|
||||
console.error(msg)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const apiKey = process.env.LOKALISE_API_KEY
|
||||
const project_id = process.env.LOKALISE_PROJECT
|
||||
if (!apiKey) throw new Error('No API Key set for Lokalize! Set with "LOKALISE_API_KEY"')
|
||||
if (!project_id) throw new Error('No project id set for Lokalize! Set with "LOKALISE_PROJECT"')
|
||||
if (!apiKey) exit('No API Key set for Lokalize! Set with "LOKALISE_API_KEY"')
|
||||
if (!project_id) exit('No project id set for Lokalize! Set with "LOKALISE_PROJECT"')
|
||||
const client = new LokaliseApi({ apiKey })
|
||||
|
||||
const WGet = (url) =>
|
||||
|
9
packages/frontend/src/app.d.ts
vendored
Normal file
9
packages/frontend/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
// and what to do when importing types
|
||||
declare namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface Platform {}
|
||||
}
|
3
packages/frontend/src/global.d.ts
vendored
3
packages/frontend/src/global.d.ts
vendored
@@ -1,3 +0,0 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
@@ -1,60 +0,0 @@
|
||||
import { AES, Bytes, type TypedArray } from 'occulto'
|
||||
import type { EncryptedFileDTO, FileDTO } from './api'
|
||||
|
||||
abstract class CryptAdapter<T> {
|
||||
abstract encrypt(plaintext: T, key: TypedArray): Promise<string>
|
||||
abstract decrypt(ciphertext: string, key: TypedArray): Promise<T>
|
||||
}
|
||||
|
||||
class CryptTextAdapter implements CryptAdapter<string> {
|
||||
async encrypt(plaintext: string, key: TypedArray) {
|
||||
return await AES.encrypt(Bytes.encode(plaintext), key)
|
||||
}
|
||||
async decrypt(ciphertext: string, key: TypedArray) {
|
||||
return Bytes.decode(await AES.decrypt(ciphertext, key))
|
||||
}
|
||||
}
|
||||
|
||||
class CryptBlobAdapter implements CryptAdapter<Blob> {
|
||||
async encrypt(plaintext: Blob, key: TypedArray) {
|
||||
return await AES.encrypt(new Uint8Array(await plaintext.arrayBuffer()), key)
|
||||
}
|
||||
|
||||
async decrypt(ciphertext: string, key: TypedArray) {
|
||||
const plaintext = await AES.decrypt(ciphertext, key)
|
||||
return new Blob([plaintext], { type: 'application/octet-stream' })
|
||||
}
|
||||
}
|
||||
|
||||
class CryptFilesAdapter implements CryptAdapter<FileDTO[]> {
|
||||
async encrypt(plaintext: FileDTO[], key: TypedArray) {
|
||||
const adapter = new CryptBlobAdapter()
|
||||
const data: Promise<EncryptedFileDTO>[] = plaintext.map(async (file) => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
contents: await adapter.encrypt(file.contents, key),
|
||||
}))
|
||||
return JSON.stringify(await Promise.all(data))
|
||||
}
|
||||
|
||||
async decrypt(ciphertext: string, key: TypedArray) {
|
||||
const adapter = new CryptBlobAdapter()
|
||||
const data: EncryptedFileDTO[] = JSON.parse(ciphertext)
|
||||
const files: FileDTO[] = await Promise.all(
|
||||
data.map(async (file) => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
contents: await adapter.decrypt(file.contents, key),
|
||||
}))
|
||||
)
|
||||
return files
|
||||
}
|
||||
}
|
||||
|
||||
export const Adapters = {
|
||||
Text: new CryptTextAdapter(),
|
||||
Blob: new CryptBlobAdapter(),
|
||||
Files: new CryptFilesAdapter(),
|
||||
}
|
@@ -1,78 +0,0 @@
|
||||
export type NoteMeta = { type: 'text' | 'file' }
|
||||
|
||||
export type Note = {
|
||||
contents: string
|
||||
meta: NoteMeta
|
||||
views?: number
|
||||
expiration?: number
|
||||
}
|
||||
export type NoteInfo = {}
|
||||
export type NotePublic = Pick<Note, 'contents' | 'meta'>
|
||||
export type NoteCreate = Omit<Note, 'meta'> & { meta: string }
|
||||
|
||||
export type FileDTO = Pick<File, 'name' | 'size' | 'type'> & {
|
||||
contents: Blob
|
||||
}
|
||||
|
||||
export type EncryptedFileDTO = Omit<FileDTO, 'contents'> & {
|
||||
contents: string
|
||||
}
|
||||
|
||||
type CallOptions = {
|
||||
url: string
|
||||
method: string
|
||||
body?: any
|
||||
}
|
||||
|
||||
export class PayloadToLargeError extends Error {}
|
||||
|
||||
export async function call(options: CallOptions) {
|
||||
const response = await fetch('/api/' + options.url, {
|
||||
method: options.method,
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
mode: 'cors',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 413) throw new PayloadToLargeError()
|
||||
else throw new Error('API call failed')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function create(note: Note) {
|
||||
const { meta, ...rest } = note
|
||||
const body: NoteCreate = {
|
||||
...rest,
|
||||
meta: JSON.stringify(meta),
|
||||
}
|
||||
const data = await call({
|
||||
url: 'notes/',
|
||||
method: 'post',
|
||||
body,
|
||||
})
|
||||
return data as { id: string }
|
||||
}
|
||||
|
||||
export async function get(id: string): Promise<NotePublic> {
|
||||
const data = await call({
|
||||
url: `notes/${id}`,
|
||||
method: 'delete',
|
||||
})
|
||||
const { contents, meta } = data
|
||||
return {
|
||||
contents,
|
||||
meta: JSON.parse(meta) as NoteMeta,
|
||||
}
|
||||
}
|
||||
|
||||
export async function info(id: string): Promise<NoteInfo> {
|
||||
const data = await call({
|
||||
url: `notes/${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
return data
|
||||
}
|
@@ -1,24 +1,8 @@
|
||||
import { call } from '$lib/api'
|
||||
import { status as getStatus, type Status } from '@cryptgeon/shared'
|
||||
import { writable } from 'svelte/store'
|
||||
|
||||
export type Status = {
|
||||
version: string
|
||||
max_size: number
|
||||
max_views: number
|
||||
max_expiration: number
|
||||
allow_advanced: boolean
|
||||
theme_image: string
|
||||
theme_text: string
|
||||
theme_favicon: string
|
||||
theme_page_title: string
|
||||
}
|
||||
|
||||
export const status = writable<null | Status>(null)
|
||||
|
||||
export async function init() {
|
||||
const data = await call({
|
||||
url: 'status/',
|
||||
method: 'get',
|
||||
})
|
||||
status.set(data)
|
||||
status.set(await getStatus())
|
||||
}
|
||||
|
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
|
||||
import type { Note } from '$lib/api'
|
||||
import { status } from '$lib/stores/status'
|
||||
import Switch from '$lib/ui/Switch.svelte'
|
||||
import TextInput from '$lib/ui/TextInput.svelte'
|
||||
import type { Note } from '@cryptgeon/shared'
|
||||
|
||||
export let note: Note
|
||||
export let timeExpiration = false
|
||||
|
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
// @ts-ignore
|
||||
import QR from 'qrious'
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
|
||||
|
@@ -1,26 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
|
||||
import type { FileDTO } from '$lib/api'
|
||||
import Button from '$lib/ui/Button.svelte'
|
||||
import MaxSize from '$lib/ui/MaxSize.svelte'
|
||||
import type { FileDTO } from '@cryptgeon/shared'
|
||||
|
||||
export let label: string = ''
|
||||
export let files: FileDTO[] = []
|
||||
|
||||
function fileToDTO(file: File): FileDTO {
|
||||
async function fileToDTO(file: File): Promise<FileDTO> {
|
||||
return {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
contents: file,
|
||||
contents: new Uint8Array(await file.arrayBuffer()),
|
||||
}
|
||||
}
|
||||
|
||||
async function onInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input?.files?.length) {
|
||||
files = [...files, ...Array.from(input.files).map(fileToDTO)]
|
||||
const toAdd = await Promise.all(Array.from(input.files).map(fileToDTO))
|
||||
files = [...files, ...toAdd]
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -8,9 +8,9 @@
|
||||
import prettyBytes from 'pretty-bytes'
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
|
||||
import type { FileDTO, NotePublic } from '$lib/api'
|
||||
import Button from '$lib/ui/Button.svelte'
|
||||
import { copy } from '$lib/utils'
|
||||
import type { FileDTO, NotePublic } from '@cryptgeon/shared'
|
||||
|
||||
export let note: DecryptedNote
|
||||
|
||||
|
@@ -3,9 +3,6 @@
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
import { blur } from 'svelte/transition'
|
||||
|
||||
import { Adapters } from '$lib/adapters'
|
||||
import type { FileDTO, Note } from '$lib/api'
|
||||
import { create, PayloadToLargeError } from '$lib/api'
|
||||
import { status } from '$lib/stores/status'
|
||||
import { notify } from '$lib/toast'
|
||||
import AdvancedParameters from '$lib/ui/AdvancedParameters.svelte'
|
||||
@@ -16,6 +13,8 @@
|
||||
import Result, { type NoteResult } from '$lib/ui/NoteResult.svelte'
|
||||
import Switch from '$lib/ui/Switch.svelte'
|
||||
import TextArea from '$lib/ui/TextArea.svelte'
|
||||
import type { FileDTO, Note } from '@cryptgeon/shared'
|
||||
import { Adapters, create, PayloadToLargeError } from '@cryptgeon/shared'
|
||||
|
||||
let note: Note = {
|
||||
contents: '',
|
||||
@@ -59,7 +58,7 @@
|
||||
loading = $t('common.encrypting')
|
||||
|
||||
const key = await AES.generateKey()
|
||||
const password = await Hex.encode(key)
|
||||
const password = Hex.encode(key)
|
||||
|
||||
const data: Note = {
|
||||
contents: '',
|
||||
|
@@ -3,11 +3,10 @@
|
||||
import { onMount } from 'svelte'
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
|
||||
import { Adapters } from '$lib/adapters'
|
||||
import { get, info } from '$lib/api'
|
||||
import Button from '$lib/ui/Button.svelte'
|
||||
import Loader from '$lib/ui/Loader.svelte'
|
||||
import ShowNote, { type DecryptedNote } from '$lib/ui/ShowNote.svelte'
|
||||
import { Adapters, get, info } from '@cryptgeon/shared'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
export let data: PageData
|
||||
@@ -43,7 +42,7 @@
|
||||
loading = $t('common.downloading')
|
||||
const data = await get(id)
|
||||
loading = $t('common.decrypting')
|
||||
const key = await Hex.decode(password)
|
||||
const key = Hex.decode(password)
|
||||
switch (data.meta.type) {
|
||||
case 'text':
|
||||
note = {
|
||||
|
@@ -1,9 +1,9 @@
|
||||
import adapter from '@sveltejs/adapter-static'
|
||||
import preprocess from 'svelte-preprocess'
|
||||
import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin'
|
||||
import { vitePreprocess } from '@sveltejs/kit/vite'
|
||||
|
||||
export default {
|
||||
preprocess: preprocess(),
|
||||
|
||||
preprocess: vitePreprocess([precompileIntl('locales')]),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
fallback: 'index.html',
|
||||
|
12
packages/proxy/package.json
Normal file
12
packages/proxy/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "@cryptgeon/proxy",
|
||||
"type": "module",
|
||||
"main": "./proxy.js",
|
||||
"scripts": {
|
||||
"dev": "node ."
|
||||
},
|
||||
"dependencies": {
|
||||
"http-proxy": "^1.18.1"
|
||||
}
|
||||
}
|
16
packages/proxy/proxy.js
Normal file
16
packages/proxy/proxy.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import http from 'http'
|
||||
import httpProxy from 'http-proxy'
|
||||
|
||||
const proxy = httpProxy.createProxyServer()
|
||||
proxy.on('error', function (err, req, res) {
|
||||
console.error(err)
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' })
|
||||
res.end('500 Internal Server Error')
|
||||
})
|
||||
|
||||
const server = http.createServer(function (req, res) {
|
||||
const target = req.url.startsWith('/api/') ? 'http://127.0.0.1:8000' : 'http://localhost:8001'
|
||||
proxy.web(req, res, { target })
|
||||
})
|
||||
server.listen(1234)
|
||||
console.log('Proxy on http://localhost:1234')
|
22
packages/shared/package.json
Normal file
22
packages/shared/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "@cryptgeon/shared",
|
||||
"type": "module",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsc -w",
|
||||
"build": "tsc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"occulto": "^2.0.1"
|
||||
}
|
||||
}
|
61
packages/shared/src/adapters.ts
Normal file
61
packages/shared/src/adapters.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { AES, Bytes, type TypedArray } from 'occulto'
|
||||
import type { EncryptedFileDTO, FileDTO } from './api'
|
||||
|
||||
abstract class CryptAdapter<T> {
|
||||
abstract encrypt(plaintext: T, key: TypedArray): Promise<string>
|
||||
abstract decrypt(ciphertext: string, key: TypedArray): Promise<T>
|
||||
}
|
||||
|
||||
class CryptTextAdapter implements CryptAdapter<string> {
|
||||
async encrypt(plaintext: string, key: TypedArray) {
|
||||
return await AES.encrypt(Bytes.encode(plaintext), key)
|
||||
}
|
||||
async decrypt(ciphertext: string, key: TypedArray) {
|
||||
return Bytes.decode(await AES.decrypt(ciphertext, key))
|
||||
}
|
||||
}
|
||||
|
||||
class CryptBlobAdapter implements CryptAdapter<TypedArray> {
|
||||
async encrypt(plaintext: TypedArray, key: TypedArray) {
|
||||
return await AES.encrypt(plaintext, key)
|
||||
}
|
||||
|
||||
async decrypt(ciphertext: string, key: TypedArray) {
|
||||
return await AES.decrypt(ciphertext, key)
|
||||
// const plaintext = await AES.decrypt(ciphertext, key)
|
||||
// return new Blob([plaintext], { type: 'application/octet-stream' })
|
||||
}
|
||||
}
|
||||
|
||||
class CryptFilesAdapter implements CryptAdapter<FileDTO[]> {
|
||||
async encrypt(plaintext: FileDTO[], key: TypedArray) {
|
||||
const adapter = new CryptBlobAdapter()
|
||||
const data: Promise<EncryptedFileDTO>[] = plaintext.map(async (file) => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
contents: await adapter.encrypt(file.contents, key),
|
||||
}))
|
||||
return JSON.stringify(await Promise.all(data))
|
||||
}
|
||||
|
||||
async decrypt(ciphertext: string, key: TypedArray) {
|
||||
const adapter = new CryptBlobAdapter()
|
||||
const data: EncryptedFileDTO[] = JSON.parse(ciphertext)
|
||||
const files: FileDTO[] = await Promise.all(
|
||||
data.map(async (file) => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
contents: await adapter.decrypt(file.contents, key),
|
||||
}))
|
||||
)
|
||||
return files
|
||||
}
|
||||
}
|
||||
|
||||
export const Adapters = {
|
||||
Text: new CryptTextAdapter(),
|
||||
Blob: new CryptBlobAdapter(),
|
||||
Files: new CryptFilesAdapter(),
|
||||
}
|
106
packages/shared/src/api.ts
Normal file
106
packages/shared/src/api.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { TypedArray } from 'occulto'
|
||||
|
||||
export type NoteMeta = { type: 'text' | 'file' }
|
||||
|
||||
export type Note = {
|
||||
contents: string
|
||||
meta: NoteMeta
|
||||
views?: number
|
||||
expiration?: number
|
||||
}
|
||||
export type NoteInfo = {}
|
||||
export type NotePublic = Pick<Note, 'contents' | 'meta'>
|
||||
export type NoteCreate = Omit<Note, 'meta'> & { meta: string }
|
||||
|
||||
export type FileDTO = Pick<File, 'name' | 'size' | 'type'> & {
|
||||
contents: TypedArray
|
||||
}
|
||||
|
||||
export type EncryptedFileDTO = Omit<FileDTO, 'contents'> & {
|
||||
contents: string
|
||||
}
|
||||
|
||||
type CallOptions = {
|
||||
url: string
|
||||
method: string
|
||||
body?: any
|
||||
}
|
||||
|
||||
export class PayloadToLargeError extends Error {}
|
||||
|
||||
export let BASE = ''
|
||||
|
||||
export function setBase(url: string) {
|
||||
BASE = url
|
||||
}
|
||||
|
||||
export async function call(options: CallOptions) {
|
||||
const response = await fetch(BASE + '/api/' + options.url, {
|
||||
method: options.method,
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
mode: 'cors',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 413) throw new PayloadToLargeError()
|
||||
else throw new Error('API call failed')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function create(note: Note) {
|
||||
const { meta, ...rest } = note
|
||||
const body: NoteCreate = {
|
||||
...rest,
|
||||
meta: JSON.stringify(meta),
|
||||
}
|
||||
const data = await call({
|
||||
url: 'notes/',
|
||||
method: 'post',
|
||||
body,
|
||||
})
|
||||
return data as { id: string }
|
||||
}
|
||||
|
||||
export async function get(id: string): Promise<NotePublic> {
|
||||
const data = await call({
|
||||
url: `notes/${id}`,
|
||||
method: 'delete',
|
||||
})
|
||||
const { contents, meta } = data
|
||||
return {
|
||||
contents,
|
||||
meta: JSON.parse(meta) as NoteMeta,
|
||||
}
|
||||
}
|
||||
|
||||
export async function info(id: string): Promise<NoteInfo> {
|
||||
const data = await call({
|
||||
url: `notes/${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export type Status = {
|
||||
version: string
|
||||
max_size: number
|
||||
max_views: number
|
||||
max_expiration: number
|
||||
allow_advanced: boolean
|
||||
theme_image: string
|
||||
theme_text: string
|
||||
theme_favicon: string
|
||||
theme_page_title: string
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
const data = await call({
|
||||
url: 'status/',
|
||||
method: 'get',
|
||||
})
|
||||
return data as Status
|
||||
}
|
2
packages/shared/src/index.ts
Normal file
2
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './adapters.js'
|
||||
export * from './api.js'
|
12
packages/shared/tsconfig.json
Normal file
12
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"incremental": true,
|
||||
"composite": true,
|
||||
"target": "es2022",
|
||||
"module": "es2022",
|
||||
"rootDir": "./src",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "./dist",
|
||||
"strict": true
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user