Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions bot/src/commands/music_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@
from discord.ext import commands

from src.services.lavalink_service import LavalinkVoiceClient
from src.services.server_settings_service import QueueCapacity
from src.services.server_settings import ServerSettingsService
from src.services.server_settings_service import QueueCapacity, ServerSettingsService
from src.utils.embeds import EmbedFactory
from src.utils.time import ms_to_clock
from lavalink.errors import ClientError
Expand Down
1 change: 1 addition & 0 deletions bot/src/configs/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ class StatusAPIConfig(BaseModel):
usage_token: Optional[str] = None
control_start_cmd: Optional[str] = None
control_stop_cmd: Optional[str] = None
control_restart_cmd: Optional[str] = None
control_reload_cmd: Optional[str] = None
control_reload_commands_cmd: Optional[str] = None
control_restart_frontend_cmd: Optional[str] = None
Expand Down
2 changes: 2 additions & 0 deletions bot/src/services/status_api_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,8 @@ async def _handle_control_action(self, request: web.Request) -> web.Response:
await self._run_or_fallback(self.config.control_restart_frontend_cmd, self._noop)
elif action in {"start", "start-bot", "start_bot"}:
await self._run_or_fallback(self.config.control_start_cmd, self._noop)
elif action in {"restart", "restart-bot", "restart_bot"}:
await self._run_or_fallback(self.config.control_restart_cmd, self._noop)
elif action in {"stop", "stop-bot", "stop_bot"}:
await self._run_or_fallback(self.config.control_stop_cmd, self._noop)
elif action in {"hot-patch", "hot_patch"}:
Expand Down
8 changes: 4 additions & 4 deletions frontend/app/account/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
updateFormData({ privacy: privacyData })
}
} catch (error) {
console.error('Failed to load user data:', error)

Check warning on line 70 in frontend/app/account/page.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected console statement
setErrorState('contactError', 'Failed to load user data')
} finally {
setLoadingState('isLoading', false)
Expand Down Expand Up @@ -159,7 +159,7 @@
const url = URL.createObjectURL(data)
const a = document.createElement('a')
a.href = url
a.download = `account-data-${state.discordId}.json`
a.download = `vectobeat-data-${state.discordId}.pdf`
a.click()
URL.revokeObjectURL(url)
setMessageState('dataMessage', 'Data exported successfully')
Expand Down Expand Up @@ -358,15 +358,15 @@
<SettingsCard title="Data Management" description="Export or delete your account data">
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-900">Export Data</h4>
<p className="text-sm text-gray-500">Download a copy of your account data</p>
<h4 className="text-sm font-medium text-gray-900">Export Data (Compliance Mode)</h4>
<p className="text-sm text-gray-500">Download a consolidated PDF report of your account data for GDPR compliance</p>
<div className="mt-2">
<SettingsButton
onClick={handleExportData}
loading={state.dataDeleting}
variant="secondary"
>
Export Data
Request Data Export
</SettingsButton>
</div>
</div>
Expand Down
68 changes: 41 additions & 27 deletions frontend/app/api/account/export/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type NextRequest, NextResponse } from "next/server"
import { PDFDocument } from "pdf-lib"
import path from "path"
import { getFullUserData, getStoredUserProfile } from "@/lib/db"
import { getFullUserData, getStoredUserProfile, getPool } from "@/lib/db"
import { ensureStripeCustomerForUser } from "@/lib/stripe-customers"
import { verifyRequestForUser } from "@/lib/auth"
import { PdfGenerator } from "@/lib/pdf-generator"
Expand Down Expand Up @@ -44,10 +44,10 @@

// --- Generate PDF ---
const doc = await PDFDocument.create()

// Attempt to resolve logo path
const logoPath = path.join(process.cwd(), "public", "logo.png")

const gen = new PdfGenerator(doc, logoPath)
await gen.init()

Expand Down Expand Up @@ -77,13 +77,13 @@

// 2. Contact Information
gen.drawSectionTitle("Contact Information")

const email = storedProfile?.email ?? userData.contact?.email ?? "-"
const phone = storedProfile?.phone ?? userData.contact?.phone ?? "-"
let stripeId = userData.contact?.stripeCustomerId ??
userData.subscriptions?.find((s: any) => s.stripeCustomerId)?.stripeCustomerId ??
"-"

