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

Commit

Permalink
fix: send all available sessions in cookie within limits (#14)
Browse files Browse the repository at this point in the history
  • Loading branch information
Alexandre Chau committed Mar 21, 2023
1 parent a537b7f commit 71b1290
Show file tree
Hide file tree
Showing 4 changed files with 328 additions and 14 deletions.
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,
);
77 changes: 72 additions & 5 deletions src/service-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
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 @@ -24,6 +24,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 @@ -93,7 +94,12 @@ const plugin: FastifyPluginAsync<EtherpadPluginOptions> = async (fastify, option
return await taskRunner.runSingleSequence(createItem);
} catch (error) {
// create item failed, delete created pad
etherpad.deletePad({ padID: buildPadID({ groupID, padName }) });
const padID = buildPadID({ groupID, padName });
etherpad
.deletePad({ padID })
.catch((e) =>
log.error(`${PLUGIN_NAME}: failed to delete orphan etherpad ${padID}`, e),
);
throw error;
}
},
Expand Down Expand Up @@ -179,11 +185,72 @@ const plugin: FastifyPluginAsync<EtherpadPluginOptions> = async (fastify, option
const { sessionID } = await etherpad.createSession({
authorID,
groupID,
validUntil: expiration.toSeconds(),
validUntil: expiration.toUnixInteger(),
});

// 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
Loading

0 comments on commit 71b1290

Please sign in to comment.