├── src ├── background.js ├── img │ ├── logo.ico │ ├── logo.png │ ├── logo_16.png │ └── logo_48.png ├── manifest.json ├── capture.html ├── js │ ├── domOperations.js │ ├── captureArea.js │ ├── main.js │ ├── common.js │ └── options.js └── options.html ├── LICENSE └── README.md /src/background.js: -------------------------------------------------------------------------------- 1 | import "./js/main.js" 2 | -------------------------------------------------------------------------------- /src/img/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graemephi/imgur-uploader/HEAD/src/img/logo.ico -------------------------------------------------------------------------------- /src/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graemephi/imgur-uploader/HEAD/src/img/logo.png -------------------------------------------------------------------------------- /src/img/logo_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graemephi/imgur-uploader/HEAD/src/img/logo_16.png -------------------------------------------------------------------------------- /src/img/logo_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graemephi/imgur-uploader/HEAD/src/img/logo_48.png -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version" : 3, 3 | "name": "imgur Uploader", 4 | "description": "Right-click uploading of images and screenshots anonymously or to your imgur account.", 5 | "version": "2.4.1", 6 | "icons": { 7 | "16": "img/logo_16.png", 8 | "48": "img/logo_48.png", 9 | "128": "img/logo.png" 10 | }, 11 | "permissions": [ 12 | "activeTab", 13 | "contextMenus", 14 | "notifications", 15 | "storage", 16 | "scripting" 17 | ], 18 | "optional_permissions": [ 19 | "clipboardWrite" 20 | ], 21 | "background": { 22 | "service_worker": "background.js", 23 | "type": "module" 24 | }, 25 | "options_ui": { 26 | "page": "options.html", 27 | "open_in_tab": false 28 | }, 29 | "web_accessible_resources": [{ 30 | "resources": ["options.html"], 31 | "matches": ["https://api.imgur.com/*"] 32 | }] 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /src/capture.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 40 | 41 | 42 |
43 |
44 | 45 | 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # imgur uploader 2 | [Google Chrome extension](https://chrome.google.com/webstore/detail/imgur-uploader/lcpkicdemehhmkjolekhlglljnkggfcf) to upload images to imgur 3 | 4 | # Changelog 5 | 6 | Version 2.4.1: Fix bug in oauth flow that made some people unable to authenticate with imgur. 7 | 8 | Version 2.4: Update to Manifest V3. Fix capture area bug when the browser's 9 | default zoom level was not 1.0, caused by incorrectly applying a zoom correction 10 | factor when the browser had already applied that correction for us. 11 | 12 | Version 2.3.1: Fix bug caused by improper use of a browser API, which are more strict nowadays. 13 | 14 | Version 2.3: Relaxed permission requirements as made possible by updates to Chrome's extension API (or, possibly, just their documentation). 15 | 16 | Version 2.2: Inverted the selection rectangle. This won't ever cause the selection rectangle to be in the captured image, which affected some users. Also, uploading images should work with more kinds of images now, including data urls and SVGs. 17 | 18 | Version 2.1: I was lying in bed last night and it occurred to me I'd added support for higher dpis but not different zoom levels. This was fortunate as when I went to muck around with it I discovered I'd introduced a bug that affected many if not most users. You can take captures while zoomed in or out now. 19 | 20 | Version 2.0: This update removes all 3rd party code and so the code for imgur Uploader is now in the public domain. Additionally: 21 | - Rewrite of capture area to fix various bugs, work in more websites, and respect high dpi displays. 22 | - Added requested features: not focusing tabs of uploaded images; copying to clipboard without opening images 23 | - Uses https all of the time 24 | - Opens album page when uploading into albums 25 | 26 | Version 1.2: Added uploading directly into albums. 27 | -------------------------------------------------------------------------------- /src/js/domOperations.js: -------------------------------------------------------------------------------- 1 | { 2 | 3 | // This script is part of imgur uploader. 4 | 5 | // Extensions as of Manifest V3 run background code in service workers, which 6 | // means the extension core no longer has access to the DOM. But we only ever 7 | // need it when we've been given the active tab permission, so we inject 8 | // ourselves into that tab and use the DOM there. 9 | 10 | function truncate(string) { 11 | if (string.length < 60) { 12 | return string; 13 | } else { 14 | return string.substring(0, 58) + "..."; 15 | } 16 | } 17 | 18 | function listener(message, sender, sendResponse) { 19 | let asynchronous = false; 20 | if (message.type === "dom op") { 21 | switch (message.op) { 22 | case "imageToDataURL": { 23 | let img = new Image(); 24 | img.setAttribute("crossorigin", "Anonymous"); 25 | img.src = message.src; 26 | img.onload = _ => { 27 | try { 28 | // Can't use OffscreenCanvas here as it does not have toDataURL. 29 | let { x, y, width, height } = message.clipRect || { x: 0, y: 0, width: img.width, height: img.height }; 30 | let dim = message.dimensions || { width: width, height: height }; 31 | let canvas = document.createElement("canvas"); 32 | canvas.width = dim.width; 33 | canvas.height = dim.height; 34 | let ctx = canvas.getContext("2d"); 35 | ctx.imageSmoothingQuality = "high"; 36 | ctx.drawImage(img, x, y, width, height, 0, 0, canvas.width, canvas.height); 37 | 38 | let result = canvas.toDataURL("image/png"); 39 | sendResponse({ ok: true, result }); 40 | } catch (e) { 41 | let result = "Failed to create data URL from " + truncate(message.src); 42 | console.error(e || result); 43 | sendResponse({ ok: false, result }); 44 | } 45 | }; 46 | 47 | asynchronous = true; 48 | } break; 49 | case "clipboardWrite": { 50 | // Can't use navigator.clipboard here as interacting with the extension takes focus away from the document. 51 | let textAreaElement = document.createElement('textarea'); 52 | textAreaElement.style.opacity = "0%"; 53 | textAreaElement.style.position = "absolute"; 54 | document.body.appendChild(textAreaElement); 55 | textAreaElement.value = message.src; 56 | textAreaElement.select(); 57 | document.execCommand('copy', false, null); 58 | document.body.removeChild(textAreaElement); 59 | sendResponse({ ok: true }) 60 | } break; 61 | } 62 | }; 63 | 64 | chrome.runtime.onMessage.removeListener(listener); 65 | return asynchronous; 66 | } 67 | 68 | chrome.runtime.onMessage.addListener(listener); 69 | 70 | } 71 | -------------------------------------------------------------------------------- /src/js/captureArea.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | { 4 | 5 | var albumId = null; 6 | 7 | function clamp(x, min, max) { 8 | return (x < min) ? min 9 | : (x > max) ? max 10 | : x; 11 | } 12 | 13 | function rectBounds(x1, y1, x2, y2) { 14 | x1 = clamp(x1, 0, iframe.clientWidth); 15 | y1 = clamp(y1, 0, iframe.clientHeight); 16 | x2 = clamp(x2, 0, iframe.clientWidth); 17 | y2 = clamp(y2, 0, iframe.clientHeight); 18 | 19 | return { 20 | x: (x1 < x2) ? x1 : x2, 21 | y: (y1 < y2) ? y1 : y2, 22 | width: Math.abs(x2 - x1), 23 | height: Math.abs(y2 - y1), 24 | }; 25 | } 26 | 27 | function onMouseDown(event) { 28 | clickX = event.clientX; 29 | clickY = event.clientY; 30 | selecting = true; 31 | 32 | area.style.opacity = 1; 33 | } 34 | 35 | function onMouseMove(event) { 36 | if (animationFrameRequest) { 37 | cancelAnimationFrame(animationFrameRequest); 38 | } 39 | 40 | animationFrameRequest = requestAnimationFrame(_ => { 41 | let rect = rectBounds(clickX, clickY, event.clientX, event.clientY); 42 | 43 | if (selecting) { 44 | let x = rect.x; 45 | let y = rect.y; 46 | let drawnWidth = Math.max(1, rect.width); 47 | let drawnHeight = Math.max(1, rect.height); 48 | 49 | area.style.left = x; 50 | area.style.top = y; 51 | area.style.width = drawnWidth; 52 | area.style.height = drawnHeight; 53 | } 54 | 55 | let x = Math.max(clickX, event.clientX); 56 | let y = Math.min(clickY, event.clientY); 57 | let distanceFromTopRightCorner = Math.sqrt(Math.pow(x - iframe.clientWidth, 2) + (y * y)); 58 | 59 | if (distanceFromTopRightCorner > 150) { 60 | icon.style.opacity = 1; 61 | icon.style.display = ""; 62 | } else if (distanceFromTopRightCorner > 50) { 63 | icon.style.opacity = Math.min(1., Math.tanh((distanceFromTopRightCorner - 50) / 150) * 3.); 64 | icon.style.display = ""; 65 | } else { 66 | // Using opacity: 0 here would make the cursor behave 67 | // differently over the icon 68 | icon.style.display = "none"; 69 | } 70 | 71 | animationFrameRequest = null; 72 | }); 73 | } 74 | 75 | function onMouseUp(event) { 76 | if (selecting) { 77 | let rect = rectBounds(clickX, clickY, event.clientX, event.clientY); 78 | chrome.runtime.sendMessage(null, { type: "capture ready", rect, devicePixelRatio: window.devicePixelRatio, albumId }); 79 | dispose(); 80 | } 81 | } 82 | 83 | function onKeyUp(event) { 84 | dispose(); 85 | } 86 | 87 | function dispose() { 88 | window.removeEventListener("keyup", onKeyUp); 89 | window.removeEventListener("mousemove", onMouseMove); 90 | iframe.contentWindow.removeEventListener("keyup", onKeyUp); 91 | iframe.contentWindow.removeEventListener("mousedown", onMouseDown); 92 | iframe.contentWindow.removeEventListener("mouseup", onMouseUp); 93 | iframe.contentWindow.removeEventListener("mousemove", onMouseMove); 94 | 95 | iframe.remove(); 96 | iframe = null; 97 | icon = null; 98 | selecting = false; 99 | 100 | if (animationFrameRequest) { 101 | cancelAnimationFrame(animationFrameRequest); 102 | animationFrameRequest = null; 103 | } 104 | } 105 | 106 | let iframe = document.createElement("iframe"); 107 | var icon = null; 108 | var area = null; 109 | 110 | var selecting = false; 111 | var clickX = 0; 112 | var clickY = 2147483647; 113 | 114 | var animationFrameRequest = null; 115 | 116 | iframe.setAttribute("style", ` 117 | height: 100%; 118 | width: 100%; 119 | background: transparent; 120 | z-index: 2147483647; 121 | border: 0; 122 | position: fixed; 123 | left: 0; 124 | top: 0; 125 | cursor: crosshair 126 | `); 127 | 128 | function setupIframe(ready, body) { 129 | if (ready && body) { 130 | let parsedDocument = (new DOMParser()).parseFromString(body, "text/html"); 131 | iframe.contentDocument.replaceChild(iframe.contentDocument.adoptNode(parsedDocument.documentElement), iframe.contentDocument.documentElement); 132 | 133 | icon = iframe.contentDocument.querySelector(".icon"); 134 | 135 | window.addEventListener("keyup", onKeyUp); 136 | window.addEventListener("mousemove", onMouseMove); 137 | iframe.contentWindow.addEventListener("keyup", onKeyUp); 138 | iframe.contentWindow.addEventListener("mousedown", onMouseDown); 139 | iframe.contentWindow.addEventListener("mouseup", onMouseUp); 140 | iframe.contentWindow.addEventListener("mousemove", onMouseMove); 141 | 142 | area = iframe.contentDocument.getElementById("area"); 143 | } 144 | } 145 | 146 | var iframeReady = false; 147 | var iframeHTML = null; 148 | 149 | function listener(message) { 150 | if (message.type == "capture init") { 151 | iframeHTML = message.html; 152 | albumId = message.albumId; 153 | setupIframe(iframeReady, iframeHTML); 154 | chrome.runtime.onMessage.removeListener(listener); 155 | } 156 | } 157 | 158 | chrome.runtime.onMessage.addListener(listener); 159 | 160 | iframe.addEventListener("load", _ => { 161 | iframeReady = true; 162 | setupIframe(iframeReady, iframeHTML); 163 | }); 164 | 165 | document.documentElement.append(iframe); 166 | 167 | } 168 | -------------------------------------------------------------------------------- /src/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Imgur Chrome Options 6 | 7 | 8 | 88 | 89 | 90 | 100 |
101 | 108 | 116 | 119 |
120 |

