mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
There is no way to answer "how many screens run ScreenTinker?". The product is
self-hostable by design, so most installs are invisible to us on purpose — and
should stay that way. This asks once, and reports only if the operator says yes.
The entire payload is three fields:
{ instance_id, version, screen_count }
instance_id is a random UUID minted on first use and kept in app_settings. It
carries nothing about the install; its only job is to let two reports from the
same server be recognised as one server, so a count is a count rather than a sum
of duplicates. That makes a report pseudonymous rather than anonymous, and the
wording shown to operators says so rather than claiming otherwise.
The payload is short on purpose. Every field added costs participation, and
participation is the only thing that makes the resulting number worth quoting.
Player-platform counts were considered and left out: release assets are already
published per platform, so GitHub's per-asset download counts answer "where should
effort go" at zero privacy cost and without asking anyone for anything.
Verifiability is the feature, not the copy. Settings shows the ACTUAL payload this
server would send, generated live from its own data, plus what it last really sent
and when. The payload is built in one function so a reviewer can check it at a
glance, and the test fails if a field is ever added.
Both answers persist. Declining is remembered as 'off' rather than falling back to
'unasked', so the prompt cannot return after an update — re-prompting is how
telemetry earns its reputation and gets patched out.
Collector side is inert unless TELEMETRY_COLLECTOR=1, so a normal install never
exposes the endpoint. Reports upsert on instance_id rather than appending, so an
install reporting daily occupies one row rather than 365 a year. The source IP is
never read or stored — receiving one is unavoidable, logging it would quietly turn
a pseudonymous report into an identifiable one.
Tests pin the negative promises, which are the ones that rot silently: sends
nothing before consent, sends nothing after a decline, payload is exactly three
keys, id survives a restart, a failed send never records a phantom report. Screen
count excludes unpaired provisioning rows, which would otherwise overstate the one
number this exists to state honestly.
docs/telemetry.md documents the payload, what is not sent, how to verify it, and
that any published total is a floor rather than a basis for extrapolation.
1657/1657 pass.
292 lines
17 KiB
JavaScript
292 lines
17 KiB
JavaScript
const API_BASE = '/api';
|
|
|
|
function getAuthHeaders() {
|
|
const token = localStorage.getItem('token');
|
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
}
|
|
|
|
async function request(url, options = {}) {
|
|
const res = await fetch(API_BASE + url, {
|
|
headers: { 'Content-Type': 'application/json', ...getAuthHeaders(), ...options.headers },
|
|
...options,
|
|
});
|
|
if (res.status === 401) {
|
|
// Token expired or invalid - redirect to login
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('user');
|
|
window.location.hash = '#/login';
|
|
window.location.reload();
|
|
throw new Error('Session expired');
|
|
}
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
throw new Error(err.error || 'Request failed');
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export const api = {
|
|
// Devices
|
|
getDevices: () => request('/devices'),
|
|
reorderDevices: (order) => request('/devices/reorder', { method: 'POST', body: JSON.stringify({ order }) }),
|
|
getDevice: (id) => request(`/devices/${id}`),
|
|
getDeviceOwnerQR: () => request('/provision/device-owner-qr'), // #161: device-owner provisioning
|
|
updateDevice: (id, data) => request(`/devices/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
deleteDevice: (id) => request(`/devices/${id}`, { method: 'DELETE' }),
|
|
// #146 Item D: operator block/unblock — refuses the device at its next register with
|
|
// no restart. Server enforces via the SNAT-safe identity chain (deviceSocket).
|
|
blockDevice: (id) => request(`/devices/${id}/block`, { method: 'POST' }),
|
|
unblockDevice: (id) => request(`/devices/${id}/unblock`, { method: 'POST' }),
|
|
// #150: fingerprint-keyed settings snapshots of previously-removed devices (this workspace),
|
|
// and the re-adopt action that applies a snapshot onto a newly-paired device.
|
|
getRemovedDevices: () => request('/devices/removed'),
|
|
reAdoptDevice: (id, fingerprint) => request(`/devices/${id}/re-adopt`, { method: 'POST', body: JSON.stringify({ fingerprint }) }),
|
|
setDevicePin: (id, body) => request(`/devices/${id}/settings-pin`, { method: 'POST', body: JSON.stringify(body) }),
|
|
|
|
// #109 PiP overlay: push/clear a floating overlay on a device or group. `id` may be a
|
|
// device id OR a group id (the server resolves + expands). Needs full scope (no-op for JWT).
|
|
sendPip: (id, opts) => request('/pip', { method: 'POST', body: JSON.stringify({ device_id: id, ...opts }) }),
|
|
clearPip: (id, pipId) => request('/pip/clear', { method: 'POST', body: JSON.stringify({ device_id: id, pip_id: pipId || undefined }) }),
|
|
|
|
// Provisioning
|
|
pairDevice: (pairing_code, name) => request('/provision/pair', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ pairing_code, name })
|
|
}),
|
|
|
|
// Content
|
|
getContent: (folderId, includeExpired = false, opts = {}) => {
|
|
const p = new URLSearchParams();
|
|
// #214: a text search spans the whole workspace, so folder_id is only sent when
|
|
// NOT searching (the server also ignores folder_id when q is present, but keeping
|
|
// the client in sync avoids a misleading URL).
|
|
const searching = opts.q && opts.q.trim();
|
|
if (!searching && folderId !== undefined) p.set('folder_id', folderId === null ? 'root' : folderId);
|
|
if (includeExpired) p.set('include_expired', '1');
|
|
if (searching) p.set('q', opts.q.trim());
|
|
if (opts.type && opts.type !== 'all') p.set('type', opts.type);
|
|
if (opts.sort) p.set('sort', opts.sort);
|
|
const qs = p.toString();
|
|
return request(`/content${qs ? '?' + qs : ''}`);
|
|
},
|
|
getContentItem: (id) => request(`/content/${id}`),
|
|
deleteContent: (id) => request(`/content/${id}`, { method: 'DELETE' }),
|
|
updateContent: (id, data) => request(`/content/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
moveContent: (id, folderId) => request(`/content/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ folder_id: folderId })
|
|
}),
|
|
// #213: batch operations — the whole batch succeeds or fails atomically server-side.
|
|
batchDeleteContent: (ids) => request('/content/batch/delete', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids })
|
|
}),
|
|
batchMoveContent: (ids, folderId) => request('/content/batch/move', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids, folder_id: folderId || null })
|
|
}),
|
|
|
|
// Folders
|
|
getFolders: () => request('/folders'),
|
|
createFolder: (name, parentId) => request('/folders', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name, parent_id: parentId || null })
|
|
}),
|
|
renameFolder: (id, name) => request(`/folders/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ name })
|
|
}),
|
|
moveFolder: (id, parentId) => request(`/folders/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ parent_id: parentId || null })
|
|
}),
|
|
deleteFolder: (id) => request(`/folders/${id}`, { method: 'DELETE' }),
|
|
// #212: accepts a single File or an array/FileList of Files. All go up in one request
|
|
// under the `files` field (the server also still accepts the legacy `file` field).
|
|
// onProgress reports aggregate percent across the whole batch. Resolves to the content
|
|
// object for a single file, or an array of them for a batch.
|
|
uploadContent: async (file, onProgress, folderId) => {
|
|
const files = (file instanceof FileList || Array.isArray(file)) ? Array.from(file) : [file];
|
|
const formData = new FormData();
|
|
for (const f of files) formData.append('files', f);
|
|
if (folderId) formData.append('folder_id', folderId);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('POST', `${API_BASE}/content`);
|
|
const token = localStorage.getItem('token');
|
|
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
|
if (onProgress) {
|
|
xhr.upload.onprogress = (e) => {
|
|
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
|
|
};
|
|
}
|
|
xhr.onload = () => {
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
resolve(JSON.parse(xhr.responseText));
|
|
} else {
|
|
reject(new Error('Upload failed'));
|
|
}
|
|
};
|
|
xhr.onerror = () => reject(new Error('Upload failed'));
|
|
xhr.send(formData);
|
|
});
|
|
},
|
|
|
|
addRemoteContent: (url, name, mime_type) => request('/content/remote', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ url, name, mime_type })
|
|
}),
|
|
|
|
addYoutubeContent: (url, name) => request('/content/youtube', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ url, name })
|
|
}),
|
|
|
|
// Assignments
|
|
getAssignments: (deviceId) => request(`/assignments/device/${deviceId}`),
|
|
addAssignment: (deviceId, data) => request(`/assignments/device/${deviceId}`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
}),
|
|
updateAssignment: (id, data) => request(`/assignments/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
deleteAssignment: (id) => request(`/assignments/${id}`, { method: 'DELETE' }),
|
|
reorderAssignments: (deviceId, order) => request(`/assignments/device/${deviceId}/reorder`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ order })
|
|
}),
|
|
|
|
// Widgets
|
|
getWidgets: () => request('/widgets'),
|
|
getWidget: (id) => request('/widgets/' + id),
|
|
|
|
// Device Groups
|
|
getGroups: () => request('/groups'),
|
|
createGroup: (name, color) => request('/groups', { method: 'POST', body: JSON.stringify({ name, color }) }),
|
|
updateGroup: (id, data) => request(`/groups/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
resyncGroup: (id) => request(`/groups/${id}/resync`, { method: 'POST' }),
|
|
deleteGroup: (id) => request(`/groups/${id}`, { method: 'DELETE' }),
|
|
getGroupDevices: (id) => request(`/groups/${id}/devices`),
|
|
addDeviceToGroup: (groupId, device_id) => request(`/groups/${groupId}/devices`, { method: 'POST', body: JSON.stringify({ device_id }) }),
|
|
removeDeviceFromGroup: (groupId, deviceId) => request(`/groups/${groupId}/devices/${deviceId}`, { method: 'DELETE' }),
|
|
sendGroupCommand: (groupId, type, payload) => request(`/groups/${groupId}/command`, { method: 'POST', body: JSON.stringify({ type, payload }) }),
|
|
|
|
// Video walls
|
|
getWalls: () => request('/walls'),
|
|
createWall: (data) => request('/walls', { method: 'POST', body: JSON.stringify(data) }),
|
|
setWallDevices: (id, devices) => request(`/walls/${id}/devices`, { method: 'PUT', body: JSON.stringify({ devices }) }),
|
|
updateWall: (id, data) => request(`/walls/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
deleteWall: (id) => request(`/walls/${id}`, { method: 'DELETE' }),
|
|
|
|
// Playlists
|
|
getPlaylists: () => request('/playlists'),
|
|
createPlaylist: (name, description) => request('/playlists', { method: 'POST', body: JSON.stringify({ name, description }) }),
|
|
getPlaylist: (id) => request(`/playlists/${id}`),
|
|
updatePlaylist: (id, data) => request(`/playlists/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
deletePlaylist: (id) => request(`/playlists/${id}`, { method: 'DELETE' }),
|
|
getPlaylistItems: (id) => request(`/playlists/${id}/items`),
|
|
addPlaylistItem: (id, data) => request(`/playlists/${id}/items`, { method: 'POST', body: JSON.stringify(data) }),
|
|
updatePlaylistItem: (id, itemId, data) => request(`/playlists/${id}/items/${itemId}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
deletePlaylistItem: (id, itemId) => request(`/playlists/${id}/items/${itemId}`, { method: 'DELETE' }),
|
|
duplicatePlaylistItem: (id, itemId) => request(`/playlists/${id}/items/${itemId}/duplicate`, { method: 'POST' }),
|
|
reorderPlaylistItems: (id, order) => request(`/playlists/${id}/items/reorder`, { method: 'POST', body: JSON.stringify({ order }) }),
|
|
// #74/#75 per-item schedule blocks
|
|
getItemSchedules: (id, itemId) => request(`/playlists/${id}/items/${itemId}/schedules`),
|
|
setItemSchedules: (id, itemId, blocks) => request(`/playlists/${id}/items/${itemId}/schedules`, { method: 'PUT', body: JSON.stringify({ blocks }) }),
|
|
assignPlaylistToDevice: (playlistId, device_id) => request(`/playlists/${playlistId}/assign`, { method: 'POST', body: JSON.stringify({ device_id }) }),
|
|
clearDevicePlaylist: (device_id) => request(`/devices/${device_id}/playlist`, { method: 'DELETE' }),
|
|
publishPlaylist: (id) => request(`/playlists/${id}/publish`, { method: 'POST' }),
|
|
discardPlaylistDraft: (id) => request(`/playlists/${id}/discard`, { method: 'POST' }),
|
|
|
|
// Device Groups - Playlist
|
|
groupAssignPlaylist: (groupId, playlist_id) => request(`/groups/${groupId}/assign-playlist`, { method: 'POST', body: JSON.stringify({ playlist_id }) }),
|
|
|
|
// API Tokens (personal access tokens, workspace-scoped)
|
|
getTokens: () => request('/tokens'),
|
|
createToken: (data) => request('/tokens', { method: 'POST', body: JSON.stringify(data) }),
|
|
revokeToken: (id) => request('/tokens/' + id, { method: 'DELETE' }),
|
|
setTokenTargets: (id, target_playlist_ids) => request('/tokens/' + id + '/targets', { method: 'PUT', body: JSON.stringify({ target_playlist_ids }) }), // #73: re-designate agency token playlists
|
|
setTokenUploadFolder: (id, upload_folder_id) => request('/tokens/' + id + '/upload-folder', { method: 'PUT', body: JSON.stringify({ upload_folder_id }) }), // #158: rebind agency token upload folder (null = root)
|
|
|
|
// TOTP 2FA (#100) — opt-in per-user, local accounts only. See routes/auth.js.
|
|
totpStatus: () => request('/auth/totp/status'),
|
|
// Unlink an instance-wide SSO provider. The new password is required in the same call:
|
|
// the account must never sit between credentials.
|
|
ssoUnlink: (password) => request('/auth/oidc/unlink', { method: 'POST', body: JSON.stringify({ password }) }),
|
|
// Returns { url } to navigate to. Fetched rather than navigated to, because the session is
|
|
// a bearer token and a top-level navigation cannot carry one.
|
|
ssoLinkStart: (slug) => request(`/auth/oidc/${encodeURIComponent(slug)}/link/start`),
|
|
totpSetup: () => request('/auth/totp/setup', { method: 'POST' }),
|
|
totpEnable: (code) => request('/auth/totp/enable', { method: 'POST', body: JSON.stringify({ code }) }),
|
|
totpDisable: (code) => request('/auth/totp/disable', { method: 'POST', body: JSON.stringify({ code }) }),
|
|
totpRegenRecovery: (code) => request('/auth/totp/recovery-codes/regenerate', { method: 'POST', body: JSON.stringify({ code }) }),
|
|
|
|
// Email verification (signup). Resend is generic (never reveals whether the address exists).
|
|
resendVerification: (email) => request('/auth/resend-verification', { method: 'POST', body: JSON.stringify({ email }) }),
|
|
|
|
// Current user
|
|
getMe: () => request('/auth/me'),
|
|
updateMe: (data) => request('/auth/me', { method: 'PUT', body: JSON.stringify(data) }),
|
|
switchWorkspace: (workspaceId) => request('/auth/switch-workspace', { method: 'POST', body: JSON.stringify({ workspace_id: workspaceId }) }),
|
|
renameWorkspace: (id, data) => request(`/workspaces/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
updateWorkspaceSecuritySettings: (workspaceId, data) => request(`/workspaces/${workspaceId}/security-settings`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
|
|
// Workspace members + invites (slice 2A read-only)
|
|
getWorkspaceMembers: (id) => request(`/workspaces/${id}/members`),
|
|
getWorkspaceInvites: (id) => request(`/workspaces/${id}/invites`),
|
|
|
|
// Workspace member/invite mutations (slice 2B). All admin-only server-side
|
|
// (canAdminWorkspace gate). Server returns translated English error messages
|
|
// mapped to i18n keys via mapMutationError() in workspace-members.js.
|
|
inviteWorkspaceMember: (workspaceId, data) => request(`/workspaces/${workspaceId}/invites`, { method: 'POST', body: JSON.stringify(data) }),
|
|
cancelWorkspaceInvite: (workspaceId, inviteId) => request(`/workspaces/${workspaceId}/invites/${inviteId}`, { method: 'DELETE' }),
|
|
updateWorkspaceMemberRole: (workspaceId, userId, role) => request(`/workspaces/${workspaceId}/members/${userId}`, { method: 'PUT', body: JSON.stringify({ role }) }),
|
|
removeWorkspaceMember: (workspaceId, userId) => request(`/workspaces/${workspaceId}/members/${userId}`, { method: 'DELETE' }),
|
|
|
|
// Slice 2C - accept a workspace invite by id (post-auth flow)
|
|
acceptInvite: (inviteId) => request(`/auth/accept-invite/${inviteId}`, { method: 'POST' }),
|
|
|
|
// Admin-provisioned user creation (#10). data: { email, name, password,
|
|
// workspaceId, role, mustChangePassword }
|
|
adminCreateUser: (data) => request('/admin/users', { method: 'POST', body: JSON.stringify(data) }),
|
|
adminCreateOrg: (name) => request('/admin/orgs', { method: 'POST', body: JSON.stringify({ name }) }),
|
|
adminListOrgs: () => request('/admin/orgs'),
|
|
// Platform-admin view: EVERY plan incl. hidden ones, with subscriber counts.
|
|
adminListPlans: () => request('/admin/plans'),
|
|
adminDeleteOrg: (id) => request(`/admin/orgs/${id}`, { method: 'DELETE' }),
|
|
adminDeleteWorkspace: (id) => request(`/admin/workspaces/${id}`, { method: 'DELETE' }),
|
|
aiGetSettings: () => request('/ai/settings'),
|
|
aiSaveSettings: (data) => request('/ai/settings', { method: 'PUT', body: JSON.stringify(data) }),
|
|
aiGenerateDesign: (prompt) => request('/ai/generate-design', { method: 'POST', body: JSON.stringify({ prompt }) }),
|
|
aiListModels: (base_url, api_key) => request('/ai/models', { method: 'POST', body: JSON.stringify({ base_url, api_key }) }),
|
|
|
|
// Instance-level default branding (#15, platform admin).
|
|
adminGetBranding: () => request('/admin/branding'),
|
|
adminSetBranding: (data) => request('/admin/branding', { method: 'PUT', body: JSON.stringify(data) }),
|
|
// #146: toggle the /api/status debug block exposure (platform-admin only).
|
|
adminGetStatusDebug: () => request('/admin/status-debug'),
|
|
adminSetStatusDebug: (enabled) => request('/admin/status-debug', { method: 'PUT', body: JSON.stringify({ enabled }) }),
|
|
// Opt-in install statistics. GET returns { state, payload, last_report } — payload is the exact
|
|
// body that would be sent, so the UI can show it rather than describe it.
|
|
adminGetTelemetry: () => request('/admin/telemetry'),
|
|
adminSetTelemetry: (enabled) => request('/admin/telemetry', { method: 'PUT', body: JSON.stringify({ enabled }) }),
|
|
|
|
// Per-user workspace membership management (platform Users page modal).
|
|
adminGetUserWorkspaces: (id) => request(`/admin/users/${id}/workspaces`),
|
|
adminAddUserWorkspace: (id, workspaceId, role) => request(`/admin/users/${id}/workspaces`, { method: 'POST', body: JSON.stringify({ workspaceId, role }) }),
|
|
adminSetUserWorkspaceRole: (id, workspaceId, role) => request(`/admin/users/${id}/workspaces/${workspaceId}`, { method: 'PUT', body: JSON.stringify({ role }) }),
|
|
adminRemoveUserWorkspace: (id, workspaceId) => request(`/admin/users/${id}/workspaces/${workspaceId}`, { method: 'DELETE' }),
|
|
|
|
// Admin - Users
|
|
getUsers: () => request('/auth/users'),
|
|
deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }),
|
|
resetUserPassword: (id, password) => request(`/auth/users/${id}/password`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ password }),
|
|
}),
|
|
assignPlan: (user_id, plan_id) => request('/subscription/assign', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ user_id, plan_id })
|
|
}),
|
|
};
|