Schreiwen eng sécher Browser Extensioun

Schreiwen eng sécher Browser Extensioun

Am Géigesaz zu der gemeinsamer "Client-Server" Architektur sinn dezentraliséierter Uwendungen charakteriséiert duerch:

  • Et ass net néideg eng Datebank mat Benotzer Login a Passwierder ze späicheren. Zougangsinformatioun gëtt exklusiv vun de Benotzer selwer gespäichert, an d'Bestätegung vun hirer Authentizitéit geschitt um Protokollniveau.
  • Net néideg e Server ze benotzen. D'Applikatiounslogik kann op engem Blockchain Netzwierk ausgefouert ginn, wou et méiglech ass déi erfuerderlech Quantitéit un Daten ze späicheren.

Et ginn 2 relativ sécher Späichere fir Benotzerschlësselen - Hardware Portemonnaien a Browser Extensiounen. Hardware Portemonnaien si meeschtens extrem sécher, awer schwéier ze benotzen a wäit vun gratis, awer Browserverlängerungen sinn déi perfekt Kombinatioun vu Sécherheet an einfacher Benotzung, a kënnen och komplett gratis fir Endbenotzer sinn.

Wann Dir dëst alles berücksichtegt, wollte mir déi sécherst Extensioun maachen, déi d'Entwécklung vun dezentraliséierten Uwendungen vereinfacht andeems en einfachen API ubitt fir mat Transaktiounen an Ënnerschrëften ze schaffen.
Mir soen Iech iwwer dës Erfahrung hei ënnen.

Den Artikel enthält Schrëtt-fir-Schrëtt Instruktioune wéi Dir eng Browserverlängerung schreift, mat Codebeispiller a Screenshots. Dir kënnt all Code fannen an Repositories. All Verpflichtung entsprécht logesch eng Sektioun vun dësem Artikel.

Eng kuerz Geschicht vu Browser Extensiounen

Browser Extensiounen sinn scho laang. Si koumen am Internet Explorer zréck am Joer 1999, am Firefox am Joer 2004. Wéi och ëmmer, fir eng ganz laang Zäit gouf et keen eenzege Standard fir Extensiounen.

Mir kënne soen datt et zesumme mat Extensiounen an der véierter Versioun vu Google Chrome erschéngt. Natierlech gouf et deemools keng Spezifizéierung, awer et war d'Chrome API déi seng Basis gouf: Nodeems de gréissten Deel vum Browsermaart eruewert huet an en agebaute Applikatiounsgeschäft hat, huet Chrome tatsächlech de Standard fir Browserextensiounen gesat.

Mozilla hat säin eegene Standard, awer d'Popularitéit vu Chrome Extensiounen ze gesinn, huet d'Firma decidéiert eng kompatibel API ze maachen. Am Joer 2015, op Initiativ vu Mozilla, gouf e spezielle Grupp am World Wide Web Consortium (W3C) gegrënnt fir un Cross-Browser Extensiounsspezifikatiounen ze schaffen.

Déi existent API Extensiounen fir Chrome goufen als Basis geholl. D'Aarbecht gouf mat der Ënnerstëtzung vu Microsoft duerchgefouert (Google huet refuséiert un der Entwécklung vum Standard deelzehuelen), an als Resultat erschéngt en Entworf Spezifikatioune.

Formell gëtt d'Spezifikatioun vun Edge, Firefox an Opera ënnerstëtzt (notéiert datt Chrome net op dëser Lëscht ass). Awer tatsächlech ass de Standard gréisstendeels kompatibel mat Chrome, well et ass tatsächlech geschriwwe baséiert op sengen Extensiounen. Dir kënnt méi iwwer d'WebExtensions API liesen hei.

Erweiderung Struktur

Déi eenzeg Datei déi fir d'Extensioun erfuerderlech ass ass de Manifest (manifest.json). Et ass och den "Entrée Punkt" fir d'Expansioun.

Manifest

