Compare commits

...

35 Commits

Author SHA1 Message Date
6c8e42f1ef README typos 2026-01-22 16:27:59 -07:00
6090d8f596 Add nginx config 2026-01-22 16:23:14 -07:00
205d62a634 README + Dockerfile fixes, format config.py 2026-01-22 14:05:38 -07:00
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
a0f2316d53 Update README instructions and add screenshots 2026-01-22 12:36:36 -07:00
d4159dcd9e Simplify README and install instructions 2026-01-22 11:59:48 -07:00
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
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
f7cce5ceec feat: Add upload completion hint and increase notification debounce timer
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-22 09:46:01 -07:00
bc1cff21c5 fix: Prevent 'duplicate' status from triggering success banner 2026-01-22 09:45:59 -07:00
d48d51bdc3 feat: Display 'All uploads complete' banner on finish
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-22 09:36:12 -07:00
099d2ec6e9 Remove docker-compose uid gid setting 2026-01-21 13:52:19 -07:00
0dc7fa8f9e Add example Telegram bot settings 2026-01-21 13:50:27 -07:00
5efd4788b4 chore: Disable verbose httpx request logging
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-21 13:36:12 -07:00
506d658073 feat: Display date-based directory name for public uploads
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-21 12:30:38 -07:00
675080ae71 feat: Include destination and source in upload notifications
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-21 12:22:14 -07:00
e51bd24db9 feat: Suggest /help command in new file notification 2026-01-21 12:22:11 -07:00
d77c1a1d1a feat: Add Telegram commands to control public uploads and show help
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-21 12:12:51 -07:00
3c7dd1c0e7 fix: Use Markdown for Telegram messages 2026-01-21 12:12:49 -07:00
e4aae22835 feat: Add markdown parsing to Telegram messages
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-21 11:59:14 -07:00
3cefce9cfc feat: Enhance batch notification formatting and reduce delay 2026-01-21 11:59:12 -07:00
6322163b10 feat: Add Telegram bot notification for batch upload completion
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-21 11:49:48 -07:00
9c70e47232 feat: Integrate Telegram bot with polling for owner commands
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-21 11:41:13 -07:00
ccaf5869bf fix: Remove green success banners for uploads and invites
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-20 22:31:51 -07:00
233c96dcf8 feat: Remove 'Choose a folder' button from upload UI
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-20 22:28:24 -07:00
2c4969ae21 style: Increase vertical gap in mobile choose controls 2026-01-20 22:28:21 -07:00
a3881b8e03 feat: Add folder upload and enhance dropzone UI on invite page
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-20 22:23:37 -07:00
e163e4dd45 fix: Add vertical spacing to buttons on mobile
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-20 22:17:43 -07:00
95a25796f9 style: Clarify drag and drop prompt for multiple items 2026-01-20 22:17:41 -07:00
fb8567a4a9 feat: Add multiple file/folder upload and enhanced drag-and-drop support
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-20 22:11:53 -07:00
cc95608364 feat: Add directory upload support with path preservation
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-20 22:05:32 -07:00
5749597408 fix: Set container user to match host for file ownership
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-20 21:58:33 -07:00
ffb45d2013 Mode frontend adjustments 2026-01-06 17:23:47 -07:00
2bf06b94a8 fix: Toggle summary visibility at start of render
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-06 17:05:39 -07:00
a86c0f4bac feat: Hide queue summary until items are added
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-06 17:00:50 -07:00
18 changed files with 553 additions and 364 deletions

View File

@@ -9,10 +9,11 @@ TIMEZONE=America/Edmonton
PUBLIC_UPLOAD_PAGE_ENABLED=true PUBLIC_UPLOAD_PAGE_ENABLED=true
# Local dedupe cache (SQLite) # Local dedupe cache (SQLite)
STATE_DB=./data/state.db #STATE_DB=./data/state.db
# Base URL for generating absolute invite links (recommended for production) # Base URL for generating absolute invite links
# e.g., PUBLIC_BASE_URL=https://photos.example.com # Recommended for production, also sets CORS headers
# e.g., PUBLIC_BASE_URL=https://upload.example.com
#PUBLIC_BASE_URL= #PUBLIC_BASE_URL=
LOG_LEVEL=INFO LOG_LEVEL=INFO
@@ -23,6 +24,12 @@ LOG_LEVEL=INFO
CHUNKED_UPLOADS_ENABLED=true CHUNKED_UPLOADS_ENABLED=true
CHUNK_SIZE_MB=50 CHUNK_SIZE_MB=50
# Custom session secrets # Optional Telegram bot for upload alerts and control
# By default, a random one is generated # create a bot using @BotFather then copy the API key here
#SESSION_SECRET=SET-A-STRONG-RANDOM-VALUE # 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=

View File

@@ -1,28 +1,22 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
FROM python:3.11-slim FROM python:3.11-slim
WORKDIR /image_drop WORKDIR /file_drop
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1
# Install Python deps # Install Python deps
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \ RUN pip install --no-cache-dir -r requirements.txt
&& pip install --no-cache-dir python-multipart
# Copy app code # Copy app code
COPY . /image_drop COPY . /file_drop
# Data dir for SQLite (state.db)
#RUN mkdir -p /data
#VOLUME ["/data"]
# Defaults (can be overridden via compose env) # Defaults (can be overridden via compose env)
ENV HOST=0.0.0.0 \ ENV HOST=0.0.0.0 \
PORT=8080 \ PORT=8080 \
STATE_DB=/image_drop/data/state.db STATE_DB=/file_drop/data/state.db
EXPOSE 8080 EXPOSE 8080

View File

@@ -1,6 +1,6 @@
MIT License 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 Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

392
README.md
View File

