Compare commits

...

55 Commits

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

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

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