MediaWiki:Modules/signed-embed.js

Материал из Мракопедии
Перейти к: навигация, поиск

Замечание. Возможно, после сохранения вам придётся очистить кэш своего браузера, чтобы увидеть изменения.

  • Firefox / Safari: Удерживая клавишу Shift, нажмите на панели инструментов Обновить либо нажмите Ctrl-F5 или Ctrl-R (⌘-R на Mac)
  • Google Chrome: Нажмите Ctrl-Shift-R (⌘-Shift-R на Mac)
  • Internet Explorer: Удерживая Ctrl, нажмите Обновить либо нажмите Ctrl-F5
  • Opera: Перейдите в Menu → Настройки (Opera → Настройки на Mac), а затем Безопасность → Очистить историю посещений → Кэшированные изображения и файлы
/********************************************************************************
* Этот модуль занимается тем, что обрабатывает подписанные включения.
* 
* == Зачем это нужно?
* У тех, кто имеет возможность редактировать эту страницу (т.е. администраторов), 
* теперь есть еще и бонусная возможность встраивать HTML-код, JavaScript и CSS 
* без ограничений прямо в историях.
*
* Т.е. теперь можно сделать отдельно взятую историю более интерактивной, и не 
* городить велосипеды в Common.js, и не замусоривать Common.css стилями для 
* отдельной истории.
*
* == Как этим пользоваться?
* Если нужно добавить JavaScript, в историю нужно добавить тэг следующего вида:
*   <pre data-type="text/javascript">
*     alert('Привет, я жабоскрипт!');
*     $('body').html('Эта история говно, поэтому мы ее больше не показываем');
*   </pre>
*
* Если нужно добавить HTML, то тэг приобретает следующий вид:
*   <pre data-type="text/html">
*     <button id="knopa">Ого, да я же самая настоящая кнопка!</button>
*     <script>
*       $('#knopa').click(function() {
*         alert('Тут еще и обработчики! Вот это да!');
*       });
*     </script>
*   </pre>
*
* Если нужно добавить CSS, то тэг приобретает следующий вид:
*   <pre data-type="text/css" data-id="my-style-tag">
*     body {
*       background: url(http://...);
*     }
*   </pre>
*
* Отступы приведены исключительно для красоты оформления и ни на что не влияют.
*
* Затем, когда тэг добавлен, его нужно подписать. Для этого нужно нажать на 
* красную кнопку в виде восковой печати, и все волшебство случится автоматически.
*
* Вот, в принципе, и все. Неподписанные (или плохо подписанные) включения 
* заменяются на сообщение об ошибке в стиле Mediawiki с объяснением, что пошло 
* не так. Если будут вопросы — обращайстесь к toriningen-у, он эту херь породил.
********************************************************************************/

