Compare commits

...

44 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
16f1d0df0c Refine upload pages 2026-01-06 16:48:43 -07:00
ec5584c467 Refactor: Relocate public folder input and remove mobile upload bar 2026-01-06 16:38:00 -07:00
d48eaf388c Adjust folder name input style 2026-01-06 16:25:51 -07:00
1004b4ab7f feat: Add optional public upload folder naming
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-06 16:15:46 -07:00
4de027cfc3 Enable public uploads and chunking by default 2026-01-06 16:09:47 -07:00
5329844264 feat: Allow any file type upload on invite page
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-06 15:46:02 -07:00
04df7dfb83 feat: Allow all file types for upload and update UI text
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-06 15:43:54 -07:00
a12c73eba8 fix: Update dropzone text to 'Drop files here' 2026-01-06 15:43:51 -07:00
eb2c7ab45c Remove home page clutter 2026-01-06 15:38:00 -07:00
18 changed files with 596 additions and 420 deletions

View File

@@ -5,14 +5,15 @@ MAX_CONCURRENT=3
ADMIN_PASSWORD=test123
TIMEZONE=America/Edmonton
# Public uploader page (optional) — disabled by default
PUBLIC_UPLOAD_PAGE_ENABLED=false
# Public uploader page (optional)
PUBLIC_UPLOAD_PAGE_ENABLED=true
# Local dedupe cache (SQLite)
STATE_DB=./data/state.db
#STATE_DB=./data/state.db
# Base URL for generating absolute invite links (recommended for production)
# e.g., PUBLIC_BASE_URL=https://photos.example.com
# 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
@@ -20,9 +21,15 @@ LOG_LEVEL=INFO
# Chunked uploads (to work around 100MB proxy limits)
# Enable to send files in chunks from browser to this service
# Recommended chunk size for Cloudflare Tunnel is <= 95MB.
CHUNKED_UPLOADS_ENABLED=false
CHUNK_SIZE_MB=95
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=

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

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

356
README.md
View File

@@ -1,198 +1,164 @@
# 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.
- **Duplicate Prevention:** Local SHA1 cache prevents re-uploading the same file.
- **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
$ sudo docker compose up --build -d
```
---
## What's New
Set up nginx / a reverse proxy and point it to the web app.
### 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
Make sure it allows WebSocket connections through, for example:
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.
```
server {
root /var/www/html;
index index.html index.htm;
server_name upload.example.com;
### 📱 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.
listen 80;
### ♻️ 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.
client_max_body_size 100M;
### 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.
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;
### 🌙 Dark Mode
- Automatic or manual toggle; persisted preference
# websockets
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_redirect off;
}
}
```
### 📁 Album Integration
- Auto-create + assign album if configured; optional invites without album
Then restart nginx and set up HTTPS:
---
```
$ sudo service nginx restart
$ sudo certbot --nginx
```
## 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.
### Config Changes
---
If you change the `.env` file config, simply run:
## Architecture
```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
### 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.
- 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
## Folder structure
Requires Python 3.11+.
```
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
Create a venv, activate it, and install:
```text
$ virtualenv -p python3 env
$ source env/bin/activate
(env) $ pip install -r requirements.txt
```
---
## Requirements
- **Python** 3.11
- An **Immich** server + **API key**
---
# Local dev quickstart
## Development
```text
(env) $ cp .env.example .env
(env) $ vim .env
```
Run with live reload:
@@ -200,82 +166,30 @@ Run with live reload:
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)
```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
### 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.
- 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

View File

