first commit

This commit is contained in:
Fabio 2026-01-27 12:51:43 +01:00
commit 1299ccd9f0
29 changed files with 4434 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules/

60
README.md Normal file
View file

@ -0,0 +1,60 @@
# Server Json per condivisione delle mie apps
Utilizza MongoDB su 192.168.1.3 con user root e password example nella vesione non docker
## Installazione ed avvio server
vai su server e installa i packages
```sh
cd server
npm ci install
```
far partire il server con
```
node index.js
```
o con
```
npm start
```
è settato per far partire su porta 3000
## User interface per inserire i dati
vai su frontend ed avvia la UI
```
cd frontend
npx http-server . -c-1 -p 8282
```
il comando -c-1 toglie la cache
-p indica la porta
## Altri strumenti per l'utilizzo
nella directory server c'è
./link.sh
che estrae la lista usando curl
oppure il comando in js
node list.js
che estrae la lista
nel folder how_use le api e i vari comandi in js
## Installazione in docker con mongoDB incluso (non testato)
Come avviarlo
```
docker-compose up --build -d
```

27
docker-compose.yml Normal file
View file

@ -0,0 +1,27 @@
services:
myappssvr:
build: ./server
container_name: myappssvr
restart: unless-stopped
environment:
- MONGO_URI=mongodb://root:example@192.168.1.4:27017/myapphttps?authSource=admin
- JWT_SECRET=master66
- PORT=3000
- UPLOAD_DIR=uploads
ports:
- "11001:3000"
volumes:
- /home/nvme/dockerdata/myapps_svr/uploads:/app/uploads
frontend:
image: nginx:alpine
container_name: myappsfrontend
restart: unless-stopped
ports:
- "11002:80"
volumes:
- ./frontend:/usr/share/nginx/html:ro
depends_on:
- myappssvr

122
frontend/api.js Normal file
View file

