mirror of
https://github.com/Gerald-Ha/HodlEye-Crypto-Price-Tracker.git
synced 2025-04-27 20:22:58 +00:00
- Adding Portfolio Page - Fixing Alarm Layout with Category - Automatically delete alarms that have expired
488 lines
14 KiB
JavaScript
488 lines
14 KiB
JavaScript
const express = require("express");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const cors = require("cors");
|
|
const http = require("http");
|
|
const WebSocket = require("ws");
|
|
const session = require("express-session");
|
|
require("dotenv").config();
|
|
const fetch = require("node-fetch");
|
|
|
|
/*
|
|
* Metadata
|
|
* Version: 1.5.0
|
|
* Author/Dev: Gerald Hasani
|
|
* Name: HodlEye Crypto Price Tracker
|
|
* Email: contact@gerald-hasani.com
|
|
* GitHub: https://github.com/Gerald-Ha
|
|
*/
|
|
|
|
const app = express();
|
|
app.use(cors());
|
|
app.use(express.json());
|
|
app.use(express.urlencoded({ extended: true }));
|
|
app.use(session({
|
|
secret: process.env.SESSION_SECRET || "geheim",
|
|
resave: false,
|
|
saveUninitialized: false
|
|
}));
|
|
|
|
app.use((req, res, next) => {
|
|
const publicPaths = ["/login", "/login.html"];
|
|
const staticFileExtensions = [".css", ".js", ".png", ".jpg", ".jpeg", ".svg"];
|
|
if (req.session.loggedIn || publicPaths.includes(req.path) || staticFileExtensions.some(ext => req.path.endsWith(ext))) {
|
|
return next();
|
|
}
|
|
res.redirect("/login");
|
|
});
|
|
|
|
app.get("/login", (req, res) => {
|
|
if (req.session.loggedIn) {
|
|
return res.redirect("/");
|
|
}
|
|
res.sendFile(path.join(__dirname, "..", "public", "login.html"));
|
|
});
|
|
|
|
app.post("/login", (req, res) => {
|
|
const { username, password } = req.body;
|
|
if (username === process.env.LOGIN_USER && password === process.env.LOGIN_PASS) {
|
|
req.session.loggedIn = true;
|
|
return res.redirect("/");
|
|
} else {
|
|
return res.redirect("/login");
|
|
}
|
|
});
|
|
|
|
app.get("/logout", (req, res) => {
|
|
req.session.destroy();
|
|
res.redirect("/login");
|
|
});
|
|
|
|
const server = http.createServer(app);
|
|
const wss = new WebSocket.Server({ server });
|
|
const clients = new Set();
|
|
|
|
const DATA_FILE = path.join(__dirname, "..", "data", "data.json");
|
|
if (!fs.existsSync(DATA_FILE)) {
|
|
fs.writeFileSync(DATA_FILE, JSON.stringify({ cryptos: ["BTC"], alarms: [], notifications: [] }, null, 2));
|
|
}
|
|
|
|
const PORTFOLIO_FILE = path.join(__dirname, "..", "data", "portfolio.json");
|
|
if (!fs.existsSync(PORTFOLIO_FILE)) {
|
|
fs.writeFileSync(PORTFOLIO_FILE, JSON.stringify({ transactions: [] }, null, 2));
|
|
}
|
|
|
|
const TRADE_SUMMARY_FILE = path.join(__dirname, "..", "data", "trade_summary.json");
|
|
if (!fs.existsSync(TRADE_SUMMARY_FILE)) {
|
|
fs.writeFileSync(TRADE_SUMMARY_FILE, JSON.stringify({ trades: [] }, null, 2));
|
|
}
|
|
|
|
function readData() {
|
|
return JSON.parse(fs.readFileSync(DATA_FILE, "utf8"));
|
|
}
|
|
|
|
function writeData(data) {
|
|
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
|
|
}
|
|
|
|
function readPortfolio() {
|
|
return JSON.parse(fs.readFileSync(PORTFOLIO_FILE, "utf8"));
|
|
}
|
|
|
|
function writePortfolio(data) {
|
|
fs.writeFileSync(PORTFOLIO_FILE, JSON.stringify(data, null, 2));
|
|
}
|
|
|
|
function readTradeSummary() {
|
|
return JSON.parse(fs.readFileSync(TRADE_SUMMARY_FILE, "utf8"));
|
|
}
|
|
|
|
function writeTradeSummary(data) {
|
|
fs.writeFileSync(TRADE_SUMMARY_FILE, JSON.stringify(data, null, 2));
|
|
}
|
|
|
|
const fsp = fs.promises;
|
|
|
|
async function readDataAsync() {
|
|
const content = await fsp.readFile(DATA_FILE, "utf8");
|
|
return JSON.parse(content);
|
|
}
|
|
|
|
async function writeDataAsync(data) {
|
|
await fsp.writeFile(DATA_FILE, JSON.stringify(data, null, 2));
|
|
}
|
|
|
|
async function readPortfolioAsync() {
|
|
const content = await fsp.readFile(PORTFOLIO_FILE, "utf8");
|
|
return JSON.parse(content);
|
|
}
|
|
|
|
async function writePortfolioAsync(data) {
|
|
await fsp.writeFile(PORTFOLIO_FILE, JSON.stringify(data, null, 2));
|
|
}
|
|
|
|
async function readTradeSummaryAsync() {
|
|
const content = await fsp.readFile(TRADE_SUMMARY_FILE, "utf8");
|
|
return JSON.parse(content);
|
|
}
|
|
|
|
async function writeTradeSummaryAsync(data) {
|
|
await fsp.writeFile(TRADE_SUMMARY_FILE, JSON.stringify(data, null, 2));
|
|
}
|
|
|
|
app.get("/api/update", (req, res) => {
|
|
const remoteUpdateUrl = "https://raw.githubusercontent.com/Gerald-Ha/HodlEye-Crypto-Price-Tracker/refs/heads/main/update.json";
|
|
fetch(remoteUpdateUrl)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
res.header("Cache-Control", "no-cache, no-store, must-revalidate");
|
|
res.header("Pragma", "no-cache");
|
|
res.header("Expires", "0");
|
|
res.json(data);
|
|
})
|
|
.catch(() => {
|
|
res.status(500).json({ error: "Could not fetch update data" });
|
|
});
|
|
});
|
|
|
|
app.get("/api/cryptos", (req, res) => {
|
|
const data = readData();
|
|
res.json(data.cryptos);
|
|
});
|
|
|
|
app.post("/api/cryptos", (req, res) => {
|
|
const { symbol } = req.body;
|
|
if (!symbol) {
|
|
return res.status(400).json({ error: "Symbol is required." });
|
|
}
|
|
const data = readData();
|
|
const upperSymbol = symbol.toUpperCase();
|
|
if (!data.cryptos.includes(upperSymbol)) {
|
|
data.cryptos.push(upperSymbol);
|
|
writeData(data);
|
|
}
|
|
res.json(data.cryptos);
|
|
});
|
|
|
|
app.delete("/api/cryptos/:symbol", (req, res) => {
|
|
const symbol = req.params.symbol.toUpperCase();
|
|
const data = readData();
|
|
data.cryptos = data.cryptos.filter(s => s !== symbol);
|
|
writeData(data);
|
|
res.json({ success: true, cryptos: data.cryptos });
|
|
});
|
|
|
|
app.put("/api/cryptos", (req, res) => {
|
|
const { cryptoList } = req.body;
|
|
if (!Array.isArray(cryptoList)) {
|
|
return res.status(400).json({ error: "cryptoList must be an array." });
|
|
}
|
|
const data = readData();
|
|
data.cryptos = cryptoList;
|
|
writeData(data);
|
|
res.json({ success: true, cryptos: data.cryptos });
|
|
});
|
|
|
|
app.get("/api/alarms", (req, res) => {
|
|
const data = readData();
|
|
res.json(data.alarms);
|
|
});
|
|
|
|
app.post("/api/alarms", (req, res) => {
|
|
const { symbol, price, frequency, direction } = req.body;
|
|
if (!symbol || !price) {
|
|
return res.status(400).json({ error: "symbol and price are required." });
|
|
}
|
|
const data = readData();
|
|
const newAlarm = {
|
|
id: Date.now(),
|
|
symbol: symbol.toUpperCase(),
|
|
price: parseFloat(price),
|
|
frequency: frequency || "Once",
|
|
direction: direction || "Rising",
|
|
triggered: false
|
|
};
|
|
data.alarms.push(newAlarm);
|
|
writeData(data);
|
|
res.json(newAlarm);
|
|
});
|
|
|
|
app.delete("/api/alarms/:id", (req, res) => {
|
|
const alarmId = parseInt(req.params.id, 10);
|
|
const data = readData();
|
|
data.alarms = data.alarms.filter(a => a.id !== alarmId);
|
|
writeData(data);
|
|
res.json({ success: true, alarms: data.alarms });
|
|
});
|
|
|
|
app.get("/api/notifications", (req, res) => {
|
|
const data = readData();
|
|
res.json(data.notifications);
|
|
});
|
|
|
|
app.post("/api/notifications", (req, res) => {
|
|
const { message } = req.body;
|
|
if (!message) {
|
|
return res.status(400).json({ error: "message is required." });
|
|
}
|
|
const data = readData();
|
|
const entry = {
|
|
message,
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
data.notifications.unshift(entry);
|
|
writeData(data);
|
|
clients.forEach((client) => {
|
|
if (client.readyState === WebSocket.OPEN) {
|
|
client.send(JSON.stringify(entry));
|
|
}
|
|
});
|
|
res.json(entry);
|
|
});
|
|
|
|
app.delete("/api/notifications", (req, res) => {
|
|
const data = readData();
|
|
data.notifications = [];
|
|
writeData(data);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
async function binanceSupported(sym) {
|
|
const url = `https://api.binance.com/api/v3/ticker/24hr?symbol=${sym}USDT`;
|
|
try {
|
|
const r = await fetch(url);
|
|
if (!r.ok) return false;
|
|
const j = await r.json();
|
|
if (j.code) return false;
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function okxSupported(sym) {
|
|
const url = `https://www.okx.com/api/v5/market/ticker?instId=${sym}-USDT`;
|
|
try {
|
|
const r = await fetch(url);
|
|
if (!r.ok) return false;
|
|
const j = await r.json();
|
|
if (!j.data || !j.data[0]) return false;
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function getBinancePrice(sym) {
|
|
const tUrl = `https://api.binance.com/api/v3/ticker/24hr?symbol=${sym}USDT`;
|
|
const r = await fetch(tUrl);
|
|
if (!r.ok) throw new Error("Binance request failed");
|
|
const d = await r.json();
|
|
if (d.code) throw new Error("Binance error code");
|
|
return parseFloat(d.lastPrice);
|
|
}
|
|
|
|
async function getOkxPrice(sym) {
|
|
const url = `https://www.okx.com/api/v5/market/ticker?instId=${sym}-USDT`;
|
|
const r = await fetch(url);
|
|
if (!r.ok) throw new Error("OKX request failed");
|
|
const j = await r.json();
|
|
if (!j.data || !j.data[0]) throw new Error("No OKX data");
|
|
return parseFloat(j.data[0].last);
|
|
}
|
|
|
|
app.get("/api/coinPrice", async (req, res) => {
|
|
const sym = req.query.symbol ? req.query.symbol.toUpperCase() : "";
|
|
if (!sym) return res.status(400).json({ error: "No symbol" });
|
|
let price;
|
|
const binSup = await binanceSupported(sym);
|
|
const okxSup = await okxSupported(sym);
|
|
if (!binSup && !okxSup) {
|
|
return res.status(404).json({ error: "Not supported" });
|
|
}
|
|
if (binSup) {
|
|
try {
|
|
price = await getBinancePrice(sym);
|
|
return res.json({ price });
|
|
} catch {
|
|
if (!okxSup) return res.status(500).json({ error: "Failed fetching price" });
|
|
}
|
|
}
|
|
if (!price && okxSup) {
|
|
try {
|
|
price = await getOkxPrice(sym);
|
|
return res.json({ price });
|
|
} catch {
|
|
return res.status(500).json({ error: "Failed fetching price" });
|
|
}
|
|
}
|
|
});
|
|
|
|
app.get("/api/portfolio", (req, res) => {
|
|
const data = readPortfolio();
|
|
res.json(data);
|
|
});
|
|
|
|
app.post("/api/portfolio/buy", async (req, res) => {
|
|
const { id, symbol, amount, buyPrice, date } = req.body;
|
|
if (!symbol || !amount || !buyPrice) {
|
|
return res.status(400).json({ error: "Invalid data" });
|
|
}
|
|
const upSym = symbol.toUpperCase();
|
|
const binSup = await binanceSupported(upSym);
|
|
const okxSup = await okxSupported(upSym);
|
|
if (!binSup && !okxSup) {
|
|
return res.status(400).json({ error: "Coin not supported" });
|
|
}
|
|
let realPrice;
|
|
if (binSup) {
|
|
try {
|
|
realPrice = await getBinancePrice(upSym);
|
|
} catch {
|
|
realPrice = parseFloat(buyPrice);
|
|
}
|
|
} else if (okxSup) {
|
|
try {
|
|
realPrice = await getOkxPrice(upSym);
|
|
} catch {
|
|
realPrice = parseFloat(buyPrice);
|
|
}
|
|
} else {
|
|
realPrice = parseFloat(buyPrice);
|
|
}
|
|
const pf = readPortfolio();
|
|
const transactionId = id ? String(id) : String(Date.now() + Math.floor(Math.random() * 999999));
|
|
pf.transactions.push({
|
|
id: transactionId,
|
|
symbol: upSym,
|
|
amount: parseFloat(amount),
|
|
buyPrice: parseFloat(buyPrice),
|
|
date: date || new Date().toISOString().split("T")[0],
|
|
currentPrice: realPrice
|
|
});
|
|
writePortfolio(pf);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.get("/api/portfolio/prices", async (req, res) => {
|
|
const pf = readPortfolio();
|
|
let result = [];
|
|
let changed = false;
|
|
for (let t of pf.transactions) {
|
|
if (!t.id) {
|
|
t.id = String(Date.now() + Math.floor(Math.random() * 999999));
|
|
changed = true;
|
|
} else {
|
|
t.id = String(t.id);
|
|
}
|
|
let price = t.currentPrice;
|
|
try {
|
|
const binSup = await binanceSupported(t.symbol);
|
|
const okxSup = await okxSupported(t.symbol);
|
|
if (binSup) {
|
|
price = await getBinancePrice(t.symbol);
|
|
} else if (okxSup) {
|
|
price = await getOkxPrice(t.symbol);
|
|
}
|
|
t.currentPrice = price;
|
|
} catch {}
|
|
result.push({
|
|
id: t.id,
|
|
symbol: t.symbol,
|
|
amount: t.amount,
|
|
buyPrice: t.buyPrice,
|
|
date: t.date,
|
|
currentPrice: t.currentPrice
|
|
});
|
|
}
|
|
if (changed) {
|
|
writePortfolio(pf);
|
|
}
|
|
res.json(result);
|
|
});
|
|
|
|
app.post("/api/portfolio/edit", (req, res) => {
|
|
const { id, newAmount, newBuyPrice, newDate } = req.body;
|
|
const pf = readPortfolio();
|
|
let tx = pf.transactions.find(x => String(x.id) === String(id));
|
|
if (!tx) {
|
|
return res.status(400).json({ error: "Invalid ID" });
|
|
}
|
|
tx.amount = parseFloat(newAmount);
|
|
tx.buyPrice = parseFloat(newBuyPrice);
|
|
tx.date = newDate;
|
|
writePortfolio(pf);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.post("/api/portfolio/delete", (req, res) => {
|
|
const { id } = req.body;
|
|
const pf = readPortfolio();
|
|
const before = pf.transactions.length;
|
|
pf.transactions = pf.transactions.filter(t => String(t.id) !== String(id));
|
|
if (pf.transactions.length === before) {
|
|
return res.status(400).json({ error: "No transaction with given ID" });
|
|
}
|
|
writePortfolio(pf);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.post("/api/portfolio/sell", (req, res) => {
|
|
const { id, sellAmount, sellPrice, sellDate } = req.body;
|
|
if (!id || isNaN(sellAmount) || isNaN(sellPrice) || !sellDate) {
|
|
return res.status(400).json({ error: "Ungültige Daten" });
|
|
}
|
|
const pf = readPortfolio();
|
|
let txIndex = pf.transactions.findIndex(x => String(x.id) === String(id));
|
|
if (txIndex === -1) {
|
|
return res.status(400).json({ error: "Transaktion nicht gefunden" });
|
|
}
|
|
let tx = pf.transactions[txIndex];
|
|
if (sellAmount > tx.amount) {
|
|
return res.status(400).json({ error: "Verkaufsmenge überschreitet die vorhandene Menge" });
|
|
}
|
|
const profit = (parseFloat(sellPrice) - parseFloat(tx.buyPrice)) * sellAmount;
|
|
const percentProfit = tx.buyPrice > 0 ? (profit / (tx.buyPrice * sellAmount)) * 100 : 0;
|
|
const tradeEntry = {
|
|
id: Date.now(),
|
|
symbol: tx.symbol,
|
|
amount: sellAmount,
|
|
buyPrice: tx.buyPrice,
|
|
sellPrice: parseFloat(sellPrice),
|
|
profit: profit,
|
|
percentProfit: percentProfit,
|
|
buyDate: tx.date,
|
|
sellDate: sellDate
|
|
};
|
|
if (sellAmount < tx.amount) {
|
|
tx.amount = tx.amount - sellAmount;
|
|
} else {
|
|
pf.transactions.splice(txIndex, 1);
|
|
}
|
|
writePortfolio(pf);
|
|
const tsData = readTradeSummary();
|
|
tsData.trades.push(tradeEntry);
|
|
writeTradeSummary(tsData);
|
|
res.json({ success: true, trade: tradeEntry });
|
|
});
|
|
|
|
app.get("/api/trade_summary", (req, res) => {
|
|
const data = readTradeSummary();
|
|
data.trades.sort((a, b) => new Date(b.sellDate) - new Date(a.sellDate));
|
|
res.json(data.trades);
|
|
});
|
|
|
|
app.use(express.static(path.join(__dirname, "..", "public")));
|
|
|
|
wss.on("connection", (ws) => {
|
|
clients.add(ws);
|
|
ws.on("close", () => {
|
|
clients.delete(ws);
|
|
});
|
|
});
|
|
|
|
const PORT = process.env.PORT || 3099;
|
|
server.listen(PORT, () => {
|
|
console.log("Server läuft auf Port " + PORT);
|
|
});
|