Compare commits

..

29 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
20 changed files with 654 additions and 491 deletions
+13 -8
View File
@@ -8,11 +8,17 @@ TIMEZONE=America/Edmonton
# Public uploader page (optional) # Public uploader page (optional)
PUBLIC_UPLOAD_PAGE_ENABLED=true PUBLIC_UPLOAD_PAGE_ENABLED=true
# Local dedupe cache (SQLite) # Login cookie session secret
STATE_DB=./data/state.db # 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) # Custom DB location
# e.g., PUBLIC_BASE_URL=https://photos.example.com #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= #PUBLIC_BASE_URL=
LOG_LEVEL=INFO LOG_LEVEL=INFO
@@ -23,13 +29,12 @@ LOG_LEVEL=INFO
CHUNKED_UPLOADS_ENABLED=true CHUNKED_UPLOADS_ENABLED=true
CHUNK_SIZE_MB=50 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 # Optional Telegram bot for upload alerts and control
# create a bot using @BotFather then copy the API key here # create a bot using @BotFather then copy the API key here
# get your account's ID by messaging https://t.me/userinfobot # get your account's ID by messaging https://t.me/userinfobot
# Leave these blank to disable # Leave these blank to disable
# Example:
# TELEGRAM_BOT_API_KEY=1234567890:ABCDefghIjKlmnOPQRsT-UVWXyzABCdefGH
# TELEGRAM_BOT_OWNER_ID=12345678
TELEGRAM_BOT_API_KEY= TELEGRAM_BOT_API_KEY=
TELEGRAM_BOT_OWNER_ID= TELEGRAM_BOT_OWNER_ID=
+4 -10
View File
@@ -1,28 +1,22 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
FROM python:3.11-slim FROM python:3.11-slim
WORKDIR /image_drop WORKDIR /file_drop
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1
# Install Python deps # Install Python deps
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \ RUN pip install --no-cache-dir -r requirements.txt
&& pip install --no-cache-dir python-multipart
# Copy app code # Copy app code
COPY . /image_drop COPY . /file_drop
# Data dir for SQLite (state.db)
#RUN mkdir -p /data
#VOLUME ["/data"]
# Defaults (can be overridden via compose env) # Defaults (can be overridden via compose env)
ENV HOST=0.0.0.0 \ ENV HOST=0.0.0.0 \
PORT=8080 \ PORT=8080 \
STATE_DB=/image_drop/data/state.db STATE_DB=/file_drop/data/state.db
EXPOSE 8080 EXPOSE 8080
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 Simon Adams Copyright (c) 2025 Simon Adams, Tanner
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+132 -221
View File
@@ -1,198 +1,162 @@
# Immich Drop Uploader # File Drop Uploader
A tiny web app for collecting photos/videos into your **Immich** server. A self-hosted web app for uploading files and media and saving them to the filesystem on your server.
Admin users log in to create public invite links; invite links are always public-by-URL. A public uploader page is optional and disabled by default. Useful for letting people upload vacation photos, etc. just by sending them a link.
![Immich Drop Uploader Dark Mode UI](./screenshot.png) Admin user can create invite links with optional limits and password protection. A public uploader page is optional and enabled by default.
[View Screenshots](screenshots.md)
## Features ## Features
- **Invite Links:** public-by-URL links for uploads; one-time or multi-use - **Local Saving:** All uploaded files are saved to the server's local filesystem.
- **Manage Links:** search/sort, enable/disable, delete, edit name/expiry - **Drag and Drop:** Upload multiple files and folders by dragging them onto the page.
- **Row Actions:** icon-only actions with tooltips (Open, Copy, Details, QR, Save) - **Invite Links:** Create sharable links for uploads; one-time or multi-use.
- **Passwords (optional):** protect invites with a password gate - **Manage Links:** Search/sort, enable/disable, delete, edit name/expiry.
- **Albums (optional):** upload into a specific album (auto-create supported) - **Passwords (optional):** Protect invite links with a password.
- **Duplicate Prevention:** local SHA1 cache (+ optional Immich bulk-check) - **Albums:** Upload into a specific folder (auto-create supported). Preserves client-side folder structure on upload.
- **Progress Queue:** WebSocket updates; retry failed items - **Telegram Notifications (optional):** Get notified via Telegram when upload batches are complete.
- **Chunked Uploads (optional):** large-file support with configurable chunk size - **Progress Queue:** WebSocket updates; see upload progress in real-time.
- **Privacy-first:** never lists server media; session-local uploads only - **Chunked Uploads (optional):** Large-file support with configurable chunk size.
- **Mobile + Dark Mode:** responsive UI, safe-area padding, persistent theme - **Mobile + Dark Mode:** Responsive UI, safe-area padding, persistent theme.
---
## Table of contents
- [Quick start](#quick-start)
- [New Features](#new-features)
- [Chunked Uploads](#chunked-uploads)
- [Architecture](#architecture)
- [Folder structure](#folder-structure)
- [Requirements](#requirements)
- [Configuration (.env)](#configuration-env)
- [How it works](#how-it-works)
- [Mobile notes](#mobile-notes)
- [Troubleshooting](#troubleshooting)
- [Security notes](#security-notes)
- [Development](#development)
- [License](#license)
---
## Quick start ## Quick start
You can run without a `.env` file by putting all settings in `docker-compose.yml` (recommended for deploys).
Use a `.env` file only for local development.
### docker-compose.yml (deploy without .env) Clone the repo.
Copy `.env.example` to `.env` and edit.
### Docker Compose
Create `docker-compose.yml` and edit:
```yaml ```yaml
version: "3.9"
services: services:
immich-drop: file-drop:
image: ghcr.io/nasogaa/immich-drop:latest build: .
pull_policy: always container_name: file-drop
container_name: immich-drop
restart: unless-stopped restart: unless-stopped
# Configure all settings here (no .env required)
environment:
# Immich connection (must include /api)
IMMICH_BASE_URL: https://immich.example.com/api
IMMICH_API_KEY: ${IMMICH_API_KEY}
# Optional behavior
IMMICH_ALBUM_NAME: dead-drop
PUBLIC_UPLOAD_PAGE_ENABLED: "false" # keep disabled by default
PUBLIC_BASE_URL: https://drop.example.com
# Large files: chunked uploads (bypass 100MB proxy limits)
CHUNKED_UPLOADS_ENABLED: "false" # enable chunked uploads
CHUNK_SIZE_MB: "95" # per-chunk size (MB)
# App internals
SESSION_SECRET: ${SESSION_SECRET}
# Expose the app on the host
ports: ports:
- 8080:8080 - "8080:8080"
env_file:
# Persist local dedupe cache (state.db) across restarts - .env
volumes: volumes:
- immich_drop_data:/data - ./data:/file_drop/data
- /mnt/example/file-drop:/file_drop/data/uploads
# Simple healthcheck
healthcheck: healthcheck:
test: ["CMD-SHELL", "python - <<'PY'\nimport os,urllib.request,sys; url=f\"http://127.0.0.1:{os.getenv('PORT','8080')}/\";\ntry: urllib.request.urlopen(url, timeout=3); sys.exit(0)\nexcept Exception: sys.exit(1)\nPY"] test: ["CMD-SHELL", "python - <<'PY'\nimport urllib.request,sys\ntry:\n urllib.request.urlopen('http://localhost:8080/').read(); sys.exit(0)\nexcept Exception:\n sys.exit(1)\nPY"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 10s start_period: 10s
volumes:
immich_drop_data:
``` ```
``` Start the service:
### CLI
```bash ```bash
docker compose pull $ sudo docker compose up --build -d
docker compose up -d
``` ```
---
## What's New Set up nginx / a reverse proxy and point it to the web app.
### v0.5.0 Manage Links overhaul Make sure it allows WebSocket connections through, for example:
- 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. server {
- The frontend automatically switches to chunked mode only for files larger than the configured chunk size. root /var/www/html;
index index.html index.htm;
server_name upload.example.com;
### 📱 DeviceFlexible HMI (New) listen 80;
- 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) client_max_body_size 100M;
- 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 location / {
- Added chunked uploads with configurable chunk size. proxy_pass http://127.0.0.1:8080/;
- Added optional passwords for invite links with inUI unlock prompt. proxy_set_header Host $http_host;
- Responsive HMI overhaul: mobilesafe picker, sticky mobile action bar, safearea support. proxy_set_header X-Real-IP $remote_addr;
- Retry for failed uploads and improved progress handling. proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- Support for invites with no album association. proxy_set_header X-Forwarded-Proto $scheme;
### 🌙 Dark Mode # websockets
- Automatic or manual toggle; persisted preference proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_redirect off;
}
}
```
### 📁 Album Integration Then restart nginx and set up HTTPS:
- Auto-create + assign album if configured; optional invites without album
--- ```
$ sudo service nginx restart
$ sudo certbot --nginx
```
## Chunked Uploads
- Enable chunked uploads by setting `CHUNKED_UPLOADS_ENABLED=true`. ### Config Changes
- 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.
--- 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 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. - **Frontend:** static HTML/JS (Tailwind). Drag & drop or "Choose files", queue UI with progress and status chips.
- **Backend:** FastAPI + Uvicorn. - **Backend:** FastAPI + Uvicorn.
- Proxies uploads to Immich `/assets` - Saves uploaded files to the local filesystem.
- Computes SHA1 and checks a local SQLite cache (`state.db`) - WebSocket `/ws` pushes peritem progress to the current browser session only.
- Optional Immich dedupe via `/assets/bulk-upload-check` - **Persistence:** A local SQLite database (`state.db`) prevents reuploads across sessions. Uploaded files are stored in `/data/uploads`.
- WebSocket `/ws` pushes peritem progress to the current browser session only
- **Persistence:** local SQLite (`state.db`) prevents reuploads across sessions/runs.
--- ### Setup
## Folder structure Requires Python 3.11+.
``` Create a venv, activate it, and install:
immich_drop/
├─ app/ # FastAPI application (Python package) ```text
│ ├─ app.py # ASGI app (uvicorn entry: app.app:app) $ virtualenv -p python3 env
│ └─ config.py # Settings loader (reads .env/env) $ source env/bin/activate
├─ frontend/ # Static UI (served at /static) (env) $ pip install -r requirements.txt
│ ├─ 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
``` ```
--- ```text
(env) $ cp .env.example .env
## Requirements (env) $ vim .env
```
- **Python** 3.11
- An **Immich** server + **API key**
---
# Local dev quickstart
## Development
Run with live reload: Run with live reload:
@@ -200,82 +164,29 @@ Run with live reload:
python main.py python main.py
``` ```
The backend contains docstrings so you can generate docs later if desired. ### How it works
--- 1. **Queue** - Files selected in the browser are queued; each gets a client-side ID.
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. - The menu and invite creation are behind login. Logout clears the session.
- Invite links are public by URL; share only with intended recipients. - 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 public uploader page at `/` is enabled unless disabled with `PUBLIC_UPLOAD_PAGE_ENABLED=false`.
- The Immich API key remains **serverside**; the browser never sees it.
- No browsing of uploaded media; only ephemeral session state is shown. - No browsing of uploaded media; only ephemeral session state is shown.
- Run behind HTTPS with a reverse proxy and restrict CORS to your domain(s). - Run behind HTTPS with a reverse proxy and restrict CORS to your domain(s).
## Usage flow
- Admin: Login → Menu → Create invite link (optionally onetime / expiry / album) → Share link or QR.
- Guest: Open invite link → Drop files → Upload progress and results shown.
- Optional: Enable public uploader and set `IMMICH_ALBUM_NAME` for a default landing page.
---
## License ## License
MIT. This program is free and open-source software licensed under the MIT License. Please see the `LICENSE` file for details.
That means you have the right to study, change, and distribute the software and source code to anyone and for any purpose. You deserve these rights.
## Acknowledgements
This project was forked from "Immich Drop Uploader" by Simon Adams: https://github.com/Nasogaa/immich-drop
+227 -129
View File
@@ -1,9 +1,8 @@
""" """
Immich Drop Uploader Backend (FastAPI, simplified) File Drop Uploader Backend (FastAPI, simplified)
---------------------------------------------------- ----------------------------------------------------
- Serves static frontend (no settings UI) - Serves static frontend (no settings UI)
- Uploads to Immich using values from .env ONLY - Uploads to file system
- Duplicate checks (local SHA-1 DB + optional Immich bulk-check)
- WebSocket progress per session - WebSocket progress per session
- Ephemeral "Connected" banner via /api/ping - Ephemeral "Connected" banner via /api/ping
""" """
@@ -18,6 +17,9 @@ import hashlib
import os import os
import sqlite3 import sqlite3
import binascii import binascii
import base64
import mimetypes
import zipfile
import pytz import pytz
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
@@ -26,12 +28,12 @@ import math
import logging import logging
import httpx import httpx
from fastapi import FastAPI, UploadFile, WebSocket, WebSocketDisconnect, Request, Form from fastapi import FastAPI, UploadFile, WebSocket, WebSocketDisconnect, Request, Form
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse, Response from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse, Response, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.websockets import WebSocketState from starlette.websockets import WebSocketState
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from PIL import Image, ExifTags from PIL import Image
try: try:
import qrcode import qrcode
except Exception: except Exception:
@@ -40,17 +42,21 @@ except Exception:
from app.config import Settings, load_settings from app.config import Settings, load_settings
# ---- App & static ---- # ---- 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# Global settings (read-only at runtime)
SETTINGS: Settings = load_settings()
_public_uploads_enabled_runtime = SETTINGS.public_upload_page_enabled _public_uploads_enabled_runtime = SETTINGS.public_upload_page_enabled
@@ -60,7 +66,7 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
logger = logging.getLogger("immich_drop") logger = logging.getLogger("immich_drop")
# Cookie-based session for short-lived auth token storage (no persistence) # Cookie-based session for short-lived auth token storage (no persistence)
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") FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend")
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static") app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
@@ -73,61 +79,6 @@ except Exception:
pass 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 ---------- # ---------- WebSocket hub ----------
@@ -235,7 +186,7 @@ def _schedule_batch_notification():
asyncio.create_task(send_batch_notification()) asyncio.create_task(send_batch_notification())
async def reset_telegram_debounce(): async def reset_telegram_debounce():
"""Resets the 30s timer for batch completion notification.""" """Resets the 120s timer for batch completion notification."""
if not SETTINGS.telegram_bot_api_key or not TELEGRAM_OWNER_ID: if not SETTINGS.telegram_bot_api_key or not TELEGRAM_OWNER_ID:
return return
@@ -244,7 +195,7 @@ async def reset_telegram_debounce():
if _batch_complete_timer: if _batch_complete_timer:
_batch_complete_timer.cancel() _batch_complete_timer.cancel()
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
_batch_complete_timer = loop.call_later(10, _schedule_batch_notification) _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): async def add_file_to_batch(filename: str, size: int, album_name: str, is_invite: bool):
"""Adds a completed file to the batch list.""" """Adds a completed file to the batch list."""
@@ -404,33 +355,6 @@ def get_safe_subpath(relative_path: Optional[str]) -> str:
return os.path.join(*safe_parts) return os.path.join(*safe_parts)
def read_exif_datetimes(file_bytes: bytes):
"""
Extract EXIF DateTimeOriginal / ModifyDate values when possible.
Returns (created, modified) as datetime or (None, None) on failure.
"""
created = modified = None
try:
with Image.open(io.BytesIO(file_bytes)) as im:
exif = getattr(im, "_getexif", lambda: None)() or {}
if exif:
tags = {ExifTags.TAGS.get(k, k): v for k, v in exif.items()}
dt_original = tags.get("DateTimeOriginal") or tags.get("CreateDate")
dt_modified = tags.get("ModifyDate") or dt_original
def parse_dt(s: str):
try:
return datetime.strptime(s, "%Y:%m:%d %H:%M:%S")
except Exception:
return None, None
if isinstance(dt_original, str):
created = parse_dt(dt_original)
if isinstance(dt_modified, str):
modified = parse_dt(dt_modified)
except Exception:
pass
return created, modified
def slugify(value: Optional[str]) -> str: def slugify(value: Optional[str]) -> str:
""" """
Normalizes string, converts to lowercase, removes non-alpha characters, Normalizes string, converts to lowercase, removes non-alpha characters,
@@ -599,26 +523,11 @@ async def api_upload(
fingerprint: Optional[str] = Form(None), fingerprint: Optional[str] = Form(None),
public_folder_name: 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() raw = await file.read()
size = len(raw) size = len(raw)
checksum = sha1_hex(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) # Invite token validation (if provided)
target_album_name: Optional[str] = None target_album_name: Optional[str] = None
@@ -727,7 +636,6 @@ async def api_upload(
i += 1 i += 1
with open(save_path, "wb") as f: with open(save_path, "wb") as f:
f.write(raw) f.write(raw)
db_insert_upload(checksum, file.filename, size, device_asset_id, None, created_iso)
await add_file_to_batch(file.filename, size, display_album_name, bool(invite_token)) await add_file_to_batch(file.filename, size, display_album_name, bool(invite_token))
await reset_telegram_debounce() await reset_telegram_debounce()
@@ -876,7 +784,7 @@ async def api_upload_chunk(
@app.post("/api/upload/chunk/complete") @app.post("/api/upload/chunk/complete")
async def api_upload_chunk_complete(request: Request) -> JSONResponse: 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: try:
data = await request.json() data = await request.json()
except Exception: except Exception:
@@ -948,21 +856,6 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse:
file_like_name = name file_like_name = name
file_size = len(raw) file_size = len(raw)
checksum = sha1_hex(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 # Invite validation/gating mirrors api_upload
target_album_name: Optional[str] = None target_album_name: Optional[str] = None
@@ -1063,7 +956,6 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse:
i += 1 i += 1
with open(save_path, "wb") as f: with open(save_path, "wb") as f:
f.write(raw) f.write(raw)
db_insert_upload(checksum, file_like_name, file_size, device_asset_id, None, created_iso)
await add_file_to_batch(file_like_name, file_size, display_album_name, bool(invite_token)) await add_file_to_batch(file_like_name, file_size, display_album_name, bool(invite_token))
msg = f"Saved to {display_album_name}/{os.path.basename(save_path)}" msg = f"Saved to {display_album_name}/{os.path.basename(save_path)}"
@@ -1127,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") await send_progress(session_id_local, item_id_local, "error", 100, "Failed to save file locally")
return JSONResponse({"error": "local_save_failed"}, status_code=500) return JSONResponse({"error": "local_save_failed"}, status_code=500)
@app.post("/api/uploads/batch_complete_hint")
async def api_batch_complete_hint(request: Request) -> JSONResponse:
"""
Client-side hint that a batch of uploads has completed.
This triggers the batch notification immediately instead of waiting for the debounce timer.
"""
# session_id from body is optional, for future use, but not currently used
# because the batch is global.
await send_batch_notification()
return JSONResponse({"ok": True})
# ---------- Auth & Albums & Invites APIs ---------- # ---------- Auth & Albums & Invites APIs ----------
@@ -1213,6 +1116,201 @@ async def api_albums_create(request: Request) -> JSONResponse:
logger.exception("Create album directory failed: %s", e) logger.exception("Create album directory failed: %s", e)
return JSONResponse({"error": "create_album_failed"}, status_code=500) 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) ---------- # ---------- Invites (one-time/expiring links) ----------
def ensure_invites_table() -> None: def ensure_invites_table() -> None:
+18 -10
View File
@@ -17,13 +17,13 @@ class Settings:
"""App settings loaded from environment variables (.env).""" """App settings loaded from environment variables (.env)."""
admin_password: str admin_password: str
max_concurrent: int max_concurrent: int
public_upload_page_enabled: bool = False public_upload_page_enabled: bool = True
public_base_url: str = "" public_base_url: str = ""
state_db: str = "" state_db: str = ""
session_secret: str = "" session_secret: str = ""
log_level: str = "INFO" log_level: str = "INFO"
chunked_uploads_enabled: bool = False chunked_uploads_enabled: bool = True
chunk_size_mb: int = 95 chunk_size_mb: int = 50
timezone: str = "UTC" timezone: str = "UTC"
telegram_bot_api_key: str = "" telegram_bot_api_key: str = ""
telegram_bot_owner_id: str = "" telegram_bot_owner_id: str = ""
@@ -48,7 +48,8 @@ def load_settings() -> Settings:
load_dotenv() load_dotenv()
except Exception: except Exception:
pass pass
admin_password = os.getenv("ADMIN_PASSWORD", "admin") # Default for convenience, should be changed
admin_password = os.getenv("ADMIN_PASSWORD", "test123") # Default for convenience, should be changed
if not admin_password.startswith("pbkdf2_sha256-"): if not admin_password.startswith("pbkdf2_sha256-"):
print("="*60) print("="*60)
print("WARNING: ADMIN_PASSWORD is in plaintext.") print("WARNING: ADMIN_PASSWORD is in plaintext.")
@@ -57,27 +58,34 @@ def load_settings() -> Settings:
if hashed_pw: if hashed_pw:
print(f"ADMIN_PASSWORD={hashed_pw}") print(f"ADMIN_PASSWORD={hashed_pw}")
print("="*60) print("="*60)
# Safe defaults: disable public uploader and invites unless explicitly enabled
def as_bool(v: str, default: bool = False) -> bool: def as_bool(v: str, default: bool = False) -> bool:
if v is None: if v is None:
return default return default
return str(v).strip().lower() in {"1","true","yes","on"} return str(v).strip().lower() in {"1","true","yes","on"}
public_upload = as_bool(os.getenv("PUBLIC_UPLOAD_PAGE_ENABLED", "false"), False)
public_upload = as_bool(os.getenv("PUBLIC_UPLOAD_PAGE_ENABLED", "false"), True)
try: try:
maxc = int(os.getenv("MAX_CONCURRENT", "3")) maxc = int(os.getenv("MAX_CONCURRENT", "3"))
except ValueError: except ValueError:
maxc = 3 maxc = 3
state_db = os.getenv("STATE_DB", "/data/state.db")
state_db = os.getenv("STATE_DB", "./data/state.db")
session_secret = os.getenv("SESSION_SECRET") or secrets.token_hex(32) session_secret = os.getenv("SESSION_SECRET") or secrets.token_hex(32)
log_level = os.getenv("LOG_LEVEL", "INFO").upper() log_level = os.getenv("LOG_LEVEL", "INFO").upper()
chunked_uploads_enabled = as_bool(os.getenv("CHUNKED_UPLOADS_ENABLED", "false"), False)
chunked_uploads_enabled = as_bool(os.getenv("CHUNKED_UPLOADS_ENABLED", "false"), True)
try: try:
chunk_size_mb = int(os.getenv("CHUNK_SIZE_MB", "95")) chunk_size_mb = int(os.getenv("CHUNK_SIZE_MB", "50"))
except ValueError: except ValueError:
chunk_size_mb = 95 chunk_size_mb = 50
timezone = os.getenv("TIMEZONE", "UTC") timezone = os.getenv("TIMEZONE", "UTC")
telegram_bot_api_key = os.getenv("TELEGRAM_BOT_API_KEY", "") telegram_bot_api_key = os.getenv("TELEGRAM_BOT_API_KEY", "")
telegram_bot_owner_id = os.getenv("TELEGRAM_BOT_OWNER_ID", "") telegram_bot_owner_id = os.getenv("TELEGRAM_BOT_OWNER_ID", "")
return Settings( return Settings(
admin_password=admin_password, admin_password=admin_password,
max_concurrent=maxc, max_concurrent=maxc,
-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:
+24 -14
View File
@@ -34,10 +34,11 @@ try {
} catch {} } catch {}
let items = []; let items = [];
let socket; let socket;
let allCompleteBannerShown = false;
// Status precedence: never regress (e.g., uploading -> done shouldn't go back to uploading) // Status precedence: never regress (e.g., uploading -> done shouldn't go back to uploading)
const STATUS_ORDER = { queued: 0, checking: 1, uploading: 2, duplicate: 3, done: 3, error: 4 }; const STATUS_ORDER = { queued: 0, checking: 1, uploading: 2, done: 3, error: 4 };
const FINAL_STATES = new Set(['done','duplicate','error']); const FINAL_STATES = new Set(['done','error']);
// --- Dark mode --- // --- Dark mode ---
function initDarkMode() { function initDarkMode() {
@@ -92,6 +93,7 @@ function addItem(file, relativePath){
const resolvedPath = (relativePath !== undefined) ? relativePath : (file.webkitRelativePath || ''); const resolvedPath = (relativePath !== undefined) ? relativePath : (file.webkitRelativePath || '');
const it = { id, file, name: file.name, size: file.size, status: 'queued', progress: 0, relativePath: resolvedPath }; const it = { id, file, name: file.name, size: file.size, status: 'queued', progress: 0, relativePath: resolvedPath };
items.unshift(it); items.unshift(it);
allCompleteBannerShown = false;
render(); render();
} }
@@ -117,7 +119,7 @@ function render(){
</div> </div>
</div> </div>
<div class="mt-3 h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700"> <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>
<div class="mt-2 text-sm text-gray-600 dark:text-gray-400"> <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)} ${it.status==='uploading' ? `Uploading… ${it.progress}%` : it.status.charAt(0).toUpperCase()+it.status.slice(1)}
@@ -142,19 +144,30 @@ function render(){
}); });
} catch {} } 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){ for(const it of items){
if(['queued','checking'].includes(it.status)) c.queued++; if(['queued','checking'].includes(it.status)) c.queued++;
if(it.status==='uploading') c.uploading++; if(it.status==='uploading') c.uploading++;
if(it.status==='done') c.done++; if(it.status==='done') c.done++;
if(it.status==='duplicate') c.dup++;
if(it.status==='error') c.err++; if(it.status==='error') c.err++;
} }
document.getElementById('countQueued').textContent=c.queued; document.getElementById('countQueued').textContent=c.queued;
document.getElementById('countUploading').textContent=c.uploading; document.getElementById('countUploading').textContent=c.uploading;
document.getElementById('countDone').textContent=c.done; document.getElementById('countDone').textContent=c.done;
document.getElementById('countDup').textContent=c.dup;
document.getElementById('countErr').textContent=c.err; document.getElementById('countErr').textContent=c.err;
if (!allCompleteBannerShown && items.length > 0) {
const isComplete = items.every(it => FINAL_STATES.has(it.status));
const hasSuccess = items.some(it => it.status === 'done');
if (isComplete && hasSuccess) {
showBanner("All uploads complete.", "ok");
allCompleteBannerShown = true;
// Hint to backend that this batch is done, to trigger notification sooner
try {
fetch('/api/uploads/batch_complete_hint', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ session_id: sessionId }) }).catch(()=>{});
} catch {}
}
}
} }
// --- WebSocket progress --- // --- WebSocket progress ---
@@ -237,12 +250,10 @@ async function uploadWhole(next){
render(); render();
} else if (res.ok) { } else if (res.ok) {
const statusText = (body && body.status) ? String(body.status) : ''; const statusText = (body && body.status) ? String(body.status) : '';
const isDuplicate = /duplicate/i.test(statusText); next.status = 'done';
next.status = isDuplicate ? 'duplicate' : 'done'; next.message = statusText || 'Uploaded';
next.message = statusText || (isDuplicate ? 'Duplicate' : 'Uploaded');
next.progress = 100; next.progress = 100;
render(); render();
try { if (isDuplicate) showBanner(`Duplicate: ${next.name}`, 'warn'); } catch {}
} }
} }
@@ -308,9 +319,8 @@ async function uploadChunked(next){
render(); render();
} else if (rc.ok) { } else if (rc.ok) {
const statusText = (body && body.status) ? String(body.status) : ''; const statusText = (body && body.status) ? String(body.status) : '';
const isDuplicate = /duplicate/i.test(statusText); next.status = 'done';
next.status = isDuplicate ? 'duplicate' : 'done'; next.message = statusText || 'Uploaded';
next.message = statusText || (isDuplicate ? 'Duplicate' : 'Uploaded');
next.progress = 100; next.progress = 100;
render(); render();
} }
@@ -476,7 +486,7 @@ if (btnMobilePick) {
// --- Clear buttons --- // --- Clear buttons ---
btnClearFinished.onclick = ()=>{ btnClearFinished.onclick = ()=>{
items = items.filter(i => !['done','duplicate'].includes(i.status)); items = items.filter(i => !['done'].includes(i.status));
render(); render();
// also tell server to refresh album cache so a renamed album triggers a new one // also tell server to refresh album cache so a renamed album triggers a new one
fetch('/api/album/reset', { method: 'POST' }).catch(()=>{}); fetch('/api/album/reset', { method: 'POST' }).catch(()=>{});
-1
View File
@@ -62,7 +62,6 @@
<span class="whitespace-nowrap">Queued/Processing: <b id="countQueued">0</b></span> <span class="whitespace-nowrap">Queued/Processing: <b id="countQueued">0</b></span>
<span class="whitespace-nowrap">Uploading: <b id="countUploading">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">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> <span class="whitespace-nowrap">Errors: <b id="countErr">0</b></span>
</div> </div>
</div> </div>
-1
View File
@@ -87,7 +87,6 @@
<span class="whitespace-nowrap">Queued/Processing: <b id="countQueued">0</b></span> <span class="whitespace-nowrap">Queued/Processing: <b id="countQueued">0</b></span>
<span class="whitespace-nowrap">Uploading: <b id="countUploading">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">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> <span class="whitespace-nowrap">Errors: <b id="countErr">0</b></span>
</div> </div>
</div> </div>
+10 -8
View File
@@ -14,7 +14,7 @@
<div class="mx-auto max-w-2xl p-6 space-y-6"> <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> <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"> <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"> <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 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"> <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"/> <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg> </svg>
</button> </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> <span id="pingStatus" class="ml-2 text-sm text-gray-500 dark:text-gray-400"></span>
</div> </div>
</header> </header>
@@ -33,13 +32,9 @@
<h2 class="text-lg font-medium mb-2">Enter your credentials</h2> <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> <div id="msg" class="hidden mb-3 rounded-lg border p-2 text-sm"></div>
<form id="loginForm" class="space-y-3"> <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> <div>
<label class="block text-sm mb-1">Password</label> <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>
<div class="flex items-center justify-between"> <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> <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> </div>
<script src="/static/header.js"></script> <script src="/static/header.js"></script>
<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 form = document.getElementById('loginForm');
const msg = document.getElementById('msg'); const msg = document.getElementById('msg');
function show(kind, text){ function show(kind, text){
@@ -59,7 +61,7 @@
} }
form.onsubmit = async (e)=>{ form.onsubmit = async (e)=>{
e.preventDefault(); e.preventDefault();
const email = document.getElementById('email').value.trim(); const email = 'admin';
const password = document.getElementById('password').value; const password = document.getElementById('password').value;
try{ try{
const r = await fetch('/api/login', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({email, password}) }); 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"> <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 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> <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"> <header class="flex items-center justify-between flex-wrap gap-y-2">
<h1 class="text-2xl font-semibold">Create Upload Link</h1> <h1 class="text-2xl font-semibold">Admin</h1>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 flex-wrap">
<a id="linkPublicUploader" href="/" class="hidden rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Public uploader</a> <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-1 text-sm dark:border-gray-600">Logout</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-1 text-sm dark:border-gray-600" title="Toggle dark mode"> <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"> <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"/> <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>
@@ -26,8 +26,6 @@
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/> <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg> </svg>
</button> </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> </div>
</header> </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"> <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"> <div class="flex items-center justify-between gap-2 flex-wrap">
<h2 class="text-lg font-medium">Manage Links</h2> <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"/> <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"> <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> <option value="-created">Newest</option>
@@ -98,11 +96,11 @@
<thead> <thead>
<tr class="text-left border-b dark:border-gray-700"> <tr class="text-left border-b dark:border-gray-700">
<th class="py-2"><input id="chkAll" type="checkbox"/></th> <th class="py-2"><input id="chkAll" type="checkbox"/></th>
<th class="py-2" style="width: 45%;">Name</th> <th class="py-2">Name</th>
<th class="py-2" style="width: 18%;">Status</th> <th class="py-2">Status</th>
<th class="py-2">Uses</th> <th class="py-2 hidden md:table-cell">Uses</th>
<th class="py-2">Expires</th> <th class="py-2 hidden md:table-cell">Expires</th>
<th class="py-2">Folder</th> <th class="py-2 hidden md:table-cell">Folder</th>
<th class="py-2">Actions</th> <th class="py-2">Actions</th>
</tr> </tr>
</thead> </thead>
@@ -117,8 +115,46 @@
</div> </div>
</section> </section>
<section class="text-xs text-gray-500"> <!-- Manage Files -->
Admin link page <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> </section>
</div> </div>
@@ -261,15 +297,15 @@
return ` return `
<tr class="border-b dark:border-gray-800" data-token="${row.token}"> <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"><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;')}"/> <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>
<td class="py-2">${status}</td> <td class="py-2">${status}</td>
<td class="py-2">${uses}</td> <td class="py-2 hidden md:table-cell">${uses}</td>
<td class="py-2"> <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():''}"/> <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>
<td class="py-2">${row.albumName || '—'}</td> <td class="py-2 hidden md:table-cell">${row.albumName || '—'}</td>
<td class="py-2"> <td class="py-2">
<div class="flex items-center gap-1 whitespace-nowrap"> <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"> <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 // Initial load
loadInvites(); 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> </script>
</body> </body>
</html> </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)