@ -0,0 +1,122 @@
const API_BASE = "http://192.168.1.4:11001";
// ------------------------------
// AUTH
// ------------------------------
export async function login(email, password) {
const res = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Errore login");
return data.token;
}
export async function register(email, password) {
const res = await fetch(`${API_BASE}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Errore registrazione");
return data;
}
// ------------------------------
// LINKS
// ------------------------------
export async function getLinks(token) {
const res = await fetch(`${API_BASE}/links`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json"
}
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Errore caricamento link");
return data;
}
/*export async function createLink(token, { name, url, iconFile }) {
const formData = new FormData();
formData.append("name", name);
formData.append("url", url);
if (iconFile) formData.append("icon", iconFile);
const res = await fetch(`${API_BASE}/links`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`
},
body: formData
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Errore creazione link");
return data;
}*/
export async function createLink(token, { name, url, iconFile, iconURL }) {
const formData = new FormData();
formData.append("name", name);
formData.append("url", url);
if (iconFile) {
formData.append("icon", iconFile);
}
if (iconURL) {
formData.append("iconURL", iconURL);
}
const res = await fetch(`${API_BASE}/links`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Errore creazione link");
return data;
}
export async function deleteLink(token, id) {
const res = await fetch(`${API_BASE}/links/${id}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`
}
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Errore eliminazione link");
return data;
}
export async function updateLink(token, id, { name, url, iconFile }) {
const formData = new FormData();
if (name) formData.append("name", name);
if (url) formData.append("url", url);
if (iconFile) formData.append("icon", iconFile);
const res = await fetch(`${API_BASE}/links/${id}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`
},
body: formData
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Errore aggiornamento link");
return data;
}

273
frontend/app.js Normal file
View file

@ -0,0 +1,273 @@
import {
login,
register,
getLinks,
createLink,
deleteLink,
updateLink
} from "./api.js";
const URL_SVR = "http://192.168.1.4:11001";
let token = null;
let autoIconURL = null;
let editingId = null;
// ===============================
// MOSTRA DIMENSIONI IMMAGINE
// ===============================
function showImageSize(imgElement, sizeElement) {
const img = new Image();
img.onload = () => {
sizeElement.textContent = `${img.width} × ${img.height} px`;
sizeElement.style.display = "block";
};
img.src = imgElement.src;
}
// ===============================
// AUTH
// ===============================
function setToken(t) {
token = t;
document.getElementById("authSection").style.display = token ? "none" : "block";
document.getElementById("linkSection").style.display = token ? "block" : "none";
if (token) loadLinks();
}
document.getElementById("loginForm").addEventListener("submit", async e => {
e.preventDefault();
try {
const t = await login(e.target.email.value, e.target.password.value);
setToken(t);
} catch (err) {
document.getElementById("authStatus").textContent = err.message;
}
});
document.getElementById("registerForm").addEventListener("submit", async e => {
e.preventDefault();
try {
await register(e.target.email.value, e.target.password.value);
document.getElementById("authStatus").textContent = "Registrato! Ora accedi.";
} catch (err) {
document.getElementById("authStatus").textContent = err.message;
}
});
// ===============================
// LOAD LINKS
// ===============================
/*async function loadLinks() {
const links = await getLinks(token);
const list = document.getElementById("list");
list.innerHTML = links
.map(
link => `
<div class="item" data-id="${link._id}">
${link.icon ? `<img src="${URL_SVR}${link.icon}">` : ""}
<div class="info">
<strong>${link.name}</strong><br>
<a href="${link.url}" target="_blank">${link.url}</a>
</div>
<div class="actions">
<button class="editBtn" data-id="${link._id}">Modifica</button>
<button class="deleteBtn" data-id="${link._id}">Elimina</button>
</div>
</div>
`
)
.join("");
}*/
async function loadLinks() {
const links = await getLinks(token);
// alert(links);
const list = document.getElementById("list");
list.innerHTML = links
.map(link => {
let iconHtml = "";
if (link.icon && link.icon.data && link.icon.mime) {
const base64 = btoa(
String.fromCharCode(...link.icon.data.data)
);
iconHtml = `<img src="data:${link.icon.mime};base64,${base64}" />`;
}
return `
<div class="item" data-id="${link._id}">
${iconHtml}
<div class="info">
<strong>${link.name}</strong><br>
<a href="${link.url}" target="_blank">${link.url}</a>
</div>
<div class="actions">
<button class="editBtn" data-id="${link._id}">Edit</button>
<button class="deleteBtn" data-id="${link._id}">Delete</button>
</div>
</div>
`;
})
.join("");
/*
list.innerHTML = links
.map(link => `
<div class="item" data-id="${link._id}">
${link.icon ? `<img src="${URL_SVR}/links/icon/${link._id}" class="icon">` : ""}
<div class="info">
<strong>${link.name}</strong><br>
<a href="${link.url}" target="_blank">${link.url}</a>
</div>
<div class="actions">
<button class="editBtn" data-id="${link._id}"></button>
<button class="deleteBtn" data-id="${link._id}">🗑</button>
</div>
</div>
`)
.join("");*/
}
// ===============================
// METADATA (icona automatica)
// ===============================
document.getElementById("fetchMetaBtn").addEventListener("click", async () => {
const url = document.getElementById("urlInput").value.trim();
if (!url) return;
const res = await fetch(`${URL_SVR}/metadata?url=${encodeURIComponent(url)}`);
const data = await res.json();
document.getElementById("nameInput").value = data.name || "";
autoIconURL = data.icon || null;
// Licona automatica è lultima scelta → reset input manuale
const fileInput = document.getElementById("iconInput");
fileInput.value = "";
const preview = document.getElementById("iconPreview");
const sizeBox = document.getElementById("iconSize");
if (autoIconURL) {
preview.src = autoIconURL;
preview.style.display = "block";
sizeBox.style.display = "none";
showImageSize(preview, sizeBox);
}
});
// ===============================
// ANTEPRIMA ICONA MANUALE
// ===============================
document.getElementById("iconInput").addEventListener("change", e => {
const file = e.target.files[0];
if (!file) return;
autoIconURL = null; // manuale vince
const preview = document.getElementById("iconPreview");
const sizeBox = document.getElementById("iconSize");
preview.src = URL.createObjectURL(file);
preview.style.display = "block";
sizeBox.style.display = "none";
showImageSize(preview, sizeBox);
});
// ===============================
// CREAZIONE LINK
// ===============================
document.getElementById("linkForm").addEventListener("submit", async e => {
e.preventDefault();
const raw = new FormData(e.target);
const manualFile = raw.get("icon");
const hasManualFile = manualFile instanceof File && manualFile.size > 0;
await createLink(token, {
name: raw.get("name"),
url: raw.get("url"),
iconFile: hasManualFile ? manualFile : null,
iconURL: !hasManualFile ? autoIconURL : null
});
autoIconURL = null;
document.getElementById("iconPreview").style.display = "none";
document.getElementById("iconSize").style.display = "none";
e.target.reset();
loadLinks();
});
// ===============================
// EDIT
// ===============================
document.getElementById("list").addEventListener("click", e => {
const id = e.target.dataset.id;
if (!id) return;
if (e.target.classList.contains("deleteBtn")) {
deleteLink(token, id).then(loadLinks);
return;
}
if (e.target.classList.contains("editBtn")) {
editingId = id;
const item = e.target.closest(".item");
const name = item.querySelector("strong").textContent;
const url = item.querySelector("a").textContent;
const form = document.getElementById("editForm");
form.name.value = name;
form.url.value = url;
document.getElementById("iconPreviewEdit").style.display = "none";
document.getElementById("iconSizeEdit").style.display = "none";
document.getElementById("editModal").style.display = "flex";
}
});
// ANTEPRIMA MANUALE IN EDIT
document.getElementById("iconInputEdit").addEventListener("change", e => {
const file = e.target.files[0];
if (!file) return;
const preview = document.getElementById("iconPreviewEdit");
const sizeBox = document.getElementById("iconSizeEdit");
preview.src = URL.createObjectURL(file);
preview.style.display = "block";
sizeBox.style.display = "none";
showImageSize(preview, sizeBox);
});
// SALVA EDIT
document.getElementById("editForm").addEventListener("submit", async e => {
e.preventDefault();
const name = e.target.name.value;
const url = e.target.url.value;
const iconFile = e.target.icon.files[0] || null;
await updateLink(token, editingId, {
name,
url,
iconFile,
iconURL: null
});
document.getElementById("editModal").style.display = "none";
loadLinks();
});
document.getElementById("closeModal").addEventListener("click", () => {
document.getElementById("editModal").style.display = "none";
});
setToken(null);

View file

@ -0,0 +1,83 @@
<script>0
async function getAppMetadata(baseUrl) {
try {
const res = await fetch(baseUrl, { mode: "cors" });
const html = await res.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// -------------------------------
// 1. Raccogli tutti i nomi possibili
// -------------------------------
const nameCandidates = [
doc.querySelector('meta[property="og:site_name"]')?.content,
doc.querySelector('meta[name="application-name"]')?.content,
doc.querySelector('meta[property="og:title"]')?.content,
doc.querySelector("title")?.textContent?.trim()
].filter(Boolean);
const name = nameCandidates.length > 0
? nameCandidates.sort((a, b) => a.length - b.length)[0]
: "no_name";
// -------------------------------
// 2. Raccogli icone dallHTML
// -------------------------------
const htmlIcons = [...doc.querySelectorAll("link[rel*='icon']")].map(link => ({
href: link.getAttribute("href"),
sizes: link.getAttribute("sizes") || ""
}));
// -------------------------------
// 3. Cerca il manifest.json
// -------------------------------
let manifestIcons = [];
const manifestLink = doc.querySelector('link[rel="manifest"]');
if (manifestLink) {
try {
const manifestUrl = new URL(manifestLink.href, baseUrl).href;
const manifestRes = await fetch(manifestUrl, { mode: "cors" });
const manifestJson = await manifestRes.json();
if (manifestJson.icons && Array.isArray(manifestJson.icons)) {
manifestIcons = manifestJson.icons.map(icon => ({
href: icon.src,
sizes: icon.sizes || ""
}));
}
} catch (e) {
// Manifest non accessibile o non valido
}
}
// -------------------------------
// 4. Unisci icone HTML + manifest
// -------------------------------
const allIcons = [...htmlIcons, ...manifestIcons];
// -------------------------------
// 5. Ordina per dimensione (più grande prima)
// -------------------------------
allIcons.sort((a, b) => {
const sizeA = parseInt(a.sizes.split("x")[0]) || 0;
const sizeB = parseInt(b.sizes.split("x")[0]) || 0;
return sizeB - sizeA;
});
// -------------------------------
// 6. Risolvi URL assoluto
// -------------------------------
let icon = null;
if (allIcons.length > 0) {
icon = new URL(allIcons[0].href, baseUrl).href;
}
return { name, icon };
} catch (err) {
return { name: "no_name", icon: null };
}
}
</script>

92
frontend/index.html Normal file
View file

@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>Link Manager</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Link Manager</h1>
<!-- AUTH -->
<section id="authSection">
<div class="card">
<h2>Accedi</h2>
<form id="loginForm">
<input type="email" name="email" placeholder="Email" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Login</button>
</form>
<h3>Oppure registrati</h3>
<form id="registerForm">
<input type="email" name="email" placeholder="Email" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Registrati</button>
</form>
<div id="authStatus"></div>
</div>
</section>
<!-- LINKS -->
<section id="linkSection" style="display:none;">
<div class="card">
<h2>Nuovo link</h2>
<form id="linkForm">
<div style="display:flex; gap:10px; align-items:center;">
<input type="text" name="url" id="urlInput" placeholder="URL" required style="flex:1;">
<button type="button" id="fetchMetaBtn">Cerca</button>
</div>
<input type="text" name="name" id="nameInput" placeholder="Nome" required>
<!-- Icona manuale -->
<input type="file" id="iconInput" name="icon" accept="image/*">
<!-- Anteprima -->
<img id="iconPreview" style="width:64px; height:64px; margin-top:10px; display:none;">
<div id="iconSize" style="margin-top:5px; font-size:12px; color:#666; display:none;"></div>
<button type="submit">Salva</button>
</form>
</div>
<div class="card">
<h2>I tuoi link</h2>
<div id="list"></div>
</div>
</section>
</div>
<script type="module" src="app.js"></script>
<!-- MODAL EDIT -->
<div id="editModal" class="modal" style="display:none;">
<div class="modal-content">
<h3>Modifica link</h3>
<form id="editForm">
<input type="text" name="name" placeholder="Nome">
<input type="text" name="url" placeholder="URL">
<input type="file" id="iconInputEdit" name="icon" accept="image/*">
<img id="iconPreviewEdit" style="width:64px; height:64px; margin-top:10px; display:none;">
<div id="iconSizeEdit" style="margin-top:5px; font-size:12px; color:#666; display:none;"></div>
<button type="submit">Salva modifiche</button>
<button type="button" id="closeModal">Annulla</button>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
<script>eruda.init();</script>
</body>
</html>

4
frontend/start.sh Normal file
View file

@ -0,0 +1,4 @@
#!/bin/bash
# Avvia un server HTTP sulla porta 11002 senza cache
npx http-server . -c-1 -p 11003

85
frontend/style.css Normal file
View file

@ -0,0 +1,85 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro", sans-serif;
background: #f5f5f7;
margin: 0;
padding: 40px;
color: #333;
}
.container {
max-width: 700px;
margin: auto;
}
h1 {
text-align: center;
margin-bottom: 40px;
font-weight: 600;
}
.card {
background: white;
padding: 25px;
border-radius: 18px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
margin-bottom: 30px;
}
form {
display: flex;
flex-direction: column;
gap: 12px;
}
input {
padding: 12px;
border-radius: 10px;
border: 1px solid #ccc;
font-size: 15px;
}
button {
padding: 12px;
border-radius: 10px;
border: none;
background: #007aff;
color: white;
font-size: 16px;
cursor: pointer;
font-weight: 600;
}
button:hover {
background: #0063cc;
}
#list .item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid #eee;
}
#list img {
width: 40px;
height: 40px;
object-fit: contain;
border-radius: 8px;
}
.modal {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: rgba(0,0,0,0.4);
display: flex;
justify-content: center;
align-items: center;
}
.modal-content {
background: white;
padding: 20px;
border-radius: 12px;
width: 300px;
}

