Firefox: размер файла по ссылке, или через тернии к форку

в 21:57, , рубрики: Firefox, javascript, Веб-разработка, приватность, расширение Firefox, расширение Thunderbird, свойства ссылки

Скриншот расширения Link Properties Plus
Вашему вниманию представляется небольшая история появления расширения Link Properties Plus и описание того, как работает его основная часть.
Расширение позволяет узнать размер, дату последнего изменения и некоторые другие свойства файла по ссылке (в том числе прямую ссылку после всех перенаправлений) без скачивания всего файла целиком. Если, конечно, все это сообщает сервер.

Как это было

Когда-то давно было расширение Extended Link Properties, и оно работало.
Потом была обновленная версия на уже закрывшемся forum.addonsmirror.net (копия на web.archive.org).

1.3.x

Но в июне 2009-го мне захотелось странного. Так уж получается, что, хорошо это (с точки зрения результата) или плохо (лучше бы я выспался), а мне иногда хочется странного.
В результате расширение научилось работать с окном выбора действия с файлом. Ну, и мелкие улучшения в качестве бонуса.
К сожалению, не обошлось и без сложностей: встроенный загрузчик файлов начинает скачивать файл, не дожидаясь, пока пользователь выберет, что следует делать с этим файлом. Это, конечно, отдельная «проблема» (к счастью, лишний трафик уже не столь критичен), но она порождала куда более серьезную: пока шло скачивание файла, нельзя было сделать запрос по той же самой ссылке – то есть запрос-то, вроде бы, посылался, но ответ приходит уже после скачивания. А без запроса, разумеется, ничего не узнать – только удаленному серверу известно, что за файл у него хранится.
Так появилась идея добавить к ссылке «?случайные_цифры». Да, есть вероятность, что сервер на измененный запрос вернет другую информацию, но это все равно куда лучше, чем ничего.
Хак, к слову, в новых версиях оказался уже не нужен, хотя у одного пользователя то ли падал, то ли зависал Thunderbird при попытке открытия PDF-файлов – помогло включение скрытой настройки для принудительного включения хака.
А вот выкладывать на AMO было как-то лень – и хак для окна загрузок тот еще, и было интереснее продолжить улучшения. К тому же, надо было менять идентификатор расширения и как-то оповещать пользователей, уже установивших версию с оригинальным идентификатором.
Потом в Firefox 3.7a1pre удалили из контекстного меню пункт «Свойства», и оригинальное расширение перестало работать, теперь уже навсегда. Пришлось добавлять поддержку Element Properties, расширения-заменителя.

1.4.x

Как бы там ни было, в мае 2010-го появилась новая версия, пока еще тестовая. Уже со своим окошком и не зависящая от удаленного диалога свойств. Ну, и без разных полезных косметических мелочей не обошлось.
Тогда же был обнаружен недофорк: Extended Link Properties +, код которого полностью соответствовал моей версии 1.3.5 – изменения были, но коснулись они только локализации.
Я, конечно, обиделся – меня-то не спросили (и не пытались уговорить выложить на AMO), но тратить время и нервы на разборки категорически не хотелось. :) У меня была недоделанная новая версия с кучей недотестированных изменений – куда интереснее (и полезнее – да, это своеобразный эгоизм) было заниматься ей. Так что вместо разборок появилась поддержка FTP-ссылок.
Тем временем исправили Bug 455913 — nsIHelperAppLauncher should provide info about content length, так что размер в диалоге загрузки стало возможно узнать сразу же.

1.5.x

Это было неторопливо и с перерывами на несколько месяцев: 1.4.1pre1 в апреле 2011-го, релиз – спустя почти два года, в январе 2013-го.
Зато был сделан полноценный форк с новым идентификатором, добавлена поддержка новых версий Firefox с рыжей кнопкой вместо по умолчанию скрытого меню и возможность задавать вручную произвольный HTTP referer, открытие и сохранение ссылок прямо из окошка со свойствами. А еще поддержка Thunderbird, обработка практически всех протоколов и отображение прямой ссылки на файл. И даже когда-то давно обещанное автоматическое закрытие окошка.

