PHP/FI 1. Personal Home Page Tools/Forms Interpreter
Путь от набора CGI-скриптов до одного из самых популярных языков веб-разработки
История зарождения PHP
История PHP начинается не с полноценного языка программирования, а с набора CGI-скриптов на C, известного как PHP/FI 1 (Personal Home Page Tools/Forms Interpreter). Позже, когда проект превратился в полноценный интерпретатор скриптов, акроним PHP стал расшифровываться как Hypertext Preprocessor. В этой статье мы возвращаемся к истокам PHP, рассматривая его первую версию, ее компиляцию и функциональность.
Автором PHP является Расмус Лердорф, создавший и выложивший в открытый доступ PHP/FI 1 в июне 1995 года. Возможно, он и не представлял, какое влияние его проект окажет на мир веб-разработки. В то время не было популярных сегодня систем контроля версий и хостингов IT-проектов. До появления git оставалось 10 лет, и концепция открытого ПО не была сформирована в том виде, в котором мы ее знаем сейчас. Поэтому и полноценных сервисов для его распространения не существовало. Впервые автор PHP анонсировал свой проект в списке рассылки Comp.lang.Newsgroups, посвященной веб-разработке.
Изначально проект создавался для собственных нужд с целью упростить управление личным веб-сайтом автора, отслеживать посещаемость, управлять доступом к контенту, обрабатывать результаты веб-форм, сохранять данные и отображать результаты.
Сборка и запуск PHP/FI 1 спустя почти 30 лет
Все версии PHP, включая самые ранние, можно найти на сайте Museum of PHP.
Для начала скачаем самую первую версию и распакуем архив:
wget https://museum.php.net/php1/php-108.tar.gz -P php1
cd php1
tar -xzvf php-108.tar.gz -C .
Посмотрим, какие файлы содержатся в архиве:
Список файлов:
php/phpf.c
php/phpl.c
php/phplview.c
php/phplmon.c
php/common.c
php/error.c
php/post.c
php/wm.c
php/common.h
php/config.h
php/subvar.c
php/html_common.h
php/post.h
php/version.h
php/wm.h
php/Makefile
php/README
php/License
Хоть файлов и немного, но есть README. Однако его содержание сводится к следующему:
Отредактируйте файлы
config.h
иMakefile
в соответствии с вашей системойВведите команду:
make
Скопируйте CGI-бинарные файлы в вашу директорию CGI. Для большинства сайтов, использующих NCSA HTTPD, вы можете просто назвать их filename.cgi и поместить в вашу директорию ~/public_html.
Я рекомендую запускать эти скрипты под вашим собственным идентификатором пользователя, а не под идентификатором пользователя httpd.
Проверьте http://www.io.org/~rasmus для полных инструкций по установке и настройке. (Да, я ленив. Мне не хочется писать всё дважды.)
Eсли хотите, подпишитесь на список рассылки PHP, отправив электронное письмо на адрес:
majordomo@kajen.malmo.se
со строкой:subscribe php-list
your_email_address
в теле сообщения.
Конечно, сайта http://www.io.org/~rasmus уже не существует, но мы можем следовать инструкциям и настроить проект.
В файле config.h есть следующие директивы препроцессора:
#define ROOTDIR "~/html" // корневая директория
#define HTML_DIR "." // папка с html файлами
#define LOGDIR "." // папка для лог-файлов
#define ACCDIR "." // папка с файлами контроля доступа
#define NOACCESS "NoAccess.html" // файл для отображения в случае отсутствия доступа
В Makefile просто укажем сборку в режиме отладки и дебаг-мод для проекта:
...
# Generic compiler options
CFLAGS = -g -O2 -Wall -DDEBUG $(OPTIONS)
#CFLAGS = -O2 $(OPTIONS)
...
Теперь выполним команду make
которая скомпилирует 4 CGI программы:
make
...
ls | grep ".cgi"
phpf.cgi
phpl.cgi
phplmon.cgi
phplview.cgi
На данном этапе неизвестно, за что отвечает каждая из них. Для работы нам понадобится CGI веб-сервер. В 1995 году самым популярным был NCSA HTTPd, который в том же году стал основой для Apache HTTP Server. Однако, сегодня предлагаю написать минимальную реализацию CGI веб-сервера на современном PHP.
CGI Веб-сервер для PHP/FI на современном PHP
Для начала небольшая справка о том, что такое CGI. CGI (Common Gateway Interface) — это стандарт, который позволяет веб-серверу передавать запросы пользователей на обработку внешним программам или скриптам, а затем возвращать результаты обратно пользователю. Это обеспечивает динамическую генерацию веб-страниц и интерактивное взаимодействие с пользователем. Данный стандарт появился в начале 1990-х и был популярен до начала 2000-х годов.
Базовая спецификация CGI:
Механизм Запроса: Веб-сервер идентифицирует CGI-скрипты либо по специально настроенному каталогу (например,
/cgi-bin/
), либо по расширению файла (например,.cgi
или.pl
). Когда сервер получает запрос на такой ресурс, он выполняет скрипт вместо того, чтобы отправлять файл напрямую браузеру.Передача Данных: Данные запроса передаются скрипту через переменные среды (environment variables) и стандартный ввод (stdin), а вывод скрипта (stdout) направляется обратно веб-серверу, который отправляет его пользователю.
Параметры запроса: CGI-скрипты получают информацию о запросе через переменные среды. Некоторые из ключевых переменных:
QUERY_STRING
: строка запроса, переданная в URL после знака?
.REQUEST_METHOD
: метод HTTP запроса (например,GET
илиPOST
).CONTENT_TYPE
иCONTENT_LENGTH
: информация о теле запроса, актуальная для методов типа POST.SCRIPT_NAME
иPATH_INFO
: информация о том, какой скрипт был вызван, и дополнительный путь, переданный скрипту.
Ввод: Для метода
GET
, данные передаются черезQUERY_STRING
. ДляPOST
— данные запроса читаются из стандартного ввода.Вывод: CGI-скрипт должен сначала отправить заголовки ответа (например,
Content-Type
), после чего следует пустая строка, и только потом тело ответа.
Плюсы такого подхода заключаются в простоте: веб-сервер запускает внешнюю программу и отдает клиенту результат как есть, при этом не важно на каком языке программирования написан CGI скрипт.
Основным минусом же является необходимость запуска отдельного процесса для обработки каждого запроса, что является довольно ресурсоемкой операцией.
Реализация на PHP
Вся основная логика сервера будет строиться вокруг функции proc_open, которая запускает внешнюю команду, передавая ей аргументы и переменные среды, и открывает дескрипторы ввода/вывода.
$method,
'path' => $queryParams['path'] ?? '',
'query' => $queryParams['query'] ?? '',
'body' => $body,
];
}
/** Обработка запроса, вызов cgi программы с параметрами запроса и отправка ответа клиенту
* @param $client
* @param $request
* @return void
*/
function handleRequest($client, $request)
{
$method = $request['method'];
$path = $request['path'];
$query = $request['query'];
$body = $request['body'];
// Принимаем только get и post запросы
if (!in_array($method, ['get', 'post'])) {
echo "Error: Invalid method\n";
socket_write($client, "HTTP/1.1 405 Method Not Allowed\r\n\r\n");
return;
}
// Переменные среды необходимые для PHP/FI 1
$env = [
'REQUEST_METHOD' => strtoupper($method),
'QUERY_STRING' => $query,
'CONTENT_TYPE' => 'application/x-www-form-urlencoded',
'CONTENT_LENGTH' => strlen($body)
];
// Подготовка команды и параметров для запуска
$cmd = "." . $path;
$params = str_replace('+', ' ', $query);
// Дескрипторы ввода вывода
$descriptors = [
0 => ['pipe', 'r'], // stdin
1 => ['pipe', 'w'], // stdout
2 => ['pipe', 'w'], // stderr
];
// Запуск процесса
$process = proc_open($cmd . ' ' . $params, $descriptors, $pipes, '.', $env);
if (is_resource($process)) {
// Записываем тело запроса в stdin
if($body) {
fwrite($pipes[0], $body);
}
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]);
$errors = stream_get_contents($pipes[2]);
if($errors){
var_dump($errors);
}
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
// Отправляем клиенту ответ
$statusCode = strpos($output, 'Location:') !== false ? '302 Found' : '200 OK';
$response = "HTTP/1.1 {$statusCode}\r\n{$output}";
socket_write($client, $response);
}
}
$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_set_option($socket, SOL_SOCKET, SO_REUSEADDR, 1);
socket_bind($socket, $address, $port);
socket_listen($socket);
echo "Сервер запущен http://$address:$port\n";
while (true) {
$client = socket_accept($socket);
$request = socket_read($client, 1024);
$parsedRequest = parseRequest($request);
handleRequest($client, $parsedRequest);
socket_close($client);
}
socket_close($socket);
?>
GET-параметры PHP/FI 1 получает не через переменную среды QUERY_STRING, а как параметры запуска программы.
Анализ функциональности и исходного кода
phpl.cgi
Демонстрация базового функционала phpl.cgi
phpl.cgi — основная программа которая занимается рендерингом html страниц, контролем доступа и сохранением статистики посещения страниц. Программа ищет запрошенные страницы в каталоге, который был указан в константе HTML_DIR файла config.h, проверяет наличие файла All.acc или одноименного запрошенному файлу с расширением .acc, например, для index.html это index.acc. All.acc имеет больший приоритет, и его настройки не переопределяются другими файлами контроля доступа.
Формат файла немного напоминает правила .htaccess из Apache, на каждой строке указываются правило и его аргументы, с помощью функции сопоставления по маске программа проверяет соблюдается ли данное правило. Код этой функции довольно сложен для восприятия, о чем даже указал сам автор в комментариях:
/*
* wild_match has been borrowed from eggdrop 0.9m
* by Robey Pointer
*
* I've just fixed up the prototype and whitespace to be
* more readable. -Ben Eng
*/
/*
* Yanked out C++ comments -Rasmus Lerdorf
*/
/* brand new reg.c -- this one seems to get a fairly consistant 10%
gain over the old one. i ripped off the one from IRC servers and
edited it to make it work a little better (allows better quoting,
and returns a number indicating how closely the string matched,
just like the old one). */
#include
#include
#define tolower(c) \
( ( ( ( c ) >= 'A' ) && ( ( c ) <= 'Z' ) ) ? ( ( c ) - 'A' + 'a' ) : c )
/* this is the matches( ) function from ircd, spaghetti'd up to be more
effecient on masks that don't end with a wildcard ( ie: hostmask matches ).
first it does a quick scan of the string in reverse, aborting quickly
if any non-wildcard characters don't match. once it comes to the first
wildcard, it falls into the reverse-engineered ( by fred1 ) matches
function. it's proven to be pretty durn fast. thanks to justin slootsky,
who came up with the original idea of running the matches backward ( in
slightly more complex form ).
i tried to put lots of comments, cos string matching is not my thing,
and i had to sit and think for 5 minutes for every line. ( ugh! )
*/
int
wild_match( const char *ma, const char *na )
{
const char *mask = ma, *nask = na, *m = ma, *n = na, *mlast;
int close = 0, q = 0, wild = 0;
/* take care of null strings ( should never match ) */
if( ( ma == (char *)0 ) ||
( na == (char *)0 ) )
return 0;
if( ( !*ma ) || ( !*na ) )
return 0;
/* find the end of each string */
while( *m ) m++;
while( *n ) n++;
m--;
n--;
mlast = m;
/* check the match backwards */
/* while:
chars are identical OR the mask char is an unquoted '?'
& haven't reached the start of either string
& mask char isn't an unquoted '*'
*/
while( ( ( tolower( *m ) == tolower( *n ) ) ||
( ( *m == '?' ) && ( m[ -1 ] != '\\' ) ) ) &&
( m != ma ) && ( n != na ) &&
!( ( *m == '*' ) && ( m[ -1 ] != '\\' ) ) )
{
if( !( ( *m == '?' ) && ( m[ -1 ] != '\\' ) ) )
close++; /* 1 more exact match */
m--;
n--;
if( *m == '\\' )
m--; /* if mask was quoting something, skip the \ */
}
if( ( *m != '*' ) || ( *( m - 1 ) == '\\' ) ) {
/* case II - entire string matched, there were no '*' */
if( ( m == ma ) && ( n == na ) &&
( tolower( *m ) == tolower( *n ) ) )
return close+1;
/* case III - one of the strings ended prematurely ( no match ) */
if( ( m == ma ) || ( n == na ) )
return 0;
/* case IV - failed to match the ending strings, so abort */
return 0;
}
/* else */
/* case I - hit an unquoted '*' -- fall into matching algorithm */
while( 1 ) {
q = ( m > mask ) ? ( *( m - 1 ) == '\\' ) : 0; /* quoted? */
if( m < mask ) {
if( n < nask )
return close + 1; /* Made it through both strings! */
for( m++; ( m < mlast ) ? ( *m == '?' ) : 0; m++ )
; /* skip ?s */
if( ( *m == '*' ) && ( m < mlast ) )
return close + 1;
if( wild ) {
m = ma;
n = --na;
}
else
return 0;
}
else if( n < nask ) {
while( ( *m == '*' ) && ( m >= mask ) )
m--;
return ( m < mask ) ? close+1 : 0;
}
q = ( m > mask ) ? ( *( m - 1 ) == '\\' ) : 0; /* quoted? */
if( ( *m == '*' ) && ( !q ) ) { /* unquoted '*' ? */
while( ( m > mask ) ? ( *m == '*' ) : 0 )
m--; /* Throw away the *s */
if( *m == '\\' ) {
m++;
q = 1;
} /* First non-* was quoted, so keep it */
wild = 1;
ma = m;
na = n;
}
if( ( tolower( *m ) != tolower( *n ) ) &&
( ( *m != '?' ) || q ) )
{
/* non-match */
if( wild ) {
m = ma;
n = --na;
q=( m > mask ) ? ( *( m - 1 ) == '\\' ) : 0; /* quoted? */
}
else
return 0;
}
else {
if( ( *m != '?' ) || q )
close++; /* Unquoted ?s aren't counted */
if( *m ) m--; /* This char got matched */
if( *n ) n--; /* This char got matched */
if( q ) m--; /* This quote went with the char we matched */
}
}
}
Вот как это работает:
Проверяются случаи, когда одна из строк или обе пустые (не могут совпадать).
Быстро сканирует строку в обратном порядке, прекращая работу, если какие-либо символы, не являющиеся метасимволами, не совпадают.
Пока символы в обеих строках совпадают или символ в шаблоне равен
?
(если?
не экранирован), и не достигнуто начало обеих строк, и символ в шаблоне не является*
.Если символ в шаблоне не является неэкранированным
?
, увеличивается счетчикclose
для точного совпадения.Проверяется, является ли текущий символ в шаблоне экранированным.
В зависимости от условий сравнения возвращаются различные значения:
Если строка совпала полностью без символов
*
, возвращаетсяclose + 1
.Если одна из строк закончилась до того, как все символы сравнились, возвращается
0
.Если не удалось сравнить конечные строки, возвращается
0
.
Если обнаружен неэкранированный символ
*
, выполняется более сложное сравнение:Проверяется, является ли предыдущий символ экранированным.
Если строка закончилась, считается, что она совпала с пустой строкой.
Пропускаются символы
?
.Проверяется, есть ли после
*
другой символ в шаблоне, чтобы избежать бесконечного цикла.Если встречается
*
, который не экранирован, обрабатывается следующая итерация.Если обнаружен неэкранированный
*
, устанавливается флагwild
.Проверяется совпадение символов с учетом экранирования.
Если символы не совпадают и
wild
установлен, выполняется возврат к предыдущему*
.Если символы совпадают, увеличивается
close
и продолжается сравнение.
wild
— флаг, указывающий на то, что в шаблоне был обнаружен неэкранированный символ *
. Когда wild
установлен в 1, это означает, что текущее сравнение выполняется после обнаружения *
, и программа должна искать совпадения, пропуская определенное количество символов в строке для сравнения.close
— счетчик, отслеживающий количество точных совпадений символов между шаблоном и строкой. Этот счетчик увеличивается только при точном совпадении символов, то есть когда символы в шаблоне и строке совпадают, или когда символ в шаблоне является неэкранированным ?
.
Функция возвращает 0 в случае если строки не совпадают, значения больше 0 указывают на количество точных совпадений (без учета символов *
и ?
).
Не менее запутанным кажется код который обрабатывает сами правила:
/*
* CheckAccess
*
* Returns -1 if access file wasn't found
* 0 if user is allowed access
* 1 if user is denied access
* 2 if user is allowed access but should not be logged
*/
int CheckAccess(char *fn, int *email_req, int *password_req) {
FILE *fp;
char *r,*s,*ss=NULL,*t;
char buf[128];
char filename[1024];
int Access=0;
int Match=0;
int DefAccess=0; /* Default is to allow if not specified */
int old_em=0;
int old_pw=0;
#if DEBUG
fprintf(fperr,"In CheckAccess: NoInfo=%d\n",NoInfo);
fflush(fperr);
#endif
strcpy(filename,fn);
s=strrchr(filename,'.');
if(s) *s='\0';
if((s=strrchr(filename,'/'))) s++;
else s=filename;
if(ACCDIR[0]!='/') FixFilename(ACCDIR,buf);
else strcpy(buf,ACCDIR);
ss=buf+strlen(buf)-1;
if(*ss!='/') strcat(buf,"/");
strcat(buf,s);
strcat(buf,".acc");
#if DEBUG
fprintf(fperr,"Checking access file [%s]\n",buf);
fflush(fperr);
#endif
fp=fopen(buf,"r");
if(!fp) return(-1);
while(!feof(fp)) {
Match=0;
Access=0;
if(fgets(buf,127,fp)) {
if(buf[0]=='#') continue; /* ignore comments */
s=strchr(buf,' '); /* accept both space and tab as separator */
t=strchr(buf,'\t');
if(!s && t) s=t;
if(s && t && t
Общий алгоритм функции таков:
Построчно считывается содержимое файла и обрабатывается каждая строка
Если строка начинается с символа
#
, она считается комментарием и игнорируется.Иначе строка разделяется на две части: ключевое слово и значение. Если значение содержит пробелы или табуляции, они удаляются.
В зависимости от ключевого слова, устанавливается определенное правило доступа:
public
— доступ разрешен всем.private
— доступ запрещен всем.allow
— пользователю разрешен доступ.ban
— пользователю запрещен доступ.nolog
— отключает логирование посещения.noinfo
— отключает показ статистики для пользователя.info
— разрешает показ статистики для пользователяpasswordform
,emailform
— определяют кастомные формы для ввода пароля и email соответственно.email
— требуется email для доступа.noemail
— email не требуется.password
— требуется пароль для доступа.nopassword
— пароль не требуется.
Если строка начинается с
mail:
,browser:
,referer:
, то значение используется для сравнения с определенной информацией (email, браузер, ссылка), чтобы установить совпадение.
В качестве значения может быть предоставлена маска которая обрабатывается функцией wild_match. Если указать email *
то подойдет любая строка
Попробуем составить конфигурацию которая будет запрашивать e-mail и пароль для доступа к странице:
protected.acc:
private
email *
password secret *
protected.html:
This is protected page!
Результат:
Стандартная форма запроса e-mail и пароля, форму можно кастомизировать
После ввода e-mail и пароля получаем доступ к содержимому
В html файле могут быть обработаны некоторые перменные общий синтаксис таков:
Значения переменных могут быть подставлены из данных post запроса. Также есть несколько «системных» переменных — (cnt, todays_cnt, lasttime, lastuser, modtime, VERSION, email_addr, referer, agent, short_agent, raw_file) они в основном используются для отображения статистики в футере страницы.
Также в качестве аргументов можно передать параметр env
или version
phpf.cgi
phpf.cgi — программа отвечающая за обработку данных форм, их сохранение и отображение.
Для записи данных первым аргументом необходимо передать название формы, вторым, опционально, — адрес перенаправления клиента после сохранения данных, например:
phpf.cgi form_name phpl.cgi?index.html
Создадим простую страничку с формой ввода имени и сообщения:
Feedback Form
Feedback Form
Результаты формы будут записаны в .res файл в папке forms.
Для того чтобы прочитать данные формы нужно передать аргументы вида show filename xField, для нашей формы это может быть:
Просмотр сохраненных данных
Перед именем поля указывается префикс определяющий форматирование, возможные варианты:
m/M — для e-mail
l/L — для ссылок
t/T — обычный текст
i/I — курсив
b/B — жирный текст
Фрагмент кода отвечающий за форматирование:
void PrintFormType(int type, char *field, char *text) {
switch(type) {
case 'M':
if(field) printf("%s: %s
\n",field,text,text);
else printf("%s
\n",text,text);
break;
case 'm':
if(field) printf("%s: %s \n",field,text,text);
else printf("%s \n",text,text);
break;
case 'L':
if(field) printf("%s: %s
\n",field,text,text);
else printf("%s
\n",text,text);
break;
case 'l':
if(field) printf("%s: %s \n",field,text,text);
else printf("%s \n",text,text);
break;
case 'T':
if(field) printf("%s: %s
",field,text);
else printf("%s
",text);
break;
case 't':
if(field) printf("%s: %s ",field,text);
else printf("%s ",text);
break;
case 'I':
if(field) printf("%s: %s
",field,text);
else printf("%s
",text);
break;
case 'i':
if(field) printf("%s: %s ",field,text);
else printf("%s ",text);
break;
case 'B':
if(field) printf("%s: %s
",field,text);
else printf("%s
",text);
break;
case 'b':
if(field) printf("%s: %s ",field,text);
else printf("%s ",text);
break;
}
}
phplmon.cgi
Программа для обработки логов и отображения статистики посещения страниц. Ее функциональность довольно проста: она анализирует файлы .cnt, которые создает phpl.cgi, и выводит результат в табличном виде — имя страницы, всего посещений, посещений за день, дата последнего посещения и кем была посещена страница.
Статистика посещения страниц
phplview.cgi
Предоставляет более подробную статистику по каждому посещению страницы, данные берутся из файлов .log которые также пишет phpl.cgi.
Подробная статистика посещений веб-страницы
Заключение
Такой была самая первая версия PHP выложенная в публичный доступ в 1995 году. Небольшой набор инструментов для контроля доступа, ведения статистики посещений и сохранения данных веб-форм.
Этот фрагмент интервью Расмуса Лердорфа, опубликованный Кевином Янком на сайте SitePoint в 2002 году, раскрывает интересные аспекты развития PHP и его отличия от других языков.
SP: Что привело вас к разработке PHP? И что, по вашему мнению, отличает этот язык от других?
RL: Первая версия PHP была простым набором инструментов, которые я создал для своего веб-сайта и нескольких других проектов. Один инструмент записывал статистику посещений в базу данных mSQL, другой интерпретировал данные формы. В итоге у меня было около 30 небольших CGI-программ, написанных на C, прежде чем мне это надоело, и я объединил их все в одну библиотеку на C. Затем я написал очень простой парсер, который извлекал теги из HTML-файлов и заменял их результатом соответствующих функций в библиотеке на C.
Этот простой парсер постепенно стал включать условные теги, затем циклы, функции и т. д. Ни в один момент я не думал, что пишу язык сценариев. Я просто добавлял немного функциональности к парсеру макросов. Всю свою реальную бизнес-логику я все еще писал на C.
В конце концов, то, что, по моему мнению, выделяло PHP на ранних этапах и до сих пор выделяет его среди других, это то, что он всегда стремится найти самый короткий путь к решению проблемы задач веба. Он не пытается быть языком общего назначения, и любой, кто ищет решение веб-задачи, обычно найдет очень прямое решение через PHP. Многие альтернативы, которые утверждают, что решают задачи веба, просто слишком сложны. Когда вам нужно, чтобы что-то заработало к пятнице, чтобы вы не провели всё выходные перелистывая 800-страничные руководства, PHP начинает выглядеть довольно хорошо.
SP: Какое решение, по вашему мнению, было самым важным за годы разработки PHP? Есть ли какие-либо решения, которые вы приняли, и теперь вам хотелось бы, чтобы вы приняли их по-другому?
RL: Мне трудно пересмотреть решения, которые были приняты 6 или 7 лет назад, когда PHP использовался всего одним человеком. Не забывайте, что я не сел писать скриптовый язык, который бы использовали 9 миллионов доменов: я сел решить проблему. Решение проблемы к 17:00, чтобы вы могли пойти в кино со своей девушкой, приводит к некоторым аспектам, которые не идеальны 7 лет спустя, когда тысячам людей приходится работать над тем хаком, который вы добавили поздней ночью.
Самое важное решение, которое я принял, вероятно, это было отказаться от контроля. Открыть проект и дать почти всем, кто просил, полный доступ к исходным кодам PHP. Это привело к появлению множества отличных талантов, и люди обычно чувствовали реальное чувство причастности. Проект PHP, вероятно, один из самых крупных по числу людей с доступом к репозиторию CVS, где находится код и документация.
Настроенный проект с сервером доступен для изучения на github.