97
how_use/api.js Normal file
View file

@ -0,0 +1,97 @@
const API_BASE = "http://localhost:3000";
// ------------------------------
// AUTH
// ------------------------------
export async function login(email, password) {
const res = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Errore login");
return data.token;
}
export async function register(email, password) {
const res = await fetch(`${API_BASE}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Errore registrazione");
return data;
}
// ------------------------------
// LINKS
// ------------------------------
export async function getLinks(token) {
const res = await fetch(`${API_BASE}/links`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json"
}
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Errore caricamento link");
return data;
}
export async function createLink(token, { name, url, iconFile }) {
const formData = new FormData();
formData.append("name", name);
formData.append("url", url);
if (iconFile) formData.append("icon", iconFile);
const res = await fetch(`${API_BASE}/links`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`
},
body: formData
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Errore creazione link");
return data;
}
export async function deleteLink(token, id) {
const res = await fetch(`${API_BASE}/links/${id}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`
}
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Errore eliminazione link");
return data;
}
export async function updateLink(token, id, { name, url, iconFile }) {
const formData = new FormData();
if (name) formData.append("name", name);
if (url) formData.append("url", url);
if (iconFile) formData.append("icon", iconFile);
const res = await fetch(`${API_BASE}/links/${id}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`
},
body: formData
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Errore aggiornamento link");
return data;
}

