Merge pull request 'Add Forgejo CI/CD workflows with self-hosted runners' (#1) from forgejo-workflows into main
Some checks failed
Lint and Build / build (20) (push) Failing after 18s
Lint and Build / build (22) (push) Failing after 29s

Reviewed-on: #1
This commit is contained in:
Decobus 2025-07-19 12:03:33 +03:00
commit f913e20dec
9 changed files with 469 additions and 82 deletions

View file

@ -0,0 +1,47 @@
name: Lint and Build
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: self-hosted
strategy:
matrix:
node-version: [ 20, 22 ]
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Clean NextJS cache
run: rm -rf .next
- name: Install dependencies
run: npm ci
- name: Run type check
run: npm run type-check
- name: Run linter
run: npm run lint
- name: Build project
run: npm run build
- name: Upload Build Artifact
uses: actions/upload-artifact@v4
with:
name: obs-ss-${{ matrix.node-version }}
include-hidden-files: 'true'
path: ./.next/*

103
CLAUDE.md
View file

@ -15,68 +15,113 @@ This is a Next.js web application that controls multiple OBS Source Switchers. I
- `npm run lint` - Run ESLint to check code quality - `npm run lint` - Run ESLint to check code quality
- `npm run type-check` - Run TypeScript type checking without emitting files - `npm run type-check` - Run TypeScript type checking without emitting files
### Database Management
- `npm run create-sat-summer-2025-tables` - Create database tables with seasonal naming convention
## Architecture Overview ## Architecture Overview
### Technology Stack ### Technology Stack
- **Frontend**: Next.js 15.1.6 with React 19, TypeScript, and Tailwind CSS - **Frontend**: Next.js 15.1.6 with React 19, TypeScript, and custom CSS with glass morphism design
- **Backend**: Next.js API routes - **Backend**: Next.js API routes
- **Database**: SQLite with sqlite3 driver - **Database**: SQLite with sqlite3 driver
- **OBS Integration**: obs-websocket-js for WebSocket communication with OBS Studio - **OBS Integration**: obs-websocket-js for WebSocket communication with OBS Studio
- **Styling**: Tailwind CSS with @tailwindcss/forms plugin - **Styling**: Custom CSS with Tailwind CSS utilities and modern glass card components
### Project Structure ### Project Structure
- `/app` - Next.js App Router pages and API routes - `/app` - Next.js App Router pages and API routes
- `/api` - Backend API endpoints for stream management - `/api` - Backend API endpoints for stream management
- `/add` - Page for adding new stream clients - `/add` - Streams management page (add new streams and view existing)
- `/components` - Reusable React components (e.g., Dropdown) - `/teams` - Team management page
- `/edit/[id]` - Individual stream editing
- `/components` - Reusable React components (Header, Footer, Dropdown)
- `/lib` - Core utilities and database connection - `/lib` - Core utilities and database connection
- `database.ts` - SQLite database initialization and connection management - `database.ts` - SQLite database initialization and connection management
- `obsClient.js` - OBS WebSocket client for communicating with OBS Studio - `obsClient.js` - OBS WebSocket client with persistent connection management
- `constants.ts` - Shared constants (table names, etc.) - `constants.ts` - Dynamic table naming system for seasonal deployments
- `/types` - TypeScript type definitions - `/types` - TypeScript type definitions
- `/files` - Default directory for SQLite database and text files (configurable via .env.local) - `/files` - Default directory for SQLite database and text files (configurable via .env.local)
- `/scripts` - Database setup and management scripts
- `/.forgejo/workflows` - Forgejo CI/CD workflows for self-hosted runners
### Key Concepts ### Key Architectural Concepts
1. **Stream Management**: The app manages stream sources that can be assigned to different screen positions. Each stream has: 1. **Dynamic Table Naming System**: Uses seasonal configuration for table names (e.g., `streams_2025_summer_sat`, `teams_2025_summer_sat`) to support recurring deployments
- name: Display name
- obs_source_name: Name of the source in OBS
- url: Stream URL
- team_id: Associated team identifier
2. **Screen Types**: Seven different screen positions are supported: large, left, right, topLeft, topRight, bottomLeft, bottomRight 2. **Persistent OBS Connection Management**: Single WebSocket connection shared across all API requests with automatic reconnection and connection state tracking
3. **Text File Integration**: The app writes the active source name to text files that OBS Source Switcher reads to switch sources automatically 3. **Dual Integration Pattern**:
- WebSocket API for direct OBS control (source creation, status monitoring)
- Text file system for OBS Source Switcher plugin integration (source switching)
4. **Environment Configuration**: 4. **Glass Morphism UI Architecture**: Modern design system with backdrop blur effects, gradient backgrounds, and responsive glass card components
5. **Screen Position Management**: Seven distinct screen positions (large, left, right, topLeft, topRight, bottomLeft, bottomRight) with individual source control
6. **Real-time Status Monitoring**: Footer component polls OBS status every 30 seconds showing connection, streaming, and recording status
### Environment Configuration
- `FILE_DIRECTORY`: Directory for database and text files (default: ./files) - `FILE_DIRECTORY`: Directory for database and text files (default: ./files)
- `OBS_WEBSOCKET_HOST`: OBS WebSocket host (default: 127.0.0.1) - `OBS_WEBSOCKET_HOST`: OBS WebSocket host (default: 127.0.0.1)
- `OBS_WEBSOCKET_PORT`: OBS WebSocket port (default: 4455) - `OBS_WEBSOCKET_PORT`: OBS WebSocket port (default: 4455)
- `OBS_WEBSOCKET_PASSWORD`: OBS WebSocket password (optional) - `OBS_WEBSOCKET_PASSWORD`: OBS WebSocket password (optional)
- `API_KEY`: Required for API authentication (set in production)
### API Endpoints ### API Endpoints
- `POST /api/addStream` - Add new stream to database and OBS #### Stream Management
- `POST /api/addStream` - Add new stream to database and create browser source in OBS
- `GET /api/streams` - Get all available streams - `GET /api/streams` - Get all available streams
- `GET /api/teams` - Get all teams - `GET /api/streams/[id]` - Individual stream operations
#### Source Control
- `POST /api/setActive` - Set active stream for specific screen position
- `GET /api/getActive` - Get currently active sources for all screens - `GET /api/getActive` - Get currently active sources for all screens
- `POST /api/setActive` - Set active stream for a specific screen
#### Team Management
- `GET /api/teams` - Get all teams
- `GET /api/getTeamName` - Get team name by ID - `GET /api/getTeamName` - Get team name by ID
#### System Status
- `GET /api/obsStatus` - Real-time OBS connection and streaming status
### Database Schema ### Database Schema
Two main tables: Dynamic table names with seasonal configuration:
- `streams`: id, name, obs_source_name, url, team_id - `streams_YYYY_SEASON_SUFFIX`: id, name, obs_source_name, url, team_id
- `teams`: team_id, team_name - `teams_YYYY_SEASON_SUFFIX`: team_id, team_name
### OBS Integration Pattern ### OBS Integration Pattern
The app communicates with OBS through: The app uses a sophisticated dual integration approach:
1. WebSocket connection using obs-websocket-js
2. Text files that OBS Source Switcher monitors for source changes
3. Direct source management through OBS WebSocket API
When setting an active source: 1. **WebSocket Connection**: Direct OBS control using obs-websocket-js with persistent connection management
1. User selects stream in UI 2. **Text File System**: Each screen position has a corresponding text file that OBS Source Switcher monitors
2. API writes source name to corresponding text file (e.g., largeScreen.txt)
3. OBS Source Switcher detects file change and switches to that source **Source Control Workflow**:
1. User selects stream in React UI
2. API writes source name to position-specific text file (e.g., `large.txt`, `left.txt`)
3. OBS Source Switcher detects file change and switches to specified source
4. Real-time status updates via WebSocket API
**Connection Management**: The OBS client ensures a single persistent connection across all API requests with automatic reconnection handling and connection state validation.
### Component Patterns
- **Client Components**: All interactive components use `'use client'` directive for React 19 compatibility
- **Optimistic Updates**: UI updates immediately with error rollback for responsive user experience
- **Consistent Layout**: Glass morphism design with unified component styling across all pages
- **Responsive Design**: Grid layouts adapt to different screen sizes with mobile-first approach
### Security Architecture
**Authentication**: API key-based authentication protects all API endpoints through Next.js middleware
**Input Validation**: Comprehensive validation using centralized security utilities in `/lib/security.ts`:
- Screen parameter allowlisting prevents path traversal attacks
- URL validation ensures only http/https protocols
- String sanitization removes potentially dangerous characters
- Integer validation prevents injection attacks
**Path Protection**: File operations are restricted to allowlisted screen names, preventing directory traversal
**Error Handling**: Secure error responses that don't leak system information

View file

@ -1,28 +1,97 @@
This is a [Next.js](https://nextjs.org) app to control multiple OBS [Source Switchers](https://obsproject.com/forum/resources/source-switcher.941/) # OBS Source Switcher Plugin UI
A professional [Next.js](https://nextjs.org) web application for controlling multiple OBS [Source Switchers](https://obsproject.com/forum/resources/source-switcher.941/) with real-time WebSocket integration and modern glass morphism UI.
## Features
- **Multi-Screen Source Control**: Manage 7 different screen positions (large, left, right, and 4 corners)
- **Real-time OBS Integration**: WebSocket connection with live status monitoring
- **Team & Stream Management**: Organize streams by teams with full CRUD operations
- **Modern UI**: Glass morphism design with responsive layout
- **Professional Broadcasting**: Audio routing, scene management, and live status indicators
- **Dual Integration**: WebSocket API + text file monitoring for maximum compatibility
## Quick Start
```bash
npm install
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) to access the control interface.
## Configuration ## Configuration
The application uses a configurable directory for storing files and the database. To update the directory: create `.env.local` in the root of the project.
`.env.local` should look like: ### Environment Variables
```
Create `.env.local` in the project root:
```env
# File storage directory (optional, defaults to ./files)
FILE_DIRECTORY=C:\\OBS\\source-switching FILE_DIRECTORY=C:\\OBS\\source-switching
```
If no `.env.local` file is created, it will default to `./files`, as seen in `config.js`
# OBS WebSocket settings (optional, these are defaults)
OBS_WEBSOCKET_HOST=127.0.0.1
OBS_WEBSOCKET_PORT=4455
OBS_WEBSOCKET_PASSWORD=your_password_here
```javascript # Security (IMPORTANT: Set in production)
const config = { API_KEY=your_secure_api_key_here
FILE_DIRECTORY: path.resolve('./files'),
};
``` ```
In the "Source Switcher" properties in OBS, at the bottom, is a setting called `Current Source File`. Enable that, point it to the location of one of the text files, and put the read interval to 1000ms. Your source will change whenever the text file changes to a source _that is defined in the Source Switcher properties_ ### Security Setup
The list of available sources is defined in a SQLite3 DB, location set in the `api/setActive.ts` route. **⚠️ IMPORTANT**: Set `API_KEY` in production to protect your OBS setup from unauthorized access.
`npm install` and Generate a secure API key:
`npm run dev` to run it. ```bash
# Generate a random 32-character key
openssl rand -hex 32
```
This is my first [Next.js](https://nextjs.org) app and I am not a Javascript Developer professionally, use at your own risk. Without an API key, anyone on your network can control your OBS streams.
### OBS Source Switcher Setup
1. In OBS, configure Source Switcher properties
2. Enable "Current Source File" at the bottom
3. Point to one of the generated text files (e.g., `large.txt`, `left.txt`)
4. Set read interval to 1000ms
5. Sources will switch automatically when files change
### Database Setup
```bash
# Create seasonal database tables
npm run create-sat-summer-2025-tables
```
## Development Commands
```bash
npm run dev # Start development server
npm run build # Build for production
npm run start # Start production server
npm run lint # Run ESLint
npm run type-check # TypeScript validation
```
## Architecture
- **Frontend**: Next.js 15 with React 19 and TypeScript
- **Backend**: Next.js API routes with SQLite database
- **OBS Integration**: WebSocket connection + text file monitoring
- **Styling**: Custom CSS with glass morphism and Tailwind utilities
- **CI/CD**: Forgejo workflows with self-hosted runners
## API Endpoints
- `GET /api/streams` - List all streams
- `POST /api/addStream` - Create new stream and OBS source
- `POST /api/setActive` - Set active stream for screen position
- `GET /api/obsStatus` - Real-time OBS connection status
- `GET /api/teams` - Team management
See `CLAUDE.md` for detailed architecture documentation.

View file

@ -19,7 +19,7 @@ export default function AddStream() {
url: '', url: '',
team_id: null, team_id: null,
}); });
const [teams, setTeams] = useState([]); const [teams, setTeams] = useState<{id: number; name: string}[]>([]);
const [streams, setStreams] = useState<Stream[]>([]); const [streams, setStreams] = useState<Stream[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');

View file

@ -113,15 +113,31 @@ if (error instanceof Error) {
} }
} }
import { validateStreamInput } from '../../../lib/security';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
let name: string, obs_source_name: string, url: string, team_id: number;
// Parse and validate request body
try { try {
const body = await request.json(); const body = await request.json();
const { name, obs_source_name, url, team_id } = body; const validation = validateStreamInput(body);
if (!name || !obs_source_name || !url) { if (!validation.valid) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); return NextResponse.json({
error: 'Validation failed',
details: validation.errors
}, { status: 400 });
} }
({ name, obs_source_name, url, team_id } = validation.data!);
} catch {
return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 });
}
try {
// Connect to OBS WebSocket // Connect to OBS WebSocket
console.log("Pre-connect") console.log("Pre-connect")
await connectToOBS(); await connectToOBS();

View file

@ -3,17 +3,24 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { FILE_DIRECTORY } from '../../../config'; import { FILE_DIRECTORY } from '../../../config';
import { getDatabase } from '../../../lib/database'; import { getDatabase } from '../../../lib/database';
import { Stream, Screen } from '@/types'; import { Stream } from '@/types';
import { validateScreenInput } from '../../../lib/security';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const body: Screen = await request.json(); // Parse and validate request body
const { screen, id } = body; try {
const body = await request.json();
const validation = validateScreenInput(body);
const validScreens = ['large', 'left', 'right', 'topLeft', 'topRight', 'bottomLeft', 'bottomRight']; if (!validation.valid) {
if (!validScreens.includes(screen)) { return NextResponse.json({
return NextResponse.json({ error: 'Invalid screen name' }, { status: 400 }); error: 'Validation failed',
details: validation.errors
}, { status: 400 });
} }
const { screen, id } = validation.data!;
console.log('Writing files to', path.join(FILE_DIRECTORY(), `${screen}.txt`)); console.log('Writing files to', path.join(FILE_DIRECTORY(), `${screen}.txt`));
const filePath = path.join(FILE_DIRECTORY(), `${screen}.txt`); const filePath = path.join(FILE_DIRECTORY(), `${screen}.txt`);
@ -40,4 +47,7 @@ return NextResponse.json(
{ status: 500 } { status: 500 }
); );
} }
} catch {
return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 });
}
} }

47
lib/apiClient.ts Normal file
View file

@ -0,0 +1,47 @@
// API client utility for making authenticated requests
// Get API key from environment (client-side will need to be provided differently)
function getApiKey(): string | null {
if (typeof window === 'undefined') {
// Server-side
return process.env.API_KEY || null;
} else {
// Client-side - for now, return null to bypass auth in development
// In production, this would come from a secure storage or context
return null;
}
}
// Authenticated fetch wrapper
export async function apiCall(url: string, options: RequestInit = {}): Promise<Response> {
const apiKey = getApiKey();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options.headers,
};
// Add API key if available
if (apiKey) {
headers['x-api-key'] = apiKey;
}
return fetch(url, {
...options,
headers,
});
}
// Convenience methods
export const apiClient = {
get: (url: string) => apiCall(url, { method: 'GET' }),
post: (url: string, data: unknown) => apiCall(url, {
method: 'POST',
body: JSON.stringify(data)
}),
put: (url: string, data: unknown) => apiCall(url, {
method: 'PUT',
body: JSON.stringify(data)
}),
delete: (url: string) => apiCall(url, { method: 'DELETE' }),
};

111
lib/security.ts Normal file
View file

@ -0,0 +1,111 @@
// Security utilities for input validation and sanitization
export const VALID_SCREENS = ['large', 'left', 'right', 'topLeft', 'topRight', 'bottomLeft', 'bottomRight'] as const;
export type ValidScreen = typeof VALID_SCREENS[number];
// Input validation functions
export function isValidScreen(screen: string): screen is ValidScreen {
return VALID_SCREENS.includes(screen as ValidScreen);
}
export function isValidUrl(url: string): boolean {
try {
const urlObj = new URL(url);
return ['http:', 'https:'].includes(urlObj.protocol);
} catch {
return false;
}
}
export function isPositiveInteger(value: unknown): value is number {
return Number.isInteger(value) && value > 0;
}
// String sanitization
export function sanitizeString(input: string, maxLength: number = 100): string {
// Remove potentially dangerous characters and limit length
return input.replace(/[<>"/\\&]/g, '').trim().substring(0, maxLength);
}
// Validation schemas
export interface StreamInput {
name: string;
obs_source_name: string;
url: string;
team_id: number;
}
export interface ScreenInput {
screen: string;
id: number;
}
export function validateStreamInput(input: unknown): { valid: boolean; errors: string[]; data?: StreamInput } {
const errors: string[] = [];
const data = input as Record<string, unknown>;
if (!data.name || typeof data.name !== 'string') {
errors.push('Name is required and must be a string');
} else if (data.name.length > 100) {
errors.push('Name must be 100 characters or less');
}
if (!data.obs_source_name || typeof data.obs_source_name !== 'string') {
errors.push('OBS source name is required and must be a string');
} else if (data.obs_source_name.length > 100) {
errors.push('OBS source name must be 100 characters or less');
}
if (!data.url || typeof data.url !== 'string') {
errors.push('URL is required and must be a string');
} else if (!isValidUrl(data.url)) {
errors.push('URL must be a valid http:// or https:// URL');
}
if (!isPositiveInteger(data.team_id)) {
errors.push('Team ID must be a positive integer');
}
if (errors.length > 0) {
return { valid: false, errors };
}
return {
valid: true,
errors: [],
data: {
name: sanitizeString(data.name as string),
obs_source_name: sanitizeString(data.obs_source_name as string),
url: data.url as string,
team_id: data.team_id as number,
},
};
}
export function validateScreenInput(input: unknown): { valid: boolean; errors: string[]; data?: ScreenInput } {
const errors: string[] = [];
const data = input as Record<string, unknown>;
if (!data.screen || typeof data.screen !== 'string') {
errors.push('Screen is required and must be a string');
} else if (!isValidScreen(data.screen)) {
errors.push(`Screen must be one of: ${VALID_SCREENS.join(', ')}`);
}
if (!isPositiveInteger(data.id)) {
errors.push('ID must be a positive integer');
}
if (errors.length > 0) {
return { valid: false, errors };
}
return {
valid: true,
errors: [],
data: {
screen: data.screen as string,
id: data.id as number,
},
};
}

42
middleware.ts Normal file
View file

@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
// Only protect API routes
if (request.nextUrl.pathname.startsWith('/api/')) {
// Allow OPTIONS requests for CORS preflight
if (request.method === 'OPTIONS') {
return NextResponse.next();
}
// Check for API key in header
const apiKey = request.headers.get('x-api-key');
const validKey = process.env.API_KEY;
// If API_KEY is not set in environment, skip authentication (development mode)
if (!validKey) {
console.warn('API_KEY not set in environment variables. API endpoints are unprotected!');
return NextResponse.next();
}
// Skip authentication for localhost/internal requests (optional security)
const host = request.headers.get('host');
if (host && (host.startsWith('localhost') || host.startsWith('127.0.0.1') || host.startsWith('192.168.'))) {
console.log('Allowing internal network access without API key');
return NextResponse.next();
}
// Validate API key for external requests
if (!apiKey || apiKey !== validKey) {
return NextResponse.json(
{ error: 'Unauthorized. Valid API key required.' },
{ status: 401 }
);
}
}
return NextResponse.next();
}
export const config = {
matcher: '/api/:path*'
};