Upload Behavior

121 |
122 | 126 |
127 |
128 | 132 |
133 |
134 | 138 |
139 |
140 | 144 |
145 | 151 |
152 | 156 |
157 |
158 | 162 |
163 |
164 | 170 |
171 | 176 | 177 | 178 | -------------------------------------------------------------------------------- /src/js/main.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import { 4 | client_id, 5 | client_secret, 6 | syncStoreKeys, 7 | localStoreKeys, 8 | Auth_Success, 9 | Auth_None, 10 | Auth_Failure, 11 | Auth_Unavailable, 12 | Imgur_OAuth2URL, 13 | SynchronousStore, 14 | isAnonymous, 15 | setAccessToken, 16 | assert, 17 | request, 18 | refreshToken, 19 | populateAlbumMenu, 20 | resetMenu, 21 | } from './common.js' 22 | 23 | const Store = SynchronousStore(); 24 | 25 | function encodeURL(url) { 26 | if (url.startsWith("data:image")) { 27 | url = url.split(',')[1]; 28 | } 29 | 30 | return encodeURIComponent(url); 31 | } 32 | 33 | function imageToDataURL(tabId, src, clipRect, dimensions) { 34 | return new Promise((resolve, reject) => { 35 | chrome.scripting.executeScript({ target: { tabId }, files: ['js/domOperations.js'] }, _ => { 36 | chrome.tabs.sendMessage(tabId, { type: "dom op", op: "imageToDataURL", src, clipRect, dimensions }, message => { 37 | if (message.ok) { 38 | resolve(message.result); 39 | } else { 40 | reject(message.result); 41 | } 42 | }); 43 | }); 44 | }); 45 | } 46 | 47 | function clipboardWrite(tabId, src) { 48 | return new Promise(resolve => { 49 | chrome.scripting.executeScript({ target: { tabId }, files: ['js/domOperations.js'] }, _ => { 50 | chrome.tabs.sendMessage(tabId, { type: "dom op", op: "clipboardWrite", src }, resolve); 51 | }); 52 | }); 53 | } 54 | 55 | function imageUploadRequest(image, anonymous) { 56 | let authorization = anonymous ? `Client-id ${client_id}` : `Bearer ${Store.access_token}`; 57 | 58 | return request("https://api.imgur.com/3/image") 59 | .post(image) 60 | .headers({ 61 | Authorization: authorization, 62 | 'Content-Type': "application/x-www-form-urlencoded" 63 | }); 64 | } 65 | 66 | function uploadImage(tabId, image, albumId, isRetry) { 67 | assert(tabId); 68 | return new Promise(resolve => chrome.windows.getCurrent(resolve)) 69 | .then(currentWindow => { 70 | if (Store.authorized && Store.valid_until < Date.now()) { 71 | return refreshToken().then(_ => currentWindow); 72 | } 73 | 74 | return currentWindow; 75 | }) 76 | .then(currentWindow => { 77 | let anonymous = isAnonymous(currentWindow.incognito); 78 | 79 | var body = `image=${encodeURL(image)}`; 80 | 81 | if (albumId) { 82 | body += `&album=${albumId}`; 83 | } 84 | 85 | return imageUploadRequest(body, anonymous) 86 | .catch(error => { 87 | if (Store.authorized && error.status === 403) { 88 | return refreshToken().then(_ => imageUploadRequest(body, anonymous)); 89 | } else { 90 | return Promise.reject(error); 91 | } 92 | }); 93 | }) 94 | .then(response => open(tabId, response.data, albumId)) 95 | .catch(error => { 96 | console.error("Failed upload", error); 97 | 98 | if (Store.authorized && error.url === Imgur_OAuth2URL && error.status === 403) { 99 | return notify("Upload Failure", `Do not have permission to upload as ${Store.username}.`); 100 | } else if (!isRetry) { 101 | return imageToDataURL(tabId, image).then(data => uploadImage(tabId, data, albumId, true)); 102 | } else { 103 | return notify("Upload Failure", "That didn't work. You might want to try again."); 104 | } 105 | }); 106 | } 107 | 108 | function open(tabId, data, albumId) { 109 | let imageLink = data.link.replace("http:", "https:"); 110 | 111 | if (Store.to_clipboard) { 112 | clipboardWrite(tabId, imageLink); 113 | } 114 | 115 | if (Store.to_clipboard && Store.clipboard_only) { 116 | notify("Image uploaded", "The URL has been copied to your clipboard."); 117 | } else if (Store.to_direct_link) { 118 | chrome.tabs.create({ url: imageLink, active: !Store.no_focus }); 119 | } else if (albumId) { 120 | let imageId = /^([a-zA-Z0-9]+)$/.exec(data.id)[0]; 121 | 122 | let albumUrl = `https://imgur.com/a/${albumId}`; 123 | let albumUrlWithHash = albumUrl + "#" + imageId; 124 | 125 | chrome.tabs.create({ url: albumUrlWithHash, active: !Store.no_focus }); 126 | } else { 127 | chrome.tabs.create({ url: 'https://imgur.com/' + data.id, active: !Store.no_focus }); 128 | } 129 | } 130 | 131 | function notify(title, message) { 132 | let options = { 133 | type: "basic", 134 | title: title, 135 | message: message, 136 | iconUrl: "img/logo.png" 137 | }; 138 | 139 | return new Promise(resolve => chrome.notifications.create("", options, resolve)); 140 | } 141 | 142 | Store.listener(chrome.runtime.onInstalled, details => { 143 | if (details.reason == "install") { 144 | Store.authorized = false; 145 | Store.incognito = false; 146 | Store.to_direct_link = false; 147 | Store.no_focus = false; 148 | Store.to_clipboard = false; 149 | Store.clipboard_only = false; 150 | Store.to_albums = false; 151 | Store.scale_capture = false; 152 | Store.albums = {}; 153 | 154 | Store.access_token = null; 155 | Store.valid_until = 0; 156 | } else if (details.reason === "update" && Number(details.previousVersion.split(".")[0]) < 2) { 157 | chrome.storage.local.remove("expired"); 158 | Store.scale_capture = false; 159 | Store.no_focus = false; 160 | Store.clipboard_only = false; 161 | 162 | Store.valid_until = 0; 163 | } 164 | 165 | chrome.storage.sync.get('albums', syncStore => { 166 | if (!syncStore.hasOwnProperty('albums')) { 167 | Store.albums = {}; 168 | Store.to_albums = false; 169 | } 170 | }); 171 | 172 | populateAlbumMenu(); 173 | }); 174 | 175 | Store.listener(chrome.contextMenus.onClicked, (info, tab) => { 176 | let uploadType = info.menuItemId.split(" "); 177 | if (uploadType[0] === 'area') { 178 | let albumId = uploadType[1]; 179 | fetch(chrome.runtime.getURL("capture.html")) 180 | .then(response => response.text()) 181 | .then(html => { 182 | return chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['js/captureArea.js'] }, _ => { 183 | chrome.tabs.sendMessage(tab.id, { type: "capture init", html, albumId }); 184 | }); 185 | }); 186 | } else if (uploadType[0] === 'rehost') { 187 | uploadImage(tab.id, info.srcUrl, uploadType[1]); 188 | } 189 | }); 190 | 191 | Store.listener(chrome.runtime.onMessage, (message, sender) => { 192 | if (message.type === "capture ready") { 193 | let pixelRatio = message.devicePixelRatio; 194 | 195 | let width = Math.round(message.rect.width * pixelRatio); 196 | let height = Math.round(message.rect.height * pixelRatio); 197 | let x = Math.round(message.rect.x * pixelRatio); 198 | let y = Math.round(message.rect.y * pixelRatio); 199 | 200 | let scaled_width = Store.scale_capture ? message.rect.width : width; 201 | let scaled_height = Store.scale_capture ? message.rect.height : height; 202 | 203 | let albumId = message.albumId; 204 | 205 | if (width > 0 && height > 0) { 206 | chrome.tabs.captureVisibleTab(null, { format: 'png' }) 207 | .then(imageData => imageToDataURL(sender.tab.id, imageData, { x, y, width, height }, { width: scaled_width, height: scaled_height })) 208 | .then(dataURL => uploadImage(sender.tab.id, dataURL, albumId)); 209 | } 210 | } 211 | }); 212 | 213 | Store.listener(chrome.windows.onFocusChanged, windowId => { 214 | if (windowId != chrome.windows.WINDOW_ID_NONE && !Store.incognito && Store.to_albums) { 215 | chrome.windows.getCurrent(function (window) { 216 | if (window.incognito) { 217 | resetMenu(); 218 | } else { 219 | populateAlbumMenu(); 220 | } 221 | }); 222 | } 223 | }); 224 | -------------------------------------------------------------------------------- /src/js/common.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | export const client_id = "0c0196a10c50197"; 4 | export const client_secret = null; 5 | 6 | if (!client_secret) { 7 | /* 8 | * Hello 9 | * 10 | * client_secret is required for OAuth2--so, if you plan on linking to 11 | * user accounts. If you don't, remove this check and hack away. 12 | * 13 | * There's no particular value or secrecy attached to the client_id beyond 14 | * potentially inconveniencing This Humble Repo Owner. Same with the secret, 15 | * but once you have user auth's you really want to be managing this 16 | * stuff yourself. 17 | * 18 | * graeme 19 | */ 20 | throw new Error("Missing imgur api keys"); 21 | } 22 | 23 | export const syncStoreKeys = ["incognito", "to_direct_link", "no_focus", "to_clipboard", "clipboard_only", "scale_capture", "to_albums", "albums", "username", "authorized", "refresh_token"]; 24 | export const localStoreKeys = ["access_token", "valid_until"]; 25 | 26 | export const Auth_Success = 1; 27 | export const Auth_None = 0; 28 | export const Auth_Failure = -1; 29 | export const Auth_Unavailable = -2; 30 | 31 | export const Imgur_OAuth2URL = "https://api.imgur.com/oauth2/token"; 32 | 33 | // Provides a synchronous view onto application storage (and communicates with 34 | // other tabs to make that happen), and prevents storing keys not defined on 35 | // extension installation. 36 | // 37 | // It's not a singleton, there's more than one, right? 38 | let storePolyton = null; 39 | export function SynchronousStore() { 40 | if (storePolyton) { 41 | return storePolyton; 42 | } 43 | 44 | var initialised = false; 45 | 46 | let self = storePolyton = {}; 47 | 48 | let sync = {}; 49 | let local = {}; 50 | 51 | let pending = { 52 | sync: {}, 53 | local: {} 54 | }; 55 | 56 | let loadListeners = []; 57 | 58 | var saveTimeoutId = null; 59 | function saveToStore() { 60 | if (saveTimeoutId) { 61 | clearTimeout(saveTimeoutId); 62 | } 63 | 64 | saveTimeoutId = setTimeout(_ => { 65 | chrome.storage.sync.set(pending.sync); 66 | chrome.storage.local.set(pending.local); 67 | 68 | chrome.runtime.sendMessage({ type: "store update", info: { sync: pending.sync, local: pending.local } }); 69 | 70 | pending.sync = {}; 71 | pending.local = {}; 72 | 73 | saveTimeoutId = null; 74 | }); 75 | } 76 | 77 | Promise.all([ 78 | chrome.storage.local.get(localStoreKeys), 79 | chrome.storage.sync.get(syncStoreKeys) 80 | ]).then(([localStorage, syncStorage]) => { 81 | Object.assign(local, localStorage); 82 | Object.assign(sync, syncStorage); 83 | 84 | initialised = true; 85 | 86 | loadListeners.forEach(listener => listener.listener(...listener.args)); 87 | loadListeners = null; 88 | }); 89 | 90 | syncStoreKeys.forEach(key => { 91 | Object.defineProperty(self, key, { 92 | get() { 93 | assert(initialised, "SynchronousStorage is uninitialised"); 94 | return sync[key]; 95 | }, 96 | set(value) { 97 | sync[key] = value; 98 | pending.sync[key] = value; 99 | saveToStore(); 100 | } 101 | }); 102 | }); 103 | 104 | localStoreKeys.forEach(key => { 105 | Object.defineProperty(self, key, { 106 | get() { 107 | assert(initialised, "SynchronousStorage is uninitialised"); 108 | return local[key]; 109 | }, 110 | set(value) { 111 | local[key] = value; 112 | pending.local[key] = value; 113 | saveToStore(); 114 | } 115 | }); 116 | }); 117 | 118 | // Albums are the only mutable item in storage and we don't track updates 119 | // to it. In principle we could make the returned albums object self- 120 | // managing aswell. But why bother 121 | self.saveAlbums = _ => { 122 | pending.sync.albums = {}; 123 | Object.assign(pending.sync.albums, sync.albums); 124 | saveToStore(); 125 | }; 126 | 127 | // Store needs time after the script is first loaded to make an async 128 | // request for the storage data. 129 | 130 | // Register functions to be called when Store is initialised. 131 | self.onLoad = (listener, args) => { 132 | args = args || []; 133 | 134 | if (initialised) { 135 | listener(...args); 136 | } else { 137 | loadListeners.push({ listener: listener, args: args }); 138 | } 139 | }; 140 | 141 | // Register chrome events that require storage to be available. 142 | self.listener = (event, listener) => { 143 | event.addListener((...args) => self.onLoad(listener, args)); 144 | }; 145 | 146 | // In principle we want there to be only one store. We can't share a 147 | // singleton around from the background page, because it gets loaded and 148 | // unloaded frequently. The options page may need the store but with no 149 | // background page store available. Instead, we have to notify all extant 150 | // Stores of updates. If we don't do this, the background page's options 151 | // will be incorrect until it is unloaded (potentially a long time). This 152 | // complexity is really pushing the limit of what I'd consider reasonable 153 | // for this kind of thing. 154 | // 155 | // But I really do hate having to hit some async api in order to check a single 156 | // configuration value 157 | 158 | chrome.runtime.onMessage.addListener(message => { 159 | if (message.type === "store update") { 160 | Object.assign(sync, message.info.sync); 161 | Object.assign(local, message.info.local); 162 | } 163 | }); 164 | 165 | Object.freeze(self); 166 | 167 | return self; 168 | } 169 | 170 | let Store = SynchronousStore(); 171 | 172 | export function isAnonymous(isIncognito) { 173 | return !Store.authorized || (isIncognito && !Store.incognito); 174 | } 175 | 176 | export function setAccessToken(token, expiresIn) { 177 | if (!expiresIn) expiresIn = 60 * 60 * 1000; 178 | 179 | Store.access_token = token; 180 | Store.valid_until = Date.now() + expiresIn; 181 | } 182 | 183 | export function assert(condition, message) { 184 | if (!condition) throw new Error(message); 185 | } 186 | 187 | export function request(url) { 188 | var result = { 189 | url: url, 190 | method: "GET", 191 | data: null, 192 | headerEntries: {}, 193 | responseTypeValue: "text", 194 | 195 | post(data) { 196 | this.method = "POST"; 197 | 198 | if (typeof(data) === "object") { 199 | this.data = {}; 200 | Object.assign(this.data, data); 201 | } else if (typeof(data) === "string") { 202 | this.data = data; 203 | } else { 204 | assert(0, "Invalid POST data"); 205 | } 206 | 207 | return this; 208 | }, 209 | 210 | headers(headers) { 211 | Object.assign(this.headerEntries, headers); 212 | return this; 213 | }, 214 | 215 | catch(callback) { 216 | return this.then(identity => identity).catch(callback); 217 | }, 218 | 219 | then(callback) { 220 | let init = { 221 | method: this.method, 222 | headers: this.headerEntries, 223 | }; 224 | 225 | if (this.method === "POST") { 226 | if (typeof(this.data) === "object") { 227 | let data = new FormData(); 228 | 229 | Object.keys(this.data).forEach(key => { 230 | data.append(key, this.data[key]); 231 | }); 232 | 233 | init.body = data; 234 | } else { 235 | init.body = this.data; 236 | } 237 | } 238 | 239 | return fetch(this.url, init) 240 | .then(response => response.ok ? response.json() : Promise.reject(response)) 241 | .then(callback); 242 | } 243 | }; 244 | 245 | return result; 246 | } 247 | 248 | export function refreshToken() { 249 | return request(Imgur_OAuth2URL) 250 | .post({ 251 | refresh_token: Store.refresh_token, 252 | client_id: client_id, 253 | client_secret: client_secret, 254 | grant_type: "refresh_token" 255 | }) 256 | .then(response => { 257 | setAccessToken(response.access_token, response.expires_in); 258 | return response; 259 | }); 260 | } 261 | 262 | export function populateAlbumMenu() { 263 | if (Store.to_albums && Object.keys(Store.albums).length > 0) { 264 | var albums = Store.albums; 265 | 266 | chrome.contextMenus.removeAll(_ => { 267 | chrome.contextMenus.create({ 'title': 'Capture area', 'contexts': ['page'], 'id': 'area parent', 'documentUrlPatterns' : ['http://*/*', 'https://*/*'] }); 268 | chrome.contextMenus.create({ 'title': 'Capture area', 'contexts': ['page'], 'id': 'area', 'parentId': 'area parent', 'documentUrlPatterns' : ['http://*/*', 'https://*/*'] }); 269 | chrome.contextMenus.create({ 'type' : 'separator', 'id': 'area sep', 'contexts': ['page'], 'parentId': 'area parent', 'documentUrlPatterns' : ['http://*/*', 'https://*/*'] }); 270 | 271 | chrome.contextMenus.create({ 'title': 'Rehost image', 'contexts': ['image'], 'id': 'rehost parent', 'documentUrlPatterns' : ['http://*/*', 'https://*/*'] }); 272 | chrome.contextMenus.create({ 'title': 'Rehost image', 'contexts': ['image'], 'id': 'rehost', 'parentId': 'rehost parent', 'documentUrlPatterns' : ['http://*/*', 'https://*/*'] } ); 273 | chrome.contextMenus.create({ 'type' : 'separator', 'id': 'rehost sep', 'contexts': ['image'], 'parentId': 'rehost parent', 'documentUrlPatterns' : ['http://*/*', 'https://*/*'] }); 274 | 275 | for (var id in albums) { 276 | chrome.contextMenus.create({ 'title': albums[id], 'contexts': ['page'], 'id': 'area ' + id, 'parentId': 'area parent', 'documentUrlPatterns' : ['http://*/*', 'https://*/*'] }); 277 | chrome.contextMenus.create({ 'title': albums[id], 'contexts': ['image'], 'id': 'rehost ' + id, 'parentId': 'rehost parent', 'documentUrlPatterns' : ['http://*/*', 'https://*/*'] }); 278 | } 279 | }); 280 | } else { 281 | resetMenu(); 282 | } 283 | } 284 | 285 | export function resetMenu() { 286 | chrome.contextMenus.removeAll(_ => { 287 | chrome.contextMenus.create({ 'title': 'Capture area', 'contexts': ['page'], 'id': 'area', 'documentUrlPatterns' : ['http://*/*', 'https://*/*'] }); 288 | chrome.contextMenus.create({ 'title': 'Rehost image', 'contexts': ['image'], 'id': 'rehost', 'documentUrlPatterns' : ['http://*/*', 'https://*/*']}); 289 | }); 290 | } 291 | -------------------------------------------------------------------------------- /src/js/options.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import { 4 | client_id, 5 | client_secret, 6 | syncStoreKeys, 7 | localStoreKeys, 8 | Auth_Success, 9 | Auth_None, 10 | Auth_Failure, 11 | Auth_Unavailable, 12 | Imgur_OAuth2URL, 13 | SynchronousStore, 14 | isAnonymous, 15 | setAccessToken, 16 | assert, 17 | request, 18 | refreshToken, 19 | populateAlbumMenu, 20 | resetMenu, 21 | } from './common.js' 22 | 23 | function authenticationRedirect() { 24 | let queryParams = {}; 25 | let queryString = location.hash.substring(1); 26 | let queryRegex = /([^&=]+)=([^&]*)/g; 27 | var queryMatch; 28 | while (queryMatch = queryRegex.exec(queryString)) { 29 | queryParams[decodeURIComponent(queryMatch[1])] = decodeURIComponent(queryMatch[2]); 30 | } 31 | 32 | if (queryParams.hasOwnProperty('access_token')) { 33 | assert(queryParams.refresh_token); 34 | assert(queryParams.account_username); 35 | 36 | chrome.runtime.openOptionsPage(_ => { 37 | chrome.runtime.sendMessage({ type: "authentication", authenticated: true, info: queryParams }) 38 | .then(_ => { 39 | chrome.tabs.getCurrent(tab => chrome.tabs.remove(tab.id)) 40 | }) 41 | }); 42 | 43 | return true; 44 | } 45 | 46 | return false; 47 | } 48 | 49 | var Store = SynchronousStore(); 50 | 51 | if (!authenticationRedirect()) { 52 | window.onload = _ => Store.onLoad(wire); 53 | 54 | Store.listener(chrome.runtime.onMessage, message => { 55 | if (message.type === "authentication") { 56 | if (message.authenticated) { 57 | Store.authorized = true; 58 | Store.refresh_token = message.info.refresh_token; 59 | Store.username = message.info.account_username; 60 | 61 | setAccessToken(message.info.access_token, message.info.expires_in); 62 | 63 | displayAuthorized(message.info.account_username); 64 | } else { 65 | displayUnauthorized(); 66 | } 67 | } 68 | }); 69 | } 70 | 71 | function wire() { 72 | checkAndDisplayAuthorization(); 73 | 74 | if (Store.incognito) { 75 | document.getElementById('incognito').checked = true; 76 | } 77 | 78 | if (Store.to_direct_link) { 79 | document.getElementById('to_direct_link').checked = true; 80 | } 81 | 82 | if (Store.no_focus) { 83 | document.getElementById('no_focus').checked = true; 84 | } 85 | 86 | if (Store.to_clipboard) { 87 | document.getElementById('to_clipboard').checked = true; 88 | } 89 | 90 | if (Store.clipboard_only) { 91 | document.getElementById('clipboard_only').checked = true; 92 | } 93 | 94 | if (Store.to_albums) { 95 | document.getElementById('to_albums').checked = true; 96 | } 97 | 98 | if (Store.scale_capture) { 99 | document.getElementById('scale_capture').checked = true; 100 | } 101 | 102 | document.getElementById('auth-button').addEventListener('click', function () { 103 | chrome.tabs.create({ url: `https://api.imgur.com/oauth2/authorize?client_id=${client_id}&response_type=token` }); 104 | }); 105 | 106 | document.getElementById('logout').addEventListener('click', function (event) { 107 | setUnauthorized(); 108 | displayUnauthorized(); 109 | }); 110 | 111 | document.getElementById('incognito').addEventListener('change', function (event) { 112 | Store.incognito = event.target.checked; 113 | }); 114 | 115 | document.getElementById('to_direct_link').addEventListener('change', function (event) { 116 | Store.to_direct_link = event.target.checked; 117 | }); 118 | 119 | document.getElementById('no_focus').addEventListener('change', function (event) { 120 | Store.no_focus = event.target.checked; 121 | }); 122 | 123 | document.getElementById('to_clipboard').addEventListener('change', function (event) { 124 | if (event.target.checked) { 125 | chrome.permissions.request({ permissions: ['clipboardWrite'] }, 126 | granted => { 127 | Store.to_clipboard = granted; 128 | 129 | if (!granted) { 130 | // This does not retrigger the event. But both 131 | // retriggering and not are fine. 132 | this.checked = false; 133 | } 134 | } 135 | ); 136 | } else { 137 | chrome.permissions.remove({ permissions: ['clipboardWrite'] }, 138 | _ => { Store.to_clipboard = false; } 139 | ); 140 | } 141 | }); 142 | 143 | document.getElementById('clipboard_only').addEventListener('change', function (event) { 144 | Store.clipboard_only = event.target.checked; 145 | }); 146 | 147 | document.getElementById('scale_capture').addEventListener('change', function (event) { 148 | Store.scale_capture = event.target.checked; 149 | }); 150 | 151 | if (window.devicePixelRatio != 1.) { 152 | document.getElementById('scale_capture').parentElement.parentElement.style.display = ""; 153 | } 154 | 155 | document.getElementById('to_albums').addEventListener('change', function (event) { 156 | Store.to_albums = event.target.checked; 157 | 158 | if (event.target.checked) { 159 | updateAndDisplayAlbums(); 160 | } else { 161 | document.getElementById("album-options").style.display = "none"; 162 | resetMenu(); 163 | } 164 | }); 165 | 166 | document.getElementById('settings-menu-item').addEventListener('click', function () { 167 | document.getElementById('settings').setAttribute('class','selected'); 168 | document.getElementById('settings').style.display = ''; 169 | document.getElementById('settings-menu-item').setAttribute('class','selected'); 170 | 171 | document.getElementById('about').removeAttribute('class'); 172 | document.getElementById('about').style.display = 'none'; 173 | document.getElementById('about-menu-item').removeAttribute('class'); 174 | }); 175 | 176 | document.getElementById('about-menu-item').addEventListener('click', function () { 177 | document.getElementById('about').setAttribute('class','selected'); 178 | document.getElementById('about').style.display = ''; 179 | document.getElementById('about-menu-item').setAttribute('class','selected'); 180 | 181 | document.getElementById('settings').removeAttribute('class'); 182 | document.getElementById('settings').style.display = 'none'; 183 | document.getElementById('settings-menu-item').removeAttribute('class'); 184 | }); 185 | } 186 | 187 | function displayAuthorized(username) { 188 | var authButton = document.getElementById('auth-button'); 189 | authButton.disabled = true; 190 | 191 | var authorizeElement = document.getElementById('authorize'); 192 | var currentAccountElement = document.getElementById('current-account'); 193 | authorizeElement.style.display = "none"; 194 | currentAccountElement.style.display = ""; 195 | 196 | document.getElementById("user").innerHTML = username; 197 | 198 | document.getElementById('incognito').removeAttribute('disabled'); 199 | document.getElementById('to_albums').removeAttribute('disabled'); 200 | 201 | populateAlbumMenu(); 202 | } 203 | 204 | function setUnauthorized() { 205 | Store.authorized = false; 206 | Store.username = null; 207 | Store.refresh_token = null; 208 | Store.access_token = null; 209 | } 210 | 211 | function displayUnauthorized() { 212 | var authButton = document.getElementById('auth-button'); 213 | authButton.disabled = false; 214 | 215 | var authorizeElement = document.getElementById('authorize'); 216 | var currentAccountElement = document.getElementById('current-account'); 217 | authorizeElement.style.display = ""; 218 | currentAccountElement.style.display = "none"; 219 | 220 | document.getElementById('incognito').setAttribute('disabled', 'disabled'); 221 | document.getElementById('to_albums').setAttribute('disabled', 'disabled'); 222 | 223 | resetMenu(); 224 | } 225 | 226 | function checkAndDisplayAuthorization() { 227 | // If we have auth details we display it first and then check it is still 228 | // valid to avoid an ugly repaint in the common case 229 | 230 | return Promise.resolve().then(_ => { 231 | if (Store.authorized && Store.access_token && Store.refresh_token && Store.username) { 232 | displayAuthorized(Store.username); 233 | 234 | return refreshToken().then(_ => Auth_Success); 235 | } else { 236 | return Auth_None; 237 | } 238 | }) 239 | .catch(error => error.json(_ => { 240 | setUnauthorized(); 241 | console.error("Lost authorization", error.info); 242 | 243 | return Auth_Failure; 244 | }, _ => { 245 | console.error("Failed to refresh token", error); 246 | 247 | return Auth_Unavailable; 248 | } 249 | )) 250 | .then(result => { 251 | switch (result) { 252 | case Auth_Success: { 253 | if (Store.to_albums) { 254 | return updateAndDisplayAlbums(); 255 | } 256 | } break; 257 | case Auth_None: 258 | case Auth_Failure: { 259 | displayUnauthorized(); 260 | } break; 261 | case Auth_Unavailable: { 262 | document.getElementById("not-available").style.display = ""; 263 | document.getElementById("current-account").style.display = "none"; 264 | document.getElementById("authorize").style.display = "none"; 265 | document.getElementById("album-options").style.display = "none"; 266 | } break; 267 | } 268 | }); 269 | } 270 | 271 | function getUserAlbums(username, accessToken) { 272 | return request(`https://api.imgur.com/3/account/${username}/albums/`) 273 | .headers({ Authorization: "Bearer " + accessToken }) 274 | .then(response => response.data); 275 | } 276 | 277 | function albumTitle(album) { 278 | return album.title || `Untitled (${album.id})`; 279 | } 280 | 281 | function addToAlbumMenu(album) { 282 | Store.albums[album.id] = albumTitle(album); 283 | Store.saveAlbums(); 284 | populateAlbumMenu(); 285 | } 286 | 287 | function removeFromAlbumMenu(album) { 288 | if (Store.albums[album.id]) { 289 | delete Store.albums[album.id]; 290 | 291 | Store.saveAlbums(); 292 | populateAlbumMenu(); 293 | } 294 | } 295 | 296 | function createAlbumSelector(album, selected) { 297 | var id = `album-${album.id}`; 298 | var innerHTML = 299 | ` 300 | `; 304 | 305 | var element = document.createElement("div"); 306 | element.classList.add("album-selector"); 307 | 308 | if (selected) { 309 | element.classList.add("selected"); 310 | } 311 | 312 | element.innerHTML = innerHTML; 313 | 314 | var inputElement = element.children[0]; 315 | assert(inputElement.tagName === "INPUT"); 316 | 317 | inputElement.addEventListener("change", event => { 318 | if (event.target.checked) { 319 | element.classList.add("selected"); 320 | 321 | addToAlbumMenu(album); 322 | } else { 323 | element.classList.remove("selected"); 324 | 325 | removeFromAlbumMenu(album); 326 | } 327 | }); 328 | 329 | return element; 330 | } 331 | 332 | function updateAndDisplayAlbums() { 333 | return getUserAlbums(Store.username, Store.access_token).then(albums => { 334 | var albumListElement = document.getElementById("album-list"); 335 | 336 | while (albumListElement.firstChild) { 337 | albumListElement.removeChild(albumListElement.firstChild); 338 | } 339 | 340 | var albumsHaveUpdate = false; 341 | var selectedIds = []; 342 | 343 | if (albums.length) { 344 | albums.forEach(album => { 345 | var selected = Store.albums.hasOwnProperty(album.id); 346 | 347 | if (selected) { 348 | selectedIds.push(album.id); 349 | 350 | if (albumTitle(album) !== Store.albums[album.id]) { 351 | Store.albums[album.id] = albumTitle(album); 352 | albumsHaveUpdate = true; 353 | } 354 | } 355 | 356 | var element = createAlbumSelector(album, selected); 357 | 358 | albumListElement.append(element); 359 | }); 360 | 361 | Object.keys(Store.albums).forEach(dropdownAlbumId => { 362 | if (selectedIds.indexOf(dropdownAlbumId) === -1) { 363 | delete Store.albums[dropdownAlbumId]; 364 | albumsHaveUpdate = true; 365 | } 366 | }); 367 | 368 | if (albumsHaveUpdate) { 369 | Store.saveAlbums(); 370 | } 371 | 372 | populateAlbumMenu(); 373 | } 374 | 375 | document.getElementById("album-options").style.display = ""; 376 | }); 377 | } 378 | --------------------------------------------------------------------------------