3
how_use/del_link.js Normal file
View file

@ -0,0 +1,3 @@
import { deleteLink } from "./api.js";
await deleteLink(token, "ID_DEL_LINK");

9
how_use/list.js Normal file
View file

@ -0,0 +1,9 @@
import { login, getLinks } from "./api.js";
async function main() {
const token = await login("fabio.micheluz@gmail.com", "master66");
const links = await getLinks(token);
console.log("Lista link:", links);
}
main();

16
how_use/new_link.js Normal file
View file

@ -0,0 +1,16 @@
import { createLink } from "./api.js";
async function add() {
const token = "IL_TUO_TOKEN";
const fileInput = document.querySelector("#iconInput");
const iconFile = fileInput.files[0];
const link = await createLink(token, {
name: "Google",
url: "https://google.com",
iconFile
});
console.log("Creato:", link);
}

7
how_use/update_link.js Normal file
View file

@ -0,0 +1,7 @@
import { updateLink } from "./api.js";
await updateLink(token, "ID_DEL_LINK", {
name: "Nuovo nome",
url: "https://nuovo-url.com",
iconFile: nuovoFile
});

16
server/.env Normal file
View file

@ -0,0 +1,16 @@
# === SERVER CONFIG ===
PORT=11001
# === JWT CONFIG ===
# Cambialo SEMPRE in produzione
JWT_SECRET=master66
# === MONGO CONFIG ===
# In locale:
# MONGO_URI=mongodb://localhost:27017/mydb
#
# In Docker (usato dal docker-compose):
MONGO_URI=mongodb://root:example@192.168.1.3:27017/myapphttps?authSource=admin
# === UPLOADS ===
# Cartella dove Express serve le icone
UPLOAD_DIR=uploads