~function() {

waitFor('deps.esShim && deps.elliptic', function() {
  setupSignedEmbed({
    // ДОПИСЫВАЙТЕ СЮДА СВОИ ПУБЛИЧНЫЕ КЛЮЧИ
    "toriningen;1464505906296": "A6r5aaUrzRVMkscRd9Tb7DLChwE9pFEsqj/eknsETZp5",
    
    // НАД ЭТОЙ СТРОКОЙ
  });
  
  setupSignedEmbedToolbar();
});

// Обработчики различных типов включений, можно добавлять новые.
var mimeHandlers = {
  'text/html': function($embed, source) {
    $embed.replaceWith(source);
  },
  
  'text/javascript': function($embed, source) {
    function run() {
      try {
        eval(source);
      } catch(e) {
        console.error(e);
      }
    }
    
    setTimeout(function() {
      var pred = $embed.data('waitfor');
      if (pred != null) {
        waitFor(pred, run);
      } else {
        run();
      }
    }, 0);
    
    $embed.remove();
  },
  
  'text/css': function($embed, source) {
    $('<style>')
      .html(source)
      .attr('id', $embed.data('id'))
      .appendTo($('head'));
    
    $embed.remove();
  },
};

var pubkeys;

function setupSignedEmbed(b64pubkeys) {
  pubkeys = parseKeys(b64pubkeys);
  var selector = 'pre[data-type],pre[data-signature],pre[data-signee]';
  
  $(selector).each(function() {
    decodeEmbed(this);
  });
  
  waitFor('deps.insq', function() {
    insertionQ(selector).every(function(el) {
      decodeEmbed(el);
    });
  });
}

function setupSignedEmbedToolbar() {
  if (!window.localStorage) {
    return;
  }
  
  function extend() {
    mw.toolbar.addButton({
      imageFile: '//mrakopedia.org/w/images/5/5f/Sign-embed.png',
      speedTip: 'Переподписать все включения с неверной подписью',
      imageId: 'button-sign-embed',
      tagOpen: '', tagClose: '', sampleText: '',
    });
    
    $('#button-sign-embed').css({
      'margin-left': '0.5em',
    }).click(function() {
      mw.loader.using('jquery.color', function() {
        toolbarSign();
      });
    });
    
    mw.toolbar.addButton({
      imageFile: '//mrakopedia.org/w/images/f/f1/Sign-embed-settings.png',
      speedTip: 'Настройки криптоподписи',
      imageId: 'button-sign-embed-settings',
      tagOpen: '', tagClose: '', sampleText: '',
    });
    
    $('#button-sign-embed-settings').click(function() {
      toolbarConfigure();
    });
  }
  
  if (mw.toolbar == null) {
    return;
  }
  
  mw.loader.using('user.options', function() {
    if (mw.user.options.get('showtoolbar') == 1) {
      // Показывать кнопки только для администраторов и желающих
      if (+localStorage.enableSignedEmbedToolbar || $.inArray('sysop', mw.config.get('wgUserGroups')) !== -1) {
        waitFor('deps.elliptic && deps.toolbar', function() {
          extend();
        });
      }
    }
  });
}

function validateEmbed($embed) {
  var mime = $embed.data('type');
  if (mime == null) {
    return 'mime-missing';
  }
  
  if (mimeHandlers[mime] == null) {
    return 'mime-unknown';
  }
  
  var signee = $embed.data('signee');
  if (signee == null) {
    return 'signee-missing';
  }
  
  var pubkey = pubkeys.get(signee);
  if (pubkey == null) {
    return 'signee-unknown';
  }
  
  var b64signature = $embed.data('signature');
  if (b64signature == null) {
    return 'signature-missing';
  }
  
  try {
    var signature = elliptic.bitcoin.ECSignature.fromDER(new elliptic.buffer.Buffer(b64signature, 'base64'));
  } catch(e) {
    console.error('signature decode', $embed[0], e);
    return 'signature-malformed';
  }
  
  var source = $embed.text();
  var hash = hashEmbed($embed, source, false);
  try {
    if (!pubkey.verify(hash, signature)) {
      return 'signature-broken';
    }
  } catch(e) {
    console.error('signature verify', $embed[0], e);
    return 'signature-broken';
  }
  
  return null;
}

function decodeEmbed(embed) {
  var $embed = $(embed);
  $embed.css({'display': 'none'});
  
  var err = validateEmbed($embed);
  
  if (err) {
    var msg = {
      'mime-missing': 'У включения не указан тип: добавьте аттрибут data-type',
      'mime-unknown': 'У включения указан неизвестный тип: поддерживаются <code>' + Object.keys(mimeHandlers).join(', ') + '</code>',
      'signee-missing': 'У включения не указан автор: добавьте аттрибут data-signee',
      'signee-unknown': 'Неизвестный автор включения: добавьте ваш открытый ключ в <a href="//mrakopedia.org/wiki/MediaWiki:Modules/signed-embed.js">MediaWiki:Modules/signed-embed.js</a>',
      'signature-missing': 'У включения нет подписи: для пересоздания нажмите на кнопку с восковой печатью',
      'signature-malformed': 'Повреждена подпись: для пересоздания нажмите на кнопку с восковой печатью',
      'signature-broken': 'У включения неправильная подпись: для пересоздания нажмите на кнопку с восковой печатью',
    }[err];
    
    $embed.replaceWith($('<b class="error">').html(msg + '<br>'))
    return;
  }
  
  var source = $embed.text();
  var mime = $embed.data('type');
  mimeHandlers[mime]($embed, source);
}

function parseKeys(b64pubkeys) {
  var signees = Object.keys(b64pubkeys);
  var pubkeys = new Map();
  
  for (var i = 0; i < signees.length; i++) {
    var signee = signees[i];
    var pubkeyBuf = new elliptic.buffer.Buffer(b64pubkeys[signee], 'base64');
    var pubkey = elliptic.bitcoin.ECPair.fromPublicKeyBuffer(pubkeyBuf);
    pubkeys.set(signee, pubkey);
  }

  return pubkeys;
}

function hashEmbed($embed, source, trimLeadingNewline) {
  if (trimLeadingNewline && (source.charAt(0) === '\n')) {
    // MediaWiki содержит "улучшение" - если сразу за открытием тэга идет перенос строки, то он не учитывается при рендеринге. Мне приходится учитывать еще и чужие костыли :с
    source = source.substr(1);
  }
  
  var mime = JSON.stringify($embed.data('type'));
  var id = JSON.stringify($embed.data('id'));
  var waitfor = JSON.stringify($embed.data('waitfor'));
  var pack = [
    mime,
    id,
    waitfor,
    source,
  ].join('\n');
  
  return elliptic.bitcoin.crypto.sha256(pack);
}

function toolbarSign() {
  var rx = /(<pre .*?>)([\s\S]*?)<\/pre>/ig;
  var content = $('#wpTextbox1').val();
  var match;
  var key = readStoredKey();
  
  if (key === 'missing') {
    alert('А у вас докУментов нету. Зайдите в настройки, тут рядом - первая кнопка направо.');
    return;
  }
  if (key === 'broken') {
    alert('У вас ключ отвалился. Зайдите в настройки, тут рядом - первая кнопка направо, и загляните в экспорт.');
    return;
  }
  
  $('#wpTextbox1').val(content.replace(rx, function(full, tag, source) {
    var $embed = $(tag);
    var mime = $embed.data('type');
    
    $embed.html(source);
    
    var err = validateEmbed($embed);
    if (err == null) {
      // переподписывать не нужно, оно и так работает
      return full;
    } else if (err === 'mime-missing') {
      // без типа - подписывать нельзя
      return full;
    } else {
      var hash = hashEmbed($embed, source, true);
      var signature = key.privkey.sign(hash);
      var b64signature = signature.toDER().toString('base64');
      
      $embed
        .attr('data-signature', b64signature)
        .attr('data-signee', key.id)
        .text('');
      
      // HAAAAAX
      tag = $embed.wrapAll('<p>').parent().html().slice(0, -6); // cut off closing tag
      
      var rep = tag + source + '</' + 'pre>';
      
      return rep;
    }
  }));
  
  $('#wpTextbox1')
    .css("background-color", "#9CFF9C")
    .animate({ backgroundColor: "#FFFFFF"}, 1000);
}

function menu(opts) {
  var msg;
  var keys = {};
  var defaultOpt = '';
  var catchall = menuIamStupid;
  
  msg = (opts.title || '') + '\n\n';
  if (opts.prompt) {
    msg += opts.prompt + '\n';
  }
  
  for (var i = 0; i < opts.options.length; i++) {
    var opt = opts.options[i];
    if (opt.default) {
      defaultOpt = opt.key;
    }
    if (opt.catchall) {
      catchall = opt.action;
    }
    
    msg += opt.key + ') ' + (opt.title || '') + '\n';
    keys[opt.key] = opt.action;
  }
  
  while (true) {
    var choice = prompt(msg, defaultOpt);
    if (choice == null) {
      throw new Error('fallthrough exit');
    }
    
    var rv;
    if (keys[choice]) {
      rv = keys[choice]();
    } else {
      rv = catchall();
    }
    
    if (rv) {
      break;
    }
  }
}

function menuMain() {
  return menu({
    title: '=== Настройки подписи включений ===',
    prompt: 'Чего изволите, любезнейший?',
    options: [
      {
        key: '1',
        title: 'Расскажите, что это за хрень?',
        action: menuAbout,
      },
      {
        key: '2',
        title: 'Создайте новый ключ',
        action: menuCreate,
      },
      {
        key: '3',
        title: 'Мне снова нужен публичный ключ для той странички',
        action: menuExport,
      },
      {
        key: '4',
        title: 'Удалите сохраненный ключ',
        action: menuClear,
      },
      {
        key: '0',
        default: true,
        title: 'Всего доброго, хорошего настроения и здоровья',
        action: menuBye,
      },
    ]
  });
}

function menuIamStupid() {
  alert('Введите цифру, которая соответствует вашему выбору.');
}

function menuAbout() {
  alert([
    'Здравствуйте. Я, Кирилл. Хотел чтобы вы сделали модуль, суть токова. Администратор может добавлять к любым статьям жабоскрипт, CSS-стили без ограничений и сырой HTML-код, и для этого не нужно править Common.js/.css и изобретать велосипеды по отфильтровке адреса текущей статьи. Можно грабить корованы.',
    '',
    'Так как эта функциональность должна использоваться только администраторами, во избежание набигания злодеев и деревянных домиков, все подобные включения сопровождаются криптоподписью ECDSA secp256k1. Несмотря на то, что создать ключ и подписать вложения может каждый желающий, внести свой ключ в список доверенных могут только администраторы.',
    '',
    'Инструкция по использованию вскоре появится, если ленивая жопа toriningen ее напишет. Пока ориентируйтесь на живые образцы или спросите меня лично.',
  ].join('\n'));
}

function menuCreate() {
  var storedKey = readStoredKey();
  
  if (storedKey === 'missing') {
    return menuCreateProceed();
  }
  
  if (storedKey === 'broken') {
    return menuCreateExistsBroken();
  }
  
  return menuCreateExists(storedKey);
}

function menuCreateExistsBroken() {
  return menu({
    title: 'Ой, а у вас уже есть какой-то. Только он не читается.',
    prompt: 'Ваши действия?',
    options: [
      {
        key: '1',
        title: 'Игнорируем! Пишите поверх!',
        action: menuCreateProceed,
      },
      {
        key: '2',
        title: 'Вы знаете, я передумал.',
        action: menuCreateAbort,
      },
    ]
  });
}

function menuCreateExists(storedKey) {
  return menu({
    title: 'Ой, а у вас уже есть такой. Называется ' + JSON.stringify(storedKey.id) + '.',
    prompt: 'Ваши действия?',
    options: [
      {
        key: '1',
        title: 'Игнорируем! Пишите поверх!',
        action: menuCreateProceed,
      },
      {
        key: '2',
        title: 'Вы знаете, я передумал.',
        action: menuCreateAbort,
      },
    ]
  });
}

function menuCreateProceed() {
  var defaultId = mw.user.getName() + ';' + (+new Date());
  var id;
  
  var msg = 'Как назовем? В принципе, можно смело соглашаться с вариантом по-умолчанию.';
  while (true) {
    id = prompt(msg, defaultId).trim();
    
    if (!id) {
      msg = 'Совсем без названия нельзя. Давайте еще раз?';
      continue;
    } else if (pubkeys.get(id)) {
      msg = 'У кого-то уже есть ключ с таким названием. Давайте еще раз?';
      continue;
    }
    
    break;
  }
  
  try {
    var privkey = elliptic.bitcoin.ECPair.makeRandom();
    var wif = privkey.toWIF();
  } catch(e) {
    showErrorReport(e);
    
    throw e;
  }
  
  var key = { id: id, wif: wif };
  localStorage.signedEmbedKey = JSON.stringify(key);
  var pubkey = privkey.getPublicKeyBuffer().toString('base64');
  var pubkeyStr = '    ' + JSON.stringify(id) + ': ' + JSON.stringify(pubkey) + ',';
  
  prompt([
    'Отлично! Ваш новый ключ создан и готов к работе.',
    '',
    'Последний штрих — скопируйте строчку ниже, и вставьте ее в указанное место на странице, которая сейчас откроется. Таким образом вы добавите свой открытый ключ в доверенные.'
  ].join('\n'), pubkeyStr);
  
  window.open('https://mrakopedia.org/w/index.php?title=MediaWiki:Modules/signed-embed.js&action=edit');
  
  return true;
}

function menuCreateAbort() {
  return true;
}

function menuExport() {
  var storedKey = readStoredKey();
  
  if (storedKey === 'missing') {
    return menu({
      title: 'А у вас пока ключей нет, нечего экспортировать :(',
      prompt: 'Хотите, создадим новый?',
      options: [
        {
          key: '1',
          title: 'Ну конечно, вы еще спрашиваете!',
          action: menuCreateProceed,
        },
        {
          key: '2',
          title: 'Вы знаете, я передумал.',
          action: menuCreateAbort,
        },
      ]
    });
  }
  
  if (storedKey === 'broken') {
    prompt([
      'Увы!',
      '',
      'У вас вместо ключа — нечитающаяся фигня. Вот она, в строчке ниже, можете скопировать и посмотреть сами.'
    ].join('\n'), '!!! ЭТО НЕ КЛЮЧ !!! ' + localStorage.signedEmbedKey);
  }
  
  var pubkey = storedKey.privkey.getPublicKeyBuffer().toString('base64');
  var pubkeyStr = '    ' + JSON.stringify(storedKey.id) + ': ' + JSON.stringify(pubkey) + ',';
  
  prompt([
    'Держите, вот ваш публичный ключ.',
    '',
    'Скопируйте строчку ниже, и вставьте ее в указанное место на странице, которая сейчас откроется.',
  ].join('\n'), pubkeyStr);
  
  window.open('https://mrakopedia.org/w/index.php?title=MediaWiki:Modules/signed-embed.js&action=edit');
  
  return true;
}

function menuClear() {
  return menu({
    title: 'Тоооочно?',
    options: [
      {
        key: 'дя',
        title: 'Да, я хочу все удалить',
        action: function() {
          localStorage.removeItem('signedEmbedKey');
          alert('Готово!');
          
          return true;
        },
      },
      {
        key: 'неть',
        default: true,
        title: 'Вы знаете, я передумал.',
        action: function () { return true; },
      },
    ]
  });
}

function menuBye() {
  alert('Держитесь там!');
  
  return true;
}

function readStoredKey() {
  var storedKey = localStorage.signedEmbedKey;
  
  if (!storedKey) {
    return 'missing';
  }
  
  try {
    storedKey = JSON.parse(storedKey);
  } catch(e) {
    console.error(e);
    return 'broken';
  }
  
  try {
    storedKey.privkey = elliptic.bitcoin.ECPair.fromWIF(storedKey.wif);
  } catch(e) {
    console.error(e);
    return 'broken';
  }
  
  return storedKey;
}

function showErrorReport(e) {
  console.error(e);
  
  var errorReport = JSON.stringify({
    str: e.toString && e.toString(),
    src: e.toSource && e.toSource(),
    columnNumber: e.columnNumber,
    fileName: e.fileName,
    lineNumber: e.lineNumber,
    message: e.message,
    name: e.name,
    stack: e.stack,
  });
  
  prompt([
    'Ой!',
    '',
    'Что-то пошло не так, и у нас ничего не вышло :(',
    '',
    'Давайте сделаем вот что — скопируйте вот эту строчку ниже и отправьте ее toriningen-у, а он посмотрит и попробует исправить. Может, там не все так страшно.',
  ].join('\n'), errorReport);
}

function toolbarConfigure() {
  try {
    while (menuMain()) {}
  } catch(e) {
    if (e.msg !== 'fallthrough exit') {
      throw e;
    }
  }
}

}();