Compare commits
5 Commits
241078316c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 418a553429 | |||
| e25c715fb7 | |||
| 9fefe5c65d | |||
| e2944fd828 | |||
| a4e6a5784a |
1
.github/workflows/deploy-homelab.yml
vendored
1
.github/workflows/deploy-homelab.yml
vendored
@@ -41,6 +41,7 @@ jobs:
|
|||||||
export JWT_SECRET=${{ secrets.JWT_SECRET }}
|
export JWT_SECRET=${{ secrets.JWT_SECRET }}
|
||||||
export PORT=${{ vars.PORT }}
|
export PORT=${{ vars.PORT }}
|
||||||
export BASE_URL=${{ vars.BASE_URL }}
|
export BASE_URL=${{ vars.BASE_URL }}
|
||||||
|
export MAX_FILE_SIZE=${{ vars.MAX_FILE_SIZE }}
|
||||||
docker compose -f docker-compose.yml down
|
docker compose -f docker-compose.yml down
|
||||||
docker compose -f docker-compose.yml up -d --build
|
docker compose -f docker-compose.yml up -d --build
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ services:
|
|||||||
nanodrop:
|
nanodrop:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-3000}:${PORT:-3000}"
|
- "127.0.0.1:${PORT:-3000}:${PORT:-3000}"
|
||||||
environment: { <<: *env }
|
environment: { <<: *env }
|
||||||
volumes:
|
volumes:
|
||||||
- nanodrop-data:/app/data
|
- nanodrop-data:/app/data
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface FileRow {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateFileParams {
|
interface CreateFileParams {
|
||||||
id: string;
|
id: string;
|
||||||
userId: number;
|
userId: number;
|
||||||
originalName: string;
|
originalName: string;
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type Database from 'better-sqlite3';
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
export interface UserRow {
|
interface UserRow {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
password_hash: string;
|
password_hash: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateUserParams {
|
interface CreateUserParams {
|
||||||
username: string;
|
username: string;
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyPluginAsync } from 'fastify';
|
import type { FastifyPluginAsync } from 'fastify';
|
||||||
import { extname } from 'path';
|
import { extname } from 'path';
|
||||||
import { readFile } from 'fs/promises';
|
import { createReadStream } from 'fs';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import type Database from 'better-sqlite3';
|
import type Database from 'better-sqlite3';
|
||||||
import type { Config } from '../config.ts';
|
import type { Config } from '../config.ts';
|
||||||
@@ -23,6 +23,29 @@ interface Deps {
|
|||||||
logger: Logger;
|
logger: Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseRangeHeader(header: string, fileSize: number): { start: number; end: number } | null {
|
||||||
|
const match = header.match(/^bytes=(\d*)-(\d*)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const [, rawStart, rawEnd] = match;
|
||||||
|
|
||||||
|
let start: number;
|
||||||
|
let end: number;
|
||||||
|
|
||||||
|
if (rawStart === '') {
|
||||||
|
// Suffix range: bytes=-N (last N bytes)
|
||||||
|
const suffix = parseInt(rawEnd, 10);
|
||||||
|
start = Math.max(0, fileSize - suffix);
|
||||||
|
end = fileSize - 1;
|
||||||
|
} else {
|
||||||
|
start = parseInt(rawStart, 10);
|
||||||
|
end = rawEnd === '' ? fileSize - 1 : parseInt(rawEnd, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start > end || end >= fileSize) return null;
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => {
|
export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => {
|
||||||
const { db, config, logger } = deps;
|
const { db, config, logger } = deps;
|
||||||
|
|
||||||
@@ -140,7 +163,7 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
|
|||||||
reply.type('text/html').send(fileViewPage(file, isOwner));
|
reply.type('text/html').send(fileViewPage(file, isOwner));
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /f/:id/raw — serve raw file
|
// GET /f/:id/raw — serve raw file with range request support
|
||||||
app.get<{ Params: { id: string } }>('/f/:id/raw', async (request, reply) => {
|
app.get<{ Params: { id: string } }>('/f/:id/raw', async (request, reply) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const file = getFileById(db, id);
|
const file = getFileById(db, id);
|
||||||
@@ -151,12 +174,37 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filePath = getFilePath(config.uploadDir, file.stored_name);
|
const filePath = getFilePath(config.uploadDir, file.stored_name);
|
||||||
const data = await readFile(filePath);
|
|
||||||
const safeFilename = file.original_name.replace(/["\\\r\n]/g, '_');
|
const safeFilename = file.original_name.replace(/["\\\r\n]/g, '_');
|
||||||
|
|
||||||
reply
|
reply
|
||||||
.header('Content-Type', file.mime_type)
|
.header('Content-Type', file.mime_type)
|
||||||
.header('Content-Disposition', `inline; filename="${safeFilename}"`)
|
.header('Content-Disposition', `inline; filename="${safeFilename}"`)
|
||||||
.send(data);
|
.header('Accept-Ranges', 'bytes');
|
||||||
|
|
||||||
|
const rangeHeader = request.headers['range'];
|
||||||
|
|
||||||
|
if (!rangeHeader) {
|
||||||
|
reply.header('Content-Length', file.size);
|
||||||
|
return reply.send(createReadStream(filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = parseRangeHeader(rangeHeader, file.size);
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
return reply
|
||||||
|
.status(416)
|
||||||
|
.header('Content-Range', `bytes */${file.size}`)
|
||||||
|
.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start, end } = range;
|
||||||
|
const chunkSize = end - start + 1;
|
||||||
|
|
||||||
|
return reply
|
||||||
|
.status(206)
|
||||||
|
.header('Content-Range', `bytes ${start}-${end}/${file.size}`)
|
||||||
|
.header('Content-Length', chunkSize)
|
||||||
|
.send(createReadStream(filePath, { start, end }));
|
||||||
});
|
});
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export async function loginAs(app: FastifyInstance, username: string, password:
|
|||||||
return cookie.split(';')[0].replace('token=', '');
|
return cookie.split(';')[0].replace('token=', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MultipartFile {
|
interface MultipartFile {
|
||||||
filename: string;
|
filename: string;
|
||||||
contentType: string;
|
contentType: string;
|
||||||
data: Buffer;
|
data: Buffer;
|
||||||
|
|||||||
@@ -125,6 +125,51 @@ describe('GET /f/:id and GET /f/:id/raw', () => {
|
|||||||
const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}/raw` });
|
const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}/raw` });
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.body).toBe('hello!');
|
expect(res.body).toBe('hello!');
|
||||||
|
expect(res.headers['accept-ranges']).toBe('bytes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 206 for a byte range request', async () => {
|
||||||
|
const res = await ctx.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/f/${fileId}/raw`,
|
||||||
|
headers: { range: 'bytes=0-3' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(206);
|
||||||
|
expect(res.headers['content-range']).toBe('bytes 0-3/6');
|
||||||
|
expect(res.headers['content-length']).toBe('4');
|
||||||
|
expect(res.body).toBe('hell');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 206 for an open-ended range request', async () => {
|
||||||
|
const res = await ctx.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/f/${fileId}/raw`,
|
||||||
|
headers: { range: 'bytes=2-' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(206);
|
||||||
|
expect(res.headers['content-range']).toBe('bytes 2-5/6');
|
||||||
|
expect(res.body).toBe('llo!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 206 for a suffix range request', async () => {
|
||||||
|
const res = await ctx.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/f/${fileId}/raw`,
|
||||||
|
headers: { range: 'bytes=-3' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(206);
|
||||||
|
expect(res.headers['content-range']).toBe('bytes 3-5/6');
|
||||||
|
expect(res.body).toBe('lo!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 416 for an unsatisfiable range', async () => {
|
||||||
|
const res = await ctx.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/f/${fileId}/raw`,
|
||||||
|
headers: { range: 'bytes=100-200' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(416);
|
||||||
|
expect(res.headers['content-range']).toBe('bytes */6');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 404 for unknown file', async () => {
|
it('returns 404 for unknown file', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user