No der Spezifizéierung ass d'Manifestdatei eng valabel JSON Datei. Eng komplett Beschreiwung vu Manifesteschlësselen mat Informatioun iwwer wéi eng Schlësselen ënnerstëtzt ginn an deem Browser ka gekuckt ginn hei.

Schlësselen déi net an der Spezifizéierung sinn "kënne" ignoréiert ginn (béid Chrome a Firefox mellen Feeler, awer d'Extensiounen funktionnéieren weider).

An ech wëll op e puer Punkten opmierksam maachen.

  1. Hannergrond - en Objet deen déi folgend Felder enthält:
    1. Skripte benotzen - eng Rei vu Scripten, déi am Hannergrond ausgefouert ginn (mir schwätze méi spéit doriwwer);
    2. Säit - amplaz Scripten déi an enger eidel Säit ausgefouert ginn, kënnt Dir HTML mat Inhalt uginn. An dësem Fall gëtt d'Skriptfeld ignoréiert, an d'Skripten mussen an d'Inhaltssäit agefouert ginn;
    3. erëm - e binäre Fändel, wann net spezifizéiert, wäert de Browser den Hannergrondprozess "killen" wann en denkt datt et näischt mécht, a wann néideg nei starten. Soss gëtt d'Säit nëmmen ofgelueden wann de Browser zou ass. Net am Firefox ënnerstëtzt.
  2. content_scripts - eng Rei vun Objeten déi Iech erlaabt verschidde Scripten op verschidde Websäiten ze lueden. All Objet enthält déi folgend wichteg Felder:
    1. Mätscher - Muster URL, wat bestëmmt ob e bestëmmten Inhaltsskript mat abegraff gëtt oder net.
    2. js - eng Lëscht vun Scripten déi an dëse Match gelueden ginn;
    3. ausschléissen_Matcher - ausgeschloss vum Terrain match URLen déi mat dësem Feld passen.
  3. page_action - ass tatsächlech en Objet dat verantwortlech ass fir d'Ikon déi nieft der Adressbar am Browser an d'Interaktioun mat deem ugewise gëtt. Et erlaabt Iech och eng Popup-Fënster ze weisen, déi mat Ärem eegenen HTML, CSS a JS definéiert gëtt.
    1. default_popup - Wee op d'HTML Datei mat der Popup Interface, kann CSS an JS enthalen.
  4. Permissiounen - eng Array fir d'Gestioun vun Extensiounsrechter. Et ginn 3 Aarte vu Rechter, déi am Detail beschriwwe ginn hei
  5. web_accessible_resources - Extensiounsressourcen déi eng Websäit ufroe kann, zum Beispill Biller, JS, CSS, HTML Dateien.
  6. extern_verbindbar - hei kënnt Dir explizit d'IDs vun aneren Extensiounen an Domains vu Websäiten spezifizéieren, vun deenen Dir konnektéiere kënnt. En Domain kann zweeten Niveau oder méi héich sinn. Wierkt net am Firefox.

Ausféierung Kontext

D'Extensioun huet dräi Code Ausféierung Kontexter, dat heescht, d'Applikatioun besteet aus dräi Deeler mat verschiddenen Niveauen vum Zougang zum Browser API.

Erweiderung Kontext

Déi meescht vun der API ass hei verfügbar. An dësem Kontext "liewen" si:

  1. Hannergrond Säit - "Backend" Deel vun der Extensioun. D'Datei gëtt am Manifest mat dem "Hannergrond" Schlëssel spezifizéiert.
  2. Popup Säit - eng Popup Säit déi erschéngt wann Dir op d'Extensioun Ikon klickt. Am Manifest browser_action -> default_popup.
  3. Benotzerdefinéiert Säit - Extensioun Säit, "liewen" an enger separater Tab vun der Vue chrome-extension://<id_расширения>/customPage.html.

Dëse Kontext existéiert onofhängeg vu Browserfenster a Tabs. Hannergrond Säit existéiert an enger eenzeger Kopie a funktionnéiert ëmmer (d'Ausnam ass d'Evenement Säit, wann den Hannergrond Skript vun engem Event lancéiert gëtt a "stierft" no senger Ausféierung). Popup Säit existéiert wann der Popup Fënster op ass, an Benotzerdefinéiert Säit - wärend de Tab mat et op ass. Et gëtt keen Zougang zu anere Tabs an hiren Inhalt aus dësem Kontext.

Inhalt Skript Kontext

D'Inhaltsskriptdatei gëtt zesumme mat all Browser Tab gestart. Et huet Zougang zu engem Deel vun der API vun der Extensioun an zum DOM Bam vun der Websäit. Et sinn Inhaltsskripter déi verantwortlech sinn fir d'Interaktioun mat der Säit. Extensiounen, déi den DOM-Bam manipuléieren, maachen dat an Inhaltsskripter - zum Beispill Ad-Blocker oder Iwwersetzer. Och den Inhaltsskript kann mat der Säit iwwer Standard kommunizéieren postMessage.

Websäit Kontext

Dëst ass déi aktuell Websäit selwer. Et huet näischt mat der Extensioun ze dinn an huet keen Zougang do, ausser a Fäll wou d'Domain vun dëser Säit net explizit am Manifest uginn ass (méi doriwwer hei ënnen).

Noriichtenaustausch

Verschidden Deeler vun der Applikatioun musse Messagen mateneen austauschen. Et gëtt eng API fir dëst runtime.sendMessage fir e Message ze schécken background и tabs.sendMessage fir e Message op eng Säit ze schécken (Inhaltsskript, Popup oder Websäit wa verfügbar externally_connectable). Drënner ass e Beispill wann Dir op d'Chrome API kënnt.

// Сообщением может быть любой 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))
    }
)

