Отправляет email-рассылки с помощью сервиса Sendsay
  Все выпуски  

№41 рассылки '.Net Собеседник':


Информационный Канал Subscribe.Ru

.Net Собеседник #41

Содержание
  1. От автора
  2. Обзор новостей
  3. 10 вещей, которые вы не должны делать с SQL Server (советы разработчику доступа к данным)
  4. Время кода - Фиксированный заголовок в ASP.NET DataGrid
  5. Форумы .Net на www.sql.ru

От автора

Здравствуйте, коллеги!

Выпуск получился просто огромный. Поэтому пришлось обрезать некоторые разделы, так что сразу к делу, желаю интересного чтения :)

{К содержанию}

Обзор новостей

  1. Вышел XStream.NET XML Serializer
    Arne Vandamme создала новый XML Serializer, основанный на популярном XStream XML Serializer на Java.
  2. Вышел SQL Server 2000 Service Pack 4
    Качайте по ссылке.
  3. Вышел ReSharper 1.5.1
    Исправлено много багов, подробнее на сайте.

{К содержанию}

Статья номера

10 вещей, которые вы не должны делать с SQL Server (советы разработчику доступа к данным)

ЯЗЫК: C#
Автор статьи: Doug Seven

ПЕРЕВОД: Чужа В.Ф. ака hDrummer

Вступление

Эта статья родилась из презентации, которая была впервые представлена на TechEd 2004, а впоследствии была неоднократно представлена многим группам пользователей по всей стране. Затем я понял, что проще написать такую статью, чем пытаться донести эту информацию до всех групп поочередно – что и было сделано. Содержание статьи базируется на онлайн дискуссии, которая завязалась в моём блоге и на форуме - как результат появления вопроса "Что обычно разработчики доступа к данным делают не так в случае работы с SQL Server?" Список вырос до 25 или 26 пунктов, из которых мы отобрали 10 наиболее часто читаемых, в общем – самых «горячих». Этот список и приведен ниже – 10 вещей, которые вы не должны делать с SQL Server (или по-крайней мере знать о последствиях такого выбора). Честно говоря, на определённом этапе своей карьеры я использовал все 10 подходов, которые описаны здесь (ну, никто не идеален). Итак…

10. Добавление учётной записи с низкими привелегиями к роли Администратора

Роль администратора в SQL Server сделана для тех учётных записей, которые ДЕЙСТВИТЕЛЬНО нуждаются в правах администратора. Очень редко такими правами должна обладать учётная запись, под которой работает ваше приложение. Например, для приложения ASP.NET, вы не должны добавлять рабочий процесс ASP.NET (ASPNET или NETWORK SERVICE) в группу администраторов для создания доверительных соединений (интегрированной безопасности). Такой подход может быть небезопасным. В этом примере рабочий процесс ASP.NET не должен работать под учётной записью администратора БД SQL Server; наоборот, учётная запись ASP.NET должна работать с низким уровнем привилегий. Рабочий процесс ASP.NET устанавливается во время инсталляции .NET Framework. Если вы запускаете каркас .NET на Windows XP или Windows 2000, то рабочий процесс ASP.NET работает под учётной записью MachineName\ASPNET. На Windows Server 2003 этот же процесс работает под учётной записью NT Authority\Network Service. Добавив эту запись к роли администраторов, вы подвергаете себя возможности быть атакованным путём SQL-инъекций, кроме всего прочего, конечно.

Вместо того чтобы давать низкопривелегированному эккаунту права администратора, лучше потратить время и выяснить, какие же права необходимы приложению реально. Сделайте всё возможное для того, чтобы поместить операции по манипулированию данными в ХП. Такой подход позволит вам давать привилегию EXECUTE для учётной записи, под которой работает ASP.NET-процесс (или для другого низкопривилегированного эккаунта) для каждой отдельной ХП. Это не только поможет вам убедиться в том, что приложение делает всё, что нужно, но и усилит безопасность приложения и БД.
В следующем примере приведен код TSQL для установки доступа учётной записи ASP.NET к вашей БД, а также разрешения на выполнение ХП.

-- Windows 2000 / XP
-- Замените "MachineName" на имя вашей машины
EXEC sp_grantlogin [MachineName\ASPNET]
EXEC sp_grantdbaccess [MachineName\ASPNET], [Alias]
GRANT EXECUTE ON [ProcedureName] TO [Alias]
GO

