Skip to content
Draft
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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Required in production — generate with: openssl rand -hex 32
# SESSION_SECRET is the standard name; SECRET_KEY is still accepted for backward compatibility.
SESSION_SECRET=change_me_to_a_random_64_char_string_before_deploying

# Base directory for user files (must match your Docker volume mount)
BASE_DIRECTORY=/data

# Display name shown in the UI
SITE_NAME=SkyBit

# Port the server listens on
PORT=7070
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: CI

on:
push:
branches: [main, master]
pull_request:

jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm run build
env:
SESSION_SECRET: ci_build_placeholder_secret_at_least_32_chars_long
NEXT_TELEMETRY_DISABLED: "1"
123 changes: 123 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
npm run dev # http://localhost:7070
npm run build
npm run typecheck # tsc --noEmit
npm run lint # eslint .
docker build -t rayderc/skybit:latest .
docker rm -f skybit; docker run -d --name skybit -p 7070:7070 \
-v ./data:/data -v ./config:/app/config \
-e SESSION_SECRET=$(openssl rand -hex 32) \
rayderc/skybit:latest
```

`skybit-deploy.ps1` is a manual PowerShell script for pushing to Docker Hub — not part of CI.

## Architecture

Hybrid Next.js router: `app/` (App Router, all UI pages) + `pages/api/` (Pages Router, ~34 API endpoints). All `app/` pages are `"use client"` components. Auth is enforced client-side — pages fetch `/api/auth/user` in `useEffect` and redirect to `/login` on 401.

### Data Store

SkyBit uses a **JSON file** store at `config/data.json` — **not SQLite**. This is a key distinction from sibling apps. `lib/db.ts` reads/writes a single JSON file with three top-level keys:

- `users` — array of `{ id, username, password (bcrypt), role, created_at }`
- `tempShares` — array of `{ token, filepath, expires_at, created_at }`
- `config` — key/value map (e.g. `site_name`)
- `nextUserId` — auto-increment counter

On startup `lib/db.ts` seeds `site_name` from `SITE_NAME` env var and migrates legacy `users.json` if present (renames to `.migrated`). Corrupt `data.json` is backed up as `data.json.bak.<timestamp>` and a fresh store is started. Backups are never deleted automatically.

### File Storage

Files live under `BASE_DIRECTORY` (env var, Docker default `/data`). `lib/fs.ts` exposes:

- `resolveSafePath(userPath)` — resolves and validates that the result stays within `BASE_DIR` (throws on path-traversal attempts)
- `toRelative(absolutePath)` — strips `BASE_DIR` prefix
- File-type detection via extension sets (`IMAGE_EXTS`, `VIDEO_EXTS`, `AUDIO_EXTS`, `TEXT_EXTS`)
- `detectImageFolder()` — returns true if >50% of non-directory entries are images (triggers gallery view)

### Auth & Session

`iron-session` (`lib/session.ts`), cookie name `skybit-session` (note the hyphen — kept exactly as-is). Session data: `{ userId, username, role }`.

Secret priority: `SESSION_SECRET` env var → `SECRET_KEY` env var (legacy) → build-time placeholder. Production requires ≥32 chars or the server throws at startup.

Roles: `admin` / `mod` / `user`. Admins manage users and temp shares. Mods can upload, rename, delete, and edit text files. Users browse and download only.

### CSRF

`lib/csrf.ts` checks `Origin` vs `Host` on mutating API routes. Requests without `Origin` (direct API clients) pass unconditionally. Applied manually per-handler in `pages/api/`.

### API Endpoints (~34 total)

**Auth** — `/api/auth/login`, `/api/auth/logout`, `/api/auth/setup`, `/api/auth/user`

**Files** — `/api/files/list`, `/api/files/download`, `/api/files/upload` (streaming via busboy), `/api/files/preview`, `/api/files/thumb` (sharp thumbnails + EXIF rotation), `/api/files/delete`, `/api/files/rename`, `/api/files/move`, `/api/files/copy`, `/api/files/new-file`, `/api/files/new-folder`, `/api/files/save` (text edit), `/api/files/folder-tree`, `/api/files/bulk-delete`, `/api/files/bulk-move`, `/api/files/bulk-copy`, `/api/files/move-job`, `/api/files/copy-job`, `/api/files/job-status`

**Temp Shares** — `/api/temp-shares/create`, `/api/temp-shares/list`, `/api/temp-shares/delete`, `/api/temp-shares/edit`, `/api/temp-shares/info`, `/api/temp-shares/access` (unauthenticated — serves file to anyone with the token)

**Users** — `/api/users/list`, `/api/users/add`, `/api/users/delete`, `/api/users/edit`, `/api/users/profile`

### Background Jobs

`lib/jobs.ts` implements an in-memory job store for long-running bulk copy/move operations. Jobs are polled via `/api/files/job-status`.

### Key Features

- **Gallery view** with `@tanstack/react-virtual` virtualised row rendering (`components/GalleryView.tsx`, `components/Lightbox.tsx`)
- **ZIP downloads** via `archiver`
- **Image processing** via `sharp` (thumbnails, EXIF-aware rotation)
- **Streaming uploads** via `busboy` (no body-parser size limit)
- **Temp share links** — time-limited unauthenticated access via token; accessible at `/share?token=...` (UI) or `/api/temp-shares/access?token=...` (direct download)
- **Path-traversal protection** on all file ops via `resolveSafePath()`

## Components

Flat `components/` directory (no sub-folders):

- `FileBrowser.tsx` — main list/gallery toggle, drag-drop upload, selection, modals
- `GalleryView.tsx` — virtualised image grid using `useWindowVirtualizer`
- `Lightbox.tsx` — full-screen image preview with keyboard navigation and preloading
- `FileItem.tsx` — single row in list view
- `Navigation.tsx` — top nav bar with hamburger for mobile
- `UploadManager.tsx` — upload queue context, XHR-based parallel uploads with progress
- `SelectionBar.tsx` — bulk action toolbar (delete, move, copy, zip)
- `FolderTreePicker.tsx` — folder picker modal for move/copy targets
- `DeleteModal.tsx` — confirmation modal
- `CircuitBackground.tsx` — animated SVG cyberpunk background (used on auth pages)

## Design System

Cyberpunk dark theme (`app/globals.css`). Key CSS variables:

```
--bg: #0a0a12 (page background)
--primary: #7c0eb3 (purple accent)
--accent-cyan: #22d3ee
--accent-magenta: #f472b6
--font-mono: 'JetBrains Mono', monospace
```

JetBrains Mono applies **only** to: nav labels, badges, stat values, form labels, code/monospace contexts — never body text. Body uses system fonts.

Auth/modal cards use `overflow: hidden` with bracket pseudo-elements (cyan top-left, magenta bottom-right). No logo image — text-only `SkyBit` branding.

Mobile breakpoints: 900px (nav collapses to hamburger), 640px (modals become bottom sheets, `font-size: 16px` on inputs to prevent iOS zoom).

## Docker

Base image: `node:20-alpine`. Two-stage build (builder + runner). Production user: `nextjs` (uid 1001, non-root).

Volumes:
- `/data` — user file storage (`BASE_DIRECTORY`)
- `/app/config` — `data.json` + `.session_secret` file

Entrypoint (`docker-entrypoint.sh`) auto-generates a random session secret on first run and writes it to `/app/config/.session_secret`. If `SESSION_SECRET` is already set in the environment, the file is not used. Port 7070.

Security headers set in `next.config.ts`: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`, and a strict `Content-Security-Policy`.
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,7 @@ ENV HOSTNAME=0.0.0.0
ENV BASE_DIRECTORY=/data
ENV SITE_NAME=SkyBit

HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||'7070')+'/').then(r=>process.exit(r.status<500?0:1)).catch(()=>process.exit(1))"

CMD ["/docker-entrypoint.sh"]
5 changes: 3 additions & 2 deletions app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import DeleteModal from '@/components/DeleteModal';

interface User { id: number; username: string; role: string; created_at: string; }
Expand Down Expand Up @@ -75,8 +76,8 @@ export default function AdminPage() {
<div className="page-header">
<h1 className="page-title">User Management</h1>
<div style={{ display: 'flex', gap: 8 }}>
<a href="/" className="btn btn-ghost btn-sm">← Files</a>
<a href="/admin/temp-shares" className="btn btn-secondary btn-sm">Temp Shares</a>
<Link href="/" className="btn btn-ghost btn-sm">← Files</Link>
<Link href="/admin/temp-shares" className="btn btn-secondary btn-sm">Temp Shares</Link>
<button className="btn btn-primary btn-sm" onClick={() => setShowAdd(!showAdd)}>
+ Add User
</button>
Expand Down
3 changes: 2 additions & 1 deletion app/admin/temp-shares/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';

interface Share { token: string; filepath: string; expires_at: string; created_at: string; }

Expand Down Expand Up @@ -50,7 +51,7 @@ export default function TempSharesPage() {
<div className="page-root">
<div className="page-header">
<h1 className="page-title">Temp Share Links</h1>
<a href="/admin" className="btn btn-secondary btn-sm">← Users</a>
<Link href="/admin" className="btn btn-secondary btn-sm">← Users</Link>
</div>

{shares.length === 0 ? (
Expand Down
7 changes: 4 additions & 3 deletions app/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';

export default function ProfilePage() {
const router = useRouter();
Expand Down Expand Up @@ -54,7 +55,7 @@ export default function ProfilePage() {
<p className="auth-subtitle">
<span style={{
color: user.role === 'admin' ? 'var(--primary-light)' : user.role === 'mod' ? 'var(--accent-cyan)' : 'var(--text-muted)',
}}>// {user.role}</span>
}}>{'// '}{user.role}</span>
</p>

{error && <div className="flash flash-error">{error}</div>}
Expand Down Expand Up @@ -83,10 +84,10 @@ export default function ProfilePage() {

<div style={{ borderTop: '1px solid var(--border)', marginTop: 20, paddingTop: 16, display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
{user.role === 'admin' && (
<a href="/admin" className="btn btn-secondary btn-sm">User Management</a>
<Link href="/admin" className="btn btn-secondary btn-sm">User Management</Link>
)}
{user.role === 'admin' && (
<a href="/admin/temp-shares" className="btn btn-secondary btn-sm">Temp Shares</a>
<Link href="/admin/temp-shares" className="btn btn-secondary btn-sm">Temp Shares</Link>
)}
<button className="btn btn-danger btn-sm" onClick={handleLogout}>Sign Out</button>
</div>
Expand Down
2 changes: 1 addition & 1 deletion app/share/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function ShareContent() {
<div className="auth-page">
<div className="auth-card">
<div className="auth-logo" style={{ fontSize: '1.4rem' }}>SkyBit</div>
<p className="auth-subtitle">// shared file</p>
<p className="auth-subtitle">{'// shared file'}</p>

{error ? (
<p style={{ color: 'var(--danger)', fontSize: '0.88rem', textAlign: 'center', marginBottom: 24 }}>
Expand Down
15 changes: 15 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
services:
skybit:
image: rayderc/skybit:latest
container_name: skybit
restart: always
ports:
- 7070:7070
environment:
# Generate a strong secret: openssl rand -hex 32
- SESSION_SECRET=${SESSION_SECRET:-change_me_to_a_random_64_char_string}
- BASE_DIRECTORY=/data
- SITE_NAME=SkyBit
volumes:
- ./data:/data
- ./config:/app/config
14 changes: 13 additions & 1 deletion docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,25 @@ set -e
SECRET_FILE=/app/config/.session_secret
mkdir -p /app/config /data

if [ -z "${SECRET_KEY:-}" ]; then
# Honor SESSION_SECRET (standard name); fall back to SECRET_KEY (legacy).
# If neither is set, auto-generate and persist in the secret file.
if [ -n "${SESSION_SECRET:-}" ]; then
# Already set — export as both names so lib/session.ts finds it either way
export SESSION_SECRET
export SECRET_KEY="${SESSION_SECRET}"
elif [ -n "${SECRET_KEY:-}" ]; then
# Legacy name provided — propagate as the standard name too
export SECRET_KEY
export SESSION_SECRET="${SECRET_KEY}"
else
# Neither set — use or generate the persisted secret file
if [ ! -s "$SECRET_FILE" ]; then
node -e 'process.stdout.write(require("crypto").randomBytes(48).toString("base64"))' > "$SECRET_FILE"
chmod 600 "$SECRET_FILE" 2>/dev/null || true
fi
SECRET_KEY="$(cat "$SECRET_FILE")"
export SECRET_KEY
export SESSION_SECRET="${SECRET_KEY}"
fi

# Fix permissions on mounted volumes, then drop to nextjs user
Expand Down
25 changes: 25 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";

const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
globalIgnores([
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
{
rules: {
// react-hooks v7 introduced strict new rules that flag valid patterns
// in this codebase. Downgrade to warnings to keep the build green.
"react-hooks/set-state-in-effect": "warn",
"react-hooks/refs": "warn",
"react-hooks/immutability": "warn",
},
},
]);

export default eslintConfig;
3 changes: 2 additions & 1 deletion lib/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ export interface SessionData {
const isBuild = process.env.NEXT_PHASE === 'phase-production-build';

const sessionPassword =
process.env.SESSION_SECRET ||
process.env.SECRET_KEY ||
(isBuild || process.env.NODE_ENV !== 'production'
? 'build_time_placeholder_secret_at_least_32_chars'
: '');

if (!sessionPassword || sessionPassword.length < 32) {
throw new Error(
'SECRET_KEY environment variable must be set to a string of at least 32 characters'
'SESSION_SECRET (or SECRET_KEY) environment variable must be set to a string of at least 32 characters'
);
}

Expand Down
1 change: 1 addition & 0 deletions next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
import "./.next/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
Loading
Loading