@@ -13,15 +13,18 @@ from __future__ import annotations
import asyncio
import io
import json
import re
import hashlib
import os
import sqlite3
import binascii
import pytz
from datetime import datetime
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Tuple
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.middleware.cors import CORSMiddleware
@@ -38,19 +41,26 @@ from app.config import Settings, load_settings
# ---- App & static ----
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(
CORSMiddleware,
allow_origins=["*"],
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
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
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")
# Cookie-based session for short-lived auth token storage (no persistence)
@@ -169,6 +179,165 @@ class 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 ----------
def sha1_hex(file_bytes: bytes) -> str:
@@ -199,6 +368,46 @@ def sanitize_filename(name: Optional[str]) -> str:
cleaned = ''.join(cleaned_chars).strip()
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):
"""
Extract EXIF DateTimeOriginal / ModifyDate values when possible.
@@ -216,7 +425,7 @@ def read_exif_datetimes(file_bytes: bytes):
try:
return datetime.strptime(s, "%Y:%m:%d %H:%M:%S")
except Exception:
return None
return None, None
if isinstance(dt_original, str):
created = parse_dt(dt_original)
if isinstance(dt_modified, str):
@@ -226,6 +435,19 @@ def read_exif_datetimes(file_bytes: bytes):
return created, modified
def slugify(value: Optional[str]) -> str:
"""
Normalizes string, converts to lowercase, removes non-alpha characters,
and converts spaces to hyphens. Max length is 64.
"""
if not value:
return ""
value = str(value).strip()[:64]
value = re.sub(r'[^\w\s-]', '', value).strip().lower()
value = re.sub(r'[-\s]+', '-', value)
return value.strip('-')
def _hash_password(pw: str) -> str:
"""Return PBKDF2-SHA256 hash of a password."""
try:
@@ -254,25 +476,39 @@ def _verify_password(stored: str, pw: Optional[str]) -> bool:
except Exception:
return False
def get_or_create_album_dir(album_name: str) -> str:
"""Get or create a directory for an album. Returns the path."""
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, including subdirectories.
Returns a tuple of (full_save_path, display_album_name).
"""
if not album_name or not isinstance(album_name, str):
album_name = "public"
display_album_name = album_name
if album_name == "public":
try:
tz = pytz.timezone(SETTINGS.timezone)
today = datetime.now(tz).strftime('%Y-%m-%d')
save_dir = os.path.join("./data/uploads", "public", today)
if public_subfolder:
slug = slugify(public_subfolder)
if slug:
today = f"{today}-{slug}"
display_album_name = today
base_save_dir = os.path.join("./data/uploads", "public", today)
except Exception as 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:
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)
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:
"""Push a progress update over WebSocket for one queue item."""
@@ -289,7 +525,7 @@ async def send_progress(session_id: str, item_id: str, status: str, progress: in
@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
"""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 FileResponse(os.path.join(FRONTEND_DIR, "index.html"))
@@ -323,7 +559,7 @@ async def api_ping() -> dict:
async def api_config() -> dict:
"""Expose minimal public configuration flags for the frontend."""
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,
"chunk_size_mb": SETTINGS.chunk_size_mb,
}
@@ -362,8 +598,10 @@ async def api_upload(
item_id: str = Form(...),
session_id: str = Form(...),
last_modified: Optional[int] = Form(None),
relative_path: Optional[str] = Form(None),
invite_token: Optional[str] = Form(None),
fingerprint: Optional[str] = Form(None),
public_folder_name: Optional[str] = Form(None),
):
"""Receive a file, check duplicates, forward to Immich; stream progress via WS."""
raw = await file.read()
@@ -477,12 +715,12 @@ async def api_upload(
target_album_name = album_name
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")
return JSONResponse({"error": "public_upload_disabled"}, status_code=403)
try:
save_dir = get_or_create_album_dir(album_for_saving)
safe_name = sanitize_filename(file.filename)
save_dir, display_album_name = get_or_create_album_dir(album_for_saving, public_folder_name, relative_path)
safe_name = sanitize_filename(os.path.basename(file.filename or "file"))
save_path = os.path.join(save_dir, safe_name)
# Avoid overwriting
if os.path.exists(save_path):
@@ -494,8 +732,10 @@ async def api_upload(
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()
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)
# Increment invite usage on success
@@ -585,9 +825,11 @@ async def api_upload_chunk_init(request: Request) -> JSONResponse:
meta = {
"name": (data or {}).get("name"),
"size": (data or {}).get("size"),
"relative_path": (data or {}).get("relative_path"),
"last_modified": (data or {}).get("last_modified"),
"invite_token": (data or {}).get("invite_token"),
"content_type": (data or {}).get("content_type") or "application/octet-stream",
"public_folder_name": (data or {}).get("public_folder_name"),
"created_at": datetime.utcnow().isoformat(),
}
with open(os.path.join(d, "meta.json"), "w", encoding="utf-8") as f:
@@ -633,6 +875,7 @@ async def api_upload_chunk(
except Exception as e:
logger.exception("Chunk write failed: %s", e)
return JSONResponse({"error": "chunk_write_failed"}, status_code=500)
await reset_telegram_debounce()
return JSONResponse({"ok": True})
@app.post("/api/upload/chunk/complete")
@@ -659,6 +902,8 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse:
meta = json.load(f)
except Exception:
meta = {}
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)
if total_chunks <= 0:
return JSONResponse({"error": "missing_total"}, status_code=400)
@@ -806,13 +1051,13 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse:
target_album_name = album_name
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")
return JSONResponse({"error": "public_upload_disabled"}, status_code=403)
try:
save_dir = get_or_create_album_dir(album_for_saving)
safe_name = sanitize_filename(file_like_name)
save_dir, display_album_name = get_or_create_album_dir(album_for_saving, public_folder_name, relative_path)
safe_name = sanitize_filename(os.path.basename(file_like_name or "file"))
save_path = os.path.join(save_dir, safe_name)
if os.path.exists(save_path):
base, ext = os.path.splitext(safe_name)
@@ -823,8 +1068,9 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse:
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 {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)
if invite_token:
@@ -885,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")
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 ----------
@@ -965,7 +1222,7 @@ async def api_albums_create(request: Request) -> JSONResponse:
if not name:
return JSONResponse({"error": "missing_name"}, status_code=400)
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)
except Exception as e:
logger.exception("Create album directory failed: %s", e)
@@ -1061,7 +1318,7 @@ async def api_invites_create(request: Request) -> JSONResponse:
album_name = "public"
# 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
# Compute expiry
expires_at = None

View File

@@ -17,14 +17,16 @@ 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 = ""
def _hash_password(pw: str) -> str:
"""Return PBKDF2-SHA256 hash of a password."""
@@ -46,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.")
@@ -55,25 +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,
@@ -85,4 +97,6 @@ def load_settings() -> Settings:
chunked_uploads_enabled=chunked_uploads_enabled,
chunk_size_mb=chunk_size_mb,
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 {}
let items = [];
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 };
@@ -87,20 +88,27 @@ function human(bytes){
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 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);
allCompleteBannerShown = false;
render();
}
function render(){
const summaryEl = document.getElementById('summary');
if (summaryEl) {
summaryEl.classList.toggle('hidden', items.length === 0);
}
const itemsEl = document.getElementById('items');
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="flex items-center justify-between">
<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">
${it.message ? `<span>${it.message}</span>` : ''}
</div>
@@ -149,6 +157,19 @@ function render(){
document.getElementById('countDone').textContent=c.done;
document.getElementById('countDup').textContent=c.dup;
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 ---
@@ -218,7 +239,10 @@ async function uploadWhole(next){
form.append('item_id', next.id);
form.append('session_id', sessionId);
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);
const publicFolderName = document.getElementById('publicFolderName')?.value?.trim();
if (publicFolderName) form.append('public_folder_name', publicFolderName);
form.append('fingerprint', FINGERPRINT);
const res = await fetch('/api/upload', { method:'POST', body: form });
const body = await res.json().catch(()=>({}));
@@ -233,24 +257,27 @@ async function uploadWhole(next){
next.message = statusText || (isDuplicate ? 'Duplicate' : 'Uploaded');
next.progress = 100;
render();
try { showBanner(isDuplicate ? `Duplicate: ${next.name}` : `Uploaded: ${next.name}`, isDuplicate ? 'warn' : 'ok'); } catch {}
try { if (isDuplicate) showBanner(`Duplicate: ${next.name}`, 'warn'); } catch {}
}
}
async function uploadChunked(next){
const chunkBytes = Math.max(1, CFG.chunk_size_mb|0) * 1024 * 1024;
const total = Math.ceil(next.file.size / chunkBytes) || 1;
const publicFolderName = document.getElementById('publicFolderName')?.value?.trim();
// init
try {
await fetch('/api/upload/chunk/init', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({
item_id: next.id,
session_id: sessionId,
name: next.file.name,
relative_path: next.relativePath || '',
size: next.file.size,
last_modified: next.file.lastModified || '',
invite_token: INVITE_TOKEN || '',
content_type: next.file.type || 'application/octet-stream',
fingerprint: FINGERPRINT
fingerprint: FINGERPRINT,
public_folder_name: publicFolderName || ''
}) });
} catch {}
// upload parts
@@ -343,7 +370,6 @@ if (btnPing) btnPing.onclick = async () => {
if(j.album_name) {
bannerText += ` | Uploading to album: "${j.album_name}"`;
}
showBanner(bannerText, 'ok');
}
}catch{
pingStatus.textContent = 'No connection';
@@ -362,18 +388,43 @@ if (btnPing) btnPing.onclick = async () => {
if (j.albumName) parts.push(`Uploading to album: "${j.albumName}"`);
if (j.expiresAt) parts.push(`Expires: ${new Date(j.expiresAt).toLocaleString()}`);
if (typeof j.remaining === 'number') parts.push(`Uses left: ${j.remaining}`);
if (parts.length) showBanner(parts.join(' | '), 'ok');
} catch {}
})();
// --- 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'); }));
['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();
const files = Array.from(e.dataTransfer.files || []);
const accepted = files.filter(f => /^(image|video)\//.test(f.type) || /\.(jpe?g|png|heic|heif|webp|gif|tiff|bmp|mp4|mov|m4v|avi|mkv)$/i.test(f.name));
accepted.forEach(addItem);
const filesAndPaths = [];
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();
runQueue();
});
@@ -404,23 +455,22 @@ fi.addEventListener('click', (e) => {
e.stopPropagation();
});
fi.onchange = () => {
const onFilesSelected = (inputEl) => {
if (!inputEl) return;
// Suppress any stray clicks for a short window after the picker closes
suppressClicksUntil = Date.now() + 800;
const files = Array.from(fi.files || []);
const accepted = files.filter(f =>
/^(image|video)\//.test(f.type) ||
/\.(jpe?g|png|heic|heif|webp|gif|tiff|bmp|mp4|mov|m4v|avi|mkv)$/i.test(f.name)
);
accepted.forEach(addItem);
const files = Array.from(inputEl.files || []);
files.forEach(file => addItem(file));
render();
runQueue();
// 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 (!isTouch) {
dz.addEventListener('click', () => {

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Image Drop Uploader</title>
<title>File Drop Uploader</title>
<link rel="icon" type="image/png" href="/static/favicon.png" />
<script src="https://cdn.tailwindcss.com"></script>
<script>
@@ -17,50 +17,40 @@
<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 flex-wrap gap-2">
<h1 class="text-2xl font-semibold tracking-tight">Image Drop Uploader</h1>
<h1 class="text-2xl font-semibold tracking-tight">File Drop Uploader</h1>
<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>
<button id="btnTheme" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600 dark:hover:bg-gray-800 hover:bg-gray-100 transition-colors" title="Toggle dark mode" aria-label="Toggle theme">
<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.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"/>
</svg>
<svg id="iconDark" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<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-2 text-sm dark:border-gray-600 dark:hover:bg-gray-800 hover:bg-gray-100 transition-colors" aria-label="Test connection">Test connection</button>
<span id="pingStatus" class="ml-2 text-sm text-gray-500 dark:text-gray-400 hidden sm:inline"></span>
</div>
</header>
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
<label for="publicFolderName" class="font-medium">Optional folder name:</label>
<input type="text" id="publicFolderName" name="publicFolderName" maxlength="64" class="mt-1 mx-auto w-full max-w-sm rounded-lg border border-gray-300 p-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500">
</div>
<!-- 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">
<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 -->
<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>
<p class="mt-3 font-medium hidden md:block">Drop images or videos here</p>
<p class="text-sm text-gray-600 dark:text-gray-400 hidden md:block">...or</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>
<!-- Mobile-safe choose control: label wraps the hidden input -->
<div class="mt-3 relative inline-block">
<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
<input id="fileInput"
type="file"
multiple
accept="image/*,video/*"
class="absolute inset-0 opacity-0 cursor-pointer" />
</label>
</div>
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
We never show uploaded media and keep everything session-local. No account required.
</div>
</section>
<!-- Queue summary -->
<section id="summary" class="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">
<!-- 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">
@@ -82,17 +72,9 @@
<section id="items" class="space-y-3"></section>
<footer class="pt-4 pb-10 text-center text-xs text-gray-500 dark:text-gray-400">
Built for simple, account-less image uploads. This page never lists media from the server and only shows your current session's items.
</footer>
</div>
<!-- Sticky mobile upload bar -->
<div class="md:hidden fixed left-0 right-0 bottom-0 z-20 p-3 bg-white/90 dark:bg-gray-900/90 border-t border-gray-200 dark:border-gray-700 backdrop-blur" style="padding-bottom: calc(env(safe-area-inset-bottom) + 12px)">
<div class="mx-auto max-w-4xl">
<button id="btnMobilePick" class="w-full rounded-2xl bg-black text-white dark:bg-white dark:text-black px-5 py-3" aria-label="Choose files">Choose files</button>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Image Drop Uploader (Invite)</title>
<title>File Drop Uploader (Invite)</title>
<link rel="icon" type="image/png" href="/static/favicon.png" />
<script src="https://cdn.tailwindcss.com"></script>
<script>
@@ -21,19 +21,9 @@
<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 flex-wrap gap-2">
<h1 class="text-2xl font-semibold tracking-tight">Image Drop Uploader</h1>
<h1 class="text-2xl font-semibold tracking-tight">File Drop Uploader</h1>
<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>
<button id="btnTheme" class="rounded-xl border px-3 py-1 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>
<svg id="iconDark" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<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>
<a id="linkHome" href="/" class="hidden rounded-xl border px-4 py-2 text-sm dark:border-gray-600">Home</a>
</div>
</header>
@@ -65,15 +55,19 @@
<!-- 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">
<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>
</div>
<p class="mt-3 font-medium hidden md:block">Drop images or videos here</p>
<p class="text-sm text-gray-600 dark:text-gray-400 hidden md:block">...or</p>
<div class="mt-3 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" aria-label="Choose files">
<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>
<!-- Mobile-safe choose control: label wraps the hidden input -->
<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
<input id="fileInput" type="file" multiple accept="image/*,video/*" class="absolute inset-0 opacity-0 cursor-pointer" />
<input id="fileInput"
type="file"
multiple
class="absolute inset-0 opacity-0 cursor-pointer" />
</label>
</div>
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
@@ -81,7 +75,7 @@
</div>
</section>
<section id="summary" class="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">
<!-- 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">
@@ -100,16 +94,10 @@
</section>
<section id="items" class="space-y-3"></section>
<footer class="pt-4 pb-10 text-center text-xs text-gray-500 dark:text-gray-400">
Invite upload page
</footer>
</div>
<!-- Sticky mobile upload bar -->
<div class="md:hidden fixed left-0 right-0 bottom-0 z-20 p-3 bg-white/90 dark:bg-gray-900/90 border-t border-gray-200 dark:border-gray-700 backdrop-blur" style="padding-bottom: calc(env(safe-area-inset-bottom) + 12px)">
<div class="mx-auto max-w-4xl">
<button id="btnMobilePick" class="w-full rounded-2xl bg-black text-white dark:bg-white dark:text-black px-5 py-3" aria-label="Choose files">Choose files</button>
</div>
</div>
<script src="/static/header.js"></script>
<script src="/static/app.js"></script>
</body>
@@ -156,7 +144,6 @@
dz.classList.remove('opacity-50');
if (fi) fi.disabled = false;
itemsEl.innerHTML = '';
try { showBanner('Password accepted. You can upload now.', 'ok'); } catch {}
} catch (e) {
pwError.textContent = 'Error verifying password.';
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)