Это была, так сказать, историческая справка.
А теперь сделаем простейшую реализацию получения свойств файла по ссылке.
Для неторопливых в самом конце приведена ссылка на полученный код.

Как это сейчас, реализация

Для начала наиболее простой способ тестирования кода.
Надо включить devtools.chrome.enabled = true в about:config и запустить
Веб-разработка – Простой редактор JavaScript aka Scratchpad (Shift+F4)
Далее выбрать
Окружение – Браузер
Все, можно тестировать код, который будет запускаться с правами как у расширений (так что не следует запускать что попало).

Минимальный вариант для получения свойств ссылки

Для отправки произвольных запросов существует nsIChannel, там же можно прочитать, что нам нужен или nsIIOService.newChannel(), или nsIIOService.newChannelFromURI().
А у нас есть текстовая ссылка. То есть логично использовать newChannel(), да только вот практика показывает, что URI все же понадобится – можно наткнуться на самописный протокол (Custom Buttons), который ничего не возвращает, а можно на протокол about: (nsIAboutModule) – в общем-то, развлекательство, но и про такие ссылки можно кое-что узнать, так что почему бы и нет.
Таки образом, детали еще не ясны, но понятно, что надо создать экземпляр nsIChannel и вызвать у него asyncOpen(). А этот asyncOpen() принимает первым аргументом реализацию nsIStreamListener'а, которая и позволит узнать результат отправленного запроса.
Пожалуй, пора показывать пример:

// Некие исходные данные для примера:
var uriString = "https://addons.mozilla.org/firefox/downloads/latest/413716/addon-413716-latest.xpi";
var referer = "https://addons.mozilla.org/";

var ios = Components.classes["@mozilla.org/network/io-service;1"]
	.getService(Components.interfaces.nsIIOService);
var uri = ios.newURI(uriString, null, null);
var scheme = uri.scheme && uri.scheme.toLowerCase();