-- Windows Server 2003
EXEC sp_grantlogin [NT AUTHORITY\NETWORK SERVICE]
EXEC sp_grantdbaccess [NT AUTHORITY\NETWORK SERVICE]
GRANT EXECUTE ON [ProcedureName] TO [NT AUTHORITY\NETWORK SERVICE]
GO

9. @@IDENTITY vs. SCOPE_IDENTITY

Этот подход не относится к подходам, подчиняющимся правилу «верно-неверно», скорее он касается понимания некоторых опций, которые вы выбираете. И @@IDENTITY и SCOPE_IDENTITY() возвращают последнее значение идентичности (главного ключа), которое было вставлено активной сессией, но в разных сценариях они могут вернуть разные значения. Когда я говорю об «активной сессии», я говорю о текущей активности, которую вы осуществляете. Например, если вы исполняете ХП, это как раз и есть то, что я имею ввиду. Каждый вызов ХП или UDF (пользовательской функции) – это сессия, кроме того случая, когда ХП является вложенной в ту ХП, которую вы вызываете. В случае с вложенными ХП или пользовательскими функциями, т.к. они являются отдельными методами, они являются частью текущей сессии, но не текущей области видимости. Ваша область видимости ограничена методом (ХП или ПФ), которую вы вызвали непосредственно. Вот в чём заключается разница @@IDENTITY и SCOPE_IDENTITY().
@@IDENTITY вернёт последнее значение идентичности, вставленное в таблицу в вашей текущей сессии (т.е. вы не получите идентичности, вставленные другими пользователями). И если @@IDENTITY ограничена текущей сессией, то отнюдь не ограничено текущей областью видимости. Другими словами, если у вас есть триггер на таблице, который приводит к созданию идентичности в другой таблице, то вы получите последнее значение идентичности, даже если оно было создано триггером. Это совсем не плохо, до тех пор, пока вы уверены в том, что вещи идут своим чередом. Худо будет, если приложение переписывается и добавляется новый триггер, запускаемый из ХП. Ваш код не ожидает отработки нового триггера, и вы можете получить неверное значение.
SCOPE_IDENTITY(), как и @@IDENTITY, вернёт последнее значение идентичности, созданное в текущей сессии, но также ограничит его областью вашей видимости. Другими словами, вернётся последнее значение, явно созданное вами, а не значение, созданное триггером или пользовательской функцией (ПФ).
В нижеприведенном примере видно как изменяется полученное значение после добавления триггера.

/*в тестовой БД создайте таблицу TY*/
USE SomeTestDatabase
CREATE TABLE TABLE_A ( TABLE_A_id int IDENTITY(100,5)PRIMARY KEY, ItemValue varchar(20) NULL)
/*вставляем записи в таблицу TABLE_A*/
INSERT TABLE_A VALUES ('Widget')
INSERT TABLE_A VALUES ('Boat')
INSERT TABLE_A VALUES ('Car')
GO

/*создаём таблицу TABLE_B*/
CREATE TABLE TABLE_B ( TABLE_B_id int IDENTITY(1,1)PRIMARY KEY, Username varchar(20) NOT NULL)
/*вставляем записи в TABLE_B*/
INSERT TABLE_B VALUES ('Doug')
INSERT TABLE_B VALUES ('Erika')
INSERT TABLE_B VALUES ('Lola')
GO

/*вставляем данные в TABLE_B*/
INSERT TABLE_B
VALUES ('Kali')

/*выбираем данные и смотрим значения @@IDENTITY и SCOPE_IDENTITY()*/
SELECT * FROM TABLE_A
SELECT * FROM TABLE_B
SELECT @@Identity AS [@@Identity], SCOPE_IDENTITY() AS [SCOPE_IDENTITY]
GO

/*Создаём триггер, вставляющий строку в таблицу TABLE_A, когда вставляется строка таблицу TABLE_B*/
CREATE TRIGGER TABLE_B_trig
ON TABLE_B
FOR INSERT AS
BEGIN
INSERT TABLE_A VALUES ('Airplane')
END
GO

/*теперь вставляем запись в таблицу TABLE_B, что приведет к запуску триггера*/
INSERT TABLE_B
VALUES ('Donny')