17
server/.env.example Normal file
View file

@ -0,0 +1,17 @@
# === SERVER CONFIG ===
PORT=3000
# === JWT CONFIG ===
# Cambialo SEMPRE in produzione
JWT_SECRET=supersegreto-cambialo
# === MONGO CONFIG ===
# In locale:
# MONGO_URI=mongodb://localhost:27017/mydb
#
# In Docker (usato dal docker-compose):
MONGO_URI=mongodb://mongo:27017/mydb
# === UPLOADS ===
# Cartella dove Express serve le icone
UPLOAD_DIR=uploads

14
server/Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --production
COPY . .
RUN mkdir -p uploads
EXPOSE 3000
CMD ["node", "index.js"]

132
server/appMetadata.js Normal file
View file

@ -0,0 +1,132 @@
// appMetadata.js
import axios from "axios";
import * as cheerio from "cheerio";
import sizeOf from "image-size";
import sharp from "sharp";
export async function getAppMetadata(baseUrl) {
console.log(baseUrl);
try {
const res = await axios.get(baseUrl, { timeout: 3000 });
const $ = cheerio.load(res.data);
// -------------------------------
// 1. Nome più corto
// -------------------------------
const nameCandidates = [
$('meta[property="og:site_name"]').attr("content"),
$('meta[name="application-name"]').attr("content"),
$('meta[property="og:title"]').attr("content"),
$("title").text().trim()
].filter(Boolean);
const name = nameCandidates.length > 0
? nameCandidates.sort((a, b) => a.length - b.length)[0]
: "no_name";
// -------------------------------
// 2. Icone HTML
// -------------------------------
const htmlIcons = [];
$("link[rel*='icon']").each((_, el) => {
const href = $(el).attr("href");
const sizes = $(el).attr("sizes") || "";
if (href) htmlIcons.push({ href, sizes });
});
// -------------------------------
// 3. Manifest.json
// -------------------------------
let manifestIcons = [];
const manifestHref = $('link[rel="manifest"]').attr("href");
if (manifestHref) {
try {
const manifestUrl = new URL(manifestHref, baseUrl).href;
const manifestRes = await axios.get(manifestUrl, { timeout: 3000 });
const manifest = manifestRes.data;
if (manifest.icons && Array.isArray(manifest.icons)) {
manifestIcons = manifest.icons.map(icon => ({
href: icon.src,
sizes: icon.sizes || ""
}));
}
} catch {}
}
// -------------------------------
// 4. Fallback 4 icone
// -------------------------------
const fallbackPaths = [
"/favicon.ico",
"/favicon.png",
"/icon.png",
"/apple-touch-icon.png"
];
const fallbackIcons = fallbackPaths.map(p => ({
href: p,
sizes: ""
}));
// -------------------------------
// 5. Unisci tutte le icone
// -------------------------------
const allIcons = [...htmlIcons, ...manifestIcons, ...fallbackIcons];
// -------------------------------
// 6. Determina dimensione reale (PNG, ICO, SVG)
// -------------------------------
const iconsWithRealSize = [];
for (const icon of allIcons) {
try {
const url = new URL(icon.href, baseUrl).href;
const imgRes = await axios.get(url, { responseType: "arraybuffer" });
let width = 0;
// ---- PNG / JPG / ICO ----
try {
const dim = sizeOf(imgRes.data);
if (dim.width) width = dim.width;
} catch {
// Non è un formato supportato da image-size
}
// ---- SVG → converti in PNG e misura ----
if (width === 0) {
try {
const pngBuffer = await sharp(imgRes.data).png().toBuffer();
const dim = sizeOf(pngBuffer);
if (dim.width) width = dim.width;
} catch {
// SVG non convertibile → ignora
}
}
if (width > 0) {
iconsWithRealSize.push({ url, size: width });
}
} catch {
// icona non accessibile → ignora
}
}
// -------------------------------
// 7. Scegli la più grande
// -------------------------------
iconsWithRealSize.sort((a, b) => b.size - a.size);
const icon = iconsWithRealSize.length > 0
? iconsWithRealSize[0].url
: null;
return { name, icon };
} catch {
return { name: "no_name", icon: null };
}
}

