Skip to content

Commit 67db3d3

Browse files
committed
Merge branch 'master' into 2503-offline-first-pwa-caching
2 parents 0ecdd03 + ac0853a commit 67db3d3

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+702
-465
lines changed

Gruntfile.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ module.exports = (grunt) => {
261261
...pick(clone(esbuildOptionBags.default), [
262262
'define', 'bundle'
263263
]),
264-
// format: 'esm',
264+
// Format must be 'iife' because we don't want 'import' in the output
265265
format: 'iife',
266266
// banner: {
267267
// js: 'import { createRequire as topLevelCreateRequire } from "module"\nconst require = topLevelCreateRequire(import.meta.url)'

backend/database.js

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use strict'
22

33
import sbp from '@sbp/sbp'
4-
import { strToB64 } from '~/shared/functions.js'
4+
import { maybeParseCID, multicodes, strToB64 } from '~/shared/functions.js'
55
import { Readable } from 'stream'
66
import fs from 'fs'
77
import { readdir, readFile } from 'node:fs/promises'
@@ -99,19 +99,14 @@ sbp('sbp/selectors/register', {
9999
'backend/db/registerName': async function (name: string, value: string): Promise<*> {
100100
const exists = await sbp('backend/db/lookupName', name)
101101
if (exists) {
102-
if (!Boom.isBoom(exists)) {
103-
return Boom.conflict('exists')
104-
} else if (exists.output.statusCode !== 404) {
105-
throw exists // throw if this is an error other than "not found"
106-
}
107-
// otherwise it is a Boom.notFound(), proceed ahead
102+
throw Boom.conflict('exists')
108103
}
109104
await sbp('chelonia.db/set', namespaceKey(name), value)
110105
return { name, value }
111106
},
112-
'backend/db/lookupName': async function (name: string): Promise<string | Error> {
107+
'backend/db/lookupName': async function (name: string): Promise<string> {
113108
const value = await sbp('chelonia.db/get', namespaceKey(name))
114-
return value || Boom.notFound()
109+
return value
115110
}
116111
})
117112

@@ -173,9 +168,8 @@ export default async () => {
173168
// TODO: Update this to only run when persistence is disabled when `chel deploy` can target SQLite.
174169
if (persistence !== 'fs' || options.fs.dirname !== dbRootPath) {
175170
// Remember to keep these values up-to-date.
176-
const HASH_LENGTH = 52
177-
const CONTRACT_MANIFEST_MAGIC = '{"head":"{\\"manifestVersion\\"'
178-
const CONTRACT_SOURCE_MAGIC = '"use strict";'
171+
const HASH_LENGTH = 56
172+
179173
// Preload contract source files and contract manifests into Chelonia DB.
180174
// Note: the data folder may contain other files if the `fs` persistence mode
181175
// has been used before. We won't load them here; that's the job of `chel migrate`.
@@ -184,7 +178,14 @@ export default async () => {
184178
// TODO: Update this code when `chel deploy` no longer generates unprefixed keys.
185179
const keys = (await readdir(dataFolder))
186180
// Skip some irrelevant files.
187-
.filter(k => k.length === HASH_LENGTH)
181+
.filter(k => {
182+
if (k.length !== HASH_LENGTH) return false
183+
const parsed = maybeParseCID(k)
184+
return ([
185+
multicodes.SHELTER_CONTRACT_MANIFEST,
186+
multicodes.SHELTER_CONTRACT_TEXT].includes(parsed?.code)
187+
)
188+
})
188189
const numKeys = keys.length
189190
let numVisitedKeys = 0
190191
let numNewKeys = 0
@@ -194,12 +195,10 @@ export default async () => {
194195
for (const key of keys) {
195196
// Skip keys which are already in the DB.
196197
if (!persistence || !await sbp('chelonia.db/get', key)) {
197-
const value = await readFile(path.join(dataFolder, key), 'utf8')
198198
// Load only contract source files and contract manifests.
199-
if (value.startsWith(CONTRACT_MANIFEST_MAGIC) || value.startsWith(CONTRACT_SOURCE_MAGIC)) {
200-
await sbp('chelonia.db/set', key, value)
201-
numNewKeys++
202-
}
199+
const value = await readFile(path.join(dataFolder, key), 'utf8')
200+
await sbp('chelonia.db/set', key, value)
201+
numNewKeys++
203202
}
204203
numVisitedKeys++
205204
const progress = numVisitedKeys === numKeys ? 100 : Math.floor(100 * numVisitedKeys / numKeys)

backend/pubsub.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ const publicMethods = {
341341

342342
const msg = typeof message === 'string' ? message : JSON.stringify(message)
343343
let shortMsg
344-
// Utility function to remove `data` (i.e., the GIMessage data) from a
344+
// Utility function to remove `data` (i.e., the SPMessage data) from a
345345
// message. We need this for push notifications, which may have a certain
346346
// maximum size (usually around 4 KiB)
347347
const shortenPayload = () => {

backend/routes.js

Lines changed: 96 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
'use strict'
44

55
import sbp from '@sbp/sbp'
6-
import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js'
7-
import { createCID } from '~/shared/functions.js'
6+
import { SPMessage } from '~/shared/domains/chelonia/SPMessage.js'
7+
import { createCID, multicodes, maybeParseCID } from '~/shared/functions.js'
88
import { SERVER_INSTANCE } from './instance-keys.js'
99
import path from 'path'
1010
import chalk from 'chalk'
@@ -42,6 +42,14 @@ const limiterPerDay = new Bottleneck.Group({
4242
reservoirRefreshAmount: SIGNUP_LIMIT_DAY
4343
})
4444

45+
const cidLookupTable = {
46+
[multicodes.SHELTER_CONTRACT_MANIFEST]: 'application/vnd.shelter.contractmanifest+json',
47+
[multicodes.SHELTER_CONTRACT_TEXT]: 'application/vnd.shelter.contracttext',
48+
[multicodes.SHELTER_CONTRACT_DATA]: 'application/vnd.shelter.contractdata+json',
49+
[multicodes.SHELTER_FILE_MANIFEST]: 'application/vnd.shelter.filemanifest+json',
50+
[multicodes.SHELTER_FILE_CHUNK]: 'application/vnd.shelter.filechunk+octet-stream'
51+
}
52+
4553
// Constant-time equal
4654
const ctEq = (expected: string, actual: string) => {
4755
let r = actual.length ^ expected.length
@@ -61,6 +69,10 @@ const staticServeConfig = {
6169
redirect: isCheloniaDashboard ? '/dashboard/' : '/app/'
6270
}
6371

72+
// We define a `Proxy` for route so that we can use `route.VERB` syntax for
73+
// defining routes instead of calling `server.route` with an object, and to
74+
// dynamically get the HAPI server object from the `SERVER_INSTANCE`, which is
75+
// defined in `server.js`.
6476
const route = new Proxy({}, {
6577
get: function (obj, prop) {
6678
return function (path: string, options: Object, handler: Function | Object) {
@@ -79,9 +91,6 @@ function notFoundNoCache (h) {
7991

8092
// RESTful API routes
8193

82-
// TODO: Update this regex once `chel` uses prefixed manifests
83-
const manifestRegex = /^z9brRu3V[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{44}$/
84-
8594
// NOTE: We could get rid of this RESTful API and just rely on pubsub.js to do this
8695
// —BUT HTTP2 might be better than websockets and so we keep this around.
8796
// See related TODO in pubsub.js and the reddit discussion link.
@@ -96,9 +105,10 @@ route.POST('/event', {
96105
// X-Real-IP HEADER! OTHERWISE THIS IS EASILY SPOOFED!
97106
const ip = request.headers['x-real-ip'] || request.info.remoteAddress
98107
try {
99-
const deserializedHEAD = GIMessage.deserializeHEAD(request.payload)
108+
const deserializedHEAD = SPMessage.deserializeHEAD(request.payload)
100109
try {
101-
if (!manifestRegex.test(deserializedHEAD.head.manifest)) {
110+
const parsed = maybeParseCID(deserializedHEAD.head.manifest)
111+
if (parsed?.code !== multicodes.SHELTER_CONTRACT_MANIFEST) {
102112
return Boom.badData('Invalid manifest')
103113
}
104114
const credentials = request.auth.credentials
@@ -195,8 +205,17 @@ route.GET('/eventsAfter/{contractID}/{since}/{limit?}', {}, async function (requ
195205
const { contractID, since, limit } = request.params
196206
const ip = request.headers['x-real-ip'] || request.info.remoteAddress
197207
try {
198-
if (contractID.startsWith('_private') || since.startsWith('_private')) {
199-
return Boom.notFound()
208+
if (
209+
!contractID ||
210+
contractID.startsWith('_private') ||
211+
!/^[0-9]+$/.test(since) ||
212+
(limit && !/^[0-9]+$/.test(limit))
213+
) {
214+
return Boom.badRequest()
215+
}
216+
const parsed = maybeParseCID(contractID)
217+
if (parsed?.code !== multicodes.SHELTER_CONTRACT_DATA) {
218+
return Boom.badRequest()
200219
}
201220

202221
const stream = await sbp('backend/db/streamEntriesAfter', contractID, since, limit)
@@ -267,10 +286,10 @@ route.POST('/name', {
267286
route.GET('/name/{name}', {}, async function (request, h) {
268287
const { name } = request.params
269288
try {
270-
// TODO: conflict with PR 2494
271-
const r = await sbp('backend/db/lookupName', name)
272-
if (typeof r !== 'string') return r
273-
return h.response(r).header('content-type', 'text/plain')
289+
const lookupResult = await sbp('backend/db/lookupName', name)
290+
return lookupResult
291+
? h.response(lookupResult).type('text/plain')
292+
: notFoundNoCache(h)
274293
} catch (err) {
275294
logger.error(err, `GET /name/${name}`, err.message)
276295
return err
@@ -282,7 +301,13 @@ route.GET('/latestHEADinfo/{contractID}', {
282301
}, async function (request, h) {
283302
const { contractID } = request.params
284303
try {
285-
if (contractID.startsWith('_private')) return Boom.notFound()
304+
if (
305+
!contractID ||
306+
contractID.startsWith('_private')
307+
) return Boom.badRequest()
308+
const parsed = maybeParseCID(contractID)
309+
if (parsed?.code !== multicodes.SHELTER_CONTRACT_DATA) return Boom.badRequest()
310+
286311
const HEADinfo = await sbp('chelonia/db/latestHEADinfo', contractID)
287312
if (!HEADinfo) {
288313
console.warn(`[backend] latestHEADinfo not found for ${contractID}`)
@@ -347,7 +372,11 @@ if (process.env.NODE_ENV === 'development') {
347372
const { hash, data } = request.payload
348373
if (!hash) return Boom.badRequest('missing hash')
349374
if (!data) return Boom.badRequest('missing data')
350-
const ourHash = createCID(data)
375+
376+
const parsed = maybeParseCID(hash)
377+
if (!parsed) return Boom.badRequest('invalid hash')
378+
379+
const ourHash = createCID(data, parsed.code)
351380
if (ourHash !== hash) {
352381
console.error(`hash(${hash}) != ourHash(${ourHash})`)
353382
return Boom.badRequest('bad hash!')
@@ -419,7 +448,7 @@ route.POST('/file', {
419448
if (!request.payload[i] || !(request.payload[i].payload instanceof Uint8Array)) {
420449
throw Boom.badRequest('chunk missing in submitted data')
421450
}
422-
const ourHash = createCID(request.payload[i].payload)
451+
const ourHash = createCID(request.payload[i].payload, multicodes.SHELTER_FILE_CHUNK)
423452
if (request.payload[i].payload.byteLength !== chunk[0]) {
424453
throw Boom.badRequest('bad chunk size')
425454
}
@@ -433,7 +462,7 @@ route.POST('/file', {
433462
// Finally, verify the size is correct
434463
if (ourSize !== manifest.size) return Boom.badRequest('Mismatched total size')
435464

436-
const manifestHash = createCID(manifestMeta.payload)
465+
const manifestHash = createCID(manifestMeta.payload, multicodes.SHELTER_FILE_MANIFEST)
437466

438467
// Check that we're not overwriting data. At best this is a useless operation
439468
// since there is no need to write things that exist. However, overwriting
@@ -473,18 +502,33 @@ route.POST('/file', {
473502
route.GET('/file/{hash}', {}, async function (request, h) {
474503
const { hash } = request.params
475504

476-
if (hash.startsWith('_private')) {
477-
return Boom.notFound()
505+
if (!hash || hash.startsWith('_private')) {
506+
return Boom.badRequest()
507+
}
508+
const parsed = maybeParseCID(hash)
509+
if (!parsed) {
510+
return Boom.badRequest()
478511
}
479512

480513
const blobOrString = await sbp('chelonia.db/get', `any:${hash}`)
481514
if (!blobOrString) {
482515
return notFoundNoCache(h)
483516
}
484-
// TODO: conflict with PR 2494
485-
return h.response(blobOrString).etag(hash)
517+
518+
const type = cidLookupTable[parsed.code] || 'application/octet-stream'
519+
520+
return h
521+
.response(blobOrString)
522+
.etag(hash)
486523
.header('Cache-Control', 'public,max-age=31536000,immutable')
487-
.header('content-type', 'application/octet-stream')
524+
// CSP to disable everything -- this only affects direct navigation to the
525+
// `/file` URL.
526+
// The CSP below prevents any sort of resource loading or script execution
527+
// on direct navigation. The `nosniff` header instructs the browser to
528+
// honour the provided content-type.
529+
.header('content-security-policy', "default-src 'none'; frame-ancestors 'none'; form-action 'none'; upgrade-insecure-requests; sandbox")
530+
.header('x-content-type-options', 'nosniff')
531+
.type(type)
488532
})
489533

490534
route.POST('/deleteFile/{hash}', {
@@ -497,7 +541,14 @@ route.POST('/deleteFile/{hash}', {
497541
}, async function (request, h) {
498542
const { hash } = request.params
499543
const strategy = request.auth.strategy
500-
if (!hash || hash.startsWith('_private')) return Boom.notFound()
544+
if (!hash || hash.startsWith('_private')) {
545+
return Boom.badRequest()
546+
}
547+
const parsed = maybeParseCID(hash)
548+
if (parsed?.code !== multicodes.SHELTER_FILE_MANIFEST) {
549+
return Boom.badRequest()
550+
}
551+
501552
const owner = await sbp('chelonia.db/get', `_private_owner_${hash}`)
502553
if (!owner) {
503554
return Boom.notFound()
@@ -593,8 +644,13 @@ route.POST('/kv/{contractID}/{key}', {
593644
}, async function (request, h) {
594645
const { contractID, key } = request.params
595646

596-
if (key.startsWith('_private')) {
597-
return Boom.notFound()
647+
// The key is mandatory and we don't allow NUL in it as it's used for indexing
648+
if (!key || key.includes('\x00') || key.startsWith('_private')) {
649+
return Boom.badRequest()
650+
}
651+
const parsed = maybeParseCID(contractID)
652+
if (parsed?.code !== multicodes.SHELTER_CONTRACT_DATA) {
653+
return Boom.badRequest()
598654
}
599655

600656
if (!ctEq(request.auth.credentials.billableContractID, contractID)) {
@@ -628,7 +684,7 @@ route.POST('/kv/{contractID}/{key}', {
628684
// pass through
629685
} else {
630686
// "Quote" string (to match ETag format)
631-
const cid = JSON.stringify(createCID(existing))
687+
const cid = JSON.stringify(createCID(existing, multicodes.RAW))
632688
if (!expectedEtag.split(',').map(v => v.trim()).includes(cid)) {
633689
return Boom.preconditionFailed()
634690
}
@@ -674,8 +730,12 @@ route.GET('/kv/{contractID}/{key}', {
674730
}, async function (request, h) {
675731
const { contractID, key } = request.params
676732

677-
if (key.startsWith('_private')) {
678-
return Boom.notFound()
733+
if (!key || key.includes('\x00') || key.startsWith('_private')) {
734+
return Boom.badRequest()
735+
}
736+
const parsed = maybeParseCID(contractID)
737+
if (parsed?.code !== multicodes.SHELTER_CONTRACT_DATA) {
738+
return Boom.badRequest()
679739
}
680740

681741
if (!ctEq(request.auth.credentials.billableContractID, contractID)) {
@@ -687,8 +747,12 @@ route.GET('/kv/{contractID}/{key}', {
687747
return notFoundNoCache(h)
688748
}
689749

750+
<<<<<<< HEAD
690751
// TODO: conflict with PR 2494
691752
return h.response(result).etag(createCID(result)).header('content-type', 'application/json')
753+
=======
754+
return h.response(result).etag(createCID(result, multicodes.RAW))
755+
>>>>>>> master
692756
})
693757

694758
// SPA routes
@@ -775,7 +839,7 @@ route.POST('/zkpp/register/{name}', {
775839
if (!credentials?.billableContractID) {
776840
return Boom.unauthorized('Registering a salt requires ownership information', 'shelter')
777841
}
778-
if (req.params['name'].startsWith('_private')) return Boom.notFound()
842+
if (req.params['name'].startsWith('_private')) return Boom.badRequest()
779843
const contractID = await sbp('backend/db/lookupName', req.params['name'])
780844
if (contractID !== credentials.billableContractID) {
781845
// This ensures that only the owner of the contract can set a salt for it,
@@ -814,7 +878,7 @@ route.GET('/zkpp/{name}/auth_hash', {
814878
query: Joi.object({ b: Joi.string().required() })
815879
}
816880
}, async function (req, h) {
817-
if (req.params['name'].startsWith('_private')) return Boom.notFound()
881+
if (req.params['name'].startsWith('_private')) return Boom.badRequest()
818882
try {
819883
const challenge = await getChallenge(req.params['name'], req.query['b'])
820884

@@ -843,7 +907,7 @@ route.GET('/zkpp/{name}/contract_hash', {
843907
})
844908
}
845909
}, async function (req, h) {
846-
if (req.params['name'].startsWith('_private')) return Boom.notFound()
910+
if (req.params['name'].startsWith('_private')) return Boom.badRequest()
847911
try {
848912
const salt = await getContractSalt(req.params['name'], req.query['r'], req.query['s'], req.query['sig'], req.query['hc'])
849913

@@ -871,7 +935,7 @@ route.POST('/zkpp/{name}/updatePasswordHash', {
871935
})
872936
}
873937
}, async function (req, h) {
874-
if (req.params['name'].startsWith('_private')) return Boom.notFound()
938+
if (req.params['name'].startsWith('_private')) return Boom.badRequest()
875939
try {
876940
const result = await updateContractSalt(req.params['name'], req.payload['r'], req.payload['s'], req.payload['sig'], req.payload['hc'], req.payload['Ea'])
877941

backend/server.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
createServer
1919
} from './pubsub.js'
2020
import { addChannelToSubscription, deleteChannelFromSubscription, postEvent, pushServerActionhandlers, subscriptionInfoWrapper } from './push.js'
21-
import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js'
21+
import { SPMessage } from '~/shared/domains/chelonia/SPMessage.js'
2222
import type { SubMessage, UnsubMessage } from '~/shared/pubsub.js'
2323

2424
// Node.js version 18 and lower don't have global.crypto defined
@@ -160,7 +160,7 @@ sbp('sbp/selectors/register', {
160160
},
161161
'backend/server/handleEntry': async function (deserializedHEAD: Object, entry: string) {
162162
const contractID = deserializedHEAD.contractID
163-
if (deserializedHEAD.head.op === GIMessage.OP_CONTRACT) {
163+
if (deserializedHEAD.head.op === SPMessage.OP_CONTRACT) {
164164
sbp('okTurtles.data/get', PUBSUB_INSTANCE).channels.add(contractID)
165165
}
166166
await sbp('chelonia/private/in/enqueueHandleEvent', contractID, entry)

0 commit comments

Comments
 (0)