1 Commits
Author SHA1 Message Date
remco 435ea78251 chore(deps): update All dependencies
CI / check (pull_request) Failing after 12s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped
2026-08-09 14:00:26 +00:00
30 changed files with 307 additions and 220 deletions

No files matched your search

-27
View File
@@ -1,27 +0,0 @@
import { eq } from "drizzle-orm";
import { WebsiteSetting } from "@/db/schema";
import { db } from "./src/lib/db";
async function main() {
const result = await db
.select()
.from(WebsiteSetting)
.where(eq(WebsiteSetting.key, "habbo_imaging_url"));
console.log("Current:", result);
if (result[0]?.value !== "/imaging") {
await db
.update(WebsiteSetting)
.set({ value: "/imaging" })
.where(eq(WebsiteSetting.key, "habbo_imaging_url"));
console.log("Updated to /imaging");
} else {
console.log("Already correct");
}
process.exit(0);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+1 -1
View File
@@ -83,7 +83,7 @@
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"postcss": "^8.5.26", "postcss": "^8.5.26",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"tsx": "^4.23.12", "tsx": "^4.23.11",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vite": "8.2.1", "vite": "8.2.1",
"vitest": "4.1.10" "vitest": "4.1.10"
+12 -23
View File
@@ -189,17 +189,17 @@ importers:
specifier: ^4.3.3 specifier: ^4.3.3
version: 4.3.3 version: 4.3.3
tsx: tsx:
specifier: ^4.23.12 specifier: ^4.23.11
version: 4.23.12 version: 4.23.11
typescript: typescript:
specifier: ^7.0.2 specifier: ^7.0.2
version: 7.0.2 version: 7.0.2
vite: vite:
specifier: 8.2.1 specifier: 8.2.1
version: 8.2.1(@types/[email protected])([email protected])([email protected])([email protected]2)([email protected]) version: 8.2.1(@types/[email protected])([email protected])([email protected])([email protected]1)([email protected])
vitest: vitest:
specifier: 4.1.10 specifier: 4.1.10
version: 4.1.10(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]2)([email protected])) version: 4.1.10(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]1)([email protected]))
packages: packages:
@@ -2974,11 +2974,6 @@ packages:
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
hasBin: true hasBin: true
[email protected]:
resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==}
engines: {node: '>=18.0.0'}
hasBin: true
[email protected]: [email protected]:
resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
engines: {node: '>=16.20.0'} engines: {node: '>=16.20.0'}
@@ -4253,7 +4248,7 @@ snapshots:
obug: 2.1.4 obug: 2.1.4
std-env: 4.2.0 std-env: 4.2.0
tinyrainbow: 3.1.1 tinyrainbow: 3.1.1
vitest: 4.1.10(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]2)([email protected])) vitest: 4.1.10(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]1)([email protected]))
'@vitest/[email protected]': '@vitest/[email protected]':
dependencies: dependencies:
@@ -4264,13 +4259,13 @@ snapshots:
chai: 6.2.2 chai: 6.2.2
tinyrainbow: 3.1.1 tinyrainbow: 3.1.1
'@vitest/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected]2)([email protected]))': '@vitest/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected]1)([email protected]))':
dependencies: dependencies:
'@vitest/spy': 4.1.10 '@vitest/spy': 4.1.10
estree-walker: 3.0.3 estree-walker: 3.0.3
magic-string: 0.30.21 magic-string: 0.30.21
optionalDependencies: optionalDependencies:
vite: 8.2.1(@types/[email protected])([email protected])([email protected])([email protected]2)([email protected]) vite: 8.2.1(@types/[email protected])([email protected])([email protected])([email protected]1)([email protected])
'@vitest/[email protected]': '@vitest/[email protected]':
dependencies: dependencies:
@@ -5240,12 +5235,6 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
[email protected]:
dependencies:
esbuild: 0.25.12
optionalDependencies:
fsevents: 2.3.3
[email protected]: [email protected]:
optionalDependencies: optionalDependencies:
'@typescript/typescript-aix-ppc64': 7.0.2 '@typescript/typescript-aix-ppc64': 7.0.2
@@ -5304,7 +5293,7 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected](@types/[email protected])([email protected])([email protected])([email protected]2)([email protected]): [email protected](@types/[email protected])([email protected])([email protected])([email protected]1)([email protected]):
dependencies: dependencies:
lightningcss: 1.33.0 lightningcss: 1.33.0
picomatch: 4.0.5 picomatch: 4.0.5
@@ -5316,13 +5305,13 @@ snapshots:
esbuild: 0.25.12 esbuild: 0.25.12
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 2.7.0 jiti: 2.7.0
tsx: 4.23.12 tsx: 4.23.11
yaml: 2.9.0 yaml: 2.9.0
[email protected](@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]2)([email protected])): [email protected](@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]1)([email protected])):
dependencies: dependencies:
'@vitest/expect': 4.1.10 '@vitest/expect': 4.1.10
'@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected])([email protected])([email protected]2)([email protected])) '@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected])([email protected])([email protected]1)([email protected]))
'@vitest/pretty-format': 4.1.10 '@vitest/pretty-format': 4.1.10
'@vitest/runner': 4.1.10 '@vitest/runner': 4.1.10
'@vitest/snapshot': 4.1.10 '@vitest/snapshot': 4.1.10
@@ -5339,7 +5328,7 @@ snapshots:
tinyexec: 1.3.0 tinyexec: 1.3.0
tinyglobby: 0.2.17 tinyglobby: 0.2.17
tinyrainbow: 3.1.1 tinyrainbow: 3.1.1
vite: 8.2.1(@types/[email protected])([email protected])([email protected])([email protected]2)([email protected]) vite: 8.2.1(@types/[email protected])([email protected])([email protected])([email protected]1)([email protected])
why-is-node-running: 2.3.0 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@types/node': 26.2.0 '@types/node': 26.2.0
+22 -15
View File
@@ -8,6 +8,7 @@ import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { db, MessengerFriendships, User } from "@/lib/db"; import { db, MessengerFriendships, User } from "@/lib/db";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { siteSettings } from "@/lib/services/site-settings";
export const metadata = { title: "Friends" }; export const metadata = { title: "Friends" };
@@ -65,20 +66,26 @@ export default async function FriendsPage({
), ),
); );
const friends = friendIds.length const [friends, imagerBase] = await Promise.all([
? await db friendIds.length
.select({ ? db
id: User.id, .select({
username: User.username, id: User.id,
look: User.look, username: User.username,
motto: User.motto, look: User.look,
online: User.online, motto: User.motto,
}) online: User.online,
.from(User) })
.where(inArray(User.id, friendIds)) .from(User)
.orderBy(asc(User.username)) .where(inArray(User.id, friendIds))
.catch(() => []) .orderBy(asc(User.username))
: []; .catch(() => [])
: Promise.resolve([]),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
]);
const errorMessage = const errorMessage =
error === "not_found" error === "not_found"
@@ -118,7 +125,7 @@ export default async function FriendsPage({
) : ( ) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{friends.map((friend) => { {friends.map((friend) => {
const avatar = avatarImageUrl(friend.look, { const avatar = avatarImageUrl(imagerBase ?? "", friend.look, {
size: "s", size: "s",
headOnly: true, headOnly: true,
}); });
+7 -1
View File
@@ -6,6 +6,7 @@ import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
import { db, Guilds, GuildsMembers, User } from "@/lib/db"; import { db, Guilds, GuildsMembers, User } from "@/lib/db";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import { siteSettings } from "@/lib/services/site-settings";
type GuildView = { type GuildView = {
id: number; id: number;
@@ -97,6 +98,11 @@ export default async function GuildPage({
users = []; users = [];
} }
const imagerBase = await siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
);
const usersById = new Map(users.map((u) => [u.id, u])); const usersById = new Map(users.map((u) => [u.id, u]));
const sinceById = new Map(memberRows.map((m) => [m.userId, m.memberSince])); const sinceById = new Map(memberRows.map((m) => [m.userId, m.memberSince]));
@@ -205,7 +211,7 @@ export default async function GuildPage({
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
className="avatar" className="avatar"
src={avatarImageUrl(m.look)} src={avatarImageUrl(imagerBase ?? "", m.look, { size: "l" })}
alt={`${m.username} avatar`} alt={`${m.username} avatar`}
width={50} width={50}
height={75} height={75}
+16 -9
View File
@@ -4,6 +4,7 @@ import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui"; import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
import { db, User, UsersCurrency, UsersSettings } from "@/lib/db"; import { db, User, UsersCurrency, UsersSettings } from "@/lib/db";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { siteSettings } from "@/lib/services/site-settings";
export const metadata = { title: "Leaderboard" }; export const metadata = { title: "Leaderboard" };
@@ -157,14 +158,20 @@ export default async function LeaderboardPage({
: "credits"; : "credits";
const activeTab = TABS.find((tab) => tab.key === active) ?? TABS[0]; const activeTab = TABS.find((tab) => tab.key === active) ?? TABS[0];
const rows = await (active === "credits" const [rows, imagerBase] = await Promise.all([
? loadCreditsRows() active === "credits"
: active === "diamonds" || active === "duckets" ? loadCreditsRows()
? // eslint-disable-next-line security/detect-object-injection -- active validated as "diamonds"|"duckets" in this branch : active === "diamonds" || active === "duckets"
loadCurrencyRows(CURRENCY_TYPE[active]) ? // eslint-disable-next-line security/detect-object-injection -- active validated as "diamonds"|"duckets" in this branch
: loadSettingsRows( loadCurrencyRows(CURRENCY_TYPE[active])
SETTINGS_FIELD[active as keyof typeof SETTINGS_FIELD], : loadSettingsRows(
)); SETTINGS_FIELD[active as keyof typeof SETTINGS_FIELD],
),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
]);
const valueOnly = const valueOnly =
active === "achievement" || active === "achievement" ||
@@ -206,7 +213,7 @@ export default async function LeaderboardPage({
</thead> </thead>
<tbody> <tbody>
{rows.map((row, i) => { {rows.map((row, i) => {
const avatar = avatarImageUrl(row.look, { const avatar = avatarImageUrl(imagerBase ?? "", row.look, {
size: "s", size: "s",
headOnly: true, headOnly: true,
}); });
+9 -3
View File
@@ -18,11 +18,17 @@ import { siteSettings } from "@/lib/services/site-settings";
export default async function LoginPage() { export default async function LoginPage() {
const t = await getTranslations("pages.login"); const t = await getTranslations("pages.login");
const th = await getTranslations("header"); const th = await getTranslations("header");
const [hotelName, cfg, logo] = await Promise.all([ const [hotelName, imagerRaw, cfg, logo] = await Promise.all([
resolveHotelName(), resolveHotelName(),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
captchaConfig(), captchaConfig(),
siteSettings.get("radio_social_discord", ""),
siteSettings.get("cms_logo", ""), siteSettings.get("cms_logo", ""),
]); ]);
const imager = imagerRaw ?? "https://www.habbo.com/habbo-imaging/avatarimage";
const nonce = (await headers()).get("x-nonce") ?? undefined; const nonce = (await headers()).get("x-nonce") ?? undefined;
const [online, recentUsers, latestUsers] = await Promise.all([ const [online, recentUsers, latestUsers] = await Promise.all([
@@ -228,7 +234,7 @@ export default async function LoginPage() {
}} }}
> >
<Image <Image
src={avatarImageUrl(u.look, { src={avatarImageUrl(imager, u.look, {
headOnly: true, headOnly: true,
direction: 2, direction: 2,
size: "s", size: "s",
@@ -299,7 +305,7 @@ export default async function LoginPage() {
}} }}
> >
<Image <Image
src={avatarImageUrl(u.look, { src={avatarImageUrl(imager, u.look, {
headOnly: true, headOnly: true,
direction: 2, direction: 2,
size: "s", size: "s",
+10 -2
View File
@@ -83,6 +83,7 @@ export default async function MePage({
const [ const [
userRows, userRows,
imagerBase,
hotelName, hotelName,
neededRows, neededRows,
amountRows, amountRows,
@@ -110,6 +111,10 @@ export default async function MePage({
.from(User) .from(User)
.where(eq(User.id, userId)) .where(eq(User.id, userId))
.limit(1), .limit(1),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
resolveHotelName(), resolveHotelName(),
db db
.select({ value: WebsiteSetting.value }) .select({ value: WebsiteSetting.value })
@@ -268,7 +273,10 @@ export default async function MePage({
: "Never"; : "Never";
const referralLink = `/register?ref=${encodeURIComponent(user.username)}`; const referralLink = `/register?ref=${encodeURIComponent(user.username)}`;
const avatarFull = avatarImageUrl(user.look, { const imager =
imagerBase ?? "https://www.habbo.com/habbo-imaging/avatarimage";
const avatarFull = avatarImageUrl(imager, user.look, {
size: "l",
direction: 2, direction: 2,
}); });
content = ( content = (
@@ -497,7 +505,7 @@ export default async function MePage({
}} }}
> >
<Image <Image
src={avatarImageUrl(friend.look, { src={avatarImageUrl(imager, friend.look, {
size: "s", size: "s",
headOnly: true, headOnly: true,
})} })}
+10 -3
View File
@@ -19,6 +19,7 @@ import {
} from "@/lib/db"; } from "@/lib/db";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import { siteSettings } from "@/lib/services/site-settings";
type SearchParams = Promise<{ type SearchParams = Promise<{
accepted?: string; accepted?: string;
@@ -64,9 +65,10 @@ export default async function MessagesPage({
sendedOn: number; sendedOn: number;
}[] = []; }[] = [];
let friendships: { userOneId: number; userTwoId: number }[] = []; let friendships: { userOneId: number; userTwoId: number }[] = [];
let imagerBase = "https://www.habbo.com/habbo-imaging/avatarimage";
try { try {
const [reqRows, msgRows, friendRows] = await Promise.all([ const [reqRows, msgRows, friendRows, base] = await Promise.all([
db db
.select({ .select({
id: MessengerFriendrequests.id, id: MessengerFriendrequests.id,
@@ -99,10 +101,15 @@ export default async function MessagesPage({
eq(MessengerFriendships.userTwoId, userId), eq(MessengerFriendships.userTwoId, userId),
), ),
), ),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
]); ]);
requests = reqRows; requests = reqRows;
messages = msgRows; messages = msgRows;
friendships = friendRows; friendships = friendRows;
if (base) imagerBase = base;
} catch { } catch {
requests = []; requests = [];
messages = []; messages = [];
@@ -270,7 +277,7 @@ export default async function MessagesPage({
<div className="grid" style={{ padding: "1rem", gap: "0.75rem" }}> <div className="grid" style={{ padding: "1rem", gap: "0.75rem" }}>
{requests.map((req) => { {requests.map((req) => {
const from = userById.get(req.userFromId); const from = userById.get(req.userFromId);
const avatar = avatarImageUrl(from?.look ?? "", { const avatar = avatarImageUrl(imagerBase, from?.look ?? "", {
size: "s", size: "s",
headOnly: true, headOnly: true,
}); });
@@ -360,7 +367,7 @@ export default async function MessagesPage({
<div className="grid" style={{ padding: "1rem", gap: "0.75rem" }}> <div className="grid" style={{ padding: "1rem", gap: "0.75rem" }}>
{messages.map((msg) => { {messages.map((msg) => {
const from = userById.get(msg.userFromId); const from = userById.get(msg.userFromId);
const avatar = avatarImageUrl(from?.look ?? "", { const avatar = avatarImageUrl(imagerBase, from?.look ?? "", {
size: "s", size: "s",
headOnly: true, headOnly: true,
}); });
+10 -3
View File
@@ -22,10 +22,15 @@ import { captchaConfig } from "@/lib/services/captcha";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
async function getHotelData() { async function getHotelData() {
const [hotelName, logo] = await Promise.all([ const [hotelName, imagerRaw, logo] = await Promise.all([
resolveHotelName(), resolveHotelName(),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
siteSettings.get("cms_logo", ""), siteSettings.get("cms_logo", ""),
]); ]);
const imager = imagerRaw ?? "https://www.habbo.com/habbo-imaging/avatarimage";
const [ const [
online, online,
@@ -83,6 +88,7 @@ async function getHotelData() {
return { return {
hotelName, hotelName,
imager,
online, online,
users, users,
rooms, rooms,
@@ -102,6 +108,7 @@ export default async function Home() {
const th = await getTranslations("header"); const th = await getTranslations("header");
const { const {
hotelName, hotelName,
imager,
online, online,
users, users,
rooms, rooms,
@@ -479,7 +486,7 @@ export default async function Home() {
> >
<div className="relative overflow-visible"> <div className="relative overflow-visible">
<Image <Image
src={avatarImageUrl(u.look, { src={avatarImageUrl(imager, u.look, {
headOnly: true, headOnly: true,
direction: 3, direction: 3,
})} })}
@@ -657,7 +664,7 @@ export default async function Home() {
}} }}
> >
<Image <Image
src={avatarImageUrl(u.look, { src={avatarImageUrl(imager, u.look, {
headOnly: true, headOnly: true,
direction: 2, direction: 2,
size: "s", size: "s",
+9 -2
View File
@@ -4,6 +4,7 @@ import { getTranslations } from "next-intl/server";
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui"; import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { siteSettings } from "@/lib/services/site-settings";
type LeaderRow = { type LeaderRow = {
username: string; username: string;
@@ -35,7 +36,13 @@ async function loadRows(): Promise<
export default async function RadioLeaderboardPage() { export default async function RadioLeaderboardPage() {
const t = await getTranslations("pages.radioLeaderboard"); const t = await getTranslations("pages.radioLeaderboard");
const rows = await loadRows(); const [rows, imagerBase] = await Promise.all([
loadRows(),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
]);
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
@@ -56,7 +63,7 @@ export default async function RadioLeaderboardPage() {
</thead> </thead>
<tbody> <tbody>
{rows.map((row, i) => { {rows.map((row, i) => {
const avatar = avatarImageUrl(row.look, { const avatar = avatarImageUrl(imagerBase ?? "", row.look, {
size: "s", size: "s",
headOnly: true, headOnly: true,
}); });
+7 -2
View File
@@ -7,6 +7,7 @@ import { auth } from "@/lib/auth";
import { db, RadioShouts, User } from "@/lib/db"; import { db, RadioShouts, User } from "@/lib/db";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import { siteSettings } from "@/lib/services/site-settings";
type SearchParams = Promise<{ posted?: string; error?: string }>; type SearchParams = Promise<{ posted?: string; error?: string }>;
@@ -34,13 +35,17 @@ export default async function RadioShoutsPage({
const t = await getTranslations("pages.radioShouts"); const t = await getTranslations("pages.radioShouts");
const { posted, error } = await searchParams; const { posted, error } = await searchParams;
const [shouts, session] = await Promise.all([ const [shouts, imagerBase, session] = await Promise.all([
db db
.select() .select()
.from(RadioShouts) .from(RadioShouts)
.orderBy(desc(RadioShouts.createdAt)) .orderBy(desc(RadioShouts.createdAt))
.limit(50) .limit(50)
.catch(() => []), .catch(() => []),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
auth(), auth(),
]); ]);
@@ -136,7 +141,7 @@ export default async function RadioShoutsPage({
{shouts.map((s) => { {shouts.map((s) => {
const author = authorById.get(Number(s.userId)); const author = authorById.get(Number(s.userId));
const authorAvatar = author const authorAvatar = author
? avatarImageUrl(author.look, { ? avatarImageUrl(imagerBase ?? "", author.look, {
size: "s", size: "s",
headOnly: true, headOnly: true,
}) })
+7 -1
View File
@@ -10,6 +10,7 @@ import {
} from "@/components/public/ui"; } from "@/components/public/ui";
import { db, User } from "@/lib/db"; import { db, User } from "@/lib/db";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { siteSettings } from "@/lib/services/site-settings";
export const metadata = { title: "Rankings" }; export const metadata = { title: "Rankings" };
@@ -40,6 +41,11 @@ export default async function RankingsPage() {
users = []; users = [];
} }
const imagerBase = await siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
);
return ( return (
<main style={{ display: "grid", gap: "1.5rem" }}> <main style={{ display: "grid", gap: "1.5rem" }}>
<ContentCard icon="🏆" title={t("title")} subtitle={t("subtitle")} /> <ContentCard icon="🏆" title={t("title")} subtitle={t("subtitle")} />
@@ -50,7 +56,7 @@ export default async function RankingsPage() {
) : ( ) : (
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}> <div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
{users.map((u, i) => { {users.map((u, i) => {
const avatar = avatarImageUrl(u.look, { const avatar = avatarImageUrl(imagerBase ?? "", u.look, {
size: "m", size: "m",
}); });
return ( return (
+9 -3
View File
@@ -18,11 +18,17 @@ import { siteSettings } from "@/lib/services/site-settings";
export default async function RegisterPage() { export default async function RegisterPage() {
const th = await getTranslations("header"); const th = await getTranslations("header");
const tpr = await getTranslations("pages.register"); const tpr = await getTranslations("pages.register");
const [hotelName, cfg, logo] = await Promise.all([ const [hotelName, imagerRaw, cfg, logo] = await Promise.all([
resolveHotelName(), resolveHotelName(),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
captchaConfig(), captchaConfig(),
siteSettings.get("radio_social_discord", ""),
siteSettings.get("cms_logo", ""), siteSettings.get("cms_logo", ""),
]); ]);
const imager = imagerRaw ?? "https://www.habbo.com/habbo-imaging/avatarimage";
const nonce = (await headers()).get("x-nonce") ?? undefined; const nonce = (await headers()).get("x-nonce") ?? undefined;
const [online, recentUsers, latestUsers] = await Promise.all([ const [online, recentUsers, latestUsers] = await Promise.all([
@@ -225,7 +231,7 @@ export default async function RegisterPage() {
}} }}
> >
<Image <Image
src={avatarImageUrl(u.look, { src={avatarImageUrl(imager, u.look, {
headOnly: true, headOnly: true,
direction: 2, direction: 2,
size: "s", size: "s",
@@ -296,7 +302,7 @@ export default async function RegisterPage() {
}} }}
> >
<Image <Image
src={avatarImageUrl(u.look, { src={avatarImageUrl(imager, u.look, {
headOnly: true, headOnly: true,
direction: 2, direction: 2,
})} })}
+8 -1
View File
@@ -4,6 +4,7 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { db, Rooms, User } from "@/lib/db"; import { db, Rooms, User } from "@/lib/db";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { siteSettings } from "@/lib/services/site-settings";
export const metadata: Metadata = { title: "Search" }; export const metadata: Metadata = { title: "Search" };
@@ -17,6 +18,12 @@ export default async function SearchPage({
const { q } = await searchParams; const { q } = await searchParams;
const query = q?.trim(); const query = q?.trim();
const imager =
(await siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
)) ?? "";
if (!query) { if (!query) {
return ( return (
<div <div
@@ -135,7 +142,7 @@ export default async function SearchPage({
}} }}
> >
<Image <Image
src={avatarImageUrl(u.look, { src={avatarImageUrl(imager, u.look, {
headOnly: true, headOnly: true,
direction: 2, direction: 2,
})} })}
+10 -1
View File
@@ -8,6 +8,7 @@ import { Reveal } from "@/components/motion-reveal";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { db, User } from "@/lib/db"; import { db, User } from "@/lib/db";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { siteSettings } from "@/lib/services/site-settings";
export default async function SettingsPage() { export default async function SettingsPage() {
const t = await getTranslations("pages.settings"); const t = await getTranslations("pages.settings");
@@ -40,7 +41,15 @@ export default async function SettingsPage() {
if (!user) redirect("/login"); if (!user) redirect("/login");
const avatar = avatarImageUrl(user.look); let imagerBase = "https://www.habbo.com/habbo-imaging/avatarimage";
try {
imagerBase =
(await siteSettings.get("habbo_imaging_url", imagerBase)) ?? imagerBase;
} catch {
/* fall back to default */
}
const avatar = avatarImageUrl(imagerBase, user.look, { size: "l" });
return ( return (
<div className="flex flex-col gap-6 pb-14"> <div className="flex flex-col gap-6 pb-14">
+9 -4
View File
@@ -26,6 +26,7 @@ import {
} from "@/lib/db"; } from "@/lib/db";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import { siteSettings } from "@/lib/services/site-settings";
type SearchParams = Promise<{ type SearchParams = Promise<{
friend?: string; friend?: string;
@@ -86,7 +87,7 @@ export default async function ProfilePage({
const { username } = await params; const { username } = await params;
const { friend, guestbook: guestbookStatus, error } = await searchParams; const { friend, guestbook: guestbookStatus, error } = await searchParams;
const [userRows, session] = await Promise.all([ const [userRows, imagerBase, session] = await Promise.all([
db db
.select({ .select({
id: User.id, id: User.id,
@@ -101,13 +102,17 @@ export default async function ProfilePage({
.from(User) .from(User)
.where(eq(User.username, username)) .where(eq(User.username, username))
.limit(1), .limit(1),
siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
),
auth(), auth(),
]); ]);
const user = userRows[0] ?? null; const user = userRows[0] ?? null;
if (!user) notFound(); if (!user) notFound();
const avatar = avatarImageUrl(user.look); const avatar = avatarImageUrl(imagerBase ?? "", user.look, { size: "l" });
const registered = formatDate(new Date(user.accountCreated * 1000), "date"); const registered = formatDate(new Date(user.accountCreated * 1000), "date");
const meId = Number(session?.user?.id); const meId = Number(session?.user?.id);
const isLoggedIn = Number.isInteger(meId) && meId > 0; const isLoggedIn = Number.isInteger(meId) && meId > 0;
@@ -479,7 +484,7 @@ export default async function ProfilePage({
> >
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={avatarImageUrl(f.look, { src={avatarImageUrl(imagerBase ?? "", f.look, {
size: "m", size: "m",
headOnly: true, headOnly: true,
})} })}
@@ -681,7 +686,7 @@ export default async function ProfilePage({
{guestbook.map((g) => { {guestbook.map((g) => {
const author = authorById.get(g.userId); const author = authorById.get(g.userId);
const authorAvatar = author const authorAvatar = author
? avatarImageUrl(author.look, { ? avatarImageUrl(imagerBase ?? "", author.look, {
size: "s", size: "s",
headOnly: true, headOnly: true,
}) })
@@ -75,6 +75,7 @@ function AvatarPreview({ type, id }: { type?: string; id?: number }) {
return ( return (
<img <img
src={getAvatarUrl(figure, { src={getAvatarUrl(figure, {
size: "l",
direction: 2, direction: 2,
headDirection: 3, headDirection: 3,
headOnly: HEAD_TYPES.has(type), headOnly: HEAD_TYPES.has(type),
+1 -1
View File
@@ -132,7 +132,7 @@ export default async function ModTeamPage() {
<div className="relative"> <div className="relative">
<AvatarImage <AvatarImage
figure={member.look} figure={member.look}
options={{ headOnly: true }} options={{ headOnly: true, size: "l" }}
alt={member.username} alt={member.username}
width={32} width={32}
height={32} height={32}
+3 -5
View File
@@ -1,7 +1,7 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { resolveImagerBase } from "@/lib/imager"; import { resolveUpstreamBase } from "@/lib/imager";
const FIGURE_RE = /^([a-z]{2}-\d+)(\.[a-z]{2}-\d+)*$/i; const FIGURE_RE = /^[a-z]{2}-\d+/i;
const FIGURE_MAX_LEN = 512; const FIGURE_MAX_LEN = 512;
const FIGURE_MAX_PARTS = 24; const FIGURE_MAX_PARTS = 24;
const UPSTREAM_TIMEOUT_MS = 10_000; const UPSTREAM_TIMEOUT_MS = 10_000;
@@ -49,7 +49,6 @@ export async function GET(request: NextRequest) {
searchParams.get("headOnly") === "1"; searchParams.get("headOnly") === "1";
const gesture = searchParams.get("gesture") ?? undefined; const gesture = searchParams.get("gesture") ?? undefined;
const action = searchParams.get("action") ?? undefined; const action = searchParams.get("action") ?? undefined;
const effect = searchParams.get("effect") ?? undefined;
const params = new URLSearchParams({ const params = new URLSearchParams({
figure, figure,
@@ -61,12 +60,11 @@ export async function GET(request: NextRequest) {
if (headOnly) params.set("headonly", "1"); if (headOnly) params.set("headonly", "1");
if (gesture) params.set("gesture", gesture); if (gesture) params.set("gesture", gesture);
if (action) params.set("action", action); if (action) params.set("action", action);
if (effect) params.set("effect", effect);
const imgFormat = searchParams.get("img_format"); const imgFormat = searchParams.get("img_format");
if (imgFormat) params.set("img_format", imgFormat); if (imgFormat) params.set("img_format", imgFormat);
const upstream = resolveImagerBase(); const upstream = resolveUpstreamBase();
const upstreamUrl = `${upstream}?${params.toString()}`; const upstreamUrl = `${upstream}?${params.toString()}`;
try { try {
+3 -5
View File
@@ -1,7 +1,7 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { resolveImagerBase } from "@/lib/imager"; import { resolveUpstreamBase } from "@/lib/imager";
const FIGURE_RE = /^([a-z]{2}-\d+)(\.[a-z]{2}-\d+)*$/i; const FIGURE_RE = /^[a-z]{2}-\d+/i;
const FIGURE_MAX_LEN = 512; const FIGURE_MAX_LEN = 512;
const FIGURE_MAX_PARTS = 24; const FIGURE_MAX_PARTS = 24;
const UPSTREAM_TIMEOUT_MS = 10_000; const UPSTREAM_TIMEOUT_MS = 10_000;
@@ -49,7 +49,6 @@ export async function GET(request: NextRequest) {
searchParams.get("headOnly") === "1"; searchParams.get("headOnly") === "1";
const gesture = searchParams.get("gesture") ?? undefined; const gesture = searchParams.get("gesture") ?? undefined;
const action = searchParams.get("action") ?? undefined; const action = searchParams.get("action") ?? undefined;
const effect = searchParams.get("effect") ?? undefined;
const params = new URLSearchParams({ const params = new URLSearchParams({
figure, figure,
@@ -61,12 +60,11 @@ export async function GET(request: NextRequest) {
if (headOnly) params.set("headonly", "1"); if (headOnly) params.set("headonly", "1");
if (gesture) params.set("gesture", gesture); if (gesture) params.set("gesture", gesture);
if (action) params.set("action", action); if (action) params.set("action", action);
if (effect) params.set("effect", effect);
const imgFormat = searchParams.get("img_format"); const imgFormat = searchParams.get("img_format");
if (imgFormat) params.set("img_format", imgFormat); if (imgFormat) params.set("img_format", imgFormat);
const upstream = resolveImagerBase(); const upstream = resolveUpstreamBase();
const upstreamUrl = `${upstream}?${params.toString()}`; const upstreamUrl = `${upstream}?${params.toString()}`;
try { try {
+1 -1
View File
@@ -120,7 +120,7 @@ export default async function ModTeamLitePage() {
<div className="relative"> <div className="relative">
<AvatarImage <AvatarImage
figure={member.look} figure={member.look}
options={{ headOnly: true }} options={{ headOnly: true, size: "l" }}
alt={member.username} alt={member.username}
width={32} width={32}
height={32} height={32}
+1
View File
@@ -43,6 +43,7 @@ export default async function ModUserDetailPage({
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<img <img
src={getAvatarUrl(user.look, { src={getAvatarUrl(user.look, {
size: "l",
direction: 2, direction: 2,
headDirection: 2, headDirection: 2,
})} })}
+1 -2
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { avatarImageUrl } from "@/lib/format";
export interface OnlineUser { export interface OnlineUser {
id: number; id: number;
@@ -56,7 +55,7 @@ export function OnlineUsersWidget({
> >
<div className="relative flex-none w-[50px] h-[62px]"> <div className="relative flex-none w-[50px] h-[62px]">
<Image <Image
src={avatarImageUrl(user.look, { size: "s", headOnly: true })} src={`https://www.habbo.com/habbo-imaging/avatarimage?width=50&height=62&direction=2&headonly=1&crop=face&action=wav&gender=m&look=${encodeURIComponent(user.look)}`}
alt={user.username} alt={user.username}
fill fill
className="rounded object-cover border border-[var(--admin-border)]" className="rounded object-cover border border-[var(--admin-border)]"
+8 -2
View File
@@ -16,6 +16,7 @@ import {
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl } from "@/lib/format";
import { resolveHotelName } from "@/lib/hotel-name"; import { resolveHotelName } from "@/lib/hotel-name";
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
function Currency({ function Currency({
icon, icon,
@@ -77,7 +78,12 @@ export async function TopHeader({ session }: { session: Session | null }) {
} }
} catch {} } catch {}
const avatar = avatarImageUrl(look, { headOnly: true, direction: 2 }); const imager =
(await siteSettings.get(
"habbo_imaging_url",
"https://www.habbo.com/habbo-imaging/avatarimage",
)) ?? "";
const avatar = avatarImageUrl(imager, look, { headOnly: true, direction: 2 });
let showAdmin = false; let showAdmin = false;
let showMod = false; let showMod = false;
const ctx = await getApiAdminContext(); const ctx = await getApiAdminContext();
@@ -388,7 +394,7 @@ export async function TopHeader({ session }: { session: Session | null }) {
className="flex items-center gap-2 rounded-lg px-2 py-1 text-xs transition-colors hover:bg-white/10" className="flex items-center gap-2 rounded-lg px-2 py-1 text-xs transition-colors hover:bg-white/10"
> >
<Image <Image
src={avatarImageUrl(u.look, { src={avatarImageUrl(imager, u.look, {
headOnly: true, headOnly: true,
direction: 2, direction: 2,
size: "s", size: "s",
+7 -29
View File
@@ -1,15 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { describe, expect, it } from "vitest";
const envMock = vi.hoisted(() => ({
env: {
NEXT_PUBLIC_IMAGER_URL: "https://img.example.com" as string | undefined,
NEXT_PUBLIC_APP_URL: undefined as string | undefined,
IMAGING_UPSTREAM_URL: "https://up.example.com" as string | undefined,
},
}));
vi.mock("@/env", () => envMock);
import { avatarImageUrl, excerpt, slugify } from "./format"; import { avatarImageUrl, excerpt, slugify } from "./format";
describe("slugify", () => { describe("slugify", () => {
@@ -25,31 +14,20 @@ describe("slugify", () => {
}); });
describe("avatarImageUrl", () => { describe("avatarImageUrl", () => {
beforeEach(() => { const base = "https://www.habbo.com/habbo-imaging/avatarimage";
envMock.env.NEXT_PUBLIC_IMAGER_URL = "https://img.example.com";
});
it("appends the figure and options", () => { it("appends the figure and options", () => {
const url = avatarImageUrl("hr-100", { size: "l", headOnly: true }); const url = avatarImageUrl(base, "hr-100", { size: "l", headOnly: true });
expect(url).toContain("figure=hr-100"); expect(url).toContain("figure=hr-100");
expect(url).toContain("size=l"); expect(url).toContain("size=l");
expect(url).toContain("headonly=1"); expect(url).toContain("headonly=1");
expect(url.startsWith("https://img.example.com?")).toBe(true); expect(url.startsWith(`${base}?`)).toBe(true);
});
it("preserves the exact user look instead of replacing it with a default figure", () => {
const look =
"hr-11782-40-40.hd-180-7-14.ch-11592-66.lg-10726-79-1408.sh-11764-1408.ha-11958-70-1408.wa-2007-0";
const url = new URL(avatarImageUrl(look));
expect(url.searchParams.get("figure")).toBe(look);
}); });
it("uses & when the base already has a query string", () => { it("uses & when the base already has a query string", () => {
// This test is no longer applicable since avatarImageUrl doesn't accept a base parameter expect(
// The base URL is now resolved from env avatarImageUrl(`${base}?x=1`, "hr-100").includes("?x=1&figure=hr-100"),
expect(true).toBe(true); ).toBe(true);
}); });
}); });
+21 -4
View File
@@ -1,9 +1,26 @@
import { getAvatarUrl } from "@/lib/imager"; import { resolveImagerBase } from "@/lib/imager";
import type { AvatarOptions } from "@/types/admin";
/** Build a Habbo avatar-imager URL for a figure string. */ /** Build a Habbo avatar-imager URL for a figure string. */
export function avatarImageUrl(look: string, opts: AvatarOptions = {}): string { export function avatarImageUrl(
return getAvatarUrl(look, opts); base: string,
look: string,
opts: {
size?: "s" | "m" | "l";
headOnly?: boolean;
direction?: number;
headDirection?: number;
} = {},
): string {
const resolved = resolveImagerBase(base);
const params = new URLSearchParams({ figure: look, img_format: "png" });
if (opts.size) params.set("size", opts.size);
if (opts.headOnly) params.set("headonly", "1");
if (opts.direction !== undefined)
params.set("direction", String(opts.direction));
if (opts.headDirection !== undefined)
params.set("head_direction", String(opts.headDirection));
const sep = resolved.includes("?") ? "&" : "?";
return `${resolved}${sep}${params.toString()}`;
} }
/** URL-safe slug from a title (lowercase, ascii, hyphenated). */ /** URL-safe slug from a title (lowercase, ascii, hyphenated). */
+61 -39
View File
@@ -2,34 +2,69 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const envMock = vi.hoisted(() => ({ const envMock = vi.hoisted(() => ({
env: { env: {
NEXT_PUBLIC_IMAGER_URL: "https://img.example.com" as string | undefined, NEXT_PUBLIC_IMAGER_URL: undefined as string | undefined,
NEXT_PUBLIC_APP_URL: undefined as string | undefined, NEXT_PUBLIC_APP_URL: undefined as string | undefined,
IMAGING_UPSTREAM_URL: "https://up.example.com" as string | undefined, IMAGING_UPSTREAM_URL: undefined as string | undefined,
}, },
})); }));
vi.mock("@/env", () => envMock); vi.mock("@/env", () => envMock);
import { getAvatarUrl, resolveImagerBase } from "./imager"; import { getAvatarUrl, resolveImagerBase, resolveUpstreamBase } from "./imager";
describe("resolveImagerBase", () => { describe("resolveImagerBase", () => {
beforeEach(() => { beforeEach(() => {
envMock.env.NEXT_PUBLIC_IMAGER_URL = "https://img.example.com"; envMock.env.NEXT_PUBLIC_IMAGER_URL = undefined;
envMock.env.NEXT_PUBLIC_APP_URL = undefined;
}); });
it("returns configured imager env var", () => { it("prefers a configured imager env var", () => {
expect(resolveImagerBase()).toBe("https://img.example.com");
});
it("strips trailing slashes", () => {
envMock.env.NEXT_PUBLIC_IMAGER_URL = "https://img.example.com/"; envMock.env.NEXT_PUBLIC_IMAGER_URL = "https://img.example.com/";
expect(resolveImagerBase()).toBe("https://img.example.com"); expect(resolveImagerBase()).toBe("https://img.example.com");
}); });
it("falls back to epicnabbo.nl when env var is not set", () => { it("ignores the broken self-hosted proxy env route", () => {
envMock.env.NEXT_PUBLIC_IMAGER_URL = undefined; envMock.env.NEXT_PUBLIC_IMAGER_URL =
expect(resolveImagerBase()).toBe( "https://img.example.com/api/imaging/avatar";
"https://epicnabbo.nl/imaging/avatarimage", envMock.env.NEXT_PUBLIC_APP_URL = "https://app.example.com/";
expect(resolveImagerBase()).toBe("https://app.example.com/imaging");
});
it("falls back to app URL imaging route", () => {
envMock.env.NEXT_PUBLIC_APP_URL = "https://app.example.com/";
expect(resolveImagerBase()).toBe("https://app.example.com/imaging");
});
it("falls back to relative /imaging", () => {
expect(resolveImagerBase()).toBe("/imaging");
});
it("uses configured setting when valid", () => {
expect(resolveImagerBase("https://cdn.example.com/imager/")).toBe(
"https://cdn.example.com/imager",
);
});
it("rejects configured setting that is the broken proxy route", () => {
expect(
resolveImagerBase("https://cdn.example.com/api/imaging/avatar"),
).toBe("/imaging");
});
});
describe("resolveUpstreamBase", () => {
beforeEach(() => {
envMock.env.IMAGING_UPSTREAM_URL = undefined;
});
it("uses env upstream when configured", () => {
envMock.env.IMAGING_UPSTREAM_URL = "https://up.example.com/";
expect(resolveUpstreamBase()).toBe("https://up.example.com");
});
it("falls back to Habbo public imager", () => {
expect(resolveUpstreamBase()).toBe(
"https://www.habbo.com/habbo-imaging/avatarimage",
); );
}); });
}); });
@@ -37,17 +72,17 @@ describe("resolveImagerBase", () => {
describe("getAvatarUrl", () => { describe("getAvatarUrl", () => {
beforeEach(() => { beforeEach(() => {
envMock.env.NEXT_PUBLIC_IMAGER_URL = "https://img.example.com"; envMock.env.NEXT_PUBLIC_IMAGER_URL = "https://img.example.com";
envMock.env.NEXT_PUBLIC_APP_URL = undefined;
}); });
it("builds a query string with defaults (omits default params)", () => { it("builds a query string with defaults", () => {
const url = getAvatarUrl("hd-180-1"); const url = getAvatarUrl("hd-180-1");
expect(url.startsWith("https://img.example.com?")).toBe(true); expect(url.startsWith("https://img.example.com?")).toBe(true);
expect(url).toContain("figure=hd-180-1"); expect(url).toContain("figure=hd-180-1");
expect(url).not.toContain("direction=2"); expect(url).toContain("direction=2");
expect(url).not.toContain("head_direction=3"); expect(url).toContain("head_direction=3");
expect(url).not.toContain("size=m"); expect(url).toContain("size=m");
expect(url).toContain("img_format=apng"); expect(url).toContain("img_format=png");
expect(url).toContain("effect=14");
}); });
it("honors custom options", () => { it("honors custom options", () => {
@@ -67,28 +102,15 @@ describe("getAvatarUrl", () => {
expect(url).toContain("action=sit"); expect(url).toContain("action=sit");
}); });
it("uses app URL imaging route when imager env absent", () => {
envMock.env.NEXT_PUBLIC_IMAGER_URL = undefined;
envMock.env.NEXT_PUBLIC_APP_URL = "https://app.example.com/";
const url = getAvatarUrl("hd-180-1");
expect(url.startsWith("https://app.example.com/imaging?")).toBe(true);
});
it("escapes figure values in the query string", () => { it("escapes figure values in the query string", () => {
const url = getAvatarUrl("hd-180-1&x=1"); const url = getAvatarUrl("hd-180-1&x=1");
expect(url).not.toContain("&x=1"); expect(url).not.toContain("&x=1");
}); });
it("allows custom img_format", () => {
const url = getAvatarUrl("hd-180-1", { img_format: "png" });
expect(url).toContain("img_format=png");
});
it("allows custom effect", () => {
const url = getAvatarUrl("hd-180-1", { effect: 0 });
expect(url).toContain("effect=0");
});
it("uses epicnabbo.nl when no env var is set", () => {
envMock.env.NEXT_PUBLIC_IMAGER_URL = undefined;
const url = getAvatarUrl("hd-180-1");
expect(url.startsWith("https://epicnabbo.nl/imaging/avatarimage?")).toBe(
true,
);
expect(url).toContain("img_format=apng");
expect(url).toContain("effect=14");
});
}); });
+43 -27
View File
@@ -1,8 +1,9 @@
/** /**
* Avatar imager helpers. * Avatar imager helpers.
* *
* The public-facing imager URL is configured via NEXT_PUBLIC_IMAGER_URL env var. * The public-facing imager URL resolves to the site's own `/imaging` endpoint
* Defaults to epicnabbo.nl imager with effect=14 and img_format=apng. * by default. That endpoint proxies the image from the configured upstream
* (defaults to Habbo's public imager).
*/ */
export type { AvatarOptions } from "@/types/admin"; export type { AvatarOptions } from "@/types/admin";
@@ -10,25 +11,45 @@ export type { AvatarOptions } from "@/types/admin";
import { env } from "@/env"; import { env } from "@/env";
import type { AvatarOptions } from "@/types/admin"; import type { AvatarOptions } from "@/types/admin";
const DEFAULT_IMAGER_URL = "https://epicnabbo.nl/imaging/avatarimage"; /** Build an absolute default imager URL from public env vars. */
function defaultImagerUrl(): string {
/** const pub = env.NEXT_PUBLIC_IMAGER_URL?.trim();
* Resolve the public-facing imager base URL from env. if (pub && !pub.includes("/api/imaging/avatar"))
* Falls back to epicnabbo.nl if not configured. return pub.replace(/\/+$/, "");
*/ const app = env.NEXT_PUBLIC_APP_URL?.trim();
export function resolveImagerBase(): string { if (app) return `${app.replace(/\/+$/, "")}/imaging`;
const fromEnv = env.NEXT_PUBLIC_IMAGER_URL?.trim(); return "/imaging";
if (!fromEnv) {
return DEFAULT_IMAGER_URL;
}
return fromEnv.replace(/\/+$/, "");
} }
/** /**
* Build an avatar image URL using the configured imager. * Resolve the public-facing imager base URL.
* Uses effect=14 and img_format=apng by default for epicnabbo.nl compatibility. * Rejects the broken self-hosted proxy route if stored in settings.
* Preserves the user's stored figure so the rendered avatar matches the account. */
* Omits default parameters (direction=2, head_direction=3, size=m) for cleaner URLs. export function resolveImagerBase(configured?: string | null): string {
const fromEnv = env.NEXT_PUBLIC_IMAGER_URL?.trim();
if (fromEnv && !fromEnv.includes("/api/imaging/avatar")) {
return fromEnv.replace(/\/+$/, "");
}
const v = (configured ?? "").trim();
if (!v || v.includes("/api/imaging/avatar")) {
return defaultImagerUrl();
}
return v.replace(/\/+$/, "");
}
/**
* Resolve the upstream URL used by the proxy endpoint.
* Falls back to Habbo's public imager when nothing else is configured.
*/
export function resolveUpstreamBase(): string {
const fromEnv = env.IMAGING_UPSTREAM_URL?.trim();
if (fromEnv) return fromEnv.replace(/\/+$/, "");
return "https://www.habbo.com/habbo-imaging/avatarimage";
}
/**
* Build an avatar image URL (site's own `/imaging` endpoint by default).
*/ */
export function getAvatarUrl( export function getAvatarUrl(
figure: string, figure: string,
@@ -41,21 +62,16 @@ export function getAvatarUrl(
headOnly = false, headOnly = false,
gesture, gesture,
action, action,
effect = 14,
img_format = "apng",
} = options; } = options;
const params = new URLSearchParams({ const params = new URLSearchParams({
figure, figure,
img_format, direction: String(direction),
effect: String(effect), head_direction: String(headDirection),
size,
img_format: "png",
}); });
// Only add non-default params
if (direction !== 2) params.set("direction", String(direction));
if (headDirection !== 3) params.set("head_direction", String(headDirection));
if (size !== "m") params.set("size", size);
if (headOnly) params.set("headonly", "1"); if (headOnly) params.set("headonly", "1");
if (gesture) params.set("gesture", gesture); if (gesture) params.set("gesture", gesture);
if (action) params.set("action", action); if (action) params.set("action", action);
-4
View File
@@ -40,8 +40,4 @@ export interface AvatarOptions {
gesture?: string; gesture?: string;
/** Action: wav, sit, lay, etc. */ /** Action: wav, sit, lay, etc. */
action?: string; action?: string;
/** Effect ID (e.g., 14 for ghost) */
effect?: string | number;
/** Image format (e.g., "png", "apng") */
img_format?: string;
} }