Compare commits

..

14 Commits

Author SHA1 Message Date
Maxime Cannoodt
d5b2b52f95 fetch decrypt and render jpegs 2022-09-13 23:21:26 +02:00
Maxime Cannoodt
654aa3e9c4 cascade delete notes 2022-09-11 18:07:06 +02:00
Maxime Cannoodt
3b08d84b26 test get embed controller 2022-09-11 17:47:01 +02:00
Maxime Cannoodt
a675de3420 embec ton 2022-08-30 13:54:17 +02:00
Maxime Cannoodt
f60dc3b018 embed controller 2022-08-30 13:52:52 +02:00
Maxime Cannoodt
bbc92dc01c more updated tests 2022-08-30 13:29:00 +02:00
Maxime Cannoodt
35bcc5c5b9 update tests 2022-08-30 13:24:39 +02:00
Maxime Cannoodt
146e1848bc post requests and tests 2022-08-30 12:53:52 +02:00
Maxime Cannoodt
86d8771303 note dao unit test 2022-08-30 10:05:41 +02:00
Maxime Cannoodt
433394a3c2 add transactions 2022-08-30 09:51:53 +02:00
Maxime Cannoodt
6de10f07e6 wip 2022-08-28 22:43:18 +02:00
Maxime Cannoodt
a797aa00e9 embed schema and dao 2022-08-28 21:57:47 +02:00
Maxime Cannoodt
a2569a2b34 write array buffer/base64 conversion 2022-08-28 21:04:17 +02:00
Maxime Cannoodt
e9956486fd add table 2022-08-25 16:24:18 +02:00
86 changed files with 1520 additions and 6712 deletions

5
.gitignore vendored
View File

@ -13,7 +13,4 @@ server/.env
# production configuration
docker-compose.server.yml
/letsencrypt
# Dev env configuration
proxy.js
/letsencrypt

View File

@ -12,9 +12,6 @@ The preferred way to report bugs or request new features for the web app or the
If you want a more interactive way to discuss bugs or features, you can join the [Discord server](https://discord.gg/y3HqyGeABK).
## Funding
By popular request I have written a post about how the public instance of Noteshare is funded: https://noteshare.space/funding
## Local development

View File

@ -52,7 +52,7 @@ services:
labels:
- "traefik.enable=true" # tell Traefik this is something we would like to expose
- "traefik.http.routers.backend.entrypoints=web" # what entrypoint should be used for the backend service.
- "traefik.http.routers.backend.rule=Host(`localhost`) && PathPrefix(`/api`) && (Method(`POST`) || Method(`DELETE`))" #
- "traefik.http.routers.backend.rule=Host(`localhost`) && PathPrefix(`/api`) && Method(`POST`)" #
# Frontend for serving encrypted notes over HTML (SvelteKit)
frontend:

View File

@ -1,7 +1,7 @@
{
"scripts": {
"proxy": "node proxy.js",
"dev": "concurrently -n plugin,webapp,server,proxy \"npm --prefix ./plugin run dev\" \"npm --prefix ./webapp run dev\" \"npm --prefix ./server run dev\" \"npm run proxy\""
"dev": "concurrently -n plugin,webapp,server,proxy \"npm --prefix ./plugin run dev\" \"npm --prefix ./webapp run dev -- --open\" \"npm --prefix ./server run dev\" \"npm run proxy\""
},
"dependencies": {
"concurrently": "^7.2.2"

2
plugin

@ -1 +1 @@
Subproject commit 7a645c3e11c14f222405068c4710d30686800013
Subproject commit 8ccf08c4d2d1fb99f38488085c3f40c22393c9c0

707
pnpm-lock.yaml generated
View File

@ -1,707 +0,0 @@
lockfileVersion: 5.4
specifiers:
concurrently: ^7.2.2
express: ^4.18.1
http-proxy-middleware: ^2.0.6
dependencies:
concurrently: 7.5.0
devDependencies:
express: 4.18.2
http-proxy-middleware: 2.0.6
packages:
/@types/http-proxy/1.17.9:
resolution: {integrity: sha512-QsbSjA/fSk7xB+UXlCT3wHBy5ai9wOcNDWwZAtud+jXhwOM3l+EYZh8Lng4+/6n8uar0J7xILzqftJdJ/Wdfkw==}
dependencies:
'@types/node': 18.11.9
dev: true
/@types/node/18.11.9:
resolution: {integrity: sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==}
dev: true
/accepts/1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
dependencies:
mime-types: 2.1.35
negotiator: 0.6.3
dev: true
/ansi-regex/5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
dev: false
/ansi-styles/4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
dependencies:
color-convert: 2.0.1
dev: false
/array-flatten/1.1.1:
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
dev: true
/body-parser/1.20.1:
resolution: {integrity: sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
dependencies:
bytes: 3.1.2
content-type: 1.0.4
debug: 2.6.9
depd: 2.0.0
destroy: 1.2.0
http-errors: 2.0.0
iconv-lite: 0.4.24
on-finished: 2.4.1
qs: 6.11.0
raw-body: 2.5.1
type-is: 1.6.18
unpipe: 1.0.0
transitivePeerDependencies:
- supports-color
dev: true
/braces/3.0.2:
resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==}
engines: {node: '>=8'}
dependencies:
fill-range: 7.0.1
dev: true
/bytes/3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
dev: true
/call-bind/1.0.2:
resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==}
dependencies:
function-bind: 1.1.1
get-intrinsic: 1.1.3
dev: true
/chalk/4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
dev: false
/cliui/8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
dev: false
/color-convert/2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
dependencies:
color-name: 1.1.4
dev: false
/color-name/1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
dev: false
/concurrently/7.5.0:
resolution: {integrity: sha512-5E3mwiS+i2JYBzr5BpXkFxOnleZTMsG+WnE/dCG4/P+oiVXrbmrBwJ2ozn4SxwB2EZDrKR568X+puVohxz3/Mg==}
engines: {node: ^12.20.0 || ^14.13.0 || >=16.0.0}
hasBin: true
dependencies:
chalk: 4.1.2
date-fns: 2.29.3
lodash: 4.17.21
rxjs: 7.5.7
shell-quote: 1.7.4
spawn-command: 0.0.2-1
supports-color: 8.1.1
tree-kill: 1.2.2
yargs: 17.6.2
dev: false
/content-disposition/0.5.4:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
dependencies:
safe-buffer: 5.2.1
dev: true
/content-type/1.0.4:
resolution: {integrity: sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==}
engines: {node: '>= 0.6'}
dev: true
/cookie-signature/1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
dev: true
/cookie/0.5.0:
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
engines: {node: '>= 0.6'}
dev: true
/date-fns/2.29.3:
resolution: {integrity: sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==}
engines: {node: '>=0.11'}
dev: false
/debug/2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
dependencies:
ms: 2.0.0
dev: true
/depd/2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
dev: true
/destroy/1.2.0:
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
dev: true
/ee-first/1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
dev: true
/emoji-regex/8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
dev: false
/encodeurl/1.0.2:
resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
engines: {node: '>= 0.8'}
dev: true
/escalade/3.1.1:
resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==}
engines: {node: '>=6'}
dev: false
/escape-html/1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
dev: true
/etag/1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
dev: true
/eventemitter3/4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
dev: true
/express/4.18.2:
resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==}
engines: {node: '>= 0.10.0'}
dependencies:
accepts: 1.3.8
array-flatten: 1.1.1
body-parser: 1.20.1
content-disposition: 0.5.4
content-type: 1.0.4
cookie: 0.5.0
cookie-signature: 1.0.6
debug: 2.6.9
depd: 2.0.0
encodeurl: 1.0.2
escape-html: 1.0.3
etag: 1.8.1
finalhandler: 1.2.0
fresh: 0.5.2
http-errors: 2.0.0
merge-descriptors: 1.0.1
methods: 1.1.2
on-finished: 2.4.1
parseurl: 1.3.3
path-to-regexp: 0.1.7
proxy-addr: 2.0.7
qs: 6.11.0
range-parser: 1.2.1
safe-buffer: 5.2.1
send: 0.18.0
serve-static: 1.15.0
setprototypeof: 1.2.0
statuses: 2.0.1
type-is: 1.6.18
utils-merge: 1.0.1
vary: 1.1.2
transitivePeerDependencies:
- supports-color
dev: true
/fill-range/7.0.1:
resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==}
engines: {node: '>=8'}
dependencies:
to-regex-range: 5.0.1
dev: true
/finalhandler/1.2.0:
resolution: {integrity: sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==}
engines: {node: '>= 0.8'}
dependencies:
debug: 2.6.9
encodeurl: 1.0.2
escape-html: 1.0.3
on-finished: 2.4.1
parseurl: 1.3.3
statuses: 2.0.1
unpipe: 1.0.0
transitivePeerDependencies:
- supports-color
dev: true
/follow-redirects/1.15.2:
resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
dev: true
/forwarded/0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
dev: true
/fresh/0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
dev: true
/function-bind/1.1.1:
resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==}
dev: true
/get-caller-file/2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
dev: false
/get-intrinsic/1.1.3:
resolution: {integrity: sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==}
dependencies:
function-bind: 1.1.1
has: 1.0.3
has-symbols: 1.0.3
dev: true
/has-flag/4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
dev: false
/has-symbols/1.0.3:
resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==}
engines: {node: '>= 0.4'}
dev: true
/has/1.0.3:
resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==}
engines: {node: '>= 0.4.0'}
dependencies:
function-bind: 1.1.1
dev: true
/http-errors/2.0.0:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
dependencies:
depd: 2.0.0
inherits: 2.0.4
setprototypeof: 1.2.0
statuses: 2.0.1
toidentifier: 1.0.1
dev: true
/http-proxy-middleware/2.0.6:
resolution: {integrity: sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==}
engines: {node: '>=12.0.0'}
peerDependencies:
'@types/express': ^4.17.13
peerDependenciesMeta:
'@types/express':
optional: true
dependencies:
'@types/http-proxy': 1.17.9
http-proxy: 1.18.1
is-glob: 4.0.3
is-plain-obj: 3.0.0
micromatch: 4.0.5
transitivePeerDependencies:
- debug
dev: true
/http-proxy/1.18.1:
resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
engines: {node: '>=8.0.0'}
dependencies:
eventemitter3: 4.0.7
follow-redirects: 1.15.2
requires-port: 1.0.0
transitivePeerDependencies:
- debug
dev: true
/iconv-lite/0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
dependencies:
safer-buffer: 2.1.2
dev: true
/inherits/2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
dev: true
/ipaddr.js/1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
dev: true
/is-extglob/2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
dev: true
/is-fullwidth-code-point/3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
dev: false
/is-glob/4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
dependencies:
is-extglob: 2.1.1
dev: true
/is-number/7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
dev: true
/is-plain-obj/3.0.0:
resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==}
engines: {node: '>=10'}
dev: true
/lodash/4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
dev: false
/media-typer/0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
dev: true
/merge-descriptors/1.0.1:
resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==}
dev: true
/methods/1.1.2:
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
engines: {node: '>= 0.6'}
dev: true
/micromatch/4.0.5:
resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==}
engines: {node: '>=8.6'}
dependencies:
braces: 3.0.2
picomatch: 2.3.1
dev: true
/mime-db/1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
dev: true
/mime-types/2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
dependencies:
mime-db: 1.52.0
dev: true
/mime/1.6.0:
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
engines: {node: '>=4'}
hasBin: true
dev: true
/ms/2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
dev: true
/ms/2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
dev: true
/negotiator/0.6.3:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
dev: true
/object-inspect/1.12.2:
resolution: {integrity: sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==}
dev: true
/on-finished/2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
dependencies:
ee-first: 1.1.1
dev: true
/parseurl/1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
dev: true
/path-to-regexp/0.1.7:
resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==}
dev: true
/picomatch/2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
dev: true
/proxy-addr/2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
dependencies:
forwarded: 0.2.0
ipaddr.js: 1.9.1
dev: true
/qs/6.11.0:
resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
engines: {node: '>=0.6'}
dependencies:
side-channel: 1.0.4
dev: true
/range-parser/1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
dev: true
/raw-body/2.5.1:
resolution: {integrity: sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==}
engines: {node: '>= 0.8'}
dependencies:
bytes: 3.1.2
http-errors: 2.0.0
iconv-lite: 0.4.24
unpipe: 1.0.0
dev: true
/require-directory/2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
dev: false
/requires-port/1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
dev: true
/rxjs/7.5.7:
resolution: {integrity: sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==}
dependencies:
tslib: 2.4.1
dev: false
/safe-buffer/5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
dev: true
/safer-buffer/2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
dev: true
/send/0.18.0:
resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==}
engines: {node: '>= 0.8.0'}
dependencies:
debug: 2.6.9
depd: 2.0.0
destroy: 1.2.0
encodeurl: 1.0.2
escape-html: 1.0.3
etag: 1.8.1
fresh: 0.5.2
http-errors: 2.0.0
mime: 1.6.0
ms: 2.1.3
on-finished: 2.4.1
range-parser: 1.2.1
statuses: 2.0.1
transitivePeerDependencies:
- supports-color
dev: true
/serve-static/1.15.0:
resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==}
engines: {node: '>= 0.8.0'}
dependencies:
encodeurl: 1.0.2
escape-html: 1.0.3
parseurl: 1.3.3
send: 0.18.0
transitivePeerDependencies:
- supports-color
dev: true
/setprototypeof/1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
dev: true
/shell-quote/1.7.4:
resolution: {integrity: sha512-8o/QEhSSRb1a5i7TFR0iM4G16Z0vYB2OQVs4G3aAFXjn3T6yEx8AZxy1PgDF7I00LZHYA3WxaSYIf5e5sAX8Rw==}
dev: false
/side-channel/1.0.4:
resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==}
dependencies:
call-bind: 1.0.2
get-intrinsic: 1.1.3
object-inspect: 1.12.2
dev: true
/spawn-command/0.0.2-1:
resolution: {integrity: sha512-n98l9E2RMSJ9ON1AKisHzz7V42VDiBQGY6PB1BwRglz99wpVsSuGzQ+jOi6lFXBGVTCrRpltvjm+/XA+tpeJrg==}
dev: false
/statuses/2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
dev: true
/string-width/4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
dev: false
/strip-ansi/6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
dependencies:
ansi-regex: 5.0.1
dev: false
/supports-color/7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
dependencies:
has-flag: 4.0.0
dev: false
/supports-color/8.1.1:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
dependencies:
has-flag: 4.0.0
dev: false
/to-regex-range/5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
dependencies:
is-number: 7.0.0
dev: true
/toidentifier/1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
dev: true
/tree-kill/1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
dev: false
/tslib/2.4.1:
resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==}
dev: false
/type-is/1.6.18:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
dependencies:
media-typer: 0.3.0
mime-types: 2.1.35
dev: true
/unpipe/1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
dev: true
/utils-merge/1.0.1:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
dev: true
/vary/1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
dev: true
/wrap-ansi/7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
dev: false
/y18n/5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
dev: false
/yargs-parser/21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
dev: false
/yargs/17.6.2:
resolution: {integrity: sha512-1/9UrdHjDZc0eOU0HxOHoS78C69UD3JRMvzlJ7S79S2nTaWRA/whGCTV8o9e/N/1Va9YIV7Q4sOxD8VV4pCWOw==}
engines: {node: '>=12'}
dependencies:
cliui: 8.0.1
escalade: 3.1.1
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
dev: false

