Implement backend API and database services in Docker setup
- Added a new `api` service for the NestJS backend, including health checks and dependencies on PostgreSQL, Redis, and MinIO. - Introduced PostgreSQL and Redis services with health checks and configurations for data persistence. - Added MinIO for S3-compatible object storage and a one-shot service to initialize required buckets. - Updated the Nginx configuration to proxy requests to the new backend API and MinIO storage. - Enhanced the Dockerfile to support the new API environment variables and configurations. - Updated the `package.json` and `package-lock.json` to include new dependencies for QR code generation and other utilities. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -55,9 +55,9 @@ class ApiService {
|
||||
return this.client(originalRequest)
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// Token refresh failed - clear auth and redirect to login
|
||||
// Token refresh failed - clear auth and redirect to home
|
||||
this.clearAuth()
|
||||
window.location.href = '/login'
|
||||
window.location.href = '/'
|
||||
return Promise.reject(refreshError)
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,7 @@ class ApiService {
|
||||
public clearAuth() {
|
||||
sessionStorage.removeItem('auth_token')
|
||||
sessionStorage.removeItem('nostr_token')
|
||||
sessionStorage.removeItem('nostr_pubkey')
|
||||
sessionStorage.removeItem('refresh_token')
|
||||
}
|
||||
|
||||
@@ -119,13 +120,13 @@ class ApiService {
|
||||
throw new Error('No refresh token available')
|
||||
}
|
||||
|
||||
// Call refresh endpoint (implement based on backend)
|
||||
const response = await axios.post(`${apiConfig.baseURL}/auth/refresh`, {
|
||||
// Call Nostr refresh endpoint
|
||||
const response = await axios.post(`${apiConfig.baseURL}/auth/nostr/refresh`, {
|
||||
refreshToken,
|
||||
})
|
||||
|
||||
const newToken = response.data.accessToken
|
||||
this.setToken(newToken, 'cognito')
|
||||
this.setToken(newToken, 'nostr')
|
||||
|
||||
if (response.data.refreshToken) {
|
||||
sessionStorage.setItem('refresh_token', response.data.refreshToken)
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import axios from 'axios'
|
||||
import { apiService } from './api.service'
|
||||
import { nip98Service } from './nip98.service'
|
||||
import { apiConfig } from '../config/api.config'
|
||||
import { accountManager } from '../lib/accounts'
|
||||
import type { EventTemplate } from 'nostr-tools'
|
||||
import type {
|
||||
LoginCredentials,
|
||||
RegisterData,
|
||||
@@ -8,6 +13,48 @@ import type {
|
||||
ApiUser,
|
||||
} from '../types/api'
|
||||
|
||||
/**
|
||||
* Create a NIP-98 HTTP Auth event (kind 27235) and return it as
|
||||
* a base64-encoded Authorization header value: `Nostr <base64>`
|
||||
*/
|
||||
async function createNip98AuthHeader(
|
||||
url: string,
|
||||
method: string,
|
||||
body?: string,
|
||||
): Promise<string> {
|
||||
const account = accountManager.active
|
||||
if (!account) throw new Error('No active Nostr account')
|
||||
|
||||
const tags: string[][] = [
|
||||
['u', url],
|
||||
['method', method.toUpperCase()],
|
||||
]
|
||||
|
||||
// If there's a body, include its SHA-256 hash
|
||||
if (body && body.length > 0) {
|
||||
const encoder = new TextEncoder()
|
||||
const hash = await crypto.subtle.digest('SHA-256', encoder.encode(body))
|
||||
const hashHex = Array.from(new Uint8Array(hash))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
tags.push(['payload', hashHex])
|
||||
}
|
||||
|
||||
const template: EventTemplate = {
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: '',
|
||||
}
|
||||
|
||||
// Sign with the account's signer
|
||||
const signed = await account.signer.signEvent(template)
|
||||
|
||||
// Base64-encode the signed event JSON
|
||||
const b64 = btoa(JSON.stringify(signed))
|
||||
return `Nostr ${b64}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication Service
|
||||
* Handles Cognito and Nostr authentication
|
||||
@@ -68,17 +115,41 @@ class AuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Nostr session
|
||||
* Create Nostr session via NIP-98 HTTP Auth.
|
||||
* Signs a kind-27235 event and sends it as the Authorization header.
|
||||
*/
|
||||
async createNostrSession(request: NostrSessionRequest): Promise<NostrSessionResponse> {
|
||||
const response = await apiService.post<NostrSessionResponse>('/auth/nostr/session', request)
|
||||
async createNostrSession(_request: NostrSessionRequest): Promise<NostrSessionResponse> {
|
||||
const url = `${apiConfig.baseURL}/auth/nostr/session`
|
||||
const method = 'POST'
|
||||
|
||||
// Create NIP-98 auth header — no body is sent
|
||||
const authHeader = await createNip98AuthHeader(url, method)
|
||||
|
||||
// Send the request without a body.
|
||||
// We use axios({ method }) instead of axios.post(url, data) to
|
||||
// guarantee no Content-Type or body is serialized.
|
||||
const response = await axios<NostrSessionResponse>({
|
||||
method: 'POST',
|
||||
url,
|
||||
headers: { Authorization: authHeader },
|
||||
timeout: apiConfig.timeout,
|
||||
})
|
||||
|
||||
// Store Nostr token
|
||||
if (response.token) {
|
||||
apiService.setToken(response.token, 'nostr')
|
||||
const data = response.data
|
||||
|
||||
// Map accessToken to token for convenience
|
||||
data.token = data.accessToken
|
||||
|
||||
// Store Nostr JWT for subsequent authenticated requests
|
||||
if (data.accessToken) {
|
||||
apiService.setToken(data.accessToken, 'nostr')
|
||||
sessionStorage.setItem('nostr_token', data.accessToken)
|
||||
|
||||
// Also populate nip98Service storage so IndeehubApiService can find the token
|
||||
nip98Service.storeTokens(data.accessToken, data.refreshToken, data.expiresIn)
|
||||
}
|
||||
|
||||
return response
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
382
src/services/filmmaker.service.ts
Normal file
382
src/services/filmmaker.service.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* Filmmaker Service
|
||||
*
|
||||
* Source-aware API client for all filmmaker/creator endpoints.
|
||||
* Routes calls to either the original API (api.service) or
|
||||
* our self-hosted backend (indeehub-api.service) based on the
|
||||
* active content source.
|
||||
*/
|
||||
|
||||
import { apiService } from './api.service'
|
||||
import { indeehubApiService } from './indeehub-api.service'
|
||||
import { useContentSourceStore } from '../stores/contentSource'
|
||||
import type {
|
||||
ApiProject,
|
||||
ApiContent,
|
||||
ApiSeason,
|
||||
ApiFilmmakerAnalytics,
|
||||
ApiWatchAnalytics,
|
||||
ApiPaymentMethod,
|
||||
ApiPayment,
|
||||
ApiGenre,
|
||||
CreateProjectData,
|
||||
UpdateProjectData,
|
||||
UploadInitResponse,
|
||||
UploadPresignedUrlsResponse,
|
||||
} from '../types/api'
|
||||
|
||||
/**
|
||||
* Check if we should route requests to our self-hosted backend
|
||||
*/
|
||||
function usesSelfHosted(): boolean {
|
||||
const store = useContentSourceStore()
|
||||
return store.activeSource === 'indeehub-api'
|
||||
}
|
||||
|
||||
// ── Projects ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getPrivateProjects(filters?: {
|
||||
type?: string
|
||||
status?: string
|
||||
search?: string
|
||||
sort?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<ApiProject[]> {
|
||||
if (usesSelfHosted()) {
|
||||
const params = new URLSearchParams()
|
||||
if (filters?.type) params.append('type', filters.type)
|
||||
if (filters?.status) params.append('status', filters.status)
|
||||
if (filters?.search) params.append('search', filters.search)
|
||||
if (filters?.sort) params.append('sort', filters.sort)
|
||||
if (filters?.limit) params.append('limit', String(filters.limit))
|
||||
if (filters?.offset) params.append('offset', String(filters.offset))
|
||||
const qs = params.toString()
|
||||
return indeehubApiService.get<ApiProject[]>(`/projects/private${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
return apiService.get<ApiProject[]>('/projects/private', { params: filters })
|
||||
}
|
||||
|
||||
export async function getPrivateProjectsCount(filters?: {
|
||||
type?: string
|
||||
status?: string
|
||||
search?: string
|
||||
}): Promise<number> {
|
||||
if (usesSelfHosted()) {
|
||||
const params = new URLSearchParams()
|
||||
if (filters?.type) params.append('type', filters.type)
|
||||
if (filters?.status) params.append('status', filters.status)
|
||||
if (filters?.search) params.append('search', filters.search)
|
||||
const qs = params.toString()
|
||||
const res = await indeehubApiService.get<{ count: number }>(`/projects/private/count${qs ? `?${qs}` : ''}`)
|
||||
return res.count
|
||||
}
|
||||
const res = await apiService.get<{ count: number }>('/projects/private/count', { params: filters })
|
||||
return res.count
|
||||
}
|
||||
|
||||
export async function getPrivateProject(id: string): Promise<ApiProject> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.get<ApiProject>(`/projects/private/${id}`)
|
||||
}
|
||||
return apiService.get<ApiProject>(`/projects/private/${id}`)
|
||||
}
|
||||
|
||||
export async function createProject(data: CreateProjectData): Promise<ApiProject> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.post<ApiProject>('/projects', data)
|
||||
}
|
||||
return apiService.post<ApiProject>('/projects', data)
|
||||
}
|
||||
|
||||
export async function updateProject(id: string, data: UpdateProjectData): Promise<ApiProject> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.patch<ApiProject>(`/projects/${id}`, data)
|
||||
}
|
||||
return apiService.patch<ApiProject>(`/projects/${id}`, data)
|
||||
}
|
||||
|
||||
export async function deleteProject(id: string): Promise<void> {
|
||||
if (usesSelfHosted()) {
|
||||
await indeehubApiService.delete(`/projects/${id}`)
|
||||
return
|
||||
}
|
||||
await apiService.delete(`/projects/${id}`)
|
||||
}
|
||||
|
||||
export async function checkSlugExists(projectId: string, slug: string): Promise<boolean> {
|
||||
if (usesSelfHosted()) {
|
||||
const res = await indeehubApiService.get<{ exists: boolean }>(`/projects/${projectId}/slug/${slug}/exists`)
|
||||
return res.exists
|
||||
}
|
||||
const res = await apiService.get<{ exists: boolean }>(`/projects/${projectId}/slug/${slug}/exists`)
|
||||
return res.exists
|
||||
}
|
||||
|
||||
// ── Content (episodes/seasons) ──────────────────────────────────────────────────
|
||||
|
||||
export async function getContents(projectId: string): Promise<ApiContent[]> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.get<ApiContent[]>(`/contents/project/${projectId}`)
|
||||
}
|
||||
return apiService.get<ApiContent[]>(`/contents/project/${projectId}`)
|
||||
}
|
||||
|
||||
export async function upsertContent(
|
||||
projectId: string,
|
||||
data: Partial<ApiContent>
|
||||
): Promise<ApiContent> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.patch<ApiContent>(`/contents/project/${projectId}`, data)
|
||||
}
|
||||
return apiService.patch<ApiContent>(`/contents/project/${projectId}`, data)
|
||||
}
|
||||
|
||||
export async function getSeasons(projectId: string): Promise<ApiSeason[]> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.get<ApiSeason[]>(`/seasons/project/${projectId}`)
|
||||
}
|
||||
return apiService.get<ApiSeason[]>(`/seasons/project/${projectId}`)
|
||||
}
|
||||
|
||||
// ── Upload ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function initializeUpload(
|
||||
key: string,
|
||||
bucket: string,
|
||||
contentType: string
|
||||
): Promise<UploadInitResponse> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.post<UploadInitResponse>('/upload/initialize', {
|
||||
Key: key,
|
||||
Bucket: bucket,
|
||||
ContentType: contentType,
|
||||
})
|
||||
}
|
||||
return apiService.post<UploadInitResponse>('/upload/initialize', {
|
||||
Key: key,
|
||||
Bucket: bucket,
|
||||
ContentType: contentType,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getPresignedUrls(
|
||||
uploadId: string,
|
||||
key: string,
|
||||
bucket: string,
|
||||
parts: number
|
||||
): Promise<UploadPresignedUrlsResponse> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.post<UploadPresignedUrlsResponse>('/upload/presigned-urls', {
|
||||
UploadId: uploadId,
|
||||
Key: key,
|
||||
Bucket: bucket,
|
||||
parts,
|
||||
})
|
||||
}
|
||||
return apiService.post<UploadPresignedUrlsResponse>('/upload/presigned-urls', {
|
||||
UploadId: uploadId,
|
||||
Key: key,
|
||||
Bucket: bucket,
|
||||
parts,
|
||||
})
|
||||
}
|
||||
|
||||
export async function finalizeUpload(
|
||||
uploadId: string,
|
||||
key: string,
|
||||
bucket: string,
|
||||
parts: Array<{ PartNumber: number; ETag: string }>
|
||||
): Promise<void> {
|
||||
const payload = { UploadId: uploadId, Key: key, Bucket: bucket, parts }
|
||||
if (usesSelfHosted()) {
|
||||
await indeehubApiService.post('/upload/finalize', payload)
|
||||
return
|
||||
}
|
||||
await apiService.post('/upload/finalize', payload)
|
||||
}
|
||||
|
||||
// ── Analytics ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getFilmmakerAnalytics(): Promise<ApiFilmmakerAnalytics> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.get<ApiFilmmakerAnalytics>('/filmmakers/analytics')
|
||||
}
|
||||
return apiService.get<ApiFilmmakerAnalytics>('/filmmakers/analytics')
|
||||
}
|
||||
|
||||
export async function getWatchAnalytics(
|
||||
projectIds?: string[],
|
||||
dateRange?: { start: string; end: string }
|
||||
): Promise<ApiWatchAnalytics> {
|
||||
const params: Record<string, string> = {}
|
||||
if (projectIds?.length) params['projectIds'] = projectIds.join(',')
|
||||
if (dateRange) {
|
||||
params['start'] = dateRange.start
|
||||
params['end'] = dateRange.end
|
||||
}
|
||||
|
||||
if (usesSelfHosted()) {
|
||||
const qs = new URLSearchParams(params).toString()
|
||||
return indeehubApiService.get<ApiWatchAnalytics>(`/filmmakers/watch-analytics${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
return apiService.get<ApiWatchAnalytics>('/filmmakers/watch-analytics', { params })
|
||||
}
|
||||
|
||||
export async function getProjectRevenue(
|
||||
projectIds: string[],
|
||||
dateRange?: { start: string; end: string }
|
||||
): Promise<Record<string, number>> {
|
||||
const params: Record<string, string> = { ids: projectIds.join(',') }
|
||||
if (dateRange) {
|
||||
params['start'] = dateRange.start
|
||||
params['end'] = dateRange.end
|
||||
}
|
||||
|
||||
if (usesSelfHosted()) {
|
||||
const qs = new URLSearchParams(params).toString()
|
||||
return indeehubApiService.get<Record<string, number>>(`/projects/revenue${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
return apiService.get<Record<string, number>>('/projects/revenue', { params })
|
||||
}
|
||||
|
||||
// ── Payments / Withdrawal ───────────────────────────────────────────────────────
|
||||
|
||||
export async function getPaymentMethods(): Promise<ApiPaymentMethod[]> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.get<ApiPaymentMethod[]>('/payment-methods')
|
||||
}
|
||||
return apiService.get<ApiPaymentMethod[]>('/payment-methods')
|
||||
}
|
||||
|
||||
export async function addPaymentMethod(data: {
|
||||
type: 'lightning' | 'bank'
|
||||
lightningAddress?: string
|
||||
bankName?: string
|
||||
accountNumber?: string
|
||||
routingNumber?: string
|
||||
withdrawalFrequency: 'manual' | 'weekly' | 'monthly'
|
||||
}): Promise<ApiPaymentMethod> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.post<ApiPaymentMethod>('/payment-methods', data)
|
||||
}
|
||||
return apiService.post<ApiPaymentMethod>('/payment-methods', data)
|
||||
}
|
||||
|
||||
export async function updatePaymentMethod(
|
||||
id: string,
|
||||
data: Partial<ApiPaymentMethod>
|
||||
): Promise<ApiPaymentMethod> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.patch<ApiPaymentMethod>(`/payment-methods/${id}`, data)
|
||||
}
|
||||
return apiService.patch<ApiPaymentMethod>(`/payment-methods/${id}`, data)
|
||||
}
|
||||
|
||||
export async function selectPaymentMethod(id: string): Promise<void> {
|
||||
if (usesSelfHosted()) {
|
||||
await indeehubApiService.patch(`/payment-methods/${id}/select`, {})
|
||||
return
|
||||
}
|
||||
await apiService.patch(`/payment-methods/${id}/select`, {})
|
||||
}
|
||||
|
||||
export async function removePaymentMethod(id: string): Promise<void> {
|
||||
if (usesSelfHosted()) {
|
||||
await indeehubApiService.delete(`/payment-methods/${id}`)
|
||||
return
|
||||
}
|
||||
await apiService.delete(`/payment-methods/${id}`)
|
||||
}
|
||||
|
||||
export async function validateLightningAddress(address: string): Promise<boolean> {
|
||||
if (usesSelfHosted()) {
|
||||
const res = await indeehubApiService.post<{ valid: boolean }>('/payment/validate-lightning-address', { address })
|
||||
return res.valid
|
||||
}
|
||||
const res = await apiService.post<{ valid: boolean }>('/payment/validate-lightning-address', { address })
|
||||
return res.valid
|
||||
}
|
||||
|
||||
export async function getPayments(filmId?: string): Promise<ApiPayment[]> {
|
||||
const params = filmId ? { filmId } : undefined
|
||||
if (usesSelfHosted()) {
|
||||
const qs = filmId ? `?filmId=${filmId}` : ''
|
||||
return indeehubApiService.get<ApiPayment[]>(`/payment${qs}`)
|
||||
}
|
||||
return apiService.get<ApiPayment[]>('/payment', { params })
|
||||
}
|
||||
|
||||
export async function withdraw(amount: number, filmId?: string): Promise<ApiPayment> {
|
||||
const payload: Record<string, any> = { amount }
|
||||
if (filmId) payload.filmId = filmId
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.post<ApiPayment>('/payment/bank-payout', payload)
|
||||
}
|
||||
return apiService.post<ApiPayment>('/payment/bank-payout', payload)
|
||||
}
|
||||
|
||||
// ── Genres ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getGenres(): Promise<ApiGenre[]> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.get<ApiGenre[]>('/genres')
|
||||
}
|
||||
return apiService.get<ApiGenre[]>('/genres')
|
||||
}
|
||||
|
||||
// ── SAT Price ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getSatPrice(): Promise<number> {
|
||||
if (usesSelfHosted()) {
|
||||
const res = await indeehubApiService.get<{ price: number }>('/payment/sat-price')
|
||||
return res.price
|
||||
}
|
||||
const res = await apiService.get<{ price: number }>('/payment/sat-price')
|
||||
return res.price
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouped export for convenience
|
||||
*/
|
||||
export const filmmakerService = {
|
||||
// Projects
|
||||
getPrivateProjects,
|
||||
getPrivateProjectsCount,
|
||||
getPrivateProject,
|
||||
createProject,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
checkSlugExists,
|
||||
|
||||
// Content
|
||||
getContents,
|
||||
upsertContent,
|
||||
getSeasons,
|
||||
|
||||
// Upload
|
||||
initializeUpload,
|
||||
getPresignedUrls,
|
||||
finalizeUpload,
|
||||
|
||||
// Analytics
|
||||
getFilmmakerAnalytics,
|
||||
getWatchAnalytics,
|
||||
getProjectRevenue,
|
||||
|
||||
// Payments
|
||||
getPaymentMethods,
|
||||
addPaymentMethod,
|
||||
updatePaymentMethod,
|
||||
selectPaymentMethod,
|
||||
removePaymentMethod,
|
||||
validateLightningAddress,
|
||||
getPayments,
|
||||
withdraw,
|
||||
|
||||
// Genres
|
||||
getGenres,
|
||||
|
||||
// SAT Price
|
||||
getSatPrice,
|
||||
}
|
||||
190
src/services/indeehub-api.service.ts
Normal file
190
src/services/indeehub-api.service.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* IndeeHub Self-Hosted API Service
|
||||
*
|
||||
* Dedicated API client for our self-hosted NestJS backend.
|
||||
* Uses the /api/ proxy configured in nginx.
|
||||
* Auth tokens are managed by nip98.service.ts.
|
||||
*/
|
||||
|
||||
import axios, { type AxiosInstance } from 'axios'
|
||||
import { indeehubApiConfig } from '../config/api.config'
|
||||
import { nip98Service } from './nip98.service'
|
||||
|
||||
class IndeehubApiService {
|
||||
private client: AxiosInstance
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: indeehubApiConfig.baseURL,
|
||||
timeout: indeehubApiConfig.timeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Attach JWT token from NIP-98 session
|
||||
this.client.interceptors.request.use((config) => {
|
||||
const token = nip98Service.accessToken
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Auto-refresh on 401
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true
|
||||
const newToken = await nip98Service.refresh()
|
||||
if (newToken) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
return this.client(originalRequest)
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic typed GET request
|
||||
*/
|
||||
async get<T>(url: string): Promise<T> {
|
||||
const response = await this.client.get<T>(url)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic typed POST request
|
||||
*/
|
||||
async post<T>(url: string, data?: any): Promise<T> {
|
||||
const response = await this.client.post<T>(url, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic typed PATCH request
|
||||
*/
|
||||
async patch<T>(url: string, data?: any): Promise<T> {
|
||||
const response = await this.client.patch<T>(url, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic typed DELETE request
|
||||
*/
|
||||
async delete<T = void>(url: string): Promise<T> {
|
||||
const response = await this.client.delete<T>(url)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the API is reachable
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
await this.client.get('/nostr-auth/health', { timeout: 5000 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all published projects
|
||||
*/
|
||||
async getProjects(filters?: {
|
||||
status?: string
|
||||
type?: string
|
||||
genre?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<any[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (filters?.status) params.append('status', filters.status)
|
||||
if (filters?.type) params.append('type', filters.type)
|
||||
if (filters?.genre) params.append('genre', filters.genre)
|
||||
if (filters?.limit) params.append('limit', String(filters.limit))
|
||||
if (filters?.offset) params.append('offset', String(filters.offset))
|
||||
|
||||
const url = `/projects${params.toString() ? `?${params.toString()}` : ''}`
|
||||
const response = await this.client.get(url)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single project by ID
|
||||
*/
|
||||
async getProject(id: string): Promise<any> {
|
||||
const response = await this.client.get(`/projects/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get streaming URL for a content item
|
||||
* Returns different data based on deliveryMode (native vs partner)
|
||||
*/
|
||||
async getStreamingUrl(contentId: string): Promise<{
|
||||
url: string
|
||||
deliveryMode: 'native' | 'partner'
|
||||
keyUrl?: string
|
||||
drmToken?: string
|
||||
}> {
|
||||
const response = await this.client.get(`/contents/${contentId}/stream`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's library items
|
||||
*/
|
||||
async getLibrary(): Promise<any[]> {
|
||||
const response = await this.client.get('/library')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a project to user's library
|
||||
*/
|
||||
async addToLibrary(projectId: string): Promise<any> {
|
||||
const response = await this.client.post('/library', { projectId })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a project from user's library
|
||||
*/
|
||||
async removeFromLibrary(projectId: string): Promise<void> {
|
||||
await this.client.delete(`/library/${projectId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user profile (requires auth)
|
||||
*/
|
||||
async getMe(): Promise<any> {
|
||||
const response = await this.client.get('/auth/me')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get genres
|
||||
*/
|
||||
async getGenres(): Promise<any[]> {
|
||||
const response = await this.client.get('/genres')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CDN URL for a storage path
|
||||
*/
|
||||
getCdnUrl(path: string): string {
|
||||
if (!path) return ''
|
||||
if (path.startsWith('http')) return path
|
||||
if (path.startsWith('/')) return path
|
||||
return `${indeehubApiConfig.cdnURL}/${path}`
|
||||
}
|
||||
}
|
||||
|
||||
export const indeehubApiService = new IndeehubApiService()
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiService } from './api.service'
|
||||
import type { ApiRent, ApiContent } from '../types/api'
|
||||
import { USE_MOCK } from '../utils/mock'
|
||||
|
||||
/**
|
||||
* Library Service
|
||||
@@ -14,10 +15,7 @@ class LibraryService {
|
||||
rented: ApiRent[]
|
||||
continueWatching: Array<{ content: ApiContent; progress: number }>
|
||||
}> {
|
||||
// Check if we're in development mode
|
||||
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
|
||||
|
||||
if (useMockData) {
|
||||
if (USE_MOCK) {
|
||||
// Mock library data for development
|
||||
console.log('🔧 Development mode: Using mock library data')
|
||||
|
||||
@@ -81,15 +79,31 @@ class LibraryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rent content
|
||||
* Rent content via Lightning invoice.
|
||||
* Calls POST /rents/lightning which creates a BTCPay invoice.
|
||||
* Returns the invoice details including BOLT11 for QR code display.
|
||||
*/
|
||||
async rentContent(contentId: string, paymentMethodId?: string): Promise<ApiRent> {
|
||||
return apiService.post<ApiRent>('/rents', {
|
||||
contentId,
|
||||
paymentMethodId,
|
||||
async rentContent(contentId: string, couponCode?: string): Promise<{
|
||||
id: string
|
||||
contentId: string
|
||||
lnInvoice: string
|
||||
expiration: string
|
||||
sourceAmount: { amount: string; currency: string }
|
||||
conversionRate: { amount: string; sourceCurrency: string; targetCurrency: string }
|
||||
}> {
|
||||
return apiService.post('/rents/lightning', {
|
||||
id: contentId,
|
||||
couponCode,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a rent exists for a given content ID (for polling after payment).
|
||||
*/
|
||||
async checkRentExists(contentId: string): Promise<{ exists: boolean }> {
|
||||
return apiService.get(`/rents/content/${contentId}/exists`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to content
|
||||
*/
|
||||
|
||||
172
src/services/nip98.service.ts
Normal file
172
src/services/nip98.service.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* NIP-98 Authentication Bridge
|
||||
*
|
||||
* Handles Nostr-based authentication with the self-hosted backend:
|
||||
* 1. Signs NIP-98 HTTP Auth events (kind 27235)
|
||||
* 2. Exchanges them for JWT session tokens
|
||||
* 3. Manages token storage and auto-refresh
|
||||
*/
|
||||
|
||||
import axios from 'axios'
|
||||
import { indeehubApiConfig } from '../config/api.config'
|
||||
|
||||
const TOKEN_KEY = 'indeehub_api_token'
|
||||
const REFRESH_KEY = 'indeehub_api_refresh'
|
||||
const EXPIRES_KEY = 'indeehub_api_expires'
|
||||
|
||||
class Nip98Service {
|
||||
private refreshPromise: Promise<string> | null = null
|
||||
|
||||
/**
|
||||
* Check if we have a valid (non-expired) API token
|
||||
*/
|
||||
get hasValidToken(): boolean {
|
||||
const token = sessionStorage.getItem(TOKEN_KEY)
|
||||
const expires = sessionStorage.getItem(EXPIRES_KEY)
|
||||
if (!token || !expires) return false
|
||||
return Date.now() < Number(expires)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current access token
|
||||
*/
|
||||
get accessToken(): string | null {
|
||||
if (!this.hasValidToken) return null
|
||||
return sessionStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session with the backend using a NIP-98 auth event.
|
||||
*
|
||||
* The signer creates a kind 27235 event targeting POST /auth/nostr/session.
|
||||
* The backend verifies the event and returns JWT tokens.
|
||||
*
|
||||
* @param signer - An applesauce signer instance (or any object with sign() method)
|
||||
* @param pubkey - The Nostr pubkey (hex)
|
||||
*/
|
||||
async createSession(signer: any, pubkey: string): Promise<boolean> {
|
||||
try {
|
||||
const url = `${indeehubApiConfig.baseURL}/auth/nostr/session`
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
// Build the NIP-98 event
|
||||
const event = {
|
||||
kind: 27235,
|
||||
created_at: now,
|
||||
tags: [
|
||||
['u', url],
|
||||
['method', 'POST'],
|
||||
],
|
||||
content: '',
|
||||
pubkey,
|
||||
}
|
||||
|
||||
// Sign the event using the Nostr signer
|
||||
let signedEvent: any
|
||||
if (typeof signer.sign === 'function') {
|
||||
signedEvent = await signer.sign(event)
|
||||
} else if (typeof signer.signEvent === 'function') {
|
||||
signedEvent = await signer.signEvent(event)
|
||||
} else {
|
||||
throw new Error('Signer does not have a sign or signEvent method')
|
||||
}
|
||||
|
||||
// Base64-encode the signed event
|
||||
const encodedEvent = btoa(JSON.stringify(signedEvent))
|
||||
|
||||
// Send to backend — no body to avoid NIP-98 payload mismatch
|
||||
const response = await axios({
|
||||
method: 'POST',
|
||||
url,
|
||||
headers: {
|
||||
Authorization: `Nostr ${encodedEvent}`,
|
||||
},
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
const { accessToken, refreshToken, expiresIn } = response.data
|
||||
|
||||
// Store tokens
|
||||
sessionStorage.setItem(TOKEN_KEY, accessToken)
|
||||
sessionStorage.setItem(REFRESH_KEY, refreshToken)
|
||||
sessionStorage.setItem(
|
||||
EXPIRES_KEY,
|
||||
String(Date.now() + (expiresIn * 1000) - 30000) // 30s buffer
|
||||
)
|
||||
|
||||
// Also set on the main apiService for backwards compatibility
|
||||
sessionStorage.setItem('nostr_token', accessToken)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[nip98] Failed to create session:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the session using the stored refresh token
|
||||
*/
|
||||
async refresh(): Promise<string | null> {
|
||||
if (this.refreshPromise) return this.refreshPromise
|
||||
|
||||
this.refreshPromise = (async () => {
|
||||
try {
|
||||
const refreshToken = sessionStorage.getItem(REFRESH_KEY)
|
||||
if (!refreshToken) return null
|
||||
|
||||
const response = await axios.post(
|
||||
`${indeehubApiConfig.baseURL}/auth/nostr/refresh`,
|
||||
{ refreshToken },
|
||||
{ timeout: 15000 }
|
||||
)
|
||||
|
||||
const { accessToken, refreshToken: newRefresh, expiresIn } = response.data
|
||||
|
||||
sessionStorage.setItem(TOKEN_KEY, accessToken)
|
||||
if (newRefresh) sessionStorage.setItem(REFRESH_KEY, newRefresh)
|
||||
sessionStorage.setItem(
|
||||
EXPIRES_KEY,
|
||||
String(Date.now() + (expiresIn * 1000) - 30000)
|
||||
)
|
||||
sessionStorage.setItem('nostr_token', accessToken)
|
||||
|
||||
return accessToken
|
||||
} catch {
|
||||
this.clearSession()
|
||||
return null
|
||||
} finally {
|
||||
this.refreshPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return this.refreshPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Store tokens from an external auth flow (e.g. auth.service.ts).
|
||||
* Keeps nip98Service in sync so IndeehubApiService can read the token.
|
||||
*/
|
||||
storeTokens(accessToken: string, refreshToken?: string, expiresIn?: number) {
|
||||
sessionStorage.setItem(TOKEN_KEY, accessToken)
|
||||
if (refreshToken) {
|
||||
sessionStorage.setItem(REFRESH_KEY, refreshToken)
|
||||
}
|
||||
// Default to 1 hour if expiresIn is not provided
|
||||
const ttlMs = (expiresIn ?? 3600) * 1000
|
||||
sessionStorage.setItem(EXPIRES_KEY, String(Date.now() + ttlMs - 30000))
|
||||
// Backwards compatibility
|
||||
sessionStorage.setItem('nostr_token', accessToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stored session data
|
||||
*/
|
||||
clearSession() {
|
||||
sessionStorage.removeItem(TOKEN_KEY)
|
||||
sessionStorage.removeItem(REFRESH_KEY)
|
||||
sessionStorage.removeItem(EXPIRES_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
export const nip98Service = new Nip98Service()
|
||||
@@ -46,6 +46,22 @@ class SubscriptionService {
|
||||
return apiService.post<ApiSubscription>(`/subscriptions/${subscriptionId}/resume`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Lightning subscription invoice via BTCPay.
|
||||
* Returns invoice details including BOLT11 for QR code display.
|
||||
*/
|
||||
async createLightningSubscription(data: {
|
||||
type: 'enthusiast' | 'film-buff' | 'cinephile'
|
||||
period: 'monthly' | 'annual'
|
||||
}): Promise<{
|
||||
lnInvoice: string
|
||||
expiration: string
|
||||
sourceAmount: { amount: string; currency: string }
|
||||
id: string
|
||||
}> {
|
||||
return apiService.post('/subscriptions/lightning', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update payment method
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user