57
server/index.js Normal file
View file

@ -0,0 +1,57 @@
import express from "express";
import mongoose from "mongoose";
import cors from "cors";
import dotenv from "dotenv";
import linksRouter from "./routes/links.js";
import authRouter from "./routes/auth.js";
import metadataRouter from "./routes/metadata.js";
dotenv.config();
const app = express();
app.use(cors());
app.use(express.json());
// Static folder per le icone
app.use("/uploads", express.static("uploads"));
// Auth routes
app.use("/auth", authRouter);
// Link routes (protette)
app.use("/links", linksRouter);
// link per metadata
app.use("/metadata", metadataRouter);
// Connessione Mongo (URL da env con fallback)
const MONGO_URI = process.env.MONGO_URI || "mongodb://mongo:27017/mydb";
mongoose
.connect(MONGO_URI)
.then(() => {
console.log("MongoDB connesso");
})
.catch(err => {
console.error("❌ Errore di connessione a MongoDB:", err.message);
process.exit(1); // termina il processo
});
const PORT = process.env.PORT || 3000;
const server = app.listen(PORT, () => {
console.log(`API su http://localhost:${PORT}`);
});
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`❌ Porta ${PORT} già in uso. Arresto del processo.`);
process.exit(1);
} else {
console.error('Errore del server:', err);
process.exit(1);
}
});

16
server/middleware/auth.js Normal file
View file