View File

@ -3,8 +3,6 @@ const { createProxyMiddleware } = require("http-proxy-middleware");
const app = express();
const PORT = 5000;
app.use(
"/api/",
createProxyMiddleware({
@ -16,10 +14,10 @@ app.use(
app.use(
"/",
createProxyMiddleware({
target: "http://localhost:5173",
target: "http://localhost:3000",
changeOrigin: true,
})
);
app.listen(PORT);
console.log(`Reverse proxy listening at http://localhost:${PORT}`);
app.listen(5000);
console.log("Reverse proxy listening at http://localhost:5000");

View File

@ -8,4 +8,4 @@ GET_LIMIT_WINDOW_SECONDS=0.1
LOG_LEVEL=warn
# Make cleanup interval very long to avoid automatic cleanup during tests
CLEANUP_INTERVAL_SECONDS=99999
CLEANUP_INTERVAL_SECONDS=99999

View File

@ -11,8 +11,7 @@
"test:coverage": "dotenv -e .env.test -- vitest run --no-threads --coverage",
"test:db:reset": "dotenv -e .env.test -- npx prisma migrate reset -f",
"build": "npx tsc",
"dev": "npx nodemon src/server.ts | npx pino-colada",
"migrate": "npx prisma migrate dev"
"dev": "npx nodemon src/server.ts | npx pino-colada"
},
"author": "Maxime Cannoodt (mcndt)",
"license": "MIT",

View File

@ -0,0 +1,10 @@
-- CreateTable
CREATE TABLE "EncryptedEmbed" (
"id" TEXT NOT NULL PRIMARY KEY,
"note_id" TEXT NOT NULL,
"embed_id" TEXT NOT NULL,
"ciphertext" BLOB NOT NULL,
"hmac" TEXT NOT NULL,
"size_bytes" INTEGER NOT NULL,
CONSTRAINT "EncryptedEmbed_note_id_fkey" FOREIGN KEY ("note_id") REFERENCES "EncryptedNote" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);

View File

@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[note_id,embed_id]` on the table `EncryptedEmbed` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "EncryptedEmbed_note_id_embed_id_key" ON "EncryptedEmbed"("note_id", "embed_id");

View File

@ -0,0 +1,17 @@
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_EncryptedEmbed" (
"id" TEXT NOT NULL PRIMARY KEY,
"note_id" TEXT NOT NULL,
"embed_id" TEXT NOT NULL,
"ciphertext" BLOB NOT NULL,
"hmac" TEXT NOT NULL,
"size_bytes" INTEGER NOT NULL,
CONSTRAINT "EncryptedEmbed_note_id_fkey" FOREIGN KEY ("note_id") REFERENCES "EncryptedNote" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_EncryptedEmbed" ("ciphertext", "embed_id", "hmac", "id", "note_id", "size_bytes") SELECT "ciphertext", "embed_id", "hmac", "id", "note_id", "size_bytes" FROM "EncryptedEmbed";
DROP TABLE "EncryptedEmbed";
ALTER TABLE "new_EncryptedEmbed" RENAME TO "EncryptedEmbed";
CREATE UNIQUE INDEX "EncryptedEmbed_note_id_embed_id_key" ON "EncryptedEmbed"("note_id", "embed_id");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@ -1,16 +0,0 @@
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_EncryptedNote" (
"id" TEXT NOT NULL PRIMARY KEY,
"insert_time" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expire_time" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ciphertext" TEXT NOT NULL,
"hmac" TEXT,
"iv" TEXT,
"crypto_version" TEXT NOT NULL DEFAULT 'v1'
);
INSERT INTO "new_EncryptedNote" ("ciphertext", "crypto_version", "expire_time", "hmac", "id", "insert_time") SELECT "ciphertext", "crypto_version", "expire_time", "hmac", "id", "insert_time" FROM "EncryptedNote";
DROP TABLE "EncryptedNote";
ALTER TABLE "new_EncryptedNote" RENAME TO "EncryptedNote";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "EncryptedNote" ADD COLUMN "secret_token" TEXT;

View File

@ -2,7 +2,8 @@
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
previewFeatures = ["interactiveTransactions"]
}
datasource db {
@ -11,14 +12,25 @@ datasource db {
}
model EncryptedNote {
id String @id @default(cuid())
insert_time DateTime @default(now())
expire_time DateTime @default(now())
id String @id @default(cuid())
insert_time DateTime @default(now())
expire_time DateTime @default(now())
ciphertext String
hmac String?
iv String?
crypto_version String @default("v1")
secret_token String?
hmac String
crypto_version String @default("v1")
EncryptedEmbed EncryptedEmbed[]
}
model EncryptedEmbed {
id String @id @default(cuid())
note_id String
embed_id String
ciphertext Bytes
hmac String
size_bytes Int
note EncryptedNote @relation(fields: [note_id], references: [id], onDelete: Cascade)
@@unique([note_id, embed_id], name: "noteId_embedId")
}
model event {

View File

@ -4,6 +4,8 @@ import { describe, it, expect } from "vitest";
import prisma from "./db/client";
import { deleteExpiredNotes } from "./tasks/deleteExpiredNotes";
import { EventType } from "./logging/EventLogger";
import { createNote } from "./db/note.dao";
import { EncryptedNote } from "@prisma/client";
// const testNote with base64 ciphertext and hmac
const testNote = {
@ -22,7 +24,7 @@ describe("GET /api/note", () => {
const res = await supertest(app).get(`/api/note/${id}`);
// Validate returned note
expect(res.statusCode).toBe(200);
expectCodeOrThrowResponse(res, 200);
expect(res.body).toHaveProperty("id");
expect(res.body).toHaveProperty("expire_time");
expect(res.body).toHaveProperty("insert_time");
@ -48,7 +50,7 @@ describe("GET /api/note", () => {
const res = await supertest(app).get(`/api/note/NaN`);
// Validate returned note
expect(res.statusCode).toBe(404);
expectCodeOrThrowResponse(res, 404);
// Is a read event logged?
const readEvents = await prisma.event.findMany({
@ -73,21 +75,40 @@ describe("GET /api/note", () => {
const responseCodes = responses.map((res) => res.statusCode);
// at least one response should be 429
expect(responseCodes).toContain(200);
expect(responseCodes).toContain(429);
// No other response codes should be present
expect(
responseCodes.map((code) => code === 429 || code === 200)
).not.toContain(false);
// sleep for 100 ms to allow rate limiter to reset
await new Promise((resolve) => setTimeout(resolve, 100));
});
});
describe("POST /api/note", () => {
it("returns a view_url on correct POST body (without plugin version and user id)", async () => {
const res = await supertest(app).post("/api/note").send(testNote);
it("returns a view_url on correct POST body with embeds", async () => {
const res = await supertest(app)
.post("/api/note")
.send({
...testNote,
embeds: [
{
embed_id: "sample_embed_id0",
ciphertext: Buffer.from("sample_ciphertext").toString("base64"),
hmac: Buffer.from("sample_hmac").toString("base64"),
},
{
embed_id: "sample_embed_id1",
ciphertext: Buffer.from("sample_ciphertext").toString("base64"),
hmac: Buffer.from("sample_hmac").toString("base64"),
},
],
});
if (res.statusCode !== 200) {
console.log(res.body);
}
expect(res.statusCode).toBe(200);
expectCodeOrThrowResponse(res, 200);
// Returned body has correct fields
expect(res.body).toHaveProperty("expire_time");
@ -115,7 +136,7 @@ describe("POST /api/note", () => {
it("Returns a bad request on invalid POST body", async () => {
const res = await supertest(app).post("/api/note").send({});
expect(res.statusCode).toBe(400);
expectCodeOrThrowResponse(res, 400);
});
it("returns a valid view_url on correct POST body", async () => {
@ -123,7 +144,7 @@ describe("POST /api/note", () => {
let res = await supertest(app).post("/api/note").send(testNote);
// Extract note id from post response
expect(res.statusCode).toBe(200);
expectCodeOrThrowResponse(res, 200);
expect(res.body).toHaveProperty("view_url");
const match = (res.body.view_url as string).match(/note\/(.+)$/);
expect(match).not.toBeNull();
@ -134,7 +155,7 @@ describe("POST /api/note", () => {
res = await supertest(app).get(`/api/note/${note_id}`);
// Validate returned note
expect(res.statusCode).toBe(200);
expectCodeOrThrowResponse(res, 200);
expect(res.body).toHaveProperty("id");
expect(res.body).toHaveProperty("expire_time");
expect(res.body).toHaveProperty("insert_time");
@ -145,16 +166,17 @@ describe("POST /api/note", () => {
expect(res.body.hmac).toEqual(testNote.hmac);
});
it("Applies upload limit to endpoint of 500kb", async () => {
it("Applies upload limit to endpoint of 8MB", async () => {
const largeNote = {
ciphertext: "a".repeat(500 * 1024),
hmac: "sample_hmac",
ciphertext: Buffer.from("a".repeat(8 * 1024 * 1024)).toString("base64"),
hmac: Buffer.from("a".repeat(32)).toString("base64"),
};
const res = await supertest(app).post("/api/note").send(largeNote);
expect(res.statusCode).toBe(413);
expectCodeOrThrowResponse(res, 413);
});
it("Applies rate limits to endpoint", async () => {
// 2022-08-30: Skip this test because it crashes the database connection for some reason
it.skip("Applies rate limits to endpoint", async () => {
// make more requests than the post limit set in .env.test
const requests = [];
for (let i = 0; i < 51; i++) {
@ -172,11 +194,52 @@ describe("POST /api/note", () => {
responseCodes.map((code) => code === 429 || code === 200)
).not.toContain(false);
// sleep for 100 ms to allow rate limiter to reset
// sleep for 250 ms to allow rate limiter to reset
await new Promise((resolve) => setTimeout(resolve, 250));
});
});
describe("Use case: POST note with embeds, then GET embeds", () => {
it("returns a view_url on correct POST body with embeds", async () => {
const payload = {
ciphertext: Buffer.from("sample_ciphertext").toString("base64"),
hmac: Buffer.from("sample_hmac").toString("base64"),
user_id: "f06536e7df6857fc",
embeds: [
{
embed_id: "EMBED_ID",
ciphertext: Buffer.from("sample_ciphertext").toString("base64"),
hmac: Buffer.from("sample_hmac").toString("base64"),
},
],
};
// make post request
const res = await supertest(app).post("/api/note").send(payload);
// check response and extract note id
expectCodeOrThrowResponse(res, 200);
expect(res.body).toHaveProperty("view_url");
const match = (res.body.view_url as string).match(/note\/(.+)$/);
expect(match).not.toBeNull();
const note_id = (match as RegExpMatchArray)[1];
// make get request for note
const noteRes = await supertest(app).get(`/api/note/${note_id}`);
expectCodeOrThrowResponse(noteRes, 200);
expect(noteRes.body?.ciphertext).toEqual(payload.ciphertext);
expect(noteRes.body?.hmac).toEqual(payload.hmac);
// make get request for embed
const embedRes = await supertest(app).get(
`/api/note/${note_id}/embeds/EMBED_ID`
);
expectCodeOrThrowResponse(embedRes, 200);
expect(embedRes.body?.ciphertext).toEqual(payload.embeds[0].ciphertext);
expect(embedRes.body?.hmac).toEqual(payload.embeds[0].hmac);
});
});
describe("Clean expired notes", () => {
it("removes expired notes", async () => {
// insert a note with expiry date in the past using prisma
@ -212,4 +275,54 @@ describe("Clean expired notes", () => {
testNote.ciphertext.length + testNote.hmac.length
);
});
it("removes notes with embeds", async () => {
// insert a note with embeds and with expiry date in the past using prisma
const note = {
...testNote,
expire_time: new Date(0),
} as EncryptedNote;
const embeds = [
{
embed_id: "EMBED_ID",
ciphertext: Buffer.from("sample_ciphertext").toString("base64"),
hmac: Buffer.from("sample_hmac").toString("base64"),
},
];
const { id } = await createNote(note, embeds);
// make request for note and check that response is 200
const res = await supertest(app).get(`/api/note/${id}`);
expect(res.statusCode).toBe(200);
const embedRes = await supertest(app).get(
`/api/note/${id}/embeds/EMBED_ID`
);
expect(embedRes.statusCode).toBe(200);
// run cleanup
const nDeleted = await deleteExpiredNotes();
expect(nDeleted).toBeGreaterThan(0);
// if the note is added to the expire filter, it returns 410
const res2 = await supertest(app).get(`/api/note/${id}`);
expect(res2.statusCode).toBe(410);
// check that the embed is not found
const embedRes2 = await supertest(app).get(
`/api/note/${id}/embeds/EMBED_ID`
);
expect(embedRes2.statusCode).toBe(404);
});
});
function expectCodeOrThrowResponse(res: supertest.Response, expected: number) {
try {
expect(res.status).toBe(expected);
} catch (e) {
(e as Error).message = `
Unexpected status ${res.status} (expected ${expected}):
Response body: ${res.text}`;
throw e;
}
}

View File

@ -9,9 +9,6 @@ import { deleteExpiredNotes, deleteInterval } from "./tasks/deleteExpiredNotes";
// Initialize middleware clients
export const app: Express = express();
// Enable JSON body parsing
app.use(express.json({}));
// configure logging
app.use(
pinoHttp({

View File

@ -0,0 +1,20 @@
import type { NextFunction, Request, Response } from "express";
import { getEmbed } from "../../../db/embed.dao";
export async function getEmbedController(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const { id: note_id, embed_id } = req.params;
try {
const embed = await getEmbed(note_id, embed_id);
if (embed != null) {
res.status(200).json(embed).send();
} else {
res.status(404).send();
}
} catch (err) {
next(err);
}
}

View File

@ -0,0 +1,87 @@
import express from "express";
import supertest from "supertest";
import { vi, it, expect, describe, beforeEach, afterEach } from "vitest";
import { getEmbedController } from "./embeds.get.controller";
import * as embedDao from "../../../db/embed.dao";
vi.mock("../../../db/embed.dao");
const MOCK_EMBED_DTO: embedDao.EncryptedEmbedDTO = {
note_id: "valid_note_id",
embed_id: "valid_embed_id",
ciphertext: Buffer.from("sample_ciphertext").toString("base64"),
hmac: Buffer.from("sample_hmac").toString("base64"),
};
describe("Test GET embeds", () => {
let app: express.Express;
let mockEmbedDao = vi.mocked(embedDao);
beforeEach(() => {
app = express()
.use(express.json())
.get("/:id/embeds/:embed_id", getEmbedController);
});
afterEach(() => {
vi.resetAllMocks();
});
it("Should return 200 for a valid note_id+embed_id pair", async () => {
// mock db response
mockEmbedDao.getEmbed.mockImplementation(async (noteId, embedId) => {
if (
noteId === MOCK_EMBED_DTO.note_id &&
embedId === MOCK_EMBED_DTO.embed_id
) {
return MOCK_EMBED_DTO;
}
return null;
});
// make request
const res = await supertest(app).get(
"/valid_note_id/embeds/valid_embed_id"
);
// check response
expect(res.statusCode).toBe(200);
expect(res.body).toMatchObject(MOCK_EMBED_DTO);
});
it("Should return 404 for an invalid note_id+embed_id pair", async () => {
// mock db response
mockEmbedDao.getEmbed.mockImplementation(async (noteId, embedId) => {
if (
noteId === MOCK_EMBED_DTO.note_id &&
embedId === MOCK_EMBED_DTO.embed_id
) {
return MOCK_EMBED_DTO;
}
return null;
});
// make request
const res = await supertest(app).get(
"/invalid_note_id/embeds/invalid_embed_id"
);
// check response
expect(res.statusCode).toBe(404);
});
it("Should return 500 on database failure", async () => {
// mock db response
mockEmbedDao.getEmbed.mockImplementation(async (noteId, embedId) => {
throw new Error("Database failure");
});
// make request
const res = await supertest(app).get(
"/valid_note_id/embeds/valid_embed_id"
);
// check response
expect(res.statusCode).toBe(500);
});
});

View File

@ -0,0 +1,6 @@
import express from "express";
import { getEmbedController } from "./embeds.get.controller";
export const embedsRoute = express.Router({ mergeParams: true });
embedsRoute.get("/:embed_id", getEmbedController);

View File

@ -1,77 +0,0 @@
import { validateOrReject, ValidationError } from "class-validator";
import { NextFunction, Request, Response } from "express";
import { deleteNote, getNote } from "../../db/note.dao";
import checkId from "../../lib/checkUserId";
import { getNoteFilter } from "../../lib/expiredNoteFilter";
import EventLogger, { WriteEvent } from "../../logging/EventLogger";
import { getConnectingIp, getNoteSize } from "../../util";
import { NoteDeleteRequest } from "../../validation/Request";
export async function deleteNoteController(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const event: WriteEvent = {
success: false,
host: getConnectingIp(req),
user_id: req.body.user_id,
user_plugin_version: req.body.plugin_version,
};
// Validate request body
const noteDeleteRequest = new NoteDeleteRequest();
Object.assign(noteDeleteRequest, req.body);
try {
await validateOrReject(noteDeleteRequest);
} catch (_err: any) {
const err = _err as ValidationError;
res.status(400).send(err.toString());
event.error = err.toString();
await EventLogger.deleteEvent(event);
return;
}
// Validate user ID, if present
if (noteDeleteRequest.user_id && !checkId(noteDeleteRequest.user_id)) {
console.log("invalid user id");
res.status(400).send("Invalid user id (checksum failed)");
event.error = "Invalid user id (checksum failed)";
EventLogger.deleteEvent(event);
return;
}
// get note from db
const note = await getNote(req.params.id);
if (!note) {
res.status(404).send("Note not found");
event.error = "Note not found";
await EventLogger.deleteEvent(event);
return;
}
// Validate secret token
if (note.secret_token !== req.body.secret_token) {
res.status(401).send("Invalid token");
event.error = "Invalid secret token";
await EventLogger.deleteEvent(event);
return;
}
// Delete note
try {
await deleteNote(note.id);
res.status(200).send();
event.success = true;
event.note_id = note.id;
event.size_bytes = getNoteSize(note);
await EventLogger.deleteEvent(event);
} catch (err) {
event.error = (err as Error).toString();
await EventLogger.deleteEvent(event);
next(err);
}
const filter = await getNoteFilter("deletedNotes");
await filter.addNoteIds([note.id]);
}

View File

@ -1,102 +0,0 @@
import { EncryptedNote } from "@prisma/client";
import express from "express";
import supertest from "supertest";
import { vi, describe, it, beforeEach, afterEach, expect } from "vitest";
import * as noteDao from "../../db/note.dao";
import * as bloomFilter from "../../db/bloomFilter.dao";
import EventLogger from "../../logging/EventLogger";
import { deleteNoteController } from "./note.delete.controller";
vi.mock("../../db/note.dao");
vi.mock("../../db/bloomFilter.dao");
vi.mock("../../logging/EventLogger");
const VALID_USER_ID = "f06536e7df6857fc";
const MOCK_SECRET_TOKEN = "U0VDUkVUX1RPS0VO";
const MOCK_NOTE_ID = "NOTE_ID";
describe("note.delete.controller", () => {
let mockNoteDao = vi.mocked(noteDao);
let mockEventLogger = vi.mocked(EventLogger);
let mockBloomFilterDao = vi.mocked(bloomFilter);
const test_app = express()
.use(express.json())
.delete("/:id", deleteNoteController);
beforeEach(() => {
mockNoteDao.getNote.mockImplementation(async (noteId) => {
if (noteId === MOCK_NOTE_ID) {
return {
id: MOCK_NOTE_ID,
secret_token: MOCK_SECRET_TOKEN,
} as EncryptedNote;
} else {
return null;
}
});
mockNoteDao.deleteNote.mockImplementation(async (id) => {
if (id === MOCK_NOTE_ID) {
return vi.fn() as unknown as EncryptedNote;
} else {
throw new Error("Note not found");
}
});
mockBloomFilterDao.getFilter.mockImplementation(async () => {
throw new Error("No BloomFilter found");
});
});
afterEach(() => {
vi.resetAllMocks();
});
it("Should delete a note with a valid secret token", async () => {
const response = await supertest(test_app)
.delete(`/${MOCK_NOTE_ID}`)
.send({ user_id: VALID_USER_ID, secret_token: MOCK_SECRET_TOKEN });
expect(response.status).toBe(200);
expect(mockNoteDao.deleteNote).toBeCalledWith(MOCK_NOTE_ID);
expect(mockEventLogger.deleteEvent).toBeCalledWith(
expect.objectContaining({
note_id: MOCK_NOTE_ID,
user_id: VALID_USER_ID,
success: true,
})
);
});
it("Should return 401 for an invalid secret token", async () => {
const response = await supertest(test_app)
.delete(`/${MOCK_NOTE_ID}`)
.send({ user_id: VALID_USER_ID, secret_token: "0000" });
expect(response.status).toBe(401);
expect(mockNoteDao.deleteNote).not.toBeCalled();
expect(mockEventLogger.deleteEvent).toBeCalledWith(
expect.objectContaining({
user_id: VALID_USER_ID,
success: false,
})
);
});
it("Should return 404 for a note that does not exist", async () => {
const response = await supertest(test_app)
.delete("/0000")
.send({ user_id: VALID_USER_ID, secret_token: MOCK_SECRET_TOKEN });
expect(response.status).toBe(404);
expect(mockNoteDao.deleteNote).not.toBeCalled();
expect(mockEventLogger.deleteEvent).toBeCalledWith(
expect.objectContaining({
user_id: VALID_USER_ID,
success: false,
})
);
});
});

View File

@ -1,8 +1,9 @@
import { NextFunction, Request, Response } from "express";
import { getNoteFilter } from "../../lib/expiredNoteFilter";
import { getExpiredNoteFilter } from "../../lib/expiredNoteFilter";
import EventLogger from "../../logging/EventLogger";
import { getConnectingIp, getNoteSize } from "../../util";
import { getConnectingIp } from "../../util";
import { getNote } from "../../db/note.dao";
export async function getNoteController(
req: Request,
res: Response,
@ -16,23 +17,13 @@ export async function getNoteController(
success: true,
host: ip,
note_id: note.id,
size_bytes: getNoteSize(note),
size_bytes: note.ciphertext.length + note.hmac.length,
});
res.send(note);
} else {
// check the expired filter to see if the note was expired
const deletedFilter = await getNoteFilter("deletedNotes");
const expiredFilter = await getNoteFilter("expiredNotes");
if (deletedFilter.hasNoteId(req.params.id)) {
await EventLogger.readEvent({
success: false,
host: ip,
note_id: req.params.id,
error: "Note deleted",
});
res.status(410).send("Note deleted");
} else if (expiredFilter.hasNoteId(req.params.id)) {
const expiredFilter = await getExpiredNoteFilter();
if (expiredFilter.hasNoteId(req.params.id)) {
await EventLogger.readEvent({
success: false,
host: ip,
@ -47,7 +38,7 @@ export async function getNoteController(
note_id: req.params.id,
error: "Note not found",
});
res.status(404).send("Note not found");
res.status(404).send();
}
}
})

View File

@ -1,16 +1,64 @@
import { EncryptedNote } from "@prisma/client";
import { EncryptedNote, PrismaClient } from "@prisma/client";
import { NextFunction, Request, Response } from "express";
import { crc16 as crc } from "crc";
import { createNote } from "../../db/note.dao";
import { addDays, getConnectingIp, getNoteSize } from "../../util";
import { addDays, getConnectingIp } from "../../util";
import EventLogger, { WriteEvent } from "../../logging/EventLogger";
import { validateOrReject, ValidationError } from "class-validator";
import { generateToken } from "../../crypto/GenerateToken";
import { NotePostRequest } from "../../validation/Request";
import checkId from "../../lib/checkUserId";
import {
validateOrReject,
IsBase64,
IsHexadecimal,
IsNotEmpty,
ValidateIf,
ValidationError,
Matches,
IsString,
IsArray,
ValidateNested,
} from "class-validator";
import prisma from "../../db/client";
export class EncryptedEmbedBody {
@IsBase64()
@IsNotEmpty()
ciphertext!: string;
@IsBase64()
@IsNotEmpty()
hmac!: string;
@IsString()
@IsNotEmpty()
embed_id!: string;
}
/**
* Request body for creating a note
*/
export class NotePostRequest {
@IsBase64()
@IsNotEmpty()
ciphertext: string | undefined;
@IsBase64()
@IsNotEmpty()
hmac: string | undefined;
@ValidateIf((o) => o.user_id != null)
@IsHexadecimal()
user_id: string | undefined;
@ValidateIf((o) => o.plugin_version != null)
@Matches("^[0-9]+\\.[0-9]+\\.[0-9]+$")
plugin_version: string | undefined;
@Matches("^v[0-9]+$")
crypto_version: string = "v1";
// validate the shape of each item manually, avoid need for class-transformer package
@IsArray()
embeds: EncryptedEmbedBody[] = [];
}
export async function postNoteController(
req: Request,
@ -26,9 +74,18 @@ export async function postNoteController(
// Validate request body
const notePostRequest = new NotePostRequest();
const noteEmbedRequests: EncryptedEmbedBody[] = [];
Object.assign(notePostRequest, req.body);
try {
await validateOrReject(notePostRequest);
if (notePostRequest.embeds && notePostRequest.embeds.length > 0) {
for (const embed of notePostRequest.embeds) {
const embedBody = new EncryptedEmbedBody();
Object.assign(embedBody, embed);
await validateOrReject(embedBody);
noteEmbedRequests.push(embedBody);
}
}
} catch (_err: any) {
const err = _err as ValidationError;
res.status(400).send(err.toString());
@ -39,7 +96,6 @@ export async function postNoteController(
// Validate user ID, if present
if (notePostRequest.user_id && !checkId(notePostRequest.user_id)) {
console.log("invalid user id");
res.status(400).send("Invalid user id (checksum failed)");
event.error = "Invalid user id (checksum failed)";
EventLogger.writeEvent(event);
@ -48,35 +104,57 @@ export async function postNoteController(
// Create note object
const EXPIRE_WINDOW_DAYS = 30;
const secret_token = generateToken();
const note = {
ciphertext: notePostRequest.ciphertext as string,
hmac: notePostRequest.hmac as string,
iv: notePostRequest.iv as string,
expire_time: addDays(new Date(), EXPIRE_WINDOW_DAYS),
crypto_version: notePostRequest.crypto_version,
secret_token: secret_token,
} as EncryptedNote;
// Store note object
createNote(note)
.then(async (savedNote) => {
event.success = true;
event.note_id = savedNote.id;
event.size_bytes = getNoteSize(note);
event.expire_window_days = EXPIRE_WINDOW_DAYS;
await EventLogger.writeEvent(event);
res.json({
view_url: `${process.env.FRONTEND_URL}/note/${savedNote.id}`,
expire_time: savedNote.expire_time,
secret_token: savedNote.secret_token,
note_id: savedNote.id,
});
})
.catch(async (err) => {
event.error = err.toString();
await EventLogger.writeEvent(event);
next(err);
// Store note object and possible embeds in database transaction
try {
const savedNote = await createNote(note, noteEmbedRequests);
// Log write event
event.success = true;
event.note_id = savedNote.id;
event.size_bytes = savedNote.ciphertext.length + savedNote.hmac.length;
event.expire_window_days = EXPIRE_WINDOW_DAYS;
await EventLogger.writeEvent(event);
// return HTTP request
res.json({
view_url: `${process.env.FRONTEND_URL}/note/${savedNote.id}`,
expire_time: savedNote.expire_time,
});
} catch (err: any) {
// if the error matches "Duplicate embed", return a 409 conflict
event.error = err.toString();
await EventLogger.writeEvent(event);
if (err.message.includes("Duplicate embed")) {
res.status(409).send(err.message);
} else {
next(err);
}
}
}
/**
* @param id {string} a 16 character base16 string with 12 random characters and 4 CRC characters
* @returns {boolean} true if the id is valid, false otherwise
*/
function checkId(id: string): boolean {
// check length
if (id.length !== 16) {
return false;
}
// extract the random number and the checksum
const random = id.slice(0, 12);
const checksum = id.slice(12, 16);
// compute the CRC of the random number
const computedChecksum = crc(random).toString(16).padStart(4, "0");
// compare the computed checksum with the one in the id
return computedChecksum === checksum;
}

View File

@ -2,11 +2,16 @@ import express from "express";
import supertest from "supertest";
import { vi, describe, it, beforeEach, afterEach, expect } from "vitest";
import * as noteDao from "../../db/note.dao";
import * as embedDao from "../../db/embed.dao";
import EventLogger from "../../logging/EventLogger";
import { NotePostRequest } from "../../validation/Request";
import { postNoteController } from "./note.post.controller";
import {
EncryptedEmbedBody,
NotePostRequest,
postNoteController,
} from "./note.post.controller";
vi.mock("../../db/note.dao");
vi.mock("../../db/embed.dao");
vi.mock("../../logging/EventLogger");
const VALID_CIPHERTEXT = Buffer.from("sample_ciphertext").toString("base64");
@ -20,8 +25,10 @@ const VALID_CRYPTO_VERSION = "v99";
const MALFORMED_CRYPTO_VERSION = "32";
const MOCK_NOTE_ID = "1234";
const MOCK_EMBED_ID = "abcd";
type TestParams = {
case: string;
payload: Partial<NotePostRequest>;
expectedStatus: number;
};
@ -29,6 +36,7 @@ type TestParams = {
const TEST_PAYLOADS: TestParams[] = [
// Request with valid ciphertext and hmac
{
case: "valid ciphertext and hmac",
payload: {
ciphertext: VALID_CIPHERTEXT,
hmac: VALID_HMAC,
@ -37,6 +45,7 @@ const TEST_PAYLOADS: TestParams[] = [
},
// Request with valid ciphertext, hmac, user id, and plugin version
{
case: "valid ciphertext, hmac, user id, and plugin version",
payload: {
ciphertext: VALID_CIPHERTEXT,
hmac: VALID_HMAC,
@ -47,6 +56,7 @@ const TEST_PAYLOADS: TestParams[] = [
},
// Request with non-base64 ciphertext
{
case: "non-base64 ciphertext",
payload: {
ciphertext: "not_base64",
hmac: VALID_HMAC,
@ -55,6 +65,7 @@ const TEST_PAYLOADS: TestParams[] = [
},
// Request with non-base64 hmac
{
case: "non-base64 hmac",
payload: {
ciphertext: VALID_CIPHERTEXT,
hmac: "not_base64",
@ -63,6 +74,7 @@ const TEST_PAYLOADS: TestParams[] = [
},
// Request with empty ciphertext
{
case: "empty ciphertext",
payload: {
ciphertext: "",
hmac: VALID_HMAC,
@ -71,6 +83,7 @@ const TEST_PAYLOADS: TestParams[] = [
},
// Request with empty hmac
{
case: "empty hmac",
payload: {
ciphertext: VALID_CIPHERTEXT,
hmac: "",
@ -79,6 +92,7 @@ const TEST_PAYLOADS: TestParams[] = [
},
// Request with valid user id
{
case: "valid user id",
payload: {
ciphertext: VALID_CIPHERTEXT,
hmac: VALID_HMAC,
@ -88,6 +102,7 @@ const TEST_PAYLOADS: TestParams[] = [
},
// Request with malformed user id (wrong crc)
{
case: "malformed user id (wrong crc)",
payload: {
ciphertext: VALID_CIPHERTEXT,
hmac: VALID_HMAC,
@ -97,6 +112,7 @@ const TEST_PAYLOADS: TestParams[] = [
},
// Request with malformed user id (wrong length)
{
case: "malformed user id (wrong length)",
payload: {
ciphertext: VALID_CIPHERTEXT,
hmac: VALID_HMAC,
@ -106,6 +122,7 @@ const TEST_PAYLOADS: TestParams[] = [
},
// Request with valid plugin version
{
case: "valid plugin version",
payload: {
ciphertext: VALID_CIPHERTEXT,
hmac: VALID_HMAC,
@ -115,6 +132,7 @@ const TEST_PAYLOADS: TestParams[] = [
},
// Request with malformed plugin version
{
case: "malformed plugin version",
payload: {
ciphertext: VALID_CIPHERTEXT,
hmac: VALID_HMAC,
@ -124,6 +142,7 @@ const TEST_PAYLOADS: TestParams[] = [
},
// Request with valid ciphertext, hmac, user id, plugin version, and crypto version
{
case: "valid ciphertext, hmac, user id, plugin version, and crypto version",
payload: {
ciphertext: VALID_CIPHERTEXT,
hmac: VALID_HMAC,
@ -135,6 +154,7 @@ const TEST_PAYLOADS: TestParams[] = [
},
// Request with malformed crypto version
{
case: "malformed crypto version",
payload: {
ciphertext: VALID_CIPHERTEXT,
hmac: VALID_HMAC,
@ -144,33 +164,164 @@ const TEST_PAYLOADS: TestParams[] = [
},
expectedStatus: 400,
},
// Request with empty embeds array
{
case: "empty embeds array",
payload: {
ciphertext: VALID_CIPHERTEXT,
hmac: VALID_HMAC,
user_id: VALID_USER_ID,
plugin_version: VALID_VERSION,
crypto_version: VALID_CRYPTO_VERSION,
embeds: [],
},
expectedStatus: 200,
},
// Request with single valid embed
{
case: "valid embeds array",
payload: {
ciphertext: VALID_CIPHERTEXT,
hmac: VALID_HMAC,
user_id: VALID_USER_ID,
embeds: [
{
embed_id: "0",
ciphertext: VALID_CIPHERTEXT,
hmac: VALID_HMAC,
},
],
},
expectedStatus: 200,
},
// Request with embed with empty embed_id
{
case: "embed with empty embed_id",
payload: {
ciphertext: VALID_CIPHERTEXT,
hmac: VALID_HMAC,
user_id: VALID_USER_ID,
embeds: [
{
ciphertext: VALID_CIPHERTEXT,
hmac: VALID_HMAC,
} as EncryptedEmbedBody,
],
},
expectedStatus: 400,
},
// Request with embed with non-base64 ciphertext
{
case: "embed with non-base64 ciphertext",
payload: {
ciphertext: VALID_CIPHERTEXT,
hmac: VALID_HMAC,
user_id: VALID_USER_ID,
embeds: [
{
ciphertext: "not_base64",
hmac: VALID_HMAC,
embed_id: "0",
},
],
},
expectedStatus: 400,
},
// Request with embed with non-base64 hmac
{
case: "embed with non-base64 hmac",
payload: {
ciphertext: VALID_CIPHERTEXT,
hmac: VALID_HMAC,
user_id: VALID_USER_ID,
embeds: [
{
ciphertext: VALID_CIPHERTEXT,
hmac: "not_base64",
embed_id: "0",
},
],
},
expectedStatus: 400,
},
// Request with duplicate embeds
{
case: "duplicate embeds",
payload: {
ciphertext: VALID_CIPHERTEXT,
hmac: VALID_HMAC,
user_id: VALID_USER_ID,
embeds: [
{
ciphertext: VALID_CIPHERTEXT,
hmac: VALID_HMAC,
embed_id: "0",
},
{
ciphertext: VALID_CIPHERTEXT,
hmac: VALID_HMAC,
embed_id: "0",
},
],
},
expectedStatus: 409,
},
];
describe("note.post.controller", () => {
describe("Execute test cases", () => {
let mockNoteDao = vi.mocked(noteDao);
let mockEmbedDao = vi.mocked(embedDao);
let mockEventLogger = vi.mocked(EventLogger);
const test_app = express().use(express.json()).post("/", postNoteController);
beforeEach(() => {
// database writes always succeed
mockNoteDao.createNote.mockImplementation(async (note) => ({
...note,
id: MOCK_NOTE_ID,
insert_time: new Date(),
}));
const storedEmbeds: string[] = [];
mockNoteDao.createNote.mockImplementation(async (note, embeds) => {
if (embeds && embeds.length > 0) {
for (const e of embeds) {
if (storedEmbeds.find((s) => s === MOCK_NOTE_ID + e.embed_id)) {
throw new Error("Duplicate embed");
}
storedEmbeds.push(MOCK_NOTE_ID + e.embed_id);
}
}
return {
...note,
id: MOCK_NOTE_ID,
insert_time: new Date(),
};
});
mockEmbedDao.createEmbed.mockImplementation(async (embed) => {
storedEmbeds.push(embed.note_id + embed.embed_id);
return {
...embed,
ciphertext: Buffer.from(embed.ciphertext, "base64"),
id: MOCK_EMBED_ID,
size_bytes: embed.ciphertext.length,
};
});
});
afterEach(() => {
vi.resetAllMocks();
});
it.each(TEST_PAYLOADS)("test payloads", async (params) => {
it.each(TEST_PAYLOADS)("Case %#: $case", async (params) => {
const { payload, expectedStatus } = params;
// make request
const res = await supertest(test_app).post("/").send(payload);
expect(res.status).toBe(expectedStatus);
try {
expect(res.status).toBe(expectedStatus);
} catch (e) {
throw new Error(`
Unexpected status ${res.status} (expected ${expectedStatus}):
Response body: ${res.text}`);
}
// Validate reponse body
if (expectedStatus === 200) {
@ -194,7 +345,8 @@ describe("note.post.controller", () => {
hmac: payload.hmac,
crypto_version: payload.crypto_version || "v1",
expire_time: expect.any(Date),
})
}),
expect.arrayContaining(payload?.embeds ?? [])
);
}

View File

@ -1,12 +1,14 @@
import bodyParser from "body-parser";
import express from "express";
import rateLimit from "express-rate-limit";
import { deleteNoteController } from "./note.delete.controller";
import { embedsRoute } from "./embeds/embeds.router";
import { getNoteController } from "./note.get.controller";
import { postNoteController } from "./note.post.controller";
export const notesRoute = express.Router();
const jsonParser = express.json({ limit: "500k" });
const jsonParser = express.json();
const uploadLimit = bodyParser.json({ limit: "8mb" });
const postRateLimit = rateLimit({
windowMs: parseFloat(process.env.POST_LIMIT_WINDOW_SECONDS as string) * 1000,
@ -23,7 +25,8 @@ const getRateLimit = rateLimit({
});
// notesRoute.use(jsonParser, uploadLimit);
notesRoute.use("/:id/embeds", embedsRoute);
notesRoute.use(uploadLimit);
notesRoute.use(jsonParser);
notesRoute.post("", postRateLimit, postNoteController);
notesRoute.get("/:id", getRateLimit, getNoteController);
notesRoute.delete("/:id", getRateLimit, deleteNoteController);
notesRoute.route("/").post(postRateLimit, postNoteController);
notesRoute.route("/:id").get(getRateLimit, getNoteController);

View File

@ -1,9 +0,0 @@
import crypto from "crypto";
/**
* Generates a 256 bit token using the nodeJS crypto module.
* @returns base 64-encoded token.
*/
export function generateToken(): string {
return crypto.randomBytes(32).toString("base64");
}

View File

@ -0,0 +1,4 @@
import { vi } from "vitest";
export const getEmbed = vi.fn();
export const createEmbed = vi.fn();

View File

@ -1,7 +1,6 @@
import { vi } from "vitest";
export const getNote = vi.fn();
export const createNote = vi.fn();
export const getExpiredNotes = vi.fn();
export const deleteNotes = vi.fn();
export const deleteNote = vi.fn();
export const createNote = vi.fn();

View File

@ -0,0 +1,85 @@
import { describe, it, expect } from "vitest";
import type { EncryptedNote } from "@prisma/client";
import { getEmbed, createEmbed } from "./embed.dao";
import { createNote } from "./note.dao";
const VALID_CIPHERTEXT = Buffer.from("sample_ciphertext").toString("base64");
describe("Reading and writing embeds", () => {
it("Should write embeds for existing note", async () => {
const note = await createNote({
ciphertext: "test",
hmac: "test",
crypto_version: "v2",
} as EncryptedNote);
const embed = {
note_id: note.id,
embed_id: "embed_id",
hmac: "hmac",
ciphertext: VALID_CIPHERTEXT,
};
const res = await createEmbed(embed);
expect(res.note_id).toEqual(embed.note_id);
expect(res.embed_id).toEqual(embed.embed_id);
expect(res.hmac).toEqual(embed.hmac);
expect(res.id).not.toBeNull();
expect(res.id.length).toBeGreaterThan(0);
expect(res.ciphertext.byteLength).toBeGreaterThan(0);
});
it("Should throw if note_id does not refer to existing note", async () => {
const embed = {
note_id: "note_id",
embed_id: "embed_id",
hmac: "hmac",
ciphertext: VALID_CIPHERTEXT,
};
await expect(createEmbed(embed)).rejects.toThrowError();
});
it("Should throw if embed_id is not unique", async () => {
const note = await createNote({
ciphertext: "test",
hmac: "test",
crypto_version: "v2",
} as EncryptedNote);
const embed = {
note_id: note.id,
embed_id: "embed_id",
hmac: "hmac",
ciphertext: VALID_CIPHERTEXT,
};
await createEmbed(embed); // embed 1
await expect(createEmbed(embed)).rejects.toThrowError(/Duplicate embed/g); // duplicate embed
});
it("Should read embeds for existing note", async () => {
const note = await createNote({
ciphertext: "test",
hmac: "test",
crypto_version: "v2",
} as EncryptedNote);
const embed = {
note_id: note.id,
embed_id: "embed_id",
hmac: "hmac",
ciphertext: VALID_CIPHERTEXT,
};
await createEmbed(embed);
const res = await getEmbed(note.id, embed.embed_id);
expect(res).not.toBeNull();
expect(res?.note_id).toEqual(embed.note_id);
expect(res?.embed_id).toEqual(embed.embed_id);
expect(res?.hmac).toEqual(embed.hmac);
expect(res?.ciphertext).toEqual(embed.ciphertext);
});
});

View File

@ -0,0 +1,68 @@
import { EncryptedEmbed, Prisma, PrismaClient } from "@prisma/client";
import { BufferToBase64, base64ToBuffer } from "../util";
import prisma from "./client";
export interface EncryptedEmbedDTO {
note_id: string;
embed_id: string;
ciphertext: string; // in base64
hmac: string;
}
/**
* Get an embed for a note by embed_id.
* @param noteId note id
* @param embedId embed id
* @returns encrypted embed (serialized ciphertext to base64)
*/
export async function getEmbed(
noteId: string,
embedId: string
): Promise<EncryptedEmbedDTO | null> {
const embed = await prisma.encryptedEmbed.findUnique({
where: {
noteId_embedId: {
note_id: noteId,
embed_id: embedId,
},
},
});
if (!embed) return null;
return {
note_id: embed.note_id,
embed_id: embed.embed_id,
hmac: embed.hmac,
ciphertext: BufferToBase64(embed.ciphertext),
};
}
/**
* Create an embed for a note.
* @param embed EncryptedEmbedDTO to serialize and save
* @param transactionClient optionally pass a TransactionClient object when running in a Prisma interactive transaction
* @returns the saved EncryptedEmbed (deserialized ciphertext to Buffer)
*/
export async function createEmbed(
embed: EncryptedEmbedDTO,
transactionClient: Prisma.TransactionClient = prisma
): Promise<EncryptedEmbed> {
const cipher_buf = base64ToBuffer(embed.ciphertext);
const data = {
note_id: embed.note_id,
embed_id: embed.embed_id,
hmac: embed.hmac,
ciphertext: cipher_buf,
size_bytes: cipher_buf.byteLength,
} as EncryptedEmbed;
return transactionClient.encryptedEmbed.create({ data }).catch((err) => {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
// The .code property can be accessed in a type-safe manner
if (err.code === "P2002") {
throw new Error("Duplicate embed");
}
}
throw err;
});
}

View File

@ -0,0 +1,115 @@
import { EncryptedNote } from "@prisma/client";
import { describe, it, expect } from "vitest";
import { getEmbed } from "./embed.dao";
import { createNote, deleteNotes, getExpiredNotes, getNote } from "./note.dao";
import prisma from "./client";
const VALID_CIPHERTEXT = Buffer.from("sample_ciphertext").toString("base64");
const VALID_HMAC = Buffer.from("sample_hmac").toString("base64");
const VALID_NOTE = {
ciphertext: VALID_CIPHERTEXT,
hmac: VALID_HMAC,
crypto_version: "v2",
expire_time: new Date(),
} as EncryptedNote;
const VALID_EMBED = {
embed_id: "embed_id",
hmac: VALID_HMAC,
ciphertext: VALID_CIPHERTEXT,
};
describe("Writes and reads", () => {
it("should write a new note", async () => {
const res = await createNote(VALID_NOTE);
expect(res.id).not.toBeNull();
expect(res.id.length).toBeGreaterThan(0);
expect(res.ciphertext).toStrictEqual(VALID_NOTE.ciphertext);
expect(res.hmac).toStrictEqual(VALID_NOTE.hmac);
expect(res.crypto_version).toStrictEqual(VALID_NOTE.crypto_version);
expect(res.expire_time).toStrictEqual(VALID_NOTE.expire_time);
expect(res.insert_time).not.toBeNull();
expect(res.insert_time.getTime()).toBeLessThanOrEqual(new Date().getTime());
});
it("should write a new note with one embed", async () => {
const res = await createNote(VALID_NOTE, [VALID_EMBED]);
expect(res.id).not.toBeNull();
const res2 = await getEmbed(res.id, VALID_EMBED.embed_id);
expect(res2).not.toBeNull();
expect(res2?.ciphertext).toStrictEqual(VALID_EMBED.ciphertext);
expect(res2?.hmac).toStrictEqual(VALID_EMBED.hmac);
});
it("should write a new note with multiple embeds", async () => {
const res = await createNote(VALID_NOTE, [
VALID_EMBED,
{ ...VALID_EMBED, embed_id: "embed_id2" },
]);
expect(res.id).not.toBeNull();
const res2 = await getEmbed(res.id, VALID_EMBED.embed_id);
expect(res2?.embed_id).toStrictEqual(VALID_EMBED.embed_id);
const res3 = await getEmbed(res.id, "embed_id2");
expect(res3?.embed_id).toStrictEqual("embed_id2");
}),
it("should fail writing a new note with duplicate embed_ids", async () => {
await expect(
createNote(VALID_NOTE, [VALID_EMBED, VALID_EMBED])
).rejects.toThrowError();
});
it("should roll back a failed note with embeds", async () => {
const noteCount = (await prisma.encryptedNote.findMany())?.length;
const embedCount = (await prisma.encryptedEmbed.findMany())?.length;
await expect(
createNote({ ...VALID_NOTE }, [VALID_EMBED, VALID_EMBED])
).rejects.toThrowError();
const noteCountAfter = (await prisma.encryptedNote.findMany())?.length;
const embedCountAfter = (await prisma.encryptedEmbed.findMany())?.length;
expect(noteCountAfter).toStrictEqual(noteCount);
expect(embedCountAfter).toStrictEqual(embedCount);
});
it("should find an existing note by id", async () => {
const note = await createNote(VALID_NOTE);
const res = await getNote(note.id);
expect(res).not.toBeNull();
expect(res).toMatchObject(note);
});
it("should not find a non-existing note by id", async () => {
const res = await getNote("non-existing-id");
expect(res).toBeNull();
});
it("should properly delete notes", async () => {
const note = await createNote(VALID_NOTE);
const res = await getNote(note.id);
expect(res).not.toBeNull();
const res2 = await deleteNotes([note.id]);
expect(res2).toBe(1);
const res3 = await getNote(note.id);
expect(res3).toBeNull();
});
it("should return expired notes", async () => {
const expiredNote = await createNote({
...VALID_NOTE,
expire_time: new Date(0),
});
const freshNote = await createNote({
...VALID_NOTE,
expire_time: new Date(Date.now() + 1000),
});
const res = await getExpiredNotes();
expect(res).toContainEqual(expiredNote);
expect(res).not.toContainEqual(freshNote);
});
});

View File

@ -1,5 +1,12 @@
import { EncryptedNote } from "@prisma/client";
import prisma from "./client";
import { createEmbed, EncryptedEmbedDTO } from "./embed.dao";
type EncryptedEmbed = {
ciphertext: string;
hmac: string;
embed_id: string;
};
export async function getNote(noteId: string): Promise<EncryptedNote | null> {
return prisma.encryptedNote.findUnique({
@ -7,9 +14,32 @@ export async function getNote(noteId: string): Promise<EncryptedNote | null> {
});
}
export async function createNote(note: EncryptedNote): Promise<EncryptedNote> {
return prisma.encryptedNote.create({
data: note,
export async function createNote(
note: EncryptedNote,
embeds: EncryptedEmbed[] = []
): Promise<EncryptedNote> {
return prisma.$transaction(async (transactionClient) => {
// 1. Save note
const savedNote = await transactionClient.encryptedNote.create({
data: note,
});
// 2. Store embeds
if (embeds.length > 0) {
const _embeds: EncryptedEmbedDTO[] = embeds.map(
(embed) =>
({
...embed,
note_id: savedNote.id,
} as EncryptedEmbedDTO)
);
for (const embed of _embeds) {
await createEmbed(embed, transactionClient);
}
}
// 3. Finalize transaction
return savedNote;
});
}
@ -23,12 +53,6 @@ export async function getExpiredNotes(): Promise<EncryptedNote[]> {
});
}
export async function deleteNote(noteId: string): Promise<EncryptedNote> {
return prisma.encryptedNote.delete({
where: { id: noteId },
});
}
export async function deleteNotes(noteIds: string[]): Promise<number> {
return prisma.encryptedNote
.deleteMany({

View File

@ -1,21 +0,0 @@
import { crc16 as crc } from "crc";
/**
* @param id {string} a 16 character base16 string with 12 random characters and 4 CRC characters
* @returns {boolean} true if the id is valid, false otherwise
*/
export default function checkId(id: string): boolean {
// check length
if (id.length !== 16) {
return false;
}
// extract the random number and the checksum
const random = id.slice(0, 12);
const checksum = id.slice(12, 16);
// compute the CRC of the random number
const computedChecksum = crc(random).toString(16).padStart(4, "0");
// compare the computed checksum with the one in the id
return computedChecksum === checksum;
}

View File

@ -1,24 +1,16 @@
import { ScalableBloomFilter } from "bloom-filters";
import { getFilter, upsertFilter } from "../db/bloomFilter.dao";
export const EXPIRED_NOTES_FILTER_NAME = "expiredNotes" as const;
export const DELETED_NOTES_FILTER_NAME = "deletedNotes" as const;
type FilterName =
| typeof EXPIRED_NOTES_FILTER_NAME
| typeof DELETED_NOTES_FILTER_NAME;
export class NoteIdFilter {
export class ExpiredNoteFilter {
_filter: ScalableBloomFilter;
_name: string;
static FILTER_NAME = "expiredNotes";
private constructor(name: string, filter: ScalableBloomFilter) {
private constructor(filter: ScalableBloomFilter) {
this._filter = filter;
this._name = name;
}
public static async deserializeFromDb(name: string): Promise<NoteIdFilter> {
return NoteIdFilter._deserializeFilter(name)
public static async deserializeFromDb(): Promise<ExpiredNoteFilter> {
return ExpiredNoteFilter._deserializeFilter()
.catch((err) => {
if (err.message === "No BloomFilter found") {
return new ScalableBloomFilter();
@ -27,7 +19,7 @@ export class NoteIdFilter {
}
})
.then((filter) => {
return new NoteIdFilter(name, filter);
return new ExpiredNoteFilter(filter);
});
}
@ -43,26 +35,24 @@ export class NoteIdFilter {
}
private _serialize(): Promise<void> {
return upsertFilter(this._name, this._filter);
return upsertFilter(ExpiredNoteFilter.FILTER_NAME, this._filter);
}
private static _deserializeFilter(
name: string
): Promise<ScalableBloomFilter> {
return getFilter<ScalableBloomFilter>(name, ScalableBloomFilter);
private static _deserializeFilter(): Promise<ScalableBloomFilter> {
return getFilter<ScalableBloomFilter>(
this.FILTER_NAME,
ScalableBloomFilter
);
}
}
let _filters: Record<FilterName, NoteIdFilter | null> = {
expiredNotes: null,
deletedNotes: null,
};
let _filter: ExpiredNoteFilter;
export async function getNoteFilter(name: FilterName): Promise<NoteIdFilter> {
if (_filters[name] !== null) {
return _filters[name] as NoteIdFilter;
export async function getExpiredNoteFilter(): Promise<ExpiredNoteFilter> {
if (_filter) {
return _filter;
} else {
_filters[name] = await NoteIdFilter.deserializeFromDb(name);
return _filters[name] as NoteIdFilter;
_filter = await ExpiredNoteFilter.deserializeFromDb();
return _filter;
}
}

View File

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NoteIdFilter } from "./expiredNoteFilter";
import { ExpiredNoteFilter } from "./expiredNoteFilter";
import { ScalableBloomFilter } from "bloom-filters";
import * as dao from "../db/bloomFilter.dao";
@ -16,12 +16,12 @@ describe("Deserialization from database", () => {
mockedDao.getFilter.mockRejectedValue(new Error("No BloomFilter found"));
// test instatiation
const testFilter = await NoteIdFilter.deserializeFromDb("expiredNotes");
const testFilter = await ExpiredNoteFilter.deserializeFromDb();
expect(mockedDao.getFilter).toHaveBeenCalledWith(
"expiredNotes",
ScalableBloomFilter
);
expect(testFilter).toBeInstanceOf(NoteIdFilter);
expect(testFilter).toBeInstanceOf(ExpiredNoteFilter);
// expect the _filter property to be a fresh ScalableBloomFilter (capacity 8)
expect(testFilter._filter).toBeInstanceOf(ScalableBloomFilter);
@ -37,7 +37,7 @@ describe("Deserialization from database", () => {
mockedDao.getFilter.mockResolvedValue(bloomFilter);
// test instatiation
const testFilter = await NoteIdFilter.deserializeFromDb("expiredNotes");
const testFilter = await ExpiredNoteFilter.deserializeFromDb();
expect(mockedDao.getFilter).toHaveBeenCalledWith(
"expiredNotes",
ScalableBloomFilter
@ -51,7 +51,7 @@ describe("Deserialization from database", () => {
});
describe("Filter operations and serialization", () => {
let testFilter: NoteIdFilter;
let testFilter: ExpiredNoteFilter;
beforeEach(async () => {
const mockedDao = vi.mocked(dao);
@ -63,7 +63,7 @@ describe("Filter operations and serialization", () => {
});
it("should add multiple noteIds to the filter", async () => {
testFilter = await NoteIdFilter.deserializeFromDb("expiredNotes");
testFilter = await ExpiredNoteFilter.deserializeFromDb();
testFilter.addNoteIds(["test", "test2"]);
expect(testFilter.hasNoteId("test")).toBe(true);
expect(testFilter.hasNoteId("test2")).toBe(true);
@ -77,7 +77,7 @@ describe("Filter operations and serialization", () => {
});
it("Should have an error rate <1% for 1000 elements", async () => {
testFilter = await NoteIdFilter.deserializeFromDb("expiredNotes");
testFilter = await ExpiredNoteFilter.deserializeFromDb();
const elements = Array.from({ length: 1000 }, (_, i) => i.toString());
testFilter.addNoteIds(elements);

View File

@ -1,16 +1,13 @@
import { event } from "@prisma/client";
import prisma from "../db/client";
import logger from "./logger";
export enum EventType {
WRITE = "WRITE",
READ = "READ",
DELETE = "DELETE",
UPDATE = "UPDATE",
PURGE = "PURGE",
}
export interface Event {
interface Event {
success: boolean;
error?: string;
}
@ -28,10 +25,6 @@ export interface WriteEvent extends ClientEvent {
expire_window_days?: number;
}
interface DeleteEvent extends ClientEvent {}
interface UpdateEvent extends ClientEvent {}
interface ReadEvent extends ClientEvent {}
interface PurgeEvent extends Event {
@ -40,42 +33,19 @@ interface PurgeEvent extends Event {
}
export default class EventLogger {
private static printError(event: Event) {
if (event.error) {
logger.error(event.error);
}
}
public static writeEvent(event: WriteEvent): Promise<event> {
this.printError(event);
return prisma.event.create({
data: { type: EventType.WRITE, ...event },
});
}
public static readEvent(event: ReadEvent): Promise<event> {
this.printError(event);
return prisma.event.create({
data: { type: EventType.READ, ...event },
});
}
public static deleteEvent(event: DeleteEvent): Promise<event> {
this.printError(event);
return prisma.event.create({
data: { type: EventType.DELETE, ...event },
});
}
public static updateEvent(event: UpdateEvent): Promise<event> {
this.printError(event);
return prisma.event.create({
data: { type: EventType.UPDATE, ...event },
});
}
public static purgeEvent(event: PurgeEvent): Promise<event> {
this.printError(event);
return prisma.event.create({
data: { type: EventType.PURGE, ...event },
});

View File

@ -1,19 +1,9 @@
import { vi } from "vitest";
import { Event } from "../EventLogger";
import logger from "../logger";
const logEventToConsole = (event: Event) => {
if (event.error) {
console.error(event.error);
}
};
const mockedEventLogger = {
writeEvent: vi.fn(logEventToConsole),
readEvent: vi.fn(logEventToConsole),
purgeEvent: vi.fn(logEventToConsole),
deleteEvent: vi.fn(logEventToConsole),
updateEvent: vi.fn(logEventToConsole),
writeEvent: vi.fn(),
readEvent: vi.fn(),
purgeEvent: vi.fn(),
};
export default mockedEventLogger;

View File

@ -1,8 +1,7 @@
import { deleteNotes, getExpiredNotes } from "../db/note.dao";
import { getNoteFilter } from "../lib/expiredNoteFilter";
import { getExpiredNoteFilter } from "../lib/expiredNoteFilter";
import EventLogger from "../logging/EventLogger";
import logger from "../logging/logger";
import { getNoteSize } from "../util";
export async function deleteExpiredNotes(): Promise<number> {
logger.info("[Cleanup] Cleaning up expired notes...");
@ -12,18 +11,18 @@ export async function deleteExpiredNotes(): Promise<number> {
.then(async (deleteCount) => {
const logs = toDelete.map(async (note) => {
logger.info(
`[Cleanup] Deleted note ${note.id} with size ${getNoteSize(
note
)} bytes`
`[Cleanup] Deleted note ${note.id} with size ${
note.ciphertext.length + note.hmac.length
} bytes`
);
return EventLogger.purgeEvent({
success: true,
note_id: note.id,
size_bytes: getNoteSize(note),
size_bytes: note.ciphertext.length + note.hmac.length,
});
});
await Promise.all(logs);
const filter = await getNoteFilter("expiredNotes");
const filter = await getExpiredNoteFilter();
await filter.addNoteIds(toDelete.map((n) => n.id));
logger.info(`[Cleanup] Deleted ${deleteCount} expired notes.`);
return deleteCount;

View File

@ -11,7 +11,7 @@ vi.mock("../lib/expiredNoteFilter", () => {
const instance = {
addNoteIds: vi.fn(),
};
return { getNoteFilter: (name: string) => instance };
return { getExpiredNoteFilter: () => instance };
});
vi.spyOn(logger, "error");
@ -29,17 +29,15 @@ describe("deleteExpiredNotes", () => {
id: "test",
ciphertext: "test",
hmac: "test",
iv: null,
insert_time: new Date(),
expire_time: new Date(),
crypto_version: "v1",
secret_token: "secret_token",
},
]);
mockedDao.deleteNotes.mockResolvedValue(1);
// mock ExpiredNoteFilter
const mockedFilter = vi.mocked(await filter.getNoteFilter("expiredNotes"));
const mockedFilter = vi.mocked(await filter.getExpiredNoteFilter());
mockedFilter.addNoteIds.mockResolvedValue();
// test task call

View File

@ -1,4 +1,3 @@
import { EncryptedNote } from "@prisma/client";
import { Request } from "express";
export function addDays(date: Date, days: number): Date {
@ -13,10 +12,12 @@ export function getConnectingIp(req: Request): string {
req.socket.remoteAddress) as string;
}
export function getNoteSize(
note: Pick<EncryptedNote, "ciphertext" | "hmac" | "iv">
) {
return (
note.ciphertext.length + (note.hmac?.length ?? 0) + (note.iv?.length ?? 0)
);
// base64 to array buffer (Node JS api, so don't use atob or btoa)
export function base64ToBuffer(base64: string): Buffer {
return Buffer.from(base64, "base64");
}
// array buffer to base64 (Node JS api, so don't use atob or btoa)
export function BufferToBase64(buffer: Buffer): string {
return Buffer.from(buffer).toString("base64");
}

View File

@ -1,5 +1,10 @@
import { describe, it, expect } from "vitest";
import { addDays, getConnectingIp } from "./util";
import {
addDays,
BufferToBase64,
base64ToBuffer,
getConnectingIp,
} from "./util";
describe("addDays()", () => {
it("Should add n days to the input date", () => {
@ -8,3 +13,19 @@ describe("addDays()", () => {
expect(addDays(date, 30)).toEqual(expectedDate);
});
});
describe("converting to/from base64", () => {
it("Should convert a base64 string to an array buffer", () => {
const base64 = "EjRWeJA=";
const expectedBuffer = new Uint8Array([18, 52, 86, 120, 144]);
expect(new Uint8Array(base64ToBuffer(base64))).toStrictEqual(
expectedBuffer
);
});
it("Should convert an array buffer to a base64 string", () => {
const buffer = new Uint8Array([18, 52, 86, 120, 144]);
const expectedBase64 = "EjRWeJA=";
expect(BufferToBase64(buffer)).toEqual(expectedBase64);
});
});

View File

@ -1,40 +0,0 @@
import {
IsBase64,
IsHexadecimal,
IsNotEmpty,
Matches,
ValidateIf,
} from "class-validator";
abstract class NoteRequestBody {
@ValidateIf((o) => o.user_id != null)
@IsHexadecimal()
user_id: string | undefined;
@ValidateIf((o) => o.plugin_version != null)
@Matches("^[0-9]+\\.[0-9]+\\.[0-9]+$")
plugin_version: string | undefined;
}
export class NotePostRequest extends NoteRequestBody {
@IsBase64()
@IsNotEmpty()
ciphertext: string | undefined;
@IsBase64()
@ValidateIf((o) => !o.iv)
hmac?: string | undefined;
@IsBase64()
@ValidateIf((o) => !o.hmac)
iv?: string | undefined;
@Matches("^v[0-9]+$")
crypto_version: string = "v1";
}
export class NoteDeleteRequest extends NoteRequestBody {
@IsBase64()
@IsNotEmpty()
secret_token: string | undefined;
}

3
webapp/.gitignore vendored
View File

@ -1,8 +1,7 @@
.DS_Store
node_modules
/build
/.svelte-kit/*
!/.svelte-kit/tsconfig.json
/.svelte-kit
/package
.env
.env.*

View File

@ -1,46 +0,0 @@
{
"compilerOptions": {
"baseUrl": "..",
"paths": {
"$lib": [
"src/lib"
],
"$lib/*": [
"src/lib/*"
]
},
"rootDirs": [
"..",
"./types"
],
"importsNotUsedAsValues": "error",
"isolatedModules": true,
"preserveValueImports": true,
"lib": [
"esnext",
"DOM",
"DOM.Iterable"
],
"moduleResolution": "node",
"module": "esnext",
"target": "esnext"
},
"include": [
"ambient.d.ts",
"./types/**/$types.d.ts",
"../vite.config.ts",
"../src/**/*.js",
"../src/**/*.ts",
"../src/**/*.svelte",
"../src/**/*.js",
"../src/**/*.ts",
"../src/**/*.svelte",
"../tests/**/*.js",
"../tests/**/*.ts",
"../tests/**/*.svelte"
],
"exclude": [
"../node_modules/**",
"./[!ambient.d.ts]**"
]
}

View File

@ -1,24 +1,11 @@
# Changelog
## [2022-11-14] (["HACK WEEK"](https://mcndt.dev/posts/hack-week-november-2022/))
- feat: ✨ Support for showing file title as note title in web viewer.
## [2022-11-13] (["HACK WEEK"](https://mcndt.dev/posts/hack-week-november-2022/))
- security: 🔐 Can now decrypt GCM-encrypted notes from Quickshare plugin versions 1.0.2 and higher.
## [2022-09-11]
- fix: 🐛 Fix inline code showing the backtick syntax after rendering.
## [2022-08-23]
- feat: ✨ Footnotes are rendered as they are in the Obsidian client.
## [2022-08-16]
- fix: 🐛 Fix highlights not rendering correctly when mixed with other formatting. ([issue #19](https://github.com/mcndt/noteshare.space/issues/19))
- fix: 🐛 Fix highlights not rendering correctly when mixed with other formatting. ([issue #19](https://github.com/mcndt/noteshare.space/issues/19))
- fix: 🐛 Fix some characters escaping the rendering for #tags. ([issue #10](https://github.com/mcndt/noteshare.space/issues/10))
## [2022-08-11]

1379
webapp/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,9 +5,11 @@
"author": "Maxime Cannoodt (mcndt)",
"license": "MIT",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"dev": "svelte-kit dev",
"build": "svelte-kit build",
"package": "svelte-kit package",
"preview": "svelte-kit preview",
"prepare": "svelte-kit sync",
"check": "svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check --plugin-search-dir=. . && eslint .",
@ -18,10 +20,10 @@
},
"devDependencies": {
"@sveltejs/adapter-auto": "next",
"@sveltejs/kit": "^1.0.0-next.544",
"@sveltejs/kit": "^1.0.0-next.350",
"@tailwindcss/typography": "^0.5.2",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/svelte": "^3.2.2",
"@testing-library/svelte": "^3.1.3",
"@types/crypto-js": "^4.1.1",
"@types/marked": "^4.0.3",
"@typescript-eslint/eslint-plugin": "^5.27.0",
@ -41,13 +43,13 @@
"tailwindcss": "^3.1.3",
"tslib": "^2.3.1",
"typescript": "^4.7.2",
"vite": "^3.2.3",
"vite-plugin-markdown": "^2.1.0",
"vitest": "^0.17.0"
"vitest": "^0.17.0",
"vitest-svelte-kit": "^0.0.6"
},
"type": "module",
"dependencies": {
"@sveltejs/adapter-node": "^1.0.0-next.100",
"@sveltejs/adapter-node": "^1.0.0-next.78",
"crypto-js": "^4.1.1",
"highlight.js": "^11.6.0",
"katex": "^0.16.0",

3842
webapp/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,8 @@
import { getCalloutColor, getCalloutIcon } from '$lib/util/callout';
import CalloutIcon from '$lib/components/CalloutIcon.svelte';
export let title = '';
export let type = 'note';
let title = '';
let type = 'note';
let color = '--callout-warning';
let icon = 'note';
let init = false;
@ -12,9 +12,8 @@
$: if (content) {
const titleElement = content.getElementsByTagName('p')[0];
const preFilled = title != '';
const match = titleElement.innerText.split('\n')[0].match(/\[!(.+)\]([+-]?)(?:\s(.+))?/);
if (match && !preFilled) {
if (match) {
type = match[1]?.trim();
title = match[3]?.trim() ?? type[0].toUpperCase() + type.substring(1).toLowerCase();
}
@ -23,13 +22,11 @@
icon = getCalloutIcon(type);
// Remove title from content
if (!preFilled) {
const pos = titleElement.innerHTML.indexOf('<br>');
if (pos >= 0) {
titleElement.innerHTML = titleElement.innerHTML.substring(pos + 4);
} else {
titleElement.innerHTML = '';
}
const pos = titleElement.innerHTML.indexOf('<br>');
if (pos >= 0) {
titleElement.innerHTML = titleElement.innerHTML.substring(pos + 4);
} else {
titleElement.innerHTML = '';
}
init = true;
}

View File

@ -1,51 +0,0 @@
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import Callout from './Callout.svelte';
const localStorageKey = 'shared-note-notification';
// Increase this value to show the notification again to existing users.
const messageId = 3;
// Use this to show the notification to new users only if the notification is younger than this date.
const expire_time = new Date('2022-09-10');
let show = false;
onMount(() => {
if (!browser) return;
const serializedId = localStorage.getItem(localStorageKey);
const id = serializedId ? parseInt(serializedId) : -1;
if (id < messageId && new Date() < expire_time) {
show = true;
}
});
function onDismiss() {
show = false;
localStorage.setItem(localStorageKey, messageId.toString());
}
</script>
{#if show}
<!-- <div class="mb-5 px-4 py-4 bg-blue-100 rounded-lg"> -->
<div
class="prose prose-zinc dark:prose-invert max-w-none prose-li:my-0 prose-ul:mt-0 prose-ol:mt-0 leading-7
prose-strong:font-bold prose-a:font-normal prose-blockquote:font-normal prose-blockquote:not-italic
prose-blockquote:first:before:content-[''] prose-hr:transition-colors mb-5"
>
<Callout type="info" title="Obsidian QuickShare 1.0.0 launched 🚀">
<p>
Obsidian QuickShare and Noteshare.space are now out of beta 🚀 You can now find the plugin
in the Obsidian community plugin marketplace (see <a href="/install">instructions</a>).
Check out the roadmap for upcoming features <a href="/roadmap">here</a>.
</p>
<div class="mt-1.5">
<button
on:click={onDismiss}
class="px-1.5 py-0.5 text-[.9em] hover:bg-neutral-200 dark:hover:bg-neutral-700 font-semibold text-red-700 dark:text-red-500 underline hover:text-grey-500"
>Don't show again</button
>
</div>
</Callout>
</div>
{/if}

View File

@ -1,7 +1,7 @@
<hr class="border-zinc-200 dark:border-zinc-700 transition-colors" />
<footer
class="px-3 py-6 md:p-8 text-center flex flex-wrap justify-center items-center gap-x-2 gap-y-1.5 text-zinc-500 dark:text-zinc-400"
class="px-3 py-6 md:p-8 text-center flex flex-wrap justify-center items-center gap-x-2 text-zinc-500 dark:text-zinc-400"
>
<span>
Built with love by <a class="underline" href="https://mcndt.dev" alt="blog">mcndt</a>
@ -11,8 +11,6 @@
<span>-</span>
<a class="underline" href="/changelog">Changelog</a>
<span>-</span>
<a class="underline" href="/roadmap">Roadmap</a>
<span>-</span>
<a class="underline" href="/contact">Contact</a>
<span>-</span>
<a class="underline" href="https://discord.gg/y3HqyGeABK">Discord</a>
@ -23,7 +21,5 @@
>🐛 Report bug</a
>
<span>-</span>
<a class="underline" href="/funding">Expenses & funding</a>
<span>-</span>
<a class="underline" href="https://www.buymeacoffee.com/mcndt">☕ Buy me a coffee</a>
</footer>

View File

@ -19,8 +19,6 @@
import Footnote from '$lib/marked/renderers/Footnote.svelte';
export let plaintext: string;
export let fileTitle: string | undefined;
let ref: HTMLDivElement;
let footnotes: HTMLDivElement[];
let footnoteContainer: HTMLDivElement;
@ -31,14 +29,10 @@
const options = { ...marked.defaults, breaks: true };
function onParsed() {
!fileTitle && setTitle();
setTitle();
parseFootnotes();
}
$: if (fileTitle) {
document.title = fileTitle.trim();
}
/**
* Searches for the first major header in the document to use as page title.
*/
@ -72,11 +66,8 @@
id="md-box"
class="prose prose-zinc dark:prose-invert max-w-none prose-li:my-0 prose-ul:mt-0 prose-ol:mt-0 leading-7
prose-strong:font-bold prose-a:font-normal prose-blockquote:font-normal prose-blockquote:not-italic
prose-blockquote:first:before:content-[''] prose-hr:transition-colors prose-code:before:content-[''] prose-code:after:content-['']"
prose-blockquote:first:before:content-[''] prose-hr:transition-colors"
>
{#if fileTitle}
<h1>{fileTitle}</h1>
{/if}
<SvelteMarkdown
on:parsed={onParsed}
renderers={{

View File

@ -2,31 +2,16 @@
import { AES, enc, HmacSHA256 } from 'crypto-js';
type CryptData = {
ciphertext: string;
key: string;
iv?: string;
hmac?: string;
};
type CryptData_v1 = CryptData & {
hmac: string;
};
type CryptData_v3 = CryptData & {
iv: string;
};
export async function decrypt(cryptData: CryptData, version: string): Promise<string> {
export async function decrypt(
cryptData: { ciphertext: string; hmac: string; key: string },
version: string
): Promise<string> {
console.debug(`decrypting with crypto suite ${version}`);
if (version === 'v1') {
return decrypt_v1(cryptData as CryptData_v1);
return decrypt_v1(cryptData);
}
if (version === 'v2') {
return decrypt_v2(cryptData as CryptData_v1);
}
if (version === 'v3') {
return decrypt_v3(cryptData as CryptData_v3);
return decrypt_v2(cryptData);
}
throw new Error(`Unsupported crypto version: ${version}`);
}
@ -51,6 +36,15 @@ export async function decrypt_v2(cryptData: {
hmac: string;
key: string;
}): Promise<string> {
const md = await decryptBuffer_v2(cryptData);
return new TextDecoder().decode(md);
}
export async function decryptBuffer_v2(cryptData: {
ciphertext: string;
hmac: string;
key: string;
}): Promise<ArrayBuffer> {
const secret = base64ToArrayBuffer(cryptData.key);
const ciphertext_buf = base64ToArrayBuffer(cryptData.ciphertext);
const hmac_buf = base64ToArrayBuffer(cryptData.hmac);
@ -66,39 +60,17 @@ export async function decrypt_v2(cryptData: {
throw Error('Failed HMAC check');
}
const md = await window.crypto.subtle.decrypt(
const data = await window.crypto.subtle.decrypt(
{ name: 'AES-CBC', iv: new Uint8Array(16) },
await _getAesCbcKey(secret),
await _getAesKey(secret),
ciphertext_buf
);
return new TextDecoder().decode(md);
return data;
}
export async function decrypt_v3(cryptData: {
ciphertext: string;
iv: string;
key: string;
}): Promise<string> {
const secret = base64ToArrayBuffer(cryptData.key);
const ciphertext_buf = base64ToArrayBuffer(cryptData.ciphertext);
const iv_buf = base64ToArrayBuffer(cryptData.iv);
const md = await window.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv_buf },
await _getAesGcmKey(secret),
ciphertext_buf
);
return new TextDecoder().decode(md);
}
function _getAesCbcKey(secret: ArrayBuffer): Promise<CryptoKey> {
function _getAesKey(secret: ArrayBuffer): Promise<CryptoKey> {
return window.crypto.subtle.importKey('raw', secret, { name: 'AES-CBC', length: 256 }, false, [
'decrypt'
]);
}
function _getAesGcmKey(secret: ArrayBuffer): Promise<CryptoKey> {
return window.crypto.subtle.importKey('raw', secret, { name: 'AES-GCM', length: 256 }, false, [
'encrypt',
'decrypt'
]);
}

View File

@ -0,0 +1,34 @@
export async function getEmbedId(filename: string): Promise<string> {
// generate 64 bit id
const idBuf = new Uint32Array((await deriveKey(filename)).slice(0, 8));
// convert idBuf to base 32 string
const id = idBuf.reduce((acc, cur) => {
return acc + cur.toString(32);
}, '');
return id;
}
async function deriveKey(seed: string): Promise<ArrayBuffer> {
const keyMaterial = await window.crypto.subtle.importKey(
'raw',
new TextEncoder().encode(seed),
{ name: 'PBKDF2' },
false,
['deriveBits']
);
const masterKey = await window.crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt: new Uint8Array(16),
iterations: 100000,
hash: 'SHA-256'
},
keyMaterial,
256
);
return new Uint8Array(masterKey);
}

View File

@ -1,4 +1,6 @@
<script lang="ts">
import { browser } from '$app/env';
import { onMount } from 'svelte';
import hljs from 'highlight.js/lib/common';
@ -10,14 +12,12 @@
let highlighted: string;
onMount(() => {
try {
if (browser) {
if (hljs.getLanguage(lang) !== undefined) {
highlighted = hljs.highlight(text, { language: lang }).value;
} else {
highlighted = text;
}
} catch {
highlighted = text;
}
});
</script>

View File

@ -1,25 +1,71 @@
<script lang="ts">
import EmbedIcon from 'svelte-icons/md/MdAttachment.svelte';
import FaRegQuestionCircle from 'svelte-icons/fa/FaRegQuestionCircle.svelte';
import { EmbedType, getEmbedType, getMimeType } from '$lib/util/embeds';
import { onMount } from 'svelte';
import { getEmbedId } from '$lib/crypto/embedId';
import type { EncryptedEmbed } from '$lib/model/EncryptedEmbed';
import { decryptBuffer_v2 } from '$lib/crypto/decrypt';
export let text: string;
let image: HTMLImageElement;
let imageUrl: string;
onMount(async () => {
if (getEmbedType(text) === EmbedType.IMAGE) {
const encryptedEmbed = await fetchEmbed(text);
const embedBuffer = await decryptEmbed(encryptedEmbed);
console.log(embedBuffer);
imageUrl = renderImage(embedBuffer, text);
return () => {
URL.revokeObjectURL(imageUrl);
};
}
});
function renderImage(buffer: ArrayBuffer, filename: string): string {
// const bufferView = new Uint8Array(buffer);
const blob = new Blob([buffer], { type: getMimeType(filename) });
const url = URL.createObjectURL(blob);
return url;
}
async function decryptEmbed(embed: EncryptedEmbed): Promise<ArrayBuffer> {
const key = location.hash.slice(1);
const data = await decryptBuffer_v2({ ...embed, key });
return data;
}
async function fetchEmbed(filename: string): Promise<EncryptedEmbed> {
const embedId = await getEmbedId(filename);
const response = await fetch(`${location.pathname}/embeds/${embedId}`);
if (response.ok) {
return (await response.json()) as EncryptedEmbed;
}
throw new Error(`Failed to fetch embed: ${response.statusText}`);
}
</script>
<div>
<dfn class="not-italic" title="Interal embeds are not shared currently.">
<div
class="px-4 py-12 border border-zinc-300 dark:border-zinc-600 inline-flex flex-col items-center justify-center"
>
<span class="h-8 text-zinc-400 ml-0.5 inline-flex items-center whitespace-nowrap gap-1"
><span class="w-8 h-8 inline-block">
<EmbedIcon />
{#if imageUrl}
<img bind:this={image} src={imageUrl} alt={text} />
{:else}
<div>
<dfn class="not-italic" title="Interal embeds are not shared currently.">
<div
class="px-4 py-12 border border-zinc-300 dark:border-zinc-600 inline-flex flex-col items-center justify-center"
>
<span class="h-8 text-zinc-400 ml-0.5 inline-flex items-center whitespace-nowrap gap-1"
><span class="w-8 h-8 inline-block">
<EmbedIcon />
</span>
<span>Internal embed</span>
</span>
<span>Internal embed</span>
</span>
<span class="underline cursor-not-allowed inline-flex items-center">
<span class="text-[#705dcf] opacity-50">{text}</span>
<span class="inline-block w-3 h-3 mb-2 text-zinc-400 ml-0.5"><FaRegQuestionCircle /></span>
</span>
</div>
</dfn>
</div>
<span class="underline cursor-not-allowed inline-flex items-center">
<span class="text-[#705dcf] opacity-50">{text}</span>
<span class="inline-block w-3 h-3 mb-2 text-zinc-400 ml-0.5"><FaRegQuestionCircle /></span
>
</span>
</div>
</dfn>
</div>
{/if}

View File

@ -0,0 +1,6 @@
export type EncryptedEmbed = {
note_id: string;
embed_id: string;
ciphertext: string;
hmac: string;
};

View File

@ -0,0 +1,21 @@
/**
* Returns the EmbedType if embeddable, false if not.
* @param filename File extension to check.
* @returns EmbedType if embeddable, false if not.
*/
export function getEmbedType(filename: string): EmbedType | boolean {
return isImage(filename) ? EmbedType.IMAGE : false;
}
export function getMimeType(filename: string) {
return 'image/jpeg';
}
export enum EmbedType {
IMAGE = 'IMAGE'
}
function isImage(filename: string): boolean {
const match = filename.match(/(png|jpe?g|svg|bmp|gif|)$/i);
return !!match && match[0]?.length > 0;
}

View File

@ -1,38 +0,0 @@
<script lang="ts">
import { page } from '$app/stores';
console.log($page);
</script>
<div class="prose max-w-2xl prose-zinc dark:prose-invert">
{#if $page.status === 404}
<h1>404: No note found 🕵️</h1>
<p class="prose-xl">No note was found at this link. Are you from the future?</p>
{:else if $page.status === 410 && $page.error?.message === 'Note expired'}
<h1>📝💨 This note is no longer here!</h1>
<p class="prose-xl">
Notes are stored for a limited amount of time. The note at this link was either set to expire,
or deleted due to inactivity. Sorry!
</p>
{:else if $page.status === 410 && $page.error?.message === 'Note deleted'}
<h1>📝🗑 This note has been deleted.</h1>
<p class="prose-xl">The note at this link has been deleted by the user who shared it. Sorry!</p>
{:else}
<h1>Something went wrong 🤔</h1>
<p class="prose-xl">
{#if import.meta.env.DEV}
<pre class="prose-xl">{JSON.stringify($page.error, null, 2)}</pre>
{:else}
<p class="prose-xl">An error occurred while loading this page. Please try again later.</p>
{/if}
</p>
{/if}
<div class="not-prose w-full flex justify-center mt-16">
{#if $page.status === 404 || ($page.status === 410 && $page.error?.message === 'Note expired')}
<img src="/expired_note.svg" alt="encrypted-art" class="w-80" />
{:else if $page.status === 410 && $page.error?.message === 'Note deleted'}
<img src="/deleted_note.svg" alt="encrypted-art" class="w-80" />
{/if}
</div>
</div>

View File

@ -1,2 +0,0 @@
// try making this false to prevent LinkedIn crawler returning 416
export const prerender = false;

View File

@ -0,0 +1,43 @@
<script context="module" lang="ts">
import type { Load } from '@sveltejs/kit';
export const load: Load = ({ error, status }) => {
let explainText = '';
let title = '';
if (status == 404) {
title = `404: No note found 🕵️`;
explainText = `No note was found at this link. Are you from the future?`;
}
if (status == 410) {
title = '📝💨 This note is no longer here! ';
explainText = `Notes are stored for a limited amount of time. The note at this link was either set to expire, or deleted due to inactivity. Sorry!`;
}
return {
props: {
status: status,
title: title,
explainText: explainText
}
};
};
</script>
<script lang="ts">
export let status: number;
export let title: string;
export let explainText: string;
</script>
<div class="prose max-w-2xl prose-zinc dark:prose-invert">
<h1>{title}</h1>
<p class="prose-xl">{explainText}</p>
<div class="not-prose w-full flex justify-center mt-16">
{#if status == 404 || status == 410}
<img src="/expired_note.svg" alt="encrypted-art" class="w-80" />
{/if}
</div>
</div>

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { browser } from '$app/environment';
import { browser } from '$app/env';
import Footer from '$lib/components/Footer.svelte';
import NavBar from '$lib/components/navbar/NavBar.svelte';
@ -29,42 +29,7 @@
</script>
<svelte:head>
<title>{import.meta.env.VITE_BRANDING} — Securely share your Obsidian notes with one click.</title
>
<meta
name="title"
content="Noteshare.space — Securely share your Obsidian notes with one click."
/>
<meta
name="description"
content="Securely share your Obsidian notes with one click. Zero configuration. End-to-end encrypted. No account needed. Completely open source! Download the QuickShare extension in the Obsidian community plugin marketplace."
/>
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://noteshare.space/" />
<meta
property="og:title"
content="Noteshare.space — Securely share your Obsidian notes with one click."
/>
<meta
property="og:description"
content="Securely share your Obsidian notes with one click. Zero configuration. End-to-end encrypted. No account needed. Completely open source! Download the QuickShare extension in the Obsidian community plugin marketplace."
/>
<meta property="og:image" content="https://noteshare.space/meta.png" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://noteshare.space/" />
<meta
property="twitter:title"
content="Noteshare.space — Securely share your Obsidian notes with one click."
/>
<meta
property="twitter:description"
content="Securely share your Obsidian notes with one click. Zero configuration. End-to-end encrypted. No account needed. Completely open source! Download the QuickShare extension in the Obsidian community plugin marketplace."
/>
<meta property="twitter:image" content="https://noteshare.space/meta.png" />
<title>{import.meta.env.VITE_BRANDING}</title>
</svelte:head>
<div class=" h-full {dark !== undefined ? '' : 'hidden'} {dark ? darkTheme : ''}">
@ -91,6 +56,7 @@
>
</span>
</NavBarLink>
<ThemeToggle bind:dark />
</svelte:fragment>
></NavBar

View File

@ -1,3 +1,7 @@
<script context="module">
export const prerender = true;
</script>
<svelte:head>
<title>About | {import.meta.env.VITE_BRANDING}</title>
</svelte:head>

View File

@ -1 +0,0 @@
export const prerender = true;

View File

@ -1,6 +1,8 @@
<script lang="ts">
<script context="module" lang="ts">
export const prerender = true;
// @ts-expect-error - Markdown files are not recognized by Svelte
import { html } from '../funding.md';
import { toc, html } from '/CHANGELOG.md';
</script>
<div class="md:py-8 prose prose-md max-w-3xl dark:prose-invert">

View File

@ -1,8 +0,0 @@
<script lang="ts">
// @ts-expect-error - Markdown files are not recognized by Vite
import { html } from '/CHANGELOG.md';
</script>
<div class="md:py-8 prose prose-md max-w-3xl dark:prose-invert">
{@html html}
</div>

View File

@ -1 +0,0 @@
export const prerender = true;

View File

@ -1,3 +1,7 @@
<script context="module">
export const prerender = true;
</script>
<svelte:head>
<title>Contact | {import.meta.env.VITE_BRANDING}</title>
</svelte:head>
@ -5,12 +9,12 @@
<div class="md:py-8 prose max-w-3xl dark:prose-invert">
<h1>Contact</h1>
Hi 🙋 I'm
Hi! I'm
<a href="https://mcndt.dev">mcndt</a>
and I build the Obsidian QuickShare plugin and operate Noteshare.space. There are several ways to get
in touch with me.
<h2>🐛 Bugs and feature requests</h2>
<h2>Bugs and feature requests</h2>
<p>
The preferred way to report bugs or request new features for the web app or the Obsidian plugin
is via the
@ -19,7 +23,7 @@
>.
</p>
<h2>Inquiries about Noteshare.space</h2>
<h2>Inquiries about Noteshare.space</h2>
<p>
For questions and inquiries about Noteshare.space (the "official" note hosting service for the
Obsidian QuickShare plugin), please E-mail me at <a href="mailto:contact@noteshare.space"
@ -27,14 +31,14 @@
>.
</p>
<h2>💬 Community</h2>
<h2>Community</h2>
<p>
If you want a more interactive way to discuss bugs or features, or just want to chat about how
you use QuickShare and Noteshare.space, you can join the
<a href="https://discord.gg/y3HqyGeABK">Discord server</a>.
</p>
<h2>👋 Get to know me!</h2>
<h2>Get to know me!</h2>
<p>
Im a computer science engineer with interest in a wide range of topics, including productivity,
PKM, artificial intelligence, product development and game design.

View File

@ -1 +0,0 @@
export const prerender = true;

View File

@ -1,25 +0,0 @@
# Operational Expenses and Funding 💰
## Operational Expenses
I architected the serverside code to be as light and portable as possible. Currently, it runs on a VPS I rent for about **7$/month**. This is currently the only operational expense for Noteshare.space.
The storage requirements for hosting encrypted text data are incredible small; at the time of writing (28 September, 2022), Noteshare is storing **1,000 notes in a little over 7MB**.
### Future expenses: hosting file attachments
For those who have read [the roadmap](/roadmap), I am working on supporting embedded images and file attachments in a future update. As you can imagine, this greatly increases the storage requirements, as just one attachement has a size in the order of megabytes.
As I develop this feature, I am still deciding on how I will fund this: community donors, tiered access, ... If you have any suggestions, feel free to reach out to me via [email](mailto:contact@noteshare.space) or the [community Discord](https://discord.gg/y3HqyGeABK).
## Funding
I already rented this VPS for other purposes, so I haven no problem paying out of pocket for hosting and you can expect the service to be available
If you wish to financially support me and the development of this plugin and service, you can send donations via my [_Buy me a coffee_-profile](https://www.buymeacoffee.com/mcndt).
## Self-hosting
If you are still worried about relying on me for keeping Noteshare up and running, it is an option to host your own instance of Noteshare. You can find the source code, as well as a `docker-compose.yml` file to get you up and running on the [Noteshare GitHub repo](https://github.com/mcndt/Noteshare.space).
_Last updated: 2022-09-28_

View File

@ -1 +0,0 @@
export const prerender = true;

View File

@ -1,31 +1,31 @@
<script context="module">
export const prerender = true;
</script>
<script>
import Tile from '$lib/components/index/tile.svelte';
</script>
<svelte:head>
<title>{import.meta.env.VITE_BRANDING} — Securely share your Obsidian notes with one click.</title
>
<title>{import.meta.env.VITE_BRANDING} | Create share links for Obsidian in one click</title>
</svelte:head>
<article class="mx-auto max-w-4xl text-zinc-900 dark:text-zinc-100">
<div class="space-y-6 pt-20 pb-16 md:pb-24 px-4 md:px-0">
<div class="space-y-6 pt-20 pb-24 px-4 md:px-0">
<h1 id="title" class="font-extrabold text-4xl md:text-5xl text-center">
Securely share your <span class="text-[#705dcf]">Obsidian</span> notes with one click.
</h1>
<p id="tagline" class="prose-xl md:prose-2xl text-center text-zinc-700 dark:text-zinc-300">
Zero configuration. End-to-end encrypted. <br />No account needed.
</p>
<div id="install-button" class="text-center pt-8">
<p id="install-button" class="text-center pt-2">
<a href="/install">
<button
class="py-1.5 px-4 rounded-lg border-2 border-[#705dcf] text-[#705dcf] font-semibold hover:bg-[#705dcf] hover:text-white transition-colors"
>Install plugin</button
>
</a>
<p class="mt-2.5 italic text-sm text-zinc-500 dark:text-zinc-400">
Now on the Obsidian <br /> community plugin marketplace!
</p>
</div>
</p>
</div>
<hr class="border-zinc-200 dark:border-zinc-700 transition-colors" />

View File

@ -1,3 +1,7 @@
<script context="module">
export const prerender = true;
</script>
<svelte:head>
<title>Get the plugin | {import.meta.env.VITE_BRANDING}</title>
<script src="https://tarptaeya.github.io/repo-card/repo-card.js"></script>
@ -6,27 +10,10 @@
<div class="md:py-8 prose max-w-3xl dark:prose-invert">
<h1>Installing the Obsidian Plugin</h1>
<h2>📦 Install from the community plugin marketplace</h2>
The plugin and Noteshare.space are currently in beta. Therefore, the plugin is not yet available
through the Obsidian community plugins marketplace.
<p>
If you have Obsidian installed on this device, you can <a
href="obsidian://show-plugin?id=obsidian-quickshare"
>
click here</a
>
to open Obsidian and install the plugin. Alternatively, you can install the plugin manually by following
the instructions below.
</p>
<ol>
<li>Open the Obsidian settings menu.</li>
<li>In the sidebar, click "Community plugins".</li>
<li>Click the "Browse" button on the right.</li>
<li>Type "QuickShare" in the search bar.</li>
<li>Click "Install".</li>
<li><strong>!! Don't forget to activate the plugin after installation !!</strong></li>
</ol>
<h2>👷 Beta testing with BRAT</h2>
<h2>Beta testing with BRAT</h2>
<p>
To beta test, you can install the plugin using BRAT and the GitHub URL (<a

View File

@ -1 +0,0 @@
export const prerender = true;

View File

@ -1,23 +1,51 @@
<script context="module" , lang="ts">
import type { Load } from '@sveltejs/kit';
export const load: Load = async ({ props }) => {
const note: EncryptedNote = props.note;
const maxage = Math.floor((note.expire_time.valueOf() - note.insert_time.valueOf()) / 1000);
return {
status: 200,
cache: {
maxage: maxage,
private: false
},
props: { note }
};
};
</script>
<script lang="ts">
import { onMount } from 'svelte';
import { decrypt } from '$lib/crypto/decrypt';
import MarkdownRenderer from '$lib/components/MarkdownRenderer.svelte';
import LogoMarkdown from 'svelte-icons/io/IoLogoMarkdown.svelte';
import IconEncrypted from 'svelte-icons/md/MdLockOutline.svelte';
import { browser } from '$app/environment';
import type { EncryptedNote } from '$lib/model/EncryptedNote';
import { browser } from '$app/env';
import RawRenderer from '$lib/components/RawRenderer.svelte';
import LogoDocument from 'svelte-icons/md/MdUndo.svelte';
import Dismissable from '$lib/components/Dismissable.svelte';
import type { PageData } from './$types';
export let data: PageData;
let { note } = data;
// Auto-loaded from [id].ts endpoint
export let note: EncryptedNote;
let plaintext: string;
let timeString: string;
let decryptFailed = false;
let showRaw = false;
let fileTitle: string | undefined;
onMount(() => {
if (browser) {
const key = location.hash.slice(1);
decrypt({ ...note, key }, note.crypto_version)
.then((value) => (plaintext = value))
.catch(() => (decryptFailed = true));
}
});
$: if (note.insert_time) {
const diff_ms = new Date().valueOf() - new Date(note.insert_time).valueOf();
timeString = msToString(diff_ms);
}
function toggleRaw() {
showRaw = !showRaw;
@ -39,33 +67,6 @@
const months = days / 30.42;
return `${Math.floor(months)} month${months >= 2 ? 's' : ''}`;
}
function parsePayload(payload: string): { body: string; title?: string } {
try {
const parsed = JSON.parse(payload);
return { body: parsed?.body, title: parsed?.title };
} catch (e) {
return { body: payload, title: undefined };
}
}
onMount(() => {
if (browser && note) {
const key = location.hash.slice(1);
decrypt({ ...note, key }, note.crypto_version)
.then((value) => {
const { body, title } = parsePayload(value);
plaintext = body;
fileTitle = title;
})
.catch(() => (decryptFailed = true));
}
});
$: if (note?.insert_time) {
const diff_ms = new Date().valueOf() - new Date(note.insert_time).valueOf();
timeString = msToString(diff_ms);
}
</script>
<svelte:head>
@ -77,8 +78,6 @@
{#if plaintext}
<div class="max-w-2xl mx-auto">
<Dismissable />
<p
class="mb-4 text-sm flex gap-2 flex-col md:gap-0 md:flex-row justify-between text-zinc-500 dark:text-zinc-400"
>
@ -102,7 +101,7 @@
{#if showRaw}
<RawRenderer>{plaintext}</RawRenderer>
{:else}
<MarkdownRenderer {plaintext} {fileTitle} />
<MarkdownRenderer {plaintext} />
{/if}
</div>
{/if}

View File

@ -1,10 +1,8 @@
import type { EncryptedNote } from '$lib/model/EncryptedNote';
import type { PageServerLoad } from './$types';
import type { RequestHandler } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ request, params, setHeaders, getClientAddress }) => {
const ip = (request.headers.get('x-forwarded-for') || getClientAddress()) as string;
export const get: RequestHandler = async ({ request, clientAddress, params }) => {
const ip = (request.headers.get('x-forwarded-for') || clientAddress) as string;
const url = `${import.meta.env.VITE_SERVER_INTERNAL}/api/note/${params.id}`;
const response = await fetch(url, {
headers: {
@ -18,18 +16,27 @@ export const load: PageServerLoad = async ({ request, params, setHeaders, getCli
note.insert_time = new Date(note.insert_time as unknown as string);
note.expire_time = new Date(note.expire_time as unknown as string);
const maxage = Math.floor((note.expire_time.valueOf() - note.insert_time.valueOf()) / 1000);
setHeaders({
maxage: `${maxage}`,
'Cache-Control': `max-age=${maxage}, public`
});
return { note };
return {
status: response.status,
headers: {
'Cache-Control': `public, max-age=${maxage}`
},
cache: {
maxage: maxage,
private: false
},
body: { note }
};
} catch {
throw error(500, response.statusText);
return {
status: 500,
error: response.statusText
};
}
} else {
// get the response body (the reason why the request failed)
const body = await response.text();
throw error(response.status, body);
return {
status: response.status,
error: response.statusText
};
}
};

View File

@ -0,0 +1,34 @@
import type { EncryptedEmbed } from '$lib/model/EncryptedEmbed';
import type { RequestHandler } from '@sveltejs/kit';
export const get: RequestHandler = async ({ request, clientAddress, params }) => {
const ip = (request.headers.get('x-forwarded-for') || clientAddress) as string;
const url = `${import.meta.env.VITE_SERVER_INTERNAL}/api/note/${params.note_id}/embeds/${
params.id
}`;
const response = await fetch(url, {
headers: {
'x-forwarded-for': ip
}
});
if (response.ok) {
try {
const embed: EncryptedEmbed = await response.json();
return {
status: response.status,
body: embed
};
} catch {
return {
status: 500,
error: response.statusText
};
}
} else {
return {
status: response.status,
error: response.statusText
};
}
};

View File

@ -1 +0,0 @@
export const prerender = true;

View File

@ -1,41 +0,0 @@
<svelte:head>
<title>Roadmap | {import.meta.env.VITE_BRANDING}</title>
</svelte:head>
<div class="md:py-8 prose max-w-3xl dark:prose-invert">
<h1>Roadmap</h1>
<p>
Here you can find the current roadmap for the plugin. If you have any suggestions for the web
app or the Obsidian plugin is via the <a
href="https://github.com/mcndt/obsidian-quickshare/issues"
>
GitHub issues page
</a>. For a more detailed overview of open issues and bugs on this open source project, check
out the <a href="https://github.com/users/mcndt/projects/1/views/5">GitHub project page</a>.
</p>
<div class="md:grid md:grid-cols-3 gap-x-2 w-full">
<div>
<h2>⚙️ In progress</h2>
<ul>
<li>Adding embedded images to shared note.</li>
</ul>
</div>
<div>
<h2>🗓️ Planned</h2>
<ul>
<li>Deleting previously shared notes.</li>
<li>Manually setting an expire time.</li>
</ul>
</div>
<div>
<h2>💭 Potential features</h2>
<ul>
<li>Rendering DataView codeblocks before sharing.</li>
<li>Rendering Excalidraw embeds before sharing.</li>
<li>Rendering Mermaid diagrams in the web app.</li>
</ul>
</div>
</div>
</div>

View File

@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/svelte';
import { readMd } from './util';
import MarkdownRenderer from '$lib/components/MarkdownRenderer.svelte';
const testCases = [
@ -44,11 +45,9 @@ describe.each(testCases)('Rendering callouts', async (testCase) => {
expect(titleEl).toHaveClass('callout-title');
});
// TODO: this test is broken. Need to fix it.
it.skip('Renders callout content correctly ', async () => {
it('Renders callout content correctly ', async () => {
render(MarkdownRenderer, { plaintext: testCase.markdown });
const contentEl = await screen.findByText(testCase.content);
// const contentEl = await screen.findByText(testCase.content);
expect(contentEl).toBeInTheDocument();
expect(contentEl.parentElement).toHaveClass('callout-content');
});

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

View File

@ -1,6 +1,8 @@
// import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-node';
import preprocess from 'svelte-preprocess';
import { plugin as markdown } from 'vite-plugin-markdown';
import { searchForWorkspaceRoot } from 'vite';
/** @type {import('@sveltejs/kit').Config} */
const config = {
@ -12,7 +14,24 @@ const config = {
})
],
kit: {
adapter: adapter()
adapter: adapter(),
vite: {
optimizeDeps: {
include: ['highlight.js', 'highlight.js/lib/core']
},
plugins: [markdown({ mode: ['html', 'toc'] })],
test: {
globals: true,
environment: 'happy-dom',
setupFiles: ['setupTest.js']
},
server: {
fs: {
// Allow serving CHANGELOG.md file
allow: [searchForWorkspaceRoot(process.cwd()), '/CHANGELOG.md']
}
}
}
}
};

View File

@ -1,24 +0,0 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { plugin as markdown } from 'vite-plugin-markdown';
import { searchForWorkspaceRoot } from 'vite';
/** @type {import('vite').UserConfig} */
const config = {
plugins: [sveltekit(), markdown({ mode: ['html', 'toc'] })],
optimizeDeps: {
include: ['highlight.js', 'highlight.js/lib/core']
},
test: {
globals: true,
environment: 'happy-dom',
setupFiles: ['setupTest.js']
},
server: {
fs: {
// Allow serving CHANGELOG.md file
allow: [searchForWorkspaceRoot(process.cwd()), '/CHANGELOG.md']
}
}
};
export default config;

3
webapp/vitest.config.ts Normal file
View File

@ -0,0 +1,3 @@
import { extractFromSvelteConfig } from 'vitest-svelte-kit';
export default extractFromSvelteConfig();