Fir voll Kommunikatioun, Dir kënnt Verbindungen schafen duerch runtime.connect. Als Äntwert wäerte mir kréien runtime.Port, op déi, während et op ass, Dir kënnt all Zuel vu Messagen schécken. Op der Client Säit, z.B. contentscript, et gesäit esou aus:

// Опять же 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 Hannergrond:

// Обработчик для подключения 'своих' вкладок. Контент скриптов, 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) {
    ...
});

Et gëtt och en Event onDisconnect a Method disconnect.

Applikatioun Diagramm

Loosst eis eng Browserverlängerung maachen déi privat Schlësselen späichert, Zougang zu ëffentlechen Informatioun ubitt (Adress, ëffentleche Schlëssel kommunizéiert mat der Säit an erlaabt Drëtt Partei Uwendungen eng Ënnerschrëft fir Transaktiounen ze froen.

Applikatioun Entwécklung

Eis Applikatioun muss souwuel mam Benotzer interagéieren an der Säit mat enger API ubidden fir Methoden ze ruffen (zum Beispill fir Transaktiounen z'ënnerschreiwen). Maacht Iech just mat engem contentscript wäert net schaffen, well et nëmmen Zougang zu der DOM huet, awer net op d'JS vun der Säit. Connect iwwer runtime.connect mir kënnen net, well d'API op all Domainen néideg ass, an nëmmen spezifesch kann am Manifest uginn. Als Resultat wäert d'Diagramm esou ausgesinn:

Schreiwen eng sécher Browser Extensioun

Et gëtt en anert Skript - inpage, déi mir an d'Säit sprëtzen. Et wäert a sengem Kontext lafen a bitt eng API fir mat der Extensioun ze schaffen.

Den Ufank

All Browser Extensioun Code ass verfügbar op GitHub. Wärend der Beschreiwung ginn et Linken op Verpflichtungen.

Fänke mer mam Manifest un:

{
  // Имя и описание, версия. Все это будет видно в браузере в 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"]
}

Schafen eidel background.js, popup.js, inpage.js an contentscript.js. Mir addéieren popup.html - an eis Applikatioun kann schonn an Google Chrome gelueden ginn a sécherstellen datt et funktionnéiert.

Fir dëst z'iwwerpréiwen, kënnt Dir de Code huelen vun hei. Zousätzlech zu deem wat mir gemaach hunn, huet de Link d'Versammlung vum Projet mat Webpack konfiguréiert. Fir eng Applikatioun an de Browser ze addéieren, an chrome://extensions musst Dir load unpacked an den Dossier mat der entspriechender Extensioun wielen - an eisem Fall dist.

Schreiwen eng sécher Browser Extensioun

Elo ass eis Extensioun installéiert a funktionnéiert. Dir kënnt d'Entwéckler Tools fir verschidde Kontexter lafen wéi follegt:

popup ->

Schreiwen eng sécher Browser Extensioun

Zougang zu der Inhaltsskriptkonsole gëtt duerch d'Konsole vun der Säit selwer gemaach, op där se gestart gëtt.Schreiwen eng sécher Browser Extensioun

Noriichtenaustausch

Also musse mir zwee Kommunikatiounskanälen opbauen: Inpage <-> Hannergrond a Popup <-> Hannergrond. Dir kënnt natierlech just Messagen un den Hafen schécken an Ären eegene Protokoll erfannen, awer ech léiwer d'Approche déi ech am Metamask Open Source Projet gesinn hunn.

Dëst ass eng Browserextensioun fir mam Ethereum Netzwierk ze schaffen. An et kommunizéieren verschidden Deeler vun der Applikatioun iwwer RPC mat der dnode Bibliothéik. Et erlaabt Iech en Austausch zimlech séier a bequem z'organiséieren wann Dir et mat engem Nodejs Stream als Transport ubitt (dat heescht en Objet deen déiselwecht Interface implementéiert):

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)))
})

