Schreiben einer sicheren Browsererweiterung

Schreiben einer sicheren Browsererweiterung

Im Gegensatz zur gängigen „Client-Server“-Architektur zeichnen sich dezentrale Anwendungen durch Folgendes aus:

  • Es ist nicht erforderlich, eine Datenbank mit Benutzeranmeldungen und Passwörtern zu speichern. Zugangsinformationen werden ausschließlich von den Nutzern selbst gespeichert und die Bestätigung ihrer Authentizität erfolgt auf Protokollebene.
  • Es ist nicht erforderlich, einen Server zu verwenden. Die Anwendungslogik kann auf einem Blockchain-Netzwerk ausgeführt werden, wo die erforderliche Datenmenge gespeichert werden kann.

Es gibt zwei relativ sichere Speicher für Benutzerschlüssel – Hardware-Wallets und Browser-Erweiterungen. Hardware-Wallets sind meist äußerst sicher, aber schwierig zu verwenden und alles andere als kostenlos. Browser-Erweiterungen sind jedoch die perfekte Kombination aus Sicherheit und Benutzerfreundlichkeit und können für Endbenutzer auch völlig kostenlos sein.

Unter Berücksichtigung all dessen wollten wir die sicherste Erweiterung entwickeln, die die Entwicklung dezentraler Anwendungen vereinfacht, indem sie eine einfache API für die Arbeit mit Transaktionen und Signaturen bereitstellt.
Von diesem Erlebnis erzählen wir Ihnen weiter unten.

Der Artikel enthält Schritt-für-Schritt-Anleitungen zum Schreiben einer Browser-Erweiterung mit Codebeispielen und Screenshots. Den gesamten Code finden Sie in Lagerstätten. Jeder Commit entspricht logischerweise einem Abschnitt dieses Artikels.

Eine kurze Geschichte der Browsererweiterungen

Browsererweiterungen gibt es schon seit langem. Sie erschienen bereits 1999 im Internet Explorer, 2004 in Firefox. Allerdings gab es lange Zeit keinen einheitlichen Standard für Erweiterungen.

Wir können sagen, dass es zusammen mit Erweiterungen in der vierten Version von Google Chrome erschien. Natürlich gab es damals noch keine Spezifikation, aber es war die Chrome-API, die zur Grundlage wurde: Nachdem Chrome den größten Teil des Browsermarktes erobert hatte und über einen integrierten Anwendungsspeicher verfügte, setzte Chrome tatsächlich den Standard für Browsererweiterungen.

Mozilla hatte seinen eigenen Standard, aber angesichts der Beliebtheit von Chrome-Erweiterungen beschloss das Unternehmen, eine kompatible API zu entwickeln. Im Jahr 2015 wurde auf Initiative von Mozilla eine spezielle Gruppe innerhalb des World Wide Web Consortium (W3C) gegründet, die an Spezifikationen für browserübergreifende Erweiterungen arbeiten soll.

Als Grundlage wurden die bestehenden API-Erweiterungen für Chrome genommen. Die Arbeiten wurden mit Unterstützung von Microsoft durchgeführt (Google weigerte sich, an der Entwicklung des Standards mitzuwirken) und als Ergebnis erschien ein Entwurf Spezifikationen.

Formal wird die Spezifikation von Edge, Firefox und Opera unterstützt (beachten Sie, dass Chrome nicht auf dieser Liste steht). Tatsächlich ist der Standard jedoch weitgehend mit Chrome kompatibel, da er tatsächlich auf Basis seiner Erweiterungen geschrieben wird. Weitere Informationen zur WebExtensions-API finden Sie hier hier.

Erweiterungsstruktur

Die einzige Datei, die für die Erweiterung erforderlich ist, ist das Manifest (manifest.json). Es ist auch der „Einstiegspunkt“ zur Erweiterung.

Manifest

Laut Spezifikation handelt es sich bei der Manifestdatei um eine gültige JSON-Datei. Eine vollständige Beschreibung der Manifestschlüssel mit Informationen darüber, welche Schlüssel in welchem ​​Browser unterstützt werden, kann angezeigt werden hier.

Schlüssel, die nicht in der Spezifikation enthalten sind, „können“ ignoriert werden (sowohl Chrome als auch Firefox melden Fehler, die Erweiterungen funktionieren jedoch weiterhin).

Und ich möchte auf einige Punkte aufmerksam machen.

  1. Hintergrund – ein Objekt, das die folgenden Felder enthält:
    1. Skripte — eine Reihe von Skripten, die im Hintergrundkontext ausgeführt werden (wir werden etwas später darüber sprechen);
    2. Seite - Anstelle von Skripten, die auf einer leeren Seite ausgeführt werden, können Sie HTML mit Inhalt angeben. In diesem Fall wird das Skriptfeld ignoriert und die Skripte müssen in die Inhaltsseite eingefügt werden;
    3. ausdauernd – ein binäres Flag. Wenn nicht angegeben, „tötet“ der Browser den Hintergrundprozess, wenn er davon ausgeht, dass er nichts tut, und startet ihn bei Bedarf neu. Andernfalls wird die Seite erst beim Schließen des Browsers entladen. Wird in Firefox nicht unterstützt.
  2. content_scripts – ein Array von Objekten, mit dem Sie verschiedene Skripte auf verschiedene Webseiten laden können. Jedes Objekt enthält die folgenden wichtigen Felder:
    1. Streichhölzer - Muster-URL, die bestimmt, ob ein bestimmtes Inhaltsskript eingebunden wird oder nicht.
    2. js — eine Liste der Skripte, die in dieses Spiel geladen werden;
    3. Ausschluss_Übereinstimmungen - schließt aus dem Feld aus match URLs, die diesem Feld entsprechen.
  3. page_action - ist eigentlich ein Objekt, das für das Symbol verantwortlich ist, das neben der Adressleiste im Browser angezeigt wird, und für die Interaktion damit. Außerdem können Sie ein Popup-Fenster anzeigen, das mit Ihrem eigenen HTML, CSS und JS definiert ist.
    1. default_popup – Pfad zur HTML-Datei mit der Popup-Schnittstelle, kann CSS und JS enthalten.
  4. Berechtigungen – ein Array zur Verwaltung von Erweiterungsrechten. Es gibt 3 Arten von Rechten, die im Detail beschrieben werden hier
  5. web_accessible_resources – Erweiterungsressourcen, die eine Webseite anfordern kann, zum Beispiel Bilder, JS-, CSS- und HTML-Dateien.
  6. extern_anschließbar — Hier können Sie explizit die IDs anderer Erweiterungen und Domänen von Webseiten angeben, von denen aus Sie eine Verbindung herstellen können. Eine Domain kann der zweiten Ebene oder höher angehören. Funktioniert nicht in Firefox.