let stripeId = userData.contact?.stripeCustomerId ??
userData.subscriptions?.find((s: any) => s.stripeCustomerId)?.stripeCustomerId ??
"-"

// Attempt to resolve Stripe ID if missing but we have an email
if ((stripeId === "-" || !stripeId) && email !== "-") {
Expand All @@ -97,7 +97,7 @@
stripeId = resolvedId
}
} catch (err) {
console.error("Failed to resolve Stripe ID during export:", err)

Check warning on line 100 in frontend/app/api/account/export/route.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected console statement
}
}

Expand All @@ -110,7 +110,7 @@
gen.drawSectionTitle("Address")
const street = [userData.preferences.addressStreet, userData.preferences.addressHouseNumber].filter(Boolean).join(" ")
const city = [userData.preferences.addressPostalCode, userData.preferences.addressCity].filter(Boolean).join(" ")

gen.drawKeyValue("Street", street)
gen.drawKeyValue("City", city)
gen.drawKeyValue("State", userData.preferences.addressState)
Expand All @@ -125,10 +125,10 @@
gen.drawKeyValue("Headline", userData.settings.headline)
gen.drawKeyValue("Location", userData.settings.location)
gen.drawKeyValue("Website", userData.settings.website)

if (userData.settings.bio) {
gen.checkPageBreak(40)
gen.drawKeyValue("Bio", userData.settings.bio.substring(0, 100) + (userData.settings.bio.length > 100 ? "..." : ""))
gen.checkPageBreak(40)
gen.drawKeyValue("Bio", userData.settings.bio.substring(0, 100) + (userData.settings.bio.length > 100 ? "..." : ""))
}
}

Expand Down Expand Up @@ -168,25 +168,25 @@
gen.drawKeyValue("Analytics Opt-In", userData.privacy?.analyticsOptIn ? "Yes" : "No")

if (userData.userBackupCodes && userData.userBackupCodes.length > 0) {
gen.checkPageBreak(40)
const rows = userData.userBackupCodes.map(code => [
"****" + code.id.slice(-4), // Mask ID
formatDate(code.createdAt),
code.usedAt ? formatDate(code.usedAt) : "Unused"
])
gen.drawTable(["Backup Code (Masked)", "Created", "Status"], rows, [150, 150, 150])
gen.checkPageBreak(40)

const rows = userData.userBackupCodes.map(code => [
"****" + code.id.slice(-4), // Mask ID
formatDate(code.createdAt),
code.usedAt ? formatDate(code.usedAt) : "Unused"
])
gen.drawTable(["Backup Code (Masked)", "Created", "Status"], rows, [150, 150, 150])
}

// 6b. Password History
if (userData.passwordHistory && userData.passwordHistory.length > 0) {
gen.checkPageBreak(40)
gen.drawSectionTitle("Password History")
const rows = userData.passwordHistory.map((h: any) => [
formatDate(h.createdAt),
h.password
])
gen.drawTable(["Date", "Password (Hash/Encrypted)"], rows, [150, 300])
gen.checkPageBreak(40)
gen.drawSectionTitle("Password History")
const rows = userData.passwordHistory.map((h: any) => [
formatDate(h.createdAt),
h.password
])
gen.drawTable(["Date", "Password (Hash/Encrypted)"], rows, [150, 300])
}

// 7. Preferences
Expand Down Expand Up @@ -304,6 +304,20 @@

const pdfBytes = await doc.save()

const db = getPool()
if (db) {
try {
await db.dataExportRequest.create({
data: {
discordId,
status: "completed",
}
})
} catch (err) {
console.error("[VectoBeat] Failed to log compliance data export request:", err)

Check warning on line 317 in frontend/app/api/account/export/route.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected console statement
}
}