@@ -1,281 +1,195 @@
# Immich Drop Uploader # File Drop Uploader
A tiny web app for collecting photos/videos into your **Immich** server. A self-hosted web app for uploading files and media and saving them to the filesystem on your 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. 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 ## Features
- **Invite Links:** public-by-URL links for uploads; one-time or multi-use - **Local Saving:** All uploaded files are saved to the server's local filesystem.
- **Manage Links:** search/sort, enable/disable, delete, edit name/expiry - **Drag and Drop:** Upload multiple files and folders by dragging them onto the page.
- **Row Actions:** icon-only actions with tooltips (Open, Copy, Details, QR, Save) - **Invite Links:** Create sharable links for uploads; one-time or multi-use.
- **Passwords (optional):** protect invites with a password gate - **Manage Links:** Search/sort, enable/disable, delete, edit name/expiry.
- **Albums (optional):** upload into a specific album (auto-create supported) - **Passwords (optional):** Protect invite links with a password.
- **Duplicate Prevention:** local SHA1 cache (+ optional Immich bulk-check) - **Albums:** Upload into a specific folder (auto-create supported). Preserves client-side folder structure on upload.
- **Progress Queue:** WebSocket updates; retry failed items - **Duplicate Prevention:** Local SHA1 cache prevents re-uploading the same file.
- **Chunked Uploads (optional):** large-file support with configurable chunk size - **Telegram Notifications (optional):** Get notified via Telegram when upload batches are complete.
- **Privacy-first:** never lists server media; session-local uploads only - **Progress Queue:** WebSocket updates; see upload progress in real-time.
- **Mobile + Dark Mode:** responsive UI, safe-area padding, persistent theme - **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 ## 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 ```yaml
version: "3.9"
services: services:
immich-drop: file-drop:
image: ghcr.io/nasogaa/immich-drop:latest build: .
pull_policy: always container_name: file-drop
container_name: immich-drop
restart: unless-stopped 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: ports:
- 8080:8080 - "8080:8080"
env_file:
# Persist local dedupe cache (state.db) across restarts - .env
volumes: volumes:
- immich_drop_data:/data - ./data:/file_drop/data
- /mnt/example/file-drop:/file_drop/data/uploads
# Simple healthcheck
healthcheck: 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 interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 10s start_period: 10s
volumes:
immich_drop_data:
``` ```
``` Start the service:
### CLI
```bash ```bash
docker compose pull $ sudo docker compose up --build -d
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
``` ```
--- 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;
--- listen 80;
# Local dev quickstart
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 duplicate checks, EXIF timestamps, album add, and peritem progress via WebSocket.
## Development ## 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.
- Computes SHA1 and checks a local SQLite cache (`state.db`) to prevent duplicates.
- 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: Run with live reload:
```bash ```bash
python main.py 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.
2. **De-dupe (local)** - Server computes **SHA1** and checks `state.db`. If seen, marks as **duplicate**.
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 - The menu and invite creation are behind login. Logout clears the session.
# Server (dev only) - Invite links are public by URL; share only with intended recipients.
HOST=0.0.0.0 - The public uploader page at `/` is enabled unless disabled with `PUBLIC_UPLOAD_PAGE_ENABLED=false`.
PORT=8080 - No browsing of uploaded media; only ephemeral session state is shown.
# 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.
- Run behind HTTPS with a reverse proxy and restrict CORS to your domain(s). - 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 ## 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

View File

@@ -20,9 +20,11 @@ import sqlite3
import binascii import binascii
import pytz import pytz
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional from typing import Dict, List, Optional, Tuple
import math
import logging import logging
import httpx
from fastapi import FastAPI, UploadFile, WebSocket, WebSocketDisconnect, Request, Form 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
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -39,19 +41,26 @@ from app.config import Settings, load_settings
# ---- App & static ---- # ---- App & static ----
app = FastAPI(title="Immich Drop Uploader (Python)") app = FastAPI(title="Immich 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
_public_uploads_enabled_runtime = SETTINGS.public_upload_page_enabled
# Global settings (read-only at runtime)
SETTINGS: Settings = load_settings()
# Basic logging setup using settings # Basic logging setup using settings
logging.basicConfig(level=SETTINGS.log_level, format="%(asctime)s %(levelname)s %(name)s: %(message)s") logging.basicConfig(level=SETTINGS.log_level, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING)
logger = logging.getLogger("immich_drop") logger = logging.getLogger("immich_drop")
# Cookie-based session for short-lived auth token storage (no persistence) # Cookie-based session for short-lived auth token storage (no persistence)
@@ -170,6 +179,165 @@ class SessionHub:
hub = SessionHub() hub = SessionHub()
# ---------- Telegram Bot ----------
# Batch upload notifications
_upload_batch: List[Tuple[str, int, str, bool]] = [] # filename, size, album_name, is_invite
_batch_complete_timer: Optional[asyncio.TimerHandle] = None
_batch_lock = asyncio.Lock()
def human_size(bytes_val: int) -> str:
"""Return a human-readable size string."""
if not bytes_val:
return "0 B"
k = 1024
sizes = ['B', 'KB', 'MB', 'GB', 'TB']
i = 0
if bytes_val > 0:
i = int(math.floor(math.log(bytes_val) / math.log(k)))
if i >= len(sizes):
i = len(sizes) - 1
return f"{(bytes_val / (k**i)):.1f} {sizes[i]}"
async def send_batch_notification():
"""Format and send a summary of the recently completed upload batch."""
async with _batch_lock:
if not _upload_batch:
return
batch_copy = list(_upload_batch)
_upload_batch.clear()
global _batch_complete_timer
if _batch_complete_timer:
_batch_complete_timer.cancel()
_batch_complete_timer = None
num_files = len(batch_copy)
total_size = sum(size for _, size, _, _ in batch_copy)
# All items in a batch should have the same destination album and source type
album_name = batch_copy[0][2] if batch_copy else "Unknown"
is_invite = batch_copy[0][3] if batch_copy else False
source = "Invite" if is_invite else "Public"
file_list_str = ""
if num_files > 0:
filenames = [name or "file" for name, _, _, _ in batch_copy]
if num_files > 15:
file_list_str = "\n".join(f"{name}" for name in filenames[:15])
file_list_str += f"\n... and {num_files - 15} more."
else:
file_list_str = "\n".join(f"{name}" for name in filenames)
msg = f"New files uploaded:\n\n- Destination: `{album_name}`\n- Source: {source}\n- Files: {num_files}\n- Total size: {human_size(total_size)}\n\n```\n{file_list_str}\n```List commands: /help".strip()
await send_telegram_message(TELEGRAM_OWNER_ID, msg, markdown=True)
def _schedule_batch_notification():
# Helper to run async func from sync context of call_later
asyncio.create_task(send_batch_notification())
async def reset_telegram_debounce():
"""Resets the 120s timer for batch completion notification."""
if not SETTINGS.telegram_bot_api_key or not TELEGRAM_OWNER_ID:
return
global _batch_complete_timer
async with _batch_lock:
if _batch_complete_timer:
_batch_complete_timer.cancel()
loop = asyncio.get_event_loop()
_batch_complete_timer = loop.call_later(120, _schedule_batch_notification)
async def add_file_to_batch(filename: str, size: int, album_name: str, is_invite: bool):
"""Adds a completed file to the batch list."""
if not SETTINGS.telegram_bot_api_key or not TELEGRAM_OWNER_ID:
return
async with _batch_lock:
_upload_batch.append((filename, size, album_name, is_invite))
TELEGRAM_API_URL = f"https://api.telegram.org/bot{SETTINGS.telegram_bot_api_key}"
TELEGRAM_OWNER_ID = SETTINGS.telegram_bot_owner_id
async def send_telegram_message(chat_id: str, text: str, markdown: bool = False):
"""Send a message via Telegram bot."""
if not SETTINGS.telegram_bot_api_key:
return
payload = {"chat_id": chat_id, "text": text}
if markdown:
payload["parse_mode"] = "Markdown"
async with httpx.AsyncClient() as client:
try:
await client.post(f"{TELEGRAM_API_URL}/sendMessage", json=payload)
logger.info("Sent Telegram message to %s", chat_id)
except Exception as e:
logger.error("Failed to send Telegram message: %s", e)
async def handle_telegram_update(update: dict):
"""Process a single Telegram update."""
if "message" not in update:
return
message = update["message"]
chat_id = message.get("chat", {}).get("id")
from_id = message.get("from", {}).get("id")
text = message.get("text", "")
if str(from_id) != TELEGRAM_OWNER_ID:
logger.warning("Ignoring Telegram message from non-owner: %s", from_id)
return
global _public_uploads_enabled_runtime
if text == "/start":
await send_telegram_message(str(chat_id), "File Drop Bot active.")
elif text == "/help":
help_text = (
"/help - Show this help message\n"
"/start - Check if bot is active\n"
"/disable_public - Temporarily disable public uploads\n"
"/enable_public - Temporarily enable public uploads"
)
await send_telegram_message(str(chat_id), help_text)
elif text == "/disable_public":
_public_uploads_enabled_runtime = False
logger.info("Public uploads disabled by Telegram owner.")
await send_telegram_message(str(chat_id), "Public uploads have been disabled.")
elif text == "/enable_public":
_public_uploads_enabled_runtime = True
logger.info("Public uploads enabled by Telegram owner.")
await send_telegram_message(str(chat_id), "Public uploads have been enabled.")
async def poll_telegram_updates():
"""Poll for Telegram updates and process them."""
if not SETTINGS.telegram_bot_api_key or not TELEGRAM_OWNER_ID:
logger.info("Telegram bot not configured, skipping polling.")
return
update_offset = 0
async with httpx.AsyncClient(timeout=35) as client:
while True:
try:
response = await client.get(
f"{TELEGRAM_API_URL}/getUpdates",
params={"offset": update_offset, "timeout": 30}
)
updates = response.json().get("result", [])
for update in updates:
await handle_telegram_update(update)
update_offset = update["update_id"] + 1
except Exception as e:
logger.error("Error polling Telegram updates: %s", e)
await asyncio.sleep(10) # wait before retrying on error
@app.on_event("startup")
async def startup_event():
"""On app startup, send boot message and start polling."""
if SETTINGS.telegram_bot_api_key and TELEGRAM_OWNER_ID:
await send_telegram_message(TELEGRAM_OWNER_ID, "File Drop Bot booted up.")
asyncio.create_task(poll_telegram_updates())
# ---------- Helpers ---------- # ---------- Helpers ----------
def sha1_hex(file_bytes: bytes) -> str: def sha1_hex(file_bytes: bytes) -> str:
@@ -200,6 +368,46 @@ def sanitize_filename(name: Optional[str]) -> str:
cleaned = ''.join(cleaned_chars).strip() cleaned = ''.join(cleaned_chars).strip()
return cleaned or "file" return cleaned or "file"
def get_safe_subpath(relative_path: Optional[str]) -> str:
"""
From a relative path from the client, return a safe subpath for the filesystem.
This removes the filename, and sanitizes each directory component.
e.g., 'foo/bar/baz.jpg' -> 'foo/bar'
e.g., '../../foo/bar.jpg' -> 'foo'
"""
if not relative_path:
return ""
# We only want the directory part.
directory_path = os.path.dirname(relative_path)
if not directory_path or directory_path == '.':
return ""
# Normalize path, especially for windows clients sending '\'
normalized_path = os.path.normpath(directory_path.replace('\\', '/'))
# Split into components
parts = normalized_path.split('/')
# Sanitize and filter components
safe_parts = []
for part in parts:
# No empty parts, no current/parent dir references
if not part or part == '.' or part == '..':
continue
# Remove potentially harmful characters from each part of the path
safe_part = re.sub(r'[<>:"|?*]', '', part).strip().replace('/', '_')
if safe_part:
safe_parts.append(safe_part)
if not safe_parts:
return ""
# Using os.path.join to be OS-agnostic for the server's filesystem
return os.path.join(*safe_parts)
def read_exif_datetimes(file_bytes: bytes): def read_exif_datetimes(file_bytes: bytes):
""" """
Extract EXIF DateTimeOriginal / ModifyDate values when possible. Extract EXIF DateTimeOriginal / ModifyDate values when possible.
@@ -268,11 +476,15 @@ def _verify_password(stored: str, pw: Optional[str]) -> bool:
except Exception: except Exception:
return False return False
def get_or_create_album_dir(album_name: str, public_subfolder: Optional[str] = None) -> str: def get_or_create_album_dir(album_name: str, public_subfolder: Optional[str] = None, relative_path: Optional[str] = None) -> Tuple[str, str]:
"""Get or create a directory for an album. Returns the path.""" """
Get or create a directory for an album, including subdirectories.
Returns a tuple of (full_save_path, display_album_name).
"""
if not album_name or not isinstance(album_name, str): if not album_name or not isinstance(album_name, str):
album_name = "public" album_name = "public"
display_album_name = album_name
if album_name == "public": if album_name == "public":
try: try:
tz = pytz.timezone(SETTINGS.timezone) tz = pytz.timezone(SETTINGS.timezone)
@@ -281,16 +493,22 @@ def get_or_create_album_dir(album_name: str, public_subfolder: Optional[str] = N
slug = slugify(public_subfolder) slug = slugify(public_subfolder)
if slug: if slug:
today = f"{today}-{slug}" today = f"{today}-{slug}"
save_dir = os.path.join("./data/uploads", "public", today) display_album_name = today
base_save_dir = os.path.join("./data/uploads", "public", today)
except Exception as e: except Exception as e:
logger.warning("Timezone logic failed, falling back to 'public' album. Timezone: %s. Error: %s", SETTINGS.timezone, e) logger.warning("Timezone logic failed, falling back to 'public' album. Timezone: %s. Error: %s", SETTINGS.timezone, e)
save_dir = os.path.join("./data/uploads", "public") base_save_dir = os.path.join("./data/uploads", "public")
display_album_name = "public"
else: else:
safe_album_name = sanitize_filename(album_name) safe_album_name = sanitize_filename(album_name)
save_dir = os.path.join("./data/uploads", "albums", safe_album_name) display_album_name = safe_album_name
base_save_dir = os.path.join("./data/uploads", "albums", safe_album_name)
safe_subpath = get_safe_subpath(relative_path)
save_dir = os.path.join(base_save_dir, safe_subpath) if safe_subpath else base_save_dir
os.makedirs(save_dir, exist_ok=True) os.makedirs(save_dir, exist_ok=True)
return save_dir return save_dir, display_album_name
async def send_progress(session_id: str, item_id: str, status: str, progress: int = 0, message: str = "", response_id: Optional[str] = None) -> None: async def send_progress(session_id: str, item_id: str, status: str, progress: int = 0, message: str = "", response_id: Optional[str] = None) -> None:
"""Push a progress update over WebSocket for one queue item.""" """Push a progress update over WebSocket for one queue item."""
@@ -307,7 +525,7 @@ async def send_progress(session_id: str, item_id: str, status: str, progress: in
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse: async def index(request: Request) -> HTMLResponse:
"""Serve the SPA (frontend/index.html) or redirect to login if disabled.""" """Serve the SPA (frontend/index.html) or redirect to login if disabled."""
if not SETTINGS.public_upload_page_enabled: if not _public_uploads_enabled_runtime:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
return FileResponse(os.path.join(FRONTEND_DIR, "index.html")) return FileResponse(os.path.join(FRONTEND_DIR, "index.html"))
@@ -341,7 +559,7 @@ async def api_ping() -> dict:
async def api_config() -> dict: async def api_config() -> dict:
"""Expose minimal public configuration flags for the frontend.""" """Expose minimal public configuration flags for the frontend."""
return { return {
"public_upload_page_enabled": SETTINGS.public_upload_page_enabled, "public_upload_page_enabled": _public_uploads_enabled_runtime,
"chunked_uploads_enabled": SETTINGS.chunked_uploads_enabled, "chunked_uploads_enabled": SETTINGS.chunked_uploads_enabled,
"chunk_size_mb": SETTINGS.chunk_size_mb, "chunk_size_mb": SETTINGS.chunk_size_mb,
} }
@@ -380,6 +598,7 @@ async def api_upload(
item_id: str = Form(...), item_id: str = Form(...),
session_id: str = Form(...), session_id: str = Form(...),
last_modified: Optional[int] = Form(None), last_modified: Optional[int] = Form(None),
relative_path: Optional[str] = Form(None),
invite_token: Optional[str] = Form(None), invite_token: Optional[str] = Form(None),
fingerprint: Optional[str] = Form(None), fingerprint: Optional[str] = Form(None),
public_folder_name: Optional[str] = Form(None), public_folder_name: Optional[str] = Form(None),
@@ -496,12 +715,12 @@ async def api_upload(
target_album_name = album_name target_album_name = album_name
album_for_saving = target_album_name if invite_token else "public" album_for_saving = target_album_name if invite_token else "public"
if not invite_token and not SETTINGS.public_upload_page_enabled: if not invite_token and not _public_uploads_enabled_runtime:
await send_progress(session_id, item_id, "error", 100, "Public uploads disabled") await send_progress(session_id, item_id, "error", 100, "Public uploads disabled")
return JSONResponse({"error": "public_upload_disabled"}, status_code=403) return JSONResponse({"error": "public_upload_disabled"}, status_code=403)
try: try:
save_dir = get_or_create_album_dir(album_for_saving, public_folder_name) save_dir, display_album_name = get_or_create_album_dir(album_for_saving, public_folder_name, relative_path)
safe_name = sanitize_filename(file.filename) safe_name = sanitize_filename(os.path.basename(file.filename or "file"))
save_path = os.path.join(save_dir, safe_name) save_path = os.path.join(save_dir, safe_name)
# Avoid overwriting # Avoid overwriting
if os.path.exists(save_path): if os.path.exists(save_path):
@@ -513,8 +732,10 @@ async def api_upload(
with open(save_path, "wb") as f: with open(save_path, "wb") as f:
f.write(raw) f.write(raw)
db_insert_upload(checksum, file.filename, size, device_asset_id, None, created_iso) 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()
msg = f"Saved to {album_for_saving}/{os.path.basename(save_path)}" msg = f"Saved to {display_album_name}/{os.path.basename(save_path)}"
await send_progress(session_id, item_id, "done", 100, msg) await send_progress(session_id, item_id, "done", 100, msg)
# Increment invite usage on success # Increment invite usage on success
@@ -604,6 +825,7 @@ async def api_upload_chunk_init(request: Request) -> JSONResponse:
meta = { meta = {
"name": (data or {}).get("name"), "name": (data or {}).get("name"),
"size": (data or {}).get("size"), "size": (data or {}).get("size"),
"relative_path": (data or {}).get("relative_path"),
"last_modified": (data or {}).get("last_modified"), "last_modified": (data or {}).get("last_modified"),
"invite_token": (data or {}).get("invite_token"), "invite_token": (data or {}).get("invite_token"),
"content_type": (data or {}).get("content_type") or "application/octet-stream", "content_type": (data or {}).get("content_type") or "application/octet-stream",
@@ -653,6 +875,7 @@ async def api_upload_chunk(
except Exception as e: except Exception as e:
logger.exception("Chunk write failed: %s", e) logger.exception("Chunk write failed: %s", e)
return JSONResponse({"error": "chunk_write_failed"}, status_code=500) return JSONResponse({"error": "chunk_write_failed"}, status_code=500)
await reset_telegram_debounce()
return JSONResponse({"ok": True}) return JSONResponse({"ok": True})
@app.post("/api/upload/chunk/complete") @app.post("/api/upload/chunk/complete")
@@ -680,6 +903,7 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse:
except Exception: except Exception:
meta = {} meta = {}
public_folder_name = meta.get("public_folder_name") public_folder_name = meta.get("public_folder_name")
relative_path = meta.get("relative_path")
total_chunks = int(meta.get("total_chunks") or (data or {}).get("total_chunks") or 0) total_chunks = int(meta.get("total_chunks") or (data or {}).get("total_chunks") or 0)
if total_chunks <= 0: if total_chunks <= 0:
return JSONResponse({"error": "missing_total"}, status_code=400) return JSONResponse({"error": "missing_total"}, status_code=400)
@@ -827,13 +1051,13 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse:
target_album_name = album_name target_album_name = album_name
album_for_saving = target_album_name if invite_token else "public" album_for_saving = target_album_name if invite_token else "public"
if not invite_token and not SETTINGS.public_upload_page_enabled: if not invite_token and not _public_uploads_enabled_runtime:
await send_progress(session_id_local, item_id_local, "error", 100, "Public uploads disabled") await send_progress(session_id_local, item_id_local, "error", 100, "Public uploads disabled")
return JSONResponse({"error": "public_upload_disabled"}, status_code=403) return JSONResponse({"error": "public_upload_disabled"}, status_code=403)
try: try:
save_dir = get_or_create_album_dir(album_for_saving, public_folder_name) save_dir, display_album_name = get_or_create_album_dir(album_for_saving, public_folder_name, relative_path)
safe_name = sanitize_filename(file_like_name) safe_name = sanitize_filename(os.path.basename(file_like_name or "file"))
save_path = os.path.join(save_dir, safe_name) save_path = os.path.join(save_dir, safe_name)
if os.path.exists(save_path): if os.path.exists(save_path):
base, ext = os.path.splitext(safe_name) base, ext = os.path.splitext(safe_name)
@@ -844,8 +1068,9 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse:
with open(save_path, "wb") as f: with open(save_path, "wb") as f:
f.write(raw) f.write(raw)
db_insert_upload(checksum, file_like_name, file_size, device_asset_id, None, created_iso) 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 {album_for_saving}/{os.path.basename(save_path)}" msg = f"Saved to {display_album_name}/{os.path.basename(save_path)}"
await send_progress(session_id_local, item_id_local, "done", 100, msg) await send_progress(session_id_local, item_id_local, "done", 100, msg)
if invite_token: if invite_token:
@@ -906,6 +1131,17 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse:
await send_progress(session_id_local, item_id_local, "error", 100, "Failed to save file locally") await send_progress(session_id_local, item_id_local, "error", 100, "Failed to save file locally")
return JSONResponse({"error": "local_save_failed"}, status_code=500) return JSONResponse({"error": "local_save_failed"}, status_code=500)
@app.post("/api/uploads/batch_complete_hint")
async def api_batch_complete_hint(request: Request) -> JSONResponse:
"""
Client-side hint that a batch of uploads has completed.
This triggers the batch notification immediately instead of waiting for the debounce timer.
"""
# session_id from body is optional, for future use, but not currently used
# because the batch is global.
await send_batch_notification()
return JSONResponse({"ok": True})
# ---------- Auth & Albums & Invites APIs ---------- # ---------- Auth & Albums & Invites APIs ----------
@@ -986,7 +1222,7 @@ async def api_albums_create(request: Request) -> JSONResponse:
if not name: if not name:
return JSONResponse({"error": "missing_name"}, status_code=400) return JSONResponse({"error": "missing_name"}, status_code=400)
try: try:
get_or_create_album_dir(name) _save_dir, _display_name = get_or_create_album_dir(name)
return JSONResponse({"id": name, "albumName": name}, status_code=201) return JSONResponse({"id": name, "albumName": name}, status_code=201)
except Exception as e: except Exception as e:
logger.exception("Create album directory failed: %s", e) logger.exception("Create album directory failed: %s", e)
@@ -1082,7 +1318,7 @@ async def api_invites_create(request: Request) -> JSONResponse:
album_name = "public" album_name = "public"
# Ensure album directory exists # Ensure album directory exists
get_or_create_album_dir(album_name) _save_dir, _display_name = get_or_create_album_dir(album_name)
resolved_album_id = None # not used resolved_album_id = None # not used
# Compute expiry # Compute expiry
expires_at = None expires_at = None

View File

@@ -17,14 +17,16 @@ class Settings:
"""App settings loaded from environment variables (.env).""" """App settings loaded from environment variables (.env)."""
admin_password: str admin_password: str
max_concurrent: int max_concurrent: int
public_upload_page_enabled: bool = False public_upload_page_enabled: bool = True
public_base_url: str = "" public_base_url: str = ""
state_db: str = "" state_db: str = ""
session_secret: str = "" session_secret: str = ""
log_level: str = "INFO" log_level: str = "INFO"
chunked_uploads_enabled: bool = False chunked_uploads_enabled: bool = True
chunk_size_mb: int = 95 chunk_size_mb: int = 50
timezone: str = "UTC" timezone: str = "UTC"
telegram_bot_api_key: str = ""
telegram_bot_owner_id: str = ""
def _hash_password(pw: str) -> str: def _hash_password(pw: str) -> str:
"""Return PBKDF2-SHA256 hash of a password.""" """Return PBKDF2-SHA256 hash of a password."""
@@ -46,7 +48,8 @@ def load_settings() -> Settings:
load_dotenv() load_dotenv()
except Exception: except Exception:
pass 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-"): if not admin_password.startswith("pbkdf2_sha256-"):
print("="*60) print("="*60)
print("WARNING: ADMIN_PASSWORD is in plaintext.") print("WARNING: ADMIN_PASSWORD is in plaintext.")
@@ -55,25 +58,34 @@ def load_settings() -> Settings:
if hashed_pw: if hashed_pw:
print(f"ADMIN_PASSWORD={hashed_pw}") print(f"ADMIN_PASSWORD={hashed_pw}")
print("="*60) print("="*60)
# Safe defaults: disable public uploader and invites unless explicitly enabled
def as_bool(v: str, default: bool = False) -> bool: def as_bool(v: str, default: bool = False) -> bool:
if v is None: if v is None:
return default return default
return str(v).strip().lower() in {"1","true","yes","on"} 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: try:
maxc = int(os.getenv("MAX_CONCURRENT", "3")) maxc = int(os.getenv("MAX_CONCURRENT", "3"))
except ValueError: except ValueError:
maxc = 3 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) session_secret = os.getenv("SESSION_SECRET") or secrets.token_hex(32)
log_level = os.getenv("LOG_LEVEL", "INFO").upper() 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: try:
chunk_size_mb = int(os.getenv("CHUNK_SIZE_MB", "95")) chunk_size_mb = int(os.getenv("CHUNK_SIZE_MB", "50"))
except ValueError: except ValueError:
chunk_size_mb = 95 chunk_size_mb = 50
timezone = os.getenv("TIMEZONE", "UTC") 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( return Settings(
admin_password=admin_password, admin_password=admin_password,
max_concurrent=maxc, max_concurrent=maxc,
@@ -85,4 +97,6 @@ def load_settings() -> Settings:
chunked_uploads_enabled=chunked_uploads_enabled, chunked_uploads_enabled=chunked_uploads_enabled,
chunk_size_mb=chunk_size_mb, chunk_size_mb=chunk_size_mb,
timezone=timezone, timezone=timezone,
telegram_bot_api_key=telegram_bot_api_key,
telegram_bot_owner_id=telegram_bot_owner_id,
) )

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

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:

View File

@@ -34,6 +34,7 @@ try {
} catch {} } catch {}
let items = []; let items = [];
let socket; let socket;
let allCompleteBannerShown = false;
// Status precedence: never regress (e.g., uploading -> done shouldn't go back to uploading) // 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 STATUS_ORDER = { queued: 0, checking: 1, uploading: 2, duplicate: 3, done: 3, error: 4 };
@@ -87,20 +88,27 @@ function human(bytes){
return (bytes/Math.pow(k,i)).toFixed(1)+' '+sizes[i]; return (bytes/Math.pow(k,i)).toFixed(1)+' '+sizes[i];
} }
function addItem(file){ function addItem(file, relativePath){
const id = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Math.random().toString(36).slice(2)); const id = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Math.random().toString(36).slice(2));
const it = { id, file, name: file.name, size: file.size, status: 'queued', progress: 0 }; const resolvedPath = (relativePath !== undefined) ? relativePath : (file.webkitRelativePath || '');
const it = { id, file, name: file.name, size: file.size, status: 'queued', progress: 0, relativePath: resolvedPath };
items.unshift(it); items.unshift(it);
allCompleteBannerShown = false;
render(); render();
} }
function render(){ function render(){
const summaryEl = document.getElementById('summary');
if (summaryEl) {
summaryEl.classList.toggle('hidden', items.length === 0);
}
const itemsEl = document.getElementById('items'); const itemsEl = document.getElementById('items');
itemsEl.innerHTML = items.map(it => ` itemsEl.innerHTML = items.map(it => `
<div class="rounded-2xl border bg-white dark:bg-gray-800 dark:border-gray-700 p-4 shadow-sm transition-colors"> <div class="rounded-2xl border bg-white dark:bg-gray-800 dark:border-gray-700 p-4 shadow-sm transition-colors">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="min-w-0"> <div class="min-w-0">
<div class="truncate font-medium">${it.name} <span class="text-xs text-gray-500 dark:text-gray-400">(${human(it.size)})</span></div> <div class="truncate font-medium">${it.relativePath || it.name} <span class="text-xs text-gray-500 dark:text-gray-400">(${human(it.size)})</span></div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400"> <div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
${it.message ? `<span>${it.message}</span>` : ''} ${it.message ? `<span>${it.message}</span>` : ''}
</div> </div>
@@ -149,6 +157,19 @@ function render(){
document.getElementById('countDone').textContent=c.done; document.getElementById('countDone').textContent=c.done;
document.getElementById('countDup').textContent=c.dup; document.getElementById('countDup').textContent=c.dup;
document.getElementById('countErr').textContent=c.err; document.getElementById('countErr').textContent=c.err;
if (!allCompleteBannerShown && items.length > 0) {
const isComplete = items.every(it => FINAL_STATES.has(it.status));
const hasSuccess = items.some(it => it.status === 'done');
if (isComplete && hasSuccess) {
showBanner("All uploads complete.", "ok");
allCompleteBannerShown = true;
// Hint to backend that this batch is done, to trigger notification sooner
try {
fetch('/api/uploads/batch_complete_hint', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ session_id: sessionId }) }).catch(()=>{});
} catch {}
}
}
} }
// --- WebSocket progress --- // --- WebSocket progress ---
@@ -218,6 +239,7 @@ async function uploadWhole(next){
form.append('item_id', next.id); form.append('item_id', next.id);
form.append('session_id', sessionId); form.append('session_id', sessionId);
form.append('last_modified', next.file.lastModified || ''); form.append('last_modified', next.file.lastModified || '');
if (next.relativePath) form.append('relative_path', next.relativePath);
if (INVITE_TOKEN) form.append('invite_token', INVITE_TOKEN); if (INVITE_TOKEN) form.append('invite_token', INVITE_TOKEN);
const publicFolderName = document.getElementById('publicFolderName')?.value?.trim(); const publicFolderName = document.getElementById('publicFolderName')?.value?.trim();
if (publicFolderName) form.append('public_folder_name', publicFolderName); if (publicFolderName) form.append('public_folder_name', publicFolderName);
@@ -235,7 +257,7 @@ async function uploadWhole(next){
next.message = statusText || (isDuplicate ? 'Duplicate' : 'Uploaded'); next.message = statusText || (isDuplicate ? 'Duplicate' : 'Uploaded');
next.progress = 100; next.progress = 100;
render(); render();
try { showBanner(isDuplicate ? `Duplicate: ${next.name}` : `Uploaded: ${next.name}`, isDuplicate ? 'warn' : 'ok'); } catch {} try { if (isDuplicate) showBanner(`Duplicate: ${next.name}`, 'warn'); } catch {}
} }
} }
@@ -249,6 +271,7 @@ async function uploadChunked(next){
item_id: next.id, item_id: next.id,
session_id: sessionId, session_id: sessionId,
name: next.file.name, name: next.file.name,
relative_path: next.relativePath || '',
size: next.file.size, size: next.file.size,
last_modified: next.file.lastModified || '', last_modified: next.file.lastModified || '',
invite_token: INVITE_TOKEN || '', invite_token: INVITE_TOKEN || '',
@@ -347,7 +370,6 @@ if (btnPing) btnPing.onclick = async () => {
if(j.album_name) { if(j.album_name) {
bannerText += ` | Uploading to album: "${j.album_name}"`; bannerText += ` | Uploading to album: "${j.album_name}"`;
} }
showBanner(bannerText, 'ok');
} }
}catch{ }catch{
pingStatus.textContent = 'No connection'; pingStatus.textContent = 'No connection';
@@ -366,17 +388,43 @@ if (btnPing) btnPing.onclick = async () => {
if (j.albumName) parts.push(`Uploading to album: "${j.albumName}"`); if (j.albumName) parts.push(`Uploading to album: "${j.albumName}"`);
if (j.expiresAt) parts.push(`Expires: ${new Date(j.expiresAt).toLocaleString()}`); if (j.expiresAt) parts.push(`Expires: ${new Date(j.expiresAt).toLocaleString()}`);
if (typeof j.remaining === 'number') parts.push(`Uses left: ${j.remaining}`); if (typeof j.remaining === 'number') parts.push(`Uses left: ${j.remaining}`);
if (parts.length) showBanner(parts.join(' | '), 'ok');
} catch {} } catch {}
})(); })();
// --- Drag & drop (no click-to-open on touch) --- // --- Drag & drop (no click-to-open on touch) ---
['dragenter','dragover'].forEach(ev => dz.addEventListener(ev, e=>{ e.preventDefault(); dz.classList.add('border-blue-500','bg-blue-50','dark:bg-blue-900','dark:bg-opacity-20'); })); ['dragenter','dragover'].forEach(ev => dz.addEventListener(ev, e=>{ e.preventDefault(); dz.classList.add('border-blue-500','bg-blue-50','dark:bg-blue-900','dark:bg-opacity-20'); }));
['dragleave','drop'].forEach(ev => dz.addEventListener(ev, e=>{ e.preventDefault(); dz.classList.remove('border-blue-500','bg-blue-50','dark:bg-blue-900','dark:bg-opacity-20'); })); ['dragleave','drop'].forEach(ev => dz.addEventListener(ev, e=>{ e.preventDefault(); dz.classList.remove('border-blue-500','bg-blue-50','dark:bg-blue-900','dark:bg-opacity-20'); }));
dz.addEventListener('drop', (e)=>{ dz.addEventListener('drop', async (e)=>{
e.preventDefault(); e.preventDefault();
const files = Array.from(e.dataTransfer.files || []); const filesAndPaths = [];
files.forEach(addItem);
const traverseFileTree = async (entry) => {
if (!entry) return;
if (entry.isFile) {
return new Promise(resolve => {
entry.file(file => {
filesAndPaths.push({ file, path: entry.fullPath ? entry.fullPath.substring(1) : file.name });
resolve();
});
});
} else if (entry.isDirectory) {
const reader = entry.createReader();
const entries = await new Promise(resolve => reader.readEntries(resolve));
for (const subEntry of entries) {
await traverseFileTree(subEntry);
}
}
};
if (e.dataTransfer.items && e.dataTransfer.items.length > 0 && e.dataTransfer.items[0].webkitGetAsEntry) {
const promises = Array.from(e.dataTransfer.items).map(item => traverseFileTree(item.webkitGetAsEntry()));
await Promise.all(promises);
filesAndPaths.forEach(fp => addItem(fp.file, fp.path));
} else {
// Fallback for browsers without directory drop support
Array.from(e.dataTransfer.files).forEach(file => addItem(file));
}
render(); render();
runQueue(); runQueue();
}); });
@@ -407,19 +455,22 @@ fi.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
}); });
fi.onchange = () => { const onFilesSelected = (inputEl) => {
if (!inputEl) return;
// Suppress any stray clicks for a short window after the picker closes // Suppress any stray clicks for a short window after the picker closes
suppressClicksUntil = Date.now() + 800; suppressClicksUntil = Date.now() + 800;
const files = Array.from(fi.files || []); const files = Array.from(inputEl.files || []);
files.forEach(addItem); files.forEach(file => addItem(file));
render(); render();
runQueue(); runQueue();
// Reset a bit later so selecting the same items again still triggers 'change' // Reset a bit later so selecting the same items again still triggers 'change'
setTimeout(() => { try { fi.value = ''; } catch {} }, 500); setTimeout(() => { try { inputEl.value = ''; } catch {} }, 500);
}; };
fi.onchange = () => onFilesSelected(fi);
// If you want the whole dropzone clickable on desktop only, enable this: // If you want the whole dropzone clickable on desktop only, enable this:
if (!isTouch) { if (!isTouch) {
dz.addEventListener('click', () => { dz.addEventListener('click', () => {

View File

@@ -20,7 +20,6 @@
<h1 class="text-2xl font-semibold tracking-tight">File Drop Uploader</h1> <h1 class="text-2xl font-semibold tracking-tight">File Drop Uploader</h1>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a href="/login" class="rounded-xl border px-4 py-2 text-sm dark:border-gray-600 dark:hover:bg-gray-800 hover:bg-gray-100 transition-colors" aria-label="Login">Login</a> <a href="/login" class="rounded-xl border px-4 py-2 text-sm dark:border-gray-600 dark:hover:bg-gray-800 hover:bg-gray-100 transition-colors" aria-label="Login">Login</a>
<span id="pingStatus" class="ml-2 text-sm text-gray-500 dark:text-gray-400 hidden sm:inline"></span>
</div> </div>
</header> </header>
@@ -31,11 +30,11 @@
<!-- Dropzone --> <!-- Dropzone -->
<section id="dropzone" class="rounded-2xl border-2 border-dashed p-8 md:p-10 text-center bg-white dark:bg-gray-800 dark:border-gray-600 transition-colors"> <section id="dropzone" class="rounded-2xl border-2 border-dashed p-8 md:p-10 text-center bg-white dark:bg-gray-800 dark:border-gray-600 transition-colors">
<div id="dropHint" class="mx-auto h-12 w-12 opacity-70 hidden md:block"> <div id="dropHint" class="mx-auto -mt-10 h-12 w-12 opacity-70 hidden md:block">
<!-- upload icon --> <!-- upload icon -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 16V8m0 0l-3 3m3-3 3 3M4 16a4 4 0 0 0 4 4h8a4 4 0 0 0 4-4v-1a1 1 0 1 0-2 0v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-1a1 1 0 1 0-2 0v1z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 16V8m0 0l-3 3m3-3 3 3M4 16a4 4 0 0 0 4 4h8a4 4 0 0 0 4-4v-1a1 1 0 1 0-2 0v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-1a1 1 0 1 0-2 0v1z"/></svg>
</div> </div>
<p class="mt-3 font-medium hidden md:block">Drop files here</p> <p class="mt-3 font-medium hidden md:block">Drop multiple files or folders here</p>
<p class="mb-5 text-sm text-gray-600 dark:text-gray-400 hidden md:block">...or</p> <p class="mb-5 text-sm text-gray-600 dark:text-gray-400 hidden md:block">...or</p>
<!-- Mobile-safe choose control: label wraps the hidden input --> <!-- Mobile-safe choose control: label wraps the hidden input -->
@@ -51,7 +50,7 @@
</section> </section>
<!-- Queue summary --> <!-- Queue summary -->
<section id="summary" class="!mt-12 rounded-2xl border bg-white p-4 shadow-sm dark:bg-gray-800 dark:border-gray-700 transition-colors"> <section id="summary" class="hidden !mt-12 rounded-2xl border bg-white p-4 shadow-sm dark:bg-gray-800 dark:border-gray-700 transition-colors">
<div class="flex flex-wrap items-end justify-between gap-2 text-sm"> <div class="flex flex-wrap items-end justify-between gap-2 text-sm">
<!-- Buttons: on small screens show on their own row above --> <!-- Buttons: on small screens show on their own row above -->
<div class="order-1 w-full md:order-2 md:w-auto flex gap-2 justify-end"> <div class="order-1 w-full md:order-2 md:w-auto flex gap-2 justify-end">

View File

@@ -23,7 +23,7 @@
<header class="flex items-center justify-between flex-wrap gap-2"> <header class="flex items-center justify-between flex-wrap gap-2">
<h1 class="text-2xl font-semibold tracking-tight">File Drop Uploader</h1> <h1 class="text-2xl font-semibold tracking-tight">File Drop Uploader</h1>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a id="linkHome" href="/" class="hidden rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Home</a> <a id="linkHome" href="/" class="hidden rounded-xl border px-4 py-2 text-sm dark:border-gray-600">Home</a>
</div> </div>
</header> </header>
@@ -55,15 +55,19 @@
<!-- Dropzone and queue copied from index.html --> <!-- Dropzone and queue copied from index.html -->
<section id="dropzone" class="rounded-2xl border-2 border-dashed p-8 md:p-10 text-center bg-white dark:bg-gray-800 dark:border-gray-600"> <section id="dropzone" class="rounded-2xl border-2 border-dashed p-8 md:p-10 text-center bg-white dark:bg-gray-800 dark:border-gray-600">
<div id="dropHint" class="mx-auto h-12 w-12 opacity-70 hidden md:block"> <div id="dropHint" class="mx-auto -mt-6 h-12 w-12 opacity-70 hidden md:block">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 16V8m0 0l-3 3m3-3 3 3M4 16a4 4 0 0 0 4 4h8a4 4 0 0 0 4-4v-1a1 1 0 1 0-2 0v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-1a1 1 0 1 0-2 0v1z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 16V8m0 0l-3 3m3-3 3 3M4 16a4 4 0 0 0 4 4h8a4 4 0 0 0 4-4v-1a1 1 0 1 0-2 0v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-1a1 1 0 1 0-2 0v1z"/></svg>
</div> </div>
<p class="mt-3 font-medium hidden md:block">Drop files here</p> <p class="mt-3 font-medium hidden md:block">Drop multiple files or folders here</p>
<p class="text-sm text-gray-600 dark:text-gray-400 hidden md:block">...or</p> <p class="mb-5 text-sm text-gray-600 dark:text-gray-400 hidden md:block">...or</p>
<div class="mt-3 relative inline-block"> <!-- Mobile-safe choose control: label wraps the hidden input -->
<label class="rounded-2xl bg-black text-white dark:bg-white dark:text-black px-5 py-3 hover:opacity-90 cursor-pointer select-none" aria-label="Choose files"> <div class="relative inline-block">
<label class="rounded-2xl bg-black text-white dark:bg-white dark:text-black px-5 py-3 hover:opacity-90 cursor-pointer select-none transition-colors" aria-label="Choose files">
Choose files Choose files
<input id="fileInput" type="file" multiple class="absolute inset-0 opacity-0 cursor-pointer" /> <input id="fileInput"
type="file"
multiple
class="absolute inset-0 opacity-0 cursor-pointer" />
</label> </label>
</div> </div>
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400"> <div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
@@ -71,7 +75,7 @@
</div> </div>
</section> </section>
<section id="summary" class="!mt-12 rounded-2xl border bg-white p-4 shadow-sm dark:bg-gray-800 dark:border-gray-700"> <section id="summary" class="hidden !mt-12 rounded-2xl border bg-white p-4 shadow-sm dark:bg-gray-800 dark:border-gray-700">
<div class="flex flex-wrap items-end justify-between gap-2 text-sm"> <div class="flex flex-wrap items-end justify-between gap-2 text-sm">
<!-- Buttons: ensure present on invite page and visible on small screens --> <!-- Buttons: ensure present on invite page and visible on small screens -->
<div class="order-1 w-full md:order-2 md:w-auto flex gap-2 justify-end"> <div class="order-1 w-full md:order-2 md:w-auto flex gap-2 justify-end">
@@ -140,7 +144,6 @@
dz.classList.remove('opacity-50'); dz.classList.remove('opacity-50');
if (fi) fi.disabled = false; if (fi) fi.disabled = false;
itemsEl.innerHTML = ''; itemsEl.innerHTML = '';
try { showBanner('Password accepted. You can upload now.', 'ok'); } catch {}
} catch (e) { } catch (e) {
pwError.textContent = 'Error verifying password.'; pwError.textContent = 'Error verifying password.';
pwError.classList.remove('hidden'); pwError.classList.remove('hidden');

BIN
media/admin-page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
media/after-uploading.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
media/invite-page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
media/public-uploader.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
media/telegram-bot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

21
screenshots.md Normal file
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)