Ausführungskontext

Die Erweiterung verfügt über drei Codeausführungskontexte, d. h. die Anwendung besteht aus drei Teilen mit unterschiedlichen Zugriffsebenen auf die Browser-API.

Erweiterungskontext

Der Großteil der API ist hier verfügbar. In diesem Zusammenhang „leben“ sie:

  1. Hintergrundseite — „Backend“-Teil der Erweiterung. Die Datei wird im Manifest mit dem Schlüssel „background“ angegeben.
  2. Popup-Seite – eine Popup-Seite, die erscheint, wenn Sie auf das Erweiterungssymbol klicken. Im Manifest browser_action -> default_popup.
  3. Benutzerdefinierte Seite — Erweiterungsseite, „lebend“ in einem separaten Tab der Ansicht chrome-extension://<id_расширения>/customPage.html.

Dieser Kontext existiert unabhängig von Browserfenstern und -registerkarten. Hintergrundseite existiert in einer einzigen Kopie und funktioniert immer (Ausnahme ist die Ereignisseite, wenn das Hintergrundskript durch ein Ereignis gestartet wird und nach seiner Ausführung „stirbt“). Popup-Seite existiert, wenn das Popup-Fenster geöffnet ist, und Benutzerdefinierte Seite – während die Registerkarte damit geöffnet ist. Ein Zugriff auf andere Registerkarten und deren Inhalte ist aus diesem Kontext nicht möglich.

Kontext des Inhaltsskripts

Die Inhaltsskriptdatei wird zusammen mit jeder Browser-Registerkarte gestartet. Es hat Zugriff auf einen Teil der API der Erweiterung und auf den DOM-Baum der Webseite. Es sind Inhaltsskripte, die für die Interaktion mit der Seite verantwortlich sind. Erweiterungen, die den DOM-Baum manipulieren, tun dies in Inhaltsskripten – zum Beispiel Werbeblocker oder Übersetzer. Außerdem kann das Inhaltsskript per Standard mit der Seite kommunizieren postMessage.

Webseitenkontext

Dies ist die eigentliche Webseite selbst. Es hat nichts mit der Erweiterung zu tun und hat dort keinen Zugriff, außer in Fällen, in denen die Domain dieser Seite nicht explizit im Manifest angegeben ist (mehr dazu weiter unten).

Messaging

Verschiedene Teile der Anwendung müssen Nachrichten miteinander austauschen. Dafür gibt es eine API runtime.sendMessage Eine Nachricht senden background и tabs.sendMessage um eine Nachricht an eine Seite zu senden (Inhaltsskript, Popup oder Webseite, falls verfügbar). externally_connectable). Nachfolgend finden Sie ein Beispiel für den Zugriff auf die Chrome-API.

// Сообщением может быть любой JSON сериализуемый объект
const msg = {a: 'foo', b: 'bar'};

// extensionId можно не указывать, если мы хотим послать сообщение 'своему' расширению (из ui или контент скрипта)
chrome.runtime.sendMessage(extensionId, msg);

// Так выглядит обработчик
chrome.runtime.onMessage.addListener((msg) => console.log(msg))

// Можно слать сообщения вкладкам зная их id
chrome.tabs.sendMessage(tabId, msg)

// Получить к вкладкам и их id можно, например, вот так
chrome.tabs.query(
    {currentWindow: true, active : true},
    function(tabArray){
      tabArray.forEach(tab => console.log(tab.id))
    }
)

Für eine vollständige Kommunikation können Sie Verbindungen herstellen über runtime.connect. Als Antwort erhalten wir runtime.Port, an die Sie im geöffneten Zustand beliebig viele Nachrichten senden können. Auf der Client-Seite beispielsweise contentscript, es sieht aus wie das:

