Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/utils/url.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,16 @@ export function getContentType(filePath) {
'.m3u8': 'application/vnd.apple.mpegurl',
'.pls': 'audio/x-scpls'
};

// Solid convention dotfiles (.acl, .meta) are RDF resources. path.extname
// returns '' for leading-dot names, so the map lookup above misses them;
// fall back to a basename check and tag them as JSON-LD — the format JSS
// writes them in via serializeAcl() / createPodStructure(). Content
// negotiation then handles Turtle-native clients (umai, Soukai-based apps,
// older Solid tooling) via handleGet's conneg branch.
const base = path.basename(filePath);
if (base === '.acl' || base === '.meta') return 'application/ld+json';

return types[ext] || 'application/octet-stream';
}

Expand Down
67 changes: 67 additions & 0 deletions test/conneg.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,73 @@ describe('Content Negotiation (conneg enabled)', () => {
'Accept-Post should include text/turtle');
});
});

// Regression coverage for #294 — Solid convention dotfiles (.acl, .meta)
// were excluded from conneg because getContentType() returned
// application/octet-stream for them. Turtle-native clients (umai etc.)
// fetching <container>/.meta got JSON-LD back and errored on parse.
describe('Solid convention dotfiles (#294)', () => {
const metaData = {
'@context': { 'ldp': 'http://www.w3.org/ns/ldp#' },
'@id': '',
'@type': 'ldp:BasicContainer'
};

before(async () => {
// Write a JSON-LD .meta file (the format JSS writes internally).
await request('/connegtest/public/.meta', {
method: 'PUT',
headers: { 'Content-Type': 'application/ld+json' },
body: JSON.stringify(metaData),
auth: 'connegtest'
});
});

it('serves .meta as JSON-LD by default', async () => {
const res = await request('/connegtest/public/.meta', { auth: 'connegtest' });
assertStatus(res, 200);
assertHeaderContains(res, 'Content-Type', 'application/ld+json');
});

it('serves .meta as Turtle when Accept: text/turtle (the umai case)', async () => {
const res = await request('/connegtest/public/.meta', {
headers: { 'Accept': 'text/turtle' },
auth: 'connegtest'
});
assertStatus(res, 200);
assertHeaderContains(res, 'Content-Type', 'text/turtle');
const turtle = await res.text();
// First byte after the `@prefix` block must parse as Turtle,
// not '{' (the bug signature umai hit).
assert.ok(!turtle.trimStart().startsWith('{'),
`response looks like JSON, not Turtle: ${turtle.slice(0, 60)}`);
});

it('accepts Turtle PUT to .meta and round-trips to JSON-LD', async () => {
const turtle = `
@prefix ldp: <http://www.w3.org/ns/ldp#>.
<> a ldp:BasicContainer.
`;
const putRes = await request('/connegtest/public/.meta', {
method: 'PUT',
headers: { 'Content-Type': 'text/turtle' },
body: turtle,
auth: 'connegtest'
});
assert.ok(putRes.status < 300, `PUT turtle should succeed, got ${putRes.status}`);

// Default GET now serves the converted-and-stored JSON-LD.
const getRes = await request('/connegtest/public/.meta', {
headers: { 'Accept': 'application/ld+json' },
auth: 'connegtest'
});
assertStatus(getRes, 200);
assertHeaderContains(getRes, 'Content-Type', 'application/ld+json');
const body = await getRes.json();
assert.ok(body['@context'] || body['@graph'] || body['@type'] || body['@id'],
'round-tripped JSON-LD should have at least one @-keyword');
});
});
});

describe('Content Negotiation (conneg disabled - default)', () => {
Expand Down
36 changes: 35 additions & 1 deletion test/url.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import { describe, it } from 'node:test';
import assert from 'node:assert';
import { getPodName } from '../src/utils/url.js';
import { getPodName, getContentType } from '../src/utils/url.js';

describe('getPodName', () => {
describe('subdomain mode', () => {
Expand Down Expand Up @@ -73,3 +73,37 @@ describe('getPodName', () => {
});
});
});

// Regression coverage for #294 — .acl and .meta must be recognised as RDF
// resources so content negotiation kicks in for Turtle-native clients.
describe('getContentType', () => {
describe('extension-based mapping (existing)', () => {
it('maps .jsonld → application/ld+json', () => {
assert.strictEqual(getContentType('/x/card.jsonld'), 'application/ld+json');
});
it('maps .ttl → text/turtle', () => {
assert.strictEqual(getContentType('/x/card.ttl'), 'text/turtle');
});
it('falls back to application/octet-stream for unknown extensions', () => {
assert.strictEqual(getContentType('/x/file.xyz'), 'application/octet-stream');
});
});

describe('Solid convention dotfiles (#294)', () => {
it('treats .acl as application/ld+json (the format JSS writes it in)', () => {
assert.strictEqual(getContentType('/alice/public/.acl'), 'application/ld+json');
assert.strictEqual(getContentType('.acl'), 'application/ld+json');
});

it('treats .meta as application/ld+json', () => {
assert.strictEqual(getContentType('/alice/public/.meta'), 'application/ld+json');
assert.strictEqual(getContentType('.meta'), 'application/ld+json');
});

it('does not mistake non-dotfile paths containing .acl for ACL files', () => {
// A regular file that happens to have "acl" in its name/path stays
// classified by extension, not by coincidence.
assert.strictEqual(getContentType('/alice/notes/my-acl-plan.md'), 'text/markdown');
});
});
});