Merge pull request #8280 from keymanapp/refactor/web/cookie-serialization

refactor(web): cookie serialization + deserialization 🧩
This commit is contained in:
Joshua Horton 2023-02-24 08:15:45 +07:00 committed by GitHub
commit 884efc700b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 308 additions and 346 deletions

View file

@ -0,0 +1,91 @@
type DecodedCookieFieldValue = string | number | boolean;
type FilteredRecordEncoder = (value: DecodedCookieFieldValue, key: string) => string;
type FilteredRecordDecoder = (value: string, key: string) => DecodedCookieFieldValue;
const no_change = (val: string) => val as string;
export default class CookieSerializer<Type extends Record<keyof Type, DecodedCookieFieldValue>> {
readonly name: string;
constructor(name: string) {
this.name = name;
}
load(decoder?: FilteredRecordDecoder): Type {
return this.loadCookie(this.name, decoder || no_change) as Type;
}
save(cookie: Type, encoder?: FilteredRecordEncoder) {
this.saveCookie(this.name, cookie, encoder || no_change);
}
/**
* Document cookie parsing for use by kernel, OSK, UI etc.
*
* @return {Object} array of names and strings
*/
private _loadRawCookies(): Record<string, string> {
let v: Record<string, string> = {};
if(typeof(document.cookie) != 'undefined' && document.cookie != '') {
let c = document.cookie.split(/;\s*/);
for(let i = 0; i < c.length; i++) {
let d = c[i].split('=');
if(d.length == 2) {
v[d[0]] = d[1];
}
}
}
return v;
}
/**
* Document cookie parsing for use by kernel, OSK, UI etc.
*
* @param {string} cookieName cookie name
* @return {Object} array of variables and values
*/
private loadCookie(cookieName: string, decoder: FilteredRecordDecoder): Record<string, DecodedCookieFieldValue> {
let cookie: Record<string, DecodedCookieFieldValue> = {};
let allCookies = this._loadRawCookies();
const encodedCookie = allCookies[cookieName];
if(encodedCookie) {
let rawDecode = decodeURIComponent(encodedCookie).split(';');
for(let i=0; i<rawDecode.length; i++) {
// Prevent accidental empty-key entries caused by cookie-final ';'.
if(i == rawDecode.length - 1 && !rawDecode[i]) {
break;
}
let record = rawDecode[i].split('=');
if(record.length > 1) {
const [key, value] = record;
// key, value
cookie[key] = decoder(value, key);
} else {
// key, <implied 'true', as boolean flag>
cookie[record[0]] = '';
}
}
}
return cookie;
}
/**
* Standard cookie saving for use by kernel, OSK, UI etc.
*
* @param {string} cookieName name of cookie
* @param {Object} cookieValueMap object with array of named arguments and values
*/
private saveCookie(cookieName: string, cookieValueMap: Record<string, DecodedCookieFieldValue>, encoder: FilteredRecordEncoder) {
let serialization='';
for(let key in cookieValueMap) {
serialization += key + '=' + encoder(cookieValueMap[key], key) + ";";
}
let d = new Date(new Date().valueOf() + 1000 * 60 * 60 * 24 * 30).toUTCString();
let cookieConfig = ' path=/; expires=' + d; //Fri, 31 Dec 2099 23:59:59 GMT;';
document.cookie = `${cookieName}=${encodeURIComponent(serialization)}; ${cookieConfig}`;
}
}

View file

@ -1,4 +1,5 @@
export { getAbsoluteX, getAbsoluteY } from './getAbsolute.js';
export { default as createUnselectableElement } from './createUnselectableElement.js';
export { createStyleSheet, StylesheetManager } from './stylesheets.js';
export { default as landscapeView } from './landscapeView.js';
export { default as landscapeView } from './landscapeView.js';
export { default as CookieSerializer } from './cookieSerializer.js';

View file

