From 2cfa32c40959dd8c66eadc5bd4426f95134770d2 Mon Sep 17 00:00:00 2001 From: SarthakDudhe Date: Wed, 6 May 2026 20:05:25 +0530 Subject: [PATCH 1/2] Add Last-Modified header for GET and HEAD --- lib/handlers/get.mjs | 17 ++++--- lib/ldp.mjs | 6 +-- test/integration/http-test.mjs | 86 ++++++++++++++++++++++------------ 3 files changed, 70 insertions(+), 39 deletions(-) diff --git a/lib/handlers/get.mjs b/lib/handlers/get.mjs index a76b42527..b65ca616e 100644 --- a/lib/handlers/get.mjs +++ b/lib/handlers/get.mjs @@ -77,13 +77,16 @@ export default async function handler (req, res, next) { let contentRange let chunksize - if (ret) { - stream = ret.stream - contentType = ret.contentType - container = ret.container - contentRange = ret.contentRange - chunksize = ret.chunksize - } + if (ret) { + stream = ret.stream + contentType = ret.contentType + container = ret.container + contentRange = ret.contentRange + chunksize = ret.chunksize + if (ret.modified) { + res.header('Last-Modified', ret.modified.toUTCString()) + } + } // Till here it must exist if (!includeBody) { diff --git a/lib/ldp.mjs b/lib/ldp.mjs index 83dc25904..83f5f9264 100644 --- a/lib/ldp.mjs +++ b/lib/ldp.mjs @@ -451,7 +451,7 @@ class LDP { } if (!options.includeBody) { - return { stream: stats, contentType, container: stats.isDirectory() } + return { stream: stats, contentType, container: stats.isDirectory(), modified: stats.mtime } } if (stats.isDirectory()) { @@ -465,7 +465,7 @@ class LDP { throw err } const stream = stringToStream(data) - return { stream, contentType, container: true } + return { stream, contentType, container: true, modified: stats.mtime } } else { let chunksize, contentRange, start, end if (options.range) { @@ -487,7 +487,7 @@ class LDP { }) .on('open', function () { debug.handlers(`GET -- Reading ${pathLocal}`) - return resolve({ stream, contentType, container: false, contentRange, chunksize }) + return resolve({ stream, contentType, container: false, contentRange, chunksize, modified: stats.mtime }) }) })) } diff --git a/test/integration/http-test.mjs b/test/integration/http-test.mjs index cac2c886f..9c4b944c9 100644 --- a/test/integration/http-test.mjs +++ b/test/integration/http-test.mjs @@ -9,9 +9,9 @@ import { assert, expect } from 'chai' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const suffixAcl = '.acl' -const suffixMeta = '.meta' -const server = setupSupertestServer({ +const suffixAcl = '.acl' +const suffixMeta = '.meta' +const server = setupSupertestServer({ live: true, dataBrowserPath: 'default', root: path.join(__dirname, '../resources'), @@ -253,13 +253,20 @@ describe('HTTP APIs', function () { .expect('content-type', /text\/turtle/) .expect('Access-Control-Allow-Origin', 'http://example.com') .expect(200, done) - }) - it('should have set Link as resource', function (done) { - server.get('/sampleContainer2/example1.ttl') - .expect('content-type', /text\/turtle/) - .expect('Link', /; rel="type"/) - .expect(200, done) - }) + }) + it('should have set Link as resource', function (done) { + server.get('/sampleContainer2/example1.ttl') + .expect('content-type', /text\/turtle/) + .expect('Link', /; rel="type"/) + .expect(200, done) + }) + it('should have set Last-Modified for resource', function (done) { + const modified = fs.statSync(path.join(__dirname, + '../resources/sampleContainer2/example1.ttl')).mtime.toUTCString() + server.get('/sampleContainer2/example1.ttl') + .expect('Last-Modified', modified) + .expect(200, done) + }) it('should have set Updates-Via to use WebSockets', function (done) { server.get('/sampleContainer2/example1.ttl') .expect('updates-via', /wss?:\/\//) @@ -273,13 +280,20 @@ describe('HTTP APIs', function () { .expect(hasHeader('describedBy', 'example1.ttl' + suffixMeta)) .end(done) }) - it('should have set Link as Container/BasicContainer', function (done) { - server.get('/sampleContainer2/') - .expect('content-type', /text\/turtle/) - .expect('Link', /; rel="type"/) - .expect('Link', /; rel="type"/) - .expect(200, done) - }) + it('should have set Link as Container/BasicContainer', function (done) { + server.get('/sampleContainer2/') + .expect('content-type', /text\/turtle/) + .expect('Link', /; rel="type"/) + .expect('Link', /; rel="type"/) + .expect(200, done) + }) + it('should have set Last-Modified for container', function (done) { + const modified = fs.statSync(path.join(__dirname, + '../resources/sampleContainer2')).mtime.toUTCString() + server.get('/sampleContainer2/') + .expect('Last-Modified', modified) + .expect(200, done) + }) it('should load skin (mashlib) if resource was requested as text/html', function (done) { server.get('/sampleContainer2/example1.ttl') .set('Accept', 'text/html') @@ -505,11 +519,18 @@ describe('HTTP APIs', function () { .expect('updates-via', /wss?:\/\//) .expect(200, done) }) - it('should have set Link as Resource', function (done) { - server.head('/sampleContainer2/example1.ttl') - .expect('Link', /; rel="type"/) - .expect(200, done) - }) + it('should have set Link as Resource', function (done) { + server.head('/sampleContainer2/example1.ttl') + .expect('Link', /; rel="type"/) + .expect(200, done) + }) + it('should have set Last-Modified for resource', function (done) { + const modified = fs.statSync(path.join(__dirname, + '../resources/sampleContainer2/example1.ttl')).mtime.toUTCString() + server.head('/sampleContainer2/example1.ttl') + .expect('Last-Modified', modified) + .expect(200, done) + }) it('should have set acl and describedBy Links for resource', function (done) { server.head('/sampleContainer2/example1.ttl') @@ -523,13 +544,20 @@ describe('HTTP APIs', function () { .expect('Content-Type', /text\/turtle/) .expect(200, done) }) - it('should have set Link as Container/BasicContainer', - function (done) { - server.head('/sampleContainer2/') - .expect('Link', /; rel="type"/) - .expect('Link', /; rel="type"/) - .expect(200, done) - }) + it('should have set Link as Container/BasicContainer', + function (done) { + server.head('/sampleContainer2/') + .expect('Link', /; rel="type"/) + .expect('Link', /; rel="type"/) + .expect(200, done) + }) + it('should have set Last-Modified for container', function (done) { + const modified = fs.statSync(path.join(__dirname, + '../resources/sampleContainer2')).mtime.toUTCString() + server.head('/sampleContainer2/') + .expect('Last-Modified', modified) + .expect(200, done) + }) it('should have set acl and describedBy Links for container', function (done) { server.head('/sampleContainer2/') From e742b341919d0a8f755170a214dca8d11df8effd Mon Sep 17 00:00:00 2001 From: SarthakDudhe Date: Thu, 7 May 2026 19:28:03 +0530 Subject: [PATCH 2/2] Fix plus-sign recovery email handling --- lib/models/account-manager.mjs | 16 +++++------ lib/models/account-template.mjs | 35 +++++++++++++++--------- test/unit/account-manager-test.mjs | 42 ++++++++++++++++++++++------- test/unit/account-template-test.mjs | 33 ++++++++++++++++------- 4 files changed, 87 insertions(+), 39 deletions(-) diff --git a/lib/models/account-manager.mjs b/lib/models/account-manager.mjs index b204b6439..9fa188f02 100644 --- a/lib/models/account-manager.mjs +++ b/lib/models/account-manager.mjs @@ -231,14 +231,14 @@ class AccountManager { return this.store.getGraph(rootAclUri) }) .then(rootAclGraph => { - const matches = rootAclGraph.match(null, ns.acl('agent')) - let recoveryMailto = matches.find(agent => agent.object.value.startsWith('mailto:')) - if (recoveryMailto) { - recoveryMailto = recoveryMailto.object.value.replace('mailto:', '') - } - return recoveryMailto - }) - } + const matches = rootAclGraph.match(null, ns.acl('agent')) + let recoveryMailto = matches.find(agent => agent.object.value.startsWith('mailto:')) + if (recoveryMailto) { + recoveryMailto = decodeURIComponent(recoveryMailto.object.value.replace('mailto:', '')) + } + return recoveryMailto + }) + } verifyEmailDependencies (userAccount) { if (!this.emailService) { diff --git a/lib/models/account-template.mjs b/lib/models/account-template.mjs index d01262895..0af6cdd56 100644 --- a/lib/models/account-template.mjs +++ b/lib/models/account-template.mjs @@ -9,7 +9,7 @@ import { URL } from 'url' export const TEMPLATE_EXTENSIONS = ['.acl', '.meta', '.json', '.hbs', '.handlebars'] export const TEMPLATE_FILES = ['card'] -class AccountTemplate { +class AccountTemplate { constructor (options = {}) { this.substitutions = options.substitutions || {} this.templateExtensions = options.templateExtensions || TEMPLATE_EXTENSIONS @@ -26,17 +26,28 @@ class AccountTemplate { return fsUtils.copyTemplateDir(templatePath, accountPath) } - static templateSubstitutionsFor (userAccount) { - const webUri = new URL(userAccount.webId) - const podRelWebId = userAccount.webId.replace(webUri.origin, '') - const substitutions = { - name: userAccount.displayName, - webId: userAccount.externalWebId ? userAccount.webId : podRelWebId, - email: userAccount.email, - idp: userAccount.idp - } - return substitutions - } + static templateSubstitutionsFor (userAccount) { + const webUri = new URL(userAccount.webId) + const podRelWebId = userAccount.webId.replace(webUri.origin, '') + const substitutions = { + name: userAccount.displayName, + webId: userAccount.externalWebId ? userAccount.webId : podRelWebId, + email: AccountTemplate.encodeMailtoAddress(userAccount.email), + idp: userAccount.idp + } + return substitutions + } + + static encodeMailtoAddress (email) { + if (!email) { + return email + } + const [localPart, domain] = email.split('@') + if (!domain) { + return encodeURIComponent(email) + } + return `${encodeURIComponent(localPart)}@${domain}` + } readAccountFiles (accountPath) { return new Promise((resolve, reject) => { diff --git a/test/unit/account-manager-test.mjs b/test/unit/account-manager-test.mjs index d7b7e758e..a6fc17c11 100644 --- a/test/unit/account-manager-test.mjs +++ b/test/unit/account-manager-test.mjs @@ -358,8 +358,8 @@ describe('AccountManager', () => { }) describe('loadAccountRecoveryEmail()', () => { - it('parses and returns the agent mailto from the root acl', () => { - const userAccount = UserAccount.from({ username: 'alice' }) + it('parses and returns the agent mailto from the root acl', () => { + const userAccount = UserAccount.from({ username: 'alice' }) const rootAclGraph = rdf.graph() rootAclGraph.add( @@ -377,13 +377,37 @@ describe('AccountManager', () => { const accountManager = AccountManager.from(options) return accountManager.loadAccountRecoveryEmail(userAccount) - .then(recoveryEmail => { - expect(recoveryEmail).to.equal('alice@example.com') - }) - }) - - it('should return undefined when agent mailto is missing', () => { - const userAccount = UserAccount.from({ username: 'alice' }) + .then(recoveryEmail => { + expect(recoveryEmail).to.equal('alice@example.com') + }) + }) + + it('decodes encoded plus signs in the agent mailto from the root acl', () => { + const userAccount = UserAccount.from({ username: 'solidgold' }) + + const rootAclGraph = rdf.graph() + rootAclGraph.add( + rdf.namedNode('https://solidgold.example.com/.acl#owner'), + ns.acl('agent'), + rdf.namedNode('mailto:jon%2Bsolidtest@snap.com') + ) + + const store = { + suffixAcl: '.acl', + getGraph: sinon.stub().resolves(rootAclGraph) + } + + const options = { host, multiuser: true, store } + const accountManager = AccountManager.from(options) + + return accountManager.loadAccountRecoveryEmail(userAccount) + .then(recoveryEmail => { + expect(recoveryEmail).to.equal('jon+solidtest@snap.com') + }) + }) + + it('should return undefined when agent mailto is missing', () => { + const userAccount = UserAccount.from({ username: 'alice' }) const emptyGraph = rdf.graph() diff --git a/test/unit/account-template-test.mjs b/test/unit/account-template-test.mjs index d30a46cd2..fd8025c97 100644 --- a/test/unit/account-template-test.mjs +++ b/test/unit/account-template-test.mjs @@ -40,19 +40,32 @@ describe('AccountTemplate', () => { }) describe('templateSubstitutionsFor()', () => { - it('should init', () => { - const userOptions = { - username: 'alice', - webId: 'https://alice.example.com/profile/card#me', - name: 'Alice Q.', + it('should init', () => { + const userOptions = { + username: 'alice', + webId: 'https://alice.example.com/profile/card#me', + name: 'Alice Q.', email: 'alice@example.com' } const userAccount = UserAccount.from(userOptions) const substitutions = AccountTemplate.templateSubstitutionsFor(userAccount) expect(substitutions.name).to.equal('Alice Q.') - expect(substitutions.email).to.equal('alice@example.com') - expect(substitutions.webId).to.equal('/profile/card#me') - }) - }) -}) + expect(substitutions.email).to.equal('alice@example.com') + expect(substitutions.webId).to.equal('/profile/card#me') + }) + + it('encodes plus signs in email addresses for mailto templates', () => { + const userOptions = { + username: 'solidgold', + webId: 'https://solidgold.example.com/profile/card#me', + name: 'Solid Gold', + email: 'jon+solidtest@snap.com' + } + const userAccount = UserAccount.from(userOptions) + + const substitutions = AccountTemplate.templateSubstitutionsFor(userAccount) + expect(substitutions.email).to.equal('jon%2Bsolidtest@snap.com') + }) + }) +})