Compare commits

..

5 commits
dev ... main

Author SHA1 Message Date
8b7bedbf0b
fix mobile icon
All checks were successful
Code quality checks / biome (push) Successful in 10s
2025-04-19 14:06:37 -04:00
bf66b301ae
add badges and fix clan tags, and readme issue
All checks were successful
Code quality checks / biome (push) Successful in 11s
2025-04-19 13:37:37 -04:00
7816210a2c
add clan badges
Some checks failed
Code quality checks / biome (push) Failing after 8s
2025-04-18 04:31:25 -04:00
b109f67125
fix ip log issue, make changes to embed
Some checks failed
Code quality checks / biome (push) Failing after 11s
2025-04-17 18:57:45 -04:00
23f37beef3
update to allow html readme, fx id page for colors
All checks were successful
Code quality checks / biome (push) Successful in 11s
2025-04-17 18:26:59 -04:00
10 changed files with 229 additions and 35 deletions

View file

@ -5,3 +5,6 @@ PORT=8080
# this is only the default value if non is give in /id # this is only the default value if non is give in /id
LANYARD_USER_ID=id-here LANYARD_USER_ID=id-here
LANYARD_INSTANCE=https://lanyard.rest LANYARD_INSTANCE=https://lanyard.rest
# Required if you want to enable badges
BADGE_API_URL=http://localhost:8081

View file

@ -36,6 +36,7 @@ cp .env.example .env
| `PORT` | Port to run the server on (default: `8080`) | | `PORT` | Port to run the server on (default: `8080`) |
| `LANYARD_USER_ID` | Your Discord user ID | | `LANYARD_USER_ID` | Your Discord user ID |
| `LANYARD_INSTANCE` | Lanyard WebSocket endpoint URL | | `LANYARD_INSTANCE` | Lanyard WebSocket endpoint URL |
| `BADGE_API_URL` | Uses the [badge api](https://git.creations.works/creations/badgeAPI) only required if you want to use badges
#### Optional Lanyard KV Vars (per-user customization) #### Optional Lanyard KV Vars (per-user customization)

View file

@ -9,3 +9,5 @@ export const lanyardConfig: LanyardConfig = {
userId: process.env.LANYARD_USER_ID || "", userId: process.env.LANYARD_USER_ID || "",
instance: process.env.LANYARD_INSTANCE || "https://api.lanyard.rest", instance: process.env.LANYARD_INSTANCE || "https://api.lanyard.rest",
}; };
export const badgeApi: string | null = process.env.BADGE_API_URL || null;

View file