Elo wäerte mir eng Applikatiounsklass erstellen. Et wäert API Objekter fir d'Popup an d'Websäit erstellen, an en Dnode fir si erstellen:

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)
        })
    }
}

Hei an ënnen, amplaz vum globalen Chrome Objet, benotze mir ExtensiounApi, déi Zougang zu Chrome am Google Browser a Browser an aneren. Dëst gëtt gemaach fir Cross-Browser Kompatibilitéit, awer fir den Zweck vun dësem Artikel kéint een einfach 'chrome.runtime.connect' benotzen.

Loosst eis eng Applikatioun Instanz am Hannergrond Skript 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)
    }
}

Zënter dnode funktionnéiert mat Streamen, a mir kréien en Hafen, ass eng Adapterklass néideg. Et gëtt gemaach mat der liesbarer Streambibliothéik, déi nodejs Streams am Browser implementéiert:

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()
    }
}

Loosst eis elo eng Verbindung an der UI erstellen:

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;
    }
}

Da kreéiere mir d'Verbindung am Inhaltsskript:

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);
    }
}

Well mir d'API net am Inhaltsskript brauchen, awer direkt op der Säit, maache mir zwou Saachen:

  1. Mir kreéieren zwee Baachen. Ee - Richtung Säit, uewen um PostMessage. Fir dëst benotze mir dëst dësem Package vun de Schëpfer vu Metamask. Deen zweete Stream ass fir den Hannergrond iwwer den Hafen deen vum kritt gëtt runtime.connect. Loosst eis se kafen. Elo wäert d'Säit e Stream op den Hannergrond hunn.
  2. Injizéiert de Skript an den DOM. Luet de Skript erof (Zougang zu deem war am Manifest erlaabt) a kreéiert en Tag script mat sengem Inhalt dobannen:

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);
    }
}

Elo kreéiere mir en API-Objet an der Inpage a setzen et op 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;
}

Mir si prett Remote Procedure Call (RPC) mat getrennten API fir Säit an UI. Wann Dir eng nei Säit mam Hannergrond verbënnt, kënne mir dëst gesinn:

Schreiwen eng sécher Browser Extensioun

Eidel API an Hierkonft. Op der Säit Säit kënne mir d'Hallo Funktioun esou nennen:

Schreiwen eng sécher Browser Extensioun