@ -0,0 +1,16 @@
import jwt from "jsonwebtoken";
export function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization || "";
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null;
if (!token) return res.status(401).json({ error: "Token mancante" });
try {
const payload = jwt.verify(token, process.env.JWT_SECRET || "devsecret");
req.userId = payload.userId;
next();
} catch (err) {
return res.status(401).json({ error: "Token non valido" });
}
}

14
server/models/Link.js Normal file
View file

@ -0,0 +1,14 @@
import mongoose from "mongoose";
const LinkSchema = new mongoose.Schema({
url: { type: String, required: true },
name: { type: String, required: true },
icon: {
data: { type: Buffer, required: false },
mime: { type: String, required: false },
size: { type: Number, required: false }
},
owner: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }
});
export default mongoose.model("Link", LinkSchema);

8
server/models/User.js Normal file
View file

@ -0,0 +1,8 @@
import mongoose from "mongoose";
const UserSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
passwordHash: { type: String, required: true }
});
export default mongoose.model("User", UserSchema);

2953
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

20
server/package.json Normal file
View file

@ -0,0 +1,20 @@
{
"type": "module",
"dependencies": {
"axios": "^1.13.2",
"bcryptjs": "^3.0.3",
"cheerio": "^1.1.2",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"icojs": "^0.20.1",
"image-size": "^2.0.2",
"jsonwebtoken": "^9.0.3",
"mongoose": "^9.0.2",
"multer": "^2.0.2",
"sharp": "^0.34.5"
},
"scripts": {
"start": "node index.js"
}
}

42
server/routes/auth.js Normal file
View file

@ -0,0 +1,42 @@
import express from "express";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import User from "../models/User.js";
const router = express.Router();
// Registrazione
router.post("/register", async (req, res) => {
const { email, password } = req.body;
if (!email || !password)
return res.status(400).json({ error: "Email e password richiesti" });
const existing = await User.findOne({ email });
if (existing) return res.status(400).json({ error: "Email già registrata" });
const passwordHash = await bcrypt.hash(password, 10);
const user = await User.create({ email, passwordHash });
res.json({ id: user._id, email: user.email });
});
// Login
router.post("/login", async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user) return res.status(400).json({ error: "Credenziali non valide" });
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) return res.status(400).json({ error: "Credenziali non valide" });
const token = jwt.sign(
{ userId: user._id },
process.env.JWT_SECRET || "devsecret",
{ expiresIn: "7d" }
);
res.json({ token });
});
export default router;

162
server/routes/links.js Normal file
View file