return new NextResponse(Buffer.from(pdfBytes), {
headers: {
"Content-Type": "application/pdf",
Expand Down
1 change: 1 addition & 0 deletions frontend/app/api/admin/bot-control/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const ACTIONS: Record<string, string> = {
reload_commands: "reload-commands",
reload_config: "reload-config",
restart_frontend: "restart-frontend",
hot_patch: "hot-patch",
}

export async function POST(request: NextRequest) {
Expand Down
42 changes: 42 additions & 0 deletions frontend/app/api/admin/compliance/export-requests/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { NextResponse, type NextRequest } from "next/server"
import { cookies } from "next/headers"
import { verifyRequestForUser } from "@/lib/auth"
import { getPrismaClient, getUserRole } from "@/lib/db"

const checkAdmin = async (req: NextRequest) => {
const cookieStore = await cookies()
const discordId =
cookieStore.get("discord_user_id")?.value ||
cookieStore.get("discord_id")?.value ||
cookieStore.get("discordId")?.value ||
req.nextUrl.searchParams.get("discordId")

if (!discordId) return false

const verification = await verifyRequestForUser(req, discordId)
if (!verification.valid) return false

const role = await getUserRole(discordId)
return role === "admin" || role === "operator"
}

export async function GET(req: NextRequest) {
if (!await checkAdmin(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}

try {
const prisma = getPrismaClient()
if (!prisma) return NextResponse.json({ error: "Database unavailable" }, { status: 500 })

const requests = await prisma.dataExportRequest.findMany({
orderBy: { createdAt: "desc" },
take: 200,
})

return NextResponse.json(requests)
} catch (error) {
console.error("Get Data Export Requests Error:", error)
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 })
}
}
42 changes: 30 additions & 12 deletions frontend/app/api/admin/connectivity/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ const BOT_ENV_KEYS = new Set([
const getEnvCandidates = (target: "frontend" | "bot") =>
target === "bot"
? [
path.resolve(process.cwd(), "../bot/.env"),
path.resolve(process.cwd(), "../bot/.env.development"),
path.resolve(process.cwd(), "../bot/.env.local"),
]
path.resolve(process.cwd(), "../bot/.env"),
path.resolve(process.cwd(), "../bot/.env.development"),
path.resolve(process.cwd(), "../bot/.env.local"),
]
: [
path.resolve(process.cwd(), ".env"),
path.resolve(process.cwd(), ".env.development"),
path.resolve(process.cwd(), ".env.local"),
]
path.resolve(process.cwd(), ".env"),
path.resolve(process.cwd(), ".env.development"),
path.resolve(process.cwd(), ".env.local"),
]

const readEnvFile = async (target: "frontend" | "bot") => {
const candidates = getEnvCandidates(target)
Expand All @@ -56,16 +56,24 @@ const readEnvFile = async (target: "frontend" | "bot") => {
return {}
}

const fetchWithTimeout = async (url: string) => {
const fetchWithTimeout = async (url: string, token: string | null = null) => {
if (!/^https?:\/\//i.test(url)) {
return true
}
const controller = new AbortController()
const id = setTimeout(() => controller.abort(), 3000)
try {
await apiClient(url, { signal: controller.signal, cache: "no-store" })
const headers: HeadersInit = {}
if (token) {
headers["Authorization"] = `Bearer ${token}`
}
await apiClient(url, { signal: controller.signal, headers, cache: "no-store" })
return true
} catch {
} catch (error: any) {
// If it's an API error returning 401/403/404, the server is online but rejected the ping/auth
if (error?.name === "ApiError" || error?.status === 401 || error?.status === 403 || error?.status === 404) {
return true
}
return false
} finally {
clearTimeout(id)
Expand Down Expand Up @@ -94,7 +102,17 @@ export async function GET(request: NextRequest) {
const fromBot = BOT_ENV_KEYS.has(service.envKey)
const envVal = fromBot ? botEnv[service.envKey] || process.env[service.envKey] : frontendEnv[service.envKey] || process.env[service.envKey]
if (!envVal) return { label: service.label, key: service.key, url: null, status: "missing" }
const online = await fetchWithTimeout(envVal)

// Determine authenticaton header key if applicable
let authKey = null
if (service.key === "status_api") authKey = "BOT_STATUS_API_KEY"
else if (service.key === "server_settings") authKey = "SERVER_SETTINGS_API_KEY"
else if (service.key === "queue_sync") authKey = "QUEUE_SYNC_API_KEY"
else if (service.key === "telemetry") authKey = "QUEUE_TELEMETRY_API_KEY"

const envAuth = authKey ? (fromBot ? botEnv[authKey] || process.env[authKey] : frontendEnv[authKey] || process.env[authKey]) : null

const online = await fetchWithTimeout(envVal, envAuth || null)
return { label: service.label, key: service.key, url: envVal, status: online ? "online" : "offline" }
}),
)
Expand Down
Loading
Loading