/*теперь @@IDENTITY и SCOPE_IDENTITY() вернут разные значения. SCOPE_IDENTITY() вернёт значение из TABLE_A (то, что было явно вами создано), а @@IDENTITY вернёт значение из TABLE_B (созданное триггером).*/
SELECT * FROM TABLE_A
SELECT * FROM TABLE_B
SELECT @@Identity AS [@@Identity], SCOPE_IDENTITY() AS [SCOPE_IDENTITY]
GO

8. Выборка полустатических данных при каждом запросе

Ах, производительность. Это то, о чём здесь и пойдёт речь. Если в вашем приложении есть полустатические данные, т.е. данные, которые не часто изменяются, а вы часто обращаетесь к ним и при каждом запросе к хранилищу вы перекачиваете их клиенту, то вы теряете хорошую возможность увеличить производительность вашего приложения. Данные, являющиеся полустатичными хотя бы на протяжении некоторого периода времени могут быть кэшированы приложением для уменьшения нагрузки на сервер БД.

Есть пара опций для настройки кэширования в вашем приложении.
  • Cache API: Cache API – кэш уровня приложения. Это то место, в которое можно поместить ЛЮБОЙ объект и определить для него правила нахождения в кэше. Размер кэша определяется количеством оперативной памяти машины, на которой работает приложение. Плюсом Cache API есть то, что можно поместить объект любой сложности в кэш, а затем извлечь его для работы. Можно определить скользящее время жизни объекта (например, кэшировать его после использования на 5 минут, а если он в течении 5 минут не используется – убивать его.) Можно определить абсолютное время окончания кэширования объекта – хранить в кэше 1 час, а затем убрать из кэша, не взирая на то был ли он использован или нет. Можно поставить наличие объекта в кэше в зависимости от наличия файла или его обновления. Это хорошо работает в случае с кэшированием данных XML, когда кэш обновляется после обновления самого файла.
  • Кэширование вывода: Для тех данных, которые вы хотите кэшировать и вы уверены, что доступ к исходным данным не понадобится, можно кэшировать сам вывод, т.е., например, HTML, а не те объекты, которые использовались для его создания. Это легко реализовать, как показано во втором примере ниже.
Использование Cache API:

DataTable productsTable;
// здесь код для получения данных из таблицы Product

//этот код помещает объект в кэш
Cache.Add(
  "ProductsTable",             
//имя
  productsTable,               
//объект для кэширования
  null,                        
//зависимость кэша
  DateTime.Now.AddSeconds(60), 
//абсолютное истечение
  TimeSpan.Zero,               
//плавающее истечение
  CacheItemPriority.High,      
//приоритет
  null                         
//onRemoveCallback
); 

