add a basic system for third-party applications to provide transparency about data collection practices

This commit is contained in:
hippoz 2022-10-30 01:05:20 +03:00
parent 2d1f491aa3
commit 49d8032638
No known key found for this signature in database
GPG key ID: 7C52899193467641
11 changed files with 214 additions and 19 deletions

View file

@ -306,7 +306,7 @@ body {
font-size: 1.2rem; font-size: 1.2rem;
} }
.text_small { .text-small {
font-size: 0.833rem; font-size: 0.833rem;
} }

View file

@ -1,13 +1,31 @@
<script> <script>
import { quadInOut } from "svelte/easing"; import { quadInOut } from "svelte/easing";
import { maybeFly, maybeFlyIf } from "../animations"; import { maybeFly, maybeFlyIf } from "../animations";
import { presenceStore, showPresenceSidebar, smallViewport } from "../stores"; import { overlayStore, OverlayType, presenceStore, showPresenceSidebar, smallViewport } from "../stores";
import UserInfo from "./overlays/UserInfo.svelte";
const close = () => { const close = () => {
$showPresenceSidebar = false; $showPresenceSidebar = false;
}; };
</script> </script>
<style>
.user-badge {
display: inline-flex;
justify-content: center;
align-items: center;
background-color: var(--purple-2);
padding-top: 1px;
padding-bottom: 1px;
padding-left: 0.375rem;
padding-right: 0.375rem;
border-radius: 9999px;
font-size: x-small;
margin-left: var(--space-sm);
cursor: pointer;
}
</style>
<div class="sidebar-container" in:maybeFly="{{ duration: 175, easing: quadInOut, x: 10 }}" out:maybeFlyIf="{{ _condition: !smallViewport.value, duration: 175, easing: quadInOut, x: -10 }}"> <div class="sidebar-container" in:maybeFly="{{ duration: 175, easing: quadInOut, x: 10 }}" out:maybeFlyIf="{{ _condition: !smallViewport.value, duration: 175, easing: quadInOut, x: -10 }}">
<div class="top-bar"> <div class="top-bar">
<span class="input-label">User List</span> <span class="input-label">User List</span>
@ -17,6 +35,9 @@
<button class="sidebar-button"> <button class="sidebar-button">
<div class="material-icons-outlined">alternate_email</div> <div class="material-icons-outlined">alternate_email</div>
<span class="sidebar-button-text">{ entry.user.username }</span> <span class="sidebar-button-text">{ entry.user.username }</span>
{#if entry.bridgesTo || entry.privacy || entry.terms}
<span class="user-badge" on:click={ () => overlayStore.push(OverlayType.UserInfo, { presenceEntry: entry }) }>SERVICE</span>
{/if}
</button> </button>
{/each} {/each}
{#if $smallViewport} {#if $smallViewport}

View file

@ -1,7 +1,8 @@
<script> <script>
import { quadInOut } from "svelte/easing"; import { quadInOut } from "svelte/easing";
import { maybeFly, maybeFlyIf } from "../animations"; import { maybeFly, maybeFlyIf } from "../animations";
import { channels, gatewayStatus, overlayStore, selectedChannel, showSidebar, smallViewport, userInfoStore, unreadStore, OverlayType } from "../stores"; import { channels, gatewayStatus, overlayStore, selectedChannel, showSidebar, smallViewport, userInfoStore, unreadStore, OverlayType, thirdPartyServicePresenceStore } from "../stores";
import ThirdPartyNotice from "./overlays/ThirdPartyNotice.svelte";
import UserTopBar from "./UserTopBar.svelte"; import UserTopBar from "./UserTopBar.svelte";
const selectChannel = (channel) => { const selectChannel = (channel) => {
@ -43,6 +44,13 @@
<span class="sidebar-button-text">Settings</span> <span class="sidebar-button-text">Settings</span>
</button> </button>
</div> </div>
{#if $thirdPartyServicePresenceStore}
<div class="top-bar darker">
<span class="material-icons-outlined">cloud</span>
<span class="text-small top-bar-heading">some third party services may have additional terms</span>
<button class="button" on:click={ () => overlayStore.push(OverlayType.ThirdPartyNotice) }>Review</button>
</div>
{/if}
{#if !$gatewayStatus.ready} {#if !$gatewayStatus.ready}
<div class="top-bar darker"> <div class="top-bar darker">
<span class="material-icons-outlined">cloud</span> <span class="material-icons-outlined">cloud</span>

View file

@ -9,6 +9,8 @@
import EditMessage from "./EditMessage.svelte"; import EditMessage from "./EditMessage.svelte";
import Settings from "./Settings.svelte"; import Settings from "./Settings.svelte";
import Prompt from "./Prompt.svelte"; import Prompt from "./Prompt.svelte";
import UserInfo from "./UserInfo.svelte";
import ThirdPartyNotice from "./ThirdPartyNotice.svelte";
const OverlayComponent = { const OverlayComponent = {
0: CreateChannel, 0: CreateChannel,
@ -19,6 +21,8 @@
5: EditMessage, 5: EditMessage,
6: Settings, 6: Settings,
7: Prompt, 7: Prompt,
8: UserInfo,
9: ThirdPartyNotice,
}; };
</script> </script>

View file

@ -0,0 +1,53 @@
<script>
import { maybeModalFade, maybeModalScale } from "../../animations";
import { overlayStore, OverlayType, presenceStore } from "../../stores";
export let close = () => {};
</script>
<style>
.info-modal {
max-width: 560px;
}
.notice-user-card {
display: flex;
align-items: center;
justify-content: left;
padding: var(--space-md);
}
.notice-user-card-buttons {
margin-left: auto;
}
.full-width {
width: 100%;
}
</style>
<div class="modal-backdrop" transition:maybeModalFade on:click="{ close }">
<div class="modal info-modal" transition:maybeModalScale on:click|stopPropagation>
<div class="modal-header">
<span class="h4">Third Party Services</span>
</div>
<p>Certain third-party services on this server may have different data processing, data usage, and data collection policies, as well as Terms of Service or similar. Please carefully review each service in the list below. If you do not agree with any of these policies, you may be able to ask the user to opt out, or make an account deletion request to the server owner. The server owner may be able to aid you in the process of opting out.</p>
{#each $presenceStore as entry (entry.user.id)}
{#if entry.bridgesTo || entry.terms || entry.privacy}
<div class="notice-user-card full-width">
<span class="material-icons-outlined">alternate_email</span>
<span class="h5 top-bar-heading">{ entry.user.username }</span>
<div class="notice-user-card-buttons">
<button class="button" on:click="{ () => overlayStore.push(OverlayType.UserInfo, { presenceEntry: entry }) }">View Info</button>
</div>
</div>
{/if}
{/each}
<div class="modal-footer">
<button class="button modal-secondary-action" on:click="{ close }">Cancel</button>
</div>
</div>
</div>

View file

@ -0,0 +1,39 @@
<script>
import { maybeModalFade, maybeModalScale } from "../../animations";
export let presenceEntry;
export let close = () => {};
</script>
<style>
.user-info-modal {
max-width: 560px;
}
</style>
<div class="modal-backdrop" transition:maybeModalFade on:click="{ close }">
<div class="modal user-info-modal" transition:maybeModalScale on:click|stopPropagation>
<div class="modal-header">
<span class="h4">{ presenceEntry.user.username }</span>
</div>
<div>
{#if presenceEntry.bridgesTo}
<p>This user may send messages and other metadata to <b>{presenceEntry.bridgesTo}</b>, which may have its own Terms of Service and Privacy Policy.</p>
{/if}
{#if presenceEntry.privacy}
<p>This user has their own Privacy Policy, available at: <b>{ presenceEntry.privacy }</b>. This user may process the data it has access to on this server under different terms than the server. Please consult their Privacy Policy.</p>
{/if}
{#if presenceEntry.terms}
<p>This user has their own Terms of Service, available at: <b>{ presenceEntry.terms }</b>. Failure to comply with these Terms of Service may result in termination of the services provided to you by this user.</p>
{/if}
{#if presenceEntry.bridgesTo || presenceEntry.privacy || presenceEntry.terms}
<p>You may be able to ask the user to opt out of the above.</p>
{/if}
</div>
<div class="modal-footer">
<button class="button modal-secondary-action" on:click="{ close }">Cancel</button>
</div>
</div>
</div>

View file

@ -82,7 +82,9 @@ export default {
case GatewayPayloadType.Hello: { case GatewayPayloadType.Hello: {
this.send({ this.send({
t: GatewayPayloadType.Authenticate, t: GatewayPayloadType.Authenticate,
d: token d: {
token,
}
}); });
this.heartbeatInterval = setInterval(() => { this.heartbeatInterval = setInterval(() => {

View file

@ -401,6 +401,8 @@ export const OverlayType = {
EditMessage: 5, EditMessage: 5,
Settings: 6, Settings: 6,
Prompt: 7, Prompt: 7,
UserInfo: 8,
ThirdPartyNotice: 9
}; };
class OverlayStore extends Store { class OverlayStore extends Store {
constructor() { constructor() {
@ -566,7 +568,10 @@ class PresenceStore extends Store {
} else { } else {
// don't need to push the status, since we remove offline members from the presence list // don't need to push the status, since we remove offline members from the presence list
this.value.push({ this.value.push({
user: entry.user user: entry.user,
bridgesTo: entry.bridgesTo,
privacy: entry.privacy,
terms: entry.terms
}); });
} }
}); });
@ -647,6 +652,23 @@ class PluginStore extends Store {
} }
} }
class ThirdPartyServicePresenceStore extends Store {
constructor() {
super(false, "ThirdPartyServicePresenceStore");
presenceStore.subscribe((value) => {
let hasService = false;
value.forEach(e => {
if (e.bridgesTo || e.privacy || e.terms) {
hasService = true;
return;
}
});
this.set(hasService);
});
}
}
export const selectedChannel = new Store({ id: -1, name: "none", creator_id: -1 }, "selectedChannel"); export const selectedChannel = new Store({ id: -1, name: "none", creator_id: -1 }, "selectedChannel");
export const showSidebar = new Store(true, "showSidebar"); export const showSidebar = new Store(true, "showSidebar");
export const showPresenceSidebar = new Store(false, "showPresenceSidebar"); export const showPresenceSidebar = new Store(false, "showPresenceSidebar");
@ -663,6 +685,7 @@ export const typingStore = new TypingStore();
export const presenceStore = new PresenceStore(); export const presenceStore = new PresenceStore();
export const unreadStore = new UnreadStore(); export const unreadStore = new UnreadStore();
export const pluginStore = new PluginStore(); export const pluginStore = new PluginStore();
export const thirdPartyServicePresenceStore = new ThirdPartyServicePresenceStore();
export const setMessageInputEvent = new Store(null, "event:setMessageInput"); export const setMessageInputEvent = new Store(null, "event:setMessageInput");
export const sendMessageAction = createAction("sendMessageAction", async ({channelId, content}) => { export const sendMessageAction = createAction("sendMessageAction", async ({channelId, content}) => {

View file

@ -3,7 +3,10 @@ import { GatewayPresenceStatus } from "./gatewaypayloadtype"
export interface GatewayPresenceEntry { export interface GatewayPresenceEntry {
user: { user: {
username: string, username: string,
id: number id: number,
}, },
bridgesTo?: string,
privacy?: string,
terms?: string,
status: GatewayPresenceStatus status: GatewayPresenceStatus
} }

View file

@ -182,14 +182,29 @@ function sendPayload(ws: WebSocket, payload: GatewayPayload) {
ws.send(JSON.stringify(payload)); ws.send(JSON.stringify(payload));
} }
function getPresenceEntryForUser(user: User, status: GatewayPresenceStatus): GatewayPresenceEntry { function getPresenceEntryForConnection(ws: WebSocket, status: GatewayPresenceStatus): GatewayPresenceEntry | null {
return { if (!ws.state || !ws.state.ready || !ws.state.user) {
return null;
}
const entry: GatewayPresenceEntry = {
user: { user: {
id: user.id, id: ws.state.user.id,
username: user.username username: ws.state.user.username,
}, },
status status
};
if (typeof ws.state.bridgesTo === "string") {
entry.bridgesTo = ws.state.bridgesTo;
} }
if (typeof ws.state.privacy === "string") {
entry.privacy = ws.state.privacy;
}
if (typeof ws.state.terms === "string") {
entry.terms = ws.state.terms;
}
return entry;
} }
// The initial presence entries are sent right when the user connects. // The initial presence entries are sent right when the user connects.
@ -204,7 +219,10 @@ function getInitialPresenceEntries(): GatewayPresenceEntry[] {
const firstWs = wsList[0]; const firstWs = wsList[0];
if (firstWs.state.ready && firstWs.state.user) { if (firstWs.state.ready && firstWs.state.user) {
entries.push(getPresenceEntryForUser(firstWs.state.user, GatewayPresenceStatus.Online)); const entry = getPresenceEntryForConnection(firstWs, GatewayPresenceStatus.Online);
if (entry) {
entries.push(entry);
}
} }
}); });
@ -242,7 +260,8 @@ export default function(server: Server) {
ready: false, ready: false,
lastAliveCheck: performance.now(), lastAliveCheck: performance.now(),
dispatchChannels: new Set(), dispatchChannels: new Set(),
messagesSinceLastCheck: 0 messagesSinceLastCheck: 0,
bridgesTo: undefined
}; };
sendPayload(ws, { sendPayload(ws, {
@ -266,7 +285,7 @@ export default function(server: Server) {
// user no longer has any sessions, update presence // user no longer has any sessions, update presence
dispatch("*", { dispatch("*", {
t: GatewayPayloadType.PresenceUpdate, t: GatewayPayloadType.PresenceUpdate,
d: [getPresenceEntryForUser(ws.state.user, GatewayPresenceStatus.Offline)] d: [getPresenceEntryForConnection(ws, GatewayPresenceStatus.Offline)]
}); });
} }
} }
@ -299,11 +318,28 @@ export default function(server: Server) {
return closeWithError(ws, gatewayErrors.ALREADY_AUTHENTICATED); return closeWithError(ws, gatewayErrors.ALREADY_AUTHENTICATED);
} }
const token = payload.d; const authData = payload.d;
if (typeof token !== "string") { if (typeof authData !== "object") {
return closeWithBadPayload(ws, "d: expected string"); return closeWithBadPayload(ws, "d: expected object");
} }
const user = await decodeTokenOrNull(token);
if (typeof authData.token !== "string") {
return closeWithBadPayload(ws, "d: invalid field 'token'");
}
if (typeof authData.bridgesTo !== "undefined" && typeof authData.bridgesTo !== "string" && authData.bridgesTo.length > 40) {
return closeWithBadPayload(ws, "d: invalid field 'bridgesTo'");
}
if (typeof authData.privacy !== "undefined" && typeof authData.privacy !== "string" && authData.privacy.length > 200) {
return closeWithBadPayload(ws, "d: invalid field 'privacy'");
}
if (typeof authData.terms !== "undefined" && typeof authData.terms !== "string" && authData.terms.length > 200) {
return closeWithBadPayload(ws, "d: invalid field 'terms'");
}
const user = await decodeTokenOrNull(authData.token);
if (!user) { if (!user) {
return closeWithError(ws, gatewayErrors.BAD_AUTH); return closeWithError(ws, gatewayErrors.BAD_AUTH);
} }
@ -332,12 +368,15 @@ export default function(server: Server) {
}); });
ws.state.user = user; ws.state.user = user;
ws.state.bridgesTo = authData.bridgesTo;
ws.state.privacy = authData.privacy;
ws.state.terms = authData.terms;
// first session, notify others that we are online // first session, notify others that we are online
if (sessions.length === 1) { if (sessions.length === 1) {
dispatch("*", { dispatch("*", {
t: GatewayPayloadType.PresenceUpdate, t: GatewayPayloadType.PresenceUpdate,
d: [getPresenceEntryForUser(ws.state.user, GatewayPresenceStatus.Online)] d: [getPresenceEntryForConnection(ws, GatewayPresenceStatus.Online)]
}); });
} }

View file

@ -4,5 +4,8 @@ interface GatewayClientState {
alive: boolean, alive: boolean,
lastAliveCheck: number, lastAliveCheck: number,
dispatchChannels: Set<string>, dispatchChannels: Set<string>,
messagesSinceLastCheck: number messagesSinceLastCheck: number,
bridgesTo?: string
privacy?: string,
terms?: string,
} }