mirror of
https://github.com/cupcakearmy/cryptgeon.git
synced 2026-09-27 04:51:45 +00:00
first v3
This commit is contained in:
@@ -1,51 +1,42 @@
|
||||
import inquirer from 'inquirer'
|
||||
import { access, constants, writeFile } from 'node:fs/promises'
|
||||
import { basename, resolve } from 'node:path'
|
||||
import { AES, Hex } from 'occulto'
|
||||
import { decode } from '@msgpack/msgpack'
|
||||
import pretty from 'pretty-bytes'
|
||||
import { Adapters } from '../shared/adapters.js'
|
||||
import { API } from '../shared/api.js'
|
||||
import { decrypt, deriveKey, setServer, getServer, info, get } from '@cryptgeon/shared'
|
||||
|
||||
export async function download(url: URL, all: boolean, suggestedPassword?: string) {
|
||||
API.setOptions({ server: url.origin })
|
||||
setServer(url.origin)
|
||||
const id = url.pathname.split('/')[2]
|
||||
const preview = await API.info(id).catch(() => {
|
||||
throw new Error('Note does not exist or is expired')
|
||||
})
|
||||
const meta = await info(id)
|
||||
if (!meta) throw new Error('Note does not exist or is expired')
|
||||
|
||||
// Password
|
||||
let password: string
|
||||
const derivation = preview?.meta.derivation
|
||||
if (derivation) {
|
||||
let key: Uint8Array
|
||||
if (meta.extra && meta.extra.length > 0) {
|
||||
if (suggestedPassword) {
|
||||
password = suggestedPassword
|
||||
const derivation = decode(meta.extra) as any
|
||||
key = deriveKey(suggestedPassword, new Uint8Array(derivation.salt))
|
||||
} else {
|
||||
const response = await inquirer.prompt([
|
||||
{
|
||||
type: 'password',
|
||||
message: 'Note password',
|
||||
name: 'password',
|
||||
},
|
||||
{ type: 'password', message: 'Note password', name: 'password' },
|
||||
])
|
||||
password = response.password
|
||||
const derivation = decode(meta.extra) as any
|
||||
key = deriveKey(response.password, new Uint8Array(derivation.salt))
|
||||
}
|
||||
} else {
|
||||
password = url.hash.slice(1)
|
||||
const hex = url.hash.slice(1)
|
||||
key = new Uint8Array(Buffer.from(hex, 'hex'))
|
||||
}
|
||||
|
||||
const key = derivation ? (await AES.derive(password, derivation))[0] : Hex.decode(password)
|
||||
const note = await API.get(id)
|
||||
const note = await get(id)
|
||||
if (!note) throw new Error('Could not load note')
|
||||
|
||||
const couldNotDecrypt = new Error('Could not decrypt note. Probably an invalid password')
|
||||
switch (note.meta.type) {
|
||||
case 'file':
|
||||
const files = await Adapters.Files.decrypt(note.contents, key).catch(() => {
|
||||
throw couldNotDecrypt
|
||||
})
|
||||
if (!files) {
|
||||
throw new Error('No files found in note')
|
||||
}
|
||||
const decrypted = decrypt(note.data, key)
|
||||
const content = decode(decrypted) as any
|
||||
|
||||
switch (content.type) {
|
||||
case 'files':
|
||||
const files: { name: string; data: Uint8Array }[] = content.data
|
||||
let selected: typeof files
|
||||
if (all) {
|
||||
selected = files
|
||||
@@ -55,36 +46,32 @@ export async function download(url: URL, all: boolean, suggestedPassword?: strin
|
||||
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 })}`,
|
||||
choices: files.map((f) => ({
|
||||
value: f.name,
|
||||
name: `${f.name} - ${pretty(f.data.length, { binary: true })}`,
|
||||
checked: true,
|
||||
})),
|
||||
},
|
||||
])
|
||||
selected = files.filter((file) => names.includes(file.name))
|
||||
selected = files.filter((f) => names.includes(f.name))
|
||||
}
|
||||
|
||||
if (!selected.length) throw new Error('No files selected')
|
||||
await Promise.all(
|
||||
selected.map(async (file) => {
|
||||
let filename = resolve(file.name)
|
||||
selected.map(async (f) => {
|
||||
let filename = resolve(f.name)
|
||||
try {
|
||||
// If exists -> prepend timestamp to not overwrite the current file
|
||||
await access(filename, constants.R_OK)
|
||||
filename = resolve(`${Date.now()}-${file.name}`)
|
||||
filename = resolve(`${Date.now()}-${f.name}`)
|
||||
} catch {}
|
||||
await writeFile(filename, file.contents)
|
||||
await writeFile(filename, f.data)
|
||||
console.log(`Saved: ${basename(filename)}`)
|
||||
})
|
||||
)
|
||||
|
||||
break
|
||||
case 'text':
|
||||
const plaintext = await Adapters.Text.decrypt(note.contents, key).catch(() => {
|
||||
throw couldNotDecrypt
|
||||
})
|
||||
console.log(plaintext)
|
||||
console.log(content.data)
|
||||
break
|
||||
default:
|
||||
throw new Error('Unknown content type')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,43 @@
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { basename } from 'node:path'
|
||||
|
||||
import { encode } from '@msgpack/msgpack'
|
||||
import mime from 'mime'
|
||||
import { AES, Hex } from 'occulto'
|
||||
import { Adapters } from '../shared/adapters.js'
|
||||
import { API, FileDTO, Note, NoteMeta } from '../shared/api.js'
|
||||
import { encrypt, generateKey, deriveKey, randomBytes, setServer, getServer, create, utf8ToBytes } from '@cryptgeon/shared'
|
||||
|
||||
export type UploadOptions = Pick<Note, 'views' | 'expiration'> & { password?: string }
|
||||
export type UploadOptions = { views?: number; expiration?: number; password?: string }
|
||||
|
||||
export async function upload(input: string | string[], options: UploadOptions): Promise<string> {
|
||||
const { password, ...noteOptions } = options
|
||||
const derived = options.password ? await AES.derive(options.password) : undefined
|
||||
const key = derived ? derived[0] : await AES.generateKey()
|
||||
|
||||
let contents: string
|
||||
let type: NoteMeta['type']
|
||||
if (typeof input === 'string') {
|
||||
contents = await Adapters.Text.encrypt(input, key)
|
||||
type = 'text'
|
||||
let key: Uint8Array
|
||||
let extra = new Uint8Array()
|
||||
if (password) {
|
||||
const salt = randomBytes(16)
|
||||
key = deriveKey(password, salt)
|
||||
extra = encode({ salt, N: 32768, r: 8, p: 1 })
|
||||
} else {
|
||||
const files: FileDTO[] = await Promise.all(
|
||||
input.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
|
||||
})
|
||||
)
|
||||
contents = await Adapters.Files.encrypt(files, key)
|
||||
type = 'file'
|
||||
key = generateKey()
|
||||
}
|
||||
|
||||
// Create the actual note and upload it.
|
||||
const note: Note = { ...noteOptions, contents, meta: { type, derivation: derived?.[1] } }
|
||||
const result = await API.create(note)
|
||||
let url = `${API.getOptions().server}/note/${result.id}`
|
||||
if (!derived) url += `#${Hex.encode(key)}`
|
||||
let inner: Uint8Array
|
||||
if (typeof input === 'string') {
|
||||
inner = encode({ type: 'text', data: input })
|
||||
} else {
|
||||
const files = await Promise.all(
|
||||
input.map(async (path) => {
|
||||
const data = new Uint8Array(await readFile(path))
|
||||
const extension = path.substring(path.indexOf('.') + 1)
|
||||
const type = mime.getType(extension) ?? 'application/octet-stream'
|
||||
return { name: basename(path), mime: type, size: data.length, data }
|
||||
})
|
||||
)
|
||||
inner = encode({ type: 'files', data: files })
|
||||
}
|
||||
|
||||
const data = encrypt(inner, key)
|
||||
const result = await create({ meta: { ...noteOptions, extra }, data })
|
||||
let url = `${getServer()}/note/${result.id}`
|
||||
if (!password) url += `#${Buffer.from(key).toString('hex')}`
|
||||
return url
|
||||
}
|
||||
}
|
||||
+10
-13
@@ -5,7 +5,7 @@ import prettyBytes from 'pretty-bytes'
|
||||
|
||||
import { download } from './actions/download.js'
|
||||
import { upload } from './actions/upload.js'
|
||||
import { API } from './shared/api.js'
|
||||
import { setServer, status } from '@cryptgeon/shared'
|
||||
import { parseFile, parseNumber } from './utils/parsers.js'
|
||||
import { getStdin } from './utils/stdin.js'
|
||||
import { checkConstrains, exit } from './utils/utils.js'
|
||||
@@ -33,15 +33,12 @@ program
|
||||
.description('show information about the server')
|
||||
.addOption(server)
|
||||
.action(async (options) => {
|
||||
API.setOptions({ server: options.server })
|
||||
const response = await API.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]
|
||||
}
|
||||
setServer(options.server)
|
||||
const response = await status()
|
||||
const formatted = Object.fromEntries(
|
||||
Object.entries({ ...response, max_size: prettyBytes(response.max_size as number) })
|
||||
.filter(([key]) => !key.startsWith('theme_'))
|
||||
)
|
||||
console.table(formatted)
|
||||
})
|
||||
|
||||
@@ -54,7 +51,7 @@ send
|
||||
.addOption(minutes)
|
||||
.addOption(password)
|
||||
.action(async (files, options) => {
|
||||
API.setOptions({ server: options.server })
|
||||
setServer(options.server)
|
||||
await checkConstrains(options)
|
||||
options.password ||= await getStdin()
|
||||
try {
|
||||
@@ -72,7 +69,7 @@ send
|
||||
.addOption(minutes)
|
||||
.addOption(password)
|
||||
.action(async (text, options) => {
|
||||
API.setOptions({ server: options.server })
|
||||
setServer(options.server)
|
||||
await checkConstrains(options)
|
||||
options.password ||= await getStdin()
|
||||
try {
|
||||
@@ -103,4 +100,4 @@ program
|
||||
}
|
||||
})
|
||||
|
||||
program.parse()
|
||||
program.parse()
|
||||
@@ -1,4 +1,2 @@
|
||||
export * from './actions/download.js'
|
||||
export * from './actions/upload.js'
|
||||
export * from './shared/adapters.js'
|
||||
export * from './shared/api.js'
|
||||
export * from './actions/upload.js'
|
||||
@@ -1,61 +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<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(),
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import type { KeyData, TypedArray } from 'occulto'
|
||||
|
||||
export type NoteMeta = {
|
||||
type: 'text' | 'file'
|
||||
derivation?: KeyData
|
||||
}
|
||||
|
||||
export type Note = {
|
||||
contents: string
|
||||
meta: NoteMeta
|
||||
views?: number
|
||||
expiration?: number
|
||||
}
|
||||
export type NoteInfo = Pick<Note, 'meta'>
|
||||
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 ClientOptions = {
|
||||
server: string
|
||||
}
|
||||
|
||||
type CallOptions = {
|
||||
url: string
|
||||
method: string
|
||||
body?: any
|
||||
}
|
||||
|
||||
export class PayloadToLargeError extends Error {}
|
||||
|
||||
export let client: ClientOptions = {
|
||||
server: '',
|
||||
}
|
||||
|
||||
function setOptions(options: Partial<ClientOptions>) {
|
||||
client = { ...client, ...options }
|
||||
}
|
||||
|
||||
function getOptions(): ClientOptions {
|
||||
return client
|
||||
}
|
||||
|
||||
async function call(options: CallOptions) {
|
||||
const url = client.server + '/api/' + options.url
|
||||
const response = await fetch(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()
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
async function get(id: string): Promise<NotePublic> {
|
||||
const data = await call({
|
||||
url: `notes/${id}`,
|
||||
method: 'delete',
|
||||
})
|
||||
const { contents, meta } = data
|
||||
const note = {
|
||||
contents,
|
||||
meta: JSON.parse(meta),
|
||||
} satisfies NotePublic
|
||||
if (note.meta.derivation) note.meta.derivation.salt = new Uint8Array(Object.values(note.meta.derivation.salt))
|
||||
return note
|
||||
}
|
||||
|
||||
async function info(id: string): Promise<NoteInfo> {
|
||||
const data = await call({
|
||||
url: `notes/${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
const { meta } = data
|
||||
const note = {
|
||||
meta: JSON.parse(meta),
|
||||
} satisfies NoteInfo
|
||||
if (note.meta.derivation) note.meta.derivation.salt = new Uint8Array(Object.values(note.meta.derivation.salt))
|
||||
return note
|
||||
}
|
||||
|
||||
export type Status = {
|
||||
version: string
|
||||
max_size: number
|
||||
max_views: number
|
||||
max_expiration: number
|
||||
allow_advanced: boolean
|
||||
allow_files: boolean
|
||||
imprint_url: string
|
||||
imprint_html: string
|
||||
theme_image: string
|
||||
theme_text: string
|
||||
theme_favicon: string
|
||||
theme_page_title: string
|
||||
theme_new_note_notice: boolean
|
||||
theme_home_link: boolean
|
||||
}
|
||||
|
||||
async function status() {
|
||||
const data = await call({
|
||||
url: 'status/',
|
||||
method: 'get',
|
||||
})
|
||||
return data as Status
|
||||
}
|
||||
|
||||
export const API = {
|
||||
setOptions,
|
||||
getOptions,
|
||||
create,
|
||||
get,
|
||||
info,
|
||||
status,
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './adapters.js'
|
||||
export * from './api.js'
|
||||
@@ -1,5 +1,5 @@
|
||||
import { exit as exitNode } from 'node:process'
|
||||
import { API } from '../shared/api.js'
|
||||
import { status } from '@cryptgeon/shared'
|
||||
|
||||
export function exit(message: string) {
|
||||
console.error(message)
|
||||
@@ -7,13 +7,11 @@ export function exit(message: string) {
|
||||
}
|
||||
|
||||
export 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
|
||||
if (!constrains.views && !constrains.minutes) constrains.views = 1
|
||||
|
||||
const response = await API.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.`)
|
||||
}
|
||||
const response = await status()
|
||||
if (constrains.views && constrains.views > (response.max_views as number))
|
||||
exit(`Only a maximum of ${response.max_views} views allowed. ${constrains.views} given.`)
|
||||
if (constrains.minutes && constrains.minutes > (response.max_expiration as number))
|
||||
exit(`Only a maximum of ${response.max_expiration} minutes allowed. ${constrains.minutes} given.`)
|
||||
}
|
||||
Reference in New Issue
Block a user