init
Some checks failed
Build Website / Build NPM Project (push) Failing after 44s
Build Website / Tag Version (push) Has been skipped
Build Website / Build & Push Dockerfile (push) Has been skipped

This commit is contained in:
2025-09-27 00:40:24 -04:00
commit f08a4a0808
20 changed files with 1820 additions and 0 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.idea
.vscode
.git
.gitignore
.dockerignore
DOckerfile
docker-compose.yml
docker-compose.yaml
node_modules
archive

55
.github/workflows/build.yaml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: Build Website
run-name: Build Website
on:
push:
jobs:
build:
name: Build NPM Project
runs-on: ubuntu-latest
container: node
steps:
- name: Clone Repository
uses: ztimson/actions/clone@develop
- name: Install Dependencies
run: npm i
- name: Build Project
run: npm run build
tag:
name: Tag Version
needs: build
runs-on: ubuntu-latest
container: node
steps:
- name: Clone Repository
uses: ztimson/actions/clone@develop
- name: Get Version Number
run: echo "VERSION=$(cat package.json | grep version | grep -Eo ':.+' | grep -Eo '[[:alnum:]\.\/\-]+')" >> $GITHUB_ENV
- name: Tag Version
uses: ztimson/actions/tag@develop
with:
tag: ${{env.VERSION}}
publish:
name: Build & Push Dockerfile
needs: build
uses: ztimson/actions/.github/workflows/docker.yaml@develop
with:
name: ztimson/kiwixm
repository: ${{github.server_url}}/${{github.repository}}.git
pass: ${{secrets.DEPLOY_TOKEN}}
publish-docker:
name: Build & Push Dockerfile
needs: build
uses: ztimson/actions/.github/workflows/docker.yaml@develop
with:
name: ztimson/kiwixm
repository: hub.docker.com
pass: ${{secrets.DOCKER_TOKEN}}

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.idea
.vscode
node_modules
dist

