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
Signed by: hippoz
GPG key ID: 7C52899193467641
11 changed files with 214 additions and 19 deletions

View file

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

View file

@ -1,13 +1,31 @@
<script>
import { quadInOut } from "svelte/easing";
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 = () => {
$showPresenceSidebar = false;
};
</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="top-bar">
<span class="input-label">User List</span>
@ -17,6 +35,9 @@
<button class="sidebar-button">
<div class="material-icons-outlined">alternate_email</div>
<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>
{/each}
{#if $smallViewport}

View file

@ -1,7 +1,8 @@
<script>
import { quadInOut } from "svelte/easing";
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";
const selectChannel = (channel) => {
@ -43,6 +44,13 @@
<span class="sidebar-button-text">Settings</span>
</button>
</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}
<div class="top-bar darker">
<span class="material-icons-outlined">cloud</span>

View file

@ -9,6 +9,8 @@
import EditMessage from "./EditMessage.svelte";
import Settings from "./Settings.svelte";
import Prompt from "./Prompt.svelte";
import UserInfo from "./UserInfo.svelte";
import ThirdPartyNotice from "./ThirdPartyNotice.svelte";
const OverlayComponent = {
0: CreateChannel,
@ -19,6 +21,8 @@
5: EditMessage,
6: Settings,
7: Prompt,
8: UserInfo,
9: ThirdPartyNotice,
};
</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: {
this.send({
t: GatewayPayloadType.Authenticate,
d: token
d: {
token,
}
});
this.heartbeatInterval = setInterval(() => {

View file

@ -401,6 +401,8 @@ export const OverlayType = {
EditMessage: 5,
Settings: 6,
Prompt: 7,
UserInfo: 8,
ThirdPartyNotice: 9
};
class OverlayStore extends Store {
constructor() {
@ -566,7 +568,10 @@ class PresenceStore extends Store {
} else {
// don't need to push the status, since we remove offline members from the presence list
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 showSidebar = new Store(true, "showSidebar");
export const showPresenceSidebar = new Store(false, "showPresenceSidebar");
@ -663,6 +685,7 @@ export const typingStore = new TypingStore();
export const presenceStore = new PresenceStore();
export const unreadStore = new UnreadStore();
export const pluginStore = new PluginStore();
export const thirdPartyServicePresenceStore = new ThirdPartyServicePresenceStore();
export const setMessageInputEvent = new Store(null, "event:setMessageInput");
export const sendMessageAction = createAction("sendMessageAction", async ({channelId, content}) => {

View file

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

View file

@ -182,14 +182,29 @@ function sendPayload(ws: WebSocket, payload: GatewayPayload) {
ws.send(JSON.stringify(payload));
}
function getPresenceEntryForUser(user: User, status: GatewayPresenceStatus): GatewayPresenceEntry {
return {
function getPresenceEntryForConnection(ws: WebSocket, status: GatewayPresenceStatus): GatewayPresenceEntry | null {
if (!ws.state || !ws.state.ready || !ws.state.user) {
return null;
}
const entry: GatewayPresenceEntry = {
user: {
id: user.id,
username: user.username
id: ws.state.user.id,
username: ws.state.user.username,
},
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.
@ -204,7 +219,10 @@ function getInitialPresenceEntries(): GatewayPresenceEntry[] {
const firstWs = wsList[0];
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,
lastAliveCheck: performance.now(),
dispatchChannels: new Set(),
messagesSinceLastCheck: 0
messagesSinceLastCheck: 0,
bridgesTo: undefined
};
sendPayload(ws, {
@ -266,7 +285,7 @@ export default function(server: Server) {
// user no longer has any sessions, update presence
dispatch("*", {
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);
}
const token = payload.d;
if (typeof token !== "string") {
return closeWithBadPayload(ws, "d: expected string");
const authData = payload.d;
if (typeof authData !== "object") {
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) {
return closeWithError(ws, gatewayErrors.BAD_AUTH);
}
@ -332,12 +368,15 @@ export default function(server: Server) {
});
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
if (sessions.length === 1) {
dispatch("*", {
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,
lastAliveCheck: number,
dispatchChannels: Set<string>,
messagesSinceLastCheck: number
messagesSinceLastCheck: number,
bridgesTo?: string
privacy?: string,
terms?: string,
}