A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
567 lines
16 KiB
Go
567 lines
16 KiB
Go
package jitsi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// JitsiService handles Jitsi Meet integration for video conferences
|
|
type JitsiService struct {
|
|
baseURL string
|
|
appID string
|
|
appSecret string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// Config holds Jitsi service configuration
|
|
type Config struct {
|
|
BaseURL string // e.g., "http://localhost:8443"
|
|
AppID string // Application ID for JWT (optional)
|
|
AppSecret string // Secret for JWT signing (optional)
|
|
}
|
|
|
|
// NewJitsiService creates a new Jitsi service instance
|
|
func NewJitsiService(cfg Config) *JitsiService {
|
|
return &JitsiService{
|
|
baseURL: strings.TrimSuffix(cfg.BaseURL, "/"),
|
|
appID: cfg.AppID,
|
|
appSecret: cfg.AppSecret,
|
|
httpClient: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// Types
|
|
// ========================================
|
|
|
|
// Meeting represents a Jitsi meeting configuration
|
|
type Meeting struct {
|
|
RoomName string `json:"room_name"`
|
|
DisplayName string `json:"display_name,omitempty"`
|
|
Email string `json:"email,omitempty"`
|
|
Avatar string `json:"avatar,omitempty"`
|
|
Subject string `json:"subject,omitempty"`
|
|
Password string `json:"password,omitempty"`
|
|
StartTime *time.Time `json:"start_time,omitempty"`
|
|
Duration int `json:"duration,omitempty"` // in minutes
|
|
Config *MeetingConfig `json:"config,omitempty"`
|
|
Moderator bool `json:"moderator,omitempty"`
|
|
Features *MeetingFeatures `json:"features,omitempty"`
|
|
}
|
|
|
|
// MeetingConfig holds Jitsi room configuration options
|
|
type MeetingConfig struct {
|
|
StartWithAudioMuted bool `json:"start_with_audio_muted,omitempty"`
|
|
StartWithVideoMuted bool `json:"start_with_video_muted,omitempty"`
|
|
DisableDeepLinking bool `json:"disable_deep_linking,omitempty"`
|
|
RequireDisplayName bool `json:"require_display_name,omitempty"`
|
|
EnableLobby bool `json:"enable_lobby,omitempty"`
|
|
EnableRecording bool `json:"enable_recording,omitempty"`
|
|
}
|
|
|
|
// MeetingFeatures controls which features are enabled
|
|
type MeetingFeatures struct {
|
|
Livestreaming bool `json:"livestreaming,omitempty"`
|
|
Recording bool `json:"recording,omitempty"`
|
|
Transcription bool `json:"transcription,omitempty"`
|
|
OutboundCall bool `json:"outbound_call,omitempty"`
|
|
}
|
|
|
|
// MeetingLink contains the generated meeting URL and metadata
|
|
type MeetingLink struct {
|
|
URL string `json:"url"`
|
|
RoomName string `json:"room_name"`
|
|
JoinURL string `json:"join_url"`
|
|
ModeratorURL string `json:"moderator_url,omitempty"`
|
|
Password string `json:"password,omitempty"`
|
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
|
JWT string `json:"jwt,omitempty"`
|
|
}
|
|
|
|
// JWTClaims represents the JWT payload for Jitsi
|
|
type JWTClaims struct {
|
|
Audience string `json:"aud,omitempty"`
|
|
Issuer string `json:"iss,omitempty"`
|
|
Subject string `json:"sub,omitempty"`
|
|
Room string `json:"room,omitempty"`
|
|
ExpiresAt int64 `json:"exp,omitempty"`
|
|
NotBefore int64 `json:"nbf,omitempty"`
|
|
Context *JWTContext `json:"context,omitempty"`
|
|
Moderator bool `json:"moderator,omitempty"`
|
|
Features *JWTFeatures `json:"features,omitempty"`
|
|
}
|
|
|
|
// JWTContext contains user information for JWT
|
|
type JWTContext struct {
|
|
User *JWTUser `json:"user,omitempty"`
|
|
Group string `json:"group,omitempty"`
|
|
Callee *JWTCallee `json:"callee,omitempty"`
|
|
}
|
|
|
|
// JWTUser represents user info in JWT
|
|
type JWTUser struct {
|
|
ID string `json:"id,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Email string `json:"email,omitempty"`
|
|
Avatar string `json:"avatar,omitempty"`
|
|
Moderator bool `json:"moderator,omitempty"`
|
|
HiddenFromRecorder bool `json:"hidden-from-recorder,omitempty"`
|
|
}
|
|
|
|
// JWTCallee represents callee info (for 1:1 calls)
|
|
type JWTCallee struct {
|
|
ID string `json:"id,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Avatar string `json:"avatar,omitempty"`
|
|
}
|
|
|
|
// JWTFeatures controls JWT-based feature access
|
|
type JWTFeatures struct {
|
|
Livestreaming string `json:"livestreaming,omitempty"` // "true" or "false"
|
|
Recording string `json:"recording,omitempty"`
|
|
Transcription string `json:"transcription,omitempty"`
|
|
OutboundCall string `json:"outbound-call,omitempty"`
|
|
}
|
|
|
|
// ScheduledMeeting represents a scheduled training/meeting
|
|
type ScheduledMeeting struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description,omitempty"`
|
|
RoomName string `json:"room_name"`
|
|
HostID string `json:"host_id"`
|
|
HostName string `json:"host_name"`
|
|
StartTime time.Time `json:"start_time"`
|
|
EndTime time.Time `json:"end_time"`
|
|
Duration int `json:"duration"` // in minutes
|
|
Password string `json:"password,omitempty"`
|
|
MaxParticipants int `json:"max_participants,omitempty"`
|
|
Features *MeetingFeatures `json:"features,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// ========================================
|
|
// Meeting Management
|
|
// ========================================
|
|
|
|
// CreateMeetingLink generates a meeting URL with optional JWT authentication
|
|
func (s *JitsiService) CreateMeetingLink(ctx context.Context, meeting Meeting) (*MeetingLink, error) {
|
|
// Generate room name if not provided
|
|
roomName := meeting.RoomName
|
|
if roomName == "" {
|
|
roomName = s.generateRoomName()
|
|
}
|
|
|
|
// Sanitize room name (Jitsi-compatible)
|
|
roomName = s.sanitizeRoomName(roomName)
|
|
|
|
link := &MeetingLink{
|
|
RoomName: roomName,
|
|
URL: fmt.Sprintf("%s/%s", s.baseURL, roomName),
|
|
JoinURL: fmt.Sprintf("%s/%s", s.baseURL, roomName),
|
|
Password: meeting.Password,
|
|
}
|
|
|
|
// Generate JWT if authentication is configured
|
|
if s.appSecret != "" {
|
|
jwt, expiresAt, err := s.generateJWT(meeting, roomName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate JWT: %w", err)
|
|
}
|
|
link.JWT = jwt
|
|
link.ExpiresAt = expiresAt
|
|
link.JoinURL = fmt.Sprintf("%s/%s?jwt=%s", s.baseURL, roomName, jwt)
|
|
|
|
// Generate moderator URL if user is moderator
|
|
if meeting.Moderator {
|
|
link.ModeratorURL = link.JoinURL
|
|
}
|
|
}
|
|
|
|
// Add config parameters to URL
|
|
if meeting.Config != nil {
|
|
params := s.buildConfigParams(meeting.Config)
|
|
if params != "" {
|
|
separator := "?"
|
|
if strings.Contains(link.JoinURL, "?") {
|
|
separator = "&"
|
|
}
|
|
link.JoinURL += separator + params
|
|
}
|
|
}
|
|
|
|
return link, nil
|
|
}
|
|
|
|
// CreateTrainingSession creates a meeting link optimized for training sessions
|
|
func (s *JitsiService) CreateTrainingSession(ctx context.Context, title string, hostName string, hostEmail string, duration int) (*MeetingLink, error) {
|
|
meeting := Meeting{
|
|
RoomName: s.generateTrainingRoomName(title),
|
|
DisplayName: hostName,
|
|
Email: hostEmail,
|
|
Subject: title,
|
|
Duration: duration,
|
|
Moderator: true,
|
|
Config: &MeetingConfig{
|
|
StartWithAudioMuted: true, // Participants start muted
|
|
StartWithVideoMuted: false, // Video on for training
|
|
RequireDisplayName: true, // Know who's attending
|
|
EnableLobby: true, // Waiting room
|
|
EnableRecording: true, // Allow recording
|
|
},
|
|
Features: &MeetingFeatures{
|
|
Recording: true,
|
|
Transcription: false,
|
|
},
|
|
}
|
|
|
|
return s.CreateMeetingLink(ctx, meeting)
|
|
}
|
|
|
|
// CreateQuickMeeting creates a simple ad-hoc meeting
|
|
func (s *JitsiService) CreateQuickMeeting(ctx context.Context, displayName string) (*MeetingLink, error) {
|
|
meeting := Meeting{
|
|
DisplayName: displayName,
|
|
Config: &MeetingConfig{
|
|
StartWithAudioMuted: false,
|
|
StartWithVideoMuted: false,
|
|
},
|
|
}
|
|
|
|
return s.CreateMeetingLink(ctx, meeting)
|
|
}
|
|
|
|
// CreateParentTeacherMeeting creates a meeting for parent-teacher conferences
|
|
func (s *JitsiService) CreateParentTeacherMeeting(ctx context.Context, teacherName string, parentName string, studentName string, scheduledTime time.Time) (*MeetingLink, error) {
|
|
roomName := fmt.Sprintf("elterngespraech-%s-%s",
|
|
s.sanitizeRoomName(studentName),
|
|
scheduledTime.Format("20060102-1504"))
|
|
|
|
meeting := Meeting{
|
|
RoomName: roomName,
|
|
DisplayName: teacherName,
|
|
Subject: fmt.Sprintf("Elterngespräch: %s", studentName),
|
|
StartTime: &scheduledTime,
|
|
Duration: 30, // 30 minutes default
|
|
Moderator: true,
|
|
Password: s.generatePassword(),
|
|
Config: &MeetingConfig{
|
|
StartWithAudioMuted: false,
|
|
StartWithVideoMuted: false,
|
|
RequireDisplayName: true,
|
|
EnableLobby: true, // Teacher admits parent
|
|
DisableDeepLinking: true,
|
|
},
|
|
}
|
|
|
|
return s.CreateMeetingLink(ctx, meeting)
|
|
}
|
|
|
|
// CreateClassMeeting creates a meeting for an entire class
|
|
func (s *JitsiService) CreateClassMeeting(ctx context.Context, className string, teacherName string, subject string) (*MeetingLink, error) {
|
|
roomName := fmt.Sprintf("klasse-%s-%s",
|
|
s.sanitizeRoomName(className),
|
|
time.Now().Format("20060102"))
|
|
|
|
meeting := Meeting{
|
|
RoomName: roomName,
|
|
DisplayName: teacherName,
|
|
Subject: fmt.Sprintf("%s - %s", className, subject),
|
|
Moderator: true,
|
|
Config: &MeetingConfig{
|
|
StartWithAudioMuted: true, // Students muted by default
|
|
StartWithVideoMuted: false,
|
|
RequireDisplayName: true,
|
|
EnableLobby: false, // Direct join for classes
|
|
},
|
|
}
|
|
|
|
return s.CreateMeetingLink(ctx, meeting)
|
|
}
|
|
|
|
// ========================================
|
|
// JWT Generation
|
|
// ========================================
|
|
|
|
// generateJWT creates a signed JWT for Jitsi authentication
|
|
func (s *JitsiService) generateJWT(meeting Meeting, roomName string) (string, *time.Time, error) {
|
|
if s.appSecret == "" {
|
|
return "", nil, fmt.Errorf("app secret not configured")
|
|
}
|
|
|
|
now := time.Now()
|
|
|
|
// Default expiration: 24 hours or based on meeting duration
|
|
expiration := now.Add(24 * time.Hour)
|
|
if meeting.Duration > 0 {
|
|
expiration = now.Add(time.Duration(meeting.Duration+30) * time.Minute)
|
|
}
|
|
if meeting.StartTime != nil {
|
|
expiration = meeting.StartTime.Add(time.Duration(meeting.Duration+60) * time.Minute)
|
|
}
|
|
|
|
claims := JWTClaims{
|
|
Audience: "jitsi",
|
|
Issuer: s.appID,
|
|
Subject: "meet.jitsi",
|
|
Room: roomName,
|
|
ExpiresAt: expiration.Unix(),
|
|
NotBefore: now.Add(-5 * time.Minute).Unix(), // 5 min grace period
|
|
Moderator: meeting.Moderator,
|
|
Context: &JWTContext{
|
|
User: &JWTUser{
|
|
ID: uuid.New().String(),
|
|
Name: meeting.DisplayName,
|
|
Email: meeting.Email,
|
|
Avatar: meeting.Avatar,
|
|
Moderator: meeting.Moderator,
|
|
},
|
|
},
|
|
}
|
|
|
|
// Add features if specified
|
|
if meeting.Features != nil {
|
|
claims.Features = &JWTFeatures{
|
|
Recording: boolToString(meeting.Features.Recording),
|
|
Livestreaming: boolToString(meeting.Features.Livestreaming),
|
|
Transcription: boolToString(meeting.Features.Transcription),
|
|
OutboundCall: boolToString(meeting.Features.OutboundCall),
|
|
}
|
|
}
|
|
|
|
// Create JWT
|
|
token, err := s.signJWT(claims)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
return token, &expiration, nil
|
|
}
|
|
|
|
// signJWT creates and signs a JWT token
|
|
func (s *JitsiService) signJWT(claims JWTClaims) (string, error) {
|
|
// Header
|
|
header := map[string]string{
|
|
"alg": "HS256",
|
|
"typ": "JWT",
|
|
}
|
|
|
|
headerJSON, err := json.Marshal(header)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Payload
|
|
payloadJSON, err := json.Marshal(claims)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Encode
|
|
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
|
|
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
|
|
|
|
// Sign
|
|
message := headerB64 + "." + payloadB64
|
|
h := hmac.New(sha256.New, []byte(s.appSecret))
|
|
h.Write([]byte(message))
|
|
signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
|
|
|
return message + "." + signature, nil
|
|
}
|
|
|
|
// ========================================
|
|
// Health Check
|
|
// ========================================
|
|
|
|
// HealthCheck verifies the Jitsi server is accessible
|
|
func (s *JitsiService) HealthCheck(ctx context.Context) error {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", s.baseURL, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
resp, err := s.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("jitsi server unreachable: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 500 {
|
|
return fmt.Errorf("jitsi server error: status %d", resp.StatusCode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetServerInfo returns information about the Jitsi server
|
|
func (s *JitsiService) GetServerInfo() map[string]string {
|
|
return map[string]string{
|
|
"base_url": s.baseURL,
|
|
"app_id": s.appID,
|
|
"auth_enabled": boolToString(s.appSecret != ""),
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// URL Building
|
|
// ========================================
|
|
|
|
// BuildEmbedURL creates an embeddable iframe URL
|
|
func (s *JitsiService) BuildEmbedURL(roomName string, displayName string, config *MeetingConfig) string {
|
|
params := url.Values{}
|
|
|
|
if displayName != "" {
|
|
params.Set("userInfo.displayName", displayName)
|
|
}
|
|
|
|
if config != nil {
|
|
if config.StartWithAudioMuted {
|
|
params.Set("config.startWithAudioMuted", "true")
|
|
}
|
|
if config.StartWithVideoMuted {
|
|
params.Set("config.startWithVideoMuted", "true")
|
|
}
|
|
if config.DisableDeepLinking {
|
|
params.Set("config.disableDeepLinking", "true")
|
|
}
|
|
}
|
|
|
|
embedURL := fmt.Sprintf("%s/%s", s.baseURL, s.sanitizeRoomName(roomName))
|
|
if len(params) > 0 {
|
|
embedURL += "#" + params.Encode()
|
|
}
|
|
|
|
return embedURL
|
|
}
|
|
|
|
// BuildIFrameCode generates HTML iframe code for embedding
|
|
func (s *JitsiService) BuildIFrameCode(roomName string, width int, height int) string {
|
|
if width == 0 {
|
|
width = 800
|
|
}
|
|
if height == 0 {
|
|
height = 600
|
|
}
|
|
|
|
return fmt.Sprintf(
|
|
`<iframe src="%s/%s" width="%d" height="%d" allow="camera; microphone; fullscreen; display-capture; autoplay" style="border: 0;"></iframe>`,
|
|
s.baseURL,
|
|
s.sanitizeRoomName(roomName),
|
|
width,
|
|
height,
|
|
)
|
|
}
|
|
|
|
// ========================================
|
|
// Helper Functions
|
|
// ========================================
|
|
|
|
// generateRoomName creates a unique room name
|
|
func (s *JitsiService) generateRoomName() string {
|
|
return fmt.Sprintf("breakpilot-%s", uuid.New().String()[:8])
|
|
}
|
|
|
|
// generateTrainingRoomName creates a room name for training sessions
|
|
func (s *JitsiService) generateTrainingRoomName(title string) string {
|
|
sanitized := s.sanitizeRoomName(title)
|
|
if sanitized == "" {
|
|
sanitized = "schulung"
|
|
}
|
|
return fmt.Sprintf("%s-%s", sanitized, time.Now().Format("20060102-1504"))
|
|
}
|
|
|
|
// sanitizeRoomName removes invalid characters from room names
|
|
func (s *JitsiService) sanitizeRoomName(name string) string {
|
|
// Replace spaces and special characters
|
|
result := strings.ToLower(name)
|
|
result = strings.ReplaceAll(result, " ", "-")
|
|
result = strings.ReplaceAll(result, "ä", "ae")
|
|
result = strings.ReplaceAll(result, "ö", "oe")
|
|
result = strings.ReplaceAll(result, "ü", "ue")
|
|
result = strings.ReplaceAll(result, "ß", "ss")
|
|
|
|
// Remove any remaining non-alphanumeric characters except hyphen
|
|
var cleaned strings.Builder
|
|
for _, r := range result {
|
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
|
cleaned.WriteRune(r)
|
|
}
|
|
}
|
|
|
|
// Remove consecutive hyphens
|
|
result = cleaned.String()
|
|
for strings.Contains(result, "--") {
|
|
result = strings.ReplaceAll(result, "--", "-")
|
|
}
|
|
|
|
// Trim hyphens from start and end
|
|
result = strings.Trim(result, "-")
|
|
|
|
// Limit length
|
|
if len(result) > 50 {
|
|
result = result[:50]
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// generatePassword creates a random meeting password
|
|
func (s *JitsiService) generatePassword() string {
|
|
return uuid.New().String()[:8]
|
|
}
|
|
|
|
// buildConfigParams creates URL parameters from config
|
|
func (s *JitsiService) buildConfigParams(config *MeetingConfig) string {
|
|
params := url.Values{}
|
|
|
|
if config.StartWithAudioMuted {
|
|
params.Set("config.startWithAudioMuted", "true")
|
|
}
|
|
if config.StartWithVideoMuted {
|
|
params.Set("config.startWithVideoMuted", "true")
|
|
}
|
|
if config.DisableDeepLinking {
|
|
params.Set("config.disableDeepLinking", "true")
|
|
}
|
|
if config.RequireDisplayName {
|
|
params.Set("config.requireDisplayName", "true")
|
|
}
|
|
if config.EnableLobby {
|
|
params.Set("config.enableLobby", "true")
|
|
}
|
|
|
|
return params.Encode()
|
|
}
|
|
|
|
// boolToString converts bool to "true"/"false" string
|
|
func boolToString(b bool) string {
|
|
if b {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
}
|
|
|
|
// GetBaseURL returns the configured base URL
|
|
func (s *JitsiService) GetBaseURL() string {
|
|
return s.baseURL
|
|
}
|
|
|
|
// IsAuthEnabled returns whether JWT authentication is configured
|
|
func (s *JitsiService) IsAuthEnabled() bool {
|
|
return s.appSecret != ""
|
|
}
|