24
Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM public.ecr.aws/docker/library/node
# Environment
ENV PORT=3000 \
KIWIX_MANAGE=/usr/bin/kiwix-manage \
KIWIX_SERVE=/usr/bin/kiwix-serve \
ARCHIVE_DIR=/data/archive \
ZIM_DIR=/data/zim
# Install dependencies
RUN apt update && apt upgrade -y && \
apt install -y --no-install-recommends \
curl docker.io kiwix-tools unzip zim-tools && \
rm -rf /var/lib/apt/lists/*
# Setup app
WORKDIR /app
COPY . .
RUN mkdir -p $ARCHIVE_DIR && mkdir -p $ZIM_DIR && npm i
# Startup
EXPOSE $PORT
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD curl -f http://localhost:$PORT || exit 1
CMD ["npm", "run", "start:prod"]

104
README.md Normal file
View File

@@ -0,0 +1,104 @@
<!-- Header -->
<div id="top" align="center">
<br />
<!-- Logo -->
<img src="favicon.png" alt="Logo" width="200" height="200">
<!-- Title -->
### Kiwix Manager
<!-- Description -->
A web-based Kiwix management tool to create & upload zims as well as manage non zim files
<!-- Repo badges -->
[![Version](https://img.shields.io/badge/dynamic/json.svg?label=Version&style=for-the-badge&url=https://git.zakscode.com/api/v1/repos/ztimson/kiwixm/tags&query=$[0].name)](https://git.zakscode.com/ztimson/kiwixm/tags)
[![Pull Requests](https://img.shields.io/badge/dynamic/json.svg?label=Pull%20Requests&style=for-the-badge&url=https://git.zakscode.com/api/v1/repos/ztimson/kiwixm&query=open_pr_counter)](https://git.zakscode.com/ztimson/kiwixm/pulls)
[![Issues](https://img.shields.io/badge/dynamic/json.svg?label=Issues&style=for-the-badge&url=https://git.zakscode.com/api/v1/repos/ztimson/kiwixm&query=open_issues_count)](https://git.zakscode.com/ztimson/kiwixm/issues)
<!-- Links -->
---
<div>
<a href="https://git.zakscode.com/ztimson/kiwixm/wiki" target="_blank">Documentation</a>
• <a href="https://git.zakscode.com/ztimson/kiwixm/releases" target="_blank">Release Notes</a>
• <a href="https://git.zakscode.com/ztimson/kiwixm/issues/new?template=.github%2fissue_template%2fbug.md" target="_blank">Report a Bug</a>
• <a href="https://git.zakscode.com/ztimson/kiwixm/issues/new?template=.github%2fissue_template%2fenhancement.md" target="_blank">Request a Feature</a>
</div>
---
</div>
## Table of Contents
- [Kiwix Manager](#top)
- [About](#about)
- [Features](#features)
- [Built With](#built-with)
- [Setup](#setup)
- [Production](#production)
- [Development](#development)
- [License](#license)
## About
Kiwix Manager is a comprehensive web-based management interface for Kiwix servers that extends beyond traditional ZIM file management. It provides both a powerful Kiwix library management system and an integrated file browser for non-ZIM content, making it a complete solution for offline content libraries.
### Features
- **Non-ZIM library browser + uploader integrated into Kiwix** - Browse and upload any file type through a custom file browser that works alongside your Kiwix content
- **Kiwix management interface for updating library content** - Full administrative control over your Kiwix server and ZIM library
- **Upload ZIMs from computer or download them directly from Kiwix library** - Flexible ZIM file management with local uploads and remote downloads
- **Create ZIMs from URLs (zimmit)** - Convert websites directly to ZIM files with customizable scraping options
- **Create ZIMs from zips / files** - Generate ZIM archives from your existing file collections
### Built With
[![Docker](https://img.shields.io/badge/Docker-384d54?style=for-the-badge&logo=docker)](https://docker.com/)
[![Express](https://img.shields.io/badge/Express-000000?style=for-the-badge&logo=express)](https://expressjs.com/)
[![JavaScript](https://img.shields.io/badge/JavaScript-000000?style=for-the-badge&logo=javascript)](https://javascript.com/)
[![Node](https://img.shields.io/badge/Node.js-000000?style=for-the-badge&logo=nodedotjs)](https://nodejs.org/)
[![TailwindCSS](https://img.shields.io/badge/Tailwind_CSS-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white)](https://tailwindcss.com/)
## Setup
<details>
<summary>
<h3 id="production" style="display: inline">
Production
</h3>
</summary>
#### Prerequisites
- [Docker](https://docs.docker.com/install/)
#### Instructions
1. Run the docker image: `docker run -p 3000:3000 -v /path/to/zim/files:/data/zim -v /path/to/archive/files:/data/archive git.zakscode.com/ztimson/kiwixm:latest`
2. Open [http://localhost:3000](http://localhost:3000)
3. Access the admin panel at [http://localhost:3000/admin](http://localhost:3000/admin)
4. Browse non-ZIM files at [http://localhost:3000/archive](http://localhost:3000/archive)
</details>
<details>
<summary>
<h3 id="development" style="display: inline">
Development
</h3>
</summary>
#### Prerequisites
- [Node.js](https://nodejs.org/en/download)
- [Kiwix Tools](https://kiwix.org/en/downloads/kiwix-tools/) (for ZIM file management)
#### Instructions
1. Clone the repository: `git clone https://git.zakscode.com/ztimson/kiwixm.git`
2. Navigate to the project directory: `cd kiwixm`
3. Install the dependencies: `npm install`
4. Create the required directories: `mkdir -p data/zim data/archive`
5. Start the server: `npm run start`
6. Open [http://localhost:3000](http://localhost:3000)
</details>
## License
Copyright © 2023 Zakary Timson | All Rights Reserved | Available under MIT Licensing
See the [license](./LICENSE) for more information.

13
archive/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<title>Kiwix Archive</title>
<meta charset="UTF-8" />
</head>
<body>
<a href="/archive"><h1>Kiwix Archive</h1></a>
<script>
location.href = '/archive';
</script>
</body>
</html>

BIN
archive/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
assets/archive.zim Normal file

Binary file not shown.

BIN
favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "kiwixm",
"version": "1.0.0",
"description": "Kiwix Library Manager",
"main": "src/main.mjs",
"type": "module",
"scripts": {
"start": "node src/main.mjs",
"start:prod": "mv assets/* $ZIM_DIR/ && npm run start",
"archive-zim": "zimwriterfs --welcome=index.html --illustration=logo.png --language=eng --name=Archive --title=Archive --description=\"Your personal space for saving and browsing uploaded files\" --creator=ZaksCode --publisher=ZaksCode ./archive assets/archive.zim"
},
"keywords": [],
"author": "Zak Timson",
"license": "MIT",
"dependencies": {
"dotenv": "^17.2.2",
"express": "^5.1.0",
"multer": "^2.0.2"
}
}

139
src/controllers/admin.mjs Normal file
View File

@@ -0,0 +1,139 @@
import express from 'express';
import path from 'path';
import fs from 'fs/promises';
import {deleteFile, downloadFile, ensureDirectoryExists, getZimFiles, upload} from '../services/files.mjs';
import {restartKiwix} from '../services/kiwix.mjs';
import {environment} from '../services/environment.mjs';
import {createZimFromDirectory, createZimFromUrl} from '../services/zim.mjs';
import {promisify} from 'util';
import {exec} from 'child_process';
const execAsync = promisify(exec);
const router = express.Router();
export default function adminController(config) {
router.get('/download', (req, res) => {
const filePath = req.query.path;
if (!filePath || !filePath.endsWith('.zim')) return res.status(400).send('Invalid ZIM file path');
res.download(filePath, (err) => {
if(err) {
console.error('Download error:', err.message);
res.status(500).send('Download failed');
}
});
});
// Download endpoint
router.post('/download', async (req, res, next) => {
try {
const { url, filename, restartKiwix: shouldRestart = true } = req.body;
if (!url) return res.status(400).json({ error: 'URL required' });
await ensureDirectoryExists(config.paths.zim);
const finalFilename = filename || path.basename(new URL(url).pathname) || 'download.zim';
const destination = path.join(config.paths.zim, finalFilename);
await downloadFile(url, destination);
if (shouldRestart) await restartKiwix();
res.json({ message: 'Downloaded successfully', path: destination });
} catch (err) {
next(err);
}
});
// Upload endpoint
router.post('/upload', upload(environment.paths.zim, true).single('file'), async (req, res, next) => {
try {
if (!req.file) return res.status(400).json({ message: 'No file uploaded' });
await restartKiwix();
res.json({ message: 'File uploaded successfully', files: [req.file.filename] });
} catch (err) {
next(err);
}
});
router.post('/zim-zip', upload('/tmp/').single('zipFile'), async (req, res, next) => {
if (!req.file) return res.status(400).json({ error: 'No ZIP file uploaded.' });
const zipPath = req.file.path;
const tempDir = path.join('/tmp/', `unzipped-${Date.now()}`);
try {
await ensureDirectoryExists(tempDir);
await execAsync(`unzip -o "${zipPath}" -d "${tempDir}"`);
const { name, welcome, language, description, tags, favicon, creator } = req.body;
if(!name) throw new Error('ZIM name is are required.');
const entries = await fs.readdir(tempDir, { withFileTypes: true });
let contentPath = tempDir;
const directories = entries.filter(e => e.isDirectory());
if (directories.length === 1 && entries.length === 1) {
contentPath = path.join(tempDir, directories[0].name);
}
await createZimFromDirectory({ name, welcome, language, description, tags, favicon, creator }, contentPath);
await restartKiwix();
res.json({ message: 'ZIM from ZIP created successfully' });
} catch (err) {
next(err);
} finally {
if (tempDir) await fs.rm(tempDir, { recursive: true, force: true }).catch(e => console.error(`Failed to cleanup temp dir: ${e.message}`));
if (zipPath) await fs.unlink(zipPath).catch(e => console.error(`Failed to cleanup zip file: ${e.message}`));
}
});
router.post('/zim-url', async (req, res, next) => {
try {
const { url, name, language, description, tags, regex } = req.body;
if (!url || !name) {
return res.status(400).json({ error: 'URL and ZIM Name are required.' });
}
const zimFile = await createZimFromUrl({ url, name, language, description, tags, regex });
await restartKiwix();
res.json({ message: 'ZIM created successfully from URL.', file: zimFile });
} catch (err) {
next(err);
}
});
// Delete file endpoint
router.post('/delete', async (req, res, next) => {
try {
await deleteFile(req.body.path);
await restartKiwix();
res.json({ message: 'File deleted + Kiwix restarted' });
} catch (err) {
next(err);
}
});
// Restart endpoint
router.get('/restart', async (req, res, next) => {
try {
const ok = await restartKiwix();
ok ? res.json({ message: 'Restarted' }) : res.status(500).json({ error: 'Failed restart' });
} catch (err) {
next(err);
}
});
// Info
router.get('/info', (req, res) => res.json(config));
// Health
router.get('/health', async (req, res, next) => {
try {
const zimFiles = await getZimFiles(config.paths.zim);
res.json({ status: 'healthy', zimFileCount: zimFiles.length });
} catch (err) {
res.status(500).json({ status: 'unhealthy', error: err.message });
}
});
return router;
}

167
src/controllers/archive.mjs Normal file
View File

@@ -0,0 +1,167 @@
import express from 'express';
import path from 'path';
import fs from 'fs/promises';
import {ensureDirectoryExists, upload} from '../services/files.mjs';
import {environment} from '../services/environment.mjs';
const router = express.Router();
export default function archiveController() {
const archiveRoot = environment.paths.archive;
// Security helper to validate paths
function validatePath(requestedPath) {
const normalizedArchiveRoot = path.resolve(archiveRoot);
const normalizedPath = path.resolve(path.join(archiveRoot, requestedPath));
if (!normalizedPath.startsWith(normalizedArchiveRoot)) {
throw new Error('Access denied: Path outside archive directory');
}
return normalizedPath;
}
// Create folder endpoint
router.post('/create-folder', async (req, res, next) => {
try {
const { path: requestedPath, name } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Folder name is required' });
}
const parentPath = validatePath(requestedPath || '');
const newFolderPath = path.join(parentPath, name.trim());
// Check if folder already exists
try {
await fs.access(newFolderPath);
return res.status(409).json({ error: 'Folder already exists' });
} catch {
// Folder doesn't exist, which is what we want
}
await fs.mkdir(newFolderPath, { recursive: true });
res.json({ message: 'Folder created successfully', path: newFolderPath });
} catch (err) {
next(err);
}
});
// Upload files endpoint
router.post('/upload', upload('/tmp/', false).array('files'), async (req, res, next) => {
try {
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'No files uploaded' });
}
const uploadPath = req.body.uploadPath || '';
const targetDir = validatePath(uploadPath);
// Ensure target directory exists
await ensureDirectoryExists(targetDir);
const uploadedFiles = [];
for (const file of req.files) {
const targetPath = path.join(targetDir, file.originalname);
// Use copyFile + unlink instead of rename for cross-device moves
try {
await fs.copyFile(file.path, targetPath);
await fs.unlink(file.path);
uploadedFiles.push(file.originalname);
} catch (err) {
// If copy fails, clean up the temp file
try {
await fs.unlink(file.path);
} catch {
// Ignore cleanup errors
}
throw err;
}
}
res.json({
message: `${uploadedFiles.length} file(s) uploaded successfully`,
files: uploadedFiles
});
} catch (err) {
// Clean up any uploaded files if there's an error
if (req.files) {
for (const file of req.files) {
try {
await fs.unlink(file.path);
} catch {
// Ignore cleanup errors
}
}
}
next(err);
}
});
// Rename file/folder endpoint
router.post('/rename', async (req, res, next) => {
try {
const { path: requestedPath, oldName, newName } = req.body;
if (!oldName || !newName || !newName.trim()) {
return res.status(400).json({ error: 'Old name and new name are required' });
}
const parentPath = validatePath(requestedPath || '');
const oldPath = path.join(parentPath, oldName);
const newPath = path.join(parentPath, newName.trim());
// Check if old path exists
try {
await fs.access(oldPath);
} catch {
return res.status(404).json({ error: 'File or folder not found' });
}
// Check if new path already exists
try {
await fs.access(newPath);
return res.status(409).json({ error: 'A file or folder with that name already exists' });
} catch {
// New path doesn't exist, which is what we want
}
await fs.rename(oldPath, newPath);
res.json({ message: 'Renamed successfully' });
} catch (err) {
next(err);
}
});
// Delete file/folder endpoint
router.post('/delete', async (req, res, next) => {
try {
const { path: requestedPath, name, isDirectory } = req.body;
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
const parentPath = validatePath(requestedPath || '');
const targetPath = path.join(parentPath, name);
// Check if target exists
try {
await fs.access(targetPath);
} catch {
return res.status(404).json({ error: 'File or folder not found' });
}
if (isDirectory === true || isDirectory === 'true') {
await fs.rm(targetPath, { recursive: true, force: true });
res.json({ message: 'Folder deleted successfully' });
} else {
await fs.unlink(targetPath);
res.json({ message: 'File deleted successfully' });
}
} catch (err) {
next(err);
}
});
return router;
}

108
src/controllers/views.mjs Normal file
View File

@@ -0,0 +1,108 @@
import { renderAdminPanel } from '../views/admin.mjs';
import {getDirectoryContents, getZimFiles} from '../services/files.mjs';
import { environment } from '../services/environment.mjs';
import path from 'path';
import fs from 'fs/promises';
import {renderArchive} from '../views/archive.mjs';
export const viewController = {
adminPanel: async (req, res, next) => {
try {
const zimFiles = await getZimFiles(environment.paths.zim);
res.send(renderAdminPanel(zimFiles));
} catch (err) {
next(err);
}
},
fileBrowser: async (req, res, next) => {
try {
const requestedPath = req.query.path || '';
const currentPath = path.join(environment.paths.archive, requestedPath);
const normalizedArchiveRoot = path.resolve(environment.paths.archive);
const normalizedCurrentPath = path.resolve(currentPath);
if(!normalizedCurrentPath.startsWith(normalizedArchiveRoot))
return res.status(403).send('Forbidden');
const files = await getDirectoryContents(currentPath);
res.send(renderArchive(currentPath, files, environment.paths.archive));
} catch (err) {
next(err);
}
},
fileViewer: async (req, res, next) => {
try {
const filePath = req.query.file;
if (!filePath) {
return res.status(400).send('File path required');
}
const archiveRoot = environment.paths.archive;
const normalizedArchiveRoot = path.resolve(archiveRoot);
const normalizedFilePath = path.resolve(filePath);
// Security check
if (!normalizedFilePath.startsWith(normalizedArchiveRoot)) {
return res.status(403).send('Access denied: File outside archive directory');
}
// Check if file exists
try {
await fs.access(filePath);
} catch {
return res.status(404).send('File not found');
}
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
// Redirect to directory browser
const relativePath = path.relative(archiveRoot, filePath);
return res.redirect(`/files?path=${encodeURIComponent(relativePath)}`);
}
// Determine how to serve the file based on its extension
const ext = path.extname(filePath).toLowerCase();
// For PDFs, images, and other web-viewable files, serve them directly
if (['.pdf', '.jpg', '.jpeg', '.png', '.gif', '.txt', '.html', '.css', '.js'].includes(ext)) {
res.sendFile(normalizedFilePath);
} else {
// For other files, trigger download
res.download(normalizedFilePath);
}
} catch (err) {
next(err);
}
},
fileDownload: async (req, res, next) => {
try {
const filePath = req.query.file;
if (!filePath) {
return res.status(400).send('File path required');
}
const archiveRoot = environment.paths.archive;
const normalizedArchiveRoot = path.resolve(archiveRoot);
const normalizedFilePath = path.resolve(filePath);
// Security check
if (!normalizedFilePath.startsWith(normalizedArchiveRoot)) {
return res.status(403).send('Access denied: File outside archive directory');
}
// Check if file exists
try {
await fs.access(filePath);
} catch {
return res.status(404).send('File not found');
}
res.download(normalizedFilePath);
} catch (err) {
next(err);
}
}
};

65
src/main.mjs Normal file
View File

@@ -0,0 +1,65 @@
import express from 'express';
import adminController from './controllers/admin.mjs';
import {environment} from './services/environment.mjs';
import {ensureDirectoryExists} from './services/files.mjs';
import path from 'path';
import {startKiwix} from './services/kiwix.mjs';
import http from 'http';
import archiveController from './controllers/archive.mjs';
import {viewController} from './controllers/views.mjs';
const app = express();
// Middleware
app.use(express.json());
app.use(express.urlencoded({extended: true}));
app.use(express.static('public'));
// Routes
app.get('/admin', viewController.adminPanel);
app.use('/api', adminController(environment));
app.use('/api/files', archiveController());
app.get('/archive', viewController.fileBrowser);
app.get('/archive/view', viewController.fileViewer);
app.get('/archive/download', viewController.fileDownload);
app.get('/favicon.:ext', (req, res) => res.sendFile(path.join(environment.root, "favicon.png")));
// Kiwix proxy
app.use((req, res) => {
const proxyReq = http.request({
hostname: '127.0.0.1',
port: environment.kiwix.port,
path: req.url,
method: req.method,
headers: {
...req.headers,
host: `127.0.0.1:${environment.kiwix.port}`,
},
}, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res, { end: true });
});
proxyReq.on('error', (err) => {
console.error('Proxy error:', err);
if(!res.headersSent) res.status(502).send('Bad Gateway');
});
req.pipe(proxyReq, { end: true });
});
// Error handler
app.use((err, req, res, next) => {
console.error('Unhandled error:', err);
res.status(500).json({ error: err.message || 'Internal server error' });
});
// Start server
app.listen(environment.port, async () => {
console.log(`\n🥝 Kiwix Manager started on http://localhost:${environment.port}\n`);
await ensureDirectoryExists(environment.paths.archive);
await ensureDirectoryExists(environment.paths.zim);
await startKiwix();
});
export default app;

View File

@@ -0,0 +1,19 @@
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
dotenv.config();
export const environment = {
paths: {
archive: process.env.ARCHIVE_DIR || '/data/archive',
zim: process.env.ZIM_DIR || '/data/zim',
},
port: process.env.PORT || 3001,
root: dirname(dirname(dirname(fileURLToPath(import.meta.url)))),
kiwix: {
manage: process.env.KIWIX_MANAGE || '/usr/bin/kiwix-manage',
serve: process.env.KIWIX_SERVE || '/usr/bin/kiwix-serve',
port: process.env.KIWIX_PORT || 8000,
},
}

133
src/services/files.mjs Normal file
View File

@@ -0,0 +1,133 @@
import path from 'path';
import { createWriteStream } from 'fs';
import { pipeline } from 'stream';
import { promisify } from 'util';
import multer from 'multer';
import { promises as fs } from 'fs';
export async function getDirectoryContents(dirPath) {
try {
await fs.access(dirPath);
} catch {
// Create directory if it doesn't exist
await fs.mkdir(dirPath, { recursive: true });
}
const items = await fs.readdir(dirPath, { withFileTypes: true });
const files = [];
console.log(dirPath, items);
for (const item of items) {
const itemPath = path.join(dirPath, item.name);
const stats = await fs.stat(itemPath);
files.push({
name: item.name,
path: itemPath,
isDirectory: item.isDirectory(),
size: item.isDirectory() ? 0 : stats.size,
modified: stats.mtime.toISOString()
});
}
// Sort: directories first, then files, both alphabetically
return files.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
});
}
export function upload(path, zimOnly = false) {
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadPath = req.body.uploadPath || path;
try {
await fs.mkdir(uploadPath, { recursive: true });
cb(null, uploadPath);
} catch (err) {
cb(err);
}
},
filename: (req, file, cb) => {
let finalFilename = file.originalname;
if(req.body.filename && req.body.filename.trim() !== '') {
finalFilename = req.body.filename.trim();
if(!finalFilename.toLowerCase().endsWith('.zim')) {
finalFilename += '.zim';
}
}
cb(null, finalFilename);
}
});
return multer({
storage,
fileFilter: (req, file, cb) => {
if(!zimOnly) cb(null, true);
else if(/\.zim$/i.test(file.originalname)) cb(null, true);
else cb(new Error('Only ZIM files allowed'));
}
});
}
const pipelineAsync = promisify(pipeline);
export async function downloadFile(url, destination) {
if(!url.endsWith('.zim')) throw new Error('Only ZIM files allowed');
if(!destination.endsWith('.zim')) destination += '.zim';
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! ${response.status}`);
const writer = createWriteStream(destination);
await pipelineAsync(response.body, writer);
return destination;
}
export function parseSize(sizeStr) {
if (sizeStr.toLowerCase() === 'infinity') return Infinity;
const units = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
const match = sizeStr.match(/^(\d+)(B|KB|MB|GB)$/i);
if (!match) return 100 * 1024 * 1024; // default 100MB
return parseInt(match[1]) * units[match[2].toUpperCase()];
}
export async function ensureDirectoryExists(dirPath) {
try {
await fs.mkdir(dirPath, { recursive: true });
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
}
export function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
export async function getZimFiles(archiveDir) {
await ensureDirectoryExists(archiveDir);
const items = await fs.readdir(archiveDir, { withFileTypes: true });
const zimFiles = [];
for (const item of items) {
if (item.isFile() && path.extname(item.name).toLowerCase() === '.zim') {
const fullPath = path.join(archiveDir, item.name);
const stats = await fs.stat(fullPath);
zimFiles.push({
name: item.name,
path: fullPath,
size: stats.size,
modified: stats.mtime.toISOString()
});
}
}
return zimFiles.sort((a, b) => new Date(b.modified) - new Date(a.modified));
}
export async function deleteFile(filePath) {
await fs.unlink(filePath);
}

96
src/services/kiwix.mjs Normal file
View File

@@ -0,0 +1,96 @@
import {exec, spawn} from 'child_process';
import { promisify } from 'util';
import { environment } from './environment.mjs';
import { getZimFiles } from './files.mjs';
import { resolve } from 'path';
import fs from 'fs';
const execAsync = promisify(exec);
let kiwixProcess = null;
export async function startKiwix() {
if(kiwixProcess) return;
await synchronizeLibrary();
const libraryPath = resolve(environment.paths.zim, 'library.xml');
return new Promise((resolvePromise, rejectPromise) => {
kiwixProcess = spawn(environment.kiwix.serve, [`--port=${environment.kiwix.port}`, `--library`, libraryPath]);
kiwixProcess.stdout.on('data', (data) => console.log(data.toString().trim()));
kiwixProcess.stderr.on('data', (data) => console.error(`Kiwix Error: ${data.toString().trim()}`));
kiwixProcess.on('error', (err) => {
console.error('Failed to start Kiwix process.', err);
kiwixProcess = null;
rejectPromise(err);
});
kiwixProcess.on('close', (code) => {
console.log(`Kiwix Serve exited with code ${code}`);
kiwixProcess = null;
});
setTimeout(() => resolvePromise(), 2000);
});
}
export function stopKiwix() {
if (kiwixProcess) {
kiwixProcess.kill('SIGTERM');
console.log('Kiwix server stopped.');
kiwixProcess = null;
return true;
}
console.log('Kiwix server is not running.');
return false;
}
export async function restartKiwix() {
try {
if(kiwixProcess) {
stopKiwix();
await new Promise(resolve => setTimeout(resolve, 1000));
}
await startKiwix();
console.log('Kiwix service restarted');
return true;
} catch (err) {
console.error('Error restarting Kiwix:', err.message);
return false;
}
}
export async function synchronizeLibrary() {
const libraryPath = resolve(environment.paths.zim, 'library.xml');
try {
const { stdout: libraryOutput } = await execAsync(`${environment.kiwix.manage} ${libraryPath} show || echo`);
const libraryBooks = new Set((libraryOutput.match(/ID: \w+, Path: (.*?),/g) || []).map(line => line.substring(line.indexOf('Path: ') + 6, line.length - 1)));
const zimFiles = await getZimFiles(environment.paths.zim);
const directoryFiles = new Set(zimFiles.map(file => file.path));
const filesToAdd = [...directoryFiles].filter(file => file.endsWith('.zim') && !libraryBooks.has(file));
const filesToRemove = [...libraryBooks].filter(file => !directoryFiles.has(file));
for (const file of filesToAdd) {
console.log(`Adding file to library: ${file}`);
await execAsync(`${environment.kiwix.manage} ${libraryPath} add ${file}`);
}
for (const file of filesToRemove) {
console.log(`Removing file from library: ${file}`);
const { stdout } = await execAsync(`${environment.kiwix.manage} ${libraryPath} show`);
const match = stdout.match(new RegExp(`ID: (\\w+), Path: ${file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')},`));
if (match && match[1]) {
const bookId = match[1];
await execAsync(`${environment.kiwix.manage} ${libraryPath} remove ${bookId}`);
}
}
} catch (error) {
if (error.stderr && (error.stderr.includes('No such file or directory') || error.stderr.includes('cannot open library'))) {
console.log('Library file not found or is invalid, creating a new one.');
const zimFiles = await getZimFiles(environment.paths.zim);
for (const file of zimFiles.map(f => f.path)) {
await execAsync(`${environment.kiwix.manage} ${libraryPath} add ${file}`);
}
} else {
console.error('Error synchronizing library:', error);
throw error;
}
}
}

81
src/services/zim.mjs Normal file
View File

@@ -0,0 +1,81 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import { environment } from './environment.mjs';
import path from 'path';
import { promises as fs } from 'fs';
const execAsync = promisify(exec, { timeout: 3600000 });
function escapeDockerArg(arg) {
if (arg === null || arg === undefined || arg === '') return '""';
return `"${String(arg).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
function escapeShellArg(arg) {
if (arg === null || arg === undefined || arg === '') return "''";
return `'${String(arg).replace(/'/g, "'\\''")}'`;
}
export async function createZimFromDirectory(options, contentPath) {
let { name, welcome, language, description, tags, favicon, creator } = options;
const zimFile = name.endsWith('.zim') ? name : `${name}.zim`;
const zimFilePath = path.join(environment.paths.zim, zimFile);
if(!favicon) {
fs.copyFile('/app/favicon.png', path.join(contentPath, 'favicon.png'));
favicon = 'favicon.png';
}
let command = `zimwriterfs -w ${escapeShellArg(welcome || 'index.html')} -I ${escapeShellArg(favicon)} -l ${escapeShellArg(language || 'eng')} -t ${escapeShellArg(name)} -n ${escapeShellArg(name)} -d ${escapeShellArg(description || 'ZIM Archive')} -c ${escapeShellArg(creator || 'Unknown')} -p KiwixM`;
if(tags) command += ` --tags=${escapeShellArg(tags)}`;
command += ` "${contentPath}" "${zimFilePath}"`;
console.log('Executing:', command);
const { stdout, stderr } = await execAsync(command);
if(stderr) console.error(stderr);
return zimFile;
}
export async function createZimFromUrl(options) {
const { url, name, language, description, tags, regex, maxHops, waitEvent } = options;
const sanitizedName = name.replace(/[^a-zA-Z0-9\-_]/g, '_');
const zimFile = `${sanitizedName}.zim`;
const zimFilePath = path.join(environment.paths.zim, zimFile);
const tempDir = `/tmp/zimit-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
let zimitArgs = [];
zimitArgs.push(`--seeds ${escapeDockerArg(url)}`);
zimitArgs.push(`--name ${escapeDockerArg(name)}`);
zimitArgs.push(`--headless`);
if(description) zimitArgs.push(`--description ${escapeDockerArg(description)}`);
if(language) zimitArgs.push(`--lang ${escapeDockerArg(language || 'en-US')}`);
if(tags) zimitArgs.push(`--tags ${escapeDockerArg(tags)}`);
if(regex) zimitArgs.push(`--include ${escapeDockerArg(regex)}`);
if(maxHops && maxHops !== '') zimitArgs.push(`--max-hops ${parseInt(maxHops)}`);
if(waitEvent && waitEvent !== '') zimitArgs.push(`--wait-until ${escapeDockerArg(waitEvent)}`);
zimitArgs.push(`--output ${tempDir}`);
const containerName = `zimit-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
try {
await execAsync(`mkdir -p ${tempDir}`);
const dockerCommand = `docker run --name ${containerName} --rm -v ${tempDir}:${tempDir}:rw --shm-size=2g --cap-add=SYS_ADMIN --security-opt seccomp=unconfined ghcr.io/openzim/zimit zimit ${zimitArgs.join(' ')}`;
console.log('Executing:', dockerCommand);
let { stdout, stderr } = await execAsync(dockerCommand);
if(stderr) console.log(stderr);
try {
const resp = await execAsync(`find ${tempDir} -name "*.zim" -type f`);
const tempZimPath = resp.stdout?.split('\n')?.[0]?.trim();
await fs.copyFile(tempZimPath, zimFilePath);
return zimFile;
} catch (fileError) {
throw new Error(fileError.message);
}
} catch (err) {
await execAsync(`docker rm -f ${containerName}`).catch(() => {});
throw err;
} finally {
execAsync(`rm -rf ${tempDir}`).catch(() => {});
}
}

452
src/views/admin.mjs Normal file
View File

@@ -0,0 +1,452 @@
import {formatFileSize} from '../services/files.mjs';
export function renderAdminPanel(zimFiles) {
const totalSize = zimFiles.reduce((sum, file) => sum + file.size, 0);
return `
<!DOCTYPE html>
<html>
<head>
<title>Kiwix Manager</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.tailwindcss.com"></script>
<style>
@keyframes spin { 0%{transform:rotate(0deg);} 100%{transform:rotate(360deg);} }
.spinner { animation: spin 1s linear infinite; }
.collapsible-content {
max-height: 0;
overflow: hidden;
transition: max-height .3s;
}
.collapsible-content.active { max-height: 800px; }
.collapsible-icon { transition: transform .3s; }
.collapsible-header.active .collapsible-icon { transform: rotate(180deg); }
</style>
</head>
<body class="bg-slate-50 min-h-screen p-5">
<div class="max-w-6xl mx-auto bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden">
<div class="flex justify-between items-center p-8 border-b border-slate-200">
<div class="flex items-center gap-4">
<img alt="logo" src="/favicon.png" class="h-20 w-auto">
<div>
<h1 class="text-3xl font-bold text-slate-800 mb-1">Kiwix Manager</h1>
<p class="text-slate-500">Manage your offline content library</p>
</div>
</div>
<div class="flex gap-2">
<a href="/" class="inline-flex items-center gap-2 px-4 py-2 bg-blue-500 text-white font-semibold text-sm rounded-md hover:bg-blue-600 transition-all hover:-translate-y-0.5">Browse</a>
<button onclick="restartKiwix()" class="inline-flex items-center gap-2 px-4 py-2 bg-amber-500 text-white font-semibold text-sm rounded-md hover:bg-amber-600 transition-all hover:-translate-y-0.5">Restart Kiwix</button>
</div>
</div>
<div class="bg-slate-50 border-b border-slate-200 px-8 py-4 flex justify-between items-center flex-wrap gap-4">
<div class="flex items-center gap-4 text-sm text-slate-600">
<span><strong>${zimFiles.length}</strong> ZIM files</span>
<span>•</span>
<span>${formatFileSize(totalSize)} total</span>
</div>
<div class="flex items-center gap-4">
<a href="https://library.kiwix.org/" target="_blank" class="text-blue-500 hover:text-blue-600 font-semibold">📚 Kiwix Library</a>
</div>
</div>
<div id="alert" class="hidden mx-8 mt-4 p-4 rounded-lg justify-between items-center">
<span id="alertMessage"></span>
<button class="alert-close text-xl font-bold opacity-70 hover:opacity-100 bg-transparent border-0 cursor-pointer">&times;</button>
</div>
<div id="loading" class="hidden text-center p-8 mt-4">
<div class="inline-block w-8 h-8 border-4 border-slate-200 border-t-blue-500 rounded-full spinner mb-4"></div>
<p class="text-slate-600 font-medium">Processing...</p>
</div>
<div class="p-8">
<div class="border border-slate-200 rounded-lg mb-4 bg-white overflow-hidden">
<div class="collapsible-header bg-slate-50 px-6 py-5 cursor-pointer flex justify-between items-center font-semibold text-slate-700 hover:bg-slate-100 transition-colors" onclick="toggleCollapsible(this)">
<span>📤 Upload ZIM</span><span class="collapsible-icon text-lg">▼</span>
</div>
<div class="collapsible-content">
<div class="p-6">
<div class="flex gap-2 mb-6">
<button id="upload-type-file-btn" class="flex-1 px-6 py-3 bg-blue-500 text-white font-semibold rounded-md hover:bg-blue-600 transition-colors" onclick="switchUploadType('file')">Upload File</button>
<button id="upload-type-url-btn" class="flex-1 px-6 py-3 bg-slate-100 text-slate-600 font-semibold rounded-md hover:bg-slate-200 border border-slate-200 transition-colors" onclick="switchUploadType('url')">From URL</button>
</div>
<div id="upload-file-section">
<form id="uploadForm" enctype="multipart/form-data">
<div class="mb-4">
<label for="zimFile" class="block mb-2 font-semibold text-slate-700 text-sm">Select ZIM File</label>
<input type="file" id="zimFile" name="file" accept=".zim" required class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
</div>
<div class="text-right">
<button type="submit" class="px-6 py-3 bg-blue-500 text-white font-semibold rounded-md hover:bg-blue-600 transition-colors">Upload</button>
</div>
</form>
</div>
<div id="upload-url-section" class="hidden">
<form id="downloadForm">
<div class="mb-4">
<label for="downloadUrl" class="block mb-2 font-semibold text-slate-700 text-sm">Download URL</label>
<input type="url" id="downloadUrl" name="url" required placeholder="https://download.kiwix.org/zim/..." class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
</div>
<div class="mb-4">
<label for="downloadFilename" class="block mb-2 font-semibold text-slate-700 text-sm">Filename (optional)</label>
<input type="text" id="downloadFilename" name="filename" placeholder="Enter a new name for the file" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
</div>
<div class="flex items-center justify-end gap-4">
<button type="submit" class="px-6 py-3 bg-blue-500 text-white font-semibold rounded-md hover:bg-blue-600 transition-colors">Download</button>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="border border-slate-200 rounded-lg mb-4 bg-white overflow-hidden">
<div class="collapsible-header bg-slate-50 px-6 py-5 cursor-pointer flex justify-between items-center font-semibold text-slate-700 hover:bg-slate-100 transition-colors" onclick="toggleCollapsible(this)">
<span>🌐 URL to ZIM</span><span class="collapsible-icon text-lg">▼</span>
</div>
<div class="collapsible-content">
<div class="p-6">
<form id="zim-url-form" class="space-y-4">
<div>
<label for="zim-url-url" class="block mb-2 font-semibold text-slate-700 text-sm">URL</label>
<input type="url" id="zim-url-url" name="url" required placeholder="https://www.example.com" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
</div>
<div>
<label for="zim-url-name" class="block mb-2 font-semibold text-slate-700 text-sm">ZIM Name</label>
<input type="text" id="zim-url-name" name="name" required placeholder="example-com-archive" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
</div>
<div>
<label for="zim-url-language" class="block mb-2 font-semibold text-slate-700 text-sm">Language</label>
<input type="text" id="zim-url-language" name="language" placeholder="en-US" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
</div>
<div>
<label for="zim-url-description" class="block mb-2 font-semibold text-slate-700 text-sm">Description</label>
<textarea id="zim-url-description" name="description" rows="2" placeholder="An archive of example.com" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all"></textarea>
</div>
<div>
<label for="zim-url-tags" class="block mb-2 font-semibold text-slate-700 text-sm">Tags (semi-colon separator)</label>
<input type="text" id="zim-url-tags" name="tags" placeholder="Cooking;Preppers" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
</div>
<div>
<label for="zim-url-regex" class="block mb-2 font-semibold text-slate-700 text-sm">Include Regex</label>
<input type="text" id="zim-url-regex" name="regex" placeholder="e.g., ^https://www.example.com/.*" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="zim-url-max-hops" class="block mb-2 font-semibold text-slate-700 text-sm">Max Hop Count</label>
<input type="number" id="zim-url-max-hops" name="maxHops" min="0" max="10" placeholder="2" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
<p class="text-xs text-slate-500 mt-1">Maximum number of link hops to follow (0-10)</p>
</div>
<div>
<label for="zim-url-wait-event" class="block mb-2 font-semibold text-slate-700 text-sm">Wait Event</label>
<select id="zim-url-wait-event" name="waitEvent" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
<option value="">Default</option>
<option value="load">Page Load</option>
<option value="domcontentloaded">DOM Ready</option>
<option value="networkidle0">Network Idle (0 requests)</option>
<option value="networkidle2">Network Idle (≤2 requests)</option>
</select>
<p class="text-xs text-slate-500 mt-1">When to consider page loading complete</p>
</div>
</div>
<div class="flex justify-end pt-4">
<button type="submit" class="px-6 py-3 bg-blue-500 text-white font-semibold rounded-md hover:bg-blue-600 transition-colors">Create</button>
</div>
</form>
</div>
</div>
</div>
<div class="border border-slate-200 rounded-lg mb-4 bg-white overflow-hidden">
<div class="collapsible-header bg-slate-50 px-6 py-5 cursor-pointer flex justify-between items-center font-semibold text-slate-700 hover:bg-slate-100 transition-colors" onclick="toggleCollapsible(this)">
<span>📦 ZIP to ZIM</span><span class="collapsible-icon text-lg">▼</span>
</div>
<div class="collapsible-content">
<div class="p-6">
<form id="zimFromZipForm" enctype="multipart/form-data" class="space-y-4">
<div>
<label for="zip-file-input" class="block mb-2 font-semibold text-slate-700 text-sm">Select ZIP File</label>
<input type="file" id="zip-file-input" name="zipFile" accept=".zip" required class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
</div>
<div>
<label for="zip-name" class="block mb-2 font-semibold text-slate-700 text-sm">ZIM Name (required)</label>
<input type="text" id="zip-name" name="name" required placeholder="My Awesome ZIM" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
</div>
<div>
<label for="zip-welcome" class="block mb-2 font-semibold text-slate-700 text-sm">Welcome Page (path inside zip)</label>
<input type="text" id="zip-welcome" name="welcome" placeholder="index.html" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
</div>
<div>
<label for="zip-language" class="block mb-2 font-semibold text-slate-700 text-sm">Language</label>
<input type="text" id="zip-language" name="language" placeholder="eng" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
</div>
<div>
<label for="zip-description" class="block mb-2 font-semibold text-slate-700 text-sm">Description</label>
<textarea id="zip-description" name="description" placeholder="A short description of the content" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all"></textarea>
</div>
<div>
<label for="zip-tags" class="block mb-2 font-semibold text-slate-700 text-sm">Tags (semicolon-separated)</label>
<input type="text" id="zip-tags" name="tags" placeholder="tag1;tag2;tag3" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
</div>
<div>
<label for="zip-favicon" class="block mb-2 font-semibold text-slate-700 text-sm">Favicon (path inside zip)</label>
<input type="text" id="zip-favicon" name="favicon" placeholder="images/favicon.png" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
</div>
<div>
<label for="zip-creator" class="block mb-2 font-semibold text-slate-700 text-sm">Creator</label>
<input type="text" id="zip-creator" name="creator" placeholder="Your Name or ID" class="w-full px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
</div>
<div class="flex justify-end pt-4">
<button type="submit" class="px-6 py-3 bg-blue-500 text-white font-semibold rounded-md hover:bg-blue-600 transition-colors">Create</button>
</div>
</form>
</div>
</div>
</div>
<div class="flex items-center justify-start mt-8 mb-4 flex-wrap gap-4">
<h2 class="text-slate-700 text-xl font-semibold">ZIM Library</h2>
<input type="text" id="searchInput" placeholder="Search files..." class="flex-grow max-w-md px-4 py-3 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-3 focus:ring-blue-100 focus:border-blue-500 transition-all">
</div>
<div class="grid gap-4 mt-4">
${zimFiles.length === 0
? `<div class="text-center py-12 text-slate-500">
<div class="text-5xl mb-4 opacity-50">📚</div>
<h3 class="text-lg font-semibold mb-2">No ZIM files found</h3>
<p>Upload or download to get started</p>
</div>`
: zimFiles.map(file => `
<div class="bg-white border border-slate-200 rounded-lg p-6 hover:border-slate-300 hover:shadow-md hover:-translate-y-0.5 transition-all">
<div class="flex gap-4 mb-4">
<div class="w-12 h-12 bg-slate-100 border border-slate-200 rounded-lg flex items-center justify-center text-2xl text-slate-500 flex-shrink-0">📚</div>
<div class="flex-grow">
<div class="font-bold text-lg text-slate-800 mb-1">${file.name}</div>
<div class="text-slate-500 text-sm flex gap-4 flex-wrap">
<span>${formatFileSize(file.size)}</span>
<span>Modified: ${new Date(file.modified).toLocaleDateString()}</span>
</div>
</div>
</div>
${!file.path.endsWith('archive.zim') ? `
<div class="flex gap-2 flex-wrap">
<a href="/api/download?path=${encodeURIComponent(file.path)}" class="inline-flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-600 font-semibold text-sm rounded-md hover:bg-slate-200 border border-slate-200 transition-colors">Download</a>
<button onclick="deleteFile('${file.path.replace(/'/g, "\\'")}')" class="inline-flex items-center gap-2 px-4 py-2 bg-red-500 text-white font-semibold text-sm rounded-md hover:bg-red-600 transition-colors">Delete</button>
</div>
` : ''}
</div>`).join('')}
</div>
</div>
</div>
<script>
function switchUploadType(type) {
const fileSection = document.getElementById('upload-file-section');
const urlSection = document.getElementById('upload-url-section');
const fileBtn = document.getElementById('upload-type-file-btn');
const urlBtn = document.getElementById('upload-type-url-btn');
if (type === 'file') {
fileSection.style.display = 'block';
urlSection.style.display = 'none';
fileBtn.className = 'flex-1 px-6 py-3 bg-blue-500 text-white font-semibold rounded-md hover:bg-blue-600 transition-colors';
urlBtn.className = 'flex-1 px-6 py-3 bg-slate-100 text-slate-600 font-semibold rounded-md hover:bg-slate-200 border border-slate-200 transition-colors';
} else {
fileSection.style.display = 'none';
urlSection.style.display = 'block';
fileBtn.className = 'flex-1 px-6 py-3 bg-slate-100 text-slate-600 font-semibold rounded-md hover:bg-slate-200 border border-slate-200 transition-colors';
urlBtn.className = 'flex-1 px-6 py-3 bg-blue-500 text-white font-semibold rounded-md hover:bg-blue-600 transition-colors';
}
}
function toggleCollapsible(header) {
const content = header.nextElementSibling;
header.classList.toggle('active'); content.classList.toggle('active');
}
function showAlert(msg, type = 'success') {
const alertEl = document.getElementById('alert');
const messageEl = document.getElementById('alertMessage');
if (!alertEl || !messageEl) return;
alertEl.className = type === 'success'
? 'flex mx-8 mt-4 p-4 rounded-lg justify-between items-center bg-green-50 text-green-800 border border-green-200'
: 'flex mx-8 mt-4 p-4 rounded-lg justify-between items-center bg-red-50 text-red-800 border border-red-200';
messageEl.textContent = msg;
}
function showLoading(show=true) {
const loading = document.getElementById('loading');
loading.classList.toggle('hidden', !show);
// Disable/enable all submit buttons and action buttons
const buttons = document.querySelectorAll('button[type="submit"], .bg-blue-500, .bg-amber-500, .bg-red-500');
buttons.forEach(button => {
button.disabled = show;
if (show) {
button.style.opacity = '0.6';
button.style.cursor = 'not-allowed';
} else {
button.style.opacity = '';
button.style.cursor = '';
}
});
// Disable/enable file inputs
const fileInputs = document.querySelectorAll('input[type="file"]');
fileInputs.forEach(input => {
input.disabled = show;
});
}
async function makeRequest(url, options={}) {
try { const res = await fetch(url, options); return { ok: res.ok, data: await res.json() }; }
catch(e){ return { ok:false, error:e.message }; }
}
document.getElementById('downloadForm').addEventListener('submit', async e=>{
e.preventDefault();
showLoading(true);
const body = { url: e.target.url.value, filename: e.target.filename.value };
const result = await makeRequest('/api/download',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
showLoading(false);
if(result.ok){
window.location.href = '?message=' + encodeURIComponent('Download finished') + '&type=success';
} else {
showAlert(result.data?.message || result.error || 'An unknown error occurred.', 'error');
}
});
document.getElementById('uploadForm').addEventListener('submit', async e=>{
e.preventDefault();
const fd=new FormData(e.target);
showLoading(true);
const result=await makeRequest('/api/upload',{method:'POST',body:fd});
showLoading(false);
if(result.ok){
window.location.href = '?message=Upload%20Finished&type=success';
} else {
showAlert(result.data?.message || result.error || 'An unknown error occurred.', 'error');
}
});
// Handle ZIM from URL form submission
document.getElementById('zim-url-form').addEventListener('submit', async (e) => {
e.preventDefault();
// Show loading animation
showLoading(true);
const formData = new FormData(e.target);
const data = Object.fromEntries(formData.entries());
try {
const response = await fetch('/api/zim-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const result = await response.json();
// Hide loading animation
showLoading(false);
if (response.ok) {
// Refresh page with success message
window.location.href = '?message=' + encodeURIComponent('Successfully created ZIM') + '&type=success';
} else {
// Show error alert
showAlert('Error: ' + (result.error || result.message || 'An unknown error occurred.'), 'error');
}
} catch (error) {
// Hide loading animation
showLoading(false);
console.error('Error creating ZIM from URL:', error);
showAlert('A network error occurred: ' + error.message, 'error');
}
});
document.getElementById('zimFromZipForm').addEventListener('submit', async e => {
e.preventDefault();
const form = e.target;
if (!form.checkValidity()) {
form.reportValidity();
return;
}
showLoading(true);
const formData = new FormData(form);
const result = await makeRequest('/api/zim-zip', { method: 'POST', body: formData });
showLoading(false);
if (result.ok) {
window.location.href = '?message=' + encodeURIComponent('Successfully created ZIM from ZIP') + '&type=success';
} else {
showAlert(result.data?.error || result.data?.message || result.error || 'An unknown error occurred.', 'error');
}
});
async function deleteFile(filePath){
if(filePath.endsWith('archive.zim')) return;
if(!confirm('Delete this file?')) return;
showLoading(true);
const result=await makeRequest('/api/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:filePath})});
showLoading(false);
if(result.ok){
window.location.href = '?message=ZIM%20Deleted&type=success';
} else {
showAlert(result.data?.message || result.error || 'An unknown error occurred.', 'error');
}
}
async function restartKiwix(){
if (!confirm('Are you sure you want to restart Kiwix?')) return;
showLoading(true);
const result = await makeRequest('/api/restart');
showLoading(false);
if (result.ok) {
showAlert('Kiwix has restarted', 'success');
setTimeout(() => window.location.reload(), 5000);
} else {
showAlert(result.data?.message || result.error || 'Failed to restart Kiwix.', 'error');
}
}
// Search functionality
document.getElementById('searchInput').addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
const fileCards = document.querySelectorAll('.grid > div');
fileCards.forEach(card => {
const fileName = card.querySelector('.font-bold')?.textContent.toLowerCase();
if (!fileName || fileName.includes(searchTerm)) {
card.style.display = 'block';
} else {
card.style.display = 'none';
}
});
});
// Alert close functionality
document.querySelector('.alert-close')?.addEventListener('click', function(e) {
this.parentElement.classList.add('hidden');
});
// Handle initial message from query params
window.addEventListener('DOMContentLoaded', () => {
const urlParams = new URLSearchParams(window.location.search);
const message = urlParams.get('message');
const type = urlParams.get('type');
if (message) {
showAlert(decodeURIComponent(message), type || 'success');
// clean url
window.history.replaceState({}, document.title, window.location.pathname);
}
});
</script>
</body>
</html>
`;
}

330
src/views/archive.mjs Normal file
View File

@@ -0,0 +1,330 @@
import { formatFileSize } from '../services/files.mjs';
import path from 'path';
// Move getFileIcon function to server-side
function getFileIcon(fileName) {
const ext = fileName.toLowerCase().split('.').pop();
const icons = {
pdf: '📄', txt: '📝', doc: '📄', jpg: '🖼️', png: '🖼️', gif: '🖼️',
mp4: '🎥', mp3: '🎵', zip: '📦', html: '🌐', css: '🎨', js: '⚡'
};
return icons[ext] || '📄';
}
export function renderArchive(currentPath, files, archiveRoot) {
const relativePath = path.relative(archiveRoot, currentPath);
const pathSegments = relativePath === '' ? [] : relativePath.split(path.sep);
// Updated breadcrumbs: 🏠 / Archive / <path>
const breadcrumbs = [
{ name: '🏠', path: '/', isHome: true },
{ name: 'Archive', path: '', isArchiveRoot: true }
];
let accumulatedPath = '';
pathSegments.forEach(segment => {
accumulatedPath = accumulatedPath ? path.join(accumulatedPath, segment) : segment;
breadcrumbs.push({ name: segment, path: accumulatedPath });
});
return `<!DOCTYPE html>
<html>
<head>
<title>Kiwix Archive</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<style>
.spinner { border-color: #f3f4f6; border-top-color: #3b82f6; animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.file-grid { display: grid; gap: 1rem; }
.file-item:hover { transform: translateY(-1px); }
</style>
</head>
<body class="bg-gray-50 min-h-screen p-5">
<div class="max-w-6xl mx-auto bg-white rounded-xl shadow-lg overflow-hidden border">
<!-- Header (removed Home button) -->
<div class="flex justify-between items-center p-8 border-b">
<div class="flex items-center gap-4">
<img src="/favicon.png" class="h-12 w-auto">
<div>
<h1 class="text-3xl font-bold text-gray-800">Archive</h1>
<p class="text-gray-500">Manage your offline content library</p>
</div>
</div>
</div>
<!-- Updated Breadcrumbs -->
<div class="bg-gray-50 border-b px-8 py-4 flex items-center gap-2 flex-wrap">
${breadcrumbs.map((crumb, i) => `
${i > 0 ? '<span class="text-gray-400">/</span>' : ''}
<a href="${crumb.isHome ? '/' : `/archive?path=${encodeURIComponent(crumb.path)}`}"
class="text-gray-600 hover:text-gray-800 ${i === breadcrumbs.length - 1 ? 'font-semibold text-gray-800' : ''}">${crumb.name}</a>
`).join('')}
</div>
<!-- Toolbar -->
<div class="bg-gray-50 border-b p-6 flex justify-between items-center flex-wrap gap-4">
<input type="text" id="searchInput" placeholder="Search files..."
class="flex-1 max-w-md px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<div class="flex gap-2">
<button onclick="showModal('folder')" class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">📁 New Folder</button>
<button onclick="showModal('upload')" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">📤 Upload</button>
</div>
</div>
<!-- Alert -->
<div id="alert" class="hidden mx-8 mt-4 p-4 rounded-lg"></div>
<!-- Loading -->
<div id="loading" class="hidden text-center p-8">
<div class="spinner w-10 h-10 border-4 rounded-full mx-auto mb-4"></div>
<p>Processing...</p>
</div>
<!-- Files -->
<div class="p-8">
<div class="file-grid">
${files.length === 0 ? `
<div class="text-center py-16 text-gray-500">
<div class="text-5xl mb-4 opacity-50">📂</div>
<h3 class="text-lg font-semibold mb-2">No files found</h3>
<p>This folder is empty. Upload files or create a folder to get started.</p>
</div>
` : files.map(file => `
<div class="file-item bg-white border rounded-lg p-6 hover:shadow-md transition-all cursor-pointer flex items-center gap-4"
data-path="${file.name}" data-dir="${file.isDirectory}">
<div class="text-4xl">${file.isDirectory ? '📁' : getFileIcon(file.name)}</div>
<div class="flex-1 min-w-0">
<div class="font-semibold text-gray-800 truncate">${file.name}</div>
<div class="text-sm text-gray-500 flex gap-4">
<span>${file.isDirectory ? 'Folder' : formatFileSize(file.size)}</span>
<span>${new Date(file.modified).toLocaleDateString()}</span>
</div>
</div>
<div class="file-actions flex gap-2 flex-shrink-0">
${!file.isDirectory ? `<a href="/archive/download?file=${encodeURIComponent(currentPath + '/' + file.name)}" class="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200">Download</a>` : ''}
<button onclick="rename('${file.name.replace(/'/g, "\\'")}', ${file.isDirectory})" class="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200">Rename</button>
<button onclick="deleteItem('${file.name.replace(/'/g, "\\'")}', ${file.isDirectory})" class="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200">Delete</button>
</div>
</div>
`).join('')}
</div>
</div>
</div>
<!-- Modals -->
<div id="modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<div class="flex justify-between items-center mb-4">
<h3 id="modalTitle" class="text-lg font-semibold"></h3>
<button onclick="hideModal()" class="text-gray-400 hover:text-gray-600">×</button>
</div>
<div id="modalContent"></div>
</div>
</div>
<script>
const relativePath = '${relativePath.replace(/\\/g, '\\\\')}';
// Client-side version of getFileIcon (for consistency)
function getFileIcon(fileName) {
const ext = fileName.toLowerCase().split('.').pop();
const icons = {
pdf: '📄', txt: '📝', doc: '📄', jpg: '🖼️', png: '🖼️', gif: '🖼️',
mp4: '🎥', mp3: '🎵', zip: '📦', html: '🌐', css: '🎨', js: '⚡'
};
return icons[ext] || '📄';
}
function showModal(type) {
const modal = document.getElementById('modal');
const title = document.getElementById('modalTitle');
const content = document.getElementById('modalContent');
if (type === 'folder') {
title.textContent = 'Create New Folder';
content.innerHTML = \`
<form onsubmit="createFolder(event)">
<input type="text" name="name" placeholder="Folder name" required
class="w-full px-3 py-2 border rounded-lg mb-4 focus:ring-2 focus:ring-blue-500">
<div class="flex justify-end gap-2">
<button type="button" onclick="hideModal()" class="px-4 py-2 bg-gray-200 rounded-lg">Cancel</button>
<button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded-lg">Create</button>
</div>
</form>
\`;
} else if (type === 'upload') {
title.textContent = 'Upload Files';
content.innerHTML = \`
<form onsubmit="uploadFiles(event)" enctype="multipart/form-data">
<div class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center mb-4 cursor-pointer"
onclick="document.querySelector('input[type=file]').click()">
<p>Click to select files or drag and drop</p>
<p class="text-sm text-gray-500 mt-1">Multiple files supported</p>
</div>
<input type="file" name="files" multiple class="hidden" onchange="updateFileList(this)">
<div id="fileList" class="hidden mb-4 p-3 bg-gray-50 rounded-lg"></div>
<input type="hidden" name="uploadPath" value="${relativePath}">
<div class="flex justify-end gap-2">
<button type="button" onclick="hideModal()" class="px-4 py-2 bg-gray-200 rounded-lg">Cancel</button>
<button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded-lg">Upload</button>
</div>
</form>
\`;
} else if (type === 'rename') {
title.textContent = 'Rename';
content.innerHTML = \`
<form onsubmit="doRename(event)">
<input type="text" name="newName" required
class="w-full px-3 py-2 border rounded-lg mb-4 focus:ring-2 focus:ring-blue-500">
<input type="hidden" name="oldName">
<input type="hidden" name="isDirectory">
<div class="flex justify-end gap-2">
<button type="button" onclick="hideModal()" class="px-4 py-2 bg-gray-200 rounded-lg">Cancel</button>
<button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded-lg">Rename</button>
</div>
</form>
\`;
}
modal.classList.remove('hidden');
}
function hideModal() {
document.getElementById('modal').classList.add('hidden');
}
function showAlert(msg, type = 'success') {
const alert = document.getElementById('alert');
alert.className = \`px-4 py-3 rounded-lg \${type === 'success' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}\`;
alert.textContent = msg;
alert.classList.remove('hidden');
setTimeout(() => alert.classList.add('hidden'), 5000);
}
function showLoading(show = true) {
document.getElementById('loading').classList.toggle('hidden', !show);
}
async function makeRequest(url, options = {}) {
try {
const response = await fetch(url, options);
const data = await response.json();
return { ok: response.ok, data };
} catch (error) {
return { ok: false, error: error.message };
}
}
async function createFolder(e) {
e.preventDefault();
const name = new FormData(e.target).get('name');
showLoading();
const result = await makeRequest('/api/files/create-folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: relativePath, name })
});
showLoading(false);
hideModal();
if (result.ok) location.reload();
else showAlert(result.data?.error || 'Failed to create folder', 'error');
}
async function uploadFiles(e) {
e.preventDefault();
const formData = new FormData(e.target);
showLoading();
const result = await makeRequest('/api/files/upload', { method: 'POST', body: formData });
showLoading(false);
hideModal();
if (result.ok) {
showAlert('Files uploaded successfully');
setTimeout(() => location.reload(), 1500);
} else showAlert(result.data?.error || 'Upload failed', 'error');
}
function rename(name, isDir) {
showModal('rename');
const form = document.querySelector('#modalContent form');
form.newName.value = name;
form.oldName.value = name;
form.isDirectory.value = isDir;
form.newName.select();
}
async function doRename(e) {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.target));
showLoading();
const result = await makeRequest('/api/files/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: relativePath, ...data })
});
showLoading(false);
hideModal();
if (result.ok) location.reload();
else showAlert(result.data?.error || 'Failed to rename', 'error');
}
async function deleteItem(name, isDir) {
if (!confirm(\`Delete "\${name}"?\`)) return;
showLoading();
const result = await makeRequest('/api/files/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: relativePath, name, isDirectory: isDir })
});
showLoading(false);
if (result.ok) location.reload();
else showAlert(result.data?.error || 'Failed to delete', 'error');
}
function updateFileList(input) {
const fileList = document.getElementById('fileList');
if (input.files.length === 0) {
fileList.classList.add('hidden');
return;
}
fileList.classList.remove('hidden');
fileList.innerHTML = \`<div class="font-semibold mb-2">Selected Files (\${input.files.length}):</div>\` +
Array.from(input.files).map(f => \`<div class="text-sm text-gray-600">\${f.name}</div>\`).join('');
}
// Search functionality
document.getElementById('searchInput').addEventListener('input', (e) => {
const term = e.target.value.toLowerCase();
document.querySelectorAll('.file-item').forEach(item => {
const name = item.querySelector('.font-semibold').textContent.toLowerCase();
item.style.display = name.includes(term) ? 'flex' : 'none';
});
});
// File navigation
document.querySelectorAll('.file-item').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.closest('.file-actions')) return;
const isDir = item.dataset.dir === 'true';
const name = item.dataset.path;
if (isDir) {
const newPath = relativePath ? \`\${relativePath}/\${name}\` : name;
location.href = \`/archive?path=\${encodeURIComponent(newPath)}\`;
} else {
const fullPath = '${currentPath.replace(/\\/g, '\\\\')}'.replace(/\\\\/g, '/') + '/' + name;
location.href = \`/archive/view?file=\${encodeURIComponent(fullPath)}\`;
}
});
});
// Handle URL messages
const urlParams = new URLSearchParams(location.search);
const message = urlParams.get('message');
if (message) {
showAlert(decodeURIComponent(message), urlParams.get('type') || 'success');
history.replaceState({}, document.title, location.pathname + '?path=' + encodeURIComponent(relativePath));
}
</script>
</body>
</html>`;
}