client js bundle fixes
Some checks are pending
ci / build (push) Waiting to run
ci / test (push) Waiting to run

This commit is contained in:
CJ_Clippy 2025-09-23 17:04:31 -08:00
parent f1c593ff17
commit 1f4d4938c9
17 changed files with 44 additions and 7894 deletions

View File

@ -1,12 +1,12 @@
{ {
"name": "futureporn-our", "name": "futureporn-our",
"version": "2.6.0", "version": "2.7.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "futureporn-our", "name": "futureporn-our",
"version": "2.6.0", "version": "2.7.1",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.726.1", "@aws-sdk/client-s3": "3.726.1",
"@aws-sdk/s3-request-presigner": "^3.844.0", "@aws-sdk/s3-request-presigner": "^3.844.0",
@ -37,6 +37,7 @@
"concurrently": "^9.2.0", "concurrently": "^9.2.0",
"create-torrent": "^6.1.0", "create-torrent": "^6.1.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"env-paths": "^3.0.0",
"fastify": "^5.4.0", "fastify": "^5.4.0",
"fastify-favicon": "^5.0.0", "fastify-favicon": "^5.0.0",
"fastify-plugin": "^5.0.1", "fastify-plugin": "^5.0.1",
@ -6729,6 +6730,18 @@
"once": "^1.4.0" "once": "^1.4.0"
} }
}, },
"node_modules/env-paths": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/error-ex": { "node_modules/error-ex": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",

View File