@ -26,7 +26,7 @@ body {
} }
.hidden { .hidden {
display: none; display: none !important;
} }
.activity-header.hidden { .activity-header.hidden {
@ -45,7 +45,10 @@ body {
.avatar-status-wrapper { .avatar-status-wrapper {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1.5rem; gap: 2rem;
width: fit-content;
max-width: 700px;
} }
.avatar-wrapper { .avatar-wrapper {
@ -60,6 +63,28 @@ body {
border-radius: 50%; border-radius: 50%;
} }
.badges {
max-width: 700px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 0.3rem;
flex-wrap: wrap;
margin-top: 0.5rem;
padding: 0.5rem;
background-color: var(--card-bg);
border-radius: 10px;
border: 1px solid var(--border-color);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.badge {
width: 26px;
height: 26px;
border-radius: 50%;
}
.decoration { .decoration {
position: absolute; position: absolute;
top: -18px; top: -18px;
@ -104,11 +129,14 @@ body {
.platform-icon.mobile-only { .platform-icon.mobile-only {
position: absolute; position: absolute;
bottom: 4px; bottom: 0;
right: 4px; right: 4px;
width: 30px; width: 30px;
height: 30px; height: 30px;
pointer-events: none; pointer-events: none;
background-color: var(--background);
padding: 0.3rem 0.1rem;
border-radius: 8px;
} }
.user-info { .user-info {
@ -116,6 +144,52 @@ body {
flex-direction: column; flex-direction: column;
} }
.user-info-inner {
display: flex;
flex-direction: row;
align-items: center;
text-align: center;
gap: .5rem;
}
.user-info-inner h1 {
font-size: 2rem;
margin: 0;
}
.clan-badge {
width: 50px;
height: 20px;
border-radius: 8px;
background-color: var(--card-bg);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 0.3rem;
padding: .4rem 0.5rem;
text-align: center;
align-items: center;
justify-content: center;
}
.clan-badge img {
width: 20px;
height: 20px;
margin: 0;
padding: 0;
}
.clan-badge span {
font-size: .9rem;
color: var(--text-color);
margin: 0;
font-weight: 600;
}
h1 { h1 {
font-size: 2.5rem; font-size: 2.5rem;
margin: 0; margin: 0;
@ -364,6 +438,14 @@ ul {
align-items: center; align-items: center;
} }
.badges {
max-width: 100%;
border-radius: 0;
border: none;
background-color: transparent;
margin-top: 0;
}
.avatar-status-wrapper { .avatar-status-wrapper {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -494,14 +576,16 @@ ul {
/* readme :p */ /* readme :p */
.readme { .readme {
max-width: 700px; max-width: fit-content;
min-width: 700px;
overflow: hidden;
width: 100%; width: 100%;
background: var(--readme-bg); background: var(--readme-bg);
padding: 1.5rem; padding: 1.5rem;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
margin-top: 2rem; margin-top: 1rem;
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overflow: hidden;
@ -579,7 +663,8 @@ ul {
@media (max-width: 600px) { @media (max-width: 600px) {
.readme { .readme {
width: 100%; max-width: 100%;
min-width: 100%;
padding: 1rem; padding: 1rem;
margin-top: 1rem; margin-top: 1rem;

View file

@ -84,6 +84,7 @@ setInterval(updateElapsedAndProgress, 1000);
const head = document.querySelector("head"); const head = document.querySelector("head");
const userId = head?.dataset.userId; const userId = head?.dataset.userId;
let instanceUri = head?.dataset.instanceUri; let instanceUri = head?.dataset.instanceUri;
let badgeURL = head?.dataset.badgeUrl;
if (userId && instanceUri) { if (userId && instanceUri) {
if (!instanceUri.startsWith("http")) { if (!instanceUri.startsWith("http")) {
@ -276,6 +277,79 @@ function buildActivityHTML(activity) {
`; `;
} }
if (badgeURL && badgeURL !== "null" && userId) {
if (!badgeURL.startsWith("http")) {
badgeURL = `https://${badgeURL}`;
}
if (!badgeURL.endsWith("/")) {
badgeURL += "/";
}
async function loadBadges(userId, options = {}) {
const {
services = [],
seperated = false,
cache = true,
targetId = "badges",
} = options;
const params = new URLSearchParams();
if (services.length) params.set("services", services.join(","));
if (seperated) params.set("seperated", "true");
if (!cache) params.set("cache", "false");
const url = `${badgeURL}${userId}?${params.toString()}`;
const target = document.getElementById(targetId);
if (!target) return;
target.classList.add("hidden");
try {
const res = await fetch(url);
const json = await res.json();
if (!res.ok || !json.badges) {
target.textContent = "Failed to load badges.";
return;
}
const badges = Array.isArray(json.badges)
? json.badges
: Object.values(json.badges).flat();
if (badges.length === 0) {
target.innerHTML = "";
target.classList.add("hidden");
return;
}
target.innerHTML = "";
for (const badge of badges) {
const img = document.createElement("img");
img.src = badge.badge;
img.alt = badge.tooltip;
img.title = badge.tooltip;
img.className = "badge";
target.appendChild(img);
}
target.classList.remove("hidden");
} catch (err) {
console.error(err);
target.innerHTML = "";
target.classList.add("hidden");
}
}
loadBadges(userId, {
services: [],
seperated: false,
cache: true,
targetId: "badges",
});
}
function updatePresence(data) { function updatePresence(data) {
const avatarWrapper = document.querySelector(".avatar-wrapper"); const avatarWrapper = document.querySelector(".avatar-wrapper");
const statusIndicator = avatarWrapper?.querySelector(".status-indicator"); const statusIndicator = avatarWrapper?.querySelector(".status-indicator");

View file

@ -49,11 +49,12 @@ export async function getLanyardData(id?: string): Promise<LanyardResponse> {
export async function handleReadMe(data: LanyardData): Promise<string | null> { export async function handleReadMe(data: LanyardData): Promise<string | null> {
const userReadMe: string | null = data.kv?.readme; const userReadMe: string | null = data.kv?.readme;
const validExtension = /\.(md|markdown|txt|html?)$/i;
if ( if (
!userReadMe || !userReadMe ||
!userReadMe.toLowerCase().endsWith("readme.md") || !userReadMe.startsWith("http") ||
!userReadMe.startsWith("http") !validExtension.test(userReadMe)
) { ) {
return null; return null;
} }
@ -65,15 +66,7 @@ export async function handleReadMe(data: LanyardData): Promise<string | null> {
}, },
}); });
const contentType: string = res.headers.get("content-type") || ""; if (!res.ok) return null;
if (
!res.ok ||
!(
contentType.includes("text/markdown") ||
contentType.includes("text/plain")
)
)
return null;
if (res.headers.has("content-length")) { if (res.headers.has("content-length")) {
const size: number = Number.parseInt( const size: number = Number.parseInt(
@ -86,9 +79,17 @@ export async function handleReadMe(data: LanyardData): Promise<string | null> {
const text: string = await res.text(); const text: string = await res.text();
if (!text || text.length < 10) return null; if (!text || text.length < 10) return null;
const html: string | null = await marked.parse(text); let html: string;
const safe: string | null = DOMPurify.sanitize(html); if (
userReadMe.toLowerCase().endsWith(".html") ||
userReadMe.toLowerCase().endsWith(".htm")
) {
html = text;
} else {
html = await marked.parse(text);
}
const safe: string | null = DOMPurify.sanitize(html);
return safe; return safe;
} catch { } catch {
return null; return null;

View file

@ -1,4 +1,5 @@
import { lanyardConfig } from "@config/environment"; import { getImageColors } from "@/helpers/colors";
import { badgeApi, lanyardConfig } from "@config/environment";
import { renderEjsTemplate } from "@helpers/ejs"; import { renderEjsTemplate } from "@helpers/ejs";
import { getLanyardData, handleReadMe } from "@helpers/lanyard"; import { getLanyardData, handleReadMe } from "@helpers/lanyard";
@ -38,6 +39,14 @@ async function handler(request: ExtendedRequest): Promise<Response> {
status = presence.discord_status; status = presence.discord_status;
} }
let colors: ImageColorResult | null = null;
if (presence.kv.colors === "true") {
const avatar: string = presence.discord_user.avatar
? `https://cdn.discordapp.com/avatars/${presence.discord_user.id}/${presence.discord_user.avatar}`
: `https://cdn.discordapp.com/embed/avatars/${presence.discord_user.discriminator || 1 % 5}`;
colors = await getImageColors(avatar, true);
}
const ejsTemplateData: EjsTemplateData = { const ejsTemplateData: EjsTemplateData = {
title: presence.discord_user.global_name || presence.discord_user.username, title: presence.discord_user.global_name || presence.discord_user.username,
username: username:
@ -52,8 +61,10 @@ async function handler(request: ExtendedRequest): Promise<Response> {
}, },
instance, instance,
readme, readme,
allowSnow: presence.kv.snow || false, allowSnow: presence.kv.snow === "true",
allowRain: presence.kv.rain || false, allowRain: presence.kv.rain === "true",
colors: colors?.colors ?? {},
badgeApi: badgeApi,
}; };
return await renderEjsTemplate("index", ejsTemplateData); return await renderEjsTemplate("index", ejsTemplateData);

View file

@ -1,5 +1,5 @@
import { getImageColors } from "@/helpers/colors"; import { getImageColors } from "@/helpers/colors";
import { lanyardConfig } from "@config/environment"; import { badgeApi, lanyardConfig } from "@config/environment";
import { renderEjsTemplate } from "@helpers/ejs"; import { renderEjsTemplate } from "@helpers/ejs";
import { getLanyardData, handleReadMe } from "@helpers/lanyard"; import { getLanyardData, handleReadMe } from "@helpers/lanyard";
@ -63,6 +63,7 @@ async function handler(): Promise<Response> {
allowSnow: presence.kv.snow === "true", allowSnow: presence.kv.snow === "true",
allowRain: presence.kv.rain === "true", allowRain: presence.kv.rain === "true",
colors: colors?.colors ?? {}, colors: colors?.colors ?? {},
badgeApi: badgeApi,
}; };
return await renderEjsTemplate("index", ejsTemplateData); return await renderEjsTemplate("index", ejsTemplateData);

View file

@ -225,15 +225,15 @@ class ServerHandler {
); );
} }
const headers: Headers = response.headers; const headers = request.headers;
let ip: string | null = server.requestIP(request)?.address || null; let ip = server.requestIP(request)?.address;
if (!ip) { if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") {
ip = ip =
headers.get("CF-Connecting-IP") || headers.get("CF-Connecting-IP")?.trim() ||
headers.get("X-Real-IP") || headers.get("X-Real-IP")?.trim() ||
headers.get("X-Forwarded-For") || headers.get("X-Forwarded-For")?.split(",")[0].trim() ||
null; "unknown";
} }
logger.custom( logger.custom(

View file

@ -1,13 +1,18 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head data-user-id="<%= user.id %>" data-instance-uri="<%= instance %>"> <head data-user-id="<%= user.id %>" data-instance-uri="<%= instance %>" data-badge-url="<%= badgeApi %>">
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta property="og:title" content="<%= username %>'s Presence"> <%
const displayName = username.endsWith('s') ? `${username}'` : `${username}'s`;
const profileUrl = `https://discord.com/users/${user.id}`;
%>
<meta property="og:title" content="<%= displayName %> Discord Presence">
<meta property="og:description" content="<%= activities?.[0]?.state || 'See what ' + displayName + ' is doing on Discord.' %>">
<meta property="og:image" content="https://cdn.discordapp.com/avatars/<%= user.id %>/<%= user.avatar %>"> <meta property="og:image" content="https://cdn.discordapp.com/avatars/<%= user.id %>/<%= user.avatar %>">
<meta property="og:description" content="<%= activities[0]?.state || 'Discord Presence' %>"> <meta name="twitter:card" content="summary_large_image">
<title><%= title %></title> <title><%= title %></title>
@ -24,7 +29,7 @@
<meta name="color-scheme" content="dark"> <meta name="color-scheme" content="dark">
</head> </head>
<%- include('partial/style.ejs') %> <%- include("partial/style.ejs") %>
<body> <body>
<div class="user-card"> <div class="user-card">
@ -43,7 +48,15 @@
<% } %> <% } %>
</div> </div>
<div class="user-info"> <div class="user-info">
<h1><%= username %></h1> <div class="user-info-inner">
<h1><%= username %></h1>
<% if (user.clan && user.clan.tag) { %>
<div class="clan-badge">
<img src="https://cdn.discordapp.com/clan-badges/<%= user.clan.identity_guild_id %>/<%= user.clan.badge %>" alt="Clan Badge" class="clan-badge">
<span class="clan-name"><%= user.clan.tag %></span>
</div>
<% } %>
</div>
<% if (activities.length && activities[0].type === 4) { <% if (activities.length && activities[0].type === 4) {
const emoji = activities[0].emoji; const emoji = activities[0].emoji;
const isCustom = emoji?.id; const isCustom = emoji?.id;
@ -66,6 +79,9 @@
</div> </div>
</div> </div>
<% if(badgeApi) { %>
<div id="badges" class="badges"></div>
<% } %>
<% <%
let filtered = activities let filtered = activities
.filter(a => a.type !== 4) .filter(a => a.type !== 4)