Compare commits

..

26 Commits

Author SHA1 Message Date
tanner f45f60ef31 Login page tweaks 2026-05-19 20:49:39 +00:00
tanner b0f6c1b6f9 fix: Improve mobile responsiveness of admin UI
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-05-19 19:36:15 +00:00
tanner 09994feebc refactor: Update admin page title to Admin 2026-05-19 19:36:10 +00:00
tanner dae793e40c feat: Allow back button to close files modal
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-05-19 19:25:22 +00:00
tanner 3a881bb560 Add SESSION_SECRET env var to .env 2026-05-19 19:23:19 +00:00
tanner 4222763603 feat: Set login session cookie expiry to 365 days
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-05-19 19:10:32 +00:00
tanner e8011e3f68 feat: Redirect to /menu if admin is already logged in on login page
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-05-19 19:07:37 +00:00
tanner 2f18b1ba6b feat: Hardcode 'admin' username for login and autofocus password 2026-05-19 19:07:36 +00:00
tanner bdaa8d9049 Remove references to duplicate checking from readme 2026-05-19 18:56:46 +00:00
tanner afcbc66e7f chore: Remove 'Duplicates' counter from upload status UI
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-05-19 17:07:06 +00:00
tanner 004930f60d feat: Remove duplicate checking functionality
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-05-19 00:36:08 +00:00
tanner 3037d4078c Make file modal buttons same size 2026-05-18 16:20:26 +00:00
tanner ce9a8fe2c4 feat: Extend file search to include filenames
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-05-18 16:17:35 +00:00
tanner d2af59b754 Fix: Improve file serving content-disposition and filename
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-05-18 16:10:26 +00:00
tanner 2523410c84 feat: Add "Download All" button and outside-click close to gallery
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-05-18 16:01:03 +00:00
tanner d00e1dceeb fix: Sanitize filenames and directory paths to prevent XSS
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-05-18 15:55:40 +00:00
tanner ca5131f497 feat: Add admin file viewer with thumbnail generation and gallery
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-05-18 15:45:06 +00:00
tanner 89b7b1bd23 Remove more references to Immich 2026-05-18 15:28:20 +00:00
tanner 6c8e42f1ef README typos 2026-01-22 16:27:59 -07:00
tanner 6090d8f596 Add nginx config 2026-01-22 16:23:14 -07:00
tanner 205d62a634 README + Dockerfile fixes, format config.py 2026-01-22 14:05:38 -07:00
tanner ecc96a3e28 feat: Restrict CORS origin to public_base_url if set
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-22 13:58:09 -07:00
tanner a0f2316d53 Update README instructions and add screenshots 2026-01-22 12:36:36 -07:00
tanner d4159dcd9e Simplify README and install instructions 2026-01-22 11:59:48 -07:00
tanner c340a75eda docs: Update README for local file saving and simplified flow
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-22 11:44:34 -07:00
tanner 4cc360c3ca docs: Update README for local file saving and Telegram notifications
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-22 11:43:43 -07:00
20 changed files with 626 additions and 489 deletions
+13 -8
View File
@@ -8,11 +8,17 @@ TIMEZONE=America/Edmonton
# Public uploader page (optional)
PUBLIC_UPLOAD_PAGE_ENABLED=true
# Local dedupe cache (SQLite)
STATE_DB=./data/state.db
# Login cookie session secret
# Set this to something random if you want login sessions
# to persist between container or app restarts.
#SESSION_SECRET=
# Base URL for generating absolute invite links (recommended for production)
# e.g., PUBLIC_BASE_URL=https://photos.example.com
# Custom DB location
#STATE_DB=
# Base URL for generating absolute invite links
# Recommended for production, also sets CORS headers
# e.g., PUBLIC_BASE_URL=https://upload.example.com
#PUBLIC_BASE_URL=
LOG_LEVEL=INFO
@@ -23,13 +29,12 @@ LOG_LEVEL=INFO
CHUNKED_UPLOADS_ENABLED=true
CHUNK_SIZE_MB=50
# Custom session secrets
# By default, a random one is generated
#SESSION_SECRET=SET-A-STRONG-RANDOM-VALUE
# Optional Telegram bot for upload alerts and control
# create a bot using @BotFather then copy the API key here
# get your account's ID by messaging https://t.me/userinfobot
# Leave these blank to disable
# Example:
# TELEGRAM_BOT_API_KEY=1234567890:ABCDefghIjKlmnOPQRsT-UVWXyzABCdefGH
# TELEGRAM_BOT_OWNER_ID=12345678
TELEGRAM_BOT_API_KEY=
TELEGRAM_BOT_OWNER_ID=
+4 -10
View File
@@ -1,28 +1,22 @@
# syntax=docker/dockerfile:1.7
FROM python:3.11-slim
WORKDIR /image_drop
WORKDIR /file_drop
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Install Python deps
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \
&& pip install --no-cache-dir python-multipart
RUN pip install --no-cache-dir -r requirements.txt
# Copy app code
COPY . /image_drop
# Data dir for SQLite (state.db)
#RUN mkdir -p /data
#VOLUME ["/data"]
COPY . /file_drop
# Defaults (can be overridden via compose env)
ENV HOST=0.0.0.0 \
PORT=8080 \
STATE_DB=/image_drop/data/state.db
STATE_DB=/file_drop/data/state.db
EXPOSE 8080
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 Simon Adams
Copyright (c) 2025 Simon Adams, Tanner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+150 -239
View File
@@ -1,281 +1,192 @@
# Immich Drop Uploader
# File Drop Uploader
A tiny web app for collecting photos/videos into your **Immich** server.
Admin users log in to create public invite links; invite links are always public-by-URL. A public uploader page is optional and disabled by default.
A self-hosted web app for uploading files and media and saving them to the filesystem on your server.
Useful for letting people upload vacation photos, etc. just by sending them a link.
![Immich Drop Uploader Dark Mode UI](./screenshot.png)
Admin user can create invite links with optional limits and password protection. A public uploader page is optional and enabled by default.
[View Screenshots](screenshots.md)
## Features
- **Invite Links:** public-by-URL links for uploads; one-time or multi-use
- **Manage Links:** search/sort, enable/disable, delete, edit name/expiry
- **Row Actions:** icon-only actions with tooltips (Open, Copy, Details, QR, Save)
- **Passwords (optional):** protect invites with a password gate
- **Albums (optional):** upload into a specific album (auto-create supported)
- **Duplicate Prevention:** local SHA1 cache (+ optional Immich bulk-check)
- **Progress Queue:** WebSocket updates; retry failed items
- **Chunked Uploads (optional):** large-file support with configurable chunk size
- **Privacy-first:** never lists server media; session-local uploads only
- **Mobile + Dark Mode:** responsive UI, safe-area padding, persistent theme
- **Local Saving:** All uploaded files are saved to the server's local filesystem.
- **Drag and Drop:** Upload multiple files and folders by dragging them onto the page.
- **Invite Links:** Create sharable links for uploads; one-time or multi-use.
- **Manage Links:** Search/sort, enable/disable, delete, edit name/expiry.
- **Passwords (optional):** Protect invite links with a password.
- **Albums:** Upload into a specific folder (auto-create supported). Preserves client-side folder structure on upload.
- **Telegram Notifications (optional):** Get notified via Telegram when upload batches are complete.
- **Progress Queue:** WebSocket updates; see upload progress in real-time.
- **Chunked Uploads (optional):** Large-file support with configurable chunk size.
- **Mobile + Dark Mode:** Responsive UI, safe-area padding, persistent theme.
---
## Table of contents
- [Quick start](#quick-start)
- [New Features](#new-features)
- [Chunked Uploads](#chunked-uploads)
- [Architecture](#architecture)
- [Folder structure](#folder-structure)
- [Requirements](#requirements)
- [Configuration (.env)](#configuration-env)
- [How it works](#how-it-works)
- [Mobile notes](#mobile-notes)
- [Troubleshooting](#troubleshooting)
- [Security notes](#security-notes)
- [Development](#development)
- [License](#license)
---
## Quick start
You can run without a `.env` file by putting all settings in `docker-compose.yml` (recommended for deploys).
Use a `.env` file only for local development.
### docker-compose.yml (deploy without .env)
Clone the repo.
Copy `.env.example` to `.env` and edit.
### Docker Compose
Create `docker-compose.yml` and edit:
```yaml
version: "3.9"
services:
immich-drop:
image: ghcr.io/nasogaa/immich-drop:latest
pull_policy: always
container_name: immich-drop
file-drop:
build: .
container_name: file-drop
restart: unless-stopped
# Configure all settings here (no .env required)
environment:
# Immich connection (must include /api)
IMMICH_BASE_URL: https://immich.example.com/api
IMMICH_API_KEY: ${IMMICH_API_KEY}
# Optional behavior
IMMICH_ALBUM_NAME: dead-drop
PUBLIC_UPLOAD_PAGE_ENABLED: "false" # keep disabled by default
PUBLIC_BASE_URL: https://drop.example.com
# Large files: chunked uploads (bypass 100MB proxy limits)
CHUNKED_UPLOADS_ENABLED: "false" # enable chunked uploads
CHUNK_SIZE_MB: "95" # per-chunk size (MB)
# App internals
SESSION_SECRET: ${SESSION_SECRET}
# Expose the app on the host
ports:
- 8080:8080
# Persist local dedupe cache (state.db) across restarts
- "8080:8080"
env_file:
- .env
volumes:
- immich_drop_data:/data
# Simple healthcheck
- ./data:/file_drop/data
- /mnt/example/file-drop:/file_drop/data/uploads
healthcheck:
test: ["CMD-SHELL", "python - <<'PY'\nimport os,urllib.request,sys; url=f\"http://127.0.0.1:{os.getenv('PORT','8080')}/\";\ntry: urllib.request.urlopen(url, timeout=3); sys.exit(0)\nexcept Exception: sys.exit(1)\nPY"]
test: ["CMD-SHELL", "python - <<'PY'\nimport urllib.request,sys\ntry:\n urllib.request.urlopen('http://localhost:8080/').read(); sys.exit(0)\nexcept Exception:\n sys.exit(1)\nPY"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
immich_drop_data:
```
```
### CLI
Start the service:
```bash
docker compose pull
docker compose up -d
```
---
## What's New
### v0.5.0 Manage Links overhaul
- In-panel bulk actions footer (Delete/Enable/Disable stay inside the box)
- Per-row icon actions with tooltips; Save button lights up only on changes
- Per-row QR modal; Details modal close fixed and reliable
- Auto-refresh after creating a link; new row is highlighted and scrolled into view
- Expiry save fix: stores end-of-day to avoid off-by-one date issues
Roadmap highlight
- Wed like to add a per-user UI and remove reliance on a fixed API key by allowing users to authenticate and provide their own Immich API tokens. This is not in scope for the initial versions but aligns with future direction.
- The frontend automatically switches to chunked mode only for files larger than the configured chunk size.
### 📱 DeviceFlexible HMI (New)
- Fully responsive UI with improved spacing and wrapping for small and large screens.
- Mobilesafe file picker and a sticky bottom “Choose files” bar on phones.
- Safearea padding for devices with notches; refined dark/light theme behavior.
- Desktop keeps the dropzone clickable; touch devices avoid accidental doubleopen.
### ♻️ Reliability & Quality of Life (New)
- Retry button to reattempt any failed upload without reselecting the file.
- Progress and status updates are more resilient to late/reordered WebSocket events.
- Invites can be created without an album, keeping uploads unassigned when preferred.
### Last 8 Days Highlights
- Added chunked uploads with configurable chunk size.
- Added optional passwords for invite links with inUI unlock prompt.
- Responsive HMI overhaul: mobilesafe picker, sticky mobile action bar, safearea support.
- Retry for failed uploads and improved progress handling.
- Support for invites with no album association.
### 🌙 Dark Mode
- Automatic or manual toggle; persisted preference
### 📁 Album Integration
- Auto-create + assign album if configured; optional invites without album
---
## Chunked Uploads
- Enable chunked uploads by setting `CHUNKED_UPLOADS_ENABLED=true`.
- Configure chunk size with `CHUNK_SIZE_MB` (default: `95`). The client only uses chunked mode for files larger than this.
- Intended to bypass upstream limits (e.g., 100MB) while preserving duplicate checks, EXIF timestamps, album add, and peritem progress via WebSocket.
---
## Architecture
- **Frontend:** static HTML/JS (Tailwind). Drag & drop or "Choose files", queue UI with progress and status chips.
- **Backend:** FastAPI + Uvicorn.
- Proxies uploads to Immich `/assets`
- Computes SHA1 and checks a local SQLite cache (`state.db`)
- Optional Immich dedupe via `/assets/bulk-upload-check`
- WebSocket `/ws` pushes peritem progress to the current browser session only
- **Persistence:** local SQLite (`state.db`) prevents reuploads across sessions/runs.
---
## Folder structure
```
immich_drop/
├─ app/ # FastAPI application (Python package)
│ ├─ app.py # ASGI app (uvicorn entry: app.app:app)
│ └─ config.py # Settings loader (reads .env/env)
├─ frontend/ # Static UI (served at /static)
│ ├─ index.html # Public uploader (optional)
│ ├─ login.html # Login page (admin)
│ ├─ menu.html # Admin menu (create invites)
│ ├─ invite.html # Public invite upload page
│ ├─ app.js # Uploader logic (drop/queue/upload/ws)
│ ├─ header.js # Shared header (theme + ping + banner)
│ └─ favicon.png # Tab icon (optional)
├─ data/ # Local dev data dir (bind to /data in Docker)
├─ main.py # Thin dev entrypoint (python main.py)
├─ requirements.txt # Python dependencies
├─ Dockerfile
├─ docker-compose.yml
├─ .env.example # Example dev environment (optional)
├─ README.md
└─ screenshot.png # UI screenshot for README
$ sudo docker compose up --build -d
```
---
Set up nginx / a reverse proxy and point it to the web app.
## Requirements
Make sure it allows WebSocket connections through, for example:
- **Python** 3.11
- An **Immich** server + **API key**
```
server {
root /var/www/html;
index index.html index.htm;
server_name upload.example.com;
---
# Local dev quickstart
listen 80;
client_max_body_size 100M;
location / {
proxy_pass http://127.0.0.1:8080/;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# websockets
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_redirect off;
}
}
```
Then restart nginx and set up HTTPS:
```
$ sudo service nginx restart
$ sudo certbot --nginx
```
### Config Changes
If you change the `.env` file config, simply run:
```bash
$ sudo docker compose down
$ sudo docker compose up --build -d
```
### Updating
To update the code:
```bash
$ sudo docker compose down
$ git pull --rebase
$ sudo docker compose up --build -d
```
### Telegram Bot
An optional Telegram bot can send you notifications when uploads complete. This is useful to see if random people are filling your disk up.
To create a bot, message @BotFather on Telegram. Come up with a name and username. Botfather will then send you an API key you can paste into the `.env` config directly.
Next you'll need to find your own Telegram user ID. You can message @userinfobot and it will reply with your ID. Beware of impersonator bots (they have the name "userinfobot" but a different username).
Then message the bot you just created "/start" so that it's able to interact with you.
### Chunked Uploads
- Chunked uploads are enabled by default. Uses setting `CHUNKED_UPLOADS_ENABLED=true`.
- Configure chunk size with `CHUNK_SIZE_MB` (default: `50`). The client only uses chunked mode for files larger than this.
- Intended to bypass upstream proxy limits (e.g., 100MB) while preserving EXIF timestamps, album add, and peritem progress via WebSocket.
## Development
### Architecture
- **Frontend:** static HTML/JS (Tailwind). Drag & drop or "Choose files", queue UI with progress and status chips.
- **Backend:** FastAPI + Uvicorn.
- Saves uploaded files to the local filesystem.
- WebSocket `/ws` pushes peritem progress to the current browser session only.
- **Persistence:** A local SQLite database (`state.db`) prevents reuploads across sessions. Uploaded files are stored in `/data/uploads`.
### Setup
Requires Python 3.11+.
Create a venv, activate it, and install:
```text
$ virtualenv -p python3 env
$ source env/bin/activate
(env) $ pip install -r requirements.txt
```
```text
(env) $ cp .env.example .env
(env) $ vim .env
```
Run with live reload:
```bash
python main.py
```
The backend contains docstrings so you can generate docs later if desired.
### How it works
---
1. **Queue** - Files selected in the browser are queued; each gets a client-side ID.
3. **Save** - The file is saved to the local filesystem under `./data/uploads`.
4. **Album** - If an album is specified via an invite link, or a folder name is provided on the public page, the file is saved into a corresponding subdirectory. Client-side folder structure is also preserved.
5. **Progress** - Backend streams progress via WebSocket to the same session.
6. **Privacy** - The UI shows only the current session's items. It does not provide a way to browse saved files.
## Dev Configuration (.env)
### Security notes
```ini
# Server (dev only)
HOST=0.0.0.0
PORT=8080
# Immich connection (include /api)
IMMICH_BASE_URL=http://REPLACE_ME:2283/api
IMMICH_API_KEY=ADD-YOUR-API-KEY # needs: asset.upload; for albums also: album.create, album.read, albumAsset.create
MAX_CONCURRENT=3
# Public uploader page (optional) — disabled by default
PUBLIC_UPLOAD_PAGE_ENABLED=TRUE
# Album (optional): auto-add uploads from public uploader to this album (creates if needed)
IMMICH_ALBUM_NAME=dead-drop
# Local dedupe cache (SQLite)
STATE_DB=./data/state.db
# Base URL for generating absolute invite links (recommended for production)
# e.g., PUBLIC_BASE_URL=https://photos.example.com
#PUBLIC_BASE_URL=
# Session and security
SESSION_SECRET=SET-A-STRONG-RANDOM-VALUE
LOG_LEVEL=DEBUG
# Chunked uploads (optional)
CHUNKED_UPLOADS_ENABLED=true
CHUNK_SIZE_MB=95
```
You can keep a checkedin `/.env.example` with the keys above for onboarding.
---
## How it works
1. **Queue** Files selected in the browser are queued; each gets a clientside ID.
2. **Dedupe (local)** Server computes **SHA1** and checks `state.db`. If seen, marks as **duplicate**.
3. **Dedupe (server)** Attempts Immich `/assets/bulk-upload-check`; if Immich reports duplicate, marks accordingly.
4. **Upload** Multipart POST to `${IMMICH_BASE_URL}/assets` with:
- `assetData`, `deviceAssetId`, `deviceId`,
- `fileCreatedAt`, `fileModifiedAt` (from EXIF when available; else `lastModified`),
- `isFavorite=false`, `filename`, and header `x-immich-checksum`.
5. **Album** If `IMMICH_ALBUM_NAME` is configured, adds the uploaded asset to the album (creates album if it doesn't exist).
6. **Progress** Backend streams progress via WebSocket to the same session.
7. **Privacy** UI shows only the current session's items. It never lists server media.
---
## Security notes
- The menu and invite creation are behind login. Logout clears the session.
- Invite links are public by URL; share only with intended recipients.
- The default uploader page at `/` is disabled unless `PUBLIC_UPLOAD_PAGE_ENABLED=true`.
- The Immich API key remains **serverside**; the browser never sees it.
- No browsing of uploaded media; only ephemeral session state is shown.
- The menu and invite creation are behind login. Logout clears the session.
- Invite links are public by URL; share only with intended recipients.
- The public uploader page at `/` is enabled unless disabled with `PUBLIC_UPLOAD_PAGE_ENABLED=false`.
- No browsing of uploaded media; only ephemeral session state is shown.
- Run behind HTTPS with a reverse proxy and restrict CORS to your domain(s).
## Usage flow
- Admin: Login → Menu → Create invite link (optionally onetime / expiry / album) → Share link or QR.
- Guest: Open invite link → Drop files → Upload progress and results shown.
- Optional: Enable public uploader and set `IMMICH_ALBUM_NAME` for a default landing page.
---
## License
MIT.
This program is free and open-source software licensed under the MIT License. Please see the `LICENSE` file for details.
That means you have the right to study, change, and distribute the software and source code to anyone and for any purpose. You deserve these rights.
## Acknowledgements
This project was forked from "Immich Drop Uploader" by Simon Adams: https://github.com/Nasogaa/immich-drop
+214 -127
View File
@@ -1,9 +1,8 @@
"""
Immich Drop Uploader Backend (FastAPI, simplified)
File Drop Uploader Backend (FastAPI, simplified)
----------------------------------------------------
- Serves static frontend (no settings UI)
- Uploads to Immich using values from .env ONLY
- Duplicate checks (local SHA-1 DB + optional Immich bulk-check)
- Uploads to file system
- WebSocket progress per session
- Ephemeral "Connected" banner via /api/ping
"""
@@ -18,6 +17,9 @@ import hashlib
import os
import sqlite3
import binascii
import base64
import mimetypes
import zipfile
import pytz
from datetime import datetime
from typing import Dict, List, Optional, Tuple
@@ -26,12 +28,12 @@ import math
import logging
import httpx
from fastapi import FastAPI, UploadFile, WebSocket, WebSocketDisconnect, Request, Form
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse, Response
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse, Response, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.websockets import WebSocketState
from starlette.middleware.sessions import SessionMiddleware
from PIL import Image, ExifTags
from PIL import Image
try:
import qrcode
except Exception:
@@ -40,17 +42,21 @@ except Exception:
from app.config import Settings, load_settings
# ---- App & static ----
app = FastAPI(title="Immich Drop Uploader (Python)")
app = FastAPI(title="File Drop Uploader (Python)")
# Global settings (read-only at runtime)
SETTINGS: Settings = load_settings()
# CORS
origins = ["*"]
if SETTINGS.public_base_url:
origins = [SETTINGS.public_base_url.strip().rstrip('/')]
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Global settings (read-only at runtime)
SETTINGS: Settings = load_settings()
_public_uploads_enabled_runtime = SETTINGS.public_upload_page_enabled
@@ -60,7 +66,7 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
logger = logging.getLogger("immich_drop")
# Cookie-based session for short-lived auth token storage (no persistence)
app.add_middleware(SessionMiddleware, secret_key=SETTINGS.session_secret, same_site="lax")
app.add_middleware(SessionMiddleware, secret_key=SETTINGS.session_secret, same_site="lax", max_age=365 * 24 * 60 * 60)
FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend")
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
@@ -73,61 +79,6 @@ except Exception:
pass
# ---------- DB (local dedupe cache) ----------
def db_init() -> None:
"""Create the local SQLite table used for duplicate checks (idempotent)."""
conn = sqlite3.connect(SETTINGS.state_db)
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS uploads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
checksum TEXT UNIQUE,
filename TEXT,
size INTEGER,
device_asset_id TEXT,
immich_asset_id TEXT,
created_at TEXT,
inserted_at TEXT DEFAULT CURRENT_TIMESTAMP
);
"""
)
conn.commit()
conn.close()
def db_lookup_checksum(checksum: str) -> Optional[dict]:
"""Return a record for the given checksum if seen before (None if not)."""
conn = sqlite3.connect(SETTINGS.state_db)
cur = conn.cursor()
cur.execute("SELECT checksum, immich_asset_id FROM uploads WHERE checksum = ?", (checksum,))
row = cur.fetchone()
conn.close()
if row:
return {"checksum": row[0], "immich_asset_id": row[1]}
return None
def db_lookup_device_asset(device_asset_id: str) -> bool:
"""True if a deviceAssetId has been uploaded by this service previously."""
conn = sqlite3.connect(SETTINGS.state_db)
cur = conn.cursor()
cur.execute("SELECT 1 FROM uploads WHERE device_asset_id = ?", (device_asset_id,))
row = cur.fetchone()
conn.close()
return bool(row)
def db_insert_upload(checksum: str, filename: str, size: int, device_asset_id: str, immich_asset_id: Optional[str], created_at: str) -> None:
"""Insert a newly-uploaded asset into the local cache (ignore on duplicates)."""
conn = sqlite3.connect(SETTINGS.state_db)
cur = conn.cursor()
cur.execute(
"INSERT OR IGNORE INTO uploads (checksum, filename, size, device_asset_id, immich_asset_id, created_at) VALUES (?,?,?,?,?,?)",
(checksum, filename, size, device_asset_id, immich_asset_id, created_at)
)
conn.commit()
conn.close()
db_init()
# ---------- WebSocket hub ----------
@@ -404,33 +355,6 @@ def get_safe_subpath(relative_path: Optional[str]) -> str:
return os.path.join(*safe_parts)
def read_exif_datetimes(file_bytes: bytes):
"""
Extract EXIF DateTimeOriginal / ModifyDate values when possible.
Returns (created, modified) as datetime or (None, None) on failure.
"""
created = modified = None
try:
with Image.open(io.BytesIO(file_bytes)) as im:
exif = getattr(im, "_getexif", lambda: None)() or {}
if exif:
tags = {ExifTags.TAGS.get(k, k): v for k, v in exif.items()}
dt_original = tags.get("DateTimeOriginal") or tags.get("CreateDate")
dt_modified = tags.get("ModifyDate") or dt_original
def parse_dt(s: str):
try:
return datetime.strptime(s, "%Y:%m:%d %H:%M:%S")
except Exception:
return None, None
if isinstance(dt_original, str):
created = parse_dt(dt_original)
if isinstance(dt_modified, str):
modified = parse_dt(dt_modified)
except Exception:
pass
return created, modified
def slugify(value: Optional[str]) -> str:
"""
Normalizes string, converts to lowercase, removes non-alpha characters,
@@ -599,26 +523,11 @@ async def api_upload(
fingerprint: Optional[str] = Form(None),
public_folder_name: Optional[str] = Form(None),
):
"""Receive a file, check duplicates, forward to Immich; stream progress via WS."""
"""Receive a file and stream progress via WS."""
raw = await file.read()
size = len(raw)
checksum = sha1_hex(raw)
exif_created, exif_modified = read_exif_datetimes(raw)
created_at = exif_created or (datetime.fromtimestamp(last_modified / 1000) if last_modified else datetime.utcnow())
modified_at = exif_modified or created_at
created_iso = created_at.isoformat()
modified_iso = modified_at.isoformat()
device_asset_id = f"{file.filename}-{last_modified or 0}-{size}"
if db_lookup_checksum(checksum):
await send_progress(session_id, item_id, "duplicate", 100, "Duplicate (by checksum - local cache)")
return JSONResponse({"status": "duplicate", "id": None}, status_code=200)
if db_lookup_device_asset(device_asset_id):
await send_progress(session_id, item_id, "duplicate", 100, "Already uploaded from this device (local cache)")
return JSONResponse({"status": "duplicate", "id": None}, status_code=200)
# Invite token validation (if provided)
target_album_name: Optional[str] = None
@@ -727,7 +636,6 @@ async def api_upload(
i += 1
with open(save_path, "wb") as f:
f.write(raw)
db_insert_upload(checksum, file.filename, size, device_asset_id, None, created_iso)
await add_file_to_batch(file.filename, size, display_album_name, bool(invite_token))
await reset_telegram_debounce()
@@ -876,7 +784,7 @@ async def api_upload_chunk(
@app.post("/api/upload/chunk/complete")
async def api_upload_chunk_complete(request: Request) -> JSONResponse:
"""Assemble all parts and run the regular upload flow to Immich."""
"""Assemble all parts and run the regular upload flow."""
try:
data = await request.json()
except Exception:
@@ -948,21 +856,6 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse:
file_like_name = name
file_size = len(raw)
checksum = sha1_hex(raw)
exif_created, exif_modified = read_exif_datetimes(raw)
created_at = exif_created or (datetime.fromtimestamp(last_modified / 1000) if last_modified else datetime.utcnow())
modified_at = exif_modified or created_at
created_iso = created_at.isoformat()
modified_iso = modified_at.isoformat()
device_asset_id = f"{file_like_name}-{last_modified or 0}-{file_size}"
# Local duplicate checks
if db_lookup_checksum(checksum):
await send_progress(session_id_local, item_id_local, "duplicate", 100, "Duplicate (by checksum - local cache)")
return JSONResponse({"status": "duplicate", "id": None}, status_code=200)
if db_lookup_device_asset(device_asset_id):
await send_progress(session_id_local, item_id_local, "duplicate", 100, "Already uploaded from this device (local cache)")
return JSONResponse({"status": "duplicate", "id": None}, status_code=200)
# Invite validation/gating mirrors api_upload
target_album_name: Optional[str] = None
@@ -1063,7 +956,6 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse:
i += 1
with open(save_path, "wb") as f:
f.write(raw)
db_insert_upload(checksum, file_like_name, file_size, device_asset_id, None, created_iso)
await add_file_to_batch(file_like_name, file_size, display_album_name, bool(invite_token))
msg = f"Saved to {display_album_name}/{os.path.basename(save_path)}"
@@ -1224,6 +1116,201 @@ async def api_albums_create(request: Request) -> JSONResponse:
logger.exception("Create album directory failed: %s", e)
return JSONResponse({"error": "create_album_failed"}, status_code=500)
# ---------- File Viewer APIs ----------
UPLOAD_ROOT = "./data/uploads"
def _is_safe_path(base, path):
"""Check that resolved path is under base path."""
try:
# After resolving symlinks, the path must be inside the base.
return os.path.realpath(path).startswith(os.path.realpath(base))
except Exception:
return False
@app.get("/api/files/dirs")
async def api_files_dirs(request: Request):
if not request.session.get("accessToken"):
return JSONResponse({"error": "unauthorized"}, status_code=401)
q = (request.query_params.get("q") or "").strip().lower()
sort = (request.query_params.get("sort") or "-modified").strip()
dirs = []
try:
os.makedirs(UPLOAD_ROOT, exist_ok=True)
for root, _, filenames in os.walk(UPLOAD_ROOT):
if not filenames:
continue
dir_path = root
rel_path = os.path.relpath(dir_path, UPLOAD_ROOT)
if rel_path == '.':
continue
if q:
# Search in directory path and filenames
path_match = q in rel_path.lower()
file_match = any(q in fn.lower() for fn in filenames)
if not path_match and not file_match:
continue
try:
total_size = sum(os.path.getsize(os.path.join(dir_path, f)) for f in filenames)
file_mtimes = [os.path.getmtime(os.path.join(dir_path, f)) for f in filenames]
last_modified = max(file_mtimes) if file_mtimes else os.path.getmtime(dir_path)
dirs.append({
"path": rel_path,
"path_b64": base64.urlsafe_b64encode(rel_path.encode()).decode(),
"name": os.path.basename(dir_path),
"file_count": len(filenames),
"total_size": total_size,
"modified": last_modified
})
except Exception:
continue
except Exception as e:
logger.exception("Failed to list directories: %s", e)
return JSONResponse({"error": "server_error"}, status_code=500)
# Sort
reverse = sort.startswith('-')
sort_field = sort.lstrip('-+')
if sort_field not in ('name', 'file_count', 'total_size', 'modified'):
sort_field = 'modified'
dirs.sort(key=lambda x: x.get(sort_field, 0), reverse=reverse)
for d in dirs:
d['modified'] = datetime.fromtimestamp(d['modified']).isoformat()
return JSONResponse({"items": dirs})
@app.get("/api/files/list/{path_b64}")
async def api_files_list(path_b64: str, request: Request):
if not request.session.get("accessToken"):
return JSONResponse({"error": "unauthorized"}, status_code=401)
try:
rel_path = base64.urlsafe_b64decode(path_b64).decode()
dir_path = os.path.join(UPLOAD_ROOT, rel_path)
except Exception:
return JSONResponse({"error": "invalid_path"}, status_code=400)
if not _is_safe_path(UPLOAD_ROOT, dir_path) or not os.path.isdir(dir_path):
return JSONResponse({"error": "not_found"}, status_code=404)
files = []
try:
for filename in sorted(os.listdir(dir_path)):
file_path = os.path.join(dir_path, filename)
if os.path.isfile(file_path):
mime_type, _ = mimetypes.guess_type(filename)
is_image = bool(mime_type and mime_type.startswith('image/'))
rel_file_path = os.path.relpath(file_path, UPLOAD_ROOT)
files.append({
"name": filename,
"path_b64": base64.urlsafe_b64encode(rel_file_path.encode()).decode(),
"size": os.path.getsize(file_path),
"modified": datetime.fromtimestamp(os.path.getmtime(file_path)).isoformat(),
"is_image": is_image,
})
except Exception as e:
logger.exception("Failed to list files: %s", e)
return JSONResponse({"error": "server_error"}, status_code=500)
return JSONResponse({"items": files})
@app.get("/api/files/thumb/{path_b64}")
async def api_files_thumb(path_b64: str, request: Request):
if not request.session.get("accessToken"):
return Response(status_code=401)
try:
rel_path = base64.urlsafe_b64decode(path_b64).decode()
file_path = os.path.join(UPLOAD_ROOT, rel_path)
except Exception:
return Response(status_code=400)
if not _is_safe_path(UPLOAD_ROOT, file_path) or not os.path.isfile(file_path):
return Response(status_code=404)
mime_type, _ = mimetypes.guess_type(os.path.basename(file_path))
if not mime_type or not mime_type.startswith('image/'):
return Response(status_code=404)
try:
with Image.open(file_path) as img:
img.thumbnail((256, 256))
buf = io.BytesIO()
# Convert to RGB to avoid issues with saving palette-based images (e.g. some GIFs) as JPEG
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
img.save(buf, format="JPEG", quality=85)
buf.seek(0)
return Response(content=buf.read(), media_type="image/jpeg")
except Exception as e:
logger.warning("Failed to generate thumbnail for %s: %s", file_path, e)
return Response(status_code=500)
@app.get("/api/files/full/{path_b64}")
async def api_files_full(path_b64: str, request: Request):
if not request.session.get("accessToken"):
return Response(status_code=401)
try:
rel_path = base64.urlsafe_b64decode(path_b64).decode()
file_path = os.path.join(UPLOAD_ROOT, rel_path)
except Exception:
return Response(status_code=400)
if not _is_safe_path(UPLOAD_ROOT, file_path) or not os.path.isfile(file_path):
return Response(status_code=404)
mime_type, _ = mimetypes.guess_type(file_path)
disposition = "attachment"
if mime_type and (mime_type.startswith(('image/', 'video/', 'audio/', 'text/')) or mime_type == 'application/pdf'):
disposition = "inline"
return FileResponse(file_path, content_disposition_type=disposition, filename=os.path.basename(rel_path))
@app.get("/api/files/zip/{path_b64}")
async def api_files_zip(path_b64: str, request: Request):
if not request.session.get("accessToken"):
return Response(status_code=401)
try:
rel_path = base64.urlsafe_b64decode(path_b64).decode()
dir_path = os.path.join(UPLOAD_ROOT, rel_path)
except Exception:
return Response(status_code=400)
if not _is_safe_path(UPLOAD_ROOT, dir_path) or not os.path.isdir(dir_path):
return Response(status_code=404)
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
for filename in sorted(os.listdir(dir_path)):
file_path = os.path.join(dir_path, filename)
if os.path.isfile(file_path):
zip_file.write(file_path, filename)
zip_buffer.seek(0)
zip_filename = f"{os.path.basename(rel_path) or 'download'}.zip"
return StreamingResponse(
iter([zip_buffer.getvalue()]),
media_type="application/zip",
headers={"Content-Disposition": f"attachment; filename=\"{zip_filename}\""}
)
# ---------- Invites (one-time/expiring links) ----------
def ensure_invites_table() -> None:
+18 -10
View File
@@ -17,13 +17,13 @@ class Settings:
"""App settings loaded from environment variables (.env)."""
admin_password: str
max_concurrent: int
public_upload_page_enabled: bool = False
public_upload_page_enabled: bool = True
public_base_url: str = ""
state_db: str = ""
session_secret: str = ""
log_level: str = "INFO"
chunked_uploads_enabled: bool = False
chunk_size_mb: int = 95
chunked_uploads_enabled: bool = True
chunk_size_mb: int = 50
timezone: str = "UTC"
telegram_bot_api_key: str = ""
telegram_bot_owner_id: str = ""
@@ -48,7 +48,8 @@ def load_settings() -> Settings:
load_dotenv()
except Exception:
pass
admin_password = os.getenv("ADMIN_PASSWORD", "admin") # Default for convenience, should be changed
admin_password = os.getenv("ADMIN_PASSWORD", "test123") # Default for convenience, should be changed
if not admin_password.startswith("pbkdf2_sha256-"):
print("="*60)
print("WARNING: ADMIN_PASSWORD is in plaintext.")
@@ -57,27 +58,34 @@ def load_settings() -> Settings:
if hashed_pw:
print(f"ADMIN_PASSWORD={hashed_pw}")
print("="*60)
# Safe defaults: disable public uploader and invites unless explicitly enabled
def as_bool(v: str, default: bool = False) -> bool:
if v is None:
return default
return str(v).strip().lower() in {"1","true","yes","on"}
public_upload = as_bool(os.getenv("PUBLIC_UPLOAD_PAGE_ENABLED", "false"), False)
public_upload = as_bool(os.getenv("PUBLIC_UPLOAD_PAGE_ENABLED", "false"), True)
try:
maxc = int(os.getenv("MAX_CONCURRENT", "3"))
except ValueError:
maxc = 3
state_db = os.getenv("STATE_DB", "/data/state.db")
state_db = os.getenv("STATE_DB", "./data/state.db")
session_secret = os.getenv("SESSION_SECRET") or secrets.token_hex(32)
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
chunked_uploads_enabled = as_bool(os.getenv("CHUNKED_UPLOADS_ENABLED", "false"), False)
chunked_uploads_enabled = as_bool(os.getenv("CHUNKED_UPLOADS_ENABLED", "false"), True)
try:
chunk_size_mb = int(os.getenv("CHUNK_SIZE_MB", "95"))
chunk_size_mb = int(os.getenv("CHUNK_SIZE_MB", "50"))
except ValueError:
chunk_size_mb = 95
chunk_size_mb = 50
timezone = os.getenv("TIMEZONE", "UTC")
telegram_bot_api_key = os.getenv("TELEGRAM_BOT_API_KEY", "")
telegram_bot_owner_id = os.getenv("TELEGRAM_BOT_OWNER_ID", "")
return Settings(
admin_password=admin_password,
max_concurrent=maxc,
-17
View File
@@ -1,17 +0,0 @@
services:
image-drop:
build: .
container_name: image-drop
restart: unless-stopped
ports:
- "8080:8080"
env_file:
- .env
volumes:
- ./data:/image_drop/data
healthcheck:
test: ["CMD-SHELL", "python - <<'PY'\nimport urllib.request,sys\ntry:\n urllib.request.urlopen('http://localhost:8080/').read(); sys.exit(0)\nexcept Exception:\n sys.exit(1)\nPY"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
-33
View File
@@ -1,33 +0,0 @@
services:
immich-drop:
build: .
container_name: immich-drop
restart: unless-stopped
ports:
- "8080:8080"
environment:
#immich drop server ip
IMMICH_BASE_URL: https://immich.example.com/api
IMMICH_API_KEY: ${IMMICH_API_KEY}
PUBLIC_BASE_URL: https://drop.example.com
#Enable/Disable Public upload page to folder
PUBLIC_UPLOAD_PAGE_ENABLED: false
IMMICH_ALBUM_NAME: dead-drop
# Chunked uploads to bypass 100MB limits (e.g., Cloudflare Tunnel)
CHUNKED_UPLOADS_ENABLED: true
CHUNK_SIZE_MB: 95
volumes:
- immich_drop_data:/data
healthcheck:
test: ["CMD-SHELL", "python - <<'PY'\nimport urllib.request,sys\ntry:\n urllib.request.urlopen('http://localhost:8080/').read(); sys.exit(0)\nexcept Exception:\n sys.exit(1)\nPY"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
immich_drop_data:
+9 -14
View File
@@ -37,8 +37,8 @@ let socket;
let allCompleteBannerShown = false;
// Status precedence: never regress (e.g., uploading -> done shouldn't go back to uploading)
const STATUS_ORDER = { queued: 0, checking: 1, uploading: 2, duplicate: 3, done: 3, error: 4 };
const FINAL_STATES = new Set(['done','duplicate','error']);
const STATUS_ORDER = { queued: 0, checking: 1, uploading: 2, done: 3, error: 4 };
const FINAL_STATES = new Set(['done','error']);
// --- Dark mode ---
function initDarkMode() {
@@ -119,7 +119,7 @@ function render(){
</div>
</div>
<div class="mt-3 h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
<div class="h-full ${it.status==='done'?'bg-green-500':it.status==='duplicate'?'bg-amber-500':it.status==='error'?'bg-red-500':'bg-blue-500'}" style="width:${Math.max(it.progress, (it.status==='done'||it.status==='duplicate'||it.status==='error')?100:it.progress)}%"></div>
<div class="h-full ${it.status==='done'?'bg-green-500':it.status==='error'?'bg-red-500':'bg-blue-500'}" style="width:${Math.max(it.progress, (it.status==='done'||it.status==='error')?100:it.progress)}%"></div>
</div>
<div class="mt-2 text-sm text-gray-600 dark:text-gray-400">
${it.status==='uploading' ? `Uploading… ${it.progress}%` : it.status.charAt(0).toUpperCase()+it.status.slice(1)}
@@ -144,18 +144,16 @@ function render(){
});
} catch {}
const c = {queued:0,uploading:0,done:0,dup:0,err:0};
const c = {queued:0,uploading:0,done:0,err:0};
for(const it of items){
if(['queued','checking'].includes(it.status)) c.queued++;
if(it.status==='uploading') c.uploading++;
if(it.status==='done') c.done++;
if(it.status==='duplicate') c.dup++;
if(it.status==='error') c.err++;
}
document.getElementById('countQueued').textContent=c.queued;
document.getElementById('countUploading').textContent=c.uploading;
document.getElementById('countDone').textContent=c.done;
document.getElementById('countDup').textContent=c.dup;
document.getElementById('countErr').textContent=c.err;
if (!allCompleteBannerShown && items.length > 0) {
@@ -252,12 +250,10 @@ async function uploadWhole(next){
render();
} else if (res.ok) {
const statusText = (body && body.status) ? String(body.status) : '';
const isDuplicate = /duplicate/i.test(statusText);
next.status = isDuplicate ? 'duplicate' : 'done';
next.message = statusText || (isDuplicate ? 'Duplicate' : 'Uploaded');
next.status = 'done';
next.message = statusText || 'Uploaded';
next.progress = 100;
render();
try { if (isDuplicate) showBanner(`Duplicate: ${next.name}`, 'warn'); } catch {}
}
}
@@ -323,9 +319,8 @@ async function uploadChunked(next){
render();
} else if (rc.ok) {
const statusText = (body && body.status) ? String(body.status) : '';
const isDuplicate = /duplicate/i.test(statusText);
next.status = isDuplicate ? 'duplicate' : 'done';
next.message = statusText || (isDuplicate ? 'Duplicate' : 'Uploaded');
next.status = 'done';
next.message = statusText || 'Uploaded';
next.progress = 100;
render();
}
@@ -491,7 +486,7 @@ if (btnMobilePick) {
// --- Clear buttons ---
btnClearFinished.onclick = ()=>{
items = items.filter(i => !['done','duplicate'].includes(i.status));
items = items.filter(i => !['done'].includes(i.status));
render();
// also tell server to refresh album cache so a renamed album triggers a new one
fetch('/api/album/reset', { method: 'POST' }).catch(()=>{});
-1
View File
@@ -62,7 +62,6 @@
<span class="whitespace-nowrap">Queued/Processing: <b id="countQueued">0</b></span>
<span class="whitespace-nowrap">Uploading: <b id="countUploading">0</b></span>
<span class="whitespace-nowrap">Done: <b id="countDone">0</b></span>
<span class="whitespace-nowrap">Duplicates: <b id="countDup">0</b></span>
<span class="whitespace-nowrap">Errors: <b id="countErr">0</b></span>
</div>
</div>
-1
View File
@@ -87,7 +87,6 @@
<span class="whitespace-nowrap">Queued/Processing: <b id="countQueued">0</b></span>
<span class="whitespace-nowrap">Uploading: <b id="countUploading">0</b></span>
<span class="whitespace-nowrap">Done: <b id="countDone">0</b></span>
<span class="whitespace-nowrap">Duplicates: <b id="countDup">0</b></span>
<span class="whitespace-nowrap">Errors: <b id="countErr">0</b></span>
</div>
</div>
+10 -8
View File
@@ -14,7 +14,7 @@
<div class="mx-auto max-w-2xl p-6 space-y-6">
<div id="topBanner" class="hidden rounded-2xl border border-green-200 bg-green-50 p-3 text-green-700 text-center dark:bg-green-900 dark:border-green-700 dark:text-green-300"></div>
<header class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Login to Image Drop</h1>
<h1 class="text-2xl font-semibold">Login</h1>
<div class="flex items-center gap-2">
<a id="linkPublicUploader" href="/" class="hidden rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Public uploader</a>
<button id="btnTheme" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600" title="Toggle dark mode">
@@ -25,7 +25,6 @@
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg>
</button>
<button id="btnPing" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Test connection</button>
<span id="pingStatus" class="ml-2 text-sm text-gray-500 dark:text-gray-400"></span>
</div>
</header>
@@ -33,13 +32,9 @@
<h2 class="text-lg font-medium mb-2">Enter your credentials</h2>
<div id="msg" class="hidden mb-3 rounded-lg border p-2 text-sm"></div>
<form id="loginForm" class="space-y-3">
<div>
<label class="block text-sm mb-1">Username</label>
<input id="email" type="username" value="admin" required class="w-full rounded-lg border px-3 py-2 bg-white dark:bg-gray-800 dark:border-gray-700" />
</div>
<div>
<label class="block text-sm mb-1">Password</label>
<input id="password" type="password" required class="w-full rounded-lg border px-3 py-2 bg-white dark:bg-gray-800 dark:border-gray-700" />
<input id="password" type="password" required autofocus class="w-full rounded-lg border px-3 py-2 bg-white dark:bg-gray-800 dark:border-gray-700" />
</div>
<div class="flex items-center justify-between">
<button class="rounded-xl bg-black text-white px-4 py-2 dark:bg-white dark:text-black" type="submit">Login</button>
@@ -50,6 +45,13 @@
</div>
<script src="/static/header.js"></script>
<script>
(async function() {
try {
const r = await fetch('/api/albums');
if (r.ok) location.href = '/menu';
} catch (e) {}
})();
const form = document.getElementById('loginForm');
const msg = document.getElementById('msg');
function show(kind, text){
@@ -59,7 +61,7 @@
}
form.onsubmit = async (e)=>{
e.preventDefault();
const email = document.getElementById('email').value.trim();
const email = 'admin';
const password = document.getElementById('password').value;
try{
const r = await fetch('/api/login', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({email, password}) });
+186 -20
View File
@@ -13,12 +13,12 @@
<body class="min-h-screen bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<div class="mx-auto max-w-2xl p-6 space-y-6">
<div id="topBanner" class="hidden rounded-2xl border border-green-200 bg-green-50 p-3 text-green-700 text-center dark:bg-green-900 dark:border-green-700 dark:text-green-300"></div>
<header class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Create Upload Link</h1>
<div class="flex items-center gap-2">
<a id="linkPublicUploader" href="/" class="hidden rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Public uploader</a>
<a href="/logout" id="btnLogout" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Logout</a>
<button id="btnTheme" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600" title="Toggle dark mode">
<header class="flex items-center justify-between flex-wrap gap-y-2">
<h1 class="text-2xl font-semibold">Admin</h1>
<div class="flex items-center gap-2 flex-wrap">
<a id="linkPublicUploader" href="/" class="hidden rounded-xl border px-3 py-2 text-sm dark:border-gray-600">Public uploader</a>
<a href="/logout" id="btnLogout" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600">Logout</a>
<button id="btnTheme" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600" title="Toggle dark mode">
<svg id="iconLight" class="w-4 h-4 hidden" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414z" clip-rule="evenodd"/>
</svg>
@@ -26,8 +26,6 @@
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg>
</button>
<button id="btnPing" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Test connection</button>
<span id="pingStatus" class="ml-2 text-sm text-gray-500 dark:text-gray-400"></span>
</div>
</header>
@@ -80,7 +78,7 @@
<section class="rounded-2xl border bg-white dark:bg-gray-800 dark:border-gray-700 p-4 space-y-3">
<div class="flex items-center justify-between gap-2 flex-wrap">
<h2 class="text-lg font-medium">Manage Links</h2>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 flex-wrap justify-end">
<input id="searchQ" placeholder="Search" class="rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700"/>
<select id="sortSel" class="rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700">
<option value="-created">Newest</option>
@@ -98,11 +96,11 @@
<thead>
<tr class="text-left border-b dark:border-gray-700">
<th class="py-2"><input id="chkAll" type="checkbox"/></th>
<th class="py-2" style="width: 45%;">Name</th>
<th class="py-2" style="width: 18%;">Status</th>
<th class="py-2">Uses</th>
<th class="py-2">Expires</th>
<th class="py-2">Folder</th>
<th class="py-2">Name</th>
<th class="py-2">Status</th>
<th class="py-2 hidden md:table-cell">Uses</th>
<th class="py-2 hidden md:table-cell">Expires</th>
<th class="py-2 hidden md:table-cell">Folder</th>
<th class="py-2">Actions</th>
</tr>
</thead>
@@ -117,8 +115,46 @@
</div>
</section>
<section class="text-xs text-gray-500">
Admin link page
<!-- Manage Files -->
<section class="rounded-2xl border bg-white dark:bg-gray-800 dark:border-gray-700 p-4 space-y-3">
<div class="flex items-center justify-between gap-2 flex-wrap">
<h2 class="text-lg font-medium">Manage Files</h2>
<div class="flex items-center gap-2 flex-wrap justify-end">
<input id="filesSearchQ" placeholder="Search" class="rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700"/>
<select id="filesSortSel" class="rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700">
<option value="-modified">Newest</option>
<option value="modified">Oldest</option>
<option value="name">Name AZ</option>
<option value="-name">Name ZA</option>
<option value="-file_count">File count desc</option>
<option value="file_count">File count asc</option>
<option value="-total_size">Size desc</option>
<option value="total_size">Size asc</option>
</select>
<button id="btnFilesRefresh" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600">Refresh</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left border-b dark:border-gray-700">
<th class="py-2" style="width: 50%;">Folder</th>
<th class="py-2">Files</th>
<th class="py-2">Size</th>
<th class="py-2">Modified</th>
</tr>
</thead>
<tbody id="filesTBody"></tbody>
</table>
</div>
</section>
<section class="text-xs text-gray-500 space-y-2">
<p>Admin link page</p>
<div class="flex items-center gap-2">
<button id="btnPing" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600">Test connection</button>
<span id="pingStatus" class="text-sm text-gray-500 dark:text-gray-400"></span>
</div>
</section>
</div>
@@ -261,15 +297,15 @@
return `
<tr class="border-b dark:border-gray-800" data-token="${row.token}">
<td class="py-2"><input class="chkRow" type="checkbox" data-token="${row.token}"/></td>
<td class="py-2" style="width:45%;">
<td class="py-2">
<input class="inName w-full rounded border px-2 py-1 bg-white dark:bg-gray-900 dark:border-gray-700" data-token="${row.token}" value="${(row.name||'').replaceAll('"','&quot;')}" title="${(row.name||'').replaceAll('"','&quot;')}"/>
</td>
<td class="py-2">${status}</td>
<td class="py-2">${uses}</td>
<td class="py-2">
<td class="py-2 hidden md:table-cell">${uses}</td>
<td class="py-2 hidden md:table-cell">
<input class="inExpires w-36 rounded border px-2 py-1 bg-white dark:bg-gray-900 dark:border-gray-700" type="date" data-token="${row.token}" value="${row.expiresAt? new Date(row.expiresAt).toISOString().slice(0,10):''}" title="${row.expiresAt? new Date(row.expiresAt).toLocaleString():''}"/>
</td>
<td class="py-2">${row.albumName || '—'}</td>
<td class="py-2 hidden md:table-cell">${row.albumName || '—'}</td>
<td class="py-2">
<div class="flex items-center gap-1 whitespace-nowrap">
<button class="btnDetails group relative rounded-xl border px-2 py-1 text-xs dark:border-gray-600 inline-flex items-center" data-token="${row.token}" aria-label="Show details">
@@ -441,6 +477,136 @@
// Initial load
loadInvites();
// --- Manage Files UI logic ---
const filesSearchQ = document.getElementById('filesSearchQ');
const filesSortSel = document.getElementById('filesSortSel');
const btnFilesRefresh = document.getElementById('btnFilesRefresh');
const filesTBody = document.getElementById('filesTBody');
let DIRS = [];
function escapeHtml(text) {
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function humanSize(bytes){
if (!bytes) return '0 B';
const k = 1024, sizes = ['B','KB','MB','GB','TB'];
const i = Math.floor(Math.log(bytes)/Math.log(k));
return (bytes/Math.pow(k,i)).toFixed(1)+' '+sizes[i];
}
function fmtDayMonthForFiles(iso){ try{ const d = new Date(iso); return d.toLocaleDateString(undefined,{ day:'2-digit', month:'short' }); }catch{return '—';} }
async function loadDirs(){
const params = new URLSearchParams();
const q = (filesSearchQ.value||'').trim(); if (q) params.set('q', q);
const sort = (filesSortSel.value||'').trim(); if (sort) params.set('sort', sort);
try{
const r = await fetch('/api/files/dirs?'+params.toString());
const j = await r.json();
DIRS = (j && j.items) ? j.items : [];
} catch { DIRS = []; }
renderDirs();
}
function renderDirs(){
filesTBody.innerHTML = DIRS.map(dir => {
const modified = `<span title="${new Date(dir.modified).toLocaleString()}">${fmtDayMonthForFiles(dir.modified)}</span>`;
return `
<tr class="border-b dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900/50 cursor-pointer" data-path-b64="${dir.path_b64}">
<td class="py-2">${escapeHtml(dir.path)}</td>
<td class="py-2">${dir.file_count}</td>
<td class="py-2">${humanSize(dir.total_size)}</td>
<td class="py-2">${modified}</td>
</tr>`;
}).join('');
filesTBody.querySelectorAll('tr').forEach(tr => {
tr.onclick = () => showGallery(tr.dataset.pathB64);
});
}
async function showGallery(path_b64) {
if (!path_b64) return;
let files = [];
try {
const r = await fetch(`/api/files/list/${path_b64}`);
if (!r.ok) throw new Error(await r.text());
const j = await r.json();
files = j.items || [];
} catch (e) {
showResult('err', 'Failed to load files: ' + String(e));
return;
}
const html = `
<div id="gallery-modal" class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div class="max-w-6xl w-full h-[90vh] rounded-2xl bg-white dark:bg-gray-900 border dark:border-gray-700 p-4 flex flex-col">
<div class="flex items-center justify-between mb-2">
<div class="text-lg font-medium">Files</div>
<div class="flex items-center gap-2">
<a href="/api/files/zip/${path_b64}" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600">Download All</a>
<button class="dlgClose rounded-xl border px-3 py-2 text-sm dark:border-gray-600">Close</button>
</div>
</div>
<div class="flex-1 overflow-auto">
${files.length ? `<div class="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-4">` + files.map(it => {
const thumbUrl = it.is_image ? `/api/files/thumb/${it.path_b64}` : '';
const fullUrl = `/api/files/full/${it.path_b64}`;
const safeName = escapeHtml(it.name);
return `<a href="${fullUrl}" target="_blank" title="${safeName}\n${humanSize(it.size)}" class="group relative aspect-square rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
${it.is_image ?
`<img src="${thumbUrl}" class="w-full h-full object-cover" loading="lazy"/>` :
`<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>`
}
<div class="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs p-1 truncate pointer-events-none">${safeName}</div>
</a>`
}).join('') + `</div>` : '<div class="text-sm text-gray-500">No files in this directory.</div>'}
</div>
</div>
</div>`;
const wrap = document.createElement('div');
wrap.innerHTML = html;
const dlg = wrap.firstElementChild;
document.body.appendChild(dlg);
document.body.style.overflow = 'hidden';
const close = () => {
document.body.style.overflow = '';
if (location.hash === '#gallery') {
history.back();
} else {
try { dlg.remove(); } catch {}
}
};
dlg.onclick = (e) => { if (e.target === e.currentTarget) close(); };
dlg.querySelectorAll('.dlgClose').forEach(b => b.onclick = close);
if (location.hash !== '#gallery') {
history.pushState(null, '', '#gallery');
}
}
btnFilesRefresh.onclick = loadDirs;
filesSearchQ.oninput = () => { clearTimeout(filesSearchQ._t); filesSearchQ._t = setTimeout(loadDirs, 300); };
filesSortSel.onchange = loadDirs;
window.addEventListener('popstate', () => {
const galleryModal = document.querySelector('#gallery-modal');
if (galleryModal) {
document.body.style.overflow = '';
galleryModal.remove();
}
});
loadDirs();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

+21
View File
@@ -0,0 +1,21 @@
# Screenshots
## Public upload page
![](media/public-uploader.png)
## After uploading files
![](media/after-uploading.png)
## Admin page
![](media/admin-page.png)
## Invite link (with password)
![](media/invite-page.png)
## Telegram bot
![](media/telegram-bot.png)