@ -1,10 +1,10 @@
{ {
"name": "futureporn-our", "name": "futureporn-our",
"private": true, "private": true,
"version": "2.7.1", "version": "2.8.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently npm:dev:serve npm:dev:build:server npm:dev:build:client npm:dev:worker npm:dev:compose npm:dev:sftp npm:dev:qbittorrent", "dev": "concurrently npm:dev:serve npm:dev:build:server npm:dev:build:client npm:dev:worker npm:dev:compose npm:dev:sftp npm:dev:qbittorrent npm:dev:tailscale",
"dev:serve": "npx @dotenvx/dotenvx run -f ../../.env.development.local -- tsx watch ./src/index.ts", "dev:serve": "npx @dotenvx/dotenvx run -f ../../.env.development.local -- tsx watch ./src/index.ts",
"dev:compose": "docker compose -f compose.development.yaml up", "dev:compose": "docker compose -f compose.development.yaml up",
"dev:worker": "npx @dotenvx/dotenvx run -e GRAPHILE_LOGGER_DEBUG=1 -f ../../.env.development.local -- tsx watch ./src/worker.ts", "dev:worker": "npx @dotenvx/dotenvx run -e GRAPHILE_LOGGER_DEBUG=1 -f ../../.env.development.local -- tsx watch ./src/worker.ts",
@ -13,13 +13,14 @@
"dev:build:client": "chokidar 'src/client/**/*.{js,css}' -c 'node build.mjs'", "dev:build:client": "chokidar 'src/client/**/*.{js,css}' -c 'node build.mjs'",
"dev:qbittorrent": "npx @dotenvx/dotenvx run -f ../../.env.development.local -- sh -c 'docker run --rm --name fp-dev-qbittorrent -e QBT_LEGAL_NOTICE -e QBT_TORRENTING_PORT -e QBT_WEBUI_PORT -e QBT_DISABLE_NETWORK -e QBT_USERNAME -e QBT_PASSWORD -v \"$CACHE_ROOT\":\"$CACHE_ROOT\" -p 8083:8083 gitea.futureporn.net/futureporn/qbittorrent-nox:latest'", "dev:qbittorrent": "npx @dotenvx/dotenvx run -f ../../.env.development.local -- sh -c 'docker run --rm --name fp-dev-qbittorrent -e QBT_LEGAL_NOTICE -e QBT_TORRENTING_PORT -e QBT_WEBUI_PORT -e QBT_DISABLE_NETWORK -e QBT_USERNAME -e QBT_PASSWORD -v \"$CACHE_ROOT\":\"$CACHE_ROOT\" -p 8083:8083 gitea.futureporn.net/futureporn/qbittorrent-nox:latest'",
"dev:sftp": "docker run -p 2222:22 --rm atmoz/sftp user:pass:::watch", "dev:sftp": "docker run -p 2222:22 --rm atmoz/sftp user:pass:::watch",
"dev:tailscale": "tailscale funnel --bg 5000",
"start": "echo please use either start:server or start:worker; exit 1", "start": "echo please use either start:server or start:worker; exit 1",
"start:server": "tsx ./src/index.ts", "start:server": "tsx ./src/index.ts",
"start:worker": "tsx ./src/worker.ts", "start:worker": "tsx ./src/worker.ts",
"preview": "vite preview", "preview": "vite preview",
"build": "tsup --clean", "build": "tsup --clean",
"lint": "eslint .", "lint": "eslint .",
"clean": "rm -rf node_modules && rm -rf pnpm-lock.yaml", "clean": "rimraf ./dist",
"deploy": "npx prisma migrate deploy", "deploy": "npx prisma migrate deploy",
"test:watch": "npx vitest --watch", "test:watch": "npx vitest --watch",
"test": "npx vitest" "test": "npx vitest"
@ -80,6 +81,7 @@
"concurrently": "^9.2.0", "concurrently": "^9.2.0",
"create-torrent": "^6.1.0", "create-torrent": "^6.1.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"env-paths": "^3.0.0",
"fastify": "^5.4.0", "fastify": "^5.4.0",
"fastify-favicon": "^5.0.0", "fastify-favicon": "^5.0.0",
"fastify-plugin": "^5.0.1", "fastify-plugin": "^5.0.1",

File diff suppressed because it is too large Load Diff

View File

@ -25,6 +25,7 @@ import fastifyFlash from '@fastify/flash'
import { registerHbsHelpers } from './utils/hbsHelpers.ts' import { registerHbsHelpers } from './utils/hbsHelpers.ts'
import fastifyFavicon from 'fastify-favicon' import fastifyFavicon from 'fastify-favicon'
const __dirname = import.meta.dirname; const __dirname = import.meta.dirname;
export async function buildApp() { export async function buildApp() {
@ -61,7 +62,7 @@ export async function buildApp() {
app.register(fastifyFavicon, { path: path.join(__dirname, '..', 'dist', 'client'), name: 'favicon.ico', maxAge: 3600 }) app.register(fastifyFavicon, { path: path.join(__dirname, '..', 'src', 'assets'), name: 'favicon.ico', maxAge: 3600 })
app.register(fastifyStatic, { app.register(fastifyStatic, {
root: path.join(__dirname, '..', 'dist', 'client'), root: path.join(__dirname, '..', 'dist', 'client'),
prefix: '/assets', // optional: default '/' prefix: '/assets', // optional: default '/'

View File

@ -1,33 +1,5 @@
export const constants = { export const constants = {
twitch: {
maxGeneralApiRequestsPerMinute: 800,
maxChannelPointRewards: 50,
dev: {
paths: {
auth: '/auth/twitchmock',
users: '/mock/users',
channelPointRewards: '/mock/channel_points/custom_rewards'
}
},
staging: {
paths: {
auth: '/auth/twitch',
users: '/helix/users',
channelPointRewards: '/helix/channel_points/custom_rewards'
}
},
prod: {
paths: {
auth: '/auth/twitch',
users: '/helix/users',
channelPointRewards: '/helix/channel_points/custom_rewards'
}
},
authScopes: [
"channel:manage:redemptions", // manage custom channel point redeems
],
},
patreon: { patreon: {
authScopes: [ authScopes: [
'identity' 'identity'

View File

@ -1,10 +1,11 @@
// ./config/env.ts // ./config/env.ts
import { z } from 'zod'; import { z } from 'zod';
// import dotenvx from '@dotenvx/dotenvx' import { homedir } from 'node:os';
import { join } from 'node:path';
const defaultCachePath = join(homedir(), '.cache', 'futureporn');
// dotenvx.config({ path: ['../../.env.development'] })
// if (process.env.NODE_ENV === 'development') {
// }
const EnvSchema = z.object({ const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']), NODE_ENV: z.enum(['development', 'production', 'test']),
@ -26,7 +27,7 @@ const EnvSchema = z.object({
S3_FORCE_PATH_STYLE: z.coerce.boolean().default(false), S3_FORCE_PATH_STYLE: z.coerce.boolean().default(false),
CDN_ORIGIN: z.string(), CDN_ORIGIN: z.string(),
CDN_TOKEN_SECRET: z.string(), CDN_TOKEN_SECRET: z.string(),
CACHE_ROOT: z.string().default('/tmp/our'), CACHE_ROOT: z.string().default(defaultCachePath),
VIBEUI_DIR: z.string().default('/opt/futureporn/apps/vibeui'), VIBEUI_DIR: z.string().default('/opt/futureporn/apps/vibeui'),
APP_DIR: z.string().default('/app'), APP_DIR: z.string().default('/app'),
WHISPER_DIR: z.string(), WHISPER_DIR: z.string(),

View File

@ -1,175 +0,0 @@
// 24358114\
import { PrismaClient, type User } from '../../generated/prisma'
import { withAccelerate } from "@prisma/extension-accelerate"
import { env } from '../config/env'
import { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify'
import crypto from 'crypto'
import { type IncomingHttpHeaders } from 'http';
import logger from './utils/logger.ts'
export interface ChannelPointRedemptionEvent {
id: string;
broadcaster_user_id: string;
broadcaster_user_login: string;
broadcaster_user_name: string;
user_id: string;
user_login: string;
user_name: string;
user_input: string;
status: 'unfulfilled' | 'fulfilled' | 'canceled';
reward: {
id: string;
title: string;
cost: number;
prompt: string;
};
redeemed_at: string; // ISO 8601 timestamp
}
const prisma = new PrismaClient().$extends(withAccelerate())
// Notification request headers
const TWITCH_MESSAGE_ID = 'twitch-eventsub-message-id';
const TWITCH_MESSAGE_TIMESTAMP = 'twitch-eventsub-message-timestamp';
const TWITCH_MESSAGE_SIGNATURE = 'twitch-eventsub-message-signature';
const MESSAGE_TYPE = 'twitch-eventsub-message-type';
// Notification message types
const MESSAGE_TYPE_VERIFICATION = 'webhook_callback_verification';
const MESSAGE_TYPE_NOTIFICATION = 'notification';
const MESSAGE_TYPE_REVOCATION = 'revocation';
const HMAC_PREFIX = 'sha256=';
// HMAC functions
function getSecret() {
// Get this from your secure storage
return env.TWITCH_EVENTSUB_SECRET;
}
function getHmac(secret: string, message: string) {
return crypto.createHmac('sha256', secret).update(message).digest('hex');
}
function verifyMessage(hmac: string, signature: string) {
return crypto.timingSafeEqual(Buffer.from(hmac), Buffer.from(signature));
}
function getHeader(headers: IncomingHttpHeaders, key: string): string {
const value = headers[key];
if (!value) throw new Error(`Missing header: ${key}`);
return Array.isArray(value) ? value[0] : value;
}
function toRFC3339Nano(date = new Date()): string {
const pad = (n: number, width = 2) => n.toString().padStart(width, '0');
const year = date.getUTCFullYear();
const month = pad(date.getUTCMonth() + 1);
const day = pad(date.getUTCDate());
const hour = pad(date.getUTCHours());
const minute = pad(date.getUTCMinutes());
const second = pad(date.getUTCSeconds());
const ms = date.getUTCMilliseconds().toString().padStart(3, '0');
// Simulate extra digits (not real nanoseconds)
const extra = process.hrtime.bigint() % 1_000_000n; // fake last 6 digits
const extraStr = extra.toString().padStart(6, '0');
return `${year}-${month}-${day}T${hour}:${minute}:${second}.${ms}${extraStr}Z`;
}
export default async function redeemsRoutes(
fastify: FastifyInstance,
): Promise<void> {
fastify.post('/eventsub', async (request: FastifyRequest, reply: FastifyReply) => {
logger.debug('eventsub ablagafkadlfijaldf ')
const secret = getSecret();
const rawBody = request.body;
const headers = request.headers;
logger.debug(headers)
logger.debug(`twitch_message_timestamp=${getHeader(headers, TWITCH_MESSAGE_TIMESTAMP)}`)
const message =
getHeader(headers, TWITCH_MESSAGE_ID) +
getHeader(headers, TWITCH_MESSAGE_TIMESTAMP) +
rawBody;
const hmac = HMAC_PREFIX + getHmac(secret, message);
if (verifyMessage(hmac, getHeader(headers, TWITCH_MESSAGE_SIGNATURE))) {
logger.debug('signatures match');
if (!(rawBody instanceof Buffer)) {
throw new Error("Expected rawBody to be a Buffer");
}
const notification = JSON.parse(rawBody.toString());
const messageType = headers[MESSAGE_TYPE];
if (messageType === MESSAGE_TYPE_NOTIFICATION) {
logger.debug(`Event type: ${notification.subscription.type}`);
logger.debug(JSON.stringify(notification.event, null, 4));
if (notification.subscription.type === 'channel.channel_points_custom_reward_redemption.add') {
const event = notification.event as ChannelPointRedemptionEvent
logger.debug(`looking for reward id ${event.reward.id}`)
const pick = await prisma.pick.findFirstOrThrow({
where: {
twitchChannelPointRewardId: event.reward.id
}
})
logger.debug(`looking for broadcaster user id =${event.broadcaster_user_id}`)
const user = await prisma.user.findFirstOrThrow({
where: {
twitchId: event.broadcaster_user_id
}
})
await prisma.redeem.create({
data: {
twitchEventId: event.id,
viewerTwitchId: event.user_id,
waifuId: pick.waifuId,
userId: user.id
}
})
}
return reply.code(204).send();
} else if (messageType === MESSAGE_TYPE_VERIFICATION) {
return reply.type('text/plain').code(200).send(notification.challenge);
} else if (messageType === MESSAGE_TYPE_REVOCATION) {
logger.debug(`${notification.subscription.type} notifications revoked!`);
logger.debug(`reason: ${notification.subscription.status}`);
logger.debug(`condition: ${JSON.stringify(notification.subscription.condition, null, 4)}`);
return reply.code(204).send();
} else {
logger.debug(`Unknown message type: ${messageType}`);
return reply.code(204).send();
}
} else {
logger.debug('403 - Invalid signature');
return reply.code(403).send();
}
});
// Register a content-type parser for raw application/json
fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, function (req, body, done) {
done(null, body);
});
}

View File

@ -62,7 +62,7 @@ function rewriteMp4ReferencesWithSignedUrls(
signFn: (path: string) => string signFn: (path: string) => string
): string { ): string {
logger.debug(`rewriteMp4ReferencesWithSignedUrls called with ${playlistContent} ${cdnBasePath} ${signFn}`) logger.trace(`rewriteMp4ReferencesWithSignedUrls called with ${playlistContent} ${cdnBasePath} ${signFn}`)
const cleanBase = cdnBasePath.replace(/^\/|\/$/g, '') // remove leading/trailing slash const cleanBase = cdnBasePath.replace(/^\/|\/$/g, '') // remove leading/trailing slash
@ -120,7 +120,7 @@ export default async function registerHlsRoute(app: FastifyInstance) {
// Otherwise, rewrite .mp4 references with signed URLs // Otherwise, rewrite .mp4 references with signed URLs
const tokenPath = `/${dirname(vod.hlsPlaylist)}/` const tokenPath = `/${dirname(vod.hlsPlaylist)}/`
logger.debug(`tokenPath=${tokenPath} hlsPlaylist=${vod.hlsPlaylist}`) logger.trace(`tokenPath=${tokenPath} hlsPlaylist=${vod.hlsPlaylist}`)
if (!tokenPath.startsWith('/')) { if (!tokenPath.startsWith('/')) {
throw new Error('tokenPath did not start with a forward slash'); throw new Error('tokenPath did not start with a forward slash');

View File

@ -297,7 +297,6 @@ export default async function streamsRoutes(
// const isSelfRequest = onBehalfOf === requester.twitchName; // const isSelfRequest = onBehalfOf === requester.twitchName;
// if (!isSelfRequest) { // if (!isSelfRequest) {
// const authorized = await isEditorAuthorized(requester.twitchName, onBehalfOf);
// if (!authorized) { // if (!authorized) {
// return reply.status(401).send({ // return reply.status(401).send({

View File

@ -1,5 +1,4 @@
import { type FastifyInstance } from 'fastify' import { type FastifyInstance } from 'fastify'
import { isEditorAuthorized } from '../utils/authorization'
import { OnBehalfQuery } from '../types' import { OnBehalfQuery } from '../types'
import { PrismaClient, type User } from '../../generated/prisma' import { PrismaClient, type User } from '../../generated/prisma'
import { withAccelerate } from "@prisma/extension-accelerate" import { withAccelerate } from "@prisma/extension-accelerate"
@ -88,65 +87,6 @@ export default async function usersRoutes(
fastify.get('/settings', async function (request, reply) {
const { onBehalfOf } = request.query as {
onBehalfOf?: string;
};
const userId = request.session.get('user_id');
const requester = await prisma.user.findFirstOrThrow({
where: { id: userId }
});
let targetUser = requester;
if (onBehalfOf) {
if (!requester.twitchName) {
return reply.status(500).send({
error: true,
message:
'Requesting editor does not have a twitchName defined. Please log out and in, then contact admin if error persists.'
});
}
const isSelfRequest = onBehalfOf === requester.twitchName;
if (!isSelfRequest) {
const authorized = await isEditorAuthorized(requester.twitchName, onBehalfOf);
if (!authorized) {
return reply.status(401).send({
error: true,
message: 'Requesting editor is not authorized to edit settings for this channel.'
});
}
targetUser = await prisma.user.findFirstOrThrow({
where: { twitchName: onBehalfOf }
});
}
}
const {
twitchChannels,
waifuChoicePoolSize,
maxOnScreenWaifus,
editorTwitchNames,
modsAreEditors,
twitchName,
redeemCost,
} = targetUser;
reply.send({
waifuChoicePoolSize,
maxOnScreenWaifus,
editorTwitchNames,
modsAreEditors,
twitchChannels,
twitchName,
redeemCost
});
});
} }

View File

@ -7,36 +7,12 @@ import logger from './logger';
const prisma = new PrismaClient().$extends(withAccelerate()) const prisma = new PrismaClient().$extends(withAccelerate())
/**
* Checks if the editor is authorized to act on behalf of a given Twitch user.
*
* @param editorName - The Twitch username of the editor.
* @param onBehalfOf - The Twitch username of the user being represented.
* @returns `true` if editorName is listed in onBehalfOf's editorTwitchNames, otherwise `false`.
*/
export async function isEditorAuthorized(editorName: string, onBehalfOf: string): Promise<boolean> {
const user = await prisma.user.findFirst({
where: {
twitchName: onBehalfOf,
},
select: {
editorTwitchNames: true,
},
});
if (!user) return false;
return user.editorTwitchNames.includes(editorName);
}
export async function getTargetUser( export async function getTargetUser(
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply reply: FastifyReply
) { ) {
const { onBehalfOf } = request.query as OnBehalfQuery;
const userId = request.session.get('user_id'); const userId = request.session.get('user_id');
@ -44,35 +20,17 @@ export async function getTargetUser(
where: { id: userId } where: { id: userId }
}); });
if (!onBehalfOf) {
logger.warn(`we have found the condition where onBehalfOf not set`)
return requester
} else if (onBehalfOf === requester.twitchName) {
logger.warn(`we have found the condtion where onBehalfOf is the same name as requester.twitchName`)
return requester;
}
if (!requester.twitchName) {
if (!requester.patreonId) {
reply.status(500).send({ reply.status(500).send({
error: true, error: true,
message: message:
'Requesting editor does not have a twitchName defined. Please log out and in, then contact admin if error persists.' 'Requesting user does not have a patreonId defined. Please log out and in, then contact admin if error persists.'
}); });
throw new Error('Unauthorized'); // Prevent downstream execution throw new Error('Unauthorized'); // Prevent downstream execution
} }
const authorized = await isEditorAuthorized(requester.twitchName, onBehalfOf); return requester;
if (!authorized) {
reply.status(401).send({
error: true,
message: 'Requesting editor is not authorized to edit settings for this channel.'
});
throw new Error('Unauthorized');
}
const targetUser = await prisma.user.findFirstOrThrow({
where: { twitchName: onBehalfOf }
});
return targetUser;
} }

View File

@ -39,6 +39,7 @@ export async function getOrDownloadAsset(client: S3Client, bucket: string, key:
const cacheKey = `${bucket}:${key}`; const cacheKey = `${bucket}:${key}`;
const lockKey = `${cacheKey}:lock`; const lockKey = `${cacheKey}:lock`;
// 1. Check cache first (non-blocking) // 1. Check cache first (non-blocking)
const cachedPath = await cache.get<string>(cacheKey); const cachedPath = await cache.get<string>(cacheKey);
if (cachedPath && existsSync(cachedPath)) { if (cachedPath && existsSync(cachedPath)) {
@ -48,6 +49,7 @@ export async function getOrDownloadAsset(client: S3Client, bucket: string, key:
// 2. Ensure directory exists // 2. Ensure directory exists
await mkdirp(dir); await mkdirp(dir);
// 3. Acquire distributed lock // 3. Acquire distributed lock
let acquiredLock = false; let acquiredLock = false;
let retryCount = 0; let retryCount = 0;

View File

@ -57,7 +57,7 @@ export function registerHbsHelpers(Handlebars: typeof HandlebarsLib) {
}); });
Handlebars.registerHelper('getCdnUrl', function (s3Key) { Handlebars.registerHelper('getCdnUrl', function (s3Key) {
// Before you remove this log, find a way to memoize this function! // Before you remove this log, find a way to memoize this function!
logger.info(`getCdnUrl called with CDN_ORIGIN=${env.CDN_ORIGIN} and CDN_TOKEN_SECRET=${env.CDN_TOKEN_SECRET}`) logger.trace(`getCdnUrl called with CDN_ORIGIN=${env.CDN_ORIGIN} and CDN_TOKEN_SECRET=${env.CDN_TOKEN_SECRET}`)
return signUrl(`${env.CDN_ORIGIN}/${s3Key}`, { return signUrl(`${env.CDN_ORIGIN}/${s3Key}`, {
securityKey: env.CDN_TOKEN_SECRET, securityKey: env.CDN_TOKEN_SECRET,
expirationTime: constants.timeUnits.sevenDaysInSeconds, expirationTime: constants.timeUnits.sevenDaysInSeconds,
@ -75,7 +75,7 @@ export function registerHbsHelpers(Handlebars: typeof HandlebarsLib) {
isDirectory: true, isDirectory: true,
expirationTime: constants.timeUnits.sevenDaysInSeconds, expirationTime: constants.timeUnits.sevenDaysInSeconds,
}) })
logger.debug(`pathAllowed=${pathAllowed} url=${url}`) logger.trace(`pathAllowed=${pathAllowed} url=${url}`)
return url return url
}) })
Handlebars.registerHelper('basename', function (url: string) { Handlebars.registerHelper('basename', function (url: string) {

View File

@ -43,6 +43,7 @@
{{{body}}} {{{body}}}
<script src="/assets/js/htmx.min.js"></script> <script src="/assets/js/htmx.min.js"></script>
<script> <script>

View File

@ -18,13 +18,14 @@
<a class="navbar-item" href="/vods">VODs</a> <a class="navbar-item" href="/vods">VODs</a>
{{!-- <a class="navbar-item" href="/streams"><s>🚧 Streams</s></a> @todo --}} {{!-- <a class="navbar-item" href="/streams"><s>🚧 Streams</s></a> @todo --}}
<a class="navbar-item" href="/vt">VTubers</a> <a class="navbar-item" href="/vt">VTubers</a>
<a class="navbar-item" href="/perks">Perks</a> <a class="navbar-item" href="/perks">Patron Perks</a>
{{#if (hasRole "supporterTier1" "moderator" "admin" user)}} {{#if (hasRole "supporterTier1" "moderator" "admin" user)}}
<a class="navbar-item" href="/uploads">Uploads</a> <a class="navbar-item" href="/uploads">Uploads</a>
{{/if}} {{/if}}
{{#if (hasRole "supporterTier1" "supporterTier2" "supporterTier3" "supporterTier4" "supporterTier5" "supporterTier6" "moderator" "admin" user)}} {{#if (hasRole "supporterTier1" "supporterTier2" "supporterTier3" "supporterTier4" "supporterTier5"
"supporterTier6" "moderator" "admin" user)}}
<a class="navbar-item" href="/upload"> <a class="navbar-item" href="/upload">
<span class="icon"> <span class="icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">

View File

@ -6,7 +6,7 @@
<main class="container"> <main class="container">
<section id="perks"> <section id="perks">
<h2 class="title is-1">Perks</h2> <h2 class="title is-1">Patron Perks</h2>
<p class="subtitle">We need your help to keep the site running! In return, we offer <p class="subtitle">We need your help to keep the site running! In return, we offer
extra perks extra perks

View File

@ -1,5 +1,6 @@
{{#> main}} {{#> main}}
<link href="/assets/vod.css" rel="stylesheet"> <link href="/assets/vod.css" rel="stylesheet">