var channel = scheme == "about" && "nsIAboutModule" in Components.interfaces
	// Небольшое колдунство для about: ссылок
	? Components.classes["@mozilla.org/network/protocol/about;1?what=" + uri.path.replace(/[?&#].*$/, "")]
		.getService(Components.interfaces.nsIAboutModule)
		.newChannel(uri)
	: ios.newChannelFromURI(uri);

Теперь у нас есть экземпляр nsIChannel, и с этим надо что-то делать. :)
Во-первых, следует реализовать nsIStreamListener. А во-вторых, пригодится nsIHttpChannel.visitRequestHeaders()/nsIHttpChannel.visitResponseHeaders() (на случай если получившийся nsIChannel еще и nsIHttpChannel). Ну, а у nsIFTPChannel есть свойство lastModifiedTime.
Так что получаем вот такое продолжение:

var observer = { ... }; // Тут надо реализовать интерфейс nsIStreamListener и nsIHttpHeaderVisitor
var data = []; // Для примера будем просто собирать результаты в массив
var headers = []; // Еще один массив, для заголовков
if(channel instanceof Components.interfaces.nsIHttpChannel) {
	// Проверка на instanceof неявно делает
	// channel.QueryInterface(Components.interfaces.nsIHttpChannel),
	// но не генерирует ошибок в случае отсутствия поддержки запрашиваемого интерфейса
	channel.requestMethod = "HEAD"; // HEAD-запрос
	channel.setRequestHeader("Referer", referer, false);
	channel.visitRequestHeaders(observer);
	headers.push(""); // Отделим заголовки запроса от заголовков ответа
}
// Следующая строка выглядит странно, но nsIFTPChannel нам еще пригодится
channel instanceof Components.interfaces.nsIFTPChannel;
channel.asyncOpen(observer, null);

Вообще, конечно, размножать глобальные переменные без необходимости нехорошо, но так нагляднее. А если возникают какие-то сложности с нарезкой примеров на модули и прочей инкапсуляцией, у меня для вас плохие новости. :)

Теперь попробуем сделать объект observer, реализующий необходимые интерфейсы:

var observer = {
	// nsIRequestObserver (nsIStreamListener наследует этот интерфейс)
	onStartRequest: function(aRequest, aContext) {
		if(aRequest instanceof Components.interfaces.nsIHttpChannel)
			aRequest.visitResponseHeaders(this);
		else {
			if("contentType" in channel)
				data.push("Тип содержимого: " + channel.contentType);
			if("contentLength" in channel)
				data.push("Размер файла: " + channel.contentLength);
			if("responseStatus" in channel && "responseStatusText" in channel)
				data.push("Статус: " + channel.responseStatus + " " + channel.responseStatusText);
			if("lastModifiedTime" in aRequest && aRequest.lastModifiedTime) { // Firefox 4
				var t = aRequest.lastModifiedTime;
				data.push("Последнее изменение: " + new Date(t > 1e14 ? t/1000 : t).toLocaleString());
			}
		}
	},
	onStopRequest: function(aRequest, aContext, aStatusCode) {
		if(aRequest instanceof Components.interfaces.nsIChannel && aRequest.URI)
			data.push("Прямая ссылка: " + aRequest.URI.spec);
		this.done();
	},
	// nsIStreamListener
	onDataAvailable: function(aRequest, aContext, aInputStream, aOffset, aCount) {
		// Кажется, что-то пошло не так, не нужно нам данные получать, отменяем
		aRequest.cancel(Components.results.NS_BINDING_ABORTED);
	},
	// nsIHttpHeaderVisitor
	visitHeader: function(aHeader, aValue) {
		headers.push(aHeader + ": " + aValue);
		switch(aHeader) {
			// Тут можно как-то красиво форматировать данные
			case "Content-Length": data.push("Размер файла: " + aValue);    break;
			case "Content-Type":   data.push("Тип содержимого: " + aValue); break;
			case "Last-Modified":  data.push("Последнее изменение: " + new Date(aValue).toLocaleString());
		}
	},

	done: function() {
		alert(
			data.join("n")
			+ "nnЗаголовки:n" + headers.join("n")
		);
	}
};

В результате получим сообщение:

Тип содержимого: application/x-xpinstall
Последнее изменение: 26 Февраль 2013 г. 0:46:30
Размер файла: 46897
Прямая ссылка: https://addons.cdn.mozilla.net/storage/public-staging/413716/link_properties_plus-1.5.1-fx+sm+tb.xpi

Заголовки:
Host: addons.mozilla.org
User-Agent: Mozilla/5.0 (Windows NT 6.1; rv:20.0) Gecko/20100101 Firefox/20.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: ru-ru,ru;q=0.8,en-us;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Referer: https://addons.mozilla.org/

Server: nginx
X-Backend-Server: web13.addons.phx1.mozilla.com
Content-Type: application/x-xpinstall
Accept-Ranges: bytes
Last-Modified: Tue, 26 Feb 2013 00:46:30 GMT
X-Cache-Info: caching
Content-Length: 46897
Cache-Control: max-age=79492
Expires: Sun, 07 Apr 2013 17:32:01 GMT
Date: Sat, 06 Apr 2013 19:27:09 GMT

Отслеживание перенаправлений

Для отслеживания перенаправлений есть nsIChannel.notificationCallbacks. То есть нужно добавить

channel.notificationCallbacks = observer;

после создания channel и объекта observer, а в сам объект observer добавить реализацию nsIInterfaceRequestor. При этом nsIInterfaceRequestor.getInterface() должен уметь возвращать реализацию nsIChannelEventSink для обработки перенаправлений.
Так что добавляем «приемник» информации о перенаправлениях рядом с двумя уже имеющимися:

var redirects = []; // Массив для данных о перенаправлениях

И обновляем функцию вывода результатов

	done: function() {
		alert(
			data.join("n")
			+ "nnПеренаправления:n" + redirects.join("n")
			+ "nnЗаголовки:n" + headers.join("n")
		);
	}

А в наш observer надо добавить

var observer = {
	...
	// nsIInterfaceRequestor
	getInterface: function(iid) {
		if(iid.equals(Components.interfaces.nsIChannelEventSink))
			return this;
		throw Components.results.NS_ERROR_NO_INTERFACE;
	},
	// nsIChannelEventSink
	onChannelRedirect: function(oldChannel, newChannel, flags) { // Gecko < 2
		this.onRedirect.apply(this, arguments);
	},
	asyncOnChannelRedirect: function(oldChannel, newChannel, flags, callback) {
		// Надо обязательно разрешить перенаправление, иначе запрос будет прерван!
		callback.onRedirectVerifyCallback(Components.results.NS_OK);
		this.onRedirect.apply(this, arguments);
	},
	onRedirect: function(oldChannel, newChannel, flags) {
		if(!redirects.length) // Это самое первое перенаправление
			redirects.push(oldChannel.URI.spec);
		// https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsIChannelEventSink#Constants
		var ces = Components.interfaces.nsIChannelEventSink;
		var types = [];
		if(flags & ces.REDIRECT_TEMPORARY)
			types.push("временное");
		if(flags & ces.REDIRECT_PERMANENT)
			types.push("постоянное");
		if(flags & ces.REDIRECT_INTERNAL)
			types.push("внутреннее");
		redirects.push("=> (" + types.join(", ") + ") " + newChannel.URI.spec);
	},
	...

В результате к выводу добавится

Перенаправления:
https://addons.mozilla.org/firefox/downloads/latest/413716/addon-413716-latest.xpi
=> (постоянное) https://addons.mozilla.org/firefox/downloads/latest/link-properties-plus/addon-link-properties-plus-latest.xpi
=> (временное) https://addons.mozilla.org/firefox/downloads/file/185918/link_properties_plus-1.5.1-fx+sm+tb.xpi
=> (временное) https://addons.cdn.mozilla.net/storage/public-staging/413716/link_properties_plus-1.5.1-fx+sm+tb.xpi

Приватный режим

Теперь еще можно добавить поддержку приватного режима. В статье Supporting per-window private browsing как раз есть подходящий пример:

var channel = Services.io.newChannel("http://example.org", null, null);
channel.QueryInterface(Components.interfaces.nsIPrivateBrowsingChannel);
channel.setPrivate(true); // force the channel to be loaded in private mode

А мы можем еще дополнительно убедиться, что приватный режим уже поддерживается:

if(
	private // Флаг-настройка
	&& "nsIPrivateBrowsingChannel" in Components.interfaces
	&& channel instanceof Components.interfaces.nsIPrivateBrowsingChannel
	&& "setPrivate" in channel
)
	channel.setPrivate(true);

В реальном коде, конечно же, надо определять приватность источника ссылки. Но про это я уже писал – с помощью resource://gre/modules/PrivateBrowsingUtils.jsm можно узнать приватность любого объекта window.

Итого

Результат одним файлом:
https://gist.github.com/Infocatcher/5327631
Там же в ревизиях можно отследить наращивание функциональности: добавление обработки перенаправлений и поддержки приватного режима.

Вот и все. Остается только добавить обработку ошибок, преобразовать в удобный для использования вид, заменить alert() на что-нибудь более удобное и прицепить вызов функции для получения свойств ссылки к интерфейсу.

P.S. Новая версия расширения с отслеживанием перенаправлений и поддержкой приватного режима пока еще ожидает проверки.

Автор:

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js