Schafft mat Callback Funktiounen am modernen JS ass schlecht Manéieren, also loosst eis e klengen Helfer schreiwen fir en Dnode ze kreéieren deen Iech erlaabt en API Objet un Utils weiderzeginn.

D'API Objekter wäerten elo esou ausgesinn:

export class SignerApp {

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

...

}

Kritt en Objet vu Fernseh wéi dëst:

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

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

A ruffen Funktiounen zréck e Verspriechen:

Schreiwen eng sécher Browser Extensioun

Versioun mat asynchrone Funktiounen verfügbar hei.

Insgesamt schéngt d'RPC an d'Stream Approche zimmlech flexibel: mir kënnen Dampmultiplexing benotzen a verschidde verschidde APIe fir verschidden Aufgaben erstellen. Prinzipiell kann dnode iwwerall benotzt ginn, den Haapt Saach ass den Transport an der Form vun engem Nodejs Stream ze wéckelen.

Eng Alternativ ass de JSON-Format, deen den JSON RPC 2-Protokoll implementéiert, et funktionéiert awer mat spezifesche Transporter (TCP an HTTP(S)), wat an eisem Fall net uwendbar ass.

Intern Staat a lokal Storage

Mir mussen den internen Zoustand vun der Applikatioun späicheren - op d'mannst d'Ënnerschreiwe Schlësselen. Mir kënne ganz einfach e Staat an d'Applikatioun addéieren a Methoden fir se an der Popup API z'änneren:

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)
        }
    }

    ...

} 

Am Hannergrond wéckele mir alles an enger Funktioun a schreiwen den Applikatiounsobjekt op d'Fënster fir datt mir mat der Konsole kënne schaffen:

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)
        }
    }
}

Loosst eis e puer Schlësselen vun der UI Konsole addéieren a kucken wat mam Staat geschitt:

Schreiwen eng sécher Browser Extensioun

De Staat muss persistent gemaach ginn fir datt d'Schlësselen net beim Neistart verluer ginn.

Mir späicheren et am localStorage, iwwerschreiwe se mat all Ännerung. Duerno wäert Zougang zu et och fir d'UI néideg sinn, an ech wéilt och op Ännerungen abonnéieren. Baséierend op dësem wäert et bequem sinn eng beobachtbar Späichere ze kreéieren an op seng Ännerungen ze abonnéieren.

Mir benotzen d'Mobx Bibliothéik (https://github.com/mobxjs/mobx). De Choix ass drop gefall, well ech net domat ze schaffen hunn, mee ech wollt et wierklech studéieren.

Loosst eis d'Initialiséierung vum initialen Zoustand addéieren an de Buttek beobachtbar maachen:

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)
    }

    ...

}

"Ënnert dem Hood", huet mobx all Buttekfelder mat Proxy ersat an all Uruff un hinnen ofgefaangen. Et wäert méiglech sinn op dës Messagen ze abonnéieren.

Hei drënner wäert ech dacks de Begrëff "wann ech änneren", obwuel dat net ganz korrekt ass. Mobx Bunnen Zougang zu Felder. Getters a Setzer vu Proxyobjekter déi d'Bibliothéik erstellt gi benotzt.

Action Dekorateuren déngen zwee Zwecker:

  1. Am strikte Modus mam EnforceActions Fändel verbitt mobx de Staat direkt z'änneren. Et gëtt als gutt Praxis ugesinn ënner strikte Konditiounen ze schaffen.
  2. Och wann eng Funktioun den Zoustand e puer Mol ännert - zum Beispill ännere mir e puer Felder an e puer Zeilen vum Code - d'Beobachter ginn nëmmen informéiert wann se fäerdeg ass. Dëst ass besonnesch wichteg fir de Frontend, wou onnéideg Staatsupdates zu onnéidege Rendering vun Elementer féieren. An eisem Fall ass weder déi éischt nach déi zweet besonnesch relevant, awer mir verfollegen déi bescht Praktiken. Et ass üblech fir Dekorateuren un all Funktiounen ze befestigen, déi den Zoustand vun den observéierte Felder änneren.