@ -29,8 +29,6 @@ namespace com.keyman {
activeDevice: Device;
physicalDevice: Device;
linkedStylesheets: (HTMLLinkElement|HTMLStyleElement)[] = [];
waiting: HTMLDivElement; // The element displayed for util.wait and util.alert.
// An object mapping event names to individual event lists. Maps strings to arrays.
@ -262,37 +260,6 @@ namespace com.keyman {
setOption(optionName,value) {
this.keyman.options[optionName] = value;
}
// Unofficial API used by our desktop UIs.
getAbsoluteX(Pobj: HTMLElement): number {
return dom.Utils.getAbsoluteX(Pobj);
}
// Unofficial API used by our desktop UIs.
getAbsoluteY(Pobj: HTMLElement): number {
return dom.Utils.getAbsoluteY(Pobj);
}
/**
* Function getAbsolute
* Scope Public
* @param {Object} Pobj HTML element
* @return {Object.<string,number>}
* Description Returns absolute position of Pobj element with respect to page
*/
getAbsolute(Pobj: HTMLElement) {
var p={
/* @ export */
x: this.getAbsoluteX(Pobj),
/* @ export */
y: this.getAbsoluteY(Pobj)
};
return p;
}
// Unofficial API used by our desktop UIs.
_GetAbsolute = this.getAbsolute;
/**
* Select start handler (to replace multiple inline handlers) (Build 360)
*/
@ -541,196 +508,6 @@ namespace com.keyman {
return bgColor;
}
/**
* Add a stylesheet to a page programmatically, for use by the OSK, the UI or the page creator
*
* @param {string} s style string
* @return {Object} returns the object reference
**/
addStyleSheet(s: string): HTMLStyleElement {
var _ElemStyle: HTMLStyleElement = <HTMLStyleElement>document.createElement<'style'>('style');
_ElemStyle.type = 'text/css';
_ElemStyle.appendChild(document.createTextNode(s));
var _ElemHead=document.getElementsByTagName('HEAD');
if(_ElemHead.length > 0) {
_ElemHead[0].appendChild(_ElemStyle);
} else {
document.body.appendChild(_ElemStyle); // Won't work on Chrome, ah well
}
this.linkedStylesheets.push(_ElemStyle);
return _ElemStyle;
}
/**
* Remove a stylesheet element
*
* @param {Object} s style sheet reference
* @return {boolean} false if element is not a style sheet
**/
removeStyleSheet(s: HTMLStyleElement) {
if(s == null || typeof(s) != 'object') {
return false;
}
if(s.nodeName != 'STYLE') {
return false;
}
if(typeof(s.parentNode) == 'undefined' || s.parentNode == null) {
return false;
}
s.parentNode.removeChild(s);
return true;
}
/**
* Add a reference to an external stylesheet file
*
* @param {string} s path to stylesheet file
*/
linkStyleSheet(s: string): void {
try {
if(document.querySelector("link[href="+JSON.stringify(s)+"]") != null) {
// We've already linked this stylesheet, don't do it again
return;
}
} catch(e) {
// We've built an invalid href, somehow?
return;
}
var headElements=document.getElementsByTagName('head');
if(headElements.length > 0) {
var linkElement=document.createElement('link');
linkElement.type='text/css';
linkElement.rel='stylesheet';
linkElement.href=s;
this.linkedStylesheets.push(linkElement);
headElements[0].appendChild(linkElement);
}
}
/**
* Add a stylesheet with a font-face CSS descriptor for the embedded font appropriate
* for the browser being used
*
* @param {Object} fd keymanweb font descriptor
**/
addFontFaceStyleSheet(fd: any) { // TODO: Font descriptor object needs definition!
// Test if a valid font descriptor
if(typeof(fd) == 'undefined') return;
if(typeof(fd['files']) == 'undefined') fd['files']=fd['source'];
if(typeof(fd['files']) == 'undefined') return;
var i,ttf='',woff='',eot='',svg='',fList=[];
// TODO: 22 Aug 2014: check that font path passed from cloud is actually used!
// Do not add a new font-face style sheet if already added for this font
for(i=0; i<this.embeddedFonts.length; i++) {
if(this.embeddedFonts[i] == fd['family']) {
return;
}
}
if(typeof(fd['files']) == 'string') {
fList[0]=fd['files'];
} else {
fList=fd['files'];
}
for(i=0;i<fList.length;i++) {
if(fList[i].toLowerCase().indexOf('.otf') > 0) ttf=fList[i];
if(fList[i].toLowerCase().indexOf('.ttf') > 0) ttf=fList[i];
if(fList[i].toLowerCase().indexOf('.woff') > 0) woff=fList[i];
if(fList[i].toLowerCase().indexOf('.eot') > 0) eot=fList[i];
if(fList[i].toLowerCase().indexOf('.svg') > 0) svg=fList[i];
}
// Font path qualified to support page-relative fonts (build 347)
if(ttf != '' && (ttf.indexOf('/') < 0)) {
ttf = this.keyman.options['fonts']+ttf;
}
if(woff != '' && (woff.indexOf('/') < 0)) {
woff = this.keyman.options['fonts']+woff;
}
if(eot != '' && (eot.indexOf('/') < 0)) {
eot = this.keyman.options['fonts']+eot;
}
if(svg != '' && (svg.indexOf('/') < 0)) {
svg = this.keyman.options['fonts']+svg;
}
// Build the font-face definition according to the browser being used
var s='@font-face {\nfont-family:'
+fd['family']+';\nfont-style:normal;\nfont-weight:normal;\n';
// Build the font source string according to the browser,
// but return without adding the style sheet if the required font type is unavailable
// Modern browsers: use WOFF, TTF and fallback finally to SVG. Don't provide EOT
if(this.device.OS == 'iOS') {
if(ttf != '') {
// Modify the url if required to prevent caching
ttf = this.unCached(ttf);
s=s+'src:url(\''+ttf+'\') format(\'truetype\');';
} else {
return;
}
} else {
var s0 = [];
if(this.device.OS == 'Android') {
// Android 4.2 and 4.3 have bugs in their rendering for some scripts
// with embedded ttf or woff. svg mostly works so is a better initial
// choice on the Android browser.
if(svg != '') {
s0.push("url('"+svg+"') format('svg')");
}
if(woff != '') {
s0.push("url('"+woff+"') format('woff')");
}
if(ttf != '') {
s0.push("url('"+ttf+"') format('truetype')");
}
} else {
if(woff != '') {
s0.push("url('"+woff+"') format('woff')");
}
if(ttf != '') {
s0.push("url('"+ttf+"') format('truetype')");
}
if(svg != '') {
s0.push("url('"+svg+"') format('svg')");
}
}
if(s0.length == 0) {
return;
}
s += 'src:'+s0.join(',')+';';
}
s=s+'\n}\n';
this.addStyleSheet(s);
this.embeddedFonts.push(fd['family']);
}
/**
* Allow forced reload if necessary (stub only here)
*
@ -743,59 +520,6 @@ namespace com.keyman {
return s;
}
/**
* Document cookie parsing for use by kernel, OSK, UI etc.
*
* @param {string=} cn cookie name (optional)
* @return {Object} array of names and strings, or array of variables and values
*/
loadCookie(cn?: string) {
var v={};
if(arguments.length > 0) {
var cx = this.loadCookie();
for(var t in cx) {
if(t == cn) {
var d = decodeURIComponent(cx[t]).split(';');
for(var i=0; i<d.length; i++) {
var xc = d[i].split('=');
if(xc.length > 1) {
v[xc[0]] = xc[1];
} else {
v[xc[0]] = '';
}
}
}
}
} else {
if(typeof(document.cookie) != 'undefined' && document.cookie != '') {
var c = document.cookie.split(/;\s*/);
for(var i = 0; i < c.length; i++) {
var d = c[i].split('=');
if(d.length == 2) {
v[d[0]] = d[1];
}
}
}
}
return v;
}
/**
* Standard cookie saving for use by kernel, OSK, UI etc.
*
* @param {string} cn name of cookie
* @param {Object} cv object with array of named arguments and values
*/
saveCookie(cn: string, cv) {
var s='';
for(var v in cv) {
s = s + v+'='+cv[v]+";";
}
var d = new Date(new Date().valueOf() + 1000 * 60 * 60 * 24 * 30).toUTCString();
document.cookie = cn+'='+encodeURIComponent(s)+'; path=/; expires='+d;//Fri, 31 Dec 2099 23:59:59 GMT;';
}
/**
* Function toNumber
* Scope Public
@ -973,14 +697,6 @@ namespace com.keyman {
// Remove any KMW-added DOM element clutter.
this.waiting.parentNode.removeChild(this.waiting);
for(let ss of this.linkedStylesheets) {
if(ss.remove) {
ss.remove();
} else if(ss.parentNode) {
ss.parentNode.removeChild(ss);
}
}
}
/**

View file

@ -1,10 +1,11 @@
export { Codes, DeviceSpec, Keyboard, KeyboardProperties, SpacebarText } from '@keymanapp/keyboard-processor';
export { default as FloatingOSKView } from './views/floatingOskView.js';
export { default as FloatingOSKViewCookie } from './views/floatingOskCookie.js';
export { default as AnchoredOSKView } from './views/anchoredOskView.js';
export { default as InlinedOSKView } from './views/inlinedOskView.js';
export { BannerController } from './banner/bannerView.js';
// Is referenced by at least one desktop UI module.
export { FloatingOSKCookie as FloatingOSKViewCookie } from './views/floatingOskCookie.js';
export { default as VisualKeyboard } from './visualKeyboard.js';
export type { default as ViewConfiguration } from './config/viewConfiguration.js';

View file

@ -1,10 +1,75 @@
export default interface FloatingOSKCookie {
visible: '0' | '1';
userSet: '0' | '1';
left: string;
top: string;
width?: string;
height?: string;
import { CookieSerializer } from 'keyman/engine/dom-utils';
export interface FloatingOSKCookie {
/**
* Notes whether or not the OSK was hidden at the end of the previous session.
*/
visible: 0 | 1;
/**
* Notes whether or not the OSK was pinned (located by the user) at the end
* of the previous session.
*/
userSet: 0 | 1;
/**
* Denotes the left-position of the OSK at the end of the previous session if pinned.
* Defaults to -1 if the value was undefined.
*/
left: number;
/**
* Denotes the left-position of the OSK at the end of the previous session if pinned.
* Defaults to -1 if the value was undefined.
*/
top: number;
/**
* The previously-set OSK width.
*/
width?: number;
/**
* The previously-set OSK height.
*/
height?: number;
/**
* The version of KeymanWeb active when this cookie was generated.
*/
_version: string;
}
export class FloatingOSKCookieSerializer extends CookieSerializer<Required<FloatingOSKCookie>> {
constructor() {
super('KeymanWeb_OnScreenKeyboard');
}
loadWithDefaults(defaults: Required<FloatingOSKCookie>) {
return {...defaults, ...this.load()};
}
load() {
const cookie = super.load((value, key) => {
switch(key) {
case 'version':
return value;
default:
return Number.parseInt(value, 10);
}
});
if(!cookie.width) {
delete cookie.width; // in case of a '' entry.
}
if(!cookie.height) {
delete cookie.height; // in case of a '' entry.
}
return cookie;
}
save(cookie: Required<FloatingOSKCookie>) {
super.save(cookie);
}
}

View file

@ -9,7 +9,7 @@ import MouseDragOperation from '../input/mouseDragOperation.js';
import { getViewportScale } from '../screenUtils.js';
import Configuration from '../config/viewConfiguration.js';
import TwoStateActivator from './twoStateActivator.js';
import FloatingOSKCookie from './floatingOskCookie.js';
import { FloatingOSKCookie, FloatingOSKCookieSerializer } from './floatingOskCookie.js';
/***
KeymanWeb 10.0
@ -18,12 +18,6 @@ import FloatingOSKCookie from './floatingOskCookie.js';
interface FloatingOSKViewConfiguration extends Configuration {
activator: TwoStateActivator<HTMLElement>;
// Designed to replace util.saveCookie() within the OSK subproject.
saveViewLayout?: (data: FloatingOSKCookie) => void;
// Designed to replace util.loadCookie() within the OSK subproject.
reloadViewLayout?: () => FloatingOSKCookie;
}
export default class FloatingOSKView extends OSKView {
@ -36,6 +30,8 @@ export default class FloatingOSKView extends OSKView {
dfltX: string;
dfltY: string;
layoutSerializer = new FloatingOSKCookieSerializer();
private titleBar: TitleBar;
private resizeBar: ResizeBar;
@ -46,20 +42,12 @@ export default class FloatingOSKView extends OSKView {
public constructor(config: FloatingOSKViewConfiguration) {
config.activator = config.activator || new TwoStateActivator<HTMLElement>();
config.saveViewLayout = config.saveViewLayout || (() => {});
config.reloadViewLayout = config.reloadViewLayout || (() => {
return {} as unknown as FloatingOSKCookie;
});
super(config);
this.typedActivationModel.on('triggerChange', () => this.setDisplayPositioning());
document.body.appendChild(this._Box);
this.loadCookie();
// Add header element to OSK only for desktop browsers
this.titleBar = new TitleBar(this.titleDragHandler);
this.titleBar.on('help', () => {
@ -77,6 +65,8 @@ export default class FloatingOSKView extends OSKView {
this.resizeBar.on('showBuild', () => this.emit('showBuild'));
this.headerView = this.titleBar;
this.loadPersistedLayout();
}
private get typedActivationModel(): TwoStateActivator<HTMLElement> {
@ -120,7 +110,7 @@ export default class FloatingOSKView extends OSKView {
this.footerView = null;
}
this.loadCookie();
this.loadPersistedLayout();
this.setNeedsLayout();
}
@ -137,13 +127,13 @@ export default class FloatingOSKView extends OSKView {
let dragPromise = new ManagedPromise<void>();
this.emit('dragMove', dragPromise.corePromise);
this.loadCookie();
this.loadPersistedLayout();
this.userPositioned=false;
if(!keepDefaultPosition) {
delete this.dfltX;
delete this.dfltY;
}
this.saveCookie();
this.savePersistedLayout();
if(isVisible) {
this.present();
@ -178,24 +168,23 @@ export default class FloatingOSKView extends OSKView {
/**
* Save size, position, font size and visibility of OSK
*/
saveCookie() {
private savePersistedLayout() {
var p = this.getPos();
const c: FloatingOSKCookie = {
visible: this.displayIfActive ? '1' : '0',
userSet: this.userPositioned ? '1' : '0',
left: '' + p.left,
top: '' + p.top,
visible: this.displayIfActive ? 1 : 0,
userSet: this.userPositioned ? 1 : 0,
left: p.left,
top: p.top,
_version: Version.CURRENT.toString()
}
if(this.vkbd) {
c['width'] = '' + this.width.val;
c['height'] = '' + this.height.val;
c.width = this.width.val;
c.height = this.height.val;
}
const typedConfiguration = this.configuration as FloatingOSKViewConfiguration;
typedConfiguration.saveViewLayout(c);
this.layoutSerializer.save(c as Required<FloatingOSKCookie>);
}
/**
@ -203,30 +192,27 @@ export default class FloatingOSKView extends OSKView {
*
* @return {boolean}
*/
loadCookie(): void {
function parseIntWithDefault(str: string, fallback: number) {
let val = Number.parseInt(str, 10);
return isNaN(val) ? fallback: val;
}
private loadPersistedLayout(): void {
let c = this.layoutSerializer.loadWithDefaults({
visible: 1,
userSet: 0,
left: -1,
top: -1,
_version: undefined,
width: 0.3*screen.width,
height: 0.15*screen.height
});
const typedConfiguration = this.configuration as FloatingOSKViewConfiguration;
var c: FloatingOSKCookie = typedConfiguration.reloadViewLayout();
this.activationModel.enabled = parseIntWithDefault(c['visible'], 1) == 1;
this.userPositioned = parseIntWithDefault(c['userSet'], 0) == 1;
this.x = parseIntWithDefault(c['left'],-1);
this.y = parseIntWithDefault(c['top'],-1);
let cookieVersionString = c['_version'];
this.activationModel.enabled = c.visible == 1;
this.userPositioned = c.userSet == 1;
this.x = c.left;
this.y = c.top;
const cookieVersionString = c._version;
// Restore OSK size - font size now fixed in relation to OSK height, unless overridden (in em) by keyboard
let dfltWidth=0.3*screen.width;
let dfltHeight=0.15*screen.height;
let newWidth = parseInt(c['width'], 10);
let newHeight = parseInt(c['height'], 10);
let isNewCookie = isNaN(newHeight);
newWidth = isNaN(newWidth) ? dfltWidth : newWidth;
newHeight = isNaN(newHeight) ? dfltHeight : newHeight;
const isNewCookie = cookieVersionString === undefined;
let newWidth = c.width;
let newHeight = c.height;
// Limit the OSK dimensions to reasonable values
if(newWidth < 0.2*screen.width) {
@ -420,7 +406,7 @@ export default class FloatingOSKView extends OSKView {
this.movementEnabled = !this.noDrag;
}
// Save the user-defined OSK size
this.saveCookie();
this.savePersistedLayout();
}
/**
@ -553,7 +539,7 @@ export default class FloatingOSKView extends OSKView {
super.startHide(hiddenByUser);
if(hiddenByUser) {
this.saveCookie(); // Save current OSK state, size and position (desktop only)
this.savePersistedLayout(); // Save current OSK state, size and position (desktop only)
}
}
@ -563,7 +549,7 @@ export default class FloatingOSKView extends OSKView {
} else {
super['show']();
}
this.saveCookie();
this.savePersistedLayout();
}
/**
@ -665,7 +651,7 @@ export default class FloatingOSKView extends OSKView {
this.dragPromise.then(() => {
_this.userPositioned = true;
_this.doResizeMove();
_this.saveCookie();
_this.savePersistedLayout();
});
this.dragPromise = null;
}
@ -743,7 +729,7 @@ export default class FloatingOSKView extends OSKView {
// Remainder should be done after anything else pending on the Promise.
this.dragPromise.then(() => {
_this.doResizeMove();
_this.saveCookie();
_this.savePersistedLayout();
});
this.dragPromise = null;
}

View file

@ -0,0 +1,102 @@
import Device from '/@keymanapp/keyman/build/engine/device-detect/lib/index.mjs';
import { default as CookieSerializer } from '/@keymanapp/keyman/build/engine/dom-utils/obj/cookieSerializer.js';
let assert = chai.assert;
const device = new Device();
device.detect();
const RESET="max-age=0";
describe('CookieSerializer', function () {
describe('SimpleTestCookie', () => {
const COOKIE_ID = "SimpleTestCookie";
beforeEach(() => {
// Purge the cookie!
document.cookie = `${COOKIE_ID}=foobar; ${RESET}`;
const cookieLoader = new CookieSerializer(COOKIE_ID);
assert.deepEqual(cookieLoader.load(), {});
});
it('serializes & reloads when all values are strings', () => {
const cookieWriter = new CookieSerializer(COOKIE_ID);
const obj = {
foo: 'bar',
widget: 'sprog',
fruity: 'tooty',
flagProp: ''
}
cookieWriter.save(obj);
const cookieReader = new CookieSerializer(COOKIE_ID);
const reloadedObj = cookieReader.load();
assert.deepEqual(reloadedObj, obj);
assert.notStrictEqual(reloadedObj, obj);
});
it('serializes all values to strings', () => {
const cookieWriter = new CookieSerializer(COOKIE_ID);
const obj = {
foo: 'bar',
two: 2,
true: true
}
const expectedObj = {
foo: 'bar',
two: `${2}`,
true: `${true}`
}
cookieWriter.save(obj);
const cookieReader = new CookieSerializer(COOKIE_ID);
const reloadedObj = cookieReader.load();
assert.deepEqual(reloadedObj, expectedObj);
assert.notStrictEqual(reloadedObj, expectedObj);
});
it('accepts custom deserialization', () => {
const cookieWriter = new CookieSerializer(COOKIE_ID);
const obj = {
foo: 'bar',
two: 2,
true: true
}
cookieWriter.save(obj);
const cookieReader = new CookieSerializer(COOKIE_ID);
const reloadedObj = cookieReader.load((value, key) => {
switch(key) {
case 'two':
return Number.parseInt(value, 10);
case 'true':
return value === 'true';
default:
return value;
}
});
assert.deepEqual(reloadedObj, obj);
assert.notStrictEqual(reloadedObj, obj);
});
it('works with encodeURIComponent + decodeURIComponent', () => {
const cookieWriter = new CookieSerializer(COOKIE_ID);
const obj = {
foo: 'bar',
symbols: ':;+&?@ =\n', // Stuff that'll royally wreck cookies if not encoded.
star: '⭐' // Sure, why not test with an emoji?
}
cookieWriter.save(obj, encodeURIComponent);
const cookieReader = new CookieSerializer(COOKIE_ID);
const reloadedObj = cookieReader.load(decodeURIComponent);
assert.deepEqual(reloadedObj, obj);
assert.notStrictEqual(reloadedObj, obj);
});
});
});