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
95 changes: 95 additions & 0 deletions src/handlers/resource.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,64 @@ function getRequestPaths(request) {
return { urlPath, storagePath, resourceUrl };
}

/**
* Parse HTTP Range header
* @param {string} rangeHeader - The Range header value (e.g., "bytes=0-1023")
* @param {number} fileSize - Total file size in bytes
* @returns {{ start: number, end: number } | null}
*/
function parseRangeHeader(rangeHeader, fileSize) {
if (!rangeHeader || !rangeHeader.startsWith('bytes=')) {
return null;
}

const range = rangeHeader.slice(6); // Remove 'bytes='

// Multi-range requests (e.g., "0-100,200-300") are not supported
// Per RFC 7233, ignore Range header and serve full content instead of 416
if (range.includes(',')) {
return null;
}

const parts = range.split('-');

if (parts.length !== 2) {
return null;
}
Comment on lines +54 to +56

Copilot AI Jan 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation rejects multiple range requests (e.g., 'bytes=0-100,200-300') with a 416 Range Not Satisfiable response. According to RFC 7233, if you don't support multipart ranges but at least one range is satisfiable, you should either serve the first range or ignore the Range header and return 200 with full content. Consider handling this edge case by ignoring the Range header for multipart requests instead of returning 416.

Copilot uses AI. Check for mistakes.

let start, end;

if (parts[0] === '') {
// Suffix range: bytes=-500 (last 500 bytes)
const suffix = parseInt(parts[1], 10);
if (isNaN(suffix) || suffix <= 0) return null;
start = Math.max(0, fileSize - suffix);
end = fileSize - 1;
} else if (parts[1] === '') {
// Open-ended range: bytes=1024- (from 1024 to end)
start = parseInt(parts[0], 10);
if (isNaN(start) || start < 0) return null;
end = fileSize - 1;
} else {
// Normal range: bytes=0-1023
start = parseInt(parts[0], 10);
end = parseInt(parts[1], 10);
if (isNaN(start) || isNaN(end) || start < 0 || end < start) return null;
}

// Clamp end to file size
if (end >= fileSize) {
end = fileSize - 1;
}

// Check if range is satisfiable
if (start > end || start >= fileSize) {
return null;
}

return { start, end };
}

/**
* Handle GET request
*/
Expand Down Expand Up @@ -245,6 +303,43 @@ export async function handleGet(request, reply) {
return reply.type('text/html').send(html);
}

// Handle Range requests for media files (video, audio, etc.)
const rangeHeader = request.headers.range;
if (rangeHeader && !isRdfContentType(storedContentType)) {
const range = parseRangeHeader(rangeHeader, stats.size);

if (range) {
const { start, end } = range;
const chunkSize = end - start + 1;

const headers = getAllHeaders({
isContainer: false,
etag: stats.etag,
contentType: storedContentType,
origin,
resourceUrl,
connegEnabled
});
headers['Content-Range'] = `bytes ${start}-${end}/${stats.size}`;
headers['Content-Length'] = chunkSize;

Object.entries(headers).forEach(([k, v]) => reply.header(k, v));

const streamResult = storage.createReadStream(storagePath, { start, end });
if (!streamResult) {
return reply.code(500).send({ error: 'Stream error' });
}

Copilot AI Jan 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream errors that occur after the stream is passed to reply.send() are not handled. If the file is deleted or becomes unreadable while streaming, the error will not be caught, potentially causing the response to hang or fail silently. Consider adding an error handler to the stream before sending it in the response.

Suggested change
streamResult.stream.on('error', (err) => {
// Log the error if a logger is available
if (request && request.log && typeof request.log.error === 'function') {
request.log.error({ err }, 'Error while streaming resource');
}
// Avoid sending another response if one has already been sent
if (!reply.sent) {
reply.code(500).send({ error: 'Stream error' });
}
});

Copilot uses AI. Check for mistakes.
// Handle stream errors that occur during response
streamResult.stream.on('error', (err) => {
console.error('Stream error during range response:', err.message);
});

return reply.code(206).send(streamResult.stream);
}
// If range is null (unsupported format or multi-range), fall through to serve full content
}