Am Hannergrond fügen mir d'Initialiséierung derbäi a späicheren de Staat am LocalStorage:

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)
        }
    }
}

D'Reaktiounsfunktioun ass hei interessant. Et huet zwee Argumenter:

  1. Dateselektor.
  2. En Handler dee mat dësen Donnéeën all Kéier geruff gëtt wann se ännert.

Am Géigesaz zu Redux, wou mir explizit de Staat als Argument kréien, erënnert mobx sech un wéi eng Observatioune mir am Selector zougräifen, a rifft nëmmen den Handler wann se änneren.

Et ass wichteg genee ze verstoen wéi mobx entscheet op wéi eng Observatioune mir abonnéieren. Wann ech e selector am Code geschriwwen wéi dëst() => app.store, da gëtt d'Reaktioun ni genannt, well d'Späichere selwer net beobachtbar ass, nëmme seng Felder sinn.

Wann ech et esou geschriwwen hunn () => app.store.keys, da géif erëm näischt geschéien, well wann Dir Array-Elementer derbäigesat/läscht, wäert d'Referenz dorop net änneren.

Mobx handelt als Selektor fir d'éischte Kéier an hält nëmmen Observéierbaren op déi mir zougänglech sinn. Dëst gëtt duerch Proxy Getters gemaach. Dofir gëtt déi agebaute Funktioun hei benotzt toJS. Et gëtt en neien Objet zréck mat all Proxyen ersat duerch déi ursprénglech Felder. Wärend der Ausféierung liest et all Felder vum Objet - dofir ginn d'Getter ausgeléist.

An der Popup Konsole wäerte mir erëm e puer Schlësselen derbäi. Dës Kéier sinn se och am localStorage opgehalen:

Schreiwen eng sécher Browser Extensioun

Wann d'Hannergrond Säit nei gelueden ass, bleift d'Informatioun op der Plaz.

All Applikatiounscode bis zu dësem Punkt ka gekuckt ginn hei.

Séchert Späichere vu private Schlësselen

Privat Schlësselen am Kloertext späicheren ass onsécher: et ass ëmmer eng Chance datt Dir gehackt gëtt, Zougang zu Ärem Computer kritt, asw. Dofir späichere mir am localStorage d'Schlësselen an enger Passwuert-verschlësselte Form.

Fir méi Sécherheet wäerte mir e gespaarten Zoustand an d'Applikatioun addéieren, an deem et guer keen Zougang zu de Schlëssel gëtt. Mir wäerten d'Verlängerung automatesch an de gespaarten Zoustand transferéieren wéinst engem Timeout.

Mobx erlaabt Iech nëmmen e Minimum Satz vun Daten ze späicheren, an de Rescht gëtt automatesch berechent op der Basis. Dëst sinn déi sougenannte berechnen Eegeschaften. Si kënne mat Usiichten an Datenbanken verglach ginn:

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')
        }
    }
}

Elo späichere mir nëmmen déi verschlësselte Schlësselen a Passwuert. Alles anescht gëtt berechent. Mir maachen den Transfert op e gespaarten Zoustand andeems mir d'Passwuert aus dem Staat erofhuelen. Déi ëffentlech API huet elo eng Method fir d'Späichere ze initialiséieren.

Geschriwwen fir Verschlësselung Utilities déi Krypto-js benotzen:

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)
}

De Browser huet eng Idle API duerch déi Dir op en Event abonnéiere kënnt - Staat Ännerungen. Staat, deementspriechend, kann idle, active и locked. Fir Idle kënnt Dir en Timeout setzen, a gespaart gëtt agestallt wann d'OS selwer blockéiert ass. Mir änneren och de Selektor fir op localStorage ze späicheren:

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)
        }
    }
}

De Code virun dësem Schrëtt ass hei.

Transaktiounen

Also, mir kommen op déi Wichtegst Saach: Transaktiounen op der Blockchain erstellen an z'ënnerschreiwen. Mir benotzen d'WAVES Blockchain a Bibliothéik Wellen-Transaktiounen.

