Compare commits

...

11 Commits

Author SHA1 Message Date
f45f60ef31 Login page tweaks 2026-05-19 20:49:39 +00:00
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
09994feebc refactor: Update admin page title to Admin 2026-05-19 19:36:10 +00:00
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
3a881bb560 Add SESSION_SECRET env var to .env 2026-05-19 19:23:19 +00:00
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
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
2f18b1ba6b feat: Hardcode 'admin' username for login and autofocus password 2026-05-19 19:07:36 +00:00
bdaa8d9049 Remove references to duplicate checking from readme 2026-05-19 18:56:46 +00:00
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
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
8 changed files with 75 additions and 171 deletions

View File

@@ -8,8 +8,13 @@ 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=
# Custom DB location
#STATE_DB=
# Base URL for generating absolute invite links # Base URL for generating absolute invite links
# Recommended for production, also sets CORS headers # Recommended for production, also sets CORS headers

View File

@@ -15,7 +15,6 @@ Admin user can create invite links with optional limits and password protection.
- **Manage Links:** Search/sort, enable/disable, delete, edit name/expiry. - **Manage Links:** Search/sort, enable/disable, delete, edit name/expiry.
- **Passwords (optional):** Protect invite links with a password. - **Passwords (optional):** Protect invite links with a password.
- **Albums:** Upload into a specific folder (auto-create supported). Preserves client-side folder structure on upload. - **Albums:** Upload into a specific folder (auto-create supported). Preserves client-side folder structure on upload.
- **Duplicate Prevention:** Local SHA1 cache prevents re-uploading the same file.
- **Telegram Notifications (optional):** Get notified via Telegram when upload batches are complete. - **Telegram Notifications (optional):** Get notified via Telegram when upload batches are complete.
- **Progress Queue:** WebSocket updates; see upload progress in real-time. - **Progress Queue:** WebSocket updates; see upload progress in real-time.
- **Chunked Uploads (optional):** Large-file support with configurable chunk size. - **Chunked Uploads (optional):** Large-file support with configurable chunk size.
@@ -130,7 +129,7 @@ Then message the bot you just created "/start" so that it's able to interact wit
- Chunked uploads are enabled by default. Uses setting `CHUNKED_UPLOADS_ENABLED=true`. - 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. - Configure chunk size with `CHUNK_SIZE_MB` (default: `50`). The client only uses chunked mode for files larger than this.
- Intended to bypass upstream proxy limits (e.g., 100MB) while preserving duplicate checks, EXIF timestamps, album add, and peritem progress via WebSocket. - Intended to bypass upstream proxy limits (e.g., 100MB) while preserving EXIF timestamps, album add, and peritem progress via WebSocket.
## Development ## Development
@@ -139,7 +138,6 @@ Then message the bot you just created "/start" so that it's able to interact wit
- **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.
- Saves uploaded files to the local filesystem. - Saves uploaded files to the local filesystem.
- Computes SHA1 and checks a local SQLite cache (`state.db`) to prevent duplicates.
- WebSocket `/ws` pushes peritem progress to the current browser session only. - 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`. - **Persistence:** A local SQLite database (`state.db`) prevents reuploads across sessions. Uploaded files are stored in `/data/uploads`.
@@ -169,7 +167,6 @@ python main.py
### How it works ### How it works
1. **Queue** - Files selected in the browser are queued; each gets a client-side ID. 1. **Queue** - Files selected in the browser are queued; each gets a client-side ID.
2. **De-dupe (local)** - Server computes **SHA1** and checks `state.db`. If seen, marks as **duplicate**.
3. **Save** - The file is saved to the local filesystem under `./data/uploads`. 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. 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. 5. **Progress** - Backend streams progress via WebSocket to the same session.

View File

@@ -3,7 +3,6 @@ File Drop Uploader Backend (FastAPI, simplified)
---------------------------------------------------- ----------------------------------------------------
- Serves static frontend (no settings UI) - Serves static frontend (no settings UI)
- Uploads to file system - Uploads to file system
- Duplicate checks (local SHA-1 DB)
- WebSocket progress per session - WebSocket progress per session
- Ephemeral "Connected" banner via /api/ping - Ephemeral "Connected" banner via /api/ping
""" """
@@ -34,7 +33,7 @@ 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:
@@ -67,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")
@@ -80,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 ----------
@@ -411,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,
@@ -606,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; 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
@@ -734,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()
@@ -955,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
@@ -1070,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)}"

View File

@@ -37,8 +37,8 @@ let socket;
let allCompleteBannerShown = false; 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() {
@@ -119,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)}
@@ -144,18 +144,16 @@ 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) { if (!allCompleteBannerShown && items.length > 0) {
@@ -252,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 {}
} }
} }
@@ -323,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();
} }
@@ -491,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(()=>{});

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>

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>

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}) });

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>
@@ -121,7 +119,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 Files</h2> <h2 class="text-lg font-medium">Manage Files</h2>
<div class="flex items-center gap-2"> <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"/> <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"> <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">Newest</option>
@@ -151,8 +149,12 @@
</div> </div>
</section> </section>
<section class="text-xs text-gray-500"> <section class="text-xs text-gray-500 space-y-2">
Admin link page <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>
@@ -295,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">
@@ -544,7 +546,7 @@
} }
const html = ` const html = `
<div class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"> <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="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="flex items-center justify-between mb-2">
<div class="text-lg font-medium">Files</div> <div class="text-lg font-medium">Files</div>
@@ -574,16 +576,36 @@
wrap.innerHTML = html; wrap.innerHTML = html;
const dlg = wrap.firstElementChild; const dlg = wrap.firstElementChild;
document.body.appendChild(dlg); document.body.appendChild(dlg);
document.body.style.overflow = 'hidden';
const close = () => { try { dlg.remove(); } catch {} }; 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.onclick = (e) => { if (e.target === e.currentTarget) close(); };
dlg.querySelectorAll('.dlgClose').forEach(b => b.onclick = close); dlg.querySelectorAll('.dlgClose').forEach(b => b.onclick = close);
if (location.hash !== '#gallery') {
history.pushState(null, '', '#gallery');
}
} }
btnFilesRefresh.onclick = loadDirs; btnFilesRefresh.onclick = loadDirs;
filesSearchQ.oninput = () => { clearTimeout(filesSearchQ._t); filesSearchQ._t = setTimeout(loadDirs, 300); }; filesSearchQ.oninput = () => { clearTimeout(filesSearchQ._t); filesSearchQ._t = setTimeout(loadDirs, 300); };
filesSortSel.onchange = loadDirs; filesSortSel.onchange = loadDirs;
window.addEventListener('popstate', () => {
const galleryModal = document.querySelector('#gallery-modal');
if (galleryModal) {
document.body.style.overflow = '';
galleryModal.remove();
}
});
loadDirs(); loadDirs();
</script> </script>
</body> </body>