This commit is contained in:
2026-07-12 11:36:01 +02:00
parent 1f180a2e53
commit 062054f450
46 changed files with 976 additions and 1691 deletions
+5 -4
View File
@@ -4,12 +4,13 @@ import pkg from './package.json' with { type: 'json' }
const watch = process.argv.slice(2)[0] === '--watch'
await build({
entry: ['src/index.ts', 'src/cli.ts', 'src/shared/shared.ts'],
entry: ['src/index.ts', 'src/cli.ts'],
dts: true,
minify: true,
format: ['esm', 'cjs'],
target: 'es2020',
format: ['esm'],
target: 'es2022',
clean: true,
noExternal: ['@cryptgeon/shared'],
define: { VERSION: `"${pkg.version}"` },
watch,
})
})
+15 -18
View File
@@ -1,6 +1,6 @@
{
"name": "cryptgeon",
"version": "2.9.3",
"version": "3.0.0",
"homepage": "https://github.com/cupcakearmy/cryptgeon",
"repository": {
"type": "git",
@@ -9,39 +9,36 @@
},
"type": "module",
"exports": {
".": "./dist/index.js",
"./shared": {
"import": "./dist/shared/shared.js",
"types": "./dist/shared/shared.d.ts"
}
".": "./dist/index.js"
},
"types": "./dist/index.d.ts",
"bin": {
"cryptgeon": "./dist/cli.cjs"
"cryptgeon": "./dist/cli.js"
},
"files": [
"dist"
],
"scripts": {
"bin": "run-s build package",
"build": "tsc && node build.js",
"build": "node build.js",
"dev": "node build.js --watch",
"prepublishOnly": "run-s build"
},
"devDependencies": {
"dependencies": {
"@cryptgeon/shared": "workspace:*",
"@msgpack/msgpack": "^3.1.3",
"@commander-js/extra-typings": "^12.1.0",
"@types/inquirer": "^9.0.9",
"@types/mime": "^4.0.0",
"@types/node": "^20.19.41",
"commander": "^12.1.0",
"inquirer": "^9.3.8",
"mime": "^4.1.0",
"occulto": "^2.0.6",
"pretty-bytes": "^6.1.1",
"pretty-bytes": "^6.1.1"
},
"devDependencies": {
"@types/inquirer": "^9.0.9",
"@types/node": "^22.15.3",
"commander": "^12.1.0",
"tsup": "^8.5.1",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=18"
"node": ">=22"
}
}
}
+33 -46
View File
@@ -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')
}
}
}
+30 -33
View File
@@ -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
View File
@@ -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 -3
View File
@@ -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'
-61
View File
@@ -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(),
}
-141
View File
@@ -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,
}
-2
View File
@@ -1,2 +0,0 @@
export * from './adapters.js'
export * from './api.js'
+8 -10
View File
@@ -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.`)
}