diff options
Diffstat (limited to 'browser/extensions/screenshots/build/shot.js')
-rw-r--r-- | browser/extensions/screenshots/build/shot.js | 888 |
1 files changed, 888 insertions, 0 deletions
diff --git a/browser/extensions/screenshots/build/shot.js b/browser/extensions/screenshots/build/shot.js new file mode 100644 index 0000000000..9f4c84e0c2 --- /dev/null +++ b/browser/extensions/screenshots/build/shot.js @@ -0,0 +1,888 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals process, require */ + +this.shot = (function() { + let exports = {}; // Note: in this library we can't use any "system" dependencies because this can be used from multiple + // environments + + const isNode = + typeof process !== "undefined" && + Object.prototype.toString.call(process) === "[object process]"; + const URL = (isNode && require("url").URL) || window.URL; + + /** Throws an error if the condition isn't true. Any extra arguments after the condition + are used as console.error() arguments. */ + function assert(condition, ...args) { + if (condition) { + return; + } + console.error("Failed assertion", ...args); + throw new Error(`Failed assertion: ${args.join(" ")}`); + } + + /** True if `url` is a valid URL */ + function isUrl(url) { + try { + const parsed = new URL(url); + + if (parsed.protocol === "view-source:") { + return isUrl(url.substr("view-source:".length)); + } + + return true; + } catch (e) { + return false; + } + } + + function isValidClipImageUrl(url) { + return isUrl(url) && !(url.indexOf(")") > -1); + } + + function assertUrl(url) { + if (!url) { + throw new Error("Empty value is not URL"); + } + if (!isUrl(url)) { + const exc = new Error("Not a URL"); + exc.scheme = url.split(":")[0]; + throw exc; + } + } + + function isSecureWebUri(url) { + return isUrl(url) && url.toLowerCase().startsWith("https"); + } + + function assertOrigin(url) { + assertUrl(url); + if (url.search(/^https?:/i) !== -1) { + let newUrl = new URL(url); + if (newUrl.pathname != "/") { + throw new Error("Bad origin, might include path"); + } + } + } + + function originFromUrl(url) { + if (!url) { + return null; + } + if (url.search(/^https?:/i) === -1) { + // Non-HTTP URLs don't have an origin + return null; + } + try { + let tryUrl = new URL(url); + return tryUrl.origin; + } catch { + return null; + } + } + + /** Check if the given object has all of the required attributes, and no extra + attributes exception those in optional */ + function checkObject(obj, required, optional) { + if (typeof obj !== "object" || obj === null) { + throw new Error( + "Cannot check non-object: " + + typeof obj + + " that is " + + JSON.stringify(obj) + ); + } + required = required || []; + for (const attr of required) { + if (!(attr in obj)) { + return false; + } + } + optional = optional || []; + for (const attr in obj) { + if (!required.includes(attr) && !optional.includes(attr)) { + return false; + } + } + return true; + } + + /** Create a JSON object from a normal object, given the required and optional + attributes (filtering out any other attributes). Optional attributes are + only kept when they are truthy. */ + function jsonify(obj, required, optional) { + required = required || []; + const result = {}; + for (const attr of required) { + result[attr] = obj[attr]; + } + optional = optional || []; + for (const attr of optional) { + if (obj[attr]) { + result[attr] = obj[attr]; + } + } + return result; + } + + /** True if the two objects look alike. Null, undefined, and absent properties + are all treated as equivalent. Traverses objects and arrays */ + function deepEqual(a, b) { + if ((a === null || a === undefined) && (b === null || b === undefined)) { + return true; + } + if (typeof a !== "object" || typeof b !== "object") { + return a === b; + } + if (Array.isArray(a)) { + if (!Array.isArray(b)) { + return false; + } + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) { + return false; + } + } + } + if (Array.isArray(b)) { + return false; + } + const seen = new Set(); + for (const attr of Object.keys(a)) { + if (!deepEqual(a[attr], b[attr])) { + return false; + } + seen.add(attr); + } + for (const attr of Object.keys(b)) { + if (!seen.has(attr)) { + if (!deepEqual(a[attr], b[attr])) { + return false; + } + } + } + return true; + } + + function makeRandomId() { + // Note: this isn't for secure contexts, only for non-conflicting IDs + let id = ""; + while (id.length < 12) { + let num; + if (!id) { + num = Date.now() % Math.pow(36, 3); + } else { + num = Math.floor(Math.random() * Math.pow(36, 3)); + } + id += num.toString(36); + } + return id; + } + + class AbstractShot { + constructor(backend, id, attrs) { + attrs = attrs || {}; + assert( + /^[a-zA-Z0-9]{1,4000}\/[a-z0-9._-]{1,4000}$/.test(id), + "Bad ID (should be alphanumeric):", + JSON.stringify(id) + ); + this._backend = backend; + this._id = id; + this.origin = attrs.origin || null; + this.fullUrl = attrs.fullUrl || null; + if (!attrs.fullUrl && attrs.url) { + console.warn("Received deprecated attribute .url"); + this.fullUrl = attrs.url; + } + if (this.origin && !isSecureWebUri(this.origin)) { + this.origin = ""; + } + if (this.fullUrl && !isSecureWebUri(this.fullUrl)) { + this.fullUrl = ""; + } + this.docTitle = attrs.docTitle || null; + this.userTitle = attrs.userTitle || null; + this.createdDate = attrs.createdDate || Date.now(); + this.siteName = attrs.siteName || null; + this.images = []; + if (attrs.images) { + this.images = attrs.images.map(json => new this.Image(json)); + } + this.openGraph = attrs.openGraph || null; + this.twitterCard = attrs.twitterCard || null; + this.documentSize = attrs.documentSize || null; + this.thumbnail = attrs.thumbnail || null; + this.abTests = attrs.abTests || null; + this.firefoxChannel = attrs.firefoxChannel || null; + this._clips = {}; + if (attrs.clips) { + for (const clipId in attrs.clips) { + const clip = attrs.clips[clipId]; + this._clips[clipId] = new this.Clip(this, clipId, clip); + } + } + + const isProd = + typeof process !== "undefined" && process.env.NODE_ENV === "production"; + + for (const attr in attrs) { + if ( + attr !== "clips" && + attr !== "id" && + !this.REGULAR_ATTRS.includes(attr) && + !this.DEPRECATED_ATTRS.includes(attr) + ) { + if (isProd) { + console.warn("Unexpected attribute: " + attr); + } else { + throw new Error("Unexpected attribute: " + attr); + } + } else if (attr === "id") { + console.warn("passing id in attrs in AbstractShot constructor"); + console.trace(); + assert(attrs.id === this.id); + } + } + } + + /** Update any and all attributes in the json object, with deep updating + of `json.clips` */ + update(json) { + const ALL_ATTRS = ["clips"].concat(this.REGULAR_ATTRS); + assert( + checkObject(json, [], ALL_ATTRS), + "Bad attr to new Shot():", + Object.keys(json) + ); + for (const attr in json) { + if (attr === "clips") { + continue; + } + if ( + typeof json[attr] === "object" && + typeof this[attr] === "object" && + this[attr] !== null + ) { + let val = this[attr]; + if (val.toJSON) { + val = val.toJSON(); + } + if (!deepEqual(json[attr], val)) { + this[attr] = json[attr]; + } + } else if (json[attr] !== this[attr] && (json[attr] || this[attr])) { + this[attr] = json[attr]; + } + } + if (json.clips) { + for (const clipId in json.clips) { + if (!json.clips[clipId]) { + this.delClip(clipId); + } else if (!this.getClip(clipId)) { + this.setClip(clipId, json.clips[clipId]); + } else if ( + !deepEqual(this.getClip(clipId).toJSON(), json.clips[clipId]) + ) { + this.setClip(clipId, json.clips[clipId]); + } + } + } + } + + /** Returns a JSON version of this shot */ + toJSON() { + const result = {}; + for (const attr of this.REGULAR_ATTRS) { + let val = this[attr]; + if (val && val.toJSON) { + val = val.toJSON(); + } + result[attr] = val; + } + result.clips = {}; + for (const attr in this._clips) { + result.clips[attr] = this._clips[attr].toJSON(); + } + return result; + } + + /** A more minimal JSON representation for creating indexes of shots */ + asRecallJson() { + const result = { clips: {} }; + for (const attr of this.RECALL_ATTRS) { + let val = this[attr]; + if (val && val.toJSON) { + val = val.toJSON(); + } + result[attr] = val; + } + for (const name of this.clipNames()) { + result.clips[name] = this.getClip(name).toJSON(); + } + return result; + } + + get backend() { + return this._backend; + } + + get id() { + return this._id; + } + + get url() { + return this.fullUrl || this.origin; + } + set url(val) { + throw new Error(".url is read-only"); + } + + get fullUrl() { + return this._fullUrl; + } + set fullUrl(val) { + if (val) { + assertUrl(val); + } + this._fullUrl = val || undefined; + } + + get origin() { + return this._origin; + } + set origin(val) { + if (val) { + assertOrigin(val); + } + this._origin = val || undefined; + } + + get isOwner() { + return this._isOwner; + } + + set isOwner(val) { + this._isOwner = val || undefined; + } + + get filename() { + let filenameTitle = this.title; + const date = new Date(this.createdDate); + /* eslint-disable no-control-regex */ + filenameTitle = filenameTitle + .replace(/[\\/]/g, "_") + .replace(/[\u200e\u200f\u202a-\u202e]/g, "") + .replace(/[\x00-\x1f\x7f-\x9f:*?|"<>;,+=\[\]]+/g, " ") + .replace(/^[\s\u180e.]+|[\s\u180e.]+$/g, ""); + /* eslint-enable no-control-regex */ + filenameTitle = filenameTitle.replace(/\s{1,4000}/g, " "); + const currentDateTime = new Date( + date.getTime() - date.getTimezoneOffset() * 60 * 1000 + ).toISOString(); + const filenameDate = currentDateTime.substring(0, 10); + const filenameTime = currentDateTime.substring(11, 19).replace(/:/g, "-"); + let clipFilename = `Screenshot ${filenameDate} at ${filenameTime} ${filenameTitle}`; + + // Crop the filename size at less than 246 bytes, so as to leave + // room for the extension and an ellipsis [...]. Note that JS + // strings are UTF16 but the filename will be converted to UTF8 + // when saving which could take up more space, and we want a + // maximum of 255 bytes (not characters). Here, we iterate + // and crop at shorter and shorter points until we fit into + // 255 bytes. + let suffix = ""; + for (let cropSize = 246; cropSize >= 0; cropSize -= 32) { + if (new Blob([clipFilename]).size > 246) { + clipFilename = clipFilename.substring(0, cropSize); + suffix = "[...]"; + } else { + break; + } + } + + clipFilename += suffix; + + const clip = this.getClip(this.clipNames()[0]); + let extension = ".png"; + if (clip && clip.image && clip.image.type) { + if (clip.image.type === "jpeg") { + extension = ".jpg"; + } + } + return clipFilename + extension; + } + + get urlDisplay() { + if (!this.url) { + return null; + } + if (/^https?:\/\//i.test(this.url)) { + let txt = this.url; + txt = txt.replace(/^[a-z]{1,4000}:\/\//i, ""); + txt = txt.replace(/\/.{0,4000}/, ""); + txt = txt.replace(/^www\./i, ""); + return txt; + } else if (this.url.startsWith("data:")) { + return "data:url"; + } + let txt = this.url; + txt = txt.replace(/\?.{0,4000}/, ""); + return txt; + } + + get viewUrl() { + const url = this.backend + "/" + this.id; + return url; + } + + get creatingUrl() { + let url = `${this.backend}/creating/${this.id}`; + url += `?title=${encodeURIComponent(this.title || "")}`; + url += `&url=${encodeURIComponent(this.url)}`; + return url; + } + + get jsonUrl() { + return this.backend + "/data/" + this.id; + } + + get oembedUrl() { + return this.backend + "/oembed?url=" + encodeURIComponent(this.viewUrl); + } + + get docTitle() { + return this._title; + } + set docTitle(val) { + assert(val === null || typeof val === "string", "Bad docTitle:", val); + this._title = val; + } + + get openGraph() { + return this._openGraph || null; + } + set openGraph(val) { + assert(val === null || typeof val === "object", "Bad openGraph:", val); + if (val) { + assert( + checkObject(val, [], this._OPENGRAPH_PROPERTIES), + "Bad attr to openGraph:", + Object.keys(val) + ); + this._openGraph = val; + } else { + this._openGraph = null; + } + } + + get twitterCard() { + return this._twitterCard || null; + } + set twitterCard(val) { + assert(val === null || typeof val === "object", "Bad twitterCard:", val); + if (val) { + assert( + checkObject(val, [], this._TWITTERCARD_PROPERTIES), + "Bad attr to twitterCard:", + Object.keys(val) + ); + this._twitterCard = val; + } else { + this._twitterCard = null; + } + } + + get userTitle() { + return this._userTitle; + } + set userTitle(val) { + assert(val === null || typeof val === "string", "Bad userTitle:", val); + this._userTitle = val; + } + + get title() { + // FIXME: we shouldn't support both openGraph.title and ogTitle + const ogTitle = this.openGraph && this.openGraph.title; + const twitterTitle = this.twitterCard && this.twitterCard.title; + let title = + this.userTitle || ogTitle || twitterTitle || this.docTitle || this.url; + if (Array.isArray(title)) { + title = title[0]; + } + if (!title) { + title = "Screenshot"; + } + return title; + } + + get createdDate() { + return this._createdDate; + } + set createdDate(val) { + assert(val === null || typeof val === "number", "Bad createdDate:", val); + this._createdDate = val; + } + + clipNames() { + const names = Object.getOwnPropertyNames(this._clips); + names.sort(function(a, b) { + return a.sortOrder < b.sortOrder ? 1 : 0; + }); + return names; + } + getClip(name) { + return this._clips[name]; + } + addClip(val) { + const name = makeRandomId(); + this.setClip(name, val); + return name; + } + setClip(name, val) { + const clip = new this.Clip(this, name, val); + this._clips[name] = clip; + } + delClip(name) { + if (!this._clips[name]) { + throw new Error("No existing clip with id: " + name); + } + delete this._clips[name]; + } + delAllClips() { + this._clips = {}; + } + biggestClipSortOrder() { + let biggest = 0; + for (const clipId in this._clips) { + biggest = Math.max(biggest, this._clips[clipId].sortOrder); + } + return biggest; + } + updateClipUrl(clipId, clipUrl) { + const clip = this.getClip(clipId); + if (clip && clip.image) { + clip.image.url = clipUrl; + } else { + console.warn("Tried to update the url of a clip with no image:", clip); + } + } + + get siteName() { + return this._siteName || null; + } + set siteName(val) { + assert(typeof val === "string" || !val); + this._siteName = val; + } + + get documentSize() { + return this._documentSize; + } + set documentSize(val) { + assert(typeof val === "object" || !val); + if (val) { + assert( + checkObject( + val, + ["height", "width"], + "Bad attr to documentSize:", + Object.keys(val) + ) + ); + assert(typeof val.height === "number"); + assert(typeof val.width === "number"); + this._documentSize = val; + } else { + this._documentSize = null; + } + } + + get thumbnail() { + return this._thumbnail; + } + set thumbnail(val) { + assert(typeof val === "string" || !val); + if (val) { + assert(isUrl(val)); + this._thumbnail = val; + } else { + this._thumbnail = null; + } + } + + get abTests() { + return this._abTests; + } + set abTests(val) { + if (val === null || val === undefined) { + this._abTests = null; + return; + } + assert( + typeof val === "object", + "abTests should be an object, not:", + typeof val + ); + assert(!Array.isArray(val), "abTests should not be an Array"); + for (const name in val) { + assert( + val[name] && typeof val[name] === "string", + `abTests.${name} should be a string:`, + typeof val[name] + ); + } + this._abTests = val; + } + + get firefoxChannel() { + return this._firefoxChannel; + } + set firefoxChannel(val) { + if (val === null || val === undefined) { + this._firefoxChannel = null; + return; + } + assert( + typeof val === "string", + "firefoxChannel should be a string, not:", + typeof val + ); + this._firefoxChannel = val; + } + } + + AbstractShot.prototype.REGULAR_ATTRS = ` +origin fullUrl docTitle userTitle createdDate images +siteName openGraph twitterCard documentSize +thumbnail abTests firefoxChannel +`.split(/\s+/g); + + // Attributes that will be accepted in the constructor, but ignored/dropped + AbstractShot.prototype.DEPRECATED_ATTRS = ` +microdata history ogTitle createdDevice head body htmlAttrs bodyAttrs headAttrs +readable hashtags comments showPage isPublic resources url +fullScreenThumbnail favicon +`.split(/\s+/g); + + AbstractShot.prototype.RECALL_ATTRS = ` +url docTitle userTitle createdDate openGraph twitterCard images thumbnail +`.split(/\s+/g); + + AbstractShot.prototype._OPENGRAPH_PROPERTIES = ` +title type url image audio description determiner locale site_name video +image:secure_url image:type image:width image:height +video:secure_url video:type video:width image:height +audio:secure_url audio:type +article:published_time article:modified_time article:expiration_time article:author article:section article:tag +book:author book:isbn book:release_date book:tag +profile:first_name profile:last_name profile:username profile:gender +`.split(/\s+/g); + + AbstractShot.prototype._TWITTERCARD_PROPERTIES = ` +card site title description image +player player:width player:height player:stream player:stream:content_type +`.split(/\s+/g); + + /** Represents one found image in the document (not a clip) */ + class _Image { + // FIXME: either we have to notify the shot of updates, or make + // this read-only + constructor(json) { + assert(typeof json === "object", "Clip Image given a non-object", json); + assert( + checkObject(json, ["url"], ["dimensions", "title", "alt"]), + "Bad attrs for Image:", + Object.keys(json) + ); + assert(isUrl(json.url), "Bad Image url:", json.url); + this.url = json.url; + assert( + !json.dimensions || + (typeof json.dimensions.x === "number" && + typeof json.dimensions.y === "number"), + "Bad Image dimensions:", + json.dimensions + ); + this.dimensions = json.dimensions; + assert( + typeof json.title === "string" || !json.title, + "Bad Image title:", + json.title + ); + this.title = json.title; + assert( + typeof json.alt === "string" || !json.alt, + "Bad Image alt:", + json.alt + ); + this.alt = json.alt; + } + + toJSON() { + return jsonify(this, ["url"], ["dimensions"]); + } + } + + AbstractShot.prototype.Image = _Image; + + /** Represents a clip, either a text or image clip */ + class _Clip { + constructor(shot, id, json) { + this._shot = shot; + assert( + checkObject(json, ["createdDate", "image"], ["sortOrder"]), + "Bad attrs for Clip:", + Object.keys(json) + ); + assert(typeof id === "string" && id, "Bad Clip id:", id); + this._id = id; + this.createdDate = json.createdDate; + if ("sortOrder" in json) { + assert( + typeof json.sortOrder === "number" || !json.sortOrder, + "Bad Clip sortOrder:", + json.sortOrder + ); + } + if ("sortOrder" in json) { + this.sortOrder = json.sortOrder; + } else { + const biggestOrder = shot.biggestClipSortOrder(); + this.sortOrder = biggestOrder + 100; + } + this.image = json.image; + } + + toString() { + return `[Shot Clip id=${this.id} sortOrder=${this.sortOrder} image ${this.image.dimensions.x}x${this.image.dimensions.y}]`; + } + + toJSON() { + return jsonify(this, ["createdDate"], ["sortOrder", "image"]); + } + + get id() { + return this._id; + } + + get createdDate() { + return this._createdDate; + } + set createdDate(val) { + assert(typeof val === "number" || !val, "Bad Clip createdDate:", val); + this._createdDate = val; + } + + get image() { + return this._image; + } + set image(image) { + if (!image) { + this._image = undefined; + return; + } + assert( + checkObject( + image, + ["url"], + ["dimensions", "text", "location", "captureType", "type"] + ), + "Bad attrs for Clip Image:", + Object.keys(image) + ); + assert(isValidClipImageUrl(image.url), "Bad Clip image URL:", image.url); + assert( + image.captureType === "madeSelection" || + image.captureType === "selection" || + image.captureType === "visible" || + image.captureType === "auto" || + image.captureType === "fullPage" || + image.captureType === "fullPageTruncated" || + !image.captureType, + "Bad image.captureType:", + image.captureType + ); + assert( + typeof image.text === "string" || !image.text, + "Bad Clip image text:", + image.text + ); + if (image.dimensions) { + assert( + typeof image.dimensions.x === "number" && + typeof image.dimensions.y === "number", + "Bad Clip image dimensions:", + image.dimensions + ); + } + if (image.type) { + assert( + image.type === "png" || image.type === "jpeg", + "Unexpected image type:", + image.type + ); + } + assert( + image.location && + typeof image.location.left === "number" && + typeof image.location.right === "number" && + typeof image.location.top === "number" && + typeof image.location.bottom === "number", + "Bad Clip image pixel location:", + image.location + ); + if ( + image.location.topLeftElement || + image.location.topLeftOffset || + image.location.bottomRightElement || + image.location.bottomRightOffset + ) { + assert( + typeof image.location.topLeftElement === "string" && + image.location.topLeftOffset && + typeof image.location.topLeftOffset.x === "number" && + typeof image.location.topLeftOffset.y === "number" && + typeof image.location.bottomRightElement === "string" && + image.location.bottomRightOffset && + typeof image.location.bottomRightOffset.x === "number" && + typeof image.location.bottomRightOffset.y === "number", + "Bad Clip image element location:", + image.location + ); + } + this._image = image; + } + + isDataUrl() { + if (this.image) { + return this.image.url.startsWith("data:"); + } + return false; + } + + get sortOrder() { + return this._sortOrder || null; + } + set sortOrder(val) { + assert(typeof val === "number" || !val, "Bad Clip sortOrder:", val); + this._sortOrder = val; + } + } + + AbstractShot.prototype.Clip = _Clip; + + if (typeof exports !== "undefined") { + exports.AbstractShot = AbstractShot; + exports.originFromUrl = originFromUrl; + exports.isValidClipImageUrl = isValidClipImageUrl; + } + + return exports; +})(); +null; |