Compare commits
10 Commits
8b27038793
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 418a553429 | |||
| e25c715fb7 | |||
| 9fefe5c65d | |||
| e2944fd828 | |||
| a4e6a5784a | |||
| 241078316c | |||
| 85b6f8df2c | |||
| 00ab308280 | |||
| d616d4e067 | |||
| 82793c6d0d |
48
.github/workflows/deploy-homelab.yml
vendored
Normal file
48
.github/workflows/deploy-homelab.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: "Deploy to Homelab"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan ${{ vars.HOST }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Remove directory from server
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_ed25519 ${{ vars.USERNAME }}@${{ vars.HOST }} << 'EOF'
|
||||
rm -rf ~/${{ vars.DIRECTORY_NAME }}
|
||||
EOF
|
||||
|
||||
# Avoid needing to set up SSH access to GitHub for this user
|
||||
- name: Transfer repository files to server
|
||||
run: |
|
||||
scp -i ~/.ssh/id_ed25519 -r ./* ${{ vars.USERNAME }}@${{ vars.HOST }}:~/${{ vars.DIRECTORY_NAME }}
|
||||
|
||||
- name: Deploy on server with Docker
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_ed25519 ${{ vars.USERNAME }}@${{ vars.HOST }} << 'EOF'
|
||||
cd ~/${{ vars.DIRECTORY_NAME }}
|
||||
export TRUST_PROXY=true
|
||||
export COOKIE_SECURE=true
|
||||
export JWT_SECRET=${{ secrets.JWT_SECRET }}
|
||||
export PORT=${{ vars.PORT }}
|
||||
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 up -d --build
|
||||
EOF
|
||||
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
nanodrop:
|
||||
build: .
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
- "127.0.0.1:${PORT:-3000}:${PORT:-3000}"
|
||||
environment: { <<: *env }
|
||||
volumes:
|
||||
- nanodrop-data:/app/data
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface FileRow {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateFileParams {
|
||||
interface CreateFileParams {
|
||||
id: string;
|
||||
userId: number;
|
||||
originalName: string;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export interface UserRow {
|
||||
interface UserRow {
|
||||
id: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateUserParams {
|
||||
interface CreateUserParams {
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
import { extname } from 'path';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { createReadStream } from 'fs';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { Config } from '../config.ts';
|
||||
@@ -23,6 +23,29 @@ interface Deps {
|
||||
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 }) => {
|
||||
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));
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
const { id } = request.params;
|
||||
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 data = await readFile(filePath);
|
||||
const safeFilename = file.original_name.replace(/["\\\r\n]/g, '_');
|
||||
|
||||
reply
|
||||
.header('Content-Type', file.mime_type)
|
||||
.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
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function loginAs(app: FastifyInstance, username: string, password:
|
||||
return cookie.split(';')[0].replace('token=', '');
|
||||
}
|
||||
|
||||
export interface MultipartFile {
|
||||
interface MultipartFile {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
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` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user