@ -0,0 +1,162 @@
import express from "express";
import multer from "multer";
import axios from "axios";
import sharp from "sharp";
import Link from "../models/Link.js";
import { authMiddleware } from "../middleware/auth.js";
import { parseICO } from "icojs";
const router = express.Router();
// Multer in-memory (niente filesystem)
const upload = multer({ storage: multer.memoryStorage() });
router.use(authMiddleware);
// Scarica immagine remota come Buffer
async function downloadImageAsBuffer(url) {
const response = await axios.get(url, {
responseType: "arraybuffer",
maxRedirects: 5,
headers: {
"User-Agent": "Mozilla/5.0",
"Accept": "image/*"
}
});
return {
buffer: Buffer.from(response.data),
mime: response.headers["content-type"] || ""
};
}
// Converte immagine → WebP 128x128 contain
async function processIcon(buffer, mime) {
let inputBuffer = buffer;
// Se è ICO → converti in PNG
if (mime === "image/x-icon" || mime === "image/vnd.microsoft.icon") {
const images = await parseICO(buffer);
if (!images.length) {
throw new Error("ICO non valido");
}
// Prendiamo limmagine più grande dentro lICO
const best = images.reduce((a, b) => (a.width > b.width ? a : b));
inputBuffer = Buffer.from(best.buffer);
}
// Ora Sharp può lavorare
return await sharp(inputBuffer)
.resize(128, 128, {
fit: "contain",
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.webp({ quality: 90 })
.toBuffer();
}
// ===============================
// GET LINKS
// ===============================
router.get("/", async (req, res) => {
const links = await Link.find({ owner: req.userId });
res.json(links);
});
// ===============================
// CREATE LINK
// ===============================
router.post("/", upload.single("icon"), async (req, res) => {
const { url, name, iconURL } = req.body;
let originalBuffer = null;
// Caso 1: upload file
if (req.file) {
originalBuffer = req.file.buffer;
}
// Caso 2: URL remoto
else if (iconURL) {
originalBuffer = await downloadImageAsBuffer(iconURL);
}
let processedIcon = null;
if (originalBuffer) {
processedIcon = await processIcon(originalBuffer.buffer, originalBuffer.mime);
}
const link = await Link.create({
url,
name,
owner: req.userId,
icon: processedIcon
? {
data: processedIcon,
mime: "image/webp",
size: processedIcon.length
}
: null
});
res.json(link);
});
// ===============================
// UPDATE LINK
// ===============================
router.put("/:id", upload.single("icon"), async (req, res) => {
const { id } = req.params;
const { name, url, iconURL } = req.body;
const link = await Link.findOne({ _id: id, owner: req.userId });
if (!link) return res.status(404).json({ error: "Link non trovato" });
let originalBuffer = null;
if (req.file) {
originalBuffer = req.file.buffer;
} else if (iconURL) {
originalBuffer = await downloadImageAsBuffer(iconURL);
}
const update = { name, url };
if (originalBuffer) {
const processedIcon = await processIcon(originalBuffer.buffer, originalBuffer.mime);
update.icon = {
data: processedIcon,
mime: "image/webp",
size: processedIcon.length
};
}
const updated = await Link.findOneAndUpdate(
{ _id: id, owner: req.userId },
update,
{ new: true }
);
res.json(updated);
});
// ===============================
// DELETE LINK
// ===============================
router.delete("/:id", async (req, res) => {
const link = await Link.findOneAndDelete({
_id: req.params.id,
owner: req.userId
});
if (!link) return res.status(404).json({ error: "Link non trovato" });
res.json({ success: true });
});
export default router;

104
server/routes/metadata.js Normal file
View file

@ -0,0 +1,104 @@
import express from "express";
import axios from "axios";
import * as cheerio from "cheerio";
import { URL } from "url";
const router = express.Router();
// Normalizza URL relativi → assoluti
function normalize(base, relative) {
try {
return new URL(relative, base).href;
} catch {
return null;
}
}
// Scarica HTML con fallback CORS
async function fetchHTML(url) {
try {
const res = await axios.get(url, {
timeout: 8000,
headers: {
"User-Agent": "Mozilla/5.0"
}
});
return res.data;
} catch (err) {
return null;
}
}
router.get("/", async (req, res) => {
const siteUrl = req.query.url;
if (!siteUrl) return res.json({ error: "Missing URL" });
const html = await fetchHTML(siteUrl);
if (!html) return res.json({ name: null, icon: null });
const $ = cheerio.load(html);
// -----------------------------------------
// 1. Trova il nome più corto
// -----------------------------------------
let names = [];
const title = $("title").text().trim();
if (title) names.push(title);
$('meta[name="application-name"]').each((i, el) => {
const v = $(el).attr("content");
if (v) names.push(v.trim());
});
$('meta[property="og:site_name"]').each((i, el) => {
const v = $(el).attr("content");
if (v) names.push(v.trim());
});
const shortestName = names.length
? names.sort((a, b) => a.length - b.length)[0]
: null;
// -----------------------------------------
// 2. Trova licona più grande
// -----------------------------------------
let icons = [];
$('link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"], link[rel="apple-touch-icon-precomposed"]').each((i, el) => {
const href = $(el).attr("href");
if (!href) return;
const sizeAttr = $(el).attr("sizes");
let size = 0;
if (sizeAttr && sizeAttr.includes("x")) {
const parts = sizeAttr.split("x");
size = parseInt(parts[0]) || 0;
}
icons.push({
url: normalize(siteUrl, href),
size
});
});
// fallback favicon
icons.push({
url: normalize(siteUrl, "/favicon.ico"),
size: 16
});
// Ordina per dimensione
icons = icons.filter(i => i.url);
icons.sort((a, b) => b.size - a.size);
const bestIcon = icons.length ? icons[0].url : null;
res.json({
name: shortestName,
icon: bestIcon
});
});
export default router;

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB