Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
31f2dfa
text field and theme.css
ILIveOnAHill Jan 5, 2026
d8fa956
fix: correct extensions list
MaticBabnik Jan 5, 2026
d163ba9
feat: Add icons & some button styles
MaticBabnik Jan 5, 2026
a08f161
Merge branch 'web/hrib' into dev/fe-matic
MaticBabnik Jan 5, 2026
d0785cc
feat: Text input
MaticBabnik Jan 5, 2026
6c4f513
feat: identity WIP
MaticBabnik Jan 6, 2026
ad5a9f1
fix: Format & fix linter
MaticBabnik Jan 6, 2026
3fe60db
feat: Improve inputs & identity components
MaticBabnik Jan 6, 2026
61d1a18
room user, user label and context menu
ILIveOnAHill Jan 8, 2026
d639820
user folder
ILIveOnAHill Jan 8, 2026
3eb999b
chat text field and some formating
ILIveOnAHill Jan 9, 2026
d2d66ec
chat message and queue card
ILIveOnAHill Jan 10, 2026
0e05e49
Merge branch 'main' of github.com:sync-si/sync into dev/fe-matic
MaticBabnik Jan 10, 2026
c908275
feat: Room creation
MaticBabnik Jan 10, 2026
072d418
feat: media card
ILIveOnAHill Jan 12, 2026
4da90e1
feat: prompts
ILIveOnAHill Jan 12, 2026
cd0beb5
fix: prompt centering
ILIveOnAHill Jan 12, 2026
45b3dc8
feat: Room store
MaticBabnik Jan 12, 2026
8562c1d
feat: a few popups
ILIveOnAHill Jan 12, 2026
fe93942
feat: room settings
ILIveOnAHill Jan 13, 2026
6d1e9a4
fix: set closedByServer on the ***right*** socket
MaticBabnik Jan 13, 2026
bb37b46
fix: Drop opacity of disabled input
MaticBabnik Jan 13, 2026
315d6e2
fix: CR comments
MaticBabnik Jan 13, 2026
592dc22
Merge branch 'web/hrib' of github.com:sync-si/sync into dev/fe-matic
MaticBabnik Jan 13, 2026
13b520f
feat: Room layout
MaticBabnik Jan 13, 2026
a944b1b
feat: Media validation
MaticBabnik Jan 14, 2026
f0b1873
feat: chat
ILIveOnAHill Jan 14, 2026
095a9f5
feat: Playlist/queue
MaticBabnik Jan 14, 2026
027d8ee
Merge branch 'dev/fe-hrib' of github.com:sync-si/sync into dev/fe-matic
MaticBabnik Jan 14, 2026
bc28621
feat: Room UI polish
MaticBabnik Jan 14, 2026
9b2ec4c
feat: Sync kinda
MaticBabnik Jan 14, 2026
834ddb7
feat: Sync imporvements
MaticBabnik Jan 14, 2026
fec337b
feat: Stuff'n things
MaticBabnik Jan 14, 2026
a8528a5
feat: aAAAAAAAAAAAAAaaaaa
MaticBabnik Jan 14, 2026
3c81f32
fix: Fixerino
MaticBabnik Jan 14, 2026
aa53de0
fix: Identity gravatar bullshit
MaticBabnik Jan 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@
// CSS Variable autocomplete
"vunguyentuan.vscode-css-variables",

// Formatter/linter
"biomejs.biome",

// Typescript help
"orta.vscode-twoslash-queries",

// Vue
"Vue.volar",

// Docker (compose) files
"docker.docker"
"docker.docker",

// Formatting and linting
"oxc.oxc-vscode",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint"
]
}
18 changes: 8 additions & 10 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
{
"cssVariables.lookupFiles": [
"sync3-web/src/syncds.css"
],
"cssVariables.languages": [
"vue",
"vue-html",
"css",
"source.css.styled"
],
"cssVariables.lookupFiles": ["web/src/syncds.css"],
"editor.tabSize": 4,
"editor.indentSize": "tabSize",
"editor.detectIndentation": false,
Expand All @@ -19,4 +11,10 @@
"Dockerfile": "nginx.conf",
"vite.config.ts": "histoire.config.ts"
},
}

