chore: remove solana wallet stack

This commit is contained in:
2026-04-22 13:32:14 -04:00
parent 8ec91acc25
commit 4bfcbb6b7e
8 changed files with 104 additions and 744 deletions
+17 -74
View File
@@ -9,33 +9,25 @@ datasource db {
} }
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
plexId String @unique @map("plex_id") plexId String @unique @map("plex_id")
plexUsername String @map("plex_username") plexUsername String @map("plex_username")
email String? email String?
isAdmin Boolean @default(false) @map("is_admin") isAdmin Boolean @default(false) @map("is_admin")
isActive Boolean @default(true) @map("is_active") isActive Boolean @default(true) @map("is_active")
totalEarned Int @default(0) @map("total_earned")
totalSpent Int @default(0) @map("total_spent")
watchTimeMinutes Int @default(0) @map("watch_time_minutes")
// Solana wallet transactions Transaction[]
walletAddress String? @unique @map("wallet_address") watchEvents WatchEvent[]
encryptedPrivateKey String? @map("encrypted_private_key") contentRequests ContentRequest[]
sessions Session[]
// Stats
totalEarned Int @default(0) @map("total_earned")
totalSpent Int @default(0) @map("total_spent")
watchTimeMinutes Int @default(0) @map("watch_time_minutes")
// Relations
transactions Transaction[]
watchEvents WatchEvent[]
contentRequests ContentRequest[]
sessions Session[]
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@index([plexId]) @@index([plexId])
@@index([walletAddress])
@@map("users") @@map("users")
} }
@@ -43,29 +35,16 @@ model Transaction {
id String @id @default(uuid()) id String @id @default(uuid())
userId String @map("user_id") userId String @map("user_id")
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
type TransactionType type TransactionType
amount Int amount Int
// For earned transactions
watchEventId String? @map("watch_event_id") watchEventId String? @map("watch_event_id")
watchEvent WatchEvent? @relation(fields: [watchEventId], references: [id]) watchEvent WatchEvent? @relation(fields: [watchEventId], references: [id])
// For spent transactions
requestId String? @unique @map("request_id") requestId String? @unique @map("request_id")
request ContentRequest? @relation(fields: [requestId], references: [id]) request ContentRequest? @relation(fields: [requestId], references: [id])
// Solana transaction reference
solanaSignature String? @map("solana_signature")
// Metadata
description String? description String?
contentTitle String? @map("content_title") contentTitle String? @map("content_title")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
@@index([userId]) @@index([userId])
@@index([type])
@@index([createdAt]) @@index([createdAt])
@@map("transactions") @@map("transactions")
} }
@@ -74,28 +53,18 @@ model WatchEvent {
id String @id @default(uuid()) id String @id @default(uuid())
userId String @map("user_id") userId String @map("user_id")
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
// Tautulli data
sessionId String @map("session_id") sessionId String @map("session_id")
ratingKey String @map("rating_key") ratingKey String @map("rating_key")
contentType String @map("content_type") // movie, episode contentType String @map("content_type")
title String title String
grandparentTitle String? @map("grandparent_title") // Show name for episodes grandparentTitle String? @map("grandparent_title")
duration Int
// Watch stats
duration Int // seconds watched
percentComplete Int @map("percent_complete") percentComplete Int @map("percent_complete")
// Credits earned
creditsEarned Int @map("credits_earned") creditsEarned Int @map("credits_earned")
isProcessed Boolean @default(false) @map("is_processed") isProcessed Boolean @default(false) @map("is_processed")
// Relations
transactions Transaction[] transactions Transaction[]
watchedAt DateTime @map("watched_at") watchedAt DateTime @map("watched_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
@@unique([sessionId]) @@unique([sessionId])
@@index([userId]) @@index([userId])
@@index([watchedAt]) @@index([watchedAt])
@@ -106,30 +75,17 @@ model ContentRequest {
id String @id @default(uuid()) id String @id @default(uuid())
userId String @map("user_id") userId String @map("user_id")
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
// Overseer data
overseerRequestId Int @map("overseer_request_id") overseerRequestId Int @map("overseer_request_id")
mediaType String @map("media_type")
// Content info
mediaType String @map("media_type") // movie, tv
tmdbId Int @map("tmdb_id") tmdbId Int @map("tmdb_id")
title String title String
// Cost
creditsCost Int @map("credits_cost") creditsCost Int @map("credits_cost")
// Status
status RequestStatus @default(PENDING) status RequestStatus @default(PENDING)
// Relations
transaction Transaction? transaction Transaction?
requestedAt DateTime @map("requested_at") requestedAt DateTime @map("requested_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@index([userId]) @@index([userId])
@@index([status])
@@map("content_requests") @@map("content_requests")
} }
@@ -137,12 +93,9 @@ model Session {
id String @id @default(uuid()) id String @id @default(uuid())
userId String @map("user_id") userId String @map("user_id")
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
token String @unique token String @unique
expiresAt DateTime @map("expires_at") expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
@@index([userId]) @@index([userId])
@@index([token]) @@index([token])
@@map("sessions") @@map("sessions")
@@ -150,28 +103,18 @@ model Session {
model SystemSettings { model SystemSettings {
id String @id @default(cuid()) id String @id @default(cuid())
// Minting settings
creditsPerMinute Int @default(2) @map("credits_per_minute") creditsPerMinute Int @default(2) @map("credits_per_minute")
minWatchPercent Int @default(80) @map("min_watch_percent") minWatchPercent Int @default(80) @map("min_watch_percent")
minWatchMinutes Int @default(5) @map("min_watch_minutes") minWatchMinutes Int @default(5) @map("min_watch_minutes")
// Spending settings
movieRequestCost Int @default(500) @map("movie_request_cost") movieRequestCost Int @default(500) @map("movie_request_cost")
tvRequestCost Int @default(1000) @map("tv_request_cost") tvRequestCost Int @default(1000) @map("tv_request_cost")
tvPerSeasonCost Int @default(250) @map("tv_per_season_cost") tvPerSeasonCost Int @default(250) @map("tv_per_season_cost")
// Multipliers
newReleaseMultiplier Decimal @default(1.5) @map("new_release_multiplier") @db.Decimal(3, 2) newReleaseMultiplier Decimal @default(1.5) @map("new_release_multiplier") @db.Decimal(3, 2)
bonusMultiplierActive Boolean @default(false) @map("bonus_multiplier_active") bonusMultiplierActive Boolean @default(false) @map("bonus_multiplier_active")
bonusMultiplier Decimal @default(2.0) @map("bonus_multiplier") @db.Decimal(3, 2) bonusMultiplier Decimal @default(2.0) @map("bonus_multiplier") @db.Decimal(3, 2)
// System state
mintingPaused Boolean @default(false) @map("minting_paused") mintingPaused Boolean @default(false) @map("minting_paused")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
updatedBy String? @map("updated_by") updatedBy String? @map("updated_by")
@@map("system_settings") @@map("system_settings")
} }
+28 -155
View File
@@ -3,7 +3,6 @@ import crypto from "crypto";
import { Router } from "express"; import { Router } from "express";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { asyncHandler } from "../middleware/errorHandler"; import { asyncHandler } from "../middleware/errorHandler";
import { createWallet } from "../services/solana";
import { prisma } from "../utils/prisma"; import { prisma } from "../utils/prisma";
const ENCRYPTION_KEY = const ENCRYPTION_KEY =
@@ -27,18 +26,11 @@ const PLEX_CLIENT_ID = process.env.PLEX_CLIENT_ID || "";
const PLEX_REDIRECT_URI = process.env.PLEX_REDIRECT_URI || ""; const PLEX_REDIRECT_URI = process.env.PLEX_REDIRECT_URI || "";
const JWT_SECRET = process.env.JWT_SECRET || "secret"; const JWT_SECRET = process.env.JWT_SECRET || "secret";
if (!PLEX_CLIENT_ID) {
console.error("ERROR: PLEX_CLIENT_ID not set");
}
// Step 1: Generate Plex PIN and return auth URL
router.get( router.get(
"/plex/url", "/plex/url",
asyncHandler(async (_req, res) => { asyncHandler(async (_req, res) => {
if (!PLEX_CLIENT_ID) { if (!PLEX_CLIENT_ID)
return res.status(500).json({ error: "Plex not configured" }); return res.status(500).json({ error: "Plex not configured" });
}
const pinResponse = await axios.post( const pinResponse = await axios.post(
"https://plex.tv/api/v2/pins?strong=true", "https://plex.tv/api/v2/pins?strong=true",
null, null,
@@ -53,25 +45,18 @@ router.get(
}, },
}, },
); );
const pin = pinResponse.data; const pin = pinResponse.data;
const authUrl = `https://app.plex.tv/auth#?clientID=${encodeURIComponent(PLEX_CLIENT_ID)}&code=${pin.code}&forwardUrl=${encodeURIComponent(PLEX_REDIRECT_URI)}`; const authUrl = `https://app.plex.tv/auth#?clientID=${encodeURIComponent(PLEX_CLIENT_ID)}&code=${pin.code}&forwardUrl=${encodeURIComponent(PLEX_REDIRECT_URI)}`;
res.json({ authUrl, pinId: pin.id }); res.json({ authUrl, pinId: pin.id });
}), }),
); );
// Step 2: Exchange PIN for Plex token and create user session
router.post( router.post(
"/plex/callback", "/plex/callback",
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { pinId } = req.body; const { pinId } = req.body;
if (!pinId) return res.status(400).json({ error: "PIN ID required" });
if (!pinId) {
return res.status(400).json({ error: "PIN ID required" });
}
// Check PIN status to get auth token
const pinResponse = await axios.get( const pinResponse = await axios.get(
`https://plex.tv/api/v2/pins/${pinId}`, `https://plex.tv/api/v2/pins/${pinId}`,
{ {
@@ -81,33 +66,23 @@ router.post(
}, },
}, },
); );
const pin = pinResponse.data; const pin = pinResponse.data;
if (!pin.authToken)
if (!pin.authToken) {
return res.status(400).json({ error: "Authentication not completed" }); return res.status(400).json({ error: "Authentication not completed" });
}
const plexToken = pin.authToken;
// Get user info from Plex
const userResponse = await axios.get("https://plex.tv/api/v2/user", { const userResponse = await axios.get("https://plex.tv/api/v2/user", {
headers: { headers: {
"X-Plex-Token": plexToken, "X-Plex-Token": pin.authToken,
"X-Plex-Client-Identifier": PLEX_CLIENT_ID, "X-Plex-Client-Identifier": PLEX_CLIENT_ID,
Accept: "application/json", Accept: "application/json",
}, },
}); });
const plexUser = userResponse.data; const plexUser = userResponse.data;
// Find or create user
let user = await prisma.user.findUnique({ let user = await prisma.user.findUnique({
where: { plexId: String(plexUser.id) }, where: { plexId: String(plexUser.id) },
}); });
const isNewUser = !user; const isNewUser = !user;
if (!user) { if (!user) {
user = await prisma.user.create({ user = await prisma.user.create({
data: { data: {
@@ -127,7 +102,6 @@ router.post(
}); });
} }
// Delete old sessions and create new one
await prisma.session.deleteMany({ where: { userId: user.id } }); await prisma.session.deleteMany({ where: { userId: user.id } });
const sessionToken = jwt.sign( const sessionToken = jwt.sign(
{ userId: user.id, nonce: Date.now() }, { userId: user.id, nonce: Date.now() },
@@ -142,22 +116,13 @@ router.post(
}, },
}); });
// Backfill 30 days of watch history for new users if (isNewUser) await backfillUserHistory(user);
if (isNewUser) {
await backfillUserHistory(user);
}
// Generate JWT
const token = jwt.sign( const token = jwt.sign(
{ { userId: user.id, plexId: user.plexId, isAdmin: user.isAdmin },
userId: user.id,
plexId: user.plexId,
isAdmin: user.isAdmin,
},
JWT_SECRET, JWT_SECRET,
{ expiresIn: "7d" }, { expiresIn: "7d" },
); );
res.json({ res.json({
token, token,
sessionToken: session.token, sessionToken: session.token,
@@ -167,7 +132,6 @@ router.post(
plexUsername: user.plexUsername, plexUsername: user.plexUsername,
email: user.email, email: user.email,
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
walletAddress: user.walletAddress,
totalEarned: user.totalEarned, totalEarned: user.totalEarned,
totalSpent: user.totalSpent, totalSpent: user.totalSpent,
}, },
@@ -179,24 +143,15 @@ router.get(
"/verify", "/verify",
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer "))
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: "No token provided" }); return res.status(401).json({ error: "No token provided" });
}
const token = authHeader.substring(7);
try { try {
const decoded = jwt.verify(token, JWT_SECRET) as any; const decoded = jwt.verify(authHeader.substring(7), JWT_SECRET) as any;
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: decoded.userId }, where: { id: decoded.userId },
}); });
if (!user || !user.isActive)
if (!user || !user.isActive) {
return res.status(401).json({ error: "User not found or inactive" }); return res.status(401).json({ error: "User not found or inactive" });
}
res.json({ res.json({
user: { user: {
id: user.id, id: user.id,
@@ -204,12 +159,11 @@ router.get(
plexUsername: user.plexUsername, plexUsername: user.plexUsername,
email: user.email, email: user.email,
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
walletAddress: user.walletAddress,
totalEarned: user.totalEarned, totalEarned: user.totalEarned,
totalSpent: user.totalSpent, totalSpent: user.totalSpent,
}, },
}); });
} catch (error) { } catch {
res.status(401).json({ error: "Invalid token" }); res.status(401).json({ error: "Invalid token" });
} }
}), }),
@@ -219,21 +173,12 @@ router.post(
"/logout", "/logout",
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) { if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.substring(7);
try { try {
const decoded = jwt.verify(token, JWT_SECRET) as any; const decoded = jwt.verify(authHeader.substring(7), JWT_SECRET) as any;
await prisma.session.deleteMany({ where: { userId: decoded.userId } });
await prisma.session.deleteMany({ } catch {}
where: { userId: decoded.userId },
});
} catch {
// Ignore invalid tokens on logout
}
} }
res.json({ message: "Logged out successfully" }); res.json({ message: "Logged out successfully" });
}), }),
); );
@@ -242,38 +187,12 @@ async function backfillUserHistory(user: {
id: string; id: string;
plexId: string; plexId: string;
plexUsername: string; plexUsername: string;
walletAddress: string | null;
}) { }) {
if (!TAUTULLI_URL || !TAUTULLI_API_KEY) { if (!TAUTULLI_URL || !TAUTULLI_API_KEY) return;
console.log("Tautulli not configured, skipping backfill");
return;
}
try { try {
// Create wallet if user doesn't have one
if (!user.walletAddress) {
const wallet = createWallet();
await prisma.user.update({
where: { id: user.id },
data: {
walletAddress: wallet.publicKey,
encryptedPrivateKey: encrypt(wallet.secretKey),
},
});
user.walletAddress = wallet.publicKey;
console.log(
`Created wallet for new user ${user.plexUsername}: ${wallet.publicKey}`,
);
}
// Map Plex username to Tautulli user ID
const usersResponse = await axios.get(`${TAUTULLI_URL}/api/v2`, { const usersResponse = await axios.get(`${TAUTULLI_URL}/api/v2`, {
params: { params: { apikey: TAUTULLI_API_KEY, cmd: "get_users" },
apikey: TAUTULLI_API_KEY,
cmd: "get_users",
},
}); });
const tautulliUsers = usersResponse.data?.response?.data || []; const tautulliUsers = usersResponse.data?.response?.data || [];
const tautulliUser = tautulliUsers.find( const tautulliUser = tautulliUsers.find(
(u: any) => (u: any) =>
@@ -281,44 +200,25 @@ async function backfillUserHistory(user: {
u.email?.toLowerCase() === user.plexUsername.toLowerCase() || u.email?.toLowerCase() === user.plexUsername.toLowerCase() ||
u.friendly_name?.toLowerCase() === user.plexUsername.toLowerCase(), u.friendly_name?.toLowerCase() === user.plexUsername.toLowerCase(),
); );
if (!tautulliUser) return;
if (!tautulliUser) { const startDate = (() => {
console.log( const d = new Date();
`Tautulli user not found for Plex username ${user.plexUsername}, skipping backfill`, d.setDate(d.getDate() - 30);
); return d.toISOString().split("T")[0];
return; })();
}
const tautulliUserId = tautulliUser.user_id;
console.log(
`Mapped ${user.plexUsername} to Tautulli user ID ${tautulliUserId}`,
);
// Calculate 30 days ago
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const startDate = thirtyDaysAgo.toISOString().split("T")[0];
// Fetch history from Tautulli
const historyResponse = await axios.get(`${TAUTULLI_URL}/api/v2`, { const historyResponse = await axios.get(`${TAUTULLI_URL}/api/v2`, {
params: { params: {
apikey: TAUTULLI_API_KEY, apikey: TAUTULLI_API_KEY,
cmd: "get_history", cmd: "get_history",
user_id: tautulliUserId, user_id: tautulliUser.user_id,
start_date: startDate, start_date: startDate,
length: 1000, length: 1000,
}, },
}); });
const historyData = historyResponse.data?.response?.data?.data || []; const historyData = historyResponse.data?.response?.data?.data || [];
if (!Array.isArray(historyData) || historyData.length === 0) { if (!Array.isArray(historyData) || historyData.length === 0) return;
console.log(
`No watch history found for user ${user.plexUsername} in last 30 days`,
);
return;
}
// Get system settings for credit calculation
const settings = await prisma.systemSettings.findFirst(); const settings = await prisma.systemSettings.findFirst();
const creditsPerMinute = settings?.creditsPerMinute || 2; const creditsPerMinute = settings?.creditsPerMinute || 2;
const minWatchPercent = settings?.minWatchPercent || 80; const minWatchPercent = settings?.minWatchPercent || 80;
@@ -326,38 +226,21 @@ async function backfillUserHistory(user: {
let totalCredits = 0; let totalCredits = 0;
let totalMinutes = 0; let totalMinutes = 0;
let processedCount = 0;
for (const item of historyData) { for (const item of historyData) {
const sessionId = item.reference_id?.toString() || item.id?.toString(); const sessionId = item.reference_id?.toString() || item.id?.toString();
if (!sessionId) continue; if (!sessionId) continue;
if (await prisma.watchEvent.findUnique({ where: { sessionId } }))
// Skip duplicates continue;
const existing = await prisma.watchEvent.findUnique({
where: { sessionId },
});
if (existing) continue;
const percentComplete = item.percent_complete || 0; const percentComplete = item.percent_complete || 0;
const watchDurationMinutes = Math.floor( const watchDurationMinutes = Math.floor(
(item.stopped - item.started) / 60, (item.stopped - item.started) / 60,
); );
if ( if (
percentComplete < minWatchPercent || percentComplete < minWatchPercent ||
watchDurationMinutes < minWatchMinutes watchDurationMinutes < minWatchMinutes
) { )
continue; continue;
} const creditsEarned = watchDurationMinutes * creditsPerMinute;
let creditsEarned = watchDurationMinutes * creditsPerMinute;
if (settings?.newReleaseMultiplier && item.is_new) {
creditsEarned = Math.floor(
creditsEarned * Number(settings.newReleaseMultiplier),
);
}
// Create watch event
const watchEvent = await prisma.watchEvent.create({ const watchEvent = await prisma.watchEvent.create({
data: { data: {
userId: user.id, userId: user.id,
@@ -373,8 +256,6 @@ async function backfillUserHistory(user: {
watchedAt: new Date(item.stopped * 1000), watchedAt: new Date(item.stopped * 1000),
}, },
}); });
// DB-only credit record
await prisma.transaction.create({ await prisma.transaction.create({
data: { data: {
userId: user.id, userId: user.id,
@@ -385,14 +266,10 @@ async function backfillUserHistory(user: {
contentTitle: item.title || item.full_title || "Unknown", contentTitle: item.title || item.full_title || "Unknown",
}, },
}); });
totalCredits += creditsEarned; totalCredits += creditsEarned;
totalMinutes += watchDurationMinutes; totalMinutes += watchDurationMinutes;
processedCount++;
} }
if (totalCredits > 0)
// Update user stats
if (totalCredits > 0) {
await prisma.user.update({ await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
@@ -400,10 +277,6 @@ async function backfillUserHistory(user: {
watchTimeMinutes: { increment: totalMinutes }, watchTimeMinutes: { increment: totalMinutes },
}, },
}); });
console.log(
`Backfilled ${processedCount} watch events for ${user.plexUsername}: ${totalCredits} $COOP, ${totalMinutes} minutes`,
);
}
} catch (error) { } catch (error) {
console.error( console.error(
`Failed to backfill history for ${user.plexUsername}:`, `Failed to backfill history for ${user.plexUsername}:`,
+29 -210
View File
@@ -1,223 +1,42 @@
import crypto from "crypto";
import { Router } from "express"; import { Router } from "express";
import { import { type AuthenticatedRequest, authenticate, requireAdmin } from "../middleware/auth";
type AuthenticatedRequest,
authenticate,
requireAdmin,
} from "../middleware/auth";
import { asyncHandler } from "../middleware/errorHandler"; import { asyncHandler } from "../middleware/errorHandler";
import { createWallet } from "../services/solana";
import { prisma } from "../utils/prisma"; import { prisma } from "../utils/prisma";
const router = Router(); const router = Router();
const ENCRYPTION_KEY =
process.env.ENCRYPTION_KEY || "default-key-32-chars-long!!!!!";
function encrypt(text: string): string { router.get("/", authenticate, asyncHandler(async (req: AuthenticatedRequest, res) => {
const iv = crypto.randomBytes(16); const user = await prisma.user.findUnique({ where: { id: req.user!.id }, select: { totalEarned: true, totalSpent: true } });
const key = crypto.createHash("sha256").update(ENCRYPTION_KEY).digest(); res.json({ hasWallet: false, balance: (user?.totalEarned || 0) - (user?.totalSpent || 0), totalEarned: user?.totalEarned || 0, totalSpent: user?.totalSpent || 0 });
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); }));
let encrypted = cipher.update(text, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag();
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
}
function decrypt(encryptedData: string): string { router.post("/create", authenticate, asyncHandler(async (_req: AuthenticatedRequest, res) => {
const parts = encryptedData.split(":"); res.status(410).json({ error: "Wallets removed" });
const iv = Buffer.from(parts[0], "hex"); }));
const authTag = Buffer.from(parts[1], "hex");
const encrypted = parts[2];
const key = crypto.createHash("sha256").update(ENCRYPTION_KEY).digest();
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
router.get( router.post("/connect", authenticate, asyncHandler(async (_req: AuthenticatedRequest, res) => {
"/", res.status(410).json({ error: "Wallets removed" });
authenticate, }));
asyncHandler(async (req: AuthenticatedRequest, res) => {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: {
walletAddress: true,
totalEarned: true,
totalSpent: true,
},
});
if (!user?.walletAddress) { router.post("/backup", authenticate, asyncHandler(async (_req: AuthenticatedRequest, res) => {
return res.json({ res.status(410).json({ error: "Wallets removed" });
hasWallet: false, }));
balance: 0,
totalEarned: user?.totalEarned || 0,
totalSpent: user?.totalSpent || 0,
});
}
const dbBalance = user.totalEarned - user.totalSpent; router.get("/admin/:userId", authenticate, requireAdmin, asyncHandler(async (req: AuthenticatedRequest, res) => {
const user = await prisma.user.findUnique({ where: { id: req.params.userId }, select: { id: true, plexUsername: true, email: true, isAdmin: true, isActive: true, totalEarned: true, totalSpent: true, watchTimeMinutes: true, createdAt: true } });
if (!user) return res.status(404).json({ error: "User not found" });
res.json({ ...user, balance: user.totalEarned - user.totalSpent });
}));
res.json({ router.post("/admin/:userId/adjust", authenticate, requireAdmin, asyncHandler(async (req: AuthenticatedRequest, res) => {
hasWallet: true, const { amount, reason } = req.body;
address: user.walletAddress, if (!amount || Number.isNaN(Number(amount)) || Number(amount) === 0) return res.status(400).json({ error: "Valid amount required" });
balance: dbBalance, const user = await prisma.user.findUnique({ where: { id: req.params.userId } });
totalEarned: user.totalEarned, if (!user) return res.status(404).json({ error: "User not found" });
totalSpent: user.totalSpent, const delta = Number(amount);
}); await prisma.transaction.create({ data: { userId: user.id, type: "ADJUSTMENT", amount: Math.abs(delta), description: reason || "Admin adjustment", contentTitle: reason || "Admin adjustment" } });
}), await prisma.user.update({ where: { id: user.id }, data: delta > 0 ? { totalEarned: { increment: delta } } : { totalSpent: { increment: Math.abs(delta) } } });
); res.json({ success: true });
}));
router.post(
"/create",
authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
});
if (user?.walletAddress) {
return res.status(400).json({ error: "Wallet already exists" });
}
const wallet = createWallet();
const encryptedKey = encrypt(wallet.secretKey);
await prisma.user.update({
where: { id: req.user!.id },
data: {
walletAddress: wallet.publicKey,
encryptedPrivateKey: encryptedKey,
},
});
res.json({
success: true,
address: wallet.publicKey,
});
}),
);
router.post(
"/connect",
authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const { address } = req.body;
if (!address) {
return res.status(400).json({ error: "Address required" });
}
const existing = await prisma.user.findFirst({
where: { walletAddress: address },
});
if (existing && existing.id !== req.user!.id) {
return res.status(400).json({ error: "Wallet already in use" });
}
await prisma.user.update({
where: { id: req.user!.id },
data: { walletAddress: address },
});
res.json({ success: true, address });
}),
);
router.post(
"/backup",
authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
});
if (!user?.encryptedPrivateKey) {
return res.status(400).json({ error: "No wallet backup available" });
}
const privateKey = decrypt(user.encryptedPrivateKey);
res.json({ success: true, privateKey });
}),
);
router.get(
"/admin/:userId",
authenticate,
requireAdmin,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const user = await prisma.user.findUnique({
where: { id: req.params.userId },
select: {
id: true,
plexUsername: true,
email: true,
isAdmin: true,
isActive: true,
walletAddress: true,
totalEarned: true,
totalSpent: true,
watchTimeMinutes: true,
createdAt: true,
},
});
if (!user) {
return res.status(404).json({ error: "User not found" });
}
res.json({
...user,
balance: user.totalEarned - user.totalSpent,
});
}),
);
router.post(
"/admin/:userId/adjust",
authenticate,
requireAdmin,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const { amount, reason } = req.body;
if (!amount || Number.isNaN(Number(amount)) || Number(amount) === 0) {
return res.status(400).json({ error: "Valid amount required" });
}
const user = await prisma.user.findUnique({
where: { id: req.params.userId },
});
if (!user) {
return res.status(404).json({ error: "User not found" });
}
const delta = Number(amount);
const type = delta > 0 ? "BONUS" : "ADJUSTMENT";
await prisma.transaction.create({
data: {
userId: user.id,
type,
amount: Math.abs(delta),
description: reason || "Admin adjustment",
contentTitle: reason || "Admin adjustment",
},
});
await prisma.user.update({
where: { id: user.id },
data: {
totalEarned: delta > 0 ? { increment: delta } : undefined,
totalSpent: delta < 0 ? { increment: Math.abs(delta) } : undefined,
},
});
res.json({ success: true });
}),
);
export { router as walletRouter }; export { router as walletRouter };
-267
View File
@@ -1,267 +0,0 @@
import {
createBurnInstruction,
createMint,
createMintToInstruction,
getAccount,
getOrCreateAssociatedTokenAccount,
} from "@solana/spl-token";
import {
Connection,
Keypair,
PublicKey,
sendAndConfirmTransaction,
Transaction,
} from "@solana/web3.js";
import bs58 from "bs58";
import { prisma } from "../utils/prisma";
const RPC_URL = process.env.SOLANA_RPC_URL || "https://api.devnet.solana.com";
const DECIMALS = parseInt(process.env.SOLANA_TOKEN_DECIMALS || "6");
// Backend mint authority keypair (stored securely)
let mintAuthority: Keypair | null = null;
try {
if (process.env.SOLANA_MINT_AUTHORITY_KEYPAIR) {
const raw = process.env.SOLANA_MINT_AUTHORITY_KEYPAIR.trim();
let secretKey: Uint8Array;
if (raw.startsWith("[")) {
secretKey = new Uint8Array(JSON.parse(raw));
} else {
secretKey = bs58.decode(raw);
}
mintAuthority = Keypair.fromSecretKey(secretKey);
}
} catch (error) {
console.warn("Mint authority not configured");
}
export const connection = new Connection(RPC_URL, "confirmed");
let cachedMint: PublicKey | null = null;
// Get or create token mint address
export async function getTokenMint(): Promise<PublicKey | null> {
try {
// Return cached mint
if (cachedMint) {
return cachedMint;
}
// Check env first
if (process.env.SOLANA_MINT_ADDRESS) {
cachedMint = new PublicKey(process.env.SOLANA_MINT_ADDRESS);
return cachedMint;
}
// Auto-create mint on devnet if authority exists
if (mintAuthority && RPC_URL.includes("devnet")) {
try {
// Fund mint authority with SOL first
const balance = await connection.getBalance(mintAuthority.publicKey);
if (balance < 500000000) {
const signature = await connection.requestAirdrop(
mintAuthority.publicKey,
2 * 1000000000,
);
await connection.confirmTransaction(signature);
}
const mintKeypair = Keypair.generate();
const mint = await createMint(
connection,
mintAuthority,
mintAuthority.publicKey,
null,
DECIMALS,
mintKeypair,
);
cachedMint = mint;
console.log(`Token mint created: ${mint.toBase58()}`);
console.log(
`Add SOLANA_MINT_ADDRESS=${mint.toBase58()} to your .env to persist it`,
);
return mint;
} catch {
// Devnet faucet rate-limited, will use DB-only credits
return null;
}
}
return null;
} catch (error) {
console.error("Failed to get token mint:", error);
return null;
}
}
// Create a new Solana wallet for a user
export function createWallet(): { publicKey: string; secretKey: string } {
const keypair = Keypair.generate();
return {
publicKey: keypair.publicKey.toBase58(),
secretKey: bs58.encode(keypair.secretKey),
};
}
// Get or create token account for user
export async function getOrCreateTokenAccount(
userPublicKey: PublicKey,
mint: PublicKey,
): Promise<PublicKey> {
const tokenAccount = await getOrCreateAssociatedTokenAccount(
connection,
mintAuthority!, // payer
mint,
userPublicKey,
);
return tokenAccount.address;
}
// Mint tokens to user (called by backend after watch event)
export async function mintTokens(
userWalletAddress: string,
amount: number,
_metadata: {
sessionId: string;
contentTitle: string;
watchDurationMinutes: number;
},
): Promise<string | null> {
if (!mintAuthority) {
console.warn("Mint authority not configured, using DB-only credits");
return `db-only-${Date.now()}`;
}
try {
const userPublicKey = new PublicKey(userWalletAddress);
const mint = await getTokenMint();
if (!mint) {
console.warn("Token mint not available, using DB-only credits");
return `db-only-${Date.now()}`;
}
// Get or create user's token account
const tokenAccount = await getOrCreateTokenAccount(userPublicKey, mint);
// Calculate amount with decimals
const amountWithDecimals = amount * 10 ** DECIMALS;
// Create mint instruction
const mintInstruction = createMintToInstruction(
mint,
tokenAccount,
mintAuthority.publicKey,
BigInt(Math.floor(amountWithDecimals)),
);
// Create and send transaction
const transaction = new Transaction().add(mintInstruction);
const signature = await sendAndConfirmTransaction(connection, transaction, [
mintAuthority,
]);
console.log(`Minted ${amount} COOP to ${userWalletAddress}: ${signature}`);
return signature;
} catch (error) {
console.error("Solana minting failed, using DB-only credits:", error);
return `db-only-${Date.now()}`;
}
}
// Burn tokens from user (called when content request is approved)
export async function burnTokens(
userWalletAddress: string,
userSecretKey: string,
amount: number,
): Promise<string | null> {
try {
const userKeypair = Keypair.fromSecretKey(bs58.decode(userSecretKey));
const mint = await getTokenMint();
if (!mint) {
throw new Error("Token mint not found");
}
const userPublicKey = new PublicKey(userWalletAddress);
const tokenAccount = await getOrCreateTokenAccount(userPublicKey, mint);
// Check balance
const accountInfo = await getAccount(connection, tokenAccount);
const amountWithDecimals = BigInt(Math.floor(amount * 10 ** DECIMALS));
if (accountInfo.amount < amountWithDecimals) {
throw new Error("Insufficient balance");
}
// Create burn instruction
const burnInstruction = createBurnInstruction(
tokenAccount,
mint,
userKeypair.publicKey,
amountWithDecimals,
);
const transaction = new Transaction().add(burnInstruction);
const signature = await sendAndConfirmTransaction(connection, transaction, [
userKeypair,
]);
console.log(
`Burned ${amount} COOP from ${userWalletAddress}: ${signature}`,
);
return signature;
} catch (error) {
console.error("Failed to burn tokens:", error);
return null;
}
}
// Get token balance for user
export async function getTokenBalance(walletAddress: string): Promise<number> {
try {
const mint = await getTokenMint();
if (!mint) return 0;
const userPublicKey = new PublicKey(walletAddress);
try {
const tokenAccount = await getOrCreateAssociatedTokenAccount(
connection,
mintAuthority!,
mint,
userPublicKey,
);
const accountInfo = await getAccount(connection, tokenAccount.address);
return Number(accountInfo.amount) / 10 ** DECIMALS;
} catch {
return 0;
}
} catch (error) {
console.error("Failed to get token balance:", error);
return 0;
}
}
// Request airdrop for testing (devnet only)
export async function requestAirdrop(
walletAddress: string,
): Promise<string | null> {
try {
const publicKey = new PublicKey(walletAddress);
const signature = await connection.requestAirdrop(
publicKey,
2 * 1000000000,
); // 2 SOL
await connection.confirmTransaction(signature);
return signature;
} catch (error) {
console.error("Airdrop failed:", error);
return null;
}
}
+1 -3
View File
@@ -46,8 +46,7 @@ interface User {
email: string | null; email: string | null;
isAdmin: boolean; isAdmin: boolean;
isActive: boolean; isActive: boolean;
walletAddress: string | null; totalEarned: number;
totalEarned: number;
totalSpent: number; totalSpent: number;
watchTimeMinutes: number; watchTimeMinutes: number;
createdAt: string; createdAt: string;
@@ -256,7 +255,6 @@ export default function AdminPage() {
<div className="flex gap-2 mt-1"> <div className="flex gap-2 mt-1">
{u.isAdmin && <Badge variant="default">Admin</Badge>} {u.isAdmin && <Badge variant="default">Admin</Badge>}
{!u.isActive && <Badge variant="destructive">Inactive</Badge>} {!u.isActive && <Badge variant="destructive">Inactive</Badge>}
{u.walletAddress && <Badge variant="secondary">Wallet</Badge>}
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
+4 -9
View File
@@ -8,8 +8,7 @@ import {
Film, Film,
History, History,
TrendingDown, TrendingDown,
TrendingUp, TrendingUp
Wallet,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -24,7 +23,7 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { overseerApi, transactionApi, walletApi } from "@/lib/api"; import { overseerApi, transactionApi } from "@/lib/api";
import { useSocket } from "@/lib/socket"; import { useSocket } from "@/lib/socket";
import { useStore } from "@/lib/store"; import { useStore } from "@/lib/store";
import { formatNumber, truncateAddress } from "@/lib/utils"; import { formatNumber, truncateAddress } from "@/lib/utils";
@@ -50,9 +49,7 @@ export default function DashboardPage() {
const router = useRouter(); const router = useRouter();
const { user, isAuthenticated, logout } = useStore(); const { user, isAuthenticated, logout } = useStore();
const [wallet, setWallet] = useState<WalletData | null>(null); const [wallet, setWallet] = useState<WalletData | null>(null);
const [requestCosts, setRequestCosts] = useState({ movie: 100, tv: 200 }); const [requestCosts, setRequestCosts] = useState({ movie: 100, tv: 200 }); const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const socket = useSocket(); const socket = useSocket();
@@ -138,9 +135,7 @@ export default function DashboardPage() {
)} )}
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="wallet-adapter-custom-wrapper hidden md:block"> <div className="wallet-adapter-custom-wrapper hidden md:block"> </div>
<WalletMultiButton className="!h-9 !px-4 !text-sm !rounded-md !bg-secondary !text-secondary-foreground hover:!bg-secondary/80" />
</div>
<Button variant="ghost" onClick={logout}> <Button variant="ghost" onClick={logout}>
Sign Out Sign Out
</Button> </Button>
+22 -23
View File
@@ -1,32 +1,31 @@
import { create } from 'zustand'; import { create } from "zustand";
export interface User { export interface User {
id: string; id: string;
plexId: string; plexId: string;
plexUsername: string; plexUsername: string;
email: string | null; email: string | null;
isAdmin: boolean; isAdmin: boolean;
walletAddress: string | null; totalEarned: number;
totalEarned: number; totalSpent: number;
totalSpent: number;
} }
interface AppState { interface AppState {
user: User | null; user: User | null;
token: string | null; token: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
setUser: (user: User | null) => void; setUser: (user: User | null) => void;
setToken: (token: string | null) => void; setToken: (token: string | null) => void;
logout: () => void; logout: () => void;
} }
export const useStore = create<AppState>()((set) => ({ export const useStore = create<AppState>()((set) => ({
user: null, user: null,
token: null, token: null,
isAuthenticated: false, isAuthenticated: false,
setUser: (user) => set({ user, isAuthenticated: !!user }), setUser: (user) => set({ user, isAuthenticated: !!user }),
setToken: (token) => set({ token }), setToken: (token) => set({ token }),
logout: () => { logout: () => {
set({ user: null, token: null, isAuthenticated: false }); set({ user: null, token: null, isAuthenticated: false });
}, },
})); }));
File diff suppressed because one or more lines are too long