// Опять же extensionId можно не указывать при коммуникации внутри одного расширения. Подключение можно именовать
const port = chrome.runtime.connect({name: "knockknock"});
port.postMessage({joke: "Knock knock"});
port.onMessage.addListener(function(msg) {
    if (msg.question === "Who's there?")
        port.postMessage({answer: "Madame"});
    else if (msg.question === "Madame who?")
        port.postMessage({answer: "Madame... Bovary"});

Server oder Hintergrund:

// Обработчик для подключения 'своих' вкладок. Контент скриптов, popup или страниц расширения
chrome.runtime.onConnect.addListener(function(port) {
    console.assert(port.name === "knockknock");
    port.onMessage.addListener(function(msg) {
        if (msg.joke === "Knock knock")
            port.postMessage({question: "Who's there?"});
        else if (msg.answer === "Madame")
            port.postMessage({question: "Madame who?"});
        else if (msg.answer === "Madame... Bovary")
            port.postMessage({question: "I don't get it."});
    });
});

// Обработчик для подключения внешних вкладок. Других расширений или веб страниц, которым разрешен доступ в манифесте
chrome.runtime.onConnectExternal.addListener(function(port) {
    ...
});

Es gibt auch eine Veranstaltung onDisconnect und Methode disconnect.

Anwendungsdiagramm

Lassen Sie uns eine Browsererweiterung erstellen, die private Schlüssel speichert, Zugriff auf öffentliche Informationen (Adresse, öffentlicher Schlüssel) ermöglicht, mit der Seite kommuniziert und es Drittanbieteranwendungen ermöglicht, eine Signatur für Transaktionen anzufordern.

Anwendungsentwicklung

Unsere Anwendung muss sowohl mit dem Benutzer interagieren als auch der Seite eine API zum Aufrufen von Methoden bereitstellen (z. B. zum Signieren von Transaktionen). Begnügen Sie sich mit nur einem contentscript wird nicht funktionieren, da es nur Zugriff auf das DOM hat, nicht aber auf das JS der Seite. Verbinden Sie sich über runtime.connect Dies ist nicht möglich, da die API auf allen Domänen benötigt wird und nur bestimmte im Manifest angegeben werden können. Als Ergebnis sieht das Diagramm folgendermaßen aus:

Schreiben einer sicheren Browsererweiterung

Es wird ein weiteres Skript geben - inpage, die wir in die Seite einfügen werden. Es wird in seinem Kontext ausgeführt und stellt eine API für die Arbeit mit der Erweiterung bereit.

Hauptseite

Der gesamte Browser-Erweiterungscode ist unter verfügbar GitHub. In der Beschreibung finden Sie Links zu Commits.

Beginnen wir mit dem Manifest:

{
  // Имя и описание, версия. Все это будет видно в браузере в chrome://extensions/?id=<id расширения>
  "name": "Signer",
  "description": "Extension demo",
  "version": "0.0.1",
  "manifest_version": 2,

  // Скрипты, которые будут исполнятся в background, их может быть несколько
  "background": {
    "scripts": ["background.js"]
  },

  // Какой html использовать для popup
  "browser_action": {
    "default_title": "My Extension",
    "default_popup": "popup.html"
  },

  // Контент скрипты.
  // У нас один объект: для всех url начинающихся с http или https мы запускаем
  // contenscript context со скриптом contentscript.js. Запускать сразу по получении документа для всех фреймов
  "content_scripts": [
    {
      "matches": [
        "http://*/*",
        "https://*/*"
      ],
      "js": [
        "contentscript.js"
      ],
      "run_at": "document_start",
      "all_frames": true
    }
  ],
  // Разрешен доступ к localStorage и idle api
  "permissions": [
    "storage",
    // "unlimitedStorage",
    //"clipboardWrite",
    "idle"
    //"activeTab",
    //"webRequest",
    //"notifications",
    //"tabs"
  ],
  // Здесь указываются ресурсы, к которым будет иметь доступ веб страница. Тоесть их можно будет запрашивать fetche'м или просто xhr
  "web_accessible_resources": ["inpage.js"]
}

Erstellen Sie leere background.js, popup.js, inpage.js und contentscript.js. Wir fügen popup.html hinzu – und schon kann unsere Anwendung in Google Chrome geladen werden und sicherstellen, dass sie funktioniert.

Um dies zu überprüfen, können Sie den Code verwenden daher. Zusätzlich zu dem, was wir getan haben, hat der Link die Zusammenstellung des Projekts mithilfe von Webpack konfiguriert. Um eine Anwendung zum Browser hinzuzufügen, müssen Sie in chrome://extensions „load unpacked“ und den Ordner mit der entsprechenden Erweiterung auswählen – in unserem Fall dist.

Schreiben einer sicheren Browsererweiterung

Jetzt ist unsere Erweiterung installiert und funktioniert. Sie können die Entwicklertools für verschiedene Kontexte wie folgt ausführen:

Popup ->

Schreiben einer sicheren Browsererweiterung

Der Zugriff auf die Inhaltsskriptkonsole erfolgt über die Konsole der Seite selbst, auf der sie gestartet wird.Schreiben einer sicheren Browsererweiterung

Messaging

Wir müssen also zwei Kommunikationskanäle einrichten: Inpage <-> Hintergrund und Popup <-> Hintergrund. Sie können natürlich einfach Nachrichten an den Port senden und Ihr eigenes Protokoll erfinden, aber ich bevorzuge den Ansatz, den ich im Metamask-Open-Source-Projekt gesehen habe.

Dies ist eine Browsererweiterung für die Arbeit mit dem Ethereum-Netzwerk. Darin kommunizieren verschiedene Teile der Anwendung über RPC mithilfe der dnode-Bibliothek. Es ermöglicht Ihnen, einen Austausch recht schnell und bequem zu organisieren, wenn Sie ihm einen NodeJS-Stream als Transport (also ein Objekt, das dieselbe Schnittstelle implementiert) zur Verfügung stellen:

import Dnode from "dnode/browser";

// В этом примере условимся что клиент удаленно вызывает функции на сервере, хотя ничего нам не мешает сделать это двунаправленным

// Cервер
// API, которое мы хотим предоставить
const dnode = Dnode({
    hello: (cb) => cb(null, "world")
})
// Транспорт, поверх которого будет работать dnode. Любой nodejs стрим. В браузере есть бибилиотека 'readable-stream'
connectionStream.pipe(dnode).pipe(connectionStream)

// Клиент
const dnodeClient = Dnode() // Вызов без агрумента значит что мы не предоставляем API на другой стороне

// Выведет в консоль world
dnodeClient.once('remote', remote => {
    remote.hello(((err, value) => console.log(value)))
})

Jetzt erstellen wir eine Anwendungsklasse. Es erstellt API-Objekte für das Popup und die Webseite und erstellt einen Dnode für sie:

import Dnode from 'dnode/browser';

export class SignerApp {

    // Возвращает объект API для ui
    popupApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Возвращает объет API для страницы
    pageApi(){
        return {
            hello: cb => cb(null, 'world')
        }
    }

    // Подключает popup ui
    connectPopup(connectionStream){
        const api = this.popupApi();
        const dnode = Dnode(api);

        connectionStream.pipe(dnode).pipe(connectionStream);

        dnode.on('remote', (remote) => {
            console.log(remote)
        })
    }

    // Подключает страницу
    connectPage(connectionStream, origin){
        const api = this.popupApi();
        const dnode = Dnode(api);

        connectionStream.pipe(dnode).pipe(connectionStream);

        dnode.on('remote', (remote) => {
            console.log(origin);
            console.log(remote)
        })
    }
}

Hier und im Folgenden verwenden wir anstelle des globalen Chrome-Objekts extensionApi, das auf Chrome im Google-Browser und auf Browser in anderen zugreift. Dies geschieht aus Gründen der browserübergreifenden Kompatibilität, für die Zwecke dieses Artikels könnte man jedoch einfach „chrome.runtime.connect“ verwenden.

Lassen Sie uns eine Anwendungsinstanz im Hintergrundskript erstellen:

import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";

const app = new SignerApp();

// onConnect срабатывает при подключении 'процессов' (contentscript, popup, или страница расширения)
extensionApi.runtime.onConnect.addListener(connectRemote);

function connectRemote(remotePort) {
    const processName = remotePort.name;
    const portStream = new PortStream(remotePort);
    // При установке соединения можно указывать имя, по этому имени мы и оппределяем кто к нам подлючился, контентскрипт или ui
    if (processName === 'contentscript'){
        const origin = remotePort.sender.url
        app.connectPage(portStream, origin)
    }else{
        app.connectPopup(portStream)
    }
}

Da dnode mit Streams arbeitet und wir einen Port erhalten, wird eine Adapterklasse benötigt. Es wird mithilfe der Readable-Stream-Bibliothek erstellt, die NodeJS-Streams im Browser implementiert:

import {Duplex} from 'readable-stream';

export class PortStream extends Duplex{
    constructor(port){
        super({objectMode: true});
        this._port = port;
        port.onMessage.addListener(this._onMessage.bind(this));
        port.onDisconnect.addListener(this._onDisconnect.bind(this))
    }

    _onMessage(msg) {
        if (Buffer.isBuffer(msg)) {
            delete msg._isBuffer;
            const data = new Buffer(msg);
            this.push(data)
        } else {
            this.push(msg)
        }
    }

    _onDisconnect() {
        this.destroy()
    }

    _read(){}

    _write(msg, encoding, cb) {
        try {
            if (Buffer.isBuffer(msg)) {
                const data = msg.toJSON();
                data._isBuffer = true;
                this._port.postMessage(data)
            } else {
                this._port.postMessage(msg)
            }
        } catch (err) {
            return cb(new Error('PortStream - disconnected'))
        }
        cb()
    }
}

Jetzt erstellen wir eine Verbindung in der Benutzeroberfläche:

import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import Dnode from 'dnode/browser';

const DEV_MODE = process.env.NODE_ENV !== 'production';

setupUi().catch(console.error);

async function setupUi(){
    // Также, как и в классе приложения создаем порт, оборачиваем в stream, делаем  dnode
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    const dnode = Dnode();

    connectionStream.pipe(dnode).pipe(connectionStream);

    const background = await new Promise(resolve => {
        dnode.once('remote', api => {
            resolve(api)
        })
    });

    // Делаем объект API доступным из консоли
    if (DEV_MODE){
        global.background = background;
    }
}

Anschließend erstellen wir die Verbindung im Content-Skript:

import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import PostMessageStream from 'post-message-stream';

setupConnection();
injectScript();

function setupConnection(){
    const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
    const backgroundStream = new PortStream(backgroundPort);

    const pageStream = new PostMessageStream({
        name: 'content',
        target: 'page',
    });

    pageStream.pipe(backgroundStream).pipe(pageStream);
}

function injectScript(){
    try {
        // inject in-page script
        let script = document.createElement('script');
        script.src = extensionApi.extension.getURL('inpage.js');
        const container = document.head || document.documentElement;
        container.insertBefore(script, container.children[0]);
        script.onload = () => script.remove();
    } catch (e) {
        console.error('Injection failed.', e);
    }
}

Da wir die API nicht im Content-Skript, sondern direkt auf der Seite benötigen, machen wir zwei Dinge:

  1. Wir erstellen zwei Streams. Eins – in Richtung der Seite, oben auf der Post-Nachricht. Dafür verwenden wir dies dieses Paket von den Machern von Metamask. Der zweite Stream läuft im Hintergrund über den Port, von dem er empfangen wurde runtime.connect. Kaufen wir sie. Jetzt verfügt die Seite über einen Stream im Hintergrund.
  2. Fügen Sie das Skript in das DOM ein. Laden Sie das Skript herunter (der Zugriff darauf wurde im Manifest erlaubt) und erstellen Sie ein Tag script mit seinem Inhalt darin:

import PostMessageStream from 'post-message-stream';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";

setupConnection();
injectScript();

function setupConnection(){
    // Стрим к бекграунду
    const backgroundPort = extensionApi.runtime.connect({name: 'contentscript'});
    const backgroundStream = new PortStream(backgroundPort);

    // Стрим к странице
    const pageStream = new PostMessageStream({
        name: 'content',
        target: 'page',
    });

    pageStream.pipe(backgroundStream).pipe(pageStream);
}

function injectScript(){
    try {
        // inject in-page script
        let script = document.createElement('script');
        script.src = extensionApi.extension.getURL('inpage.js');
        const container = document.head || document.documentElement;
        container.insertBefore(script, container.children[0]);
        script.onload = () => script.remove();
    } catch (e) {
        console.error('Injection failed.', e);
    }
}

Jetzt erstellen wir ein API-Objekt in Inpage und setzen es auf global:

import PostMessageStream from 'post-message-stream';
import Dnode from 'dnode/browser';

setupInpageApi().catch(console.error);

async function setupInpageApi() {
    // Стрим к контентскрипту
    const connectionStream = new PostMessageStream({
        name: 'page',
        target: 'content',
    });

    const dnode = Dnode();

    connectionStream.pipe(dnode).pipe(connectionStream);

    // Получаем объект API
    const pageApi = await new Promise(resolve => {
        dnode.once('remote', api => {
            resolve(api)
        })
    });

    // Доступ через window
    global.SignerApp = pageApi;
}

Wir sind bereit Remote Procedure Call (RPC) mit separater API für Seite und Benutzeroberfläche. Wenn wir eine neue Seite mit dem Hintergrund verbinden, sehen wir Folgendes:

Schreiben einer sicheren Browsererweiterung

Leere API und Herkunft. Auf der Seitenseite können wir die Hello-Funktion folgendermaßen aufrufen:

Schreiben einer sicheren Browsererweiterung

Die Arbeit mit Callback-Funktionen in modernem JS ist unhöflich, also schreiben wir einen kleinen Helfer zum Erstellen eines Dnodes, der es Ihnen ermöglicht, ein API-Objekt an Utils zu übergeben.

Die API-Objekte sehen nun wie folgt aus:

export class SignerApp {

    popupApi() {
        return {
            hello: async () => "world"
        }
    }

...

}

So erhalten Sie ein Objekt aus der Ferne:

import {cbToPromise, transformMethods} from "../../src/utils/setupDnode";

const pageApi = await new Promise(resolve => {
    dnode.once('remote', remoteApi => {
        // С помощью утилит меняем все callback на promise
        resolve(transformMethods(cbToPromise, remoteApi))
    })
});

Und das Aufrufen von Funktionen gibt ein Versprechen zurück:

Schreiben einer sicheren Browsererweiterung

Version mit asynchronen Funktionen verfügbar hier.

Insgesamt scheint der RPC- und Stream-Ansatz recht flexibel zu sein: Wir können Steam-Multiplexing verwenden und mehrere verschiedene APIs für verschiedene Aufgaben erstellen. Im Prinzip kann dnode überall verwendet werden. Die Hauptsache ist, den Transport in Form eines nodejs-Streams zu verpacken.

Eine Alternative ist das JSON-Format, das das JSON RPC 2-Protokoll implementiert. Es funktioniert jedoch mit bestimmten Transporten (TCP und HTTP(S)), was in unserem Fall nicht anwendbar ist.

Interner Status und lokaler Speicher

Wir müssen den internen Status der Anwendung speichern – zumindest die Signaturschlüssel. Wir können der Anwendung ganz einfach einen Status und Methoden zu seiner Änderung in der Popup-API hinzufügen:

import {setupDnode} from "./utils/setupDnode";

export class SignerApp {

    constructor(){
        this.store = {
            keys: [],
        };
    }

    addKey(key){
        this.store.keys.push(key)
    }

    removeKey(index){
        this.store.keys.splice(index,1)
    }

    popupApi(){
        return {
            addKey: async (key) => this.addKey(key),
            removeKey: async (index) => this.removeKey(index)
        }
    }

    ...

} 

Im Hintergrund packen wir alles in eine Funktion und schreiben das Anwendungsobjekt in das Fenster, damit wir von der Konsole aus damit arbeiten können:

import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";

const DEV_MODE = process.env.NODE_ENV !== 'production';

setupApp();

function setupApp() {
    const app = new SignerApp();

    if (DEV_MODE) {
        global.app = app;
    }

    extensionApi.runtime.onConnect.addListener(connectRemote);

    function connectRemote(remotePort) {
        const processName = remotePort.name;
        const portStream = new PortStream(remotePort);
        if (processName === 'contentscript') {
            const origin = remotePort.sender.url;
            app.connectPage(portStream, origin)
        } else {
            app.connectPopup(portStream)
        }
    }
}

Fügen wir ein paar Schlüssel aus der UI-Konsole hinzu und sehen, was mit dem Status passiert:

Schreiben einer sicheren Browsererweiterung

Der Zustand muss persistent gemacht werden, damit die Schlüssel beim Neustart nicht verloren gehen.

Wir speichern es in localStorage und überschreiben es bei jeder Änderung. Anschließend ist auch für die Benutzeroberfläche ein Zugriff darauf erforderlich, und ich möchte Änderungen auch abonnieren. Auf dieser Grundlage ist es praktisch, einen beobachtbaren Speicher zu erstellen und dessen Änderungen zu abonnieren.

Wir werden die Mobx-Bibliothek verwenden (https://github.com/mobxjs/mobx). Die Wahl fiel darauf, weil ich nicht damit arbeiten musste, es aber unbedingt studieren wollte.

Fügen wir die Initialisierung des Anfangszustands hinzu und machen den Speicher beobachtbar:

import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";

export class SignerApp {

    constructor(initState = {}) {
        // Внешне store так и останется тем же объектом, только теперь все его поля стали proxy, которые отслеживают доступ к ним
        this.store =  observable.object({
            keys: initState.keys || [],
        });
    }

    // Методы, которые меняют observable принято оборачивать декоратором
    @action
    addKey(key) {
        this.store.keys.push(key)
    }

    @action
    removeKey(index) {
        this.store.keys.splice(index, 1)
    }

    ...

}

„Unter der Haube“ hat mobx alle Store-Felder durch Proxy ersetzt und fängt alle Anrufe an diese ab. Es besteht die Möglichkeit, diese Nachrichten zu abonnieren.

Im Folgenden verwende ich häufig den Begriff „bei Änderung“, obwohl dies nicht ganz korrekt ist. Mobx verfolgt den Zugriff auf Felder. Es werden Getter und Setter von Proxy-Objekten verwendet, die die Bibliothek erstellt.

Aktionsdekoratoren dienen zwei Zwecken:

  1. Im strikten Modus mit dem Flag „enforceActions“ verbietet mobx die direkte Änderung des Status. Es gilt als gute Praxis, unter strengen Auflagen zu arbeiten.
  2. Selbst wenn eine Funktion den Zustand mehrmals ändert – wir ändern beispielsweise mehrere Felder in mehreren Codezeilen – werden die Beobachter erst benachrichtigt, wenn sie abgeschlossen ist. Dies ist besonders wichtig für das Frontend, wo unnötige Statusaktualisierungen zu unnötigem Rendern von Elementen führen. In unserem Fall ist weder das Erste noch das Zweite besonders relevant, wir werden uns jedoch an die Best Practices halten. Es ist üblich, allen Funktionen, die den Zustand der beobachteten Felder ändern, Dekoratoren hinzuzufügen.

Im Hintergrund werden wir die Initialisierung und das Speichern des Status in localStorage hinzufügen:

import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
// Вспомогательные методы. Записывают/читают объект в/из localStorage виде JSON строки по ключу 'store'
import {loadState, saveState} from "./utils/localStorage";

const DEV_MODE = process.env.NODE_ENV !== 'production';

setupApp();

function setupApp() {
    const initState = loadState();
    const app = new SignerApp(initState);

    if (DEV_MODE) {
        global.app = app;
    }

    // Setup state persistence

    // Результат reaction присваивается переменной, чтобы подписку можно было отменить. Нам это не нужно, оставлено для примера
    const localStorageReaction = reaction(
        () => toJS(app.store), // Функция-селектор данных
        saveState // Функция, которая будет вызвана при изменении данных, которые возвращает селектор
    );

    extensionApi.runtime.onConnect.addListener(connectRemote);

    function connectRemote(remotePort) {
        const processName = remotePort.name;
        const portStream = new PortStream(remotePort);
        if (processName === 'contentscript') {
            const origin = remotePort.sender.url
            app.connectPage(portStream, origin)
        } else {
            app.connectPopup(portStream)
        }
    }
}

Interessant ist hier die Reaktionsfunktion. Es gibt zwei Argumente:

  1. Datenauswahl.
  2. Ein Handler, der bei jeder Änderung mit diesen Daten aufgerufen wird.

Im Gegensatz zu Redux, wo wir den Status explizit als Argument erhalten, merkt sich mobx, auf welche Observablen wir im Selektor zugreifen, und ruft den Handler nur auf, wenn sie sich ändern.

Es ist wichtig, genau zu verstehen, wie Mobx entscheidet, welche Observablen wir abonnieren. Wenn ich einen Selektor in Code wie diesem schreiben würde() => app.store, dann wird die Reaktion niemals aufgerufen, da der Speicher selbst nicht beobachtbar ist, sondern nur seine Felder.

Wenn ich es so schreiben würde () => app.store.keys, dann würde auch hier nichts passieren, da sich beim Hinzufügen/Entfernen von Array-Elementen der Verweis darauf nicht ändert.

Mobx fungiert zum ersten Mal als Selektor und verfolgt nur die Observablen, auf die wir zugegriffen haben. Dies geschieht über Proxy-Getter. Daher wird hier die eingebaute Funktion verwendet toJS. Es wird ein neues Objekt zurückgegeben, bei dem alle Proxys durch die ursprünglichen Felder ersetzt werden. Während der Ausführung werden alle Felder des Objekts gelesen – daher werden die Getter ausgelöst.

In der Popup-Konsole werden wir wieder mehrere Schlüssel hinzufügen. Diesmal landeten sie auch im localStorage:

Schreiben einer sicheren Browsererweiterung

Beim erneuten Laden der Hintergrundseite bleiben die Informationen erhalten.

Der gesamte Anwendungscode bis zu diesem Zeitpunkt kann angezeigt werden hier.

Sichere Speicherung privater Schlüssel

Das Speichern privater Schlüssel im Klartext ist unsicher: Es besteht immer die Möglichkeit, dass Sie gehackt werden, Zugriff auf Ihren Computer erhalten usw. Daher werden wir in localStorage die Schlüssel in passwortverschlüsselter Form speichern.

Für mehr Sicherheit fügen wir der Anwendung einen gesperrten Zustand hinzu, in dem überhaupt kein Zugriff auf die Schlüssel besteht. Wir werden die Erweiterung aufgrund einer Zeitüberschreitung automatisch in den gesperrten Zustand versetzen.

Mit Mobx können Sie nur einen minimalen Datensatz speichern, der Rest wird automatisch auf dieser Grundlage berechnet. Dies sind die sogenannten berechneten Eigenschaften. Sie können mit Ansichten in Datenbanken verglichen werden:

import {observable, action} from 'mobx';
import {setupDnode} from "./utils/setupDnode";
// Утилиты для безопасного шифрования строк. Используют crypto-js
import {encrypt, decrypt} from "./utils/cryptoUtils";

export class SignerApp {
    constructor(initState = {}) {
        this.store = observable.object({
            // Храним пароль и зашифрованные ключи. Если пароль null - приложение locked
            password: null,
            vault: initState.vault,

            // Геттеры для вычислимых полей. Можно провести аналогию с view в бд.
            get locked(){
                return this.password == null
            },
            get keys(){
                return this.locked ?
                    undefined :
                    SignerApp._decryptVault(this.vault, this.password)
            },
            get initialized(){
                return this.vault !== undefined
            }
        })
    }
    // Инициализация пустого хранилища новым паролем
    @action
    initVault(password){
        this.store.vault = SignerApp._encryptVault([], password)
    }
    @action
    lock() {
        this.store.password = null
    }
    @action
    unlock(password) {
        this._checkPassword(password);
        this.store.password = password
    }
    @action
    addKey(key) {
        this._checkLocked();
        this.store.vault = SignerApp._encryptVault(this.store.keys.concat(key), this.store.password)
    }
    @action
    removeKey(index) {
        this._checkLocked();
        this.store.vault = SignerApp._encryptVault([
                ...this.store.keys.slice(0, index),
                ...this.store.keys.slice(index + 1)
            ],
            this.store.password
        )
    }

    ... // код подключения и api

    // private
    _checkPassword(password) {
        SignerApp._decryptVault(this.store.vault, password);
    }

    _checkLocked() {
        if (this.store.locked){
            throw new Error('App is locked')
        }
    }

    // Методы для шифровки/дешифровки хранилища
    static _encryptVault(obj, pass){
        const jsonString = JSON.stringify(obj)
        return encrypt(jsonString, pass)
    }

    static _decryptVault(str, pass){
        if (str === undefined){
            throw new Error('Vault not initialized')
        }
        try {
            const jsonString = decrypt(str, pass)
            return JSON.parse(jsonString)
        }catch (e) {
            throw new Error('Wrong password')
        }
    }
}

Jetzt speichern wir nur noch die verschlüsselten Schlüssel und das Passwort. Alles andere ist berechnet. Die Übertragung in einen gesperrten Zustand führen wir durch, indem wir das Passwort aus dem Zustand entfernen. Die öffentliche API verfügt jetzt über eine Methode zum Initialisieren des Speichers.

Zur Verschlüsselung geschrieben Dienstprogramme, die Krypto-JS verwenden:

import CryptoJS from 'crypto-js'

// Используется для осложнения подбора пароля перебором. На каждый вариант пароля злоумышленнику придется сделать 5000 хешей
function strengthenPassword(pass, rounds = 5000) {
    while (rounds-- > 0){
        pass = CryptoJS.SHA256(pass).toString()
    }
    return pass
}

export function encrypt(str, pass){
    const strongPass = strengthenPassword(pass);
    return CryptoJS.AES.encrypt(str, strongPass).toString()
}

export function decrypt(str, pass){
    const strongPass = strengthenPassword(pass)
    const decrypted = CryptoJS.AES.decrypt(str, strongPass);
    return decrypted.toString(CryptoJS.enc.Utf8)
}

Der Browser verfügt über eine inaktive API, über die Sie ein Ereignis abonnieren können – Zustandsänderungen. Staat kann dementsprechend sein idle, active и locked. Für den Leerlauf können Sie ein Timeout festlegen, und „Locked“ wird eingestellt, wenn das Betriebssystem selbst blockiert ist. Wir werden auch den Selektor zum Speichern in localStorage ändern:

import {reaction, toJS} from 'mobx';
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {SignerApp} from "./SignerApp";
import {loadState, saveState} from "./utils/localStorage";

const DEV_MODE = process.env.NODE_ENV !== 'production';
const IDLE_INTERVAL = 30;

setupApp();

function setupApp() {
    const initState = loadState();
    const app = new SignerApp(initState);

    if (DEV_MODE) {
        global.app = app;
    }

    // Теперь мы явно узываем поле, которому будет происходить доступ, reaction отработает нормально
    reaction(
        () => ({
            vault: app.store.vault
        }),
        saveState
    );

    // Таймаут бездействия, когда сработает событие
    extensionApi.idle.setDetectionInterval(IDLE_INTERVAL);
    // Если пользователь залочил экран или бездействовал в течение указанного интервала лочим приложение
    extensionApi.idle.onStateChanged.addListener(state => {
        if (['locked', 'idle'].indexOf(state) > -1) {
            app.lock()
        }
    });

    // Connect to other contexts
    extensionApi.runtime.onConnect.addListener(connectRemote);

    function connectRemote(remotePort) {
        const processName = remotePort.name;
        const portStream = new PortStream(remotePort);
        if (processName === 'contentscript') {
            const origin = remotePort.sender.url
            app.connectPage(portStream, origin)
        } else {
            app.connectPopup(portStream)
        }
    }
}

Der Code vor diesem Schritt lautet hier.

Transaktionen

Damit kommen wir zum Wichtigsten: dem Erstellen und Signieren von Transaktionen auf der Blockchain. Wir werden die WAVES-Blockchain und -Bibliothek verwenden Wellen-Transaktionen.

Fügen wir dem Status zunächst ein Array von Nachrichten hinzu, die signiert werden müssen, und fügen dann Methoden zum Hinzufügen einer neuen Nachricht, zum Bestätigen der Signatur und zum Ablehnen hinzu:

import {action, observable, reaction} from 'mobx';
import uuid from 'uuid/v4';
import {signTx} from '@waves/waves-transactions'
import {setupDnode} from "./utils/setupDnode";
import {decrypt, encrypt} from "./utils/cryptoUtils";

export class SignerApp {

    ...

    @action
    newMessage(data, origin) {
        // Для каждого сообщения создаем метаданные с id, статусом, выременем создания и тд.
        const message = observable.object({
            id: uuid(), // Идентификатор, используюю uuid
            origin, // Origin будем впоследствии показывать в интерфейсе
            data, //
            status: 'new', // Статусов будет четыре: new, signed, rejected и failed
            timestamp: Date.now()
        });
        console.log(`new message: ${JSON.stringify(message, null, 2)}`);

        this.store.messages.push(message);

        // Возвращаем промис внутри которого mobx мониторит изменения сообщения. Как только статус поменяется мы зарезолвим его
        return new Promise((resolve, reject) => {
            reaction(
                () => message.status, //Будем обсервить статус сообщеня
                (status, reaction) => { // второй аргумент это ссылка на сам reaction, чтобы его можно было уничтожть внутри вызова
                    switch (status) {
                        case 'signed':
                            resolve(message.data);
                            break;
                        case 'rejected':
                            reject(new Error('User rejected message'));
                            break;
                        case 'failed':
                            reject(new Error(message.err.message));
                            break;
                        default:
                            return
                    }
                    reaction.dispose()
                }
            )
        })
    }
    @action
    approve(id, keyIndex = 0) {
        const message = this.store.messages.find(msg => msg.id === id);
        if (message == null) throw new Error(`No msg with id:${id}`);
        try {
            message.data = signTx(message.data, this.store.keys[keyIndex]);
            message.status = 'signed'
        } catch (e) {
            message.err = {
                stack: e.stack,
                message: e.message
            };
            message.status = 'failed'
            throw e
        }
    }
    @action
    reject(id) {
        const message = this.store.messages.find(msg => msg.id === id);
        if (message == null) throw new Error(`No msg with id:${id}`);
        message.status = 'rejected'
    }

    ...
}

Wenn wir eine neue Nachricht erhalten, fügen wir ihr Metadaten hinzu observable und hinzufügen store.messages.

Wenn nicht observable manuell, dann erledigt mobx dies selbst, wenn es Nachrichten zum Array hinzufügt. Es wird jedoch ein neues Objekt erstellt, auf das wir keinen Verweis haben, den wir aber für den nächsten Schritt benötigen.

Als Nächstes geben wir ein Versprechen zurück, das aufgelöst wird, wenn sich der Nachrichtenstatus ändert. Der Status wird durch eine Reaktion überwacht, die sich bei einer Statusänderung „selbst tötet“.

Methodencode approve и reject Ganz einfach: Wir ändern einfach den Status der Nachricht, nachdem wir sie gegebenenfalls signiert haben.

Wir fügen „Approve“ und „Reject“ in die UI-API ein, newMessage in die Seiten-API:

export class SignerApp {
    ...
    popupApi() {
        return {
            addKey: async (key) => this.addKey(key),
            removeKey: async (index) => this.removeKey(index),

            lock: async () => this.lock(),
            unlock: async (password) => this.unlock(password),
            initVault: async (password) => this.initVault(password),

            approve: async (id, keyIndex) => this.approve(id, keyIndex),
            reject: async (id) => this.reject(id)
        }
    }

    pageApi(origin) {
        return {
            signTransaction: async (txParams) => this.newMessage(txParams, origin)
        }
    }

    ...
}

Versuchen wir nun, die Transaktion mit der Erweiterung zu signieren:

Schreiben einer sicheren Browsererweiterung

Im Allgemeinen ist alles bereit, es bleibt nur noch Fügen Sie eine einfache Benutzeroberfläche hinzu.

UI

Die Schnittstelle benötigt Zugriff auf den Anwendungsstatus. Auf der UI-Seite werden wir es tun observable Zustand und fügen Sie der API eine Funktion hinzu, die diesen Zustand ändert. Fügen wir hinzu observable zum aus dem Hintergrund empfangenen API-Objekt:

import {observable} from 'mobx'
import {extensionApi} from "./utils/extensionApi";
import {PortStream} from "./utils/PortStream";
import {cbToPromise, setupDnode, transformMethods} from "./utils/setupDnode";
import {initApp} from "./ui/index";

const DEV_MODE = process.env.NODE_ENV !== 'production';

setupUi().catch(console.error);

async function setupUi() {
    // Подключаемся к порту, создаем из него стрим
    const backgroundPort = extensionApi.runtime.connect({name: 'popup'});
    const connectionStream = new PortStream(backgroundPort);

    // Создаем пустой observable для состояния background'a
    let backgroundState = observable.object({});
    const api = {
        //Отдаем бекграунду функцию, которая будет обновлять observable
        updateState: async state => {
            Object.assign(backgroundState, state)
        }
    };

    // Делаем RPC объект
    const dnode = setupDnode(connectionStream, api);
    const background = await new Promise(resolve => {
        dnode.once('remote', remoteApi => {
            resolve(transformMethods(cbToPromise, remoteApi))
        })
    });

    // Добавляем в background observable со стейтом
    background.state = backgroundState;

    if (DEV_MODE) {
        global.background = background;
    }

    // Запуск интерфейса
    await initApp(background)
}

Am Ende beginnen wir mit dem Rendern der Anwendungsoberfläche. Dies ist eine Reaktionsanwendung. Das Hintergrundobjekt wird einfach mithilfe von Requisiten übergeben. Es wäre natürlich richtig, einen separaten Dienst für Methoden und einen Speicher für den Staat einzurichten, aber für die Zwecke dieses Artikels reicht dies aus:

import {render} from 'react-dom'
import App from './App'
import React from "react";

// Инициализируем приложение с background объектом в качест ве props
export async function initApp(background){
    render(
        <App background={background}/>,
        document.getElementById('app-content')
    );
}

Mit mobx ist es sehr einfach, mit dem Rendern zu beginnen, wenn sich Daten ändern. Wir hängen den Observer-Dekorator einfach an die Verpackung mobx-reagieren auf der Komponente, und render wird automatisch aufgerufen, wenn sich von der Komponente referenzierte Observablen ändern. Sie benötigen keine MapStateToProps oder Verbindungen wie in Redux. Alles funktioniert sofort nach dem Auspacken:

import React, {Component, Fragment} from 'react'
import {observer} from "mobx-react";
import Init from './components/Initialize'
import Keys from './components/Keys'
import Sign from './components/Sign'
import Unlock from './components/Unlock'

@observer // У Компонета с этим декоратом будет автоматически вызван метод render, если будут изменены observable на которые он ссылается
export default class App extends Component {

    // Правильно конечно вынести логику рендера страниц в роутинг и не использовать вложенные тернарные операторы,
    // и привязывать observable и методы background непосредственно к тем компонентам, которые их используют
    render() {
        const {keys, messages, initialized, locked} = this.props.background.state;
        const {lock, unlock, addKey, removeKey, initVault, deleteVault, approve, reject} = this.props.background;

        return <Fragment>
            {!initialized
                ?
                <Init onInit={initVault}/>
                :
                locked
                    ?
                    <Unlock onUnlock={unlock}/>
                    :
                    messages.length > 0
                        ?
                        <Sign keys={keys} message={messages[messages.length - 1]} onApprove={approve} onReject={reject}/>
                        :
                        <Keys keys={keys} onAdd={addKey} onRemove={removeKey}/>
            }
            <div>
                {!locked && <button onClick={() => lock()}>Lock App</button>}
                {initialized && <button onClick={() => deleteVault()}>Delete all keys and init</button>}
            </div>
        </Fragment>
    }
}

Die restlichen Komponenten können im Code eingesehen werden im UI-Ordner.

Jetzt müssen Sie in der Anwendungsklasse einen Statusselektor für die Benutzeroberfläche erstellen und die Benutzeroberfläche benachrichtigen, wenn sie sich ändert. Dazu fügen wir eine Methode hinzu getState и reactionBerufung remote.updateState:

import {action, observable, reaction} from 'mobx';
import uuid from 'uuid/v4';
import {signTx} from '@waves/waves-transactions'
import {setupDnode} from "./utils/setupDnode";
import {decrypt, encrypt} from "./utils/cryptoUtils";

export class SignerApp {

    ...

    // public
    getState() {
        return {
            keys: this.store.keys,
            messages: this.store.newMessages,
            initialized: this.store.initialized,
            locked: this.store.locked
        }
    }

    ...

    //
    connectPopup(connectionStream) {
        const api = this.popupApi();
        const dnode = setupDnode(connectionStream, api);

        dnode.once('remote', (remote) => {
            // Создаем reaction на изменения стейта, который сделает вызовет удаленну процедуру и обновит стейт в ui процессе
            const updateStateReaction = reaction(
                () => this.getState(),
                (state) => remote.updateState(state),
                // Третьим аргументом можно передавать параметры. fireImmediatly значит что reaction выполниться первый раз сразу.
                // Это необходимо, чтобы получить начальное состояние. Delay позволяет установить debounce
                {fireImmediately: true, delay: 500}
            );
            // Удалим подписку при отключении клиента
            dnode.once('end', () => updateStateReaction.dispose())

        })
    }

    ...
}

Beim Empfang eines Objekts remote wird erstellt reaction um den Status zu ändern, der die Funktion auf der UI-Seite aufruft.

Der letzte Schliff besteht darin, die Anzeige neuer Nachrichten auf dem Erweiterungssymbol hinzuzufügen:

function setupApp() {
...

    // Reaction на выставление текста беджа.
    reaction(
        () => app.store.newMessages.length > 0 ? app.store.newMessages.length.toString() : '',
        text => extensionApi.browserAction.setBadgeText({text}),
        {fireImmediately: true}
    );

...
}

Die Bewerbung ist also fertig. Webseiten können eine Signatur für Transaktionen anfordern:

Schreiben einer sicheren Browsererweiterung

Schreiben einer sicheren Browsererweiterung

Der Code ist hier verfügbar Link.

Abschluss

Wenn Sie den Artikel bis zum Ende gelesen haben, aber noch Fragen haben, können Sie diese unter stellen Repositories mit Erweiterung. Dort finden Sie auch Commits für jeden vorgesehenen Schritt.

Und wenn Sie sich den Code für die eigentliche Erweiterung ansehen möchten, finden Sie diesen hier hier.

Code, Repository und Stellenbeschreibung von siemarell

Source: habr.com

Kommentar hinzufügen