Compare commits

..

No commits in common. "b57ca2c47ab0df49c8b2cfbc4cf6803d44e90c26" and "e11a45ab7093e45939957681c93479e280adce78" have entirely different histories.

6 changed files with 82 additions and 308 deletions

14
.vscode/launch.json vendored
View File

@ -1,14 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Current File with Arguments",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"args": "${command:pickArgs}"
}
]
}

View File

@ -27,7 +27,7 @@ python main.py /path/to/archive.zip
No test framework is currently configured. To add tests, install pytest:
```bash
uv add pytest
pip install pytest
```
Run all tests:
@ -47,7 +47,7 @@ No linting tool is currently configured. Recommended tools:
```bash
# Install ruff (fast linter/formatter)
uv add ruff
pip install ruff
# Run linting
ruff check .
@ -59,11 +59,17 @@ ruff check --fix .
ruff format .
```
Alternatively, using pylint:
```bash
pip install pylint
pylint main.py
```
### Type Checking
```bash
# Install mypy
uv add mypy
pip install mypy
# Run type checking
mypy main.py

View File

@ -1,104 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>File Server</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; background: #1a1a1a; }
#container {
position: relative;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
img { max-width: 100%; max-height: 100%; cursor: pointer; }
.chevron {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-size: 48px;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
padding: 20px;
user-select: none;
transition: color 0.2s;
text-decoration: none;
}
.chevron:hover { color: rgba(255, 255, 255, 0.9); }
.chevron.left { left: 10px; }
.chevron.right { right: 10px; }
</style>
</head>
<body>
<div id="container">
<a href="#" id="img-link"><img id="img" alt="Random image"></a>
<a href="#" class="chevron left" id="prev-btn">&#8249;</a>
<a href="#" class="chevron right" id="next-btn">&#8250;</a>
</div>
<script>
let currentData = null;
function loadImageSrc(hash) {
document.getElementById('img').src = '/' + hash + '/data';
document.getElementById('img-link').href = '#';
history.replaceState(null, '', '#' + hash);
}
async function loadInfo(hash) {
const response = await fetch('/' + hash);
if (!response.ok) {
loadRandom();
return;
}
currentData = await response.json();
loadImageSrc(currentData.img);
document.getElementById('img').title = currentData.filename || '';
document.getElementById('prev-btn').href = '#' + currentData.previous;
document.getElementById('next-btn').href = '#' + currentData.next;
}
async function loadRandom() {
const response = await fetch('/random?' + Date.now());
const data = await response.json();
await loadInfo(data.img);
}
window.addEventListener('hashchange', function() {
const hash = window.location.hash.slice(1);
if (hash) {
loadInfo(hash);
} else {
loadRandom();
}
});
window.addEventListener('hashchange', function() {
const hash = window.location.hash.slice(1);
if (hash) loadInfo(hash);
});
document.addEventListener('keydown', function(e) {
e.preventDefault();
if (e.code === 'Space') {
window.location.hash = '';
} else if (e.code === 'ArrowLeft') {
document.getElementById('prev-btn').click();
} else if (e.code === 'ArrowRight') {
document.getElementById('next-btn').click();
} else {
return;
}
});
const hash = window.location.hash.slice(1);
if (hash) {
loadInfo(hash);
} else {
loadRandom();
}
</script>
</body>
</html>

197
main.py
View File

@ -1,32 +1,31 @@
import argparse
import hashlib
import mimetypes
from io import BytesIO
import os
import random
import sys
import hashlib
import argparse
import secrets
import zipfile
from glob import glob
from io import BytesIO
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, StreamingResponse
import mimetypes
app = FastAPI()
file_mapping = {}
indexers = []
indexer = None
class FileIndexer:
def __init__(self, path: str, salt: str | None = None):
def __init__(self, path: str):
self.path = Path(path)
self._salt = salt
self._file_mapping = self._index()
self.file_mapping = {}
self._salt = None
self._index()
@property
def salt(self) -> str:
"""Generate a random salt for hashing"""
if self._salt is None:
if not self._salt:
self._salt = secrets.token_hex(16)
return self._salt
@ -34,184 +33,132 @@ class FileIndexer:
"""Generate a salted hash of the file path"""
return hashlib.sha256((filepath + self.salt).encode()).hexdigest()
def _index(self) -> dict[str, str]:
"""Index all files in the directory"""
mapping = {}
def _index(self):
"""Index all files in the zip file"""
for root, _, files in os.walk(self.path):
for file in files:
filepath = os.path.join(root, file)
# Generate hash for the file path
file_hash = self._hash_path(filepath)
mapping[file_hash] = filepath
return mapping
# Store mapping
self.file_mapping[file_hash] = filepath
def get_file_by_hash(self, file_hash: str):
"""Get file content by hash"""
if file_hash not in self._file_mapping:
if file_hash not in self.file_mapping:
return None
file_path = self._file_mapping[file_hash]
with open(file_path, "rb") as f:
file_path = self.file_mapping[file_hash]
with open(file_path, 'rb') as f:
yield from f
def get_filename_by_hash(self, file_hash: str) -> str | None:
def get_filename_by_hash(self, file_hash: str) -> str:
"""Get filename by hash"""
if file_hash not in self._file_mapping:
if file_hash not in self.file_mapping:
return None
return self._file_mapping[file_hash]
return self.file_mapping[file_hash]
class ZipFileIndexer(FileIndexer):
def _index(self) -> dict[str, str]:
def _index(self):
"""Index all files in the zip file"""
mapping = {}
with zipfile.ZipFile(self.path, "r") as zip_file:
for file_info in zip_file.infolist():
if not file_info.is_dir():
file_hash = self._hash_path(file_info.filename)
mapping[file_hash] = file_info.filename
return mapping
with zipfile.ZipFile(self.path, 'r') as zip_file:
self.file_mapping = {
self._hash_path(file_info.filename): file_info
for file_info in zip_file.infolist()
if not file_info.is_dir()
}
def get_file_by_hash(self, file_hash: str):
"""Get file content by hash"""
if file_hash not in self._file_mapping:
if file_hash not in self.file_mapping:
return None
filename = self._file_mapping[file_hash]
file_info = self.file_mapping[file_hash]
with zipfile.ZipFile(self.path, "r") as zip_file:
yield from BytesIO(zip_file.read(filename))
with zipfile.ZipFile(self.path, 'r') as zip_file:
yield from BytesIO(zip_file.read(file_info.filename))
def get_filename_by_hash(self, file_hash: str) -> str | None:
def get_filename_by_hash(self, file_hash: str) -> str:
"""Get filename by hash"""
if file_hash not in self._file_mapping:
if file_hash not in self.file_mapping:
return None
return self._file_mapping[file_hash]
return self.file_mapping[file_hash].filename
INDEXER_MAP = {".zip": ZipFileIndexer}
INDEXER_MAP = {
".zip": ZipFileIndexer
}
def initialize_server(args: argparse.Namespace):
"""Initialize the server with directory or glob indexing"""
global file_mapping, indexers
"""Initialize the server with directory indexing"""
global indexer
src_path = Path(args.source)
shared_salt = args.salt
if shared_salt is None:
shared_salt = secrets.token_hex(16)
if not src_path.exists():
raise SystemExit(f"Source path {src_path} does not exist")
if src_path.is_dir():
indexer = FileIndexer(str(src_path), shared_salt)
indexers.append(indexer)
file_mapping.update(indexer._file_mapping)
indexer = FileIndexer(src_path)
else:
pattern = args.source
matching_files = glob(pattern)
if not matching_files:
raise SystemExit(f"No files match pattern {pattern}")
for file_path in matching_files:
file_ext = Path(file_path).suffix
if file_ext in INDEXER_MAP:
indexer = INDEXER_MAP[file_ext](file_path, shared_salt)
indexers.append(indexer)
file_mapping.update(indexer._file_mapping)
print(f"Indexed {len(file_mapping)} files from {len(indexers)} source(s)")
indexer = INDEXER_MAP[src_path.suffix](src_path)
print(f"Indexed {len(indexer.file_mapping)} files")
@app.get("/")
async def root():
"""Serve the Frontend app"""
return FileResponse("frontend.html")
@app.get("/random")
async def get_random_file():
"""Get random file hashes from the mapping"""
if not file_mapping:
"""Serve a random file from the mapping"""
if not indexer.file_mapping:
raise HTTPException(status_code=404, detail="No files indexed")
keys = list(file_mapping.keys())
random_idx = random.randint(0, len(keys) - 1)
current = keys[random_idx]
next_hash = keys[(random_idx + 1) % len(keys)]
prev_hash = keys[random_idx - 1] if random_idx > 0 else keys[-1]
return {"img": current, "next": next_hash, "previous": prev_hash}
random_hash = random.choice(list(indexer.file_mapping.keys()))
def _find_indexer_for_hash(file_hash: str):
"""Find the indexer that contains the file with the given hash"""
for idx in indexers:
if file_hash in idx._file_mapping:
return idx
return None
@app.get("/{file_hash}/data")
async def get_file_data(file_hash: str):
"""Serve a specific file by its hash"""
if file_hash not in file_mapping:
raise HTTPException(status_code=404, detail="File not found")
indexer = _find_indexer_for_hash(file_hash)
if not indexer:
raise HTTPException(status_code=404, detail="File not found")
filename = indexer.get_filename_by_hash(file_hash)
content_type, _ = mimetypes.guess_type(filename or "")
filename = indexer.get_filename_by_hash(random_hash)
content_type, _ = mimetypes.guess_type(filename)
if not content_type:
content_type = "application/octet-stream"
return StreamingResponse(
indexer.get_file_by_hash(file_hash),
buffer = indexer.get_file_by_hash(random_hash)
response = StreamingResponse(
buffer,
media_type=content_type,
headers={
"Content-Disposition": f"inline; filename={os.path.basename(filename or '')}",
},
"Content-Disposition": f"inline; filename={os.path.basename(filename)}",
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
)
return response
@app.get("/{file_hash}")
async def get_file_info(file_hash: str):
"""Get file info by hash"""
if file_hash not in file_mapping:
async def get_file_by_hash(file_hash: str):
"""Serve a specific file by its hash"""
if file_hash not in indexer.file_mapping:
raise HTTPException(status_code=404, detail="File not found")
keys = list(file_mapping.keys())
idx = keys.index(file_hash)
next_hash = keys[(idx + 1) % len(keys)]
prev_hash = keys[idx - 1] if idx > 0 else keys[-1]
indexer = _find_indexer_for_hash(file_hash)
if not indexer:
raise HTTPException(status_code=404, detail="File not found")
filename = indexer.get_filename_by_hash(file_hash)
return {"img": file_hash, "next": next_hash, "previous": prev_hash, "filename": filename}
file_path, content_type, buffer = indexer.get_file_by_hash(file_hash)
return StreamingResponse(
buffer,
media_type=content_type,
headers={
"Content-Disposition": f"inline; filename={os.path.basename(file_path)}",
}
)
# Optional: Add a health check endpoint
@app.get("/health")
async def health_check():
return {"status": "healthy", "file_count": len(file_mapping)}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run the file server")
parser.add_argument(
"source",
type=str,
help="Path to directory, ZIP archive, or glob pattern (e.g., *.zip, path/to/zips/*.zip)",
)
parser.add_argument("source", type=str, help="Path to directory or ZIP archive")
parser.add_argument("--host", type=str, default="0.0.0.0", help="Host to bind to")
parser.add_argument("--port", type=int, default=8000, help="Port to bind to")
parser.add_argument("--salt", type=str, default=None, help="Salt for hashing file paths")
args = parser.parse_args()
initialize_server(args)
import uvicorn
uvicorn.run(app, host=args.host, port=args.port)

View File

@ -8,24 +8,3 @@ dependencies = [
"fastapi>=0.128.0",
"uvicorn>=0.40.0",
]
[project.optional-dependencies]
dev = [
"ruff>=0.15.5",
]
[tool.ruff]
target-version = "py313"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM"]
ignore = []
[tool.ruff.format]
quote-style = "double"
[dependency-groups]
dev = [
"ruff>=0.15.5",
]

42
uv.lock generated
View File

@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.13"
[[package]]
@ -77,26 +77,11 @@ dependencies = [
{ name = "uvicorn" },
]
[package.optional-dependencies]
dev = [
{ name = "ruff" },
]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.128.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.5" },
{ name = "uvicorn", specifier = ">=0.40.0" },
]
provides-extras = ["dev"]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.15.5" }]
[[package]]
name = "h11"
@ -184,31 +169,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
]
[[package]]
name = "ruff"
version = "0.15.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" },
{ url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" },
{ url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" },
{ url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" },
{ url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" },
{ url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" },
{ url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" },
{ url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" },
{ url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" },
{ url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" },
{ url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" },
{ url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" },
{ url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" },
{ url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" },
{ url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" },
]
[[package]]
name = "starlette"
version = "0.50.0"