//с помощью этого кода можно получить объект из кэша
if(Cache["ProductsTable"] != null
  productsTable = (DataTable)Cache["ProductsTable"];

Кэширование вывода:

<%-- устанавливаем кэш в 60 секунд --%>
<%@ OutputCache Duration="60" VaryByParam="None" %>

<%-- устанавливаем кэш в 60 секунд и создаём отдельную кэшированную версию страницы, базирующуюся на параметре "City" --%>
<%@ OutputCache Duration="60" VaryByParam="City" %>

<%-- устанавливаем кэш в 60 секунд и создаём отдельную кэшированную версию страницы, базирующуюся на заголовке Accept-Language --%>
<%@ OutputCache Duration="60" VaryByParam="None" VaryByHeader="Accept-Language" %>

7. Включение кода манипуляции данными SQL Data Manipulation Language в код приложения

Такой подход – просто прямая дорога к неприятностям. Это не только возможность быть атакованным с помощью SQL-инъекции, но также такой код тяжелее поддерживать. С кодом SQL «врезанным» в код приложения, каждое изменение запроса должно вести к перекомпиляции. Например, вот такой SQL в вашем приложении свидетельствует об отсутствии у вас какого бы то нибыло опыта разработки.

string sql = "SELECT * FROM Users WHERE username='"+Username.Text+"' AND 
 password= '"
+Encrypt(Password.Text)+"'";

SqlCommand command = new SqlCommand (sql, connection);

Что может случиться в таком случае – читайте в статье Stop SQL Injection Attacks Before They Stop You, автор - Paul Litwin.

Конечно, более приемлемый выход, чем сцепленные строки – если вы ДОЛЖНЫ иметь «врезанный» SQL в коде – параметризованные запросы. Здесь можно увидеть запросы, использующие параметры (помогающие избежать атак с SQL-инъекциями).

string sql = "SELECT * FROM Users WHERE username=@Username AND password= @Password"

SqlCommand command = new SqlCommand (sql, connection);
command.Parameters.Add("@Username", SqlDbType.VarChar).Value = UserName.Text;
command.Parameters.Add("@Password", SqlDbType.VarChar).Value = Encrypt(Password.Text);

SqlCommand command = new SqlCommand (sql, connection); 

Ещё лучшим решением является использование ХП, пусть ваши запросы хранятся в СУБД, компилируются и оптимизируются и могут быть изменены без перекомпиляции вашего кода.

SqlCommand command = new SqlCommand ("Users_GetUser", connection);
command.CommandType = CommandType.StoredProcedure;

command.Parameters.Add("@Username", SqlDbType.VarChar).Value = UserName.Text;
command.Parameters.Add("@Password", SqlDbType.VarChar).Value = Encrypt(Password.Text);

SqlCommand command = new SqlCommand (sql, connection);

Девиз, по которому надо жить - "врезанныйSql == смерть;"

6. Не используйте SELECT *

Достаточно странно наблюдать за тем, что многие из нас используют такой способ извлечения данных. Так вот, многие из нас используют "SELECT * FROM..." при написании запросов к источникам данных. Это плохо. Многие разработчики до сих пор пишут такие запросы на стадии разработки, поскольку на этой стадии в таблице мало полей, мало данных или существует ещё нечто, что извиняет такой подход на том этапе. Но что происходит при увеличении количества данных или добавлении полей? Например, поля Image, хранящего картинку 1024x768 с портретом пользователя? Теперь каждый вызов "SELECT * FROM..." возвращает огромный по размерам набор данных, что имеет ОГРОМНОЕ влияние на производительность.

Это просто лень. По моей теории, во время дизайна приложения вы знаете все запросы, которые вам понадобятся и можете написать чёткие ХП, возвращающие чётко те данные, которые вернут ТОЛЬКО то, что вам нужно – без всяких исключений. И никогда больше не используйте "SELECT * FROM...".

5. ХП без обработки исключений

Каждый день вы пишите код (я надеюсь на это). И каждый день вы пишите обработку исключений, поскольку может произойти что-то непредвиденное. Странно, что не все из нас делают это в ХП. Или вы думаете, что в ХП пройдёт всё, как по маслу? Вы говорите, что обрабатываете все исключения в коде приложения? А почему бы не делать это как можно ближе к источнику ошибки? Это моя пятая рекомендация.

Далее вы увидите пример обработки ошибок в ХП. Есть много способов решить эту проблему, и это один из них. В этом примере мы базируемся на XML файле, в котором определены ссылки на коды, имеющие соответствие с их расшифровками, понятными пользователю. Коды ошибок определены архитектором нашего приложения.

CREATE PROCEDURE dbo.Users_Insert
@Username VARCHAR (20)
AS
SET NOCOUNT ON
DECLARE @Err INT
SET @Err = 0
--Всё ок
INSERT Users (Username) VALUES (@Username)
SET @Err = @@ERROR
-- это устанавливает @@ERROR в 0
IF (@Err <> 0)
BEGIN
IF (@Err = 547)
--операция, конфликтующая с ограничением
BEGIN
SET @Err = 32
--наша ошибка, сигнализирующая о том, что имя пользователя уже занято
GOTO abort
END
ELSE
BEGIN
SET @Err = 1
-- наша ошибка 'Неизвестная ошибка'
END
END
abort:
SET NOCOUNT OFF
RETURN @Err
GO

Вы можете отреагировать на возвращённое значение после возврата кода ошибки приложению. Если это 0, то ошибки не было. Если это 32, то смотрим файл ErrorCodes.xml для получения строкового значения:

"Ошибка создания пользовательской учётной записи. Такое имя пользователя уже существует. Выберите новое имя пользователя."

Если код ошибки равен "1", тогда в файле ErrorCodes.xml находим такую ошибку:

"Произошла непредвиденная ошибка. Попробуйте ещё раз. Если проблема не разрешилась, соединитесь со службой техподдержки."

Как минимум, обработка ошибок должна существовать для операций вставки, удаления и обновления записей.

Кстати:  Как только вы используете значение @@ERROR, оно устанавливается равным 0, так что важно скопировать значение @@ERROR в локальную переменную для дальнейшей работы с ним.

4. Префикс для ХП "sp_"

Я часто вспоминаю свои первые шаги в изучении SQL Server – тогда я столкнулся с одной штукой. Дело в том, что когда я только начинал, то ХП именовались в соответствии с венгерской нотацией - "sp_". К своему удивлению позднее я узнал, что "sp_" обозначает системную хранимую процедуру "System Stored Procedure" (почему они не использовали "ssp_" мы наверное никогда не узнаем). Я сказал, что часто вспоминаю об это потому, что сплошь и рядом сталкиваюсь с такими названиями. Однажды я столкнулся с сотнями таких ребят, создававшими внутреннее приложение для своей, одной из самых больших компаний в мире (название скрою для ясности ;).
Позвольте мне процитировать SQL Server Books Online:

Системные ХП (System Stored Procedures)
Многие ваши действия, как администратора в Microsoft SQL Server 2000, выполняются посредством специальных процедур, известных как системные хранимые процедуры (system stored procedure). Системные ХП создаются и хранятся в БД master и имеют префикс sp_ . Системные ХП могут быть выполнены из любой БД без полной квалификации имени – т.е. без указания master.
Настоятельно не рекомендуется создавать ХП с именем, начинающимся с sp_ . SQL Server всегда ищет такие процедуры в следующем порядке:
  • В БД master.
  • Как ХП, для которой есть квалификатор (имя БД или обладателя БД).
  • ХП, для которой обладатель - dbo, если другой не указан.
Так что, даже если вы создали ХП с префиксом sp_ в текуще БД, то сначала опрашивается БД master, даже если вы указываете имя базы данных!
Кстати, если ваша ХП имеет такое же имя как и системная, то она никогда не будет исполнена.

3. Вы не защищаете строку соединения с БД

Строка соединения с БД – это наиболее секретная информация, которую использует ваше приложение. Вы должны защитить её ЛЮБОЙ ценой. В прошлом некоторые люди (OK, это был я) говорили вам, что хранить такие строки надо в web.config файле. Теперь я здесь чтобы сказать вам, что я ошибался. На заре ASP.NET (где-то в 2000-х) мы думали, что хранить строки соединения в web.config хорошая идея. Реальность показала, что здесь есть большой риск – это всё-таки файл XML, т.е. понятный человеку, что значит – кто-то может его прочесть и могут быть проблемы. Всё тайное когда-нибудь становится явным.

Новое правило: Хранение строки соединения в web.config небезопасно – шифруйте его!

Отсюда новый вопрос, "Где мне хранить свой шифровальный ключ?" Ответ - "Нигде, пусть Windows делает это за вас с помощью Data Protection API (DPAPI)."

Windows 2000, XP и 2003 все включают Win32 DPAPI. Это неуправляемый API, который можно использовать сильной шифровки информации, предоставляя Windows управлять хранилищем ключей. Очень просто. Единственно – всё зашифрованное с этим DPAPI может быть расшифровано только на той машине, где было зашифровано. Что значит, что DPAPI плохое решение для шифрование данных, хранимых в БД, но хорошее для хранения данных, хранимых в web.config.

В .NET Framework v1.x нет управляемой обёртки для Win32 DPAPI. Вам надо написать свою собственную обёртку. К счастью, группа Patterns & Practices в Microsoft поработала над этим, создав обучалку, включающую хороший пример кода (http://msdn.microsoft.com/library/default.asp?url=/library/en-us/secmod/html/secmod21.asp).

2. Принимаем весь ввод

В книге "Writing Secure Code, Second Edition" (MSPress), Michael Howard пишет "Весь пользовательский ввод – это зло " (фактически, это название главы 10). Так что всё просто, как и звучит – весь ввод пользователя нужно считать потенциально зловредным. В любой форме - TextBox, строка запроса, куки – от всего можно ожидать неприятностей.
.NET Framework поставляется с большим числом инструментов для проверки пользовательского ввода – как на стороне клиента, так и на стороне сервера.
  • В ASP.NET есть пять компонент контроля ввода: RequiredFieldValidator, RegularExpressionValidator, CompareValidator, RangeValidator, CustomValidator, плюс ValidationSummary.
  • Компоненты Windows Forms имеют событие Validating для осуществления проверки ввода.
  • Класс System.Text.RegularExpressions.RegEx является мощным инструментом для работы с регулярными выражениями.
  • HttpUtility.HtmlEncode может быть использована для шифровки HTML перед выводом на для предотвращения скриптовых атак.
  • ASP.NET v1.1 (2003) включает атрибут ValidateRequest (в директиве @Page или Web.config) предотвращает использование вредоносных спиртов.

1. Использование записи "sa" для доступа к БД

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

  • НИКОГДА не используйте запись "sa" для программного доступа к БД.
  • Используйте один или более учётных записей для программного доступа к данным
    1. Для доступа к данным – учётную запись, которая может выполнить только SELECT.
    2. Для изменения данных – учётную запись, которая может только выполнять ХП.
  • Не используя "sa", вы ограничиваете потеницального хакера в выполнении системных команд или процедур.
  • Если вам действительно-предействительно нужно использовать "sa", создайте новую учётную запись, назовите её "essay" (проверка) и, может быть, вы обойдётесь и без "sa".
Надеюсь, что этот список будет вам полезен. Конечно, он не полон, но составлен разработчиками, реально работающими с этим продуктом и видящих, что творится сплошь и рядом, на постоянной основе, у менее опытных коллег.

Так что используйте эти советы, проникнитесь ими и перестаньте делать глупости :)

{К содержанию}

Время кода

Фиксированный заголовок в ASP.NET DataGrid

ЯЗЫК: -
Автор статьи: KjellSJ

ПЕРЕВОД: Чужа В.Ф. ака hDrummer


Вступление

Часто нужно создать DataGrid, в котором будет много записей (например, список клиентов или знакомых), причём у сетки с данными должна быть фиксированная высота и полоса прокрутки в том случае, если отображаемое число записей не помещается в отведённое место. Этого легко добиться, разместив DataGrid внутри тэга DIV:

<DIV style="OVERFLOW: auto; HEIGHT:120px"> <asp:DataGrid ... </DIV>

Бывает такое, что нужно иметь возможность прокручивать записи в сетке, а заголовок оставить фиксированным, чего можно добиться с помощью стиля:

<style type="text/css"> <!-- .DataGridFixedHeader {background-color: white; position:relative; top:expression(this.offsetParent.scrollTop);} --> </style>

Данные должны перекрываться заголовком, как только они попадут под строку заголовка. Убедитесь в том, что стиль прописан в одну строчку, поскольку выражение, записанное в несколько строк, может некорректно работать.

Применить стиль к заголовку можно с помощью элемента HeaderStyle:

<asp:DataGrid id="dgContacts" runat="server" ... > ... <HeaderStyle CssClass="ms-formlabel DataGridFixedHeader"></HeaderStyle> ...

Кстати, вы можете указать несколько классов внутри атрибута CssClass .

Есть другие способы достижения похожего эффекта – например, установка отдельной таблицы с названиями колонок прямо над DataGrid, но такой подход плох тем, что таблицу надо вручную подстраивать - ширина колонок будет в разных таблицах разной. Также это добавит работы при изменении количества колонок в DataGrid. Решение с использованием стилей также просто в использовании, как и в изложении.

Поддерживаемые браузеры

Поскольку решение базируется на выражениях CSS, то работает в MSIE 5.x и 6.x. Это чисто клиентское решение и никаким образом не связано с кодированием на стороне сервера (кроме добавления стиля), однако у вас могут быть проблемы с другими браузерами или в случае со сложной страницей.

Вот и всё.


{К содержанию}

Форумы .Net - вопросы оставшиеся без ответа

Тип RegularExpresionValidator - проверить control на НЕ abc


На этом сорок первый выпуск .Net Собеседника закончен.
До следующего номера.



Чужа Виталий Ф. aka hDrummer, MCAD, MCDBA, MCP
hdrummer@sql.ru - жду ваши предложения и замечания.



Subscribe.Ru
Поддержка подписчиков
Другие рассылки этой тематики
Другие рассылки этого автора
Подписан адрес:
Код этой рассылки: comp.soft.prog.dotnetgrains
Отписаться
Вспомнить пароль

В избранное