@@ -0,0 +1,3 @@
|
||||
.certs/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,45 @@
|
||||
# Share
|
||||
|
||||
Local network file sharing via web page. Drag & drop, Ctrl+V paste, download.
|
||||
|
||||
No dependencies — Python 3 stdlib only.
|
||||
|
||||
## Quick start
|
||||
|
||||
```
|
||||
python3 share.py
|
||||
```
|
||||
|
||||
Open `http://localhost:8888` in a browser. The network address is printed at startup.
|
||||
|
||||
## Options
|
||||
|
||||
```
|
||||
python3 share.py -p 3001 -d ~/Downloads/shared --ttl 7
|
||||
```
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `-p`, `--port` | `8888` | Port |
|
||||
| `-d`, `--dir` | `~/Downloads/shared` | Upload directory |
|
||||
| `--ttl` | `7` | File lifetime in days (0 = keep forever) |
|
||||
|
||||
## systemd (Arch)
|
||||
|
||||
Service file: `~/.config/systemd/user/share.service`
|
||||
|
||||
```
|
||||
systemctl --user enable --now share # start + autostart
|
||||
systemctl --user restart share # restart
|
||||
systemctl --user stop share # stop
|
||||
journalctl --user -u share -f # logs
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Drag & drop files onto the page
|
||||
- Ctrl+V to paste images / files from clipboard
|
||||
- Click the drop zone to open file picker
|
||||
- Upload progress bar
|
||||
- File list with download and delete
|
||||
- Auto-cleanup of old files (configurable via `--ttl`)
|
||||
@@ -0,0 +1,8 @@
|
||||
[share]
|
||||
port = 3001
|
||||
dir = ~/Downloads/shared
|
||||
ttl = 7
|
||||
san = 192.168.0.1
|
||||
lang = ru
|
||||
refresh = 5
|
||||
per_page = 10,25,50,100
|
||||
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Share — local file sharing server (HTTPS)."""
|
||||
|
||||
import argparse
|
||||
import configparser
|
||||
import email.parser
|
||||
import http.server
|
||||
import json
|
||||
import mimetypes
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# ── Cleanup ──
|
||||
|
||||
|
||||
def cleanup_loop(upload_dir: Path, max_age: int):
|
||||
if max_age <= 0:
|
||||
return
|
||||
while True:
|
||||
time.sleep(3600)
|
||||
now = time.time()
|
||||
try:
|
||||
for f in upload_dir.iterdir():
|
||||
if f.is_file() and now - f.stat().st_mtime > max_age:
|
||||
f.unlink()
|
||||
print(f" - expired: {f.name}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── SSL ──
|
||||
|
||||
|
||||
def get_local_ip():
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
return ip
|
||||
except Exception:
|
||||
return "127.0.0.1"
|
||||
|
||||
|
||||
def ensure_cert(cert_dir: Path, extra_ips: list[str] | None = None) -> tuple[Path, Path]:
|
||||
"""Generate self-signed cert if missing. Returns (certfile, keyfile)."""
|
||||
cert_dir.mkdir(parents=True, exist_ok=True)
|
||||
cert = cert_dir / "cert.pem"
|
||||
key = cert_dir / "key.pem"
|
||||
if cert.is_file() and key.is_file():
|
||||
return cert, key
|
||||
|
||||
ips = {"127.0.0.1", get_local_ip()}
|
||||
if extra_ips:
|
||||
ips.update(extra_ips)
|
||||
san = ",".join([f"IP:{ip}" for ip in sorted(ips)] + ["DNS:localhost"])
|
||||
|
||||
subprocess.run([
|
||||
"openssl", "req", "-x509", "-newkey", "rsa:2048",
|
||||
"-keyout", str(key), "-out", str(cert),
|
||||
"-days", "3650", "-nodes",
|
||||
"-subj", "/CN=Share",
|
||||
"-addext", f"subjectAltName={san}",
|
||||
], check=True, capture_output=True)
|
||||
print(f" Generated self-signed cert in {cert_dir}")
|
||||
return cert, key
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
# ── HTTP Handler ──
|
||||
|
||||
|
||||
class ShareServer(http.server.ThreadingHTTPServer):
|
||||
def __init__(self, addr, handler, *, upload_dir: Path, html: bytes, ssl_ctx: ssl.SSLContext | None = None):
|
||||
super().__init__(addr, handler)
|
||||
self.upload_dir = upload_dir
|
||||
self.html = html
|
||||
self.ssl_ctx = ssl_ctx
|
||||
|
||||
def get_request(self):
|
||||
client, addr = self.socket.accept()
|
||||
if self.ssl_ctx:
|
||||
client.settimeout(5)
|
||||
try:
|
||||
client = self.ssl_ctx.wrap_socket(client, server_side=True)
|
||||
except (ssl.SSLError, OSError):
|
||||
client.close()
|
||||
raise
|
||||
client.settimeout(None)
|
||||
return client, addr
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
server: ShareServer
|
||||
|
||||
def finish(self):
|
||||
try:
|
||||
super().finish()
|
||||
except (ssl.SSLError, BrokenPipeError, ConnectionResetError, OSError):
|
||||
pass
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
print(f" [{datetime.now():%H:%M:%S}] {args[0]}")
|
||||
|
||||
def _json(self, data):
|
||||
self._respond(json.dumps(data).encode(), "application/json")
|
||||
|
||||
def _respond(self, data, ct):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", ct)
|
||||
self.send_header("Connection", "close")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(data if isinstance(data, bytes) else data.encode())
|
||||
|
||||
# ── Routes ──
|
||||
|
||||
ROUTES = {
|
||||
"GET": [
|
||||
("/", "_route_index"),
|
||||
("/index.html","_route_index"),
|
||||
("/files", "_route_files"),
|
||||
("/dl/", "_route_download"),
|
||||
],
|
||||
"POST": [("/upload", "_route_upload")],
|
||||
"DELETE": [("/del/", "_route_delete")],
|
||||
}
|
||||
|
||||
def _dispatch(self):
|
||||
for prefix, method in self.ROUTES.get(self.command, []):
|
||||
if self.path == prefix or (len(prefix) > 1 and prefix.endswith("/") and self.path.startswith(prefix)):
|
||||
return getattr(self, method)()
|
||||
self.send_error(404)
|
||||
|
||||
do_GET = do_POST = do_DELETE = _dispatch
|
||||
|
||||
def _route_index(self):
|
||||
self._respond(self.server.html, "text/html; charset=utf-8")
|
||||
|
||||
def _route_files(self):
|
||||
files = []
|
||||
for p in sorted(self.server.upload_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
if p.is_file():
|
||||
files.append({"name": p.name, "size": p.stat().st_size})
|
||||
self._json(files)
|
||||
|
||||
def _route_download(self):
|
||||
name = urllib.parse.unquote(self.path[4:])
|
||||
safe = self.server.upload_dir / Path(name).name
|
||||
if not safe.is_file():
|
||||
self.send_error(404)
|
||||
return
|
||||
ct = mimetypes.guess_type(safe.name)[0] or "application/octet-stream"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", ct)
|
||||
self.send_header("Content-Disposition", f'attachment; filename="{safe.name}"')
|
||||
self.send_header("Content-Length", str(safe.stat().st_size))
|
||||
self.end_headers()
|
||||
with open(safe, "rb") as f:
|
||||
while chunk := f.read(65536):
|
||||
self.wfile.write(chunk)
|
||||
|
||||
def _route_upload(self):
|
||||
ct = self.headers.get("Content-Type", "")
|
||||
if "multipart/form-data" not in ct:
|
||||
self.send_error(400)
|
||||
return
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(length)
|
||||
count = self._save_parts(ct, body)
|
||||
self._json({"ok": True, "count": count})
|
||||
|
||||
def _route_delete(self):
|
||||
name = urllib.parse.unquote(self.path[5:])
|
||||
safe = self.server.upload_dir / Path(name).name
|
||||
if safe.is_file():
|
||||
safe.unlink()
|
||||
self._json({"ok": True})
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
def _save_parts(self, content_type: str, body: bytes) -> int:
|
||||
header = f"Content-Type: {content_type}\r\n\r\n".encode()
|
||||
msg = email.parser.BytesParser().parsebytes(header + body)
|
||||
count = 0
|
||||
for part in msg.walk():
|
||||
fname = part.get_filename()
|
||||
if not fname:
|
||||
continue
|
||||
fname = Path(fname).name or f"paste_{datetime.now():%H%M%S}"
|
||||
data = part.get_payload(decode=True)
|
||||
if not data:
|
||||
continue
|
||||
dest = self._unique_path(fname)
|
||||
dest.write_bytes(data)
|
||||
count += 1
|
||||
print(f" + {dest.name} ({len(data)} bytes)")
|
||||
return count
|
||||
|
||||
def _unique_path(self, filename: str) -> Path:
|
||||
dest = self.server.upload_dir / filename
|
||||
if not dest.exists():
|
||||
return dest
|
||||
stem, suffix = dest.stem, dest.suffix
|
||||
i = 1
|
||||
while dest.exists():
|
||||
dest = self.server.upload_dir / f"{stem}_{i}{suffix}"
|
||||
i += 1
|
||||
return dest
|
||||
|
||||
|
||||
# ── Main ──
|
||||
|
||||
|
||||
DEFAULTS = {
|
||||
"port": "3001",
|
||||
"dir": str(Path.home() / "Downloads" / "shared"),
|
||||
"ttl": "7",
|
||||
"san": "",
|
||||
"lang": "ru",
|
||||
"refresh": "5",
|
||||
"per_page": "10,25,50,100",
|
||||
}
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""Load config: defaults ← config file ← CLI args."""
|
||||
cfg_path = BASE_DIR / "share.conf"
|
||||
cp = configparser.ConfigParser()
|
||||
cp.read_dict({"share": DEFAULTS})
|
||||
cp.read(cfg_path)
|
||||
conf = dict(cp["share"])
|
||||
|
||||
p = argparse.ArgumentParser(description="Share — local file sharing server")
|
||||
p.add_argument("-p", "--port", type=int)
|
||||
p.add_argument("-d", "--dir", type=str)
|
||||
p.add_argument("--ttl", type=int)
|
||||
p.add_argument("--san", nargs="*")
|
||||
p.add_argument("--lang", type=str)
|
||||
p.add_argument("--refresh", type=int)
|
||||
args = p.parse_args()
|
||||
|
||||
if args.port is not None:
|
||||
conf["port"] = str(args.port)
|
||||
if args.dir is not None:
|
||||
conf["dir"] = args.dir
|
||||
if args.ttl is not None:
|
||||
conf["ttl"] = str(args.ttl)
|
||||
if args.san is not None:
|
||||
conf["san"] = " ".join(args.san)
|
||||
if args.lang is not None:
|
||||
conf["lang"] = args.lang
|
||||
if args.refresh is not None:
|
||||
conf["refresh"] = str(args.refresh)
|
||||
|
||||
return conf
|
||||
|
||||
|
||||
def main():
|
||||
conf = load_config()
|
||||
|
||||
port = int(conf["port"])
|
||||
upload_dir = Path(conf["dir"]).expanduser().resolve()
|
||||
max_age = int(conf["ttl"]) * 86400
|
||||
san_list = conf["san"].split() if conf["san"] else None
|
||||
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
threading.Thread(target=cleanup_loop, args=(upload_dir, max_age), daemon=True).start()
|
||||
|
||||
ip = get_local_ip()
|
||||
cfg_json = json.dumps({
|
||||
"lang": conf["lang"],
|
||||
"refresh": int(conf["refresh"]),
|
||||
"perPage": [int(x) for x in conf["per_page"].split(",")],
|
||||
})
|
||||
cfg_tag = f"<script>const CFG={cfg_json}</script>".encode()
|
||||
html = (BASE_DIR / "static" / "index.html").read_bytes()
|
||||
html = html.replace(b"<!--CFG-->", cfg_tag)
|
||||
|
||||
cert_dir = BASE_DIR / ".certs"
|
||||
cert, key = ensure_cert(cert_dir, extra_ips=san_list)
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.load_cert_chain(cert, key)
|
||||
|
||||
server = ShareServer(("0.0.0.0", port), Handler, upload_dir=upload_dir, html=html, ssl_ctx=ctx)
|
||||
|
||||
print(f"\n Share running")
|
||||
print(f" Local: https://localhost:{port}")
|
||||
print(f" Network: https://{ip}:{port}")
|
||||
print(f" Files: {upload_dir}\n")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\n Stopped.")
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,542 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Share</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='20' fill='%230a0a0a'/><path d='M50 20v35M50 55l-18-18M50 55l18-18' stroke='%234fc3f7' stroke-width='8' stroke-linecap='round' stroke-linejoin='round' fill='none'/><line x1='25' y1='75' x2='75' y2='75' stroke='%234fc3f7' stroke-width='8' stroke-linecap='round'/></svg>">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--bg-card: #141414;
|
||||
--bg-card-hover: #1a1a1a;
|
||||
--text: #e0e0e0;
|
||||
--text-dim: #888;
|
||||
--text-muted: #555;
|
||||
--text-empty: #444;
|
||||
--accent: #4fc3f7;
|
||||
--danger: #ef5350;
|
||||
--warn: #ff9800;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0 }
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg); color: var(--text);
|
||||
min-height: 100vh; display: flex; flex-direction: column;
|
||||
align-items: center; padding: 20px;
|
||||
}
|
||||
|
||||
h1 { font-size: 1.4rem; margin-bottom: 16px; color: #fff }
|
||||
|
||||
#drop {
|
||||
width: 100%; max-width: 600px;
|
||||
border: 2px dashed var(--text-empty); border-radius: 12px;
|
||||
padding: 48px 24px; text-align: center;
|
||||
cursor: pointer; transition: all .2s;
|
||||
margin-bottom: 20px; position: relative;
|
||||
}
|
||||
#drop.over { border-color: var(--accent); background: rgba(79,195,247,.08) }
|
||||
#drop.uploading { border-color: var(--warn); background: rgba(255,152,0,.06) }
|
||||
#drop p { color: var(--text-dim); font-size: .95rem; pointer-events: none }
|
||||
#drop .hint { font-size: .8rem; color: var(--text-muted); margin-top: 8px }
|
||||
|
||||
#fileinput { display: none }
|
||||
|
||||
#progress { display: none; margin-top: 12px; width: 100%; max-width: 600px }
|
||||
#progress .bar { height: 4px; background: #222; border-radius: 2px; overflow: hidden }
|
||||
#progress .fill { height: 100%; background: var(--accent); width: 0; transition: width .15s }
|
||||
#progress .text { font-size: .75rem; color: var(--text-dim); margin-top: 4px; text-align: center }
|
||||
|
||||
#files { width: 100%; max-width: 600px; margin-top: 4px }
|
||||
#files-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px }
|
||||
#files h2 { font-size: 1rem; color: #aaa; margin-bottom: 0 }
|
||||
|
||||
.file {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px; background: var(--bg-card); border-radius: 8px;
|
||||
margin-bottom: 6px; transition: background .15s;
|
||||
}
|
||||
.file:hover { background: var(--bg-card-hover) }
|
||||
|
||||
.file .fname {
|
||||
color: var(--accent); text-decoration: none; font-size: .9rem;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
flex: 1; margin-right: 12px; cursor: pointer;
|
||||
}
|
||||
.file .fname:hover { text-decoration: underline }
|
||||
|
||||
.file .meta {
|
||||
font-size: .75rem; color: var(--text-muted); white-space: nowrap;
|
||||
display: flex; gap: 4px; align-items: center;
|
||||
}
|
||||
.file .fsize { margin-right: 6px }
|
||||
.file .btn {
|
||||
cursor: pointer; padding: 4px 5px; border-radius: 4px;
|
||||
transition: all .15s; line-height: 1;
|
||||
display: inline-flex; align-items: center;
|
||||
}
|
||||
.file .dl { color: var(--text-muted); font-size: .85rem }
|
||||
.file .dl:hover { color: var(--accent) }
|
||||
.file .del { color: var(--text-muted); font-size: .85rem; padding-right: 0 }
|
||||
.file .del:hover { color: var(--danger) }
|
||||
|
||||
.empty { color: var(--text-empty); font-size: .85rem; text-align: center; padding: 24px }
|
||||
|
||||
.pagination {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
gap: 4px; margin-top: 12px; flex-wrap: wrap;
|
||||
}
|
||||
.pagination button {
|
||||
background: none; color: var(--text-muted); border: none;
|
||||
padding: 4px 8px; font-size: .8rem;
|
||||
cursor: pointer; transition: color .15s;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.pagination button:hover:not(:disabled) { color: #fff }
|
||||
.pagination button.active { color: var(--accent); font-weight: 600 }
|
||||
.pagination button:disabled { opacity: .3; cursor: default }
|
||||
.pagination button.nav { padding: 4px }
|
||||
.pagination button.dots { padding: 4px 2px }
|
||||
|
||||
.per-page {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
font-size: .7rem; color: var(--text-muted);
|
||||
}
|
||||
.per-page span.lbl { margin-right: 2px }
|
||||
.per-page button {
|
||||
background: none; color: var(--text-muted); border: none;
|
||||
padding: 2px 4px; font-size: .7rem;
|
||||
cursor: pointer; transition: color .15s;
|
||||
}
|
||||
.per-page button:hover { color: #fff }
|
||||
.per-page button.active { color: var(--accent) }
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||
background: #222; color: #fff; padding: 10px 20px; border-radius: 8px;
|
||||
font-size: .85rem; opacity: 0; transition: opacity .3s;
|
||||
pointer-events: none; z-index: 99;
|
||||
}
|
||||
.toast.show { opacity: 1 }
|
||||
|
||||
#header {
|
||||
width: 100%; max-width: 600px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-bottom: 16px; position: relative;
|
||||
}
|
||||
#header h1 { margin-bottom: 0 }
|
||||
#lang-sw {
|
||||
position: absolute; right: 0;
|
||||
display: flex; background: var(--bg-card-hover); border-radius: 6px;
|
||||
padding: 2px; gap: 2px; font-size: .7rem;
|
||||
}
|
||||
#lang-sw span {
|
||||
padding: 3px 8px; border-radius: 4px; cursor: pointer;
|
||||
color: var(--text-muted); transition: all .2s; user-select: none;
|
||||
}
|
||||
#lang-sw span.active { background: #333; color: var(--text) }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
|
||||
<symbol id="icon-dl" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="12" y1="5" x2="12" y2="15"/><line x1="7" y1="11" x2="12" y2="16"/>
|
||||
<line x1="17" y1="11" x2="12" y2="16"/><line x1="5" y1="20" x2="19" y2="20"/>
|
||||
</symbol>
|
||||
<symbol id="icon-del" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/>
|
||||
</symbol>
|
||||
<symbol id="icon-prev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 6 9 12 15 18"/>
|
||||
</symbol>
|
||||
<symbol id="icon-next" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 6 15 12 9 18"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
<div id="header">
|
||||
<h1 id="title"></h1>
|
||||
<div id="lang-sw"><span data-l="ru">RU</span><span data-l="en">EN</span></div>
|
||||
</div>
|
||||
|
||||
<div id="drop" tabindex="0">
|
||||
<p id="drop-text"></p>
|
||||
<div class="hint" id="drop-hint"></div>
|
||||
</div>
|
||||
<input type="file" id="fileinput" multiple>
|
||||
|
||||
<div id="progress">
|
||||
<div class="bar"><div class="fill" id="pfill"></div></div>
|
||||
<div class="text" id="ptext"></div>
|
||||
</div>
|
||||
|
||||
<div id="files">
|
||||
<div id="files-header">
|
||||
<h2 id="files-title"></h2>
|
||||
<div class="per-page" id="per-page"></div>
|
||||
</div>
|
||||
<div id="flist"></div>
|
||||
<div class="pagination" id="pager"></div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<!--CFG-->
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const drop = $('drop'), fi = $('fileinput'), flist = $('flist'),
|
||||
pfill = $('pfill'), ptext = $('ptext'), toastEl = $('toast');
|
||||
|
||||
/* ── Config (from server or defaults) ── */
|
||||
|
||||
const _cfg = typeof CFG !== 'undefined' ? CFG : {};
|
||||
const defaultLang = _cfg.lang || 'ru';
|
||||
const REFRESH_SEC = (_cfg.refresh || 5) * 1000;
|
||||
const PER_PAGE_OPTS = _cfg.perPage || [10, 25, 50, 100];
|
||||
|
||||
/* ── i18n ── */
|
||||
|
||||
const I18N = {
|
||||
ru: {
|
||||
title: 'Поделиться',
|
||||
drop: 'Перетащите файлы или нажмите для выбора',
|
||||
hint: 'Ctrl+V — вставить изображения / файлы / текст',
|
||||
files: 'Файлы',
|
||||
empty: 'Файлов пока нет',
|
||||
copy: 'Копировать',
|
||||
download: 'Скачать',
|
||||
delete: 'Удалить',
|
||||
copied: 'Скопировано',
|
||||
linkCopied: 'Ссылка скопирована',
|
||||
imgCopied: 'Изображение скопировано',
|
||||
copyFail: 'Не удалось скопировать',
|
||||
uploaded: n => 'Загружено: ' + n,
|
||||
uploadFail: 'Ошибка загрузки',
|
||||
uploadError: 'Ошибка соединения',
|
||||
deleted: 'Удалено',
|
||||
perPage: 'На странице:',
|
||||
sz: [' Б', ' КБ', ' МБ', ' ГБ'],
|
||||
},
|
||||
en: {
|
||||
title: 'Share',
|
||||
drop: 'Drop files here or click to select',
|
||||
hint: 'Ctrl+V to paste images / files / text',
|
||||
files: 'Files',
|
||||
empty: 'No files yet',
|
||||
copy: 'Copy to clipboard',
|
||||
download: 'Download',
|
||||
delete: 'Delete',
|
||||
copied: 'Copied',
|
||||
linkCopied: 'Copied link',
|
||||
imgCopied: 'Copied image',
|
||||
copyFail: 'Copy failed',
|
||||
uploaded: n => 'Uploaded ' + n + ' file(s)',
|
||||
uploadFail: 'Upload failed',
|
||||
uploadError: 'Upload error',
|
||||
deleted: 'Deleted',
|
||||
perPage: 'Per page:',
|
||||
sz: [' B', ' KB', ' MB', ' GB'],
|
||||
}
|
||||
};
|
||||
|
||||
let lang = localStorage.getItem('share-lang') || defaultLang;
|
||||
function t() { return I18N[lang] }
|
||||
|
||||
function setLang(l) {
|
||||
lang = l;
|
||||
localStorage.setItem('share-lang', l);
|
||||
document.documentElement.lang = l;
|
||||
$('lang-sw').querySelectorAll('span').forEach(s => s.classList.toggle('active', s.dataset.l === l));
|
||||
$('title').textContent = t().title;
|
||||
$('drop-text').textContent = t().drop;
|
||||
$('drop-hint').textContent = t().hint;
|
||||
$('files-title').textContent = t().files;
|
||||
loadFiles();
|
||||
}
|
||||
|
||||
$('lang-sw').addEventListener('click', e => {
|
||||
if (e.target.dataset.l) setLang(e.target.dataset.l);
|
||||
});
|
||||
|
||||
const TEXT_EXT = new Set(
|
||||
'txt,log,json,xml,csv,html,css,js,ts,py,md,sh,yml,yaml,toml,ini,cfg,conf,env,sql,rs,go,java,c,h,cpp,rb,php'.split(',')
|
||||
);
|
||||
const IMG_EXT = new Set('png,jpg,jpeg,gif,webp,bmp,ico,svg'.split(','));
|
||||
|
||||
/* ── Helpers ── */
|
||||
|
||||
let toastTimer;
|
||||
function toast(msg) {
|
||||
toastEl.textContent = msg;
|
||||
toastEl.classList.add('show');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 2500);
|
||||
}
|
||||
|
||||
function fmtSize(b) {
|
||||
const s = t().sz;
|
||||
if (b < 1024) return b + s[0];
|
||||
if (b < 1048576) return (b / 1024).toFixed(1) + s[1];
|
||||
if (b < 1073741824) return (b / 1048576).toFixed(1) + s[2];
|
||||
return (b / 1073741824).toFixed(2) + s[3];
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
function fileExt(name) {
|
||||
return (name.split('.').pop() || '').toLowerCase();
|
||||
}
|
||||
|
||||
/* ── Clipboard ── */
|
||||
|
||||
function fallbackCopy(text) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;opacity:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
}
|
||||
|
||||
function copyText(text, msg) {
|
||||
msg = msg || t().copied;
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => toast(msg))
|
||||
.catch(() => { fallbackCopy(text) ? toast(msg) : toast(t().copyFail) });
|
||||
} else {
|
||||
fallbackCopy(text) ? toast(msg) : toast(t().copyFail);
|
||||
}
|
||||
}
|
||||
|
||||
function toPngBlob(blob) {
|
||||
if (blob.type === 'image/png') return Promise.resolve(blob);
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = img.naturalWidth;
|
||||
c.height = img.naturalHeight;
|
||||
c.getContext('2d').drawImage(img, 0, 0);
|
||||
c.toBlob(b => b ? resolve(b) : reject(new Error('toBlob failed')), 'image/png');
|
||||
URL.revokeObjectURL(img.src);
|
||||
};
|
||||
img.onerror = () => { URL.revokeObjectURL(img.src); reject(new Error('img load failed')); };
|
||||
img.src = URL.createObjectURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function copyFile(name) {
|
||||
const url = '/dl/' + encodeURIComponent(name);
|
||||
const ext = fileExt(name);
|
||||
|
||||
if (TEXT_EXT.has(ext)) {
|
||||
fetch(url).then(r => r.text()).then(text => copyText(text))
|
||||
.catch(() => toast(t().copyFail));
|
||||
|
||||
} else if (IMG_EXT.has(ext)) {
|
||||
const link = location.origin + url;
|
||||
if (!navigator.clipboard?.write) {
|
||||
copyText(link, t().linkCopied);
|
||||
return;
|
||||
}
|
||||
const pngBlob = fetch(url).then(r => r.blob()).then(toPngBlob);
|
||||
navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
|
||||
.then(() => toast(t().imgCopied))
|
||||
.catch(() => copyText(link, t().linkCopied));
|
||||
|
||||
} else {
|
||||
copyText(location.origin + url, t().linkCopied);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Upload ── */
|
||||
|
||||
function upload(fileList) {
|
||||
if (!fileList.length) return;
|
||||
const fd = new FormData();
|
||||
for (const f of fileList) fd.append('files', f);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
$('progress').style.display = 'block';
|
||||
drop.classList.add('uploading');
|
||||
|
||||
xhr.upload.onprogress = e => {
|
||||
if (!e.lengthComputable) return;
|
||||
const pct = Math.round(e.loaded / e.total * 100);
|
||||
pfill.style.width = pct + '%';
|
||||
ptext.textContent = fmtSize(e.loaded) + ' / ' + fmtSize(e.total) + ' (' + pct + '%)';
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
drop.classList.remove('uploading');
|
||||
pfill.style.width = '100%';
|
||||
setTimeout(() => { $('progress').style.display = 'none'; pfill.style.width = '0' }, 1200);
|
||||
if (xhr.status === 200) {
|
||||
const r = JSON.parse(xhr.responseText);
|
||||
toast(t().uploaded(r.count));
|
||||
loadFiles();
|
||||
} else { toast(t().uploadFail) }
|
||||
};
|
||||
|
||||
xhr.onerror = () => { drop.classList.remove('uploading'); toast(t().uploadError) };
|
||||
xhr.open('POST', '/upload');
|
||||
xhr.send(fd);
|
||||
}
|
||||
|
||||
/* ── Pagination ── */
|
||||
|
||||
let perPage = parseInt(localStorage.getItem('share-perpage')) || PER_PAGE_OPTS[0];
|
||||
let curPage = 1;
|
||||
let allFiles = [];
|
||||
|
||||
function setPerPage(n) {
|
||||
perPage = n;
|
||||
localStorage.setItem('share-perpage', n);
|
||||
curPage = 1;
|
||||
renderFiles();
|
||||
}
|
||||
|
||||
function setPage(p) {
|
||||
curPage = p;
|
||||
renderFiles();
|
||||
}
|
||||
|
||||
function renderPager(total) {
|
||||
const pages = Math.ceil(total / perPage);
|
||||
const pager = $('pager');
|
||||
const pp = $('per-page');
|
||||
|
||||
if (pages <= 1 && total <= PER_PAGE_OPTS[0]) { pager.innerHTML = ''; pp.innerHTML = ''; return }
|
||||
|
||||
/* per-page selector */
|
||||
pp.innerHTML = '<span class="lbl">' + t().perPage + '</span>' +
|
||||
PER_PAGE_OPTS.map(n =>
|
||||
`<button class="${n === perPage ? 'active' : ''}" data-action="pp" data-pp="${n}">${n}</button>`
|
||||
).join('');
|
||||
|
||||
if (pages <= 1) { pager.innerHTML = ''; return }
|
||||
|
||||
const icon = id => `<svg width="16" height="16"><use href="#icon-${id}"/></svg>`;
|
||||
let html = `<button class="nav" data-action="page" data-page="${curPage - 1}" ${curPage === 1 ? 'disabled' : ''}>${icon('prev')}</button>`;
|
||||
|
||||
const show = new Set();
|
||||
for (let i = 1; i <= pages; i++) {
|
||||
if (i <= 2 || i > pages - 2 || Math.abs(i - curPage) <= 1) show.add(i);
|
||||
}
|
||||
let prev = 0;
|
||||
for (const i of [...show].sort((a, b) => a - b)) {
|
||||
if (prev && i - prev > 1) html += '<button class="dots" disabled>…</button>';
|
||||
html += `<button class="${i === curPage ? 'active' : ''}" data-action="page" data-page="${i}">${i}</button>`;
|
||||
prev = i;
|
||||
}
|
||||
|
||||
html += `<button class="nav" data-action="page" data-page="${curPage + 1}" ${curPage === pages ? 'disabled' : ''}>${icon('next')}</button>`;
|
||||
pager.innerHTML = html;
|
||||
}
|
||||
|
||||
/* ── File list ── */
|
||||
|
||||
function renderFiles() {
|
||||
if (!allFiles.length) {
|
||||
flist.innerHTML = '<div class="empty">' + t().empty + '</div>';
|
||||
$('pager').innerHTML = '';
|
||||
$('per-page').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const pages = Math.ceil(allFiles.length / perPage);
|
||||
if (curPage > pages) curPage = pages;
|
||||
const start = (curPage - 1) * perPage;
|
||||
const slice = allFiles.slice(start, start + perPage);
|
||||
|
||||
flist.innerHTML = slice.map(f => `
|
||||
<div class="file">
|
||||
<span class="fname" data-action="copy" data-name="${esc(f.name)}" title="${t().copy}">${esc(f.name)}</span>
|
||||
<div class="meta">
|
||||
<span class="fsize">${fmtSize(f.size)}</span>
|
||||
<a class="btn dl" href="/dl/${encodeURIComponent(f.name)}" title="${t().download}"><svg width="14" height="14"><use href="#icon-dl"/></svg></a>
|
||||
<span class="btn del" data-action="del" data-name="${esc(f.name)}" title="${t().delete}"><svg width="14" height="14"><use href="#icon-del"/></svg></span>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
|
||||
renderPager(allFiles.length);
|
||||
}
|
||||
|
||||
function loadFiles() {
|
||||
fetch('/files').then(r => r.json()).then(files => {
|
||||
allFiles = files;
|
||||
renderFiles();
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Event delegation ── */
|
||||
|
||||
flist.addEventListener('click', e => {
|
||||
const el = e.target.closest('[data-action]');
|
||||
if (!el) return;
|
||||
const action = el.dataset.action;
|
||||
const name = el.dataset.name;
|
||||
if (action === 'copy') copyFile(name);
|
||||
else if (action === 'del') {
|
||||
fetch('/del/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||
.then(() => { toast(t().deleted); loadFiles() });
|
||||
}
|
||||
});
|
||||
|
||||
$('pager').addEventListener('click', e => {
|
||||
const el = e.target.closest('[data-action="page"]');
|
||||
if (!el || el.disabled) return;
|
||||
setPage(parseInt(el.dataset.page));
|
||||
});
|
||||
|
||||
$('per-page').addEventListener('click', e => {
|
||||
const el = e.target.closest('[data-action="pp"]');
|
||||
if (!el) return;
|
||||
setPerPage(parseInt(el.dataset.pp));
|
||||
});
|
||||
|
||||
/* ── Events ── */
|
||||
|
||||
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('over') });
|
||||
drop.addEventListener('dragleave', () => drop.classList.remove('over'));
|
||||
drop.addEventListener('drop', e => { e.preventDefault(); drop.classList.remove('over'); upload(e.dataTransfer.files) });
|
||||
drop.addEventListener('click', () => fi.click());
|
||||
fi.addEventListener('change', () => { if (fi.files.length) upload(fi.files); fi.value = '' });
|
||||
|
||||
document.addEventListener('paste', e => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
const files = [];
|
||||
let textItem = null;
|
||||
for (const item of items) {
|
||||
if (item.kind === 'file') {
|
||||
const f = item.getAsFile();
|
||||
if (f) files.push(f);
|
||||
} else if (item.kind === 'string' && item.type === 'text/plain' && !textItem) {
|
||||
textItem = item;
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length) { e.preventDefault(); upload(files) }
|
||||
else if (textItem) {
|
||||
e.preventDefault();
|
||||
textItem.getAsString(text => {
|
||||
if (!text.trim()) return;
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
upload([new File([text], 'paste_' + ts + '.txt', { type: 'text/plain' })]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setLang(lang);
|
||||
setInterval(loadFiles, REFRESH_SEC);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user