Skip to content
This repository has been archived by the owner on Jun 29, 2023. It is now read-only.

fix: send all available sessions in cookie within limits #14

Merged
merged 3 commits into from
Mar 21, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
27 changes: 27 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,29 @@
export const PLUGIN_NAME = 'graasp-plugin-etherpad';
export const ETHERPAD_API_VERSION = '1.2.13';

/**
* User agents are now required to limit the sum of the lengths of the cookie's
* name and value to 4096 bytes, and limit the length of each cookie attribute
* value to 1024 bytes. Any attempt to set a cookie exceeding the name+value
* limit is rejected, and any cookie attribute exceeding the attribute length
* limit is ignored. See https://chromestatus.com/feature/4946713618939904
*/
export const MAX_COOKIE_VALUE_SIZE_BYTES = 1024;

/**
* sessionID a string, the unique id of a session. Format is s.16RANDOMCHARS
* for example s.s8oes9dhwrvt0zif (length is 18)
* See https://etherpad.org/doc/v1.8.18/#index_data-types
* We add a comma since the session IDs are joined (length + 1)
* The session cookie can also contain multiple comma-separated sessionIDs
* See https://etherpad.org/doc/v1.8.18/#index_session
*/
export const SESSION_VALUE_COOKIE_LENGTH = 19;

/**
* Thus the maximum cookie size for etherpad sessions is
* _MAX_COOKIE_VALUE_SIZE_BYTES / SESSION_VALUE_COOKIE_LENGTH_
*/
export const MAX_SESSIONS_IN_COOKIE = Math.floor(
MAX_COOKIE_VALUE_SIZE_BYTES / SESSION_VALUE_COOKIE_LENGTH,
);
68 changes: 65 additions & 3 deletions src/service-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { FastifyInstance, FastifyPluginAsync } from 'fastify';

import { Item, ItemType, PermissionLevel, PermissionLevelCompare } from '@graasp/sdk';

import { ETHERPAD_API_VERSION } from './constants';
import { ETHERPAD_API_VERSION, MAX_SESSIONS_IN_COOKIE, PLUGIN_NAME } from './constants';
import { AccessForbiddenError, ItemMissingExtraError, ItemNotFoundError } from './errors';
import { GraaspEtherpad } from './etherpad';
import { createEtherpad, getEtherpadFromItem } from './schemas';
Expand All @@ -18,6 +18,7 @@ const plugin: FastifyPluginAsync<EtherpadPluginOptions> = async (fastify, option
items: { taskManager: itemTaskManager },
itemMemberships: { taskManager: itemMembershipTaskManager },
taskRunner,
log,
} = fastify;

const { url: etherpadUrl, publicUrl, apiKey, cookieDomain } = validatePluginOptions(options);
Expand Down Expand Up @@ -176,8 +177,69 @@ const plugin: FastifyPluginAsync<EtherpadPluginOptions> = async (fastify, option
validUntil: expiration.toSeconds(),
});

// set cookie
reply.setCookie('sessionID', sessionID, {
// get available sessions for user
const sessions = (await etherpad.listSessionsOfAuthor({ authorID })) ?? {};

// split valid from expired cookies
const now = DateTime.now();
const { valid, expired } = Object.entries(sessions).reduce(
({ valid, expired }, [id, { validUntil }]) => {
const isExpired = DateTime.fromSeconds(validUntil) >= now;
isExpired ? expired.add(id) : valid.add(id);
return { valid, expired };
},
{
valid: new Set<string>(),
expired: new Set<string>(),
},
);
// sanity check, add the new sessionID (should already be part of the set)
valid.add(sessionID);

// in practice, there is (probably) a limit of 1024B per cookie value
// https://chromestatus.com/feature/4946713618939904
// so we can only store up to limit / (size of sessionID string + ",")
// assuming that no other cookies are set on the etherpad domain
// to err on the cautious side, we invalidate the oldest cookies in this case
if (valid.size > MAX_SESSIONS_IN_COOKIE) {
const sortedRecent = Array.from(valid).sort((a, b) => {
// return inversed for most recent
const timeA = DateTime.fromSeconds(sessions[a].validUntil);
const timeB = DateTime.fromSeconds(sessions[b].validUntil);
if (timeA < timeB) {
return 1;
}
if (timeA > timeB) {
return -1;
}
return 0;
});

const toInvalidate = sortedRecent.slice(MAX_SESSIONS_IN_COOKIE);

// mutate valid and expired sets in place
toInvalidate.forEach((id) => {
valid.delete(id);
expired.add(id);
});
}

// delete expired cookies asynchronously in the background, accept failures by catching
expired.forEach((sessionID) => {
etherpad
.deleteSession({ sessionID })
.catch((e) =>
log.error(
`${PLUGIN_NAME}: failed to delete etherpad session ${sessionID}`,
sessions[sessionID],
e,
),
);
});

// set cookie with all valid cookies (users should be able to access multiple etherpads simultaneously)
const cookieValue = Array.from(valid).join(',');
reply.setCookie('sessionID', cookieValue, {
domain: cookieDomain,
path: '/',
expires: expiration.toJSDate(),
Expand Down
2 changes: 1 addition & 1 deletion test/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ type Awaited<T> = T extends PromiseLike<infer U> ? U : T;
export type BuildAppType = Awaited<ReturnType<typeof buildApp>>;

export async function buildApp(args?: { options?: EtherpadPluginOptions }) {
const app = fastify();
const app = fastify({ logger: true });

app.register(fastifyCookie);

Expand Down