Als éischt, loosst eis dem Staat eng Rei vu Messagen bäidroen, déi ënnerschriwwe musse ginn, da füügt Methoden derbäi fir en neie Message derbäi, d'Ënnerschrëft ze bestätegen, a refuséieren:

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'
    }

    ...
}

Wa mir en neie Message kréien, fügen mir Metadaten derbäi, maachen observable an dobäizemaachen store.messages.

Wann Dir net observable manuell, da wäert mobx et selwer maachen wann Dir Messagen an d'Array bäidréit. Wéi och ëmmer, et wäert en neien Objet erstellen op deen mir keng Referenz hunn, awer mir brauche se fir de nächste Schrëtt.

Als nächst wäerte mir e Verspriechen zréckginn, dat léist wann de Messagestatus ännert. De Status gëtt duerch Reaktioun iwwerwaacht, déi "sech selwer ëmbréngen" wann de Status ännert.

Method Code approve и reject ganz einfach: Mir änneren einfach de Status vun der Noriicht, no der Ënnerschrëft wann néideg.

Mir setzen Approve a refuséieren an der UI API, newMessage an der Säit 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)
        }
    }

    ...
}

Loosst eis elo probéieren d'Transaktioun mat der Extensioun z'ënnerschreiwen:

Schreiwen eng sécher Browser Extensioun

Am Allgemengen ass alles prett, alles wat bleift ass fügen einfach UI.

UI

D'Interface brauch Zougang zum Applikatiounsstaat. Op der UI Säit wäerte mir maachen observable Staat a füügt eng Funktioun un der API un déi dësen Zoustand ännert. Loosst eis addéieren observable zum API-Objet, deen vum Hannergrond kritt:

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)
}

Um Enn fänken mir un d'Applikatiouns-Interface ze maachen. Dëst ass eng Reaktiounsapplikatioun. Den Hannergrondobjekt gëtt einfach mat Requisiten iwwerginn. Et wier natierlech richteg, e separaten Service fir Methoden an e Buttek fir de Staat ze maachen, awer fir den Zweck vun dësem Artikel ass dat genuch:

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')
    );
}

Mat Mobx ass et ganz einfach Rendering unzefänken wann d'Daten änneren. Mir hänken einfach den Beobachterdekorateur aus dem Package mobx-reagéieren op der Komponent, a Render- gëtt automatesch genannt wann all observables vun der Komponent referenzéierten änneren. Dir braucht keng mapStateToProps oder verbënnt wéi an Redux. Alles funktionnéiert direkt aus der Këscht:

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>
    }
}

Déi reschtlech Komponente kënnen am Code gekuckt ginn am UI Dossier.

Elo an der Applikatiounsklass musst Dir e Staatselektor fir den UI maachen an den UI informéieren wann et ännert. Fir dëst ze maachen, loosst eis eng Method addéieren getState и reactionruffen 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())

        })
    }

    ...
}

Wann Dir en Objet kritt remote gëtt erstallt reaction fir den Zoustand z'änneren, deen d'Funktioun op der UI Säit nennt.

De finalen Touch ass d'Display vun neie Messagen op der Extensioun Ikon ze addéieren:

function setupApp() {
...

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

...
}

Also, d'Applikatioun ass prett. Websäite kënnen eng Ënnerschrëft fir Transaktiounen ufroen:

Schreiwen eng sécher Browser Extensioun

Schreiwen eng sécher Browser Extensioun

De Code ass hei verfügbar Link.

Konklusioun

Wann Dir den Artikel bis zum Schluss gelies hutt, awer nach ëmmer Froen hutt, kënnt Dir se op Repositories mat Extensioun. Do fannt Dir och Verpflichtungen fir all designéierte Schrëtt.

A wann Dir interesséiert sidd fir de Code fir déi aktuell Extensioun ze kucken, kënnt Dir dëst fannen hei.

Code, Repository an Jobbeschreiwung vun siemarell

Source: will.com

Setzt e Commentaire