Домашний файлообменник на базе Sinatra и DataMapper. Часть 1 — The Begining

    Продолжение статьи

    Длительное вступление


    Логотип  SinatraМне и моей жене часто приходится передавать файлы из точки А в точку Б, где точка А — один из домашних компьютеров, а точка Б — либо компьютер кого-нибудь из знакомых, либо, например, компьютер на работе (где, порой, запрещены флешки). К тому же идти за флешкой (которая может лежать в любой части квартиры) и копировать файл на нее — тоже достаточно лениво :) В общем, я обычно использую для этих целей сервисы вроде webfile.ru и box.net, а жена тупо посылает файл себе (или корреспонденту) по почте.


    Все эти варианты, сами понимаете, достаточно неудобны. Я решил, что файлы надо хранить на домашнем сервере, а скачивать каким-нибудь удобным способом. Я рассмотрел следующие варианты:
    • Поднять FTP. Но я не люблю FTP, и не везде есть возможность им воспользоваться
    • Поднять WebDAV сервер. Но это, во-первых, не очень секьюрно, а во-вторых, с WebDAV проблем еще больше, чем с FTP
    • Скачивать файлы через браузер. На этом варианте я и остановился


    Можно было, конечно, просто настроить Apache или Lighttpd на показ содержимого каталогов и прикрутить авторизацию, но это некрасиво выглядит внешне и создает неудобства при поиске файла, если их загружено много. К тому же сложно ограничить доступ к конкретному файлу (а, если вы помните, некоторые файлы хотелось передавать знакомым, а не только скачивать себе).

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

    Выбор платформы


    В своей жизни мне много доводилось писать на ASP.NET, но он мне надоел; PHP я знаю недостаточно хорошо и почему-то недолюбливаю; сейчас я пишу на Ruby on Rails, но он несколько монструозен для такой задачи. Однако сам язык Ruby мне очень нравится, поэтому я решил взять какой-нибудь другой framework на Ruby — заодно будет повод немного расширить свой кругозор. Сначала мой выбор пал на Rack, но быстро выяснилось, что они слишком аскетичен для моих требований. На ступеньку выше стоят два небольших фреймворка: Sinatra и Camping. По непонятным мне причинам, я выбрал Синатру.

    Знакомьтесь — Синатра


    На официальном сайте написано, что Синатра это даже не фреймворк, а DSL для создания веб-приложений, и я готов согласиться с таким определением. Для начала хотелось бы его установить. Нет ничего проще!

    sudo gem install sinatra

    Теперь напишем простейшее веб-приложение


    Создаем файл, скажем, myapp.rb и пишем в нем следующий код:

    1. require 'rubygems'
    2. require 'sinatra'
    3.  
    4. get '/' do
    5.  'Hello from Sinatra'
    6. end
    * This source code was highlighted with Source Code Highlighter.


    Запускаем

    ruby myapp.rb

    И открываем в броузере адрес localhost:4567. Вуаля! Видим Hello from Sinatra и радуемся.

    Что же мы написали? В первых двух строчках мы подключаем Синатру. А далее начинается как раз тот самый DSL: приложение на Синатре состоит из набора блоков вида:

    METHOD PATTERN do
    OUTPUT
    end


    Где METHOD — это HTTP-метод (get, post, put, delete), PATTERN — шаблон URL'а, а OUTPUT — код, генерирующий ответ для браузера. При поступлении запроса от браузера Sinatra определяет первый подходящий блок, соответствующий запросу по HTTP-методу и шаблону URL'а, выполняет код из этого блока и возвращает полученную строку клиенту.

    Остановимся чуть подробнее на PATTERN. Как я уже писал, это шаблон URL'а, и он может быть задан несколькими способами:
    • Константой (например, '/', '/about', '/hello/world')
    • Константой с именованными параметрами (например, '/:id', '/post/:id/show', '/user/:name/:action') — в этом случае, в OUTPUT можно обращаться к значениям соответствующих параметров через хэш params (params[:id], params[:name], params[:action])
    • Константой с использованием wildcard (например, '/select/*/from/*', '/load/*') — в этой ситуации в хэше params будет присутствовать массив params[:splat], содержащий все соответствия звездочкам (в первом случае при обращении, скажем, к '/select/id/from/users' params[:splat] будет содержать ['id', 'users'], а во втором, при обращении к '/load/all/my/money' в params[:splat] будет лежать ['all/my/money'] )
    • Регулярным выражением (например, '%r{/hello/([\w]+)}') — тут мы имеем дело уже с массивом params[:captures]


    Вот пример использования различных паттернов:

    require 'rubygems'
    require 'sinatra'

    get '/' do
      'Index page has you. Follow the white rabit.'
    end

    get '/give/me/the/:key' do
       "I don't have any #{params[:key]}. Maybe you should try another url."
    end

    get '/buy/*/in/*' do
      "#{params[:splat][1]} doesn't sell #{params[:splat][0].pluralize}"
    end

    get %r{^/where/is/(habr)(a\1)?.ru$} do
      "You're looking for <a href=\"http://habrahabr.ru\">habrahabr.ru</a>. It's not here."
    end


    * This source code was highlighted with Source Code Highlighter.


    Запустите приложение (ruby myapp.ru) и попробуйте следующие адреса:
    • localhost:4567/
    • localhost:4567/give/me/the/money
    • localhost:4567/buy/cars/in/Walmart
    • localhost:4567/where/is/habr.ru
    • localhost:4567/where/is/habrahabr.ru
    .

    Немного проектирования


    Теперь пришла пора заняться планированием нашего файлообменника. Что хотелось бы по-максимуму:
    1. Upload файлов
    2. Download файлов
    3. Счетчик скачиваний (для статистики)
    4. Удаление ненужных файлов
    5. Защита файла паролем
    6. Ограничение доступа
    7. Адреса, которые сложно подобрать (если ссылка имеет вид, например /download/4, то очевидно, что есть и /download/3, /download/5 и так далее; если же ссылка выглядит как /download/6a1941fb0cd47, то подобрать другие URL'ы гораздо сложнее)
    8. Возможность загружать файлы не только через веб-интерфейс, но и через Samba


    В рамках этой статьи мы рассмотрим следующий упрощенный вариант:
    • Upload/download только через веб-интерфейс
    • Защита с помощью Basic-HTTP аутентификации
    • Простые ссылки на скачивание


    Итак, начнем со структуры URL'ов.
    Главная страница ('/') по методу GET выдает список файлов. Она же занимается обслуживанием upload'а файлов по POST'у. Страница '/:id' — отдает файл броузеру. Страница '/:id/delete' файл удаляет.

    Давайте запишем это на Синатре:

    get '/' do
    # ... file index ...
    end

    post '/' do
    # ... file upload ...
    end

    get '/:id' do
    # ... send file to browser ...
    end

    get '/:id/delete' do
    # ... delete file ...
    end


    Далее у нас возникает проблема — как мы храним файлы?

    Сначала я хотел использовать следующее решение: хранить файлы в папке /public в директории с нашим приложением (Sinatra позволяет получать доступ к файлам из папки /public напрямую, без описания каких-либо PATTERN'ов; То есть файл photo.jpg, лежащий в директории public, находящейся в той же директории, что и наш myapp.rb всегда будет доступен по адресу /photo.jpg). В этом случае список файлов мы получаем просто вызывая 'ls ./public' и отдаем их по прямым ссылкам. Проблемы: прямые ссылки, без возможности их сокрытия, проблемы с русскими именами файлов и спецсимволами, отсутствие статистики и так далее. В общем, в этом случае мы фактически имеем аналог directory listing апача — а это не то, что мы хотим.

    Отсюда вывод: нам нужна база данных


    Глупо использовать для такой простой системы что-либо сложнее, чем sqlite. Ее и возьмем. Остается проблема работы с БД из ruby. Можно было, конечно, взять ActiveRecord и его ORM, но я решил, что это тоже довольно тяжелая штука, которая мне не понадобиться. Итак, я решил изучить не только Sinatra, но и DataMapper.

    DataMapper — это небольшой легковесный фреймворк, позволяющий работать с записями БД, как с объектами Ruby.

    Установка и настройка DataMapper


    Установка проста:

    sudo gem install data_mapper

    Подлючаем к приложению, дописывая после requre 'sinatra'

    require 'dm-core' # ядро
    require 'dm-validations' # валидации (not_null и тому подобное)
    require 'dm-timestamps' # автоматическое проставление времени создания и обновления для записей


    Указываем имя БД, с которой будем работать.

    DataMapper.setup(:default, "sqlite3://#{Dir.pwd}/mydatabase.sqlite3")

    Затем нам надо описать структуру таблицы, в которой мы будем хранить наши данные.

    class StoredFile
    include DataMapper::Resource

    property :id, Integer, :serial => true
    property :filename, String, :nullable => false
    property :created_at, DateTime

    default_scope(:default).update(:order => [:created_at.desc])
    end



    Рассмотрим, что мы тут натворили.
    class StoredFile — класс, экземпляры которого представляют наши файлы.
    include DataMapper::Resource — объявляем, что класс StoredFile является ресурсом DataMapper'а (то есть описывает записи в БД). Это означает, что в базе будет создана таблица stored_files и что мы получаем методы для работы с ней. Заметьте, что в отличие от ActiveRecord, мы не наследуемся от какого-то класса, а подключаем модуль, что гораздо удобнее с точки зрения ООП.
    Далее мы описываем три свойства (=столбца в БД) наших файлов — id, имя файла, дата создания. :serial => true означает, что столбец будет auto_increment (автоматически получать значения), :nullable => false, очевидно, означает, что имя файла не может быть NULL, столбец created_at автоматически будет заполнятся временем создания записи (для этого мы подключали dm-timestamps).
    Наконец, default_scope(:default).update(:order => [:created_at.desc]) говорит нам о том, что записи по умолчанию надо доставать упорядоченными по убыванию времени создания.

    После описания StoredFile пишем

    DataMapper.auto_upgrade!

    Эта строчка заставит DataMapper обновлять структуру БД при изменениях нашего класса StoredFile.

    Работаем с данными



    Итак, с файлами мы будем работать следующим образом: при аплоаде, мы будем сохранять файл под именем ID.upload в папку files (ID — это id файла в БД) и записывать в БД оригинальное имя файла. При скачивании мы будем отдавать браузеру файл и в качестве имени передавать значение filename из БД. Таким образом в файловой системе у нас будет файл вида 242.upload, а броузер будет скачивать его под именем «Отчет по откатам за 2008 год.docx».

    Пришла пора заполнить наши блоки кодом, который вы так ждали.

    # список файлов
    get '/' do
    @files = StoredFile.all # Достаем все файлы
    erb :list # Рендерим шаблон, см. далее
    end

    # upload файла
    post '/' do
    tempfile = params['file'][:tempfile] # достаем файл из POST
    @file = StoredFile.new :filename => params['file'][:filename] # создаем новый объект StoredFile
    @file.save! # сохраняем его в БД
    File.copy(tempfile.path, "./files/#{@file.id}.upload") # записываем наш файл под специальным именем в нужную папку
    redirect '/' # редирект на главную
    end

    # download файла
    get '/:id' do
    @file = StoredFile.get(params[:id]) # загружаем информацию о файле из БД
    send_file "./files/#{@file.id}.upload", :filename => @file.filename, :type => 'Application/octet-stream' # отдаем файл броузеру в виде аттачмента
    redirect '/' # редирект на главную
    end

    # удаление файла
    get '/:id/delete' do
    StoredFile.get(params[:id]).destroy # удаляем файл из БД
    File.delete("./files/#{params[:id]}.upload") # удаляем файл из ФС
    redirect '/' # редирект на главную
    end


    Надеюсь, комментарии и происходящее достаточно очевидны, за исключением строчки «erb :list». Обратите внимание, что upload/download и удаление происходят без вывода какой-либо информации пользователю (в конце его редиректит на главную страницу). На главной же мы хотим отображать список файлов. Конечно, это можно было сделать и сформировав ответ в виде строки прямо в коде, но к счастью, sinatra позволяет вам использовать несколько шаблонизаторов для генерации HTML страниц. Среди поддерживаемых шаблонизаторов есть: HAML, Erb, Builder, Sass. Cами шаблоны Синатра по умолчанию ищет в папке ./views. В нашем случае, я написал там довольно примитивный Erb шаблон, назвал его list.erb и поместил в папку views. Теперь я могу вызывать его командой erb :list. Также я мог написать, например list.haml и рендерить его командой haml :list.

    Вот код шаблона:

    <h2>Список файлов:</h2>
    <table>
    <tr>
    <th>Скачать</th>
    <th>Загружен</th>
    <th>Удалить</th>
    </tr>
    <% @files.each do |file| %>
    <tr>
    <td><a href="/<%= file.id %>" title="<%=file.filename%>"><%= file.filename %></a></td>
    <td><%= file.created_at.strftime("%d %b")%></td>
    <td><a href="/<%= file.id %>/delete" title="Удалить файл">Удалить</a></td>
    </tr>
    <% end %>
    </table>
    <h3>Добавить</h3>
    <form name="new_file" id="new_file" method="POST" enctype="multipart/form-data">
    <input type="file" name="file"/><br />
    <input type="submit" value="Загрузить"/>
    </form>


    Напоследок: защита паролем


    Мы сделаем, как я и обещал, только простую HTTP-авторизацию. Делается она так:

    use Rack::Auth::Basic do |username, password|
    username == 'admin' && password == 'secret'
    end


    Между use Rack::Auth::Basic do |username, password| и end может находится произвольный код, возвращающий true, когда доступ разрешен и false в противном случае. Я захардкодил логин и пароль, но ни чего не мешает брать их, к примеру, из БД.

    Заключение


    Полный код приложения находится тут — http://pastie.org/368694

    Если статья оказалась интересной, я продолжу рассказ про организацию домашнего файлообменника.

    Спасибо за внимание!

    Поделиться публикацией
    Комментарии 49
    • НЛО прилетело и опубликовало эту надпись здесь
        0
        > жена XXXX посылает файл себе (или корреспонденту) по почте.

        Я бы на Вашем месте заменила второе слово. Надеюсь, Ваша жена Хабра не читает и не обиделась :)
          +4
          Спасибо за замечание, но тупо характеризует в данном случае не ее интеллектуальный уровень, а бесхитростность алгоритма :) В общем, обижаться ей не на что.
            0
            По-английски, в данном случае написали бы «just», не так ли? :-)
              0
              Да, пожалуй :)
              «She just emails» или «she simply emails». Ну в общем что-то в этом духе, ага :)
          0
          Ммм, синатра, любимый фреймворк для небольших задачек:)
          Намого приятнее Кампинга, там все в одной куче.
          Еще Haml намного удобнее чем erb, лучше сразу его использовать.
            +2
            Да, я как раз думал об этом, спасибо за последний толчок. Видимо, следующая статья выйдет уже с рассказом про Haml.
              0
              А подскажете пару ссылок, где можно подробнее ознакомится с Синатрой (желательно на руссом)? Их оф страничка в данном плане даже мение информативна нежели эта статья.
                +1
                Боюсь, что не подскажу, так не знаю их таких. Для написания своих статей я использовал — оффсайт Синатры, пару тематических постов в блогах (искал под конкретные задачи в гугле запросами вида «sinatra authorization» и «sinatra configuration») и исходниками самого проекта. На русском не видел вообще ничего.

                На самом деле, большая часть возможностей Синатры уже описана в двух статьях (в третьей доведу полноту до 90%) — дело в том, что Синатра это действительно небольшой фреймворк, который не так уж много умеет «из коробки».
                  0
                  Собственно его аскетичность меня и привлекает. Спасибо за ответ и за статьи.
            0
            Синатра отличный минималистичный веб-фреймворк, а у вас вышел замечательный код-малютка :)
              0
              Замечательно! Спасибо за ещё одну отличню идею использования сервера!
                +1
                Для внутреннего обмена файлами вариант типа SAMBA не рассматривали?
                Может работать с разными системами.
                Основной плюс: файл не надо скачивать, можно открывать прямо с сервера.
                Если фактор безопасности важнее, то можно использовать SSHFS. Вещь пришла из мира unix, но есть и виндовые клиенты. Использование тоже самое как samba, только безопаснее.
                  0
                  Да самба самое то для разных ос, и настраивать практически не надо. У нас файлопомойка на работе на самбе крутится. Клиенты — win/mac/linux. Все довольный. Единственное, бесят метафайлы оставляемые макос.
                    0
                    Но с ней иногда бывает гемор, иногда сильно разные клиенты не могут договориться. Например, были проблемы mac ↔ vista.
                      0
                      Just for fun: под не-виндой создайте папку com, lpt,…
                      Винда при этом сходила с ума (сервер стоял именно на винде).
                      0
                      Самба между домашней системой и, скажем, рабочим компьютером? То есть вы предлагаете выставлять самбу наружу — на просмотр всему честному интернету?
                      Дома, локально, самба, разумеется работает (и, кстати, хорошо работает в связке Mac OS + XP + Ubuntu).
                      Что же касается SSHFS, то возражение простое: нельзя просто взять LiveCD винды и скачать файл через SSHFS, а в моём варианте — можно.
                        0
                        Что касается безопасности, то надо смотреть в каждом конкретном случае: кто ходит, кто не ходит.
                        Смысл не в том, чтобы можно было просто скачать файл на LiveCD — особенно если винда, то она совсем голая — ничего на ней толком не сделаешь с файлом. Смысл в том, чтобы с файлом можно было как-то работать. А если что-то устанавливать, то можно и SSHFS установить.
                      0
                      Live Mesh.
                      конец треда.
                        +2
                        Спасибо за наводку — конкретно про Live Mesh не слышал.

                        Но это не конец треда: Live Mesh и любые другие онлайновые сервисы вынуждают меня сначала закачивать файл к ним, а потом скачивать файл от них. Зачем два раза гонять даже двадцатимегабайтный файл через весь интернет? :)
                          0
                          >Live Mesh и любые другие онлайновые сервисы вынуждают меня сначала закачивать файл к ним, а потом скачивать файл от них. Зачем два раза гонять даже двадцатимегабайтный файл через весь интернет?
                          Почему же? У Live Mesh замечательная P2P синхронизация, так что через инет ничего гоняться не будет. А с Live Desktop папки можно вообще не синхронизировать. Народ тестировал это на десятках гигабайтов.
                            0
                            А как я передам файл своему знакомому?
                              0
                              Если надо постоянно обмениваться файлами со знакомым, можно просто завести «общий» аккаунт Live ID; указать его в Live Mesh на компьютерах, которые нужно синхронизировать; пометить какую-нибудь папку, как синхронизируемую. После этого, всё, что туда будет кидаться на одном из компьютеров, будет появляться и на остальных.
                              Вместо общего аккаунта, можно просто открыть доступ к папке другому пользователю Live Mesh. При этом можно установить его права (Owner, Contributor или Reader).
                              Если знакомый хочет получить файлы, но не хочет/может установить Live Mesh, файлы можно забрать с Live Desktop.
                                0
                                Вот это уже интереснее, но если надо разным знакомым и довольно случайно распределенным (во всяком случае, минимум половина из них не захочет ставить какие-либо клиенты)
                        +1
                        Замечательная статья. Обязательно продолжайте писать!
                          0
                          пользую для домашней файлопомойки FreeNas там тебе и веб-сервер и ftp и торренты и ssh…
                          www.freenas.org
                            0
                            Тоже интересный вариант, спасибо. Но, как я понял, FreeNAS — это именно ОС для организации NAS сервера. У меня же сервак более «интеллектуален» — он и видео пережимает, и некоторые мелкие веб-приложения хостит, и iTunes сервером является. Наверное, на FreeNAS весь этот функционал тоже можно надеть, но я не настолько хорошо знаком с FreeBSD (во всяком случае, хуже, чем теперь с Sinatra), чтобы экспериментировать :)
                              0
                              я не все фичи конечно перечислил, но то что там есть меня устраивает. есть там и сервер для мультимедии.
                              да это ОС, которая крутится у меня на стареньком компе ( куплен за 40евро) И управляется через веб браузер — какие папки расшарить по самбе, каких юзеров завести, веб-морда для управления торрентами и прочее-прочее.
                              Я согласен, что интересно что-то самому поковырять, но когда хочеться поставить и забыть, то ИМХО вполне неплохой вариант. Я и сам не знаю FreeBSD но для пользования встроенными возможностями оно и не нужно.
                                0
                                Поковырять я как раз очень люблю :) Но вам тот же ответ, что и одному из комментаторов выше: к ssh из под винды нельзя подключиться без спец-софта, а samba открывать ой как не хочется. Хочется иметь возможность переслать по аське ссылку на файл и скачать его прямо по ссылке.
                            0
                            Ещё как вариант использовать абсолютно бесплатную HTTP File Server
                            Настраивается очень просто, возможностей уйма, уметь программировать не надо :)
                              0
                              Вот же ж блин! =) Я ведь знал про эту софтину и когда-то ее даже использовал, но когда мне возникла потребность, HFS не пришел ко мне в голову. Только под Unix он через Wine, а я не люблю Wine :)
                              0
                              Статья показалась интересной, продолжайте :)
                              Только код, хотя бы в
                              <pre>
                              упаковать, а то так тяжело читать…
                                0
                                Просто великолепно! Очень полезные новые (для меня) знания, очень хорошо изложены. Огромное спасибо! Ждём ещё статей. :-)
                                  0
                                  Вперед!
                                    +1
                                    Про руби и ни слова о рельсах. Круто!
                                      0
                                      …сейчас я пишу на Ruby on Rails, но он несколько монструозен для такой задачи…
                                      Так уж прям и ни слова ;)
                                        +1
                                        Опаньки, не заметил.
                                        Я имел в виду следующее: статья полезна уже тем, что формирует представление о Ruby как о самостоятельном языке.
                                          0
                                          Ruby очень даже самостоятельный язык и не только для разработки веб-приложений. Он очень полезен в быту продвинутого Unix-пользователя — на нем очень приятно и легко писать служебные скрипты и даже небольшие приложения (в том числе и сетевые). Возможно, когда-нибудь у меня дойдут руки и до популяризации этого аспекта. (Ba)sh — не единственный удобный инструмент юниксойда :)
                                      +1
                                      хотелось почитать легкую статью про то как вы тестировали ваш фунционал, ещё было бы отлично увидеть бэнчмарки…
                                        0
                                        Вот это уж будет непременно :) Но, возможно, уже в третьей части: сейчас пишу вторую и там уже набралось немало текста.
                                        0
                                        «По непонятным мне причинам...» — причины как раз ясны. К сожалению Camping почил, и его больше не разрабатывают. Все «за» — за Синатру, которую, кстати, даже сам DHH пиарил в Тви.
                                          0
                                          Этого я не знал. К моменту выбора фреймворка я просто знал, что есть Camping, Rack и Sinatra. Что с ними происходит, какие они изнутри и что рекомендуют гуру в лице DHH я был не в курсе.
                                          0
                                          Подробно и доходчиво. Спасибо!
                                            0
                                            При аплоаде файла не происходит сохранение в БД даты аплоада, но в шаблоне делается попытка её вывести и поэтому возникает ошибка. Меня, как новичка, это сбило с толку. Надо бы добавить что-то вроде :created_at => Time.now к операции создания файла.
                                              0
                                              require 'dm-timestamps' # автоматическое проставление времени создания и обновления для записей


                                              Читаем внимательнее код, который копи-пастим ;)
                                                0
                                                Ну, так оно не работает! :) По крайней мере у меня
                                                  0
                                                  Видимо, стоило именно об этом написать. Тогда мне не пришлось бы перечитывать свой код. Возможно, в DataMapper что-то поменялось с тех пор. Могу предложить покурить доки датамаппера. Ну или отказаться от этого и проставлять Time.now в ручную :)
                                            0
                                            Огромное спасибо за материал. Рельсы не осилил, а синатра подошёл как раз. Статья относительно старая, произошло немного изменений в инструментах, описываемых здесь, поэтому опишу те проблемы, с которыми я столкнулся. Возможно, это поможет кому-то кто будет осваивать Sinatra и DataMapper именно с этой статьи.

                                            1. Чтобы заработал метод auto_upgrade!, необходимо добавить в начало файла require 'dm-migrations'
                                            2. Использование хеша параметров в таком виде params['file'][:filename] у меня не заработал. Оказалось, достаточно указать название параметра и всё: params[:filename]
                                            3. Запись создания счётчика сократилась: property :id, Serial
                                            4. Не забывайте, что для работы с различными базами данных необходимы соответствующие адаптеры, например: dm-sqlite-adapter, dm-mysql-adapter
                                            5. Я сначала не мог сообразить, как использовать MySQL-базу по паролю и имени. Делать надо так: DataMapper.setup(:default, 'mysql://user:password@localhost/dbname')

                                            И вот ещё кой-какая подсветка Haml для Kate: http://haml.googlegroups.com/web/haml_highlighting_for_kde-1.1.tar.gz
                                              0
                                              Спасибо вам за дополнения!

                                            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                            Самое читаемое