feat(spotify): show the current track as an activity
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OAuth Authorization Code flow, with tokens kept server-side: refreshing needs the client secret, so the browser never holds a Spotify token — it asks this instance what is playing and this instance calls Spotify. The callback arrives as a plain browser redirect with no Authorization header, so the OAuth state carries the user id signed with the instance secret and is compared in constant time; without that, anyone could bind their Spotify account to another user. Activities are now tracked per producer. pushActivities replaced the whole list, so the desktop game detector and Spotify would erase each other — losing exactly the case this is for, a game and Spotify at once. Polling backs off when the tab is hidden and keeps the last known track on a network error rather than reporting 'stopped listening'. A rejected refresh token (access revoked on Spotify's side) drops the row so the UI stops claiming a live connection. Scope is read-only: user-read-currently-playing and user-read-playback-state. Per the fork's language rule, the new UI ships in en and pt-BR, and this round also translates the privacy panel.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE `spotify_connections` (
|
||||
`user_id` text PRIMARY KEY NOT NULL,
|
||||
`access_token` text NOT NULL,
|
||||
`refresh_token` text NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`spotify_user_id` text,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,13 @@
|
||||
"when": 1783035334526,
|
||||
"tag": "0010_broken_blazing_skull",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1788190305853,
|
||||
"tag": "0011_lethal_bruce_banner",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -76,6 +76,10 @@ export const config = {
|
||||
sourceCodeUrl,
|
||||
commit,
|
||||
|
||||
spotify: {
|
||||
clientId: envOptional('SPOTIFY_CLIENT_ID'),
|
||||
clientSecret: envOptional('SPOTIFY_CLIENT_SECRET'),
|
||||
},
|
||||
livekit: {
|
||||
url: envOptional('LIVEKIT_URL'),
|
||||
apiKey: envOptional('LIVEKIT_API_KEY'),
|
||||
|
||||
@@ -549,3 +549,20 @@ export const inviteRedemptions = sqliteTable('invite_redemptions', {
|
||||
inviteIdx: index('idx_invite_redemptions_invite_id').on(table.inviteId),
|
||||
userIdx: index('idx_invite_redemptions_user_id').on(table.userId),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Spotify tokens, one row per user.
|
||||
*
|
||||
* Kept server-side on purpose: refreshing requires the client secret, so the
|
||||
* browser never holds a Spotify token at all — it asks this server what is
|
||||
* playing and this server talks to Spotify.
|
||||
*/
|
||||
export const spotifyConnections = sqliteTable('spotify_connections', {
|
||||
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
||||
accessToken: text('access_token').notNull(),
|
||||
refreshToken: text('refresh_token').notNull(),
|
||||
// Epoch millis at which accessToken stops working.
|
||||
expiresAt: integer('expires_at').notNull(),
|
||||
spotifyUserId: text('spotify_user_id'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { uploadRoutes } from './routes/uploads.js';
|
||||
import { filesRoutes } from './routes/files.js';
|
||||
import { dmRoutes } from './routes/dm.js';
|
||||
import { livekitRoutes } from './routes/livekit.js';
|
||||
import { spotifyRoutes } from './routes/spotify.js';
|
||||
import { socialRoutes } from './routes/social.js';
|
||||
import { settingsRoutes } from './routes/settings.js';
|
||||
import { utilRoutes } from './routes/utils.js';
|
||||
@@ -126,6 +127,7 @@ async function main(): Promise<void> {
|
||||
await app.register(filesRoutes);
|
||||
await app.register(dmRoutes);
|
||||
await app.register(livekitRoutes);
|
||||
await app.register(spotifyRoutes);
|
||||
await app.register(socialRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(utilRoutes);
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import crypto from 'crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { config } from '../config.js';
|
||||
import { getOurOrigin } from '../utils/federationAuth.js';
|
||||
import type { Activity } from '@backspace/shared';
|
||||
|
||||
const SPOTIFY_AUTH = 'https://accounts.spotify.com/authorize';
|
||||
const SPOTIFY_TOKEN = 'https://accounts.spotify.com/api/token';
|
||||
const SPOTIFY_NOW_PLAYING = 'https://api.spotify.com/v1/me/player/currently-playing';
|
||||
|
||||
// Read-only: enough to see the current track, nothing that can control playback
|
||||
// or read the library.
|
||||
const SCOPES = 'user-read-currently-playing user-read-playback-state';
|
||||
|
||||
/** Refresh this many ms before expiry, so a request never races the deadline. */
|
||||
const REFRESH_MARGIN_MS = 60_000;
|
||||
|
||||
function redirectUri(): string {
|
||||
return `${getOurOrigin()}/api/connections/spotify/callback`;
|
||||
}
|
||||
|
||||
function isConfigured(): boolean {
|
||||
return Boolean(config.spotify.clientId && config.spotify.clientSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth `state`, signed with the instance's JWT secret.
|
||||
*
|
||||
* The callback arrives as a browser redirect, which carries no Authorization
|
||||
* header — so the state has to say who started the flow, and be tamper-proof
|
||||
* or anyone could bind their Spotify account to someone else's user.
|
||||
*/
|
||||
function signState(userId: string): string {
|
||||
const payload = Buffer.from(JSON.stringify({ userId, exp: Date.now() + 10 * 60_000 })).toString('base64url');
|
||||
const sig = crypto.createHmac('sha256', config.jwtSecret).update(payload).digest('base64url');
|
||||
return `${payload}.${sig}`;
|
||||
}
|
||||
|
||||
function verifyState(state: string): string | null {
|
||||
const [payload, sig] = state.split('.');
|
||||
if (!payload || !sig) return null;
|
||||
const expected = crypto.createHmac('sha256', config.jwtSecret).update(payload).digest('base64url');
|
||||
const a = Buffer.from(sig);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
||||
try {
|
||||
const data = JSON.parse(Buffer.from(payload, 'base64url').toString()) as { userId: string; exp: number };
|
||||
if (!data.userId || typeof data.exp !== 'number' || data.exp < Date.now()) return null;
|
||||
return data.userId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function basicAuthHeader(): string {
|
||||
return 'Basic ' + Buffer.from(`${config.spotify.clientId}:${config.spotify.clientSecret}`).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a usable access token, refreshing it first when it is about to
|
||||
* expire. Returns null when the connection is gone or Spotify rejected the
|
||||
* refresh token — the caller then treats the user as disconnected.
|
||||
*/
|
||||
async function getAccessToken(userId: string): Promise<string | null> {
|
||||
const db = getDb();
|
||||
const row = db.select().from(schema.spotifyConnections)
|
||||
.where(eq(schema.spotifyConnections.userId, userId)).get();
|
||||
if (!row) return null;
|
||||
|
||||
if (row.expiresAt - REFRESH_MARGIN_MS > Date.now()) return row.accessToken;
|
||||
|
||||
const res = await fetch(SPOTIFY_TOKEN, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: basicAuthHeader(), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: row.refreshToken }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// A refresh token is rejected when the user revoked access on Spotify's
|
||||
// side. Drop the row so the UI stops claiming a live connection.
|
||||
if (res.status === 400 || res.status === 401) {
|
||||
db.delete(schema.spotifyConnections).where(eq(schema.spotifyConnections.userId, userId)).run();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = await res.json() as { access_token: string; expires_in: number; refresh_token?: string };
|
||||
db.update(schema.spotifyConnections).set({
|
||||
accessToken: body.access_token,
|
||||
// Spotify only returns a new refresh token sometimes; keep the old one otherwise.
|
||||
refreshToken: body.refresh_token ?? row.refreshToken,
|
||||
expiresAt: Date.now() + body.expires_in * 1000,
|
||||
}).where(eq(schema.spotifyConnections.userId, userId)).run();
|
||||
|
||||
return body.access_token;
|
||||
}
|
||||
|
||||
interface SpotifyTrack {
|
||||
is_playing: boolean;
|
||||
progress_ms: number | null;
|
||||
item: {
|
||||
name: string;
|
||||
duration_ms: number;
|
||||
artists: { name: string }[];
|
||||
album: { name: string; images: { url: string }[] };
|
||||
external_urls?: { spotify?: string };
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** Maps Spotify's payload onto the Activity shape the profile card renders. */
|
||||
function toActivity(track: SpotifyTrack): Activity | null {
|
||||
if (!track.is_playing || !track.item) return null;
|
||||
const now = Date.now();
|
||||
const progress = track.progress_ms ?? 0;
|
||||
return {
|
||||
type: 'listening',
|
||||
name: 'Spotify',
|
||||
details: track.item.name,
|
||||
state: track.item.artists.map((a) => a.name).join(', '),
|
||||
timestamps: { start: now - progress, end: now - progress + track.item.duration_ms },
|
||||
assets: {
|
||||
largeImage: track.item.album.images[0]?.url,
|
||||
largeText: track.item.album.name,
|
||||
},
|
||||
url: track.item.external_urls?.spotify,
|
||||
};
|
||||
}
|
||||
|
||||
export async function spotifyRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get('/api/connections/spotify/status', { preHandler: authenticate }, async (request, reply) => {
|
||||
if (!isConfigured()) return reply.code(200).send({ configured: false, connected: false });
|
||||
const db = getDb();
|
||||
const row = db.select({ userId: schema.spotifyConnections.userId })
|
||||
.from(schema.spotifyConnections)
|
||||
.where(eq(schema.spotifyConnections.userId, request.userId)).get();
|
||||
return reply.code(200).send({ configured: true, connected: Boolean(row) });
|
||||
});
|
||||
|
||||
// Returns the URL rather than redirecting: the caller is fetch(), which would
|
||||
// follow a 302 to Spotify instead of navigating the window there.
|
||||
app.get('/api/connections/spotify/authorize', { preHandler: authenticate }, async (request, reply) => {
|
||||
if (!isConfigured()) {
|
||||
return reply.code(503).send({ error: 'Spotify is not configured on this instance', statusCode: 503 });
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.spotify.clientId!,
|
||||
response_type: 'code',
|
||||
redirect_uri: redirectUri(),
|
||||
scope: SCOPES,
|
||||
state: signState(request.userId),
|
||||
});
|
||||
return reply.code(200).send({ url: `${SPOTIFY_AUTH}?${params}` });
|
||||
});
|
||||
|
||||
app.get<{ Querystring: { code?: string; state?: string; error?: string } }>(
|
||||
'/api/connections/spotify/callback',
|
||||
async (request, reply) => {
|
||||
const { code, state, error } = request.query;
|
||||
const settingsUrl = `${getOurOrigin()}/channels/@me?settings=connections`;
|
||||
|
||||
if (error || !code || !state) return reply.redirect(`${settingsUrl}&spotify=denied`);
|
||||
|
||||
const userId = verifyState(state);
|
||||
if (!userId) return reply.redirect(`${settingsUrl}&spotify=invalid_state`);
|
||||
|
||||
const res = await fetch(SPOTIFY_TOKEN, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: basicAuthHeader(), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri() }),
|
||||
});
|
||||
if (!res.ok) return reply.redirect(`${settingsUrl}&spotify=exchange_failed`);
|
||||
|
||||
const body = await res.json() as { access_token: string; refresh_token: string; expires_in: number };
|
||||
const db = getDb();
|
||||
const row = {
|
||||
userId,
|
||||
accessToken: body.access_token,
|
||||
refreshToken: body.refresh_token,
|
||||
expiresAt: Date.now() + body.expires_in * 1000,
|
||||
spotifyUserId: null,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
db.insert(schema.spotifyConnections).values(row)
|
||||
.onConflictDoUpdate({ target: schema.spotifyConnections.userId, set: row }).run();
|
||||
|
||||
return reply.redirect(`${settingsUrl}&spotify=connected`);
|
||||
},
|
||||
);
|
||||
|
||||
app.get('/api/connections/spotify/now-playing', { preHandler: authenticate }, async (request, reply) => {
|
||||
const token = await getAccessToken(request.userId);
|
||||
if (!token) return reply.code(200).send({ activity: null, connected: false });
|
||||
|
||||
const res = await fetch(SPOTIFY_NOW_PLAYING, { headers: { Authorization: `Bearer ${token}` } });
|
||||
// 204 means "nothing playing"; anything else non-OK is a transient problem
|
||||
// and must not be reported as a lost connection.
|
||||
if (res.status === 204) return reply.code(200).send({ activity: null, connected: true });
|
||||
if (!res.ok) return reply.code(200).send({ activity: null, connected: res.status !== 401 });
|
||||
|
||||
const track = await res.json() as SpotifyTrack;
|
||||
return reply.code(200).send({ activity: toActivity(track), connected: true });
|
||||
});
|
||||
|
||||
app.delete('/api/connections/spotify', { preHandler: authenticate }, async (request, reply) => {
|
||||
const db = getDb();
|
||||
db.delete(schema.spotifyConnections).where(eq(schema.spotifyConnections.userId, request.userId)).run();
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user