diff --git a/archive.py b/archive.py
index eb30eec..2bfa68f 100644
--- a/archive.py
+++ b/archive.py
@@ -235,9 +235,6 @@ class SiteArchiver:
# Create search database
await self._create_search_database_streaming(tmpdir_path, sitemap, domain, html_contents)
- # Write serve.py
- self._write_serve_py(tmpdir_path)
-
# Write metadata
metadata = {
'domain': domain,
@@ -523,968 +520,10 @@ class SiteArchiver:
await asyncio.to_thread(create_db)
logger.info(f"Created search database: {db_path}")
- def _write_serve_py(self, archive_root: Path):
- """Write embedded search server (stdlib only, no dependencies)."""
- serve_py = '''#!/usr/bin/env python3
-"""
-neopig Archive Server - Browse and search archived sites.
-Zero dependencies - uses only Python stdlib.
-
-Usage:
- python serve.py # Serve from extracted archive
- python serve.py archive.tar.gz # Serve directly from tarball
- python serve.py -p 8080 # Custom port
- ./archive.run # Self-extracting archive
-"""
-
-import argparse
-import html
-import json
-import mimetypes
-import os
-import re
-import sqlite3
-import sys
-import tarfile
-import tempfile
-from http.server import HTTPServer, BaseHTTPRequestHandler
-from pathlib import Path
-from urllib.parse import parse_qs, urlparse, unquote
-
-# Globals set at startup
-ARCHIVE_ROOT = None
-TAR_FILE = None
-TAR_MEMBERS = {}
-DB_PATH = None
-METADATA = {}
-
-
-def get_archive_source():
- """Determine if we are in a tarball, .run, or extracted directory."""
- exe_path = Path(sys.argv[0]).resolve()
-
- # Check for .run format (has NEOPIG trailer)
- if exe_path.suffix == '.run' or (len(sys.argv) == 1 and exe_path.stat().st_size > 1000000):
- try:
- with open(exe_path, 'rb') as f:
- f.seek(-22, 2)
- trailer = f.read(22)
- if trailer[:6] == b'NEOPIG':
- offset = int(trailer[6:22].decode(), 16)
- return ('run', exe_path, offset)
- except Exception:
- pass
-
- # Check command line for tarball or .run argument
- for arg in sys.argv[1:]:
- if not arg.startswith('-'):
- p = Path(arg)
- if p.exists():
- if p.suffix in ('.gz', '.tar', '.tgz'):
- return ('tarball', p, 0)
- # Check if it's a .run file with NEOPIG trailer
- if p.suffix == '.run' or p.stat().st_size > 100000:
- try:
- with open(p, 'rb') as f:
- f.seek(-22, 2)
- trailer = f.read(22)
- if trailer[:6] == b'NEOPIG':
- offset = int(trailer[6:22].decode(), 16)
- return ('run', p, offset)
- except Exception:
- pass
-
- # Must be extracted directory
- return ('directory', Path(__file__).parent, 0)
-
-
-def init_archive():
- """Initialize archive access."""
- global ARCHIVE_ROOT, TAR_FILE, TAR_MEMBERS, DB_PATH, METADATA
-
- source_type, source_path, offset = get_archive_source()
-
- if source_type == 'run':
- print(f"Serving from self-extracting archive: {source_path}")
- f = open(source_path, 'rb')
- f.seek(offset)
- TAR_FILE = tarfile.open(fileobj=f, mode='r:gz')
- elif source_type == 'tarball':
- print(f"Serving from tarball: {source_path}")
- TAR_FILE = tarfile.open(source_path, 'r:gz')
- else:
- print(f"Serving from directory: {source_path}")
- ARCHIVE_ROOT = source_path
-
- if TAR_FILE:
- # Build member lookup and find archive root
- for member in TAR_FILE.getmembers():
- TAR_MEMBERS[member.name] = member
- first = list(TAR_MEMBERS.keys())[0]
- archive_name = first.split('/')[0]
- ARCHIVE_ROOT = Path(archive_name)
-
- # Extract database to temp for searching
- db_member = f"{archive_name}/archive.db"
- if db_member in TAR_MEMBERS:
- temp_dir = tempfile.mkdtemp()
- TAR_FILE.extract(TAR_MEMBERS[db_member], temp_dir)
- DB_PATH = Path(temp_dir) / db_member
- else:
- DB_PATH = ARCHIVE_ROOT / 'archive.db'
-
- # Load metadata
- meta_path = ARCHIVE_ROOT / 'metadata.json' if not TAR_FILE else None
- if meta_path and meta_path.exists():
- METADATA = json.loads(meta_path.read_text())
- elif TAR_FILE:
- meta_member = f"{ARCHIVE_ROOT}/metadata.json"
- if meta_member in TAR_MEMBERS:
- f = TAR_FILE.extractfile(TAR_MEMBERS[meta_member])
- if f:
- METADATA = json.loads(f.read().decode())
-
-
-def read_file(path: str) -> tuple:
- """Read file from archive. Returns (content_bytes, mime_type) or (None, None)."""
- if TAR_FILE:
- # Normalize path for tarball
- tar_path = f"{ARCHIVE_ROOT}/{path}".lstrip('/')
- if tar_path in TAR_MEMBERS:
- f = TAR_FILE.extractfile(TAR_MEMBERS[tar_path])
- if f:
- mime, _ = mimetypes.guess_type(path)
- return f.read(), mime or 'application/octet-stream'
- return None, None
- else:
- file_path = ARCHIVE_ROOT / path
- if file_path.exists() and file_path.is_file():
- # Security: prevent path traversal
- try:
- file_path.resolve().relative_to(ARCHIVE_ROOT.resolve())
- except ValueError:
- return None, None
- mime, _ = mimetypes.guess_type(str(file_path))
- return file_path.read_bytes(), mime or 'application/octet-stream'
- return None, None
-
-
-def list_files(subdir: str, pattern: str = '*') -> list:
- """List files in a subdirectory."""
- files = []
- if TAR_FILE:
- prefix = f"{ARCHIVE_ROOT}/{subdir}/"
- for name in TAR_MEMBERS:
- if name.startswith(prefix) and not name.endswith('/'):
- rel = name[len(prefix):]
- if pattern == '*' or rel.endswith(pattern.replace('*', '')):
- files.append(rel)
- else:
- dir_path = ARCHIVE_ROOT / subdir
- if dir_path.exists():
- for f in dir_path.rglob(pattern):
- if f.is_file():
- files.append(str(f.relative_to(dir_path)))
- return sorted(files)
-
-
-def search_pages(query: str, limit: int = 50) -> list:
- """Search pages using FTS5 with LIKE fallback."""
- if not DB_PATH or not DB_PATH.exists():
- return []
-
- conn = sqlite3.connect(DB_PATH)
- c = conn.cursor()
- results = []
- try:
- # Try FTS5 with prefix matching (add * for partial matches)
- fts_query = ' '.join(f'"{word}"*' for word in query.split())
- c.execute("""
- SELECT p.path, p.title, snippet(pages_fts, 1, '', '', '...', 40)
- FROM pages_fts
- JOIN pages p ON pages_fts.rowid = p.id
- WHERE pages_fts MATCH ?
- ORDER BY rank
- LIMIT ?
- """, (fts_query, limit))
- results = [{'path': r[0], 'title': r[1], 'snippet': r[2]} for r in c.fetchall()]
- except sqlite3.OperationalError:
- pass
-
- # Fallback to LIKE for substring matching if FTS5 found nothing
- if not results:
- try:
- like_q = f'%{query}%'
- c.execute("""
- SELECT path, title, substr(content, 1, 200) as snippet
- FROM pages
- WHERE title LIKE ? COLLATE NOCASE
- OR content LIKE ? COLLATE NOCASE
- OR path LIKE ? COLLATE NOCASE
- LIMIT ?
- """, (like_q, like_q, like_q, limit))
- results = [{'path': r[0], 'title': r[1], 'snippet': r[2] + '...'} for r in c.fetchall()]
- except sqlite3.OperationalError:
- pass
-
- conn.close()
- return results
-
-
-def get_stats() -> dict:
- """Get archive statistics."""
- stats = {
- 'pages': len(list_files('html', '*.html')),
- 'media': len(list_files('media')),
- 'screenshots': len(list_files('screenshots', '*.png')),
- 'domain': METADATA.get('domain', 'unknown'),
- 'created': METADATA.get('created', 'unknown'),
- }
- if METADATA.get('stats'):
- stats.update(METADATA['stats'])
- return stats
-
-
-# HTML Templates
-INDEX_HTML = """
-
-
-
-
- {domain} - neopig Archive
-
-
-
-
-
-
-
-"""
-
-BROWSE_HTML = """
-
-
-
-
- Browse - {domain}
-
-
-
-
-
-"""
-
-MEDIA_HTML = """
-
-
-
-
- Media - {domain}
-
-
-
-
-
-"""
-
-MEDIA_DETAIL_HTML = """
-
-
-
-
- {caption} - neopig Archive
-
-
-
-
-
-"""
-
-
-PAGE_VIEW_HTML = """
-
-
-
-
- {title} - neopig Archive
-
-
-
-
-
-"""
-
-
-def simple_markdown_to_html(md: str) -> str:
- """Convert markdown to HTML (simple stdlib-only implementation)."""
- import re
- lines = md.split('\n')
- html_lines = []
- in_code_block = False
- in_list = False
-
- for line in lines:
- # Code blocks
- if line.startswith('```'):
- if in_code_block:
- html_lines.append('')
- in_code_block = False
- else:
- html_lines.append('')
- in_code_block = True
- continue
-
- if in_code_block:
- html_lines.append(html.escape(line))
- continue
-
- # Close list if needed
- if in_list and not line.strip().startswith(('- ', '* ', '1. ')):
- html_lines.append('')
- in_list = False
-
- # Headers
- if line.startswith('### '):
- html_lines.append(f'{html.escape(line[4:])}
')
- elif line.startswith('## '):
- html_lines.append(f'{html.escape(line[3:])}
')
- elif line.startswith('# '):
- html_lines.append(f'{html.escape(line[2:])}
')
- # Blockquotes
- elif line.startswith('> '):
- html_lines.append(f'{html.escape(line[2:])}
')
- # Horizontal rule
- elif line.strip() in ('---', '***', '___'):
- html_lines.append('
')
- # Lists
- elif line.strip().startswith(('- ', '* ')):
- if not in_list:
- html_lines.append('')
- in_list = True
- content = line.strip()[2:]
- html_lines.append(f'- {html.escape(content)}
')
- # Empty line
- elif not line.strip():
- html_lines.append('
')
- # Regular paragraph
- else:
- escaped = html.escape(line)
- # Inline code
- escaped = re.sub(r'`([^`]+)`', r'\1', escaped)
- # Bold
- escaped = re.sub(r'[*][*]([^*]+)[*][*]', r'\1', escaped)
- # Italic
- escaped = re.sub(r'[*]([^*]+)[*]', r'\1', escaped)
- # Links [text](url)
- link_re = re.compile(r'\[([^]]+)\]\(([^)]+)\)')
- escaped = link_re.sub(r'\1', escaped)
- # Images 
- img_re = re.compile(r'!\[([^]]*)\]\(([^)]+)\)')
- escaped = img_re.sub(r'
', escaped)
- html_lines.append(f'{escaped}
')
-
- if in_list:
- html_lines.append('
')
- if in_code_block:
- html_lines.append('
')
-
- return '\n'.join(html_lines)
-
-
-class ArchiveHandler(BaseHTTPRequestHandler):
- """HTTP request handler for the archive."""
-
- def log_message(self, format, *args):
- print(f"[{self.log_date_time_string()}] {args[0]}")
-
- def send_html(self, content: str, status: int = 200):
- self.send_response(status)
- self.send_header('Content-Type', 'text/html; charset=utf-8')
- self.send_header('Content-Length', len(content.encode()))
- self.end_headers()
- self.wfile.write(content.encode())
-
- def send_json(self, data, status: int = 200):
- content = json.dumps(data)
- self.send_response(status)
- self.send_header('Content-Type', 'application/json')
- self.send_header('Content-Length', len(content.encode()))
- self.end_headers()
- self.wfile.write(content.encode())
-
- def send_file(self, content: bytes, mime: str):
- self.send_response(200)
- self.send_header('Content-Type', mime)
- self.send_header('Content-Length', len(content))
- self.send_header('Cache-Control', 'public, max-age=86400')
- self.end_headers()
- self.wfile.write(content)
-
- def send_404(self):
- self.send_html('404 Not Found
', 404)
-
- def do_GET(self):
- parsed = urlparse(self.path)
- path = unquote(parsed.path)
- query = parse_qs(parsed.query)
-
- # API endpoints
- if path == '/api/search':
- q = query.get('q', [''])[0]
- results = search_pages(q) if q else []
- self.send_json(results)
- return
-
- if path == '/api/stats':
- self.send_json(get_stats())
- return
-
- # Pages
- if path == '/':
- stats = get_stats()
- content = INDEX_HTML.format(**stats)
- self.send_html(content)
- return
-
- if path == '/browse':
- pages = list_files('html', '*.html')
- items = ''.join([
- f''
- for p in pages[:500]
- ])
- if not items:
- items = 'No pages found
'
- content = BROWSE_HTML.format(domain=METADATA.get('domain', ''), items=items)
- self.send_html(content)
- return
-
- # Page view - render markdown with neopig styling
- if path.startswith('/view/'):
- page_path = path[6:] # Remove '/view/'
- # Try markdown first, fall back to HTML
- md_path = 'markdown/' + page_path.replace('.html', '.md')
- html_path = 'html/' + page_path
-
- md_content, _ = read_file(md_path)
- html_content_raw, _ = read_file(html_path)
-
- if md_content:
- # Render markdown
- rendered = simple_markdown_to_html(md_content.decode('utf-8', errors='replace'))
- elif html_content_raw:
- # Extract body from HTML and show as-is
- html_str = html_content_raw.decode('utf-8', errors='replace')
- # Simple body extraction
- import re as re_mod
- body_match = re_mod.search(r']*>(.*?)', html_str, re_mod.DOTALL | re_mod.IGNORECASE)
- rendered = body_match.group(1) if body_match else html_str
- else:
- self.send_404()
- return
-
- # Extract title
- title = page_path.replace('.html', '').replace('/', ' > ')
-
- # Check for screenshot
- ss_path = page_path.replace('.html', '.png').replace('/', '_')
- screenshot_exists = f'screenshots/{ss_path}' in [f'screenshots/{f}' for f in list_files('screenshots', '*.png')]
-
- screenshot_link = f' | [View Screenshot]' if screenshot_exists else ''
- screenshot_embed = (
- ''
- '
Page Screenshot:
'
- f'
})
'
- '
'
- ) if screenshot_exists else ''
-
- content = PAGE_VIEW_HTML.format(
- title=html.escape(title),
- path=html.escape(page_path),
- content=rendered,
- screenshot_link=screenshot_link,
- screenshot_embed=screenshot_embed,
- )
- self.send_html(content)
- return
-
- if path == '/media':
- files = list_files('media')
- items = []
- for f in files[:200]:
- ext = Path(f).suffix.lower()
- if ext in ('.mp4', '.webm', '.mov'):
- media_el = f''
- else:
- media_el = f'
'
- # Link to detail view instead of raw file
- items.append(
- ''
- )
- content = MEDIA_HTML.format(
- domain=METADATA.get('domain', ''),
- title='Media',
- items=''.join(items) if items else 'No media found
'
- )
- self.send_html(content)
- return
-
- # Media detail view - image at top with source page content below
- if path.startswith('/media/detail/'):
- media_path = path[14:] # Remove '/media/detail/'
- media_file = 'media/' + media_path
-
- # Check media exists
- media_content, mime = read_file(media_file)
- if not media_content:
- self.send_404()
- return
-
- # Determine media element type
- ext = Path(media_path).suffix.lower()
- if ext in ('.mp4', '.webm', '.mov'):
- media_el = f''
- else:
- media_el = f'
'
-
- # Try to find the source page - media path mirrors URL structure
- # e.g., media/images/foo.jpg might come from t/topic-name/123.html
- # For now, use filename as caption
- caption = Path(media_path).stem.replace('-', ' ').replace('_', ' ')
- media_uri = f'/media/{media_path}'
- page_uri = METADATA.get('target_url', METADATA.get('domain', 'unknown'))
- page_path = 'index.html'
-
- # Try to find associated page content from the database
- page_content = 'Source page content not available in archive database.
'
- if DB_PATH and DB_PATH.exists():
- conn = sqlite3.connect(DB_PATH)
- c = conn.cursor()
- try:
- # Search for pages that might contain this media
- media_name = Path(media_path).name
- c.execute("""
- SELECT path, title, content FROM pages
- WHERE content LIKE ? OR path LIKE ?
- LIMIT 1
- """, (f'%{media_name}%', f'%{media_name}%'))
- row = c.fetchone()
- if row:
- page_path = row[0].replace('html/', '')
- caption = row[1] or caption
- # Render the content
- md_file = 'markdown/' + page_path.replace('.html', '.md')
- md_content, _ = read_file(md_file)
- if md_content:
- page_content = simple_markdown_to_html(md_content.decode('utf-8', errors='replace'))
- else:
- page_content = f'{html.escape(row[2][:2000] if row[2] else "")}...
'
- except Exception:
- pass
- finally:
- conn.close()
-
- content = MEDIA_DETAIL_HTML.format(
- media_element=media_el,
- caption=html.escape(caption),
- media_uri=html.escape(media_uri),
- page_uri=html.escape(page_uri),
- page_path=html.escape(page_path),
- page_content=page_content,
- )
- self.send_html(content)
- return
-
- if path == '/screenshots':
- files = list_files('screenshots', '*.png')
- items = []
- for f in files[:200]:
- items.append(
- ''
- )
- content = MEDIA_HTML.format(
- domain=METADATA.get('domain', ''),
- title='Screenshots',
- items=''.join(items) if items else 'No screenshots found
'
- )
- self.send_html(content)
- return
-
- # Serve static files
- file_path = path.lstrip('/')
- content, mime = read_file(file_path)
- if content is not None:
- self.send_file(content, mime)
- else:
- self.send_404()
-
-
-def main():
- parser = argparse.ArgumentParser(description='neopig Archive Server')
- parser.add_argument('archive', nargs='?', help='Path to archive.tar.gz (optional)')
- parser.add_argument('-p', '--port', type=int, default=8000, help='Port to listen on')
- parser.add_argument('--host', default='0.0.0.0', help='Host to bind to')
- args = parser.parse_args()
-
- init_archive()
-
- server = HTTPServer((args.host, args.port), ArchiveHandler)
- print(f"Starting neopig archive server at http://{args.host}:{args.port}")
- print(f"Archive: {METADATA.get('domain', 'unknown')} ({get_stats()['pages']} pages)")
- print("Press Ctrl+C to stop")
-
- try:
- server.serve_forever()
- except KeyboardInterrupt:
- print("\\nShutting down...")
- server.shutdown()
-
-
-if __name__ == '__main__':
- main()
-'''
- (archive_root / 'serve.py').write_text(serve_py)
+# NOTE: Embedded serve.py removed - archives now use serp.py from bundled neopig/
+# The bootstrap.c extracts neopig/*.py and runs serp.py with the tarball as argument.
+# See: make run TARBALL=archive.tar.gz
def upgrade_neopig_in_archive(archive_path: Path) -> Path:
@@ -1504,38 +543,56 @@ def upgrade_neopig_in_archive(archive_path: Path) -> Path:
# Create new archive with updated neopig
output_path = archive_path.with_suffix('.upgraded.tar.gz')
+ logger.info(f"Reading archive: {archive_path}")
with tarfile.open(archive_path, 'r:gz') as old_tar:
- with tarfile.open(output_path, 'w:gz') as new_tar:
- # Get archive name from first member
- members = old_tar.getmembers()
- archive_name = members[0].name.split('/')[0]
+ # Get members list (this reads the whole index)
+ members = old_tar.getmembers()
+ total = len(members)
+ archive_name = members[0].name.split('/')[0]
+ logger.info(f"Found {total} members in {archive_name}")
+ with tarfile.open(output_path, 'w:gz') as new_tar:
# Copy all members except neopig/ and requirements.txt
- for member in members:
+ copied = 0
+ skipped = 0
+ for i, member in enumerate(members):
parts = member.name.split('/')
if len(parts) > 1 and parts[1] == 'neopig':
+ skipped += 1
continue # Skip old neopig files
if len(parts) > 1 and parts[1] == 'requirements.txt':
+ skipped += 1
continue # Skip old requirements
if member.isfile():
f = old_tar.extractfile(member)
if f:
new_tar.addfile(member, f)
+ copied += 1
else:
new_tar.addfile(member)
+ copied += 1
+
+ if (i + 1) % 1000 == 0:
+ logger.info(f" Progress: {i+1}/{total} ({copied} copied, {skipped} skipped)")
+
+ logger.info(f"Copied {copied} members, skipped {skipped}")
# Add new neopig files
+ logger.info("Adding neopig source files...")
for pyfile in neopig_files:
src_path = neopig_src / pyfile
if src_path.exists():
new_tar.add(src_path, arcname=f"{archive_name}/neopig/{pyfile}")
+ logger.info(f" Added: neopig/{pyfile}")
# Add requirements.txt
req_path = neopig_src / 'requirements.txt'
if req_path.exists():
new_tar.add(req_path, arcname=f"{archive_name}/requirements.txt")
+ logger.info(" Added: requirements.txt")
# Replace original with upgraded
+ logger.info(f"Replacing original archive...")
shutil.move(output_path, archive_path)
return archive_path
diff --git a/bootstrap.c b/bootstrap.c
index 405787f..6b1f377 100644
--- a/bootstrap.c
+++ b/bootstrap.c
@@ -1,10 +1,10 @@
/*
* bootstrap.c - Self-extracting archive bootstrap
*
- * A small C program that boots an embedded Python archive server.
+ * A small C program that boots neopig from an embedded tar.gz archive.
* When compiled and concatenated with a tar.gz archive, it:
- * 1. Extracts serve.py from the embedded tarball
- * 2. Runs: python3 serve.py
+ * 1. Extracts neopig/*.py from the embedded tarball to /tmp
+ * 2. Runs: python3 /tmp/neopig/serp.py
* 3. The Python script serves directly from the tarball portion
*
* Build:
@@ -16,7 +16,7 @@
* echo -n "NEOPIG" >> archive.run # Magic marker
* printf '%016x' $(stat -c%s bootstrap) >> archive.run # Offset (hex)
*
- * Or use the provided make-executable.sh script.
+ * Or use: make run TARBALL=archive.tar.gz
*/
#define _GNU_SOURCE
@@ -35,7 +35,6 @@
#define OFFSET_LEN 16
#define TRAILER_LEN (MAGIC_LEN + OFFSET_LEN)
-#define SERVE_PY_NAME "serve.py"
#define CHUNK_SIZE 16384
/* Find python3 interpreter */
@@ -94,17 +93,35 @@ static long read_trailer(const char *self_path) {
return offset;
}
-/* Extract serve.py from gzipped tarball */
-static char *extract_serve_py(const char *self_path, long tar_offset) {
- /* Create temp file for serve.py */
- char *temp_path = strdup("/tmp/neopig_serve_XXXXXX.py");
- if (!temp_path) return NULL;
+/* Create directory recursively */
+static int mkdir_p(const char *path) {
+ char tmp[4096];
+ char *p = NULL;
+ size_t len;
- /* mkstemps for .py suffix */
- int fd = mkstemps(temp_path, 3);
- if (fd < 0) {
- perror("mkstemps");
- free(temp_path);
+ snprintf(tmp, sizeof(tmp), "%s", path);
+ len = strlen(tmp);
+ if (tmp[len - 1] == '/') tmp[len - 1] = 0;
+
+ for (p = tmp + 1; *p; p++) {
+ if (*p == '/') {
+ *p = 0;
+ mkdir(tmp, 0755);
+ *p = '/';
+ }
+ }
+ return mkdir(tmp, 0755);
+}
+
+/* Extract neopig/*.py from gzipped tarball */
+static char *extract_neopig(const char *self_path, long tar_offset) {
+ /* Create temp directory */
+ char *temp_dir = strdup("/tmp/neopig_XXXXXX");
+ if (!temp_dir) return NULL;
+
+ if (!mkdtemp(temp_dir)) {
+ perror("mkdtemp");
+ free(temp_dir);
return NULL;
}
@@ -112,35 +129,30 @@ static char *extract_serve_py(const char *self_path, long tar_offset) {
FILE *self = fopen(self_path, "rb");
if (!self) {
perror("fopen self for tar");
- close(fd);
- unlink(temp_path);
- free(temp_path);
+ free(temp_dir);
return NULL;
}
if (fseek(self, tar_offset, SEEK_SET) != 0) {
perror("fseek to tar");
fclose(self);
- close(fd);
- unlink(temp_path);
- free(temp_path);
+ free(temp_dir);
return NULL;
}
/* Open gzip stream */
- gzFile gz = gzdopen(fileno(self), "rb");
+ gzFile gz = gzdopen(dup(fileno(self)), "rb");
if (!gz) {
fprintf(stderr, "gzdopen failed\n");
fclose(self);
- close(fd);
- unlink(temp_path);
- free(temp_path);
+ free(temp_dir);
return NULL;
}
+ fclose(self);
- /* Read tar headers looking for serve.py */
+ /* Read tar headers looking for neopig/*.py */
unsigned char header[512];
- int found = 0;
+ int found_any = 0;
while (gzread(gz, header, 512) == 512) {
/* Check for end of archive (all zeros) */
@@ -161,25 +173,44 @@ static char *extract_serve_py(const char *self_path, long tar_offset) {
size_str[12] = '\0';
long filesize = strtol(size_str, NULL, 8);
- /* Check if this is serve.py */
- char *basename = strrchr(filename, '/');
- basename = basename ? basename + 1 : filename;
+ /* Check if this is a neopig/*.py file */
+ char *neopig_pos = strstr(filename, "/neopig/");
+ char *py_ext = strstr(filename, ".py");
- if (strcmp(basename, SERVE_PY_NAME) == 0) {
+ if (neopig_pos && py_ext && py_ext > neopig_pos) {
/* Extract this file */
- unsigned char buf[CHUNK_SIZE];
- long remaining = filesize;
+ char *basename = neopig_pos + 8; /* Skip "/neopig/" */
+ char out_path[4096];
+ snprintf(out_path, sizeof(out_path), "%s/%s", temp_dir, basename);
- while (remaining > 0) {
- int to_read = remaining > CHUNK_SIZE ? CHUNK_SIZE : remaining;
- int got = gzread(gz, buf, to_read);
- if (got <= 0) break;
- write(fd, buf, got);
- remaining -= got;
+ int fd = open(out_path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
+ if (fd >= 0) {
+ unsigned char buf[CHUNK_SIZE];
+ long remaining = filesize;
+
+ while (remaining > 0) {
+ int to_read = remaining > CHUNK_SIZE ? CHUNK_SIZE : remaining;
+ int got = gzread(gz, buf, to_read);
+ if (got <= 0) break;
+ write(fd, buf, got);
+ remaining -= got;
+ }
+ close(fd);
+ found_any = 1;
+ printf(" Extracted: %s\n", basename);
+
+ /* Skip padding to 512 boundary */
+ int pad = (512 - (filesize % 512)) % 512;
+ if (pad > 0) {
+ gzread(gz, buf, pad);
+ }
+ } else {
+ /* Skip this file's content */
+ long blocks = (filesize + 511) / 512;
+ for (long i = 0; i < blocks; i++) {
+ gzread(gz, header, 512);
+ }
}
-
- found = 1;
- break;
} else {
/* Skip this file's content (padded to 512 bytes) */
long blocks = (filesize + 511) / 512;
@@ -190,16 +221,14 @@ static char *extract_serve_py(const char *self_path, long tar_offset) {
}
gzclose(gz);
- close(fd);
- if (!found) {
- fprintf(stderr, "Error: serve.py not found in archive\n");
- unlink(temp_path);
- free(temp_path);
+ if (!found_any) {
+ fprintf(stderr, "Error: No neopig/*.py files found in archive\n");
+ free(temp_dir);
return NULL;
}
- return temp_path;
+ return temp_dir;
}
int main(int argc, char *argv[]) {
@@ -219,6 +248,9 @@ int main(int argc, char *argv[]) {
self_path[len] = '\0';
}
+ printf("neopig self-extracting archive\n");
+ printf("==============================\n");
+
/* Read trailer to get tarball offset */
long tar_offset = read_trailer(self_path);
if (tar_offset < 0) {
@@ -227,43 +259,49 @@ int main(int argc, char *argv[]) {
printf("Archive offset: %ld bytes\n", tar_offset);
- /* Extract serve.py */
- char *serve_py_path = extract_serve_py(self_path, tar_offset);
- if (!serve_py_path) {
+ /* Extract neopig directory */
+ printf("Extracting neopig...\n");
+ char *neopig_dir = extract_neopig(self_path, tar_offset);
+ if (!neopig_dir) {
return 1;
}
- printf("Extracted: %s\n", serve_py_path);
+ /* Build path to serp.py */
+ char serp_path[4096];
+ snprintf(serp_path, sizeof(serp_path), "%s/serp.py", neopig_dir);
/* Find python */
const char *python = find_python();
- /* Build command: python3 serve.py */
- printf("Starting: %s %s %s\n", python, serve_py_path, self_path);
+ /* Build command: python3 serp.py */
+ printf("\nStarting server...\n");
+ printf("Command: %s %s %s\n\n", python, serp_path, self_path);
/* Fork and exec */
pid_t pid = fork();
if (pid < 0) {
perror("fork");
- unlink(serve_py_path);
- free(serve_py_path);
+ free(neopig_dir);
return 1;
}
if (pid == 0) {
/* Child - exec python */
- execl(python, "python3", serve_py_path, self_path, NULL);
+ execl(python, "python3", serp_path, self_path, NULL);
perror("execl python3");
_exit(1);
}
- /* Parent - wait for child and cleanup */
+ /* Parent - wait for child */
int status;
waitpid(pid, &status, 0);
- /* Cleanup temp file */
- unlink(serve_py_path);
- free(serve_py_path);
+ /* Cleanup temp directory */
+ printf("\nCleaning up %s...\n", neopig_dir);
+ char rm_cmd[4096];
+ snprintf(rm_cmd, sizeof(rm_cmd), "rm -rf %s", neopig_dir);
+ system(rm_cmd);
+ free(neopig_dir);
return WIFEXITED(status) ? WEXITSTATUS(status) : 1;
}
diff --git a/serp.py b/serp.py
index 3a24318..d4c5129 100644
--- a/serp.py
+++ b/serp.py
@@ -25,7 +25,7 @@ from pathlib import Path
from typing import List, Dict, Any, Optional
from fastapi import FastAPI, Query, HTTPException, BackgroundTasks
-from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
+from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from sqlalchemy import text
@@ -56,15 +56,76 @@ db: Database = None
# Active crawl jobs (in-memory tracking)
ACTIVE_CRAWLS: Dict[int, Dict[str, Any]] = {}
+# Tarball mode - serve directly from tar.gz archive
+import tarfile
+import tempfile
+TAR_FILE: tarfile.TarFile = None
+TAR_MEMBERS: Dict[str, tarfile.TarInfo] = {}
+ARCHIVE_ROOT: str = None # e.g., "example.com-20251230"
+TEMP_DB_PATH: str = None # Extracted database (SQLite needs real file)
+
+
+def read_from_tarball(path: str) -> bytes:
+ """Read a file from the tarball. Path is relative to archive root."""
+ if not TAR_FILE or not ARCHIVE_ROOT:
+ return None
+ full_path = f"{ARCHIVE_ROOT}/{path}"
+ if full_path in TAR_MEMBERS:
+ member = TAR_MEMBERS[full_path]
+ f = TAR_FILE.extractfile(member)
+ if f:
+ return f.read()
+ return None
+
+
+def find_media_in_tarball(md5_hash: str) -> tuple:
+ """Find media file in tarball by hash. Returns (data, extension) or (None, None)."""
+ if not TAR_FILE or not ARCHIVE_ROOT:
+ return None, None
+ prefix = f"{ARCHIVE_ROOT}/media/{md5_hash}"
+ for name, member in TAR_MEMBERS.items():
+ if name.startswith(prefix):
+ f = TAR_FILE.extractfile(member)
+ if f:
+ ext = Path(name).suffix
+ return f.read(), ext
+ return None, None
+
+
+class ArchiveDB:
+ """Simple sync SQLite wrapper for archive.db (FTS5 search only)."""
+ def __init__(self, db_path):
+ import sqlite3
+ self.conn = sqlite3.connect(db_path)
+ self.conn.row_factory = sqlite3.Row
+
+ async def search_pages(self, query, limit=50):
+ cursor = self.conn.execute(
+ "SELECT uri, title, snippet(pages_fts, 2, '', '', '...', 32) as snippet "
+ "FROM pages_fts WHERE pages_fts MATCH ? LIMIT ?",
+ (query, limit)
+ )
+ return [dict(row) for row in cursor.fetchall()]
+
+ async def get_stats(self):
+ cursor = self.conn.execute("SELECT COUNT(*) FROM pages")
+ return {"pages": cursor.fetchone()[0], "media": 0, "screenshots": 0}
+
@app.on_event("startup")
async def startup_event():
"""Initialize database on startup."""
global db
- db = Database(DB_PATH)
- await db.init() # Handles schema + WAL mode
- VAULT_PATH.mkdir(parents=True, exist_ok=True)
- logger.info(f"Vault directory ready: {VAULT_PATH}")
+ if TAR_FILE:
+ # Tarball mode: use simple archive DB
+ db = ArchiveDB(DB_PATH)
+ logger.info(f"Using archive database: {DB_PATH}")
+ else:
+ # Normal mode: use full async database
+ db = Database(DB_PATH)
+ await db.init() # Handles schema + WAL mode
+ VAULT_PATH.mkdir(parents=True, exist_ok=True)
+ logger.info(f"Vault directory ready: {VAULT_PATH}")
class CrawlRequest(BaseModel):
@@ -2612,12 +2673,29 @@ def slugify(text: str, max_len: int = 60) -> str:
@app.get("/media/{md5_hash}")
async def serve_media(md5_hash: str, download: bool = False):
"""
- Serve media file from vault.
+ Serve media file from vault or tarball.
Use ?download=1 for attachment mode with smart filename.
Caddy should be configured to cache these responses.
"""
- # Find file in vault
+ # Tarball mode: serve from tar.gz
+ if TAR_FILE:
+ data, ext = find_media_in_tarball(md5_hash)
+ if data:
+ mime_type, _ = mimetypes.guess_type(f"file{ext}")
+ if not mime_type:
+ mime_type = "application/octet-stream"
+ filename = f"{md5_hash[:12]}{ext}"
+ headers = {
+ "Cache-Control": "public, max-age=31536000, immutable",
+ "X-Content-Hash": md5_hash,
+ }
+ if download:
+ headers["Content-Disposition"] = f'attachment; filename="{filename}"'
+ return Response(content=data, media_type=mime_type, headers=headers)
+ raise HTTPException(status_code=404, detail="Media not found in archive")
+
+ # Filesystem mode: find file in vault
subdir = VAULT_PATH / md5_hash[:2]
if not subdir.exists():
raise HTTPException(status_code=404, detail="Media not found")
@@ -2784,10 +2862,83 @@ async def start_crawl(request: CrawlRequest, background_tasks: BackgroundTasks):
return {"job_ids": job_ids, "status": "running", "count": len(job_ids)}
+def init_tarball_mode(tarball_path: str):
+ """Initialize serving from a tar.gz archive."""
+ global TAR_FILE, TAR_MEMBERS, ARCHIVE_ROOT, DB_PATH, TEMP_DB_PATH
+
+ logger.info(f"Opening archive: {tarball_path}")
+ tarball = Path(tarball_path)
+
+ # Handle .run files with NEOPIG trailer
+ offset = 0
+ if tarball.suffix == '.run' or tarball.stat().st_size > 100000:
+ try:
+ with open(tarball, 'rb') as f:
+ f.seek(-22, 2)
+ trailer = f.read(22)
+ if trailer[:6] == b'NEOPIG':
+ offset = int(trailer[6:22].decode(), 16)
+ logger.info(f"Detected .run format, tarball offset: {offset}")
+ except Exception:
+ pass
+
+ # Open tarball
+ if offset > 0:
+ # Create a wrapper that presents just the tarball portion
+ class OffsetFile:
+ """File wrapper that starts reading from an offset."""
+ def __init__(self, path, offset):
+ self._f = open(path, 'rb')
+ self._offset = offset
+ self._f.seek(offset)
+ def read(self, size=-1):
+ return self._f.read(size)
+ def seek(self, pos, whence=0):
+ if whence == 0: # SEEK_SET
+ return self._f.seek(self._offset + pos)
+ elif whence == 1: # SEEK_CUR
+ return self._f.seek(pos, 1)
+ else: # SEEK_END
+ return self._f.seek(pos, 2)
+ def tell(self):
+ return self._f.tell() - self._offset
+ def close(self):
+ self._f.close()
+
+ TAR_FILE = tarfile.open(fileobj=OffsetFile(tarball, offset), mode='r:gz')
+ else:
+ TAR_FILE = tarfile.open(tarball_path, 'r:gz')
+
+ # Build member lookup
+ for member in TAR_FILE.getmembers():
+ TAR_MEMBERS[member.name] = member
+
+ # Get archive root from first member
+ first = list(TAR_MEMBERS.keys())[0]
+ ARCHIVE_ROOT = first.split('/')[0]
+ logger.info(f"Archive root: {ARCHIVE_ROOT}")
+
+ # Extract database to temp (SQLite needs real file)
+ db_member = f"{ARCHIVE_ROOT}/archive.db"
+ if db_member in TAR_MEMBERS:
+ temp_dir = tempfile.mkdtemp(prefix="neopig_")
+ TEMP_DB_PATH = f"{temp_dir}/archive.db"
+ member = TAR_MEMBERS[db_member]
+ f = TAR_FILE.extractfile(member)
+ if f:
+ with open(TEMP_DB_PATH, 'wb') as out:
+ out.write(f.read())
+ DB_PATH = TEMP_DB_PATH
+ logger.info(f"Extracted database to: {TEMP_DB_PATH}")
+ else:
+ logger.warning("No archive.db found in tarball")
+
+
def main():
global DB_PATH, VAULT_PATH
parser = argparse.ArgumentParser(description="neopig SERP")
+ parser.add_argument("tarball", nargs='?', help="Path to archive.tar.gz or .run file")
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--db", default="data/neopig.db")
@@ -2795,12 +2946,16 @@ def main():
args = parser.parse_args()
- DB_PATH = args.db
- VAULT_PATH = Path(args.vault)
-
- logger.info(f"Starting neopig SERP on {args.host}:{args.port}")
- logger.info(f"Database: {DB_PATH}")
- logger.info(f"Vault: {VAULT_PATH}")
+ # Tarball mode
+ if args.tarball:
+ init_tarball_mode(args.tarball)
+ logger.info(f"Starting neopig SERP (archive mode) on {args.host}:{args.port}")
+ else:
+ DB_PATH = args.db
+ VAULT_PATH = Path(args.vault)
+ logger.info(f"Starting neopig SERP on {args.host}:{args.port}")
+ logger.info(f"Database: {DB_PATH}")
+ logger.info(f"Vault: {VAULT_PATH}")
uvicorn.run(app, host=args.host, port=args.port)