"editor.defaultFormatter": "esbenp.prettier-vscode",
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"html.customData": [".web/node_modules/vidstack/vscode.html-data.json"]
}
5 changes: 3 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@
"typescript": "^5"
},
"dependencies": {
"@sync/wire": "workspace:*",
"elysia": "^1.4.19",
"typebox": "^1.0.69",
"@sync/wire": "workspace:*"
"jose": "^6.1.3",
"typebox": "^1.0.69"
}
}
8 changes: 6 additions & 2 deletions backend/src/handlers/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const COMMON_HANDLERS = {

const recommendation = msg.body.recommendation

if (recommendation && !(await MediaManager.verifyMedia(recommendation))) {
if (recommendation && !(await MediaManager.checkMediaJwt(recommendation))) {
replyError(ws, msg, {
type: 'invalidMedia',
message: 'The recommended media is invalid.',
Expand Down Expand Up @@ -74,7 +74,11 @@ export const COMMON_HANDLERS = {
// TODO: Rate limit.

ws.data.user.room.owner?.webSocket?.send(
serializeMsg('playbackReport', { userId: ws.data.user.id, stats: msg.body }),
serializeMsg('playbackReport', {
userId: ws.data.user.id,
stats: msg.body,
timestamp: Date.now(),
}),
)
},
} satisfies Partial<HandlerMap>
8 changes: 5 additions & 3 deletions backend/src/models/Room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { MediaManager } from '../services/MediaManager.ts'
import { type User } from './User.ts'
import type { ChatMessage, SyncState, WireRoom } from '@sync/wire/types'

const SLUG_REGEX = /^[a-zA-Z0-9-_]{3,64}$/
const SLUG_REGEX = /^[a-zA-Z0-9-_]{1,64}$/

/**
* A representation of a room. It contains websockets
Expand Down Expand Up @@ -165,7 +165,7 @@ export class Room {
}

for (const m of toValidate) {
if (!MediaManager.validateMedia(m)) {
if (!MediaManager.checkMediaJwt(m)) {
return `Media ID ${m} is invalid`
}
}
Expand All @@ -184,7 +184,9 @@ export class Room {
name: this.name,
slug: this.slug,
},
users: Array.from(this.users.values()).map((u) => u.toWire()),
users: Array.from(this.users.values())
.filter((x) => x.state !== 'new')
.map((u) => u.toWire()),
ownerId: this._owner?.id ?? '',

chat: this.chat,
Expand Down
3 changes: 3 additions & 0 deletions backend/src/reaper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import type { SyncServer } from './server'
import { RoomManager } from './services/RoomManager'
import { SessionManager } from './services/SessionManager'

// TODO: If this is a performance issue, we can probably track rooms that need to be checked
// (add to a set when created until first user joins or when a user leaves)

export function reap(server: SyncServer) {
const roomsToDestroy = new Set<string>()
const usersToRemove = new Set<User>()
Expand Down
44 changes: 36 additions & 8 deletions backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const app = new Elysia()

return {
roomSlug: r.slug,
sessionID: u.sessionId,
sessionId: u.sessionId,

you: u.toWire(),
}
Expand All @@ -72,6 +72,24 @@ const app = new Elysia()
}),
},
)
.post(
'/room/:roomId/info',
({ params, status }) => {
const room = RoomManager.getRoom(params.roomId)

if (!room) return status(404)

return {
name: room.name,
/* TODO: we could pass the array of users here */
}
},
{
params: t.Object({
roomId: t.String(),
}),
},
)
.post(
'/room/join',
({ body, status }) => {
Expand All @@ -91,7 +109,7 @@ const app = new Elysia()

return {
roomSlug: room.slug,
sessionID: u.sessionId,
sessionId: u.sessionId,

you: u.toWire(),
}
Expand All @@ -115,7 +133,7 @@ const app = new Elysia()
}

try {
const media = await MediaManager.verifyMedia(body.source)
const media = await MediaManager.getMediaJwt(body.source)
return { media }
} catch (e) {
if (e instanceof MediaValidationError) {
Expand Down Expand Up @@ -165,14 +183,17 @@ export const server = Bun.serve({
console.log(`[${user.room.slug}:${user.displayName}] Open`)

// Set the user as present
user.state = 'present'
user.lastStateChangeTimestamp = Date.now()

if (user.webSocket) {
ws.data.closedByServer = true
user.webSocket.data.closedByServer = true
user.webSocket.close(CloseCode.ConnectedElsewhere, CloseReason.ConnectedElsewhere)
user.webSocket = ws
} else if (user.state === 'new') {
user.state = 'present'
server.publish(user.room.topic, serializeMsg('userJoined', user.toWire()))
} else {
user.state = 'present'
console.log('publishing userstate present')
server.publish(
user.room.topic,
serializeMsg('userState', {
Expand All @@ -185,6 +206,7 @@ export const server = Bun.serve({

// Hello the new connection
user.webSocket = ws
user.state = 'present'
ws.subscribe(user.room.topic)
ws.send(serializeMsg('roomHello', { you: user.toWire(), ...user.room.toWire() }))
},
Expand Down Expand Up @@ -248,26 +270,32 @@ export const server = Bun.serve({
const { user } = ws.data

console.log(`[${user.room.slug}:${user.displayName}] Close: ${code} (${reason})`)
user.webSocket = undefined

// When setting closedByServer, we don't want to run the normal close logic
if (ws.data.closedByServer) return
if (ws.data.closedByServer) {
ws.data.closedByServer = false // ???
console.log('[WebSocket] Closed by server, skipping close handling')
return
}

if (code === CloseCode.Leave) {
// User intentionally left
user.room.removeUser(user, (ownerId) => {
server.publish(user.room.topic, serializeMsg('roomUpdated', { ownerId }))
})
SessionManager.destroy(user.sessionId)
user.webSocket = undefined

server.publish(user.room.topic, serializeMsg('userLeft', { userId: user.id }))
console.log(`[${user.room.slug}:${user.displayName}] Leave ${code} (${reason})`)

return
}

// User disconnected unexpectedly (or wrongly)
user.state = 'reconnecting'
user.lastStateChangeTimestamp = Date.now()
console.log('[WebSocket] updating userstate')

server.publish(
user.room.topic,
Expand Down
126 changes: 119 additions & 7 deletions backend/src/services/MediaManager.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,129 @@
import type { MediaBody } from '@sync/wire'
import { SignJWT, jwtVerify, type JWTPayload } from 'jose'

export namespace MediaManager {
export async function verifyMedia(source: string): Promise<string> {
// TODO: Implement media verification logic
return source
type Named = {
name?: string
}

const TIMEOUT_MS = 5555

const DEFAULT_SECRET = 'sync3-media-dev-secret'
const secret = getSecret()

function getSecret(): Uint8Array {
const secret = process.env.MEDIA_SECRET

if (!secret) {
console.warn('WARNING: MEDIA_SECRET not set, using insecure default!')
return new TextEncoder().encode(DEFAULT_SECRET)
}

return new TextEncoder().encode(secret)
}

function validateUrlAndExtractTitle(maybeUrl: string): string | undefined {
let url: URL
try {
url = new URL(maybeUrl)
} catch {
throw new MediaValidationError('invalidURL', 'The media source is not a valid URL.')
}

if (url.protocol !== 'https:')
throw new MediaValidationError('invalidScheme', 'Only HTTPS URLs are supported.')

const segment = url.pathname.split('/').at(-1)

if (!segment || segment.length === 0) return undefined

return decodeURIComponent(segment)
}

function isVideoContainer(contentType: string | undefined): contentType is string {
if (!contentType) return false
if (contentType.startsWith('video/mp4')) return true
if (contentType.startsWith('video/webm')) return true
return false
}

async function fetchHeadWithTimeout(url: string, timeoutMs: number = TIMEOUT_MS) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)

let response: Response

try {
response = await fetch(url, {
method: 'HEAD',
signal: controller.signal,
})
} catch (e: unknown) {
if ((e as Named).name === 'AbortError') {
throw new MediaValidationError('timeout', 'The request to fetch media timed out.')
}
throw new MediaValidationError('badResponse', 'Failed to fetch media source.')
}
clearTimeout(timeout)

if (!response.ok) {
throw new MediaValidationError(
'badResponse',
`Fetching media source returned bad response: ${response.status}`,
)
}

return response.headers
}

function parseSize(sizeStr: string | null | undefined): number | undefined {
if (!sizeStr) return undefined
const n = Number.parseInt(sizeStr, 10)
if (Number.isNaN(n)) return undefined

return n
}

export async function getMediaJwt(source: string): Promise<string> {
const title = validateUrlAndExtractTitle(source)

const headers = await fetchHeadWithTimeout(source)

const contentType = headers.get('Content-Type') || undefined

if (!isVideoContainer(contentType))
throw new MediaValidationError(
'unsupportedMime',
`Unsupported media MIME type: ${contentType || 'unknown'}`,
)

const payload: MediaBody = {
kind: 'videoFile',

source,
title: title || 'Video',

size: parseSize(headers.get('Content-Length')),
mime: contentType,
}

return await new SignJWT(payload as unknown as JWTPayload)
.setProtectedHeader({ alg: 'HS256' })
.sign(secret)
}

export function validateMedia(_jws: string): boolean {
// TODO: Implement media validation logic
return true
export async function checkMediaJwt(jwt: string): Promise<boolean> {
try {
await jwtVerify(jwt, secret, { algorithms: ['HS256'] })
return true
} catch {
return false
}
}
}

export type MediaValidationErrorType =
| 'invalidSourceFormat'
| 'invalidURL'
| 'invalidScheme'
| 'timeout'
| 'badResponse'
Expand Down
Loading