first commit
10
.dockerignore
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.git
|
||||||
|
.git.gitignore
|
||||||
|
.env
|
||||||
|
dist
|
||||||
|
sites
|
||||||
|
server.js
|
||||||
5
.env
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
TYPE=http
|
||||||
|
HOST=192.168.1.3
|
||||||
|
PORT=3600
|
||||||
|
URL=https://mys.patachina2.casacam.net
|
||||||
|
# SITES=composerize,composeverter,decomposerize
|
||||||
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
composerize/
|
||||||
|
composeverter/
|
||||||
|
decomposerize/
|
||||||
86
Dockerfile
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# --------
|
||||||
|
# STAGE: base runtime con Node e strumenti minimi
|
||||||
|
# --------
|
||||||
|
FROM node:20-bookworm-slim AS runtime-base
|
||||||
|
|
||||||
|
# Impostazioni ambiente e sicurezza build
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Aggiorna e installa Python + strumenti
|
||||||
|
# Nota: build-essential utile per pacchetti Python nativi (puoi rimuoverlo se non necessario)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 python3-venv python3-pip \
|
||||||
|
ca-certificates curl tini \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Workdir applicazione
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# Crea utente non-root e cartelle
|
||||||
|
#RUN useradd -m -u 10001 appuser \
|
||||||
|
# && mkdir -p /usr/src/app \
|
||||||
|
# && chown -R appuser:appuser /usr/src/app
|
||||||
|
|
||||||
|
# --------
|
||||||
|
# STAGE: dipendenze Node
|
||||||
|
# --------
|
||||||
|
FROM runtime-base AS node-deps
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
#copia tutto
|
||||||
|
COPY . .
|
||||||
|
COPY downloadsite-docker.sh downloadsite.sh
|
||||||
|
# Installa packages e dipendenze
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# --------
|
||||||
|
# STAGE: dipendenze Python (venv)
|
||||||
|
# --------
|
||||||
|
FROM node-deps AS python-deps
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# crea venv
|
||||||
|
RUN python3 -m venv /opt/pyenv \
|
||||||
|
&& /opt/pyenv/bin/pip install --upgrade pip \
|
||||||
|
&& /opt/pyenv/bin/pip install -r requirements.txt
|
||||||
|
|
||||||
|
# --------
|
||||||
|
# STAGE: final image
|
||||||
|
# --------
|
||||||
|
FROM python-deps AS final
|
||||||
|
|
||||||
|
# Riduci ulteriormente l'immagine togliendo strumenti di build se li avevi installati
|
||||||
|
# (In questo esempio li teniamo per eventuali run-time native libs; opzionale rimuoverli con apt-get purge)
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Imposta PATH per usare il venv e crea symlink "python3" puntato al venv
|
||||||
|
ENV PATH="/opt/pyenv/bin:${PATH}"
|
||||||
|
ENV PATH="/usr/src/app:${PATH}"
|
||||||
|
# Symlink esplicito utile se il tuo codice chiama "python3" hard-coded
|
||||||
|
RUN ln -sf /opt/pyenv/bin/python /usr/local/bin/python3
|
||||||
|
|
||||||
|
# Sicurezza: esegui come utente non-root
|
||||||
|
#USER appuser
|
||||||
|
|
||||||
|
# Espone la porta dell'Express server
|
||||||
|
EXPOSE 3600
|
||||||
|
|
||||||
|
# Healthcheck semplice sulla root
|
||||||
|
#HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
# CMD curl -fsS http://localhost:3000/ || exit 1
|
||||||
|
|
||||||
|
# Usa tini come init per gestire segnali e orphan processes
|
||||||
|
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||||
|
|
||||||
|
|
||||||
|
# Avvio
|
||||||
|
CMD ["node", "server_docker.js"]
|
||||||
47
README.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Creare un server per più website statici
|
||||||
|
|
||||||
|
per questo esempio scaricheremo i siti dal web usando
|
||||||
|
|
||||||
|
(https://forgit.patachina.it/Fabio/website-downloader.git)
|
||||||
|
|
||||||
|
1. creare il folder principale es: dock clonando la git
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://forgit.patachina.it/Fabio/multi_static_website.git dock
|
||||||
|
```
|
||||||
|
|
||||||
|
2. scaricare i vari siti in directory differenti all'interno di dock
|
||||||
|
|
||||||
|
```
|
||||||
|
cd dock
|
||||||
|
downloadsite.sh https://www.decomposerize.com/ decomposerize
|
||||||
|
downloadsite.sh https://www.composerize.com/ composerize
|
||||||
|
downloadsite.sh https://www.composeverter.com/ composeverter
|
||||||
|
```
|
||||||
|
|
||||||
|
3. installare i packages per il server npm
|
||||||
|
|
||||||
|
npm install
|
||||||
|
|
||||||
|
4. inserire i parametri del server
|
||||||
|
|
||||||
|
http o https
|
||||||
|
IP
|
||||||
|
porta
|
||||||
|
|
||||||
|
5. inserire le directory separate da ,
|
||||||
|
|
||||||
|
SITES=
|
||||||
|
|
||||||
|
6. il file diventa
|
||||||
|
|
||||||
|
```sh
|
||||||
|
TYPE=http
|
||||||
|
HOST=192.168.1.3
|
||||||
|
PORT=12000
|
||||||
|
SITES=composerize,composeverter,decomposerize
|
||||||
|
```
|
||||||
|
|
||||||
|
7. avviare il server
|
||||||
|
|
||||||
|
node server.js
|
||||||
15
docker-compose.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: sites:latest
|
||||||
|
container_name: sites
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- 3600:3000
|
||||||
|
volumes:
|
||||||
|
- /home/nvme/dockerdata/sites:/usr/src/app/sites
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3000
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
TYPE: http
|
||||||
|
URL: https://mys.patachina2.casacam.net
|
||||||
6
downloadsite-docker.sh
Executable file
|
|
@ -0,0 +1,6 @@
|
||||||
|
#!/bin/bash
|
||||||
|
python website-downloader.py \
|
||||||
|
--url $1 \
|
||||||
|
--destination $2 \
|
||||||
|
--max-pages 100 \
|
||||||
|
--threads 8
|
||||||
7
downloadsite.sh
Executable file
|
|
@ -0,0 +1,7 @@
|
||||||
|
#!/bin/bash
|
||||||
|
source /usr/local/python/website-downloader/.venv/bin/activate
|
||||||
|
python /usr/local/python/website-downloader/website-downloader.py \
|
||||||
|
--url $1 \
|
||||||
|
--destination $2 \
|
||||||
|
--max-pages 100 \
|
||||||
|
--threads 8
|
||||||
163
generateSitesJson.js
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
// Assicurati di avere "type": "module" nel package.json
|
||||||
|
// oppure salva il file come .mjs
|
||||||
|
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
|
||||||
|
const root = "./home/sites"; // cartella principale
|
||||||
|
const outputFile = "./home/sites.json"; // file di output
|
||||||
|
const iconsDir = "./home/icons"; // cartella dove salvare le icone
|
||||||
|
const baseDir = "./home";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancella un'icona dato il path relativo (es. "icons/composerize.ico")
|
||||||
|
* @param {string} iconPath - Percorso relativo dell'icona rispetto a baseDir
|
||||||
|
*/
|
||||||
|
function deleteIconSync(iconPath) {
|
||||||
|
try {
|
||||||
|
const fullPath = path.join(baseDir, iconPath);
|
||||||
|
fs.unlinkSync(fullPath);
|
||||||
|
console.log(`Cancellato: ${fullPath}`);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
console.error("File non trovato:", iconPath);
|
||||||
|
} else {
|
||||||
|
console.error("Errore durante la cancellazione:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function parseSite(folder) {
|
||||||
|
const sitePath = path.join(root, folder);
|
||||||
|
let shortName = folder; // fallback
|
||||||
|
let iconPath = null;
|
||||||
|
|
||||||
|
// 1. Cerca manifest.json
|
||||||
|
const manifestPath = path.join(sitePath, "manifest.json");
|
||||||
|
if (fs.existsSync(manifestPath)) {
|
||||||
|
try {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||||
|
if (manifest.short_name) shortName = manifest.short_name;
|
||||||
|
if (manifest.icons && manifest.icons.length > 0) {
|
||||||
|
iconPath = path.join(sitePath, manifest.icons[0].src);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Se non c’è manifest, prova index.html
|
||||||
|
const indexPath = path.join(sitePath, "index.html");
|
||||||
|
if (!iconPath && fs.existsSync(indexPath)) {
|
||||||
|
const html = fs.readFileSync(indexPath, "utf8");
|
||||||
|
const titleMatch = html.match(/<title>(.*?)<\/title>/i);
|
||||||
|
if (titleMatch) shortName = titleMatch[1];
|
||||||
|
|
||||||
|
const iconMatch = html.match(/<link[^>]+rel=["']icon["'][^>]+href=["']([^"']+)["']/i);
|
||||||
|
if (iconMatch) {
|
||||||
|
iconPath = path.join(sitePath, iconMatch[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback su favicon.ico
|
||||||
|
const faviconPath = path.join(sitePath, "favicon.ico");
|
||||||
|
if (!iconPath && fs.existsSync(faviconPath)) {
|
||||||
|
iconPath = faviconPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Copia l’icona nella cartella comune
|
||||||
|
let finalIcon = "icons/default.png"; // path pulito
|
||||||
|
if (iconPath && fs.existsSync(iconPath)) {
|
||||||
|
const ext = path.extname(iconPath) || ".png";
|
||||||
|
const dest = path.join(iconsDir, `${folder}${ext}`);
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(iconPath, dest);
|
||||||
|
finalIcon = `icons/${folder}${ext}`; // solo riferimento relativo
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Errore copia icona per ${folder}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dir: folder, name: shortName, icon: finalIcon };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSitesJson() {
|
||||||
|
// crea la cartella icons se non esiste
|
||||||
|
if (!fs.existsSync(iconsDir)) {
|
||||||
|
fs.mkdirSync(iconsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scansiona tutte le sottocartelle (escludendo home e icons)
|
||||||
|
const sites = fs.readdirSync(root).filter(f =>
|
||||||
|
fs.statSync(path.join(root, f)).isDirectory() && f !== "home" && f !== "icons"
|
||||||
|
);
|
||||||
|
console.log("leggo i siti:", sites);
|
||||||
|
//console.log("primo sito", parseSite(sites[0]));
|
||||||
|
// Estrarre info
|
||||||
|
const results = sites.map(parseSite);
|
||||||
|
|
||||||
|
// Salva in JSON
|
||||||
|
fs.writeFileSync(outputFile, JSON.stringify(results, null, 2), "utf8");
|
||||||
|
results.unshift({ dir: 'home', name: 'Home', icon: 'home.ico' });
|
||||||
|
console.log(`✅ File JSON salvato in ${outputFile}`);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// elimina dal file sites.json gli oggetti con dir = "a"
|
||||||
|
export function delFieldDir(dir) {
|
||||||
|
if (!fs.existsSync(outputFile)) {
|
||||||
|
console.error("❌ File non trovato:", outputFile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(outputFile, "utf8");
|
||||||
|
const results = JSON.parse(data);
|
||||||
|
|
||||||
|
const filt = results.filter(item => item.dir == dir);
|
||||||
|
//console.log("icona da canc: ",filt);
|
||||||
|
console.log("icona da canc: ",filt[0].icon);
|
||||||
|
deleteIconSync(filt[0].icon);
|
||||||
|
// filtra via gli elementi con directory = dir
|
||||||
|
const filtered = results.filter(item => item.dir !== dir);
|
||||||
|
//console.log("nuovo sites.json", filtered);
|
||||||
|
fs.writeFileSync(outputFile, JSON.stringify(filtered, null, 2), "utf8");
|
||||||
|
console.log(`✅ File aggiornato: rimossi i campi con dir = ${dir}`);
|
||||||
|
filtered.unshift({ dir: 'home', name: 'Home', icon: 'home.ico' });
|
||||||
|
return filtered;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Errore durante la modifica del file:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addFieldDir(dir) {
|
||||||
|
if (!fs.existsSync(outputFile)) {
|
||||||
|
console.error("❌ File non trovato:", outputFile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(outputFile, "utf8");
|
||||||
|
const results = JSON.parse(data);
|
||||||
|
//console.log("sito agg",parseSite(dir));
|
||||||
|
// aggiungi elemonto elementi con directory = dir
|
||||||
|
results.push(parseSite(dir));
|
||||||
|
//console.log("add site nuovo sites.json", results);
|
||||||
|
//const filtered = results.filter(item => item.dir !== dir);
|
||||||
|
//console.log("nuovo sites.json", filtered);
|
||||||
|
fs.writeFileSync(outputFile, JSON.stringify(results, null, 2), "utf8");
|
||||||
|
results.unshift({ dir: 'home', name: 'Home', icon: 'home.ico' });
|
||||||
|
console.log(`✅ File aggiornato: rimossi i campi con dir = ${dir}`);
|
||||||
|
return results;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Errore durante la modifica del file:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
home/icons/composerize.ico
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
home/icons/composerize.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
home/icons/composeverter.ico
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
home/icons/composeverter.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
home/icons/composterize.ico
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
home/icons/decomposerize.ico
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
home/icons/decomposerize.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
50
home/index.html
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Sites Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Sites Dashboard</h1>
|
||||||
|
<div id="grid" class="grid"></div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
async function loadSites() {
|
||||||
|
// carica sites.json dalla cartella superiore
|
||||||
|
const res = await fetch("./sites.json");
|
||||||
|
const sites = await res.json();
|
||||||
|
|
||||||
|
const grid = document.getElementById("grid");
|
||||||
|
grid.innerHTML = "";
|
||||||
|
|
||||||
|
sites.forEach(site => {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "card";
|
||||||
|
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = site.icon; // già completo
|
||||||
|
img.alt = site.name;
|
||||||
|
|
||||||
|
const title = document.createElement("div");
|
||||||
|
title.className = "title";
|
||||||
|
title.textContent = site.name;
|
||||||
|
|
||||||
|
card.appendChild(img);
|
||||||
|
card.appendChild(title);
|
||||||
|
|
||||||
|
// 👉 aggiungi click handler
|
||||||
|
card.addEventListener("click", () => {
|
||||||
|
// cambia l'iframe che contiene la dashboard
|
||||||
|
window.parent.document.getElementById("contentFrame").src = `/${site.dir}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSites();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
319
home/manifest-editor.html
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Editor Manifest PWA</title>
|
||||||
|
|
||||||
|
<!-- Link al manifest con id per cache-busting -->
|
||||||
|
<link rel="manifest" id="manifestLink" href="/manifest.json?v=0">
|
||||||
|
<meta name="theme-color" content="#0d6efd">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; margin: 2rem; }
|
||||||
|
fieldset { border: 1px solid #ddd; padding: 1rem; margin-bottom: 1rem; border-radius: 8px; }
|
||||||
|
label { display: block; margin-top: .5rem; }
|
||||||
|
input[type="text"], input[type="color"], select, textarea {
|
||||||
|
width: 100%; max-width: 640px; padding: .5rem; margin-top: .25rem;
|
||||||
|
}
|
||||||
|
.icons { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
|
||||||
|
.icon-card { border: 1px solid #eee; border-radius: 8px; padding: .75rem; background: #fafafa; }
|
||||||
|
.row { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
|
||||||
|
button { padding: .5rem .75rem; border-radius: 6px; border: 1px solid #ccc; background: #fff; cursor: pointer; }
|
||||||
|
button.primary { background: #0d6efd; color: #fff; border-color: #0d6efd; }
|
||||||
|
button.danger { background: #dc3545; color: #fff; border-color: #dc3545; }
|
||||||
|
.status { margin-top: .5rem; font-size: .95rem; color: #555; }
|
||||||
|
.thumb { width: 64px; height: 64px; object-fit: contain; border: 1px solid #ddd; background: #fff; border-radius: 6px; }
|
||||||
|
.meta { font-size: .9rem; color: #333; }
|
||||||
|
.note { font-size: .85rem; color: #666; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Editor Manifest PWA</h1>
|
||||||
|
|
||||||
|
<div class="status" id="status">Caricamento manifest...</div>
|
||||||
|
|
||||||
|
<form id="manifestForm">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Campi principali</legend>
|
||||||
|
<label>name <input type="text" id="name"></label>
|
||||||
|
<label>short_name <input type="text" id="short_name"></label>
|
||||||
|
<label>description <textarea id="description" rows="3"></textarea></label>
|
||||||
|
<label>start_url <input type="text" id="start_url" placeholder="/"></label>
|
||||||
|
<label>scope <input type="text" id="scope" placeholder="/"></label>
|
||||||
|
<label>display
|
||||||
|
<select id="display">
|
||||||
|
<option value="standalone">standalone</option>
|
||||||
|
<option value="fullscreen">fullscreen</option>
|
||||||
|
<option value="minimal-ui">minimal-ui</option>
|
||||||
|
<option value="browser">browser</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="row">
|
||||||
|
<label style="flex:1">background_color <input type="color" id="background_color" value="#ffffff"></label>
|
||||||
|
<label style="flex:1">theme_color <input type="color" id="theme_color" value="#0d6efd"></label>
|
||||||
|
</div>
|
||||||
|
<label>orientation
|
||||||
|
<select id="orientation">
|
||||||
|
<option value="">(nessuna)</option>
|
||||||
|
<option value="any">any</option>
|
||||||
|
<option value="natural">natural</option>
|
||||||
|
<option value="portrait">portrait</option>
|
||||||
|
<option value="landscape">landscape</option>
|
||||||
|
<option value="portrait-primary">portrait-primary</option>
|
||||||
|
<option value="landscape-primary">landscape-primary</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>lang <input type="text" id="lang" placeholder="it"></label>
|
||||||
|
<label>categories (separate da virgola)
|
||||||
|
<input type="text" id="categories" placeholder="business, shopping">
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Icone</legend>
|
||||||
|
|
||||||
|
<div id="iconsContainer" class="icons"></div>
|
||||||
|
|
||||||
|
<h3>Aggiungi nuova icona</h3>
|
||||||
|
<div class="row">
|
||||||
|
<input type="file" id="iconFile" name="icon" accept="image/png,image/jpeg,image/webp">
|
||||||
|
<select id="purpose">
|
||||||
|
<option value="any">any</option>
|
||||||
|
<option value="any maskable">any maskable</option>
|
||||||
|
<option value="maskable">maskable</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" id="uploadIconBtn">Carica icona</button>
|
||||||
|
</div>
|
||||||
|
<small class="note">
|
||||||
|
Suggerito: carica un’immagine grande (es. 1024×1024) — il server genererà automaticamente 192×192 e 512×512.
|
||||||
|
</small>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button type="submit" class="primary">Salva manifest</button>
|
||||||
|
<button type="button" id="reloadManifestBtn">Ricarica manifest</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
<!--
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
|
||||||
|
<script>eruda.init();</script>
|
||||||
|
-->
|
||||||
|
<script>
|
||||||
|
// --- Setup iniziale ---
|
||||||
|
|
||||||
|
//console.log('[manifest] href:', window.location.href);
|
||||||
|
//console.log('[manifest] search:', window.location.search);
|
||||||
|
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const dir = params.get('dir')?.replace(/^\/+|\/+$/g, '');
|
||||||
|
//console.log("dir=",dir);
|
||||||
|
|
||||||
|
let manifest = {};
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const iconsContainer = document.getElementById('iconsContainer');
|
||||||
|
|
||||||
|
async function loadManifest() {
|
||||||
|
statusEl.textContent = 'Carico manifest...';
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('dir', dir);
|
||||||
|
const res = await fetch('/api/manifest', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
//const res = await fetch('/api/manifest');
|
||||||
|
manifest = await res.json();
|
||||||
|
statusEl.textContent = 'Manifest caricato';
|
||||||
|
|
||||||
|
// Popola campi
|
||||||
|
document.getElementById('name').value = manifest.name || '';
|
||||||
|
document.getElementById('short_name').value = manifest.short_name || '';
|
||||||
|
document.getElementById('description').value = manifest.description || '';
|
||||||
|
document.getElementById('start_url').value = manifest.start_url || '/';
|
||||||
|
document.getElementById('scope').value = manifest.scope || '/';
|
||||||
|
document.getElementById('display').value = manifest.display || 'standalone';
|
||||||
|
document.getElementById('background_color').value = manifest.background_color || '#ffffff';
|
||||||
|
document.getElementById('theme_color').value = manifest.theme_color || '#0d6efd';
|
||||||
|
document.getElementById('orientation').value = manifest.orientation || '';
|
||||||
|
document.getElementById('lang').value = manifest.lang || '';
|
||||||
|
document.getElementById('categories').value = (manifest.categories || []).join(', ');
|
||||||
|
|
||||||
|
renderIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIcons() {
|
||||||
|
iconsContainer.innerHTML = '';
|
||||||
|
const icons = manifest.icons || [];
|
||||||
|
|
||||||
|
if (icons.length === 0) {
|
||||||
|
iconsContainer.innerHTML = '<p class="note">Nessuna icona presente nel manifest.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
icons.forEach((icon, idx) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'icon-card';
|
||||||
|
|
||||||
|
const previewSrc = icon.src; // supporta sia /icons/... sia http(s)://...
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="row">
|
||||||
|
<img class="thumb" src="sites/${dir}/${previewSrc}" alt="${icon.sizes || ''}">
|
||||||
|
<div class="meta">
|
||||||
|
<div><strong>src:</strong> ${icon.src}</div>
|
||||||
|
<div><strong>sizes:</strong> ${icon.sizes || ''}</div>
|
||||||
|
<div><strong>type:</strong> ${icon.type || ''}</div>
|
||||||
|
<div><strong>purpose:</strong> ${icon.purpose || ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:.5rem">
|
||||||
|
<button type="button" data-idx="${idx}" class="removeIconBtn">Rimuovi dal manifest</button>
|
||||||
|
<button type="button" data-src="${icon.src}" class="deleteIconFileBtn danger">Elimina file + manifest</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
iconsContainer.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rimuovi solo dal manifest
|
||||||
|
document.querySelectorAll('.removeIconBtn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
const idx = parseInt(e.target.dataset.idx, 10);
|
||||||
|
manifest.icons.splice(idx, 1);
|
||||||
|
try {
|
||||||
|
await saveManifest(false);
|
||||||
|
renderIcons();
|
||||||
|
statusEl.textContent = 'Icona rimossa dal manifest';
|
||||||
|
bumpManifestLink();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
statusEl.textContent = 'Errore rimozione icona dal manifest';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Elimina file (se sotto /icons) e rimuovi dal manifest lato server
|
||||||
|
document.querySelectorAll('.deleteIconFileBtn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
const src = e.target.dataset.src;
|
||||||
|
statusEl.textContent = 'Elimino icona...';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/icons?src=${encodeURIComponent(src)}&removeFile=true&myDir=${dir}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Delete failed');
|
||||||
|
await loadManifest();
|
||||||
|
statusEl.textContent = 'Icona eliminata (file + manifest)';
|
||||||
|
bumpManifestLink();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
statusEl.textContent = 'Errore eliminazione icona';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveManifest(bump = true) {
|
||||||
|
// Aggiorna manifest con i valori del form
|
||||||
|
manifest.name = document.getElementById('name').value;
|
||||||
|
manifest.short_name = document.getElementById('short_name').value;
|
||||||
|
manifest.description = document.getElementById('description').value;
|
||||||
|
manifest.start_url = document.getElementById('start_url').value || '/';
|
||||||
|
manifest.scope = document.getElementById('scope').value || '/';
|
||||||
|
manifest.display = document.getElementById('display').value;
|
||||||
|
manifest.background_color = document.getElementById('background_color').value;
|
||||||
|
manifest.theme_color = document.getElementById('theme_color').value;
|
||||||
|
|
||||||
|
const orientation = document.getElementById('orientation').value;
|
||||||
|
if (orientation) manifest.orientation = orientation; else delete manifest.orientation;
|
||||||
|
|
||||||
|
const lang = document.getElementById('lang').value;
|
||||||
|
if (lang) manifest.lang = lang; else delete manifest.lang;
|
||||||
|
|
||||||
|
const cats = document.getElementById('categories').value
|
||||||
|
.split(',')
|
||||||
|
.map(c => c.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
manifest.categories = cats;
|
||||||
|
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('manifest', JSON.stringify(manifest));
|
||||||
|
form.append('dir', dir);
|
||||||
|
const res = await fetch('/api/manifest', {
|
||||||
|
method: 'PUT',
|
||||||
|
//headers: { 'Content-Type': 'application/json' },
|
||||||
|
//body: JSON.stringify({manifest , dir})
|
||||||
|
body: form
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Salvataggio manifest fallito');
|
||||||
|
statusEl.textContent = 'Manifest salvato';
|
||||||
|
if (bump) bumpManifestLink();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpManifestLink() {
|
||||||
|
const link = document.getElementById('manifestLink');
|
||||||
|
const url = new URL(link.href, location.origin);
|
||||||
|
url.searchParams.set('v', Date.now().toString());
|
||||||
|
link.href = url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('manifestForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await saveManifest(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
statusEl.textContent = 'Errore salvataggio manifest';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('reloadManifestBtn').addEventListener('click', loadManifest);
|
||||||
|
|
||||||
|
document.getElementById('uploadIconBtn').addEventListener('click', async () => {
|
||||||
|
const fileInput = document.getElementById('iconFile');
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
const purpose = document.getElementById('purpose').value;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
statusEl.textContent = 'Seleziona un file icona';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEl.textContent = 'Caricamento icona...';
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('icon', file);
|
||||||
|
formData.append('purpose', purpose);
|
||||||
|
formData.append('mydir', dir);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/upload-icon', { method: 'POST', body: formData });
|
||||||
|
if (!res.ok) throw new Error('Upload fallito');
|
||||||
|
const data = await res.json(); // { ok, icons: [...] }
|
||||||
|
|
||||||
|
manifest.icons = mergeIcons(manifest.icons || [], data.icons);
|
||||||
|
await saveManifest(true);
|
||||||
|
await loadManifest();
|
||||||
|
statusEl.textContent = 'Icona caricata e manifest aggiornato';
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
statusEl.textContent = 'Errore upload icona';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function mergeIcons(existing, added) {
|
||||||
|
const key = i => `${i.src}|${i.sizes}`;
|
||||||
|
const map = new Map(existing.map(i => [key(i), i]));
|
||||||
|
for (const a of added) map.set(key(a), a);
|
||||||
|
return Array.from(map.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avvio
|
||||||
|
loadManifest();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
home/mod.png
Normal file
|
After Width: | Height: | Size: 288 KiB |
12
home/myicons/browserconfig.xml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<browserconfig>
|
||||||
|
<msapplication>
|
||||||
|
<tile>
|
||||||
|
<square70x70logo src="/myicons/favicon-70x70.png"/>
|
||||||
|
<square150x150logo src="/myicons/favicon-150x150.png"/>
|
||||||
|
<square310x310logo src="/myicons/favicon-310x310.png"/>
|
||||||
|
<TileColor>#ffffff</TileColor>
|
||||||
|
</tile>
|
||||||
|
</msapplication>
|
||||||
|
</browserconfig>
|
||||||
BIN
home/myicons/favicon-114x114.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
home/myicons/favicon-120x120.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
home/myicons/favicon-128x128.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
home/myicons/favicon-144x144.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
home/myicons/favicon-150x150.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
home/myicons/favicon-152x152.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
home/myicons/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
home/myicons/favicon-180x180.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
home/myicons/favicon-192x192.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
home/myicons/favicon-310x310.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
home/myicons/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
home/myicons/favicon-384x384.png
Normal file
|
After Width: | Height: | Size: 225 KiB |
BIN
home/myicons/favicon-512x512.png
Normal file
|
After Width: | Height: | Size: 320 KiB |
BIN
home/myicons/favicon-57x57.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
home/myicons/favicon-60x60.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
home/myicons/favicon-70x70.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
home/myicons/favicon-72x72.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
home/myicons/favicon-76x76.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
home/myicons/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
home/myicons/favicon.ico
Normal file
|
After Width: | Height: | Size: 364 KiB |
60
home/myicons/manifest.json
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
{
|
||||||
|
"name": "My Sites",
|
||||||
|
"description": "I miei Siti",
|
||||||
|
"short_name": "MySites",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/myicons/favicon-72x72.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/myicons/favicon-96x96.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/myicons/favicon-128x128.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/myicons/favicon-144x144.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/myicons/favicon-152x152.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "152x152",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/myicons/favicon-192x192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/myicons/favicon-384x384.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/myicons/favicon-512x512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scope": "/",
|
||||||
|
"start_url": "/?source=pwa",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
242
home/settings.html
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Sites Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="style1.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Sites Settings</h1>
|
||||||
|
<div id="grid" class="grid"></div>
|
||||||
|
|
||||||
|
<!-- Modal: Aggiungi (esistente) -->
|
||||||
|
<div id="addModal" class="modal hidden">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Aggiungi nuovo sito</h2>
|
||||||
|
<form id="addForm">
|
||||||
|
<label>
|
||||||
|
URL:
|
||||||
|
<input type="text" id="siteUrl" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Dir:
|
||||||
|
<input type="text" id="siteDir" required />
|
||||||
|
</label>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Salva</button>
|
||||||
|
<button type="button" id="closeModal">Chiudi</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Conferma cancellazione -->
|
||||||
|
<div id="delModal" class="modal hidden" data-modal>
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Conferma cancellazione</h2>
|
||||||
|
<p id="delQuestion">Vuoi davvero cancellare questo sito?</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" id="confirmDelete" class="danger">Sì, cancella</button>
|
||||||
|
<button type="button" data-close="delModal">Annulla</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
// ------------ Helpers: modal handling ------------
|
||||||
|
function openModal(id) {
|
||||||
|
const m = document.getElementById(id);
|
||||||
|
if (!m) return;
|
||||||
|
m.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
function closeModal(id) {
|
||||||
|
const m = document.getElementById(id);
|
||||||
|
if (!m) return;
|
||||||
|
m.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal when clicking on overlay
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const modalEl = e.target.closest('[data-modal]');
|
||||||
|
if (!modalEl && e.target.classList.contains('modal')) {
|
||||||
|
e.target.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Close modal by buttons with data-close
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const closeId = e.target.getAttribute('data-close');
|
||||||
|
if (closeId) closeModal(closeId);
|
||||||
|
});
|
||||||
|
// Close on Escape
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
document.querySelectorAll('.modal').forEach(m => m.classList.add('hidden'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------ State for selected site ------------
|
||||||
|
let selectedSite = null; // the site object currently selected for delete/edit
|
||||||
|
|
||||||
|
// ------------ Load and render sites ------------
|
||||||
|
async function loadSites() {
|
||||||
|
const res = await fetch('./sites.json', { cache: 'no-store' });
|
||||||
|
const sites = await res.json();
|
||||||
|
|
||||||
|
const grid = document.getElementById('grid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
|
||||||
|
sites.forEach(site => {
|
||||||
|
// Expecting: site.name, site.icon, site.url (optional), site.dir, and maybe site.shortName/defaultFile
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card';
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = site.icon;
|
||||||
|
img.alt = site.name;
|
||||||
|
|
||||||
|
// mostra mod.png quando sei sopra la card
|
||||||
|
const originalSrc = site.icon;
|
||||||
|
card.addEventListener('mouseenter', () => {
|
||||||
|
if (!card.classList.contains('add-card')) {
|
||||||
|
img.src = 'mod.png';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
card.addEventListener('mouseleave', () => {
|
||||||
|
img.src = originalSrc;
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'title';
|
||||||
|
title.textContent = site.name;
|
||||||
|
|
||||||
|
card.appendChild(img);
|
||||||
|
card.appendChild(title);
|
||||||
|
|
||||||
|
// --- Interaction rules (excluding + card) ---
|
||||||
|
// 1) Click/tap → open Delete modal
|
||||||
|
// 2) Long-press → open Edit manifest modal
|
||||||
|
// Robust long-press with pointer events (mouse & touch)
|
||||||
|
let pressTimer = null;
|
||||||
|
let longPressed = false;
|
||||||
|
const LONG_PRESS_MS = 600;
|
||||||
|
|
||||||
|
const startPress = async (e) => {
|
||||||
|
longPressed = false;
|
||||||
|
clearTimeout(pressTimer);
|
||||||
|
pressTimer = setTimeout(async () => {
|
||||||
|
longPressed = true;
|
||||||
|
selectedSite = site;
|
||||||
|
const url = new URL('/manifest', window.location.origin);
|
||||||
|
url.searchParams.set('dir',selectedSite.dir);
|
||||||
|
window.location.assign(url.toString());
|
||||||
|
}, LONG_PRESS_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const endPress = (e) => {
|
||||||
|
clearTimeout(pressTimer);
|
||||||
|
if (!longPressed) {
|
||||||
|
// treat as click → delete
|
||||||
|
selectedSite = site;
|
||||||
|
const q = document.getElementById('delQuestion');
|
||||||
|
q.textContent = `Vuoi davvero cancellare il sito "${site.name}" (dir: ${site.dir})?`;
|
||||||
|
openModal('delModal');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const cancelPress = () => {
|
||||||
|
clearTimeout(pressTimer);
|
||||||
|
};
|
||||||
|
|
||||||
|
card.addEventListener('pointerdown', startPress);
|
||||||
|
card.addEventListener('pointerup', endPress);
|
||||||
|
card.addEventListener('pointerleave', cancelPress);
|
||||||
|
card.addEventListener('pointercancel', cancelPress);
|
||||||
|
// prevent context menu from interfering on desktop long-press
|
||||||
|
card.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
|
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- "+" Add card ---
|
||||||
|
const addCard = document.createElement('div');
|
||||||
|
addCard.className = 'card add-card';
|
||||||
|
|
||||||
|
const plus = document.createElement('div');
|
||||||
|
plus.className = 'plus-icon';
|
||||||
|
plus.textContent = '+';
|
||||||
|
|
||||||
|
const addTitle = document.createElement('div');
|
||||||
|
addTitle.className = 'title';
|
||||||
|
addTitle.textContent = 'Aggiungi';
|
||||||
|
|
||||||
|
addCard.appendChild(plus);
|
||||||
|
addCard.appendChild(addTitle);
|
||||||
|
addCard.addEventListener('click', () => {
|
||||||
|
document.getElementById('addModal').classList.remove('hidden');
|
||||||
|
});
|
||||||
|
grid.appendChild(addCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
loadSites();
|
||||||
|
|
||||||
|
// ------------ Add-site modal handlers (existing) ------------
|
||||||
|
const addModal = document.getElementById('addModal');
|
||||||
|
const closeBtn = document.getElementById('closeModal');
|
||||||
|
closeBtn.addEventListener('click', () => addModal.classList.add('hidden'));
|
||||||
|
|
||||||
|
const addForm = document.getElementById('addForm');
|
||||||
|
addForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const url = document.getElementById('siteUrl').value.trim();
|
||||||
|
const dir = document.getElementById('siteDir').value.trim();
|
||||||
|
|
||||||
|
if (!url || !dir) return;
|
||||||
|
|
||||||
|
const res = await fetch('/add-site', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url, dir })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
alert('Sito aggiunto con successo!');
|
||||||
|
addModal.classList.add('hidden');
|
||||||
|
await loadSites();
|
||||||
|
//window.parent.refreshSideBar();
|
||||||
|
window.parent.postMessage("refreshSideBar", "*");
|
||||||
|
} else {
|
||||||
|
const msg = await safeText(res);
|
||||||
|
alert('Errore nell\'aggiunta del sito: ' + msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function safeText(res) {
|
||||||
|
try { return await res.text(); } catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------ Delete confirmation handlers ------------
|
||||||
|
document.getElementById('confirmDelete').addEventListener('click', async () => {
|
||||||
|
if (!selectedSite || !selectedSite.dir) {
|
||||||
|
alert('Nessun sito selezionato o dir mancante.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch('/del-site', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ dir: selectedSite.dir })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
closeModal('delModal');
|
||||||
|
selectedSite = null;
|
||||||
|
await loadSites();
|
||||||
|
//window.parent.refreshSideBar();
|
||||||
|
window.parent.postMessage("refreshSideBar", "*");
|
||||||
|
} else {
|
||||||
|
const msg = await safeText(res);
|
||||||
|
alert('Errore nella cancellazione: ' + msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
456
home/sidebar.html
Normal file
|
|
@ -0,0 +1,456 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Siti vari</title>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
|
||||||
|
<link rel="apple-touch-icon" sizes="57x57" href="/myicons/favicon-57x57.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="60x60" href="/myicons/favicon-60x60.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="72x72" href="/myicons/favicon-72x72.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="76x76" href="/myicons/favicon-76x76.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="114x114" href="/myicons/favicon-114x114.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="120x120" href="/myicons/favicon-120x120.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="144x144" href="/myicons/favicon-144x144.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="152x152" href="/myicons/favicon-152x152.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/myicons/favicon-180x180.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/myicons/favicon-16x16.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/myicons/favicon-32x32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="96x96" href="/myicons/favicon-96x96.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/myicons/favicon-192x192.png">
|
||||||
|
<link rel="shortcut icon" type="image/x-icon" href="/myicons/favicon.ico">
|
||||||
|
<link rel="icon" type="image/x-icon" href="/myicons/favicon.ico">
|
||||||
|
<meta name="msapplication-TileColor" content="#ffffff">
|
||||||
|
<meta name="msapplication-TileImage" content="/myicons/favicon-144x144.png">
|
||||||
|
<meta name="msapplication-config" content="/myicons/browserconfig.xml">
|
||||||
|
<link rel="manifest" href="/myicons/manifest.json">
|
||||||
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--sidebar-w: 260px;
|
||||||
|
--sidebar-bg: #0f172a;
|
||||||
|
--sidebar-fg: #e2e8f0;
|
||||||
|
--border: #1f2937;
|
||||||
|
--overlay-bg: rgba(0,0,0,0.5);
|
||||||
|
--transition: 250ms ease;
|
||||||
|
--focus: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base */
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
background: radial-gradient(1200px 700px at 20% 10%, #0b1220 0%, #070c1a 50%, #050a16 100%);
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icona menù */
|
||||||
|
.menu-btn {
|
||||||
|
position: fixed;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
z-index: 100;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 4px;
|
||||||
|
transition: opacity var(--transition);
|
||||||
|
}
|
||||||
|
.menu-btn.hidden { opacity: 0; pointer-events: none; }
|
||||||
|
.menu-btn.dragging { cursor: grabbing; }
|
||||||
|
.menu-btn svg { pointer-events: none; }
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: var(--sidebar-w);
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(180deg, #0f172a 0%, #0c1423 100%);
|
||||||
|
color: var(--sidebar-fg);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform var(--transition);
|
||||||
|
z-index: 90;
|
||||||
|
padding: 16px 12px;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.sidebar.open { transform: translateX(0); }
|
||||||
|
|
||||||
|
|
||||||
|
.nav-header { display: flex; align-items: center; }
|
||||||
|
.nav-title {
|
||||||
|
font-size: .8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin: 8px 8px 4px;
|
||||||
|
}
|
||||||
|
.nav-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between; /* titolo a sinistra, bottone a destra */
|
||||||
|
padding: 0 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
margin: 0; /* reset margini per allineamento */
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--sidebar-fg);
|
||||||
|
padding: 4px;
|
||||||
|
transition: transform var(--transition), color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn:hover {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
color: var(--focus);
|
||||||
|
}
|
||||||
|
.edit-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--sidebar-fg);
|
||||||
|
padding: 4px;
|
||||||
|
transition: transform var(--transition), color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-btn:hover {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
color: var(--focus);
|
||||||
|
}
|
||||||
|
.nav-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
||||||
|
.nav-link {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--sidebar-fg);
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: background var(--transition), transform var(--transition), border-color var(--transition);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.nav-link:hover {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
transform: translateX(2px);
|
||||||
|
border-color: #263145;
|
||||||
|
}
|
||||||
|
.nav-link:focus-visible {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overlay */
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--overlay-bg);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
z-index: 80;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.overlay.show { display: block; }
|
||||||
|
|
||||||
|
/* Contenuto principale */
|
||||||
|
.content {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100dvh; /* viewport dinamico, elimina banda nera su mobile */
|
||||||
|
}
|
||||||
|
.frame-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
display: block;
|
||||||
|
background: #fff; /* evita bleed del gradiente */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback per browser che non supportano 100dvh */
|
||||||
|
@supports not (height: 100dvh) {
|
||||||
|
.content { height: 100vh; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-error {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #ef4444;
|
||||||
|
background: rgba(0,0,0,0.25);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.frame-error.show { display: flex; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Icona menù -->
|
||||||
|
<button id="menuBtn" class="menu-btn" aria-label="Apri menù">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="2" y="3" width="20" height="2" fill="currentColor"/>
|
||||||
|
<rect x="2" y="7" width="20" height="2" fill="currentColor"/>
|
||||||
|
<rect x="2" y="11" width="20" height="2" fill="currentColor"/>
|
||||||
|
<rect x="2" y="15" width="20" height="2" fill="currentColor"/>
|
||||||
|
<rect x="2" y="19" width="20" height="2" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Sidebar
|
||||||
|
<aside id="sidebar" class="sidebar" aria-hidden="true">
|
||||||
|
<nav class="nav" id="navRoot">
|
||||||
|
<h2 class="nav-title">Sites</h2>
|
||||||
|
<ul id="siteList" class="nav-list"></ul>
|
||||||
|
</nav>
|
||||||
|
</aside> -->
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside id="sidebar" class="sidebar" aria-hidden="true">
|
||||||
|
<nav class="nav" id="navRoot">
|
||||||
|
|
||||||
|
<div class="nav-header">
|
||||||
|
<h2 class="nav-title">Sites</h2>
|
||||||
|
<div class="nav-actions">
|
||||||
|
<button id="refreshBtn" class="refresh-btn" aria-label="Ricarica">
|
||||||
|
<i class="fas fa-sync-alt"></i>
|
||||||
|
</button>
|
||||||
|
<button id="editBtn" class="edit-btn" aria-label="Edit">
|
||||||
|
<i class="fa-solid fa-gear"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul id="siteList" class="nav-list"></ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Overlay -->
|
||||||
|
<div id="overlay" class="overlay"></div>
|
||||||
|
|
||||||
|
<!-- Contenuto principale -->
|
||||||
|
<main class="content">
|
||||||
|
<div class="frame-wrap">
|
||||||
|
<iframe id="contentFrame" referrerpolicy="no-referrer"></iframe>
|
||||||
|
<div id="frameError" class="frame-error">
|
||||||
|
<div>
|
||||||
|
<strong>Il sito non può essere caricato in iframe.</strong><br />
|
||||||
|
Potrebbe avere X-Frame-Options o Content-Security-Policy che ne impediscono l’incorporamento.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const menuBtn = document.getElementById('menuBtn');
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const overlay = document.getElementById('overlay');
|
||||||
|
const siteList = document.getElementById('siteList');
|
||||||
|
const iframe = document.getElementById('contentFrame');
|
||||||
|
const frameErr = document.getElementById('frameError');
|
||||||
|
const navRoot = document.getElementById('navRoot');
|
||||||
|
|
||||||
|
const refreshBtn = document.getElementById('refreshBtn');
|
||||||
|
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
const isOpen = sidebar.classList.toggle('open');
|
||||||
|
overlay.classList.toggle('show', isOpen);
|
||||||
|
sidebar.setAttribute('aria-hidden', String(!isOpen));
|
||||||
|
menuBtn.classList.toggle('hidden', isOpen);
|
||||||
|
if (!isOpen) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.addEventListener('click', toggleSidebar);
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && sidebar.classList.contains('open')) toggleSidebar();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Evita che i click dentro la sidebar propaghino al document (prevenendo chiusure involontarie) */
|
||||||
|
navRoot.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
navRoot.addEventListener('mousedown', (e) => e.stopPropagation());
|
||||||
|
navRoot.addEventListener('touchstart', (e) => e.stopPropagation(), { passive: false });
|
||||||
|
|
||||||
|
// Long press drag&drop — isolato SOLO al pulsante menù
|
||||||
|
let pressTimer, isDragging = false, offsetX, offsetY, pressActive = false;
|
||||||
|
|
||||||
|
function startPress(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
pressActive = true;
|
||||||
|
const point = e.touches ? e.touches[0] : e;
|
||||||
|
pressTimer = setTimeout(() => {
|
||||||
|
isDragging = true;
|
||||||
|
menuBtn.classList.add('dragging');
|
||||||
|
offsetX = point.clientX - menuBtn.offsetLeft;
|
||||||
|
offsetY = point.clientY - menuBtn.offsetTop;
|
||||||
|
}, 400);
|
||||||
|
/* Attiva listeners di fine solo quando la press parte sul pulsante */
|
||||||
|
window.addEventListener('mouseup', endPressOnce);
|
||||||
|
window.addEventListener('touchend', endPressOnce);
|
||||||
|
window.addEventListener('mousemove', moveBtn);
|
||||||
|
window.addEventListener('touchmove', moveBtn, { passive: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function endPressCore() {
|
||||||
|
clearTimeout(pressTimer);
|
||||||
|
if (isDragging) {
|
||||||
|
isDragging = false;
|
||||||
|
menuBtn.classList.remove('dragging');
|
||||||
|
} else if (pressActive) {
|
||||||
|
// click breve sul pulsante → toggle menù
|
||||||
|
toggleSidebar();
|
||||||
|
}
|
||||||
|
pressActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endPressOnce(e) {
|
||||||
|
endPressCore();
|
||||||
|
// pulizia listeners temporanei
|
||||||
|
window.removeEventListener('mouseup', endPressOnce);
|
||||||
|
window.removeEventListener('touchend', endPressOnce);
|
||||||
|
window.removeEventListener('mousemove', moveBtn);
|
||||||
|
window.removeEventListener('touchmove', moveBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveBtn(e) {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const point = e.touches ? e.touches[0] : e;
|
||||||
|
menuBtn.style.left = (point.clientX - offsetX) + 'px';
|
||||||
|
menuBtn.style.top = (point.clientY - offsetY) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
menuBtn.addEventListener('mousedown', startPress);
|
||||||
|
menuBtn.addEventListener('touchstart', startPress, { passive: false });
|
||||||
|
|
||||||
|
// Helper per URL pulite
|
||||||
|
function joinUrl(base, path) {
|
||||||
|
const cleanBase = String(base).replace(/\/+$/,'');
|
||||||
|
const cleanPath = String(path).replace(/^\/+/,'');
|
||||||
|
return cleanBase + '/' + cleanPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSideBar() {
|
||||||
|
//if (doRefresh) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/config.json', { cache: 'no-cache' });
|
||||||
|
if (!res.ok) throw new Error('config.json non raggiungibile: ' + res.status);
|
||||||
|
const cfg = await res.json();
|
||||||
|
if (!cfg || !Array.isArray(cfg.s) || !cfg.url) {
|
||||||
|
throw new Error('Struttura config non valida. Attesi: { url: string, sites: string[] }');
|
||||||
|
}
|
||||||
|
siteList.innerHTML = '';
|
||||||
|
cfg.s.forEach(site => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.textContent = site.name;
|
||||||
|
btn.className = 'nav-link';
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation(); /* il click resta nel menù */
|
||||||
|
const target = joinUrl(cfg.url, site.dir);
|
||||||
|
openInFrame(target);
|
||||||
|
toggleSidebar();
|
||||||
|
});
|
||||||
|
li.appendChild(btn);
|
||||||
|
siteList.appendChild(li);
|
||||||
|
});
|
||||||
|
//doRefresh =false;
|
||||||
|
return cfg;
|
||||||
|
} catch (err) {
|
||||||
|
//doRefresh =false;
|
||||||
|
frameErr.classList.add('show');
|
||||||
|
frameErr.innerHTML = '<div><strong>Errore di configurazione.</strong><br />' +
|
||||||
|
(err && err.message ? err.message : 'Impossibile caricare config.json') + '</div>';
|
||||||
|
}
|
||||||
|
//}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("message", (event) => {
|
||||||
|
if (event.data === "refreshSideBar") {
|
||||||
|
refreshSideBar();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Caricamento sites da config.json
|
||||||
|
(async function loadConfig() {
|
||||||
|
try {
|
||||||
|
let cfg = await refreshSideBar();
|
||||||
|
|
||||||
|
refreshBtn.addEventListener('click', () => {
|
||||||
|
// ricarica l’iframe
|
||||||
|
iframe.src = iframe.src.split('?')[0] + '?t=' + Date.now();
|
||||||
|
toggleSidebar();
|
||||||
|
});
|
||||||
|
|
||||||
|
editBtn.addEventListener('click', () => {
|
||||||
|
// carica /config
|
||||||
|
const target = joinUrl(cfg.url, "settings");
|
||||||
|
openInFrame(target);
|
||||||
|
toggleSidebar();
|
||||||
|
doRefresh = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sito iniziale
|
||||||
|
openInFrame(joinUrl(cfg.url, cfg.s[0].dir));
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
frameErr.classList.add('show');
|
||||||
|
frameErr.innerHTML = '<div><strong>Errore di configurazione.</strong><br />' +
|
||||||
|
(err && err.message ? err.message : 'Impossibile caricare config.json') + '</div>';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Carica URL nell’iframe e gestisce casi di blocco
|
||||||
|
function openInFrame(url) {
|
||||||
|
frameErr.classList.remove('show');
|
||||||
|
|
||||||
|
// Mixed content
|
||||||
|
const pageIsHttps = location.protocol === 'https:';
|
||||||
|
const urlIsHttp = /^http:/.test(url);
|
||||||
|
if (pageIsHttps && urlIsHttp) {
|
||||||
|
frameErr.classList.add('show');
|
||||||
|
frameErr.innerHTML = '<div><strong>Contenuto misto bloccato.</strong><br />Pagina HTTPS, sito HTTP. Usa HTTPS.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caricamento con timeout (indicativo di X-Frame-Options/CSP)
|
||||||
|
iframe.src = url;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
frameErr.classList.add('show');
|
||||||
|
frameErr.innerHTML = '<div><strong>Il sito non può essere caricato in iframe.</strong><br />Verifica X-Frame-Options/CSP (frame-ancestors).</div>';
|
||||||
|
}, 6000);
|
||||||
|
|
||||||
|
iframe.onload = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
frameErr.classList.remove('show');
|
||||||
|
};
|
||||||
|
iframe.onerror = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
frameErr.classList.add('show');
|
||||||
|
frameErr.innerHTML = '<div><strong>Errore di caricamento.</strong><br />Verifica che l’URL sia raggiungibile.</div>';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
home/sites.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"dir": "composerize",
|
||||||
|
"name": "Composerize",
|
||||||
|
"icon": "icons/composerize.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dir": "composeverter",
|
||||||
|
"name": "Composeverter",
|
||||||
|
"icon": "icons/composeverter.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dir": "decomposerize",
|
||||||
|
"name": "Decomposerize",
|
||||||
|
"icon": "icons/decomposerize.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
63
home/style.css
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
margin: 20px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card img {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: contain;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.card.add-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px dashed #aaa;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plus-icon {
|
||||||
|
font-size: 2em;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.hidden { display: none; }
|
||||||
|
.modal {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.modal-content {
|
||||||
|
background: #fff; padding: 20px; border-radius: 8px;
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
.actions { margin-top: 10px; display: flex; gap: 10px; }
|
||||||
134
home/style1.css
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
margin: 20px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 10px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card img {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: contain;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.add-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px dashed #aaa;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.add-card:hover {
|
||||||
|
background: #fafafa;
|
||||||
|
border-color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plus-icon {
|
||||||
|
font-size: 2em;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modali */
|
||||||
|
.modal.hidden { display: none; }
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: #fff;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 300px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 15px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions .danger {
|
||||||
|
background: #e03a3a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button:not(.danger) {
|
||||||
|
background: #eee;
|
||||||
|
color: #333;
|
||||||
5
make_server_docker.sh
Executable file
|
|
@ -0,0 +1,5 @@
|
||||||
|
#!/bin/bash
|
||||||
|
echo "commento queste righe"
|
||||||
|
grep -n "dotenv" server.js | grep -v "//"
|
||||||
|
sed '/dotenv/ s/^/\/\//' server.js > server_docker.js
|
||||||
|
echo "Creato server_docker.js con le righe commentate."
|
||||||
1628
package-lock.json
generated
Normal file
12
package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"body-parser": "^2.2.1",
|
||||||
|
"child_process": "^1.0.2",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"fs-extra": "^11.3.2",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"sharp": "^0.34.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
requests~=2.32.4
|
||||||
|
beautifulsoup4~=4.13.4
|
||||||
|
wget~=3.2
|
||||||
|
urllib3~=2.5.0
|
||||||
330
server.js
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
import express from "express";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import fs from "fs";
|
||||||
|
import { generateSitesJson, delFieldDir, addFieldDir } from "./generateSitesJson.js";
|
||||||
|
|
||||||
|
import bodyParser from "body-parser";
|
||||||
|
import { execFile } from "child_process";
|
||||||
|
|
||||||
|
import fse from "fs-extra";
|
||||||
|
|
||||||
|
|
||||||
|
import multer from 'multer';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
|
||||||
|
let sites = generateSitesJson();
|
||||||
|
//sites.unshift({ dir: 'home', name: 'Home', icon: 'home.ico' });
|
||||||
|
//console.log(sites);
|
||||||
|
|
||||||
|
//dotenv.config();
|
||||||
|
dotenv.config({ path: './.env' });
|
||||||
|
const app = express();
|
||||||
|
app.use(bodyParser.json());
|
||||||
|
|
||||||
|
// ricostruisci __dirname in ambiente ESM
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// Root sicura per le cartelle dei siti: ./sites
|
||||||
|
const SITES_ROOT = path.resolve(__dirname,'home' , 'sites');
|
||||||
|
const HOME_DIR = path.resolve(__dirname, 'home');
|
||||||
|
const SITES_JSON = path.resolve(HOME_DIR, 'sites.json');
|
||||||
|
const DOWNLOAD_SITES = 'home/sites';
|
||||||
|
|
||||||
|
// Leggi variabili da .env
|
||||||
|
const HOST = process.env.HOST || "0.0.0.0";
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const TYPE = process.env.TYPE || "http";
|
||||||
|
const URL = process.env.URL || "https://sites.patachina2.casacam.net";
|
||||||
|
|
||||||
|
/** crea la dir icons se non esiste **/
|
||||||
|
const iconsDir = path.join(__dirname, 'home', 'icons');
|
||||||
|
fs.mkdirSync(iconsDir, { recursive: true });
|
||||||
|
|
||||||
|
|
||||||
|
/** Util: carica il manifest con fallback **/
|
||||||
|
function readManifest(mPath) {
|
||||||
|
try {
|
||||||
|
const text = fs.readFileSync(mPath, 'utf8');
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Risolve un path del sito sotto SITES_ROOT, impedendo traversal. */
|
||||||
|
function resolveSitePathSafe(dir) {
|
||||||
|
//const clean = normalizeDirField(dir);
|
||||||
|
//const abs = path.resolve(SITES_ROOT, clean);
|
||||||
|
const abs = path.resolve(SITES_ROOT, dir);
|
||||||
|
const rootWithSep = SITES_ROOT.endsWith(path.sep) ? SITES_ROOT : SITES_ROOT + path.sep;
|
||||||
|
if (!(abs === SITES_ROOT || abs.startsWith(rootWithSep))) {
|
||||||
|
throw new Error('Percorso non consentito.');
|
||||||
|
}
|
||||||
|
return abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Utilities --------------------
|
||||||
|
async function readJsonSafe(file) {
|
||||||
|
try {
|
||||||
|
return await fse.readJson(file);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT') return [];
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJsonAtomic(file, data) {
|
||||||
|
const tmp = file + '.tmp';
|
||||||
|
await fse.writeJson(tmp, data, { spaces: 2 });
|
||||||
|
await fse.move(tmp, file, { overwrite: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/del-site', async (req, res) => {
|
||||||
|
try {
|
||||||
|
let { dir } = req.body || {};
|
||||||
|
//console.log("dir da cancellare: ",dir);
|
||||||
|
const siteAbsPath = resolveSitePathSafe(dir);
|
||||||
|
//console.log("path assoluto: ", siteAbsPath);
|
||||||
|
if (await fse.pathExists(siteAbsPath)) {
|
||||||
|
await fse.remove(siteAbsPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
sites = delFieldDir(dir);
|
||||||
|
//await deleteIcon("icons/composerize.ico");
|
||||||
|
console.log(`la dir=${dir} è stata cancellata`);
|
||||||
|
return res.json({
|
||||||
|
ok: true,
|
||||||
|
// removed: { name: removed?.name, dir: removed?.dir },
|
||||||
|
deletedPath: siteAbsPath
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('del-site error:', err);
|
||||||
|
const msg = err?.message || 'Errore interno nella cancellazione.';
|
||||||
|
return res.status(500).json({ error: msg });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.removeHeader("X-Frame-Options");
|
||||||
|
res.setHeader("Content-Security-Policy", "frame-ancestors *");
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// endpoint POST /add-site
|
||||||
|
app.post("/add-site", (req, res) => {
|
||||||
|
const { url, dir } = req.body;
|
||||||
|
const dir1 = `${DOWNLOAD_SITES}/${dir}`;
|
||||||
|
|
||||||
|
// 👉 qui esegui lo script Python
|
||||||
|
execFile("downloadsite.sh", [url, dir1], (error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
console.error("Errore:", error);
|
||||||
|
return res.status(500).json({ status: "error", message: stderr });
|
||||||
|
}
|
||||||
|
sites = addFieldDir(dir);
|
||||||
|
res.json({ status: "ok", output: stdout });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
//const SITES = [];
|
||||||
|
// Monta le cartelle statiche in base alla lista SITES
|
||||||
|
sites.forEach(site => {
|
||||||
|
if (site) {
|
||||||
|
// directory reale: sites/<site>
|
||||||
|
const dirPath = path.join(SITES_ROOT, site.dir);
|
||||||
|
|
||||||
|
// URL pubblico: /<site>
|
||||||
|
app.use(`/${site.dir}`, express.static(dirPath));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Cartella public come root
|
||||||
|
//app.use("/", express.static("public"));
|
||||||
|
app.use("/", express.static("home", { index: "sidebar.html" }));
|
||||||
|
//app.use("/root", express.static("public"));
|
||||||
|
app.use("/home", express.static("home"));
|
||||||
|
app.use("/settings", express.static("home", { index: "settings.html" }));
|
||||||
|
app.use("/manifest", express.static("home", { index: "manifest-editor.html" }));
|
||||||
|
/*
|
||||||
|
app.use('/manifest', express.static(path.join(process.cwd(), 'home'), {
|
||||||
|
index: 'manifest-editor.html',
|
||||||
|
redirect: false // opzionale: evita 301 /manifest -> /manifest/
|
||||||
|
}));
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Endpoint per esporre la config al client
|
||||||
|
app.get("/config.json", (req, res) => {
|
||||||
|
res.json({
|
||||||
|
host: HOST,
|
||||||
|
port: PORT,
|
||||||
|
// sites: SITES,
|
||||||
|
s: sites,
|
||||||
|
url: URL
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload1 = multer();
|
||||||
|
|
||||||
|
/** GET: manifest corrente **/
|
||||||
|
app.post('/api/manifest', upload1.none(), (req, res) => {
|
||||||
|
//app.get('/api/manifest', (req, res) => {
|
||||||
|
try {
|
||||||
|
//console.log(req.body.dir);
|
||||||
|
const mydir = req.body.dir;
|
||||||
|
const manifest = readManifest(path.join(SITES_ROOT, mydir , 'manifest.json'));
|
||||||
|
res.json(manifest);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: 'Impossibile leggere manifest.json' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PUT: salva il manifest (campi + icone) **/
|
||||||
|
app.put('/api/manifest', upload1.none(), (req, res) => {
|
||||||
|
// Validazioni essenziali
|
||||||
|
const mydir = req.body.dir;
|
||||||
|
const incoming = JSON.parse(req.body.manifest);
|
||||||
|
//console.log("manifest put");
|
||||||
|
//console.log("incoming=",incoming);
|
||||||
|
//console.log("mydir=",mydir);
|
||||||
|
const manPath = path.join(SITES_ROOT, mydir , 'manifest.json');
|
||||||
|
//console.log("manPath",manPath);
|
||||||
|
if (!incoming.name || !incoming.short_name) {
|
||||||
|
return res.status(400).json({ error: 'name e short_name sono obbligatori' });
|
||||||
|
}
|
||||||
|
//console.log("step1");
|
||||||
|
if (!Array.isArray(incoming.icons)) incoming.icons = [];
|
||||||
|
//console.log("step2");
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(manPath, JSON.stringify(incoming, null, 2), 'utf8');
|
||||||
|
console.log(`manifest di <${mydir}> salvato`);
|
||||||
|
generateSitesJson();
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: 'Salvataggio manifest fallito' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Multer: configurazione upload (in memoria) **/
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
// Accetta i tipi più comuni
|
||||||
|
const okTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/jpg'];
|
||||||
|
if (file && okTypes.includes(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error(`Formato non supportato: ${file ? file.mimetype : 'Nessun file'}`));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
limits: { fileSize: 8 * 1024 * 1024 } // 8 MB
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST: upload icona + generazione 192 e 512 **/
|
||||||
|
app.post('/api/upload-icon', upload.single('icon'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Log diagnostico utile
|
||||||
|
/* console.log('Upload Content-Type:', req.headers['content-type']);
|
||||||
|
console.log('Upload body keys:', Object.keys(req.body || {}));
|
||||||
|
console.log('Upload file:', req.file && {
|
||||||
|
fieldname: req.file.fieldname,
|
||||||
|
mimetype: req.file.mimetype,
|
||||||
|
size: req.file.size,
|
||||||
|
originalname: req.file.originalname
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
const mydir = req.body.mydir;
|
||||||
|
//console.log("dir=",mydir);
|
||||||
|
|
||||||
|
const iconsMyDir = path.join(SITES_ROOT,mydir , 'icons');
|
||||||
|
fs.mkdirSync(iconsMyDir, { recursive: true });
|
||||||
|
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Nessun file ricevuto nel campo "icon". Assicurati che l’input abbia name="icon" e che il JS usi FormData.append("icon", file).'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const purpose = (req.body.purpose || 'any').trim();
|
||||||
|
const baseNameSafe = (req.file.originalname || 'icon')
|
||||||
|
.replace(/\.[^.]+$/, '') // togli estensione
|
||||||
|
.replace(/[^a-zA-Z0-9-_]/g, ''); // pulisci caratteri strani
|
||||||
|
|
||||||
|
// Puoi aggiungere altre dimensioni se vuoi
|
||||||
|
const sizes = [192, 512];
|
||||||
|
const created = [];
|
||||||
|
|
||||||
|
for (const size of sizes) {
|
||||||
|
const filename = `${baseNameSafe}-${size}.png`;
|
||||||
|
const outPath = path.join(path.join(SITES_ROOT, mydir, 'icons'), filename);
|
||||||
|
|
||||||
|
await sharp(req.file.buffer)
|
||||||
|
.resize(size, size, { fit: 'cover' })
|
||||||
|
.png({ quality: 90 })
|
||||||
|
.toFile(outPath);
|
||||||
|
|
||||||
|
created.push({
|
||||||
|
src: `/icons/${filename}`,
|
||||||
|
sizes: `${size}x${size}`,
|
||||||
|
type: 'image/png',
|
||||||
|
purpose
|
||||||
|
});
|
||||||
|
}
|
||||||
|
generateSitesJson();
|
||||||
|
res.json({ ok: true, icons: created });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Errore /api/upload-icon:', e);
|
||||||
|
res.status(500).json({ error: 'Elaborazione icona fallita' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE: rimuove icona dal manifest e opzionalmente elimina il file
|
||||||
|
* query:
|
||||||
|
* - src (richiesto): percorso icona (es. /icons/icon-192.png)
|
||||||
|
* - removeFile=true|false (opzionale): se true elimina anche il file se sotto /public/icons
|
||||||
|
*/
|
||||||
|
app.delete('/api/icons', (req, res) => {
|
||||||
|
const src = req.query.src;
|
||||||
|
const removeFile = (req.query.removeFile || 'false') === 'true';
|
||||||
|
const mydir = req.query.myDir;
|
||||||
|
const manPath = path.join(SITES_ROOT, mydir , 'manifest.json');
|
||||||
|
//console.log("delete icon:");
|
||||||
|
//console.log("src=",src);
|
||||||
|
//console.log("mydir=",mydir);
|
||||||
|
if (!src) return res.status(400).json({ error: 'Parametro src mancante' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manifest = readManifest(manPath);
|
||||||
|
manifest.icons = (manifest.icons || []).filter(i => i.src !== src);
|
||||||
|
|
||||||
|
if (removeFile) {
|
||||||
|
const absFilePath = path.join(SITES_ROOT, mydir, src.replace(/^\//, ''));
|
||||||
|
const isUnderIcons = absFilePath.startsWith(iconsDir + path.sep);
|
||||||
|
if (isUnderIcons && fs.existsSync(absFilePath)) {
|
||||||
|
fs.unlinkSync(absFilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(manPath, JSON.stringify(manifest, null, 2), 'utf8');
|
||||||
|
generateSitesJson();
|
||||||
|
res.json({ ok: true, removedFromManifest: true, fileDeleted: removeFile });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: 'Eliminazione icona fallita' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
app.listen(PORT, HOST, () => {
|
||||||
|
console.log(`✅ Server pronto su http://${HOST}:${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
330
server_docker.js
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
import express from "express";
|
||||||
|
//import dotenv from "dotenv";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import fs from "fs";
|
||||||
|
import { generateSitesJson, delFieldDir, addFieldDir } from "./generateSitesJson.js";
|
||||||
|
|
||||||
|
import bodyParser from "body-parser";
|
||||||
|
import { execFile } from "child_process";
|
||||||
|
|
||||||
|
import fse from "fs-extra";
|
||||||
|
|
||||||
|
|
||||||
|
import multer from 'multer';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
|
||||||
|
let sites = generateSitesJson();
|
||||||
|
//sites.unshift({ dir: 'home', name: 'Home', icon: 'home.ico' });
|
||||||
|
//console.log(sites);
|
||||||
|
|
||||||
|
////dotenv.config();
|
||||||
|
//dotenv.config({ path: './.env' });
|
||||||
|
const app = express();
|
||||||
|
app.use(bodyParser.json());
|
||||||
|
|
||||||
|
// ricostruisci __dirname in ambiente ESM
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// Root sicura per le cartelle dei siti: ./sites
|
||||||
|
const SITES_ROOT = path.resolve(__dirname,'home' , 'sites');
|
||||||
|
const HOME_DIR = path.resolve(__dirname, 'home');
|
||||||
|
const SITES_JSON = path.resolve(HOME_DIR, 'sites.json');
|
||||||
|
const DOWNLOAD_SITES = 'home/sites';
|
||||||
|
|
||||||
|
// Leggi variabili da .env
|
||||||
|
const HOST = process.env.HOST || "0.0.0.0";
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const TYPE = process.env.TYPE || "http";
|
||||||
|
const URL = process.env.URL || "https://sites.patachina2.casacam.net";
|
||||||
|
|
||||||
|
/** crea la dir icons se non esiste **/
|
||||||
|
const iconsDir = path.join(__dirname, 'home', 'icons');
|
||||||
|
fs.mkdirSync(iconsDir, { recursive: true });
|
||||||
|
|
||||||
|
|
||||||
|
/** Util: carica il manifest con fallback **/
|
||||||
|
function readManifest(mPath) {
|
||||||
|
try {
|
||||||
|
const text = fs.readFileSync(mPath, 'utf8');
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Risolve un path del sito sotto SITES_ROOT, impedendo traversal. */
|
||||||
|
function resolveSitePathSafe(dir) {
|
||||||
|
//const clean = normalizeDirField(dir);
|
||||||
|
//const abs = path.resolve(SITES_ROOT, clean);
|
||||||
|
const abs = path.resolve(SITES_ROOT, dir);
|
||||||
|
const rootWithSep = SITES_ROOT.endsWith(path.sep) ? SITES_ROOT : SITES_ROOT + path.sep;
|
||||||
|
if (!(abs === SITES_ROOT || abs.startsWith(rootWithSep))) {
|
||||||
|
throw new Error('Percorso non consentito.');
|
||||||
|
}
|
||||||
|
return abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Utilities --------------------
|
||||||
|
async function readJsonSafe(file) {
|
||||||
|
try {
|
||||||
|
return await fse.readJson(file);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT') return [];
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJsonAtomic(file, data) {
|
||||||
|
const tmp = file + '.tmp';
|
||||||
|
await fse.writeJson(tmp, data, { spaces: 2 });
|
||||||
|
await fse.move(tmp, file, { overwrite: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/del-site', async (req, res) => {
|
||||||
|
try {
|
||||||
|
let { dir } = req.body || {};
|
||||||
|
//console.log("dir da cancellare: ",dir);
|
||||||
|
const siteAbsPath = resolveSitePathSafe(dir);
|
||||||
|
//console.log("path assoluto: ", siteAbsPath);
|
||||||
|
if (await fse.pathExists(siteAbsPath)) {
|
||||||
|
await fse.remove(siteAbsPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
sites = delFieldDir(dir);
|
||||||
|
//await deleteIcon("icons/composerize.ico");
|
||||||
|
console.log(`la dir=${dir} è stata cancellata`);
|
||||||
|
return res.json({
|
||||||
|
ok: true,
|
||||||
|
// removed: { name: removed?.name, dir: removed?.dir },
|
||||||
|
deletedPath: siteAbsPath
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('del-site error:', err);
|
||||||
|
const msg = err?.message || 'Errore interno nella cancellazione.';
|
||||||
|
return res.status(500).json({ error: msg });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.removeHeader("X-Frame-Options");
|
||||||
|
res.setHeader("Content-Security-Policy", "frame-ancestors *");
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// endpoint POST /add-site
|
||||||
|
app.post("/add-site", (req, res) => {
|
||||||
|
const { url, dir } = req.body;
|
||||||
|
const dir1 = `${DOWNLOAD_SITES}/${dir}`;
|
||||||
|
|
||||||
|
// 👉 qui esegui lo script Python
|
||||||
|
execFile("downloadsite.sh", [url, dir1], (error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
console.error("Errore:", error);
|
||||||
|
return res.status(500).json({ status: "error", message: stderr });
|
||||||
|
}
|
||||||
|
sites = addFieldDir(dir);
|
||||||
|
res.json({ status: "ok", output: stdout });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
//const SITES = [];
|
||||||
|
// Monta le cartelle statiche in base alla lista SITES
|
||||||
|
sites.forEach(site => {
|
||||||
|
if (site) {
|
||||||
|
// directory reale: sites/<site>
|
||||||
|
const dirPath = path.join(SITES_ROOT, site.dir);
|
||||||
|
|
||||||
|
// URL pubblico: /<site>
|
||||||
|
app.use(`/${site.dir}`, express.static(dirPath));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Cartella public come root
|
||||||
|
//app.use("/", express.static("public"));
|
||||||
|
app.use("/", express.static("home", { index: "sidebar.html" }));
|
||||||
|
//app.use("/root", express.static("public"));
|
||||||
|
app.use("/home", express.static("home"));
|
||||||
|
app.use("/settings", express.static("home", { index: "settings.html" }));
|
||||||
|
app.use("/manifest", express.static("home", { index: "manifest-editor.html" }));
|
||||||
|
/*
|
||||||
|
app.use('/manifest', express.static(path.join(process.cwd(), 'home'), {
|
||||||
|
index: 'manifest-editor.html',
|
||||||
|
redirect: false // opzionale: evita 301 /manifest -> /manifest/
|
||||||
|
}));
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Endpoint per esporre la config al client
|
||||||
|
app.get("/config.json", (req, res) => {
|
||||||
|
res.json({
|
||||||
|
host: HOST,
|
||||||
|
port: PORT,
|
||||||
|
// sites: SITES,
|
||||||
|
s: sites,
|
||||||
|
url: URL
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload1 = multer();
|
||||||
|
|
||||||
|
/** GET: manifest corrente **/
|
||||||
|
app.post('/api/manifest', upload1.none(), (req, res) => {
|
||||||
|
//app.get('/api/manifest', (req, res) => {
|
||||||
|
try {
|
||||||
|
//console.log(req.body.dir);
|
||||||
|
const mydir = req.body.dir;
|
||||||
|
const manifest = readManifest(path.join(SITES_ROOT, mydir , 'manifest.json'));
|
||||||
|
res.json(manifest);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: 'Impossibile leggere manifest.json' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PUT: salva il manifest (campi + icone) **/
|
||||||
|
app.put('/api/manifest', upload1.none(), (req, res) => {
|
||||||
|
// Validazioni essenziali
|
||||||
|
const mydir = req.body.dir;
|
||||||
|
const incoming = JSON.parse(req.body.manifest);
|
||||||
|
//console.log("manifest put");
|
||||||
|
//console.log("incoming=",incoming);
|
||||||
|
//console.log("mydir=",mydir);
|
||||||
|
const manPath = path.join(SITES_ROOT, mydir , 'manifest.json');
|
||||||
|
//console.log("manPath",manPath);
|
||||||
|
if (!incoming.name || !incoming.short_name) {
|
||||||
|
return res.status(400).json({ error: 'name e short_name sono obbligatori' });
|
||||||
|
}
|
||||||
|
//console.log("step1");
|
||||||
|
if (!Array.isArray(incoming.icons)) incoming.icons = [];
|
||||||
|
//console.log("step2");
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(manPath, JSON.stringify(incoming, null, 2), 'utf8');
|
||||||
|
console.log(`manifest di <${mydir}> salvato`);
|
||||||
|
generateSitesJson();
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: 'Salvataggio manifest fallito' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Multer: configurazione upload (in memoria) **/
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
// Accetta i tipi più comuni
|
||||||
|
const okTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/jpg'];
|
||||||
|
if (file && okTypes.includes(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error(`Formato non supportato: ${file ? file.mimetype : 'Nessun file'}`));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
limits: { fileSize: 8 * 1024 * 1024 } // 8 MB
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST: upload icona + generazione 192 e 512 **/
|
||||||
|
app.post('/api/upload-icon', upload.single('icon'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Log diagnostico utile
|
||||||
|
/* console.log('Upload Content-Type:', req.headers['content-type']);
|
||||||
|
console.log('Upload body keys:', Object.keys(req.body || {}));
|
||||||
|
console.log('Upload file:', req.file && {
|
||||||
|
fieldname: req.file.fieldname,
|
||||||
|
mimetype: req.file.mimetype,
|
||||||
|
size: req.file.size,
|
||||||
|
originalname: req.file.originalname
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
const mydir = req.body.mydir;
|
||||||
|
//console.log("dir=",mydir);
|
||||||
|
|
||||||
|
const iconsMyDir = path.join(SITES_ROOT,mydir , 'icons');
|
||||||
|
fs.mkdirSync(iconsMyDir, { recursive: true });
|
||||||
|
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Nessun file ricevuto nel campo "icon". Assicurati che l’input abbia name="icon" e che il JS usi FormData.append("icon", file).'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const purpose = (req.body.purpose || 'any').trim();
|
||||||
|
const baseNameSafe = (req.file.originalname || 'icon')
|
||||||
|
.replace(/\.[^.]+$/, '') // togli estensione
|
||||||
|
.replace(/[^a-zA-Z0-9-_]/g, ''); // pulisci caratteri strani
|
||||||
|
|
||||||
|
// Puoi aggiungere altre dimensioni se vuoi
|
||||||
|
const sizes = [192, 512];
|
||||||
|
const created = [];
|
||||||
|
|
||||||
|
for (const size of sizes) {
|
||||||
|
const filename = `${baseNameSafe}-${size}.png`;
|
||||||
|
const outPath = path.join(path.join(SITES_ROOT, mydir, 'icons'), filename);
|
||||||
|
|
||||||
|
await sharp(req.file.buffer)
|
||||||
|
.resize(size, size, { fit: 'cover' })
|
||||||
|
.png({ quality: 90 })
|
||||||
|
.toFile(outPath);
|
||||||
|
|
||||||
|
created.push({
|
||||||
|
src: `/icons/${filename}`,
|
||||||
|
sizes: `${size}x${size}`,
|
||||||
|
type: 'image/png',
|
||||||
|
purpose
|
||||||
|
});
|
||||||
|
}
|
||||||
|
generateSitesJson();
|
||||||
|
res.json({ ok: true, icons: created });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Errore /api/upload-icon:', e);
|
||||||
|
res.status(500).json({ error: 'Elaborazione icona fallita' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE: rimuove icona dal manifest e opzionalmente elimina il file
|
||||||
|
* query:
|
||||||
|
* - src (richiesto): percorso icona (es. /icons/icon-192.png)
|
||||||
|
* - removeFile=true|false (opzionale): se true elimina anche il file se sotto /public/icons
|
||||||
|
*/
|
||||||
|
app.delete('/api/icons', (req, res) => {
|
||||||
|
const src = req.query.src;
|
||||||
|
const removeFile = (req.query.removeFile || 'false') === 'true';
|
||||||
|
const mydir = req.query.myDir;
|
||||||
|
const manPath = path.join(SITES_ROOT, mydir , 'manifest.json');
|
||||||
|
//console.log("delete icon:");
|
||||||
|
//console.log("src=",src);
|
||||||
|
//console.log("mydir=",mydir);
|
||||||
|
if (!src) return res.status(400).json({ error: 'Parametro src mancante' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manifest = readManifest(manPath);
|
||||||
|
manifest.icons = (manifest.icons || []).filter(i => i.src !== src);
|
||||||
|
|
||||||
|
if (removeFile) {
|
||||||
|
const absFilePath = path.join(SITES_ROOT, mydir, src.replace(/^\//, ''));
|
||||||
|
const isUnderIcons = absFilePath.startsWith(iconsDir + path.sep);
|
||||||
|
if (isUnderIcons && fs.existsSync(absFilePath)) {
|
||||||
|
fs.unlinkSync(absFilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(manPath, JSON.stringify(manifest, null, 2), 'utf8');
|
||||||
|
generateSitesJson();
|
||||||
|
res.json({ ok: true, removedFromManifest: true, fileDeleted: removeFile });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(500).json({ error: 'Eliminazione icona fallita' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
app.listen(PORT, HOST, () => {
|
||||||
|
console.log(`✅ Server pronto su http://${HOST}:${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
219
web_scraper.log
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
07:57:22 | INFO | MainThread | [1/100] https://www.composerize.com
|
||||||
|
07:57:22 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
|
||||||
|
07:57:22 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
|
||||||
|
07:57:22 | DEBUG | MainThread | Created directory sites/mio
|
||||||
|
07:57:22 | DEBUG | MainThread | Saved page sites/mio/index.html
|
||||||
|
07:57:22 | DEBUG | DL-2 | Starting new HTTPS connection (2): www.composerize.com:443
|
||||||
|
07:57:22 | DEBUG | DL-1 | Starting new HTTPS connection (3): www.composerize.com:443
|
||||||
|
07:57:22 | DEBUG | DL-4 | Starting new HTTPS connection (4): www.composerize.com:443
|
||||||
|
07:57:22 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 None
|
||||||
|
07:57:22 | DEBUG | DL-3 | Saved resource -> sites/mio/favicon.ico
|
||||||
|
07:57:22 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
|
||||||
|
07:57:22 | DEBUG | DL-4 | Saved resource -> sites/mio/manifest.json
|
||||||
|
07:57:22 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 None
|
||||||
|
07:57:22 | DEBUG | DL-1 | Created directory sites/mio/static/css
|
||||||
|
07:57:22 | DEBUG | DL-1 | Saved resource -> sites/mio/static/css/main.757d3484.css
|
||||||
|
07:57:22 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 None
|
||||||
|
07:57:22 | DEBUG | DL-2 | Created directory sites/mio/static/js
|
||||||
|
07:57:23 | DEBUG | DL-2 | Saved resource -> sites/mio/static/js/main.623047e0.js
|
||||||
|
07:57:23 | INFO | MainThread | Crawl finished: 1 pages in 1.03s (1.03s avg)
|
||||||
|
08:00:47 | INFO | MainThread | [1/100] https://www.composerize.com
|
||||||
|
08:00:47 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
|
||||||
|
08:00:47 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
|
||||||
|
08:00:47 | DEBUG | MainThread | Saved page sites/mio/index.html
|
||||||
|
08:00:47 | INFO | MainThread | Crawl finished: 1 pages in 0.14s (0.14s avg)
|
||||||
|
08:11:57 | INFO | MainThread | [1/100] https://www.composerize.com
|
||||||
|
08:11:57 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
|
||||||
|
08:11:57 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
|
||||||
|
08:11:57 | DEBUG | MainThread | Saved page sites/mio/index.html
|
||||||
|
08:11:57 | INFO | MainThread | Crawl finished: 1 pages in 0.12s (0.12s avg)
|
||||||
|
08:23:16 | INFO | MainThread | [1/100] https://www.composerize.com
|
||||||
|
08:23:16 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
|
||||||
|
08:23:16 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
|
||||||
|
08:23:16 | DEBUG | MainThread | Created directory home/sites/mio
|
||||||
|
08:23:16 | DEBUG | MainThread | Saved page home/sites/mio/index.html
|
||||||
|
08:23:16 | DEBUG | DL-3 | Starting new HTTPS connection (2): www.composerize.com:443
|
||||||
|
08:23:16 | DEBUG | DL-2 | Starting new HTTPS connection (3): www.composerize.com:443
|
||||||
|
08:23:16 | DEBUG | DL-1 | Starting new HTTPS connection (4): www.composerize.com:443
|
||||||
|
08:23:16 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 3430
|
||||||
|
08:23:16 | DEBUG | DL-4 | Saved resource -> home/sites/mio/favicon.ico
|
||||||
|
08:23:16 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
|
||||||
|
08:23:16 | DEBUG | DL-1 | Saved resource -> home/sites/mio/manifest.json
|
||||||
|
08:23:16 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 1761
|
||||||
|
08:23:16 | DEBUG | DL-3 | Created directory home/sites/mio/static/css
|
||||||
|
08:23:16 | DEBUG | DL-3 | Saved resource -> home/sites/mio/static/css/main.757d3484.css
|
||||||
|
08:23:16 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 176652
|
||||||
|
08:23:16 | DEBUG | DL-2 | Created directory home/sites/mio/static/js
|
||||||
|
08:23:16 | DEBUG | DL-2 | Saved resource -> home/sites/mio/static/js/main.623047e0.js
|
||||||
|
08:23:16 | INFO | MainThread | Crawl finished: 1 pages in 0.39s (0.39s avg)
|
||||||
|
08:25:06 | INFO | MainThread | [1/100] https://www.composerize.com
|
||||||
|
08:25:06 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
|
||||||
|
08:25:06 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
|
||||||
|
08:25:06 | DEBUG | MainThread | Created directory home/sites/mio
|
||||||
|
08:25:06 | DEBUG | MainThread | Saved page home/sites/mio/index.html
|
||||||
|
08:25:06 | DEBUG | DL-1 | Starting new HTTPS connection (2): www.composerize.com:443
|
||||||
|
08:25:06 | DEBUG | DL-2 | Starting new HTTPS connection (3): www.composerize.com:443
|
||||||
|
08:25:06 | DEBUG | DL-4 | Starting new HTTPS connection (4): www.composerize.com:443
|
||||||
|
08:25:06 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
|
||||||
|
08:25:06 | DEBUG | DL-3 | Saved resource -> home/sites/mio/manifest.json
|
||||||
|
08:25:06 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 3430
|
||||||
|
08:25:06 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 176652
|
||||||
|
08:25:06 | DEBUG | DL-2 | Created directory home/sites/mio/static/js
|
||||||
|
08:25:06 | DEBUG | DL-1 | Saved resource -> home/sites/mio/favicon.ico
|
||||||
|
08:25:06 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 1761
|
||||||
|
08:25:06 | DEBUG | DL-4 | Created directory home/sites/mio/static/css
|
||||||
|
08:25:06 | DEBUG | DL-4 | Saved resource -> home/sites/mio/static/css/main.757d3484.css
|
||||||
|
08:25:07 | DEBUG | DL-2 | Saved resource -> home/sites/mio/static/js/main.623047e0.js
|
||||||
|
08:25:07 | INFO | MainThread | Crawl finished: 1 pages in 0.30s (0.30s avg)
|
||||||
|
08:54:46 | INFO | MainThread | [1/100] https://www.composerize.com
|
||||||
|
08:54:46 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
|
||||||
|
08:54:46 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
|
||||||
|
08:54:46 | DEBUG | MainThread | Created directory home/sites/mio
|
||||||
|
08:54:46 | DEBUG | DL-3 | Starting new HTTPS connection (2): www.composerize.com:443
|
||||||
|
08:54:46 | DEBUG | DL-1 | Starting new HTTPS connection (3): www.composerize.com:443
|
||||||
|
08:54:46 | DEBUG | DL-4 | Starting new HTTPS connection (4): www.composerize.com:443
|
||||||
|
08:54:46 | DEBUG | MainThread | Saved page home/sites/mio/index.html
|
||||||
|
08:54:46 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
|
||||||
|
08:54:46 | DEBUG | DL-2 | Saved resource -> home/sites/mio/manifest.json
|
||||||
|
08:54:46 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 176652
|
||||||
|
08:54:46 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 3430
|
||||||
|
08:54:46 | DEBUG | DL-4 | Created directory home/sites/mio/static/js
|
||||||
|
08:54:46 | DEBUG | DL-1 | Saved resource -> home/sites/mio/favicon.ico
|
||||||
|
08:54:46 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 1761
|
||||||
|
08:54:46 | DEBUG | DL-3 | Created directory home/sites/mio/static/css
|
||||||
|
08:54:46 | DEBUG | DL-3 | Saved resource -> home/sites/mio/static/css/main.757d3484.css
|
||||||
|
08:54:46 | DEBUG | DL-4 | Saved resource -> home/sites/mio/static/js/main.623047e0.js
|
||||||
|
08:54:46 | INFO | MainThread | Crawl finished: 1 pages in 0.49s (0.49s avg)
|
||||||
|
08:57:24 | INFO | MainThread | [1/100] https://www.composerize.com
|
||||||
|
08:57:24 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
|
||||||
|
08:57:24 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
|
||||||
|
08:57:24 | DEBUG | MainThread | Created directory home/sites/mio
|
||||||
|
08:57:24 | DEBUG | MainThread | Saved page home/sites/mio/index.html
|
||||||
|
08:57:24 | DEBUG | DL-3 | Starting new HTTPS connection (2): www.composerize.com:443
|
||||||
|
08:57:24 | DEBUG | DL-4 | Starting new HTTPS connection (3): www.composerize.com:443
|
||||||
|
08:57:24 | DEBUG | DL-1 | Starting new HTTPS connection (4): www.composerize.com:443
|
||||||
|
08:57:24 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 176652
|
||||||
|
08:57:24 | DEBUG | DL-2 | Created directory home/sites/mio/static/js
|
||||||
|
08:57:24 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 3430
|
||||||
|
08:57:24 | DEBUG | DL-3 | Saved resource -> home/sites/mio/favicon.ico
|
||||||
|
08:57:24 | DEBUG | DL-2 | Saved resource -> home/sites/mio/static/js/main.623047e0.js
|
||||||
|
08:57:24 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
|
||||||
|
08:57:24 | DEBUG | DL-4 | Saved resource -> home/sites/mio/manifest.json
|
||||||
|
08:57:24 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 1761
|
||||||
|
08:57:24 | DEBUG | DL-1 | Created directory home/sites/mio/static/css
|
||||||
|
08:57:24 | DEBUG | DL-1 | Saved resource -> home/sites/mio/static/css/main.757d3484.css
|
||||||
|
08:57:24 | INFO | MainThread | Crawl finished: 1 pages in 0.30s (0.30s avg)
|
||||||
|
09:00:47 | INFO | MainThread | [1/100] https://www.composerize.com
|
||||||
|
09:00:47 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
|
||||||
|
09:00:47 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
|
||||||
|
09:00:47 | DEBUG | DL-4 | Starting new HTTPS connection (2): www.composerize.com:443
|
||||||
|
09:00:47 | DEBUG | DL-2 | Starting new HTTPS connection (3): www.composerize.com:443
|
||||||
|
09:00:47 | DEBUG | MainThread | Created directory home/sites/mio
|
||||||
|
09:00:47 | DEBUG | MainThread | Saved page home/sites/mio/index.html
|
||||||
|
09:00:47 | DEBUG | DL-1 | Starting new HTTPS connection (4): www.composerize.com:443
|
||||||
|
09:00:47 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 3430
|
||||||
|
09:00:47 | DEBUG | DL-3 | Saved resource -> home/sites/mio/favicon.ico
|
||||||
|
09:00:47 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 1761
|
||||||
|
09:00:47 | DEBUG | DL-4 | Created directory home/sites/mio/static/css
|
||||||
|
09:00:47 | DEBUG | DL-4 | Saved resource -> home/sites/mio/static/css/main.757d3484.css
|
||||||
|
09:00:47 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
|
||||||
|
09:00:47 | DEBUG | DL-2 | Saved resource -> home/sites/mio/manifest.json
|
||||||
|
09:00:47 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 176652
|
||||||
|
09:00:47 | DEBUG | DL-1 | Created directory home/sites/mio/static/js
|
||||||
|
09:00:47 | DEBUG | DL-1 | Saved resource -> home/sites/mio/static/js/main.623047e0.js
|
||||||
|
09:00:47 | INFO | MainThread | Crawl finished: 1 pages in 0.40s (0.40s avg)
|
||||||
|
09:41:16 | INFO | MainThread | [1/100] https://www.composerize.com
|
||||||
|
09:41:16 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
|
||||||
|
09:41:16 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
|
||||||
|
09:41:16 | DEBUG | MainThread | Created directory home/sites/composterize
|
||||||
|
09:41:16 | DEBUG | MainThread | Saved page home/sites/composterize/index.html
|
||||||
|
09:41:16 | DEBUG | DL-4 | Starting new HTTPS connection (2): www.composerize.com:443
|
||||||
|
09:41:16 | DEBUG | DL-1 | Starting new HTTPS connection (3): www.composerize.com:443
|
||||||
|
09:41:16 | DEBUG | DL-2 | Starting new HTTPS connection (4): www.composerize.com:443
|
||||||
|
09:41:16 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 3430
|
||||||
|
09:41:16 | DEBUG | DL-3 | Saved resource -> home/sites/composterize/favicon.ico
|
||||||
|
09:41:16 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
|
||||||
|
09:41:16 | DEBUG | DL-4 | Saved resource -> home/sites/composterize/manifest.json
|
||||||
|
09:41:16 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 176652
|
||||||
|
09:41:16 | DEBUG | DL-2 | Created directory home/sites/composterize/static/js
|
||||||
|
09:41:16 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 1761
|
||||||
|
09:41:16 | DEBUG | DL-1 | Created directory home/sites/composterize/static/css
|
||||||
|
09:41:16 | DEBUG | DL-1 | Saved resource -> home/sites/composterize/static/css/main.757d3484.css
|
||||||
|
09:41:16 | DEBUG | DL-2 | Saved resource -> home/sites/composterize/static/js/main.623047e0.js
|
||||||
|
09:41:16 | INFO | MainThread | Crawl finished: 1 pages in 0.44s (0.44s avg)
|
||||||
|
09:46:06 | INFO | MainThread | [1/100] https://www.composerize.com
|
||||||
|
09:46:06 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
|
||||||
|
09:46:06 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
|
||||||
|
09:46:06 | DEBUG | MainThread | Created directory home/sites/composerize
|
||||||
|
09:46:06 | DEBUG | MainThread | Saved page home/sites/composerize/index.html
|
||||||
|
09:46:06 | DEBUG | DL-2 | Starting new HTTPS connection (2): www.composerize.com:443
|
||||||
|
09:46:06 | DEBUG | DL-1 | Starting new HTTPS connection (3): www.composerize.com:443
|
||||||
|
09:46:06 | DEBUG | DL-4 | Starting new HTTPS connection (4): www.composerize.com:443
|
||||||
|
09:46:06 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
|
||||||
|
09:46:06 | DEBUG | DL-3 | Saved resource -> home/sites/composerize/manifest.json
|
||||||
|
09:46:06 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 176652
|
||||||
|
09:46:06 | DEBUG | DL-4 | Created directory home/sites/composerize/static/js
|
||||||
|
09:46:06 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 3430
|
||||||
|
09:46:06 | DEBUG | DL-2 | Saved resource -> home/sites/composerize/favicon.ico
|
||||||
|
09:46:06 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 1761
|
||||||
|
09:46:06 | DEBUG | DL-1 | Created directory home/sites/composerize/static/css
|
||||||
|
09:46:06 | DEBUG | DL-1 | Saved resource -> home/sites/composerize/static/css/main.757d3484.css
|
||||||
|
09:46:06 | DEBUG | DL-4 | Saved resource -> home/sites/composerize/static/js/main.623047e0.js
|
||||||
|
09:46:06 | INFO | MainThread | Crawl finished: 1 pages in 0.31s (0.31s avg)
|
||||||
|
10:17:29 | INFO | MainThread | [1/100] https://www.composerize.com
|
||||||
|
10:17:29 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
|
||||||
|
10:17:29 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
|
||||||
|
10:17:29 | DEBUG | MainThread | Created directory home/sites/composerize
|
||||||
|
10:17:29 | DEBUG | MainThread | Saved page home/sites/composerize/index.html
|
||||||
|
10:17:29 | DEBUG | DL-1 | Starting new HTTPS connection (2): www.composerize.com:443
|
||||||
|
10:17:29 | DEBUG | DL-3 | Starting new HTTPS connection (3): www.composerize.com:443
|
||||||
|
10:17:29 | DEBUG | DL-2 | Starting new HTTPS connection (4): www.composerize.com:443
|
||||||
|
10:17:29 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 3430
|
||||||
|
10:17:29 | DEBUG | DL-4 | Saved resource -> home/sites/composerize/favicon.ico
|
||||||
|
10:17:29 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
|
||||||
|
10:17:29 | DEBUG | DL-3 | Saved resource -> home/sites/composerize/manifest.json
|
||||||
|
10:17:29 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 176652
|
||||||
|
10:17:29 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 1761
|
||||||
|
10:17:29 | DEBUG | DL-2 | Created directory home/sites/composerize/static/js
|
||||||
|
10:17:29 | DEBUG | DL-1 | Created directory home/sites/composerize/static/css
|
||||||
|
10:17:29 | DEBUG | DL-1 | Saved resource -> home/sites/composerize/static/css/main.757d3484.css
|
||||||
|
10:17:30 | DEBUG | DL-2 | Saved resource -> home/sites/composerize/static/js/main.623047e0.js
|
||||||
|
10:17:30 | INFO | MainThread | Crawl finished: 1 pages in 0.41s (0.41s avg)
|
||||||
|
10:23:58 | INFO | MainThread | [1/100] https://www.composerize.com
|
||||||
|
10:23:58 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
|
||||||
|
10:23:58 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
|
||||||
|
10:23:58 | DEBUG | DL-3 | Starting new HTTPS connection (2): www.composerize.com:443
|
||||||
|
10:23:58 | DEBUG | MainThread | Created directory home/sites/composerize
|
||||||
|
10:23:58 | DEBUG | MainThread | Saved page home/sites/composerize/index.html
|
||||||
|
10:23:58 | DEBUG | DL-4 | Starting new HTTPS connection (3): www.composerize.com:443
|
||||||
|
10:23:58 | DEBUG | DL-1 | Starting new HTTPS connection (4): www.composerize.com:443
|
||||||
|
10:23:58 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
|
||||||
|
10:23:58 | DEBUG | DL-2 | Saved resource -> home/sites/composerize/manifest.json
|
||||||
|
10:23:58 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 3430
|
||||||
|
10:23:58 | DEBUG | DL-3 | Saved resource -> home/sites/composerize/favicon.ico
|
||||||
|
10:23:59 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 176652
|
||||||
|
10:23:59 | DEBUG | DL-4 | Created directory home/sites/composerize/static/js
|
||||||
|
10:23:59 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 1761
|
||||||
|
10:23:59 | DEBUG | DL-1 | Created directory home/sites/composerize/static/css
|
||||||
|
10:23:59 | DEBUG | DL-1 | Saved resource -> home/sites/composerize/static/css/main.757d3484.css
|
||||||
|
10:23:59 | DEBUG | DL-4 | Saved resource -> home/sites/composerize/static/js/main.623047e0.js
|
||||||
|
10:23:59 | INFO | MainThread | Crawl finished: 1 pages in 0.31s (0.31s avg)
|
||||||
|
10:27:36 | INFO | MainThread | [1/100] https://www.composerize.com
|
||||||
|
10:27:36 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
|
||||||
|
10:27:36 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
|
||||||
|
10:27:36 | DEBUG | MainThread | Created directory home/sites/composerize
|
||||||
|
10:27:36 | DEBUG | MainThread | Saved page home/sites/composerize/index.html
|
||||||
|
10:27:36 | DEBUG | DL-1 | Starting new HTTPS connection (2): www.composerize.com:443
|
||||||
|
10:27:36 | DEBUG | DL-2 | Starting new HTTPS connection (3): www.composerize.com:443
|
||||||
|
10:27:36 | DEBUG | DL-4 | Starting new HTTPS connection (4): www.composerize.com:443
|
||||||
|
10:27:37 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
|
||||||
|
10:27:37 | DEBUG | DL-3 | Saved resource -> home/sites/composerize/manifest.json
|
||||||
|
10:27:37 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 1761
|
||||||
|
10:27:37 | DEBUG | DL-4 | Created directory home/sites/composerize/static/css
|
||||||
|
10:27:37 | DEBUG | DL-4 | Saved resource -> home/sites/composerize/static/css/main.757d3484.css
|
||||||
|
10:27:37 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 3430
|
||||||
|
10:27:37 | DEBUG | DL-1 | Saved resource -> home/sites/composerize/favicon.ico
|
||||||
|
10:27:37 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 176652
|
||||||
|
10:27:37 | DEBUG | DL-2 | Created directory home/sites/composerize/static/js
|
||||||
|
10:27:37 | DEBUG | DL-2 | Saved resource -> home/sites/composerize/static/js/main.623047e0.js
|
||||||
|
10:27:37 | INFO | MainThread | Crawl finished: 1 pages in 0.39s (0.39s avg)
|
||||||
406
website-downloader.py
Executable file
|
|
@ -0,0 +1,406 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from hashlib import sha256
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util import Retry
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config / constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
LOG_FMT = "%(asctime)s | %(levelname)-8s | %(threadName)s | %(message)s"
|
||||||
|
|
||||||
|
DEFAULT_HEADERS = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) "
|
||||||
|
"Gecko/20100101 Firefox/128.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
TIMEOUT = 15 # seconds
|
||||||
|
CHUNK_SIZE = 8192 # bytes
|
||||||
|
|
||||||
|
# Conservative margins under common OS limits (~255–260 bytes)
|
||||||
|
MAX_PATH_LEN = 240
|
||||||
|
MAX_SEG_LEN = 120
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
filename="web_scraper.log",
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format=LOG_FMT,
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
_console = logging.StreamHandler(sys.stdout)
|
||||||
|
_console.setLevel(logging.INFO)
|
||||||
|
_console.setFormatter(logging.Formatter(LOG_FMT, datefmt="%H:%M:%S"))
|
||||||
|
logging.getLogger().addHandler(_console)
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTTP session (retry, timeouts, custom UA)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SESSION = requests.Session()
|
||||||
|
RETRY_STRAT = Retry(
|
||||||
|
total=5,
|
||||||
|
backoff_factor=0.5,
|
||||||
|
status_forcelist=[429, 500, 502, 503, 504],
|
||||||
|
allowed_methods=["GET", "HEAD"],
|
||||||
|
)
|
||||||
|
SESSION.mount("http://", HTTPAdapter(max_retries=RETRY_STRAT))
|
||||||
|
SESSION.mount("https://", HTTPAdapter(max_retries=RETRY_STRAT))
|
||||||
|
SESSION.headers.update(DEFAULT_HEADERS)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def create_dir(path: Path) -> None:
|
||||||
|
"""Create path (and parents) if it does not already exist."""
|
||||||
|
if not path.exists():
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
log.debug("Created directory %s", path)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize(url_fragment: str) -> str:
|
||||||
|
"""Strip back-references and Windows backslashes."""
|
||||||
|
return url_fragment.replace("\\", "/").replace("..", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
NON_FETCHABLE_SCHEMES = {"mailto", "tel", "sms", "javascript", "data", "geo", "blob"}
|
||||||
|
|
||||||
|
|
||||||
|
def is_httpish(u: str) -> bool:
|
||||||
|
"""True iff the URL is http(s) or relative (no scheme)."""
|
||||||
|
p = urlparse(u)
|
||||||
|
return (p.scheme in ("http", "https")) or (p.scheme == "")
|
||||||
|
|
||||||
|
|
||||||
|
def is_non_fetchable(u: str) -> bool:
|
||||||
|
"""True iff the URL clearly shouldn't be fetched (mailto:, tel:, data:, ...)."""
|
||||||
|
p = urlparse(u)
|
||||||
|
return p.scheme in NON_FETCHABLE_SCHEMES
|
||||||
|
|
||||||
|
|
||||||
|
def is_internal(link: str, root_netloc: str) -> bool:
|
||||||
|
"""Return True if link belongs to root_netloc (or is protocol-relative)."""
|
||||||
|
parsed = urlparse(link)
|
||||||
|
return not parsed.netloc or parsed.netloc == root_netloc
|
||||||
|
|
||||||
|
|
||||||
|
def _shorten_segment(segment: str, limit: int = MAX_SEG_LEN) -> str:
|
||||||
|
"""
|
||||||
|
Shorten a single path segment if over limit.
|
||||||
|
Preserve extension; append a short hash to keep it unique.
|
||||||
|
"""
|
||||||
|
if len(segment) <= limit:
|
||||||
|
return segment
|
||||||
|
p = Path(segment)
|
||||||
|
stem, suffix = p.stem, p.suffix
|
||||||
|
h = sha256(segment.encode("utf-8")).hexdigest()[:12]
|
||||||
|
# leave room for '-' + hash + suffix
|
||||||
|
keep = max(0, limit - len(suffix) - 13)
|
||||||
|
return f"{stem[:keep]}-{h}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def to_local_path(parsed: urlparse, site_root: Path) -> Path:
|
||||||
|
"""
|
||||||
|
Map an internal URL to a local file path under site_root.
|
||||||
|
|
||||||
|
- Adds 'index.html' where appropriate.
|
||||||
|
- Converts extensionless paths to '.html'.
|
||||||
|
- Appends a short query-hash when ?query is present to avoid collisions.
|
||||||
|
- Enforces per-segment and overall path length limits. If still too long,
|
||||||
|
hashes the leaf name.
|
||||||
|
"""
|
||||||
|
rel = parsed.path.lstrip("/")
|
||||||
|
if not rel:
|
||||||
|
rel = "index.html"
|
||||||
|
elif rel.endswith("/"):
|
||||||
|
rel += "index.html"
|
||||||
|
elif not Path(rel).suffix:
|
||||||
|
rel += ".html"
|
||||||
|
|
||||||
|
if parsed.query:
|
||||||
|
qh = sha256(parsed.query.encode("utf-8")).hexdigest()[:10]
|
||||||
|
p = Path(rel)
|
||||||
|
rel = str(p.with_name(f"{p.stem}-q{qh}{p.suffix}"))
|
||||||
|
|
||||||
|
# Shorten individual segments
|
||||||
|
parts = Path(rel).parts
|
||||||
|
parts = tuple(_shorten_segment(seg, MAX_SEG_LEN) for seg in parts)
|
||||||
|
local_path = site_root / Path(*parts)
|
||||||
|
|
||||||
|
# If full path is still too long, hash the leaf
|
||||||
|
if len(str(local_path)) > MAX_PATH_LEN:
|
||||||
|
p = local_path
|
||||||
|
h = sha256(parsed.geturl().encode("utf-8")).hexdigest()[:16]
|
||||||
|
leaf = _shorten_segment(f"{p.stem}-{h}{p.suffix}", MAX_SEG_LEN)
|
||||||
|
local_path = p.with_name(leaf)
|
||||||
|
|
||||||
|
return local_path
|
||||||
|
|
||||||
|
|
||||||
|
def safe_write_text(path: Path, text: str, encoding: str = "utf-8") -> Path:
|
||||||
|
"""
|
||||||
|
Write text to path, falling back to a hashed filename if OS rejects it
|
||||||
|
(e.g., filename too long). Returns the final path used.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
path.write_text(text, encoding=encoding)
|
||||||
|
return path
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("Write failed for %s: %s. Falling back to hashed leaf.", path, exc)
|
||||||
|
p = path
|
||||||
|
h = sha256(str(p).encode("utf-8")).hexdigest()[:16]
|
||||||
|
fallback = p.with_name(_shorten_segment(f"{p.stem}-{h}{p.suffix}", MAX_SEG_LEN))
|
||||||
|
create_dir(fallback.parent)
|
||||||
|
fallback.write_text(text, encoding=encoding)
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fetchers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_html(url: str) -> Optional[BeautifulSoup]:
|
||||||
|
"""Download url and return a BeautifulSoup tree (or None on error)."""
|
||||||
|
try:
|
||||||
|
resp = SESSION.get(url, timeout=TIMEOUT)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return BeautifulSoup(resp.text, "html.parser")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning("HTTP error for %s – %s", url, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_binary(url: str, dest: Path) -> None:
|
||||||
|
"""Stream url to dest unless it already exists. Safe against long paths."""
|
||||||
|
if dest.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
resp = SESSION.get(url, timeout=TIMEOUT, stream=True)
|
||||||
|
resp.raise_for_status()
|
||||||
|
create_dir(dest.parent)
|
||||||
|
try:
|
||||||
|
with dest.open("wb") as fh:
|
||||||
|
for chunk in resp.iter_content(CHUNK_SIZE):
|
||||||
|
fh.write(chunk)
|
||||||
|
log.debug("Saved resource -> %s", dest)
|
||||||
|
except OSError as exc:
|
||||||
|
# Fallback to hashed leaf if OS rejects path
|
||||||
|
log.warning("Binary write failed for %s: %s. Using fallback.", dest, exc)
|
||||||
|
p = dest
|
||||||
|
h = sha256(str(p).encode("utf-8")).hexdigest()[:16]
|
||||||
|
fallback = p.with_name(
|
||||||
|
_shorten_segment(f"{p.stem}-{h}{p.suffix}", MAX_SEG_LEN)
|
||||||
|
)
|
||||||
|
create_dir(fallback.parent)
|
||||||
|
with fallback.open("wb") as fh:
|
||||||
|
for chunk in resp.iter_content(CHUNK_SIZE):
|
||||||
|
fh.write(chunk)
|
||||||
|
log.debug("Saved resource (fallback) -> %s", fallback)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.error("Failed to save %s – %s", url, exc)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Link rewriting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite_links(
|
||||||
|
soup: BeautifulSoup, page_url: str, site_root: Path, page_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Rewrite internal links to local relative paths under site_root."""
|
||||||
|
root_netloc = urlparse(page_url).netloc
|
||||||
|
for tag in soup.find_all(["a", "img", "script", "link"]):
|
||||||
|
attr = "href" if tag.name in {"a", "link"} else "src"
|
||||||
|
if not tag.has_attr(attr):
|
||||||
|
continue
|
||||||
|
original = sanitize(tag[attr])
|
||||||
|
if (
|
||||||
|
original.startswith("#")
|
||||||
|
or is_non_fetchable(original)
|
||||||
|
or not is_httpish(original)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
abs_url = urljoin(page_url, original)
|
||||||
|
if not is_internal(abs_url, root_netloc):
|
||||||
|
continue # external – leave untouched
|
||||||
|
local_path = to_local_path(urlparse(abs_url), site_root)
|
||||||
|
try:
|
||||||
|
tag[attr] = os.path.relpath(local_path, page_dir)
|
||||||
|
except ValueError:
|
||||||
|
# Different drives on Windows, etc.
|
||||||
|
tag[attr] = str(local_path)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Crawl coordinator
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def crawl_site(start_url: str, root: Path, max_pages: int, threads: int) -> None:
|
||||||
|
"""Breadth-first crawl limited to max_pages. Downloads assets via workers."""
|
||||||
|
q_pages: queue.Queue[str] = queue.Queue()
|
||||||
|
q_pages.put(start_url)
|
||||||
|
seen_pages: set[str] = set()
|
||||||
|
download_q: queue.Queue[tuple[str, Path]] = queue.Queue()
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
url, dest = download_q.get(timeout=3)
|
||||||
|
except queue.Empty:
|
||||||
|
return
|
||||||
|
if is_non_fetchable(url) or not is_httpish(url):
|
||||||
|
log.debug("Skip non-fetchable: %s", url)
|
||||||
|
download_q.task_done()
|
||||||
|
continue
|
||||||
|
fetch_binary(url, dest)
|
||||||
|
download_q.task_done()
|
||||||
|
|
||||||
|
workers: list[threading.Thread] = []
|
||||||
|
for i in range(max(1, threads)):
|
||||||
|
t = threading.Thread(target=worker, name=f"DL-{i+1}", daemon=True)
|
||||||
|
t.start()
|
||||||
|
workers.append(t)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
root_netloc = urlparse(start_url).netloc
|
||||||
|
|
||||||
|
while not q_pages.empty() and len(seen_pages) < max_pages:
|
||||||
|
page_url = q_pages.get()
|
||||||
|
if page_url in seen_pages:
|
||||||
|
continue
|
||||||
|
seen_pages.add(page_url)
|
||||||
|
log.info("[%s/%s] %s", len(seen_pages), max_pages, page_url)
|
||||||
|
|
||||||
|
soup = fetch_html(page_url)
|
||||||
|
if soup is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Gather links & assets
|
||||||
|
for tag in soup.find_all(["img", "script", "link", "a"]):
|
||||||
|
link = tag.get("src") or tag.get("href")
|
||||||
|
if not link:
|
||||||
|
continue
|
||||||
|
link = sanitize(link)
|
||||||
|
if link.startswith("#") or is_non_fetchable(link) or not is_httpish(link):
|
||||||
|
continue
|
||||||
|
abs_url = urljoin(page_url, link)
|
||||||
|
parsed = urlparse(abs_url)
|
||||||
|
if not is_internal(abs_url, root_netloc):
|
||||||
|
continue
|
||||||
|
|
||||||
|
dest_path = to_local_path(parsed, root)
|
||||||
|
# HTML?
|
||||||
|
if parsed.path.endswith("/") or not Path(parsed.path).suffix:
|
||||||
|
if abs_url not in seen_pages and abs_url not in list(
|
||||||
|
q_pages.queue
|
||||||
|
): # type: ignore[arg-type]
|
||||||
|
q_pages.put(abs_url)
|
||||||
|
else:
|
||||||
|
download_q.put((abs_url, dest_path))
|
||||||
|
|
||||||
|
# Save current page
|
||||||
|
local_path = to_local_path(urlparse(page_url), root)
|
||||||
|
create_dir(local_path.parent)
|
||||||
|
rewrite_links(soup, page_url, root, local_path.parent)
|
||||||
|
html = soup.prettify()
|
||||||
|
final_path = safe_write_text(local_path, html, encoding="utf-8")
|
||||||
|
log.debug("Saved page %s", final_path)
|
||||||
|
|
||||||
|
download_q.join()
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
if seen_pages:
|
||||||
|
log.info(
|
||||||
|
"Crawl finished: %s pages in %.2fs (%.2fs avg)",
|
||||||
|
len(seen_pages),
|
||||||
|
elapsed,
|
||||||
|
elapsed / len(seen_pages),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log.warning("Nothing downloaded – check URL or connectivity")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper function for output folder
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def make_root(url: str, custom: Optional[str]) -> Path:
|
||||||
|
"""Derive output folder from URL if custom not supplied."""
|
||||||
|
return Path(custom) if custom else Path(urlparse(url).netloc.replace(".", "_"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="Recursively mirror a website for offline use.",
|
||||||
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--url",
|
||||||
|
required=True,
|
||||||
|
help="Starting URL to crawl (e.g., https://example.com/).",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--destination",
|
||||||
|
default=None,
|
||||||
|
help="Output folder (defaults to a folder derived from the URL).",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--max-pages",
|
||||||
|
type=int,
|
||||||
|
default=50,
|
||||||
|
help="Maximum number of HTML pages to crawl.",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--threads",
|
||||||
|
type=int,
|
||||||
|
default=6,
|
||||||
|
help="Number of concurrent download workers.",
|
||||||
|
)
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
args = parse_args()
|
||||||
|
if args.max_pages < 1:
|
||||||
|
log.error("--max-pages must be >= 1")
|
||||||
|
sys.exit(2)
|
||||||
|
if args.threads < 1:
|
||||||
|
log.error("--threads must be >= 1")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
host = args.url
|
||||||
|
root = make_root(args.url, args.destination)
|
||||||
|
crawl_site(host, root, args.max_pages, args.threads)
|
||||||