const content = await storage.read(storagePath);
if (content === null) {
return reply.code(500).send({ error: 'Read error' });
Expand Down
5 changes: 3 additions & 2 deletions src/ldp/headers.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export function getResponseHeaders({ isContainer = false, etag = null, contentTy
const headers = {
'Link': getLinkHeader(isContainer, aclUrl),
'Accept-Patch': 'text/n3, application/sparql-update',
'Accept-Ranges': isContainer ? 'none' : 'bytes',
'Allow': 'GET, HEAD, PUT, DELETE, PATCH, OPTIONS' + (isContainer ? ', POST' : ''),
'Vary': connegEnabled ? 'Accept, Authorization, Origin' : 'Authorization, Origin'
};
Expand Down Expand Up @@ -94,8 +95,8 @@ export function getCorsHeaders(origin) {
return {
'Access-Control-Allow-Origin': origin || '*',
'Access-Control-Allow-Methods': 'GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'Accept, Authorization, Content-Type, DPoP, If-Match, If-None-Match, Link, Slug, Origin',
'Access-Control-Expose-Headers': 'Accept-Patch, Accept-Post, Allow, Content-Type, ETag, Link, Location, Updates-Via, WAC-Allow',
'Access-Control-Allow-Headers': 'Accept, Authorization, Content-Type, DPoP, If-Match, If-None-Match, Link, Range, Slug, Origin',
'Access-Control-Expose-Headers': 'Accept-Patch, Accept-Post, Accept-Ranges, Allow, Content-Length, Content-Range, Content-Type, ETag, Link, Location, Updates-Via, WAC-Allow',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Max-Age': '86400'
};
Expand Down
22 changes: 22 additions & 0 deletions src/storage/filesystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,28 @@ export async function read(urlPath) {
}
}

/**
* Create a readable stream for a resource (supports range requests)
* @param {string} urlPath
* @param {object} options - { start, end } byte range options
* @returns {{ stream: ReadStream, filePath: string } | null}
*/
export function createReadStream(urlPath, options = {}) {
const filePath = urlToPath(urlPath);

// Check file exists before creating stream (createReadStream doesn't throw sync)
if (!fs.pathExistsSync(filePath)) {
return null;
}

try {
const stream = fs.createReadStream(filePath, options);
return { stream, filePath };
} catch {
return null;
}
}

/**
* Write resource content
* @param {string} urlPath
Expand Down
145 changes: 145 additions & 0 deletions test/range.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* Range Request Tests
*
* Tests HTTP Range header support for partial content delivery.
*/

import { describe, it, before, after } from 'node:test';
import assert from 'node:assert';
import {
startTestServer,
stopTestServer,
request,
createTestPod,
assertStatus
} from './helpers.js';

describe('Range Requests', () => {
const testContent = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; // 36 bytes

before(async () => {
await startTestServer();
await createTestPod('rangetest');

// Create a test file with known content
await request('/rangetest/public/test.txt', {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: testContent,
auth: 'rangetest'
});
});

after(async () => {
await stopTestServer();
});

describe('Accept-Ranges header', () => {
it('should include Accept-Ranges: bytes for files', async () => {
const res = await request('/rangetest/public/test.txt');
assertStatus(res, 200);
assert.strictEqual(res.headers.get('Accept-Ranges'), 'bytes');
});

it('should include Accept-Ranges: none for containers', async () => {
const res = await request('/rangetest/public/');
assertStatus(res, 200);
assert.strictEqual(res.headers.get('Accept-Ranges'), 'none');
});
});

describe('Range header parsing', () => {
it('should return 206 for valid range bytes=0-9', async () => {
const res = await request('/rangetest/public/test.txt', {
headers: { 'Range': 'bytes=0-9' }
});
assertStatus(res, 206);

const body = await res.text();
assert.strictEqual(body, 'ABCDEFGHIJ');
assert.strictEqual(res.headers.get('Content-Range'), 'bytes 0-9/36');
assert.strictEqual(res.headers.get('Content-Length'), '10');
});

it('should return 206 for open-ended range bytes=30-', async () => {
const res = await request('/rangetest/public/test.txt', {
headers: { 'Range': 'bytes=30-' }
});
assertStatus(res, 206);

const body = await res.text();
assert.strictEqual(body, '456789');
assert.strictEqual(res.headers.get('Content-Range'), 'bytes 30-35/36');
});

it('should return 206 for suffix range bytes=-6', async () => {
const res = await request('/rangetest/public/test.txt', {
headers: { 'Range': 'bytes=-6' }
});
assertStatus(res, 206);

const body = await res.text();
assert.strictEqual(body, '456789');
assert.strictEqual(res.headers.get('Content-Range'), 'bytes 30-35/36');
});

it('should clamp end to file size for range exceeding file', async () => {
const res = await request('/rangetest/public/test.txt', {
headers: { 'Range': 'bytes=30-1000' }
});
assertStatus(res, 206);

const body = await res.text();
assert.strictEqual(body, '456789');
assert.strictEqual(res.headers.get('Content-Range'), 'bytes 30-35/36');
});
});

describe('Multi-range requests', () => {
it('should ignore multi-range and return 200 with full content', async () => {
const res = await request('/rangetest/public/test.txt', {
headers: { 'Range': 'bytes=0-5,10-15' }
});
// Multi-range is not supported, should fall back to 200
assertStatus(res, 200);

const body = await res.text();
assert.strictEqual(body, testContent);
});
});

describe('Invalid ranges', () => {
it('should return 200 for invalid range format', async () => {
const res = await request('/rangetest/public/test.txt', {
headers: { 'Range': 'invalid' }
});
// Invalid format, ignore Range header
assertStatus(res, 200);
});

it('should return 200 for non-bytes range unit', async () => {
const res = await request('/rangetest/public/test.txt', {
headers: { 'Range': 'chars=0-10' }
});
assertStatus(res, 200);
});
});

describe('RDF resources', () => {
it('should ignore Range header for RDF resources', async () => {
// Create an RDF resource
await request('/rangetest/public/data.jsonld', {
method: 'PUT',
headers: { 'Content-Type': 'application/ld+json' },
body: JSON.stringify({ '@id': '#test', 'http://example.org/name': 'Test' }),
auth: 'rangetest'
});

const res = await request('/rangetest/public/data.jsonld', {
headers: { 'Range': 'bytes=0-10' }
});
// RDF resources don't support range requests
assertStatus(res, 200);
});
});
});