18

On décortique un gestionnaire de commentaires en C : bla

Je me suis lancé le défi d’écrire un moteur de commentaires. Ça s’appelle bla, et c’est accessible .

Puisqu’il semble fonctionner correctement, voici un billet dérivant son fonctionnement et les choix que j’ai pu faire en fonction de mes compétences et les contraintes que je m’étais fixées.

Mon principal objectif était de limiter les dépendances et tout faire avec un système OpenBSD de base.

J’ai tout d’abord envisagé de créer une adresse mail dédiée afin de profiter des fonctionnalités d’un fichier forward qui placerait les commentaires dans un endroit dédié qu’il faudrait afficher ensuite je ne sais comment. Pas d’antispam possible… De plus, ça suppose que quelqu’un se donne la peine d’ouvrir son client mail pour écrire. C’est un gage que la personne qui écrit a un truc vraiment intéressant à dire me direz-vous (et d’expérience, c’est vrai), mais un formulaire reste plus pratique.

Bon, qui dit formulaire, dit traitement du formulaire ensuite. Pour ça, il faut un langage de programmation. Hors de question de faire ça en PHP, on a dit qu’on utilisait un système de base.

Il faut donc écrire le moteur en shell ou en C (ou en perl, mais je ne connais pas). C’est donc parti pour le C.

Chouette, en C, c’est rapide, et je peux intégrer des mesures de sécurité supplémentaire avec pledge et unveil.

Il faudra donc écrire un CGI en C. Pas de soucis, OpenBSD intègre slowcgi qui est facile à configurer.

Avant de commencer à sortir vi, comment est-ce j’enregistre les commentaires? Un base de données serait pas mal, et sqlite serait intéressant. Sauf que je connais très mal la façon d’interagir avec sqlite en C… Bon, restons simples, ça sera des fichiers texte brut : peut-être moins efficace, mais je sais faire.

C’est parti!

Le code en C

Le code va devoir se découper en 2 fonctions :

Avant de recevoir un commentaire, on va interdire à bla l’accès à d’autres fichiers que ceux de notre banque de données :

UNVEILORDIE(dbdir, "rwc");

dbdir est la variable contenant le chemin vers le dossier où sont enregistrés les commentaires.

On n’autorise bla qu’à gérer des entrées et sorties de données puis de pouvoir lire/écrire dans des fichiers :

PLEDGEORDIE("stdio rpath wpath cpath");

Pour recevoir un commentaire, il faut lire l’entrée standard Il “suffit” de lire le fichier appelé “stdin”.

Toutefois, on a besoin de savoir à quel article correspond le commentaire. Le plus simple sera donc de faire en sorte qu’un commentaire soit envoyé en précisant en paramètre l’URL de la page commentée. Par exemple, si on commente “/articles/page1.html” :

https://foo.bar/bla?/articles/page1.html

Pour récupérer cette information, on regarde la variable d’environnement “QUERY_STRING” :

article = strdup(getenv("QUERY_STRING"));

On appelle ici getenv qui permet de récupérer le contenu d’une variable d’environnement. J’en fais une copie avec strdup car on va avoir besoin de la modifier. En effet, un petit malin pourrait s’amuser à mettre n’importe quoi comme paramètres (sait-on jamais), alors je “nettoie” ensuite le paramètre reçu pour retirer tout ce qui n’est pas alphanumérique. Si ce n’est pas le cas, on remplace par le caractère “_” (choisi arbitrairement):

replace_nonalnum(&article, '_')

Voici à quoi ressemble cette fonction :

void
replace_nonalnum(char **s, char c)
{
	for (size_t i = 0; i < strlen(*s); i++) {
		if (!(isalnum((*s)[i]))) {
			(*s)[i] = c;
		}
	}
}

Puisqu’elle modifie la chaîne, elle reçoit un pointeur. Ensuite, on va regarder chaque caractère de la chaîne un par un, de 0 à la taille de la chaîne :

for (size_t i = 0; i < strlen(*s); i++) {

Si ce n’est pas alphanumérique :

if (!(isalnum((*s)[i]))) {

Alors on remplace le caractère :

(*s)[i] = c;

Écrire (*s) me permet d’accéder à la variable au bout du pointeur s. C’est juste une histoire de pointeur, ça se décortique tranquillement à jeun. :)

On peut maintenant récupérer un éventuel article posté :

post = get_stream_txt(stdin, MAX);

La fonction get_stream_txt ne fait qu’enregistrer le texte reçu vers le pointeur `post.

Si on a effectivement reçu quelque chose :

if (strlen(post) > 0) {

Il faut le traiter. Les données envoyées en POST ressemblent à ça :

fname=truc&antispam=pouet&...

En gros, c’est une combinaison de “variable=valeur” séparés par des “&”.

Ici, on utilise la fonction strsep pour découper les “&”, puis on appelle get_postfield qui sépare les “=”.

while((buf = strsep(&post, "&")) != NULL) {
	if (startswith(buf, "fname=")) {
		name = get_postfield(buf);
	} else if ...

Tiens, la fonction startswith apparaît :

int 
startswith(const char *str, const char *start)
{
	size_t str_len = strlen(str);
	size_t start_len = strlen(start);
	
	
	int ret = 0;
	if ((str_len >= start_len) && 
		(strncmp(str, start, start_len) == 0)) {
		ret = 1;
	}
	
	return ret;
}

Elle ne fait que comparer au maximum le nombre de caractères qui sont à comparer avec strncmp.

Parmi les données reçues, il y a la réponse à la question antispam (on verra plus tard comment elle est choisie). C’est une simple comparaison de chaînes qui a lieu :

if (strcmp(as_ans, qa[atoi(as_id)][1]) != 0) {

Les questions sont enregistrés dans un tableau, chaque ligne du tableau contenant la question et la réponse associés :

static const char *qa[][2] = {
{ "What is Batman Last Name?", "Wayne" },
{ "What is the second letter in \"pancake\"?", "a" },
...};

La partie qa[atoi(as_id) permet de choisir la bonne ligne du tableau selon le numéro de la question qui était dans le formulaire.

Si tout va bien, on peut enregistrer le commentaire. On crée le dossier correspondant à la page:

if (mkdir(article, S_IRWXU | S_IRGRP | S_IXGRP | S_IROTH | S_IXOTH ) != 0) {

Les permissions S_IRWXU… sont décrites dans la page man de chmod (2).

J’ai choisi de faire simple : le fichier commentaire portera le nom du numéro du commentaire :

n = num_files(".");
esnprintf(commfile, sizeof(commfile), "%c%d.txt", moderateme, n);

num_files ne fait que compter le nombre de fichiers, et donc le nombre de commentaires, déjà existants. On aura donc “_0.txt”, “_1.txt”.

Les noms de fichier commencent par “_”, qui est la valeur de moderateme. Ainsi, ça sera facile de reconnaitre les commentaires qui ne sont pas encore modérés et donc ne pas les afficher.

Si on n’a par reçu de données c’est qu’il faut afficher les commentaires déjà existants. Pour ça, on va juste afficher les fichiers ne commençant pas par “_”, et classés par ordre alphabétique pour conserver un affichage logique.

On crée une fonction qui va filtrer les noms de fichiers et qui sera appelée par scandir:

int 
select_comm(const struct dirent *d)
{
	int ret = 0;
	
	
	if ((d->d_name[0] != moderateme) && (endswith(d->d_name, ".txt"))) {
		ret = 1;
	}
	
	return ret;
}

Voici donc l’appel à scandir qui renvoie le contenu des fichiers:

if ((n = scandir(path, &namelist, select_comm, alphasort)) < 0) {
	err(1, "scandir:%s", path);
} else {
	/* print in reverse order */
	for(int i = 0; i < n; i++) {
		if ((namelist[i]->d_name[0] != moderateme) && (endswith(namelist[i]->d_name, ".txt"))) {
			txt = get_file_txt(namelist[i]->d_name);
			pseudo = strsep(&txt, "\n");
			if (pseudo != NULL) {
				printf(commtpl, pseudo, txt);
			}
			free(pseudo);
		}
		free(namelist[i]);
	}
	free(namelist);
}

Enfin, on affiche le formulaire :

doform(arc4random() % LEN(qa), article);

Cette fonction est très courte :

void
doform(int antispamid, const char *article)
{
	printf(form, article, qa[antispamid][0], antispamid);
}

form contient un modèle html avec des “%s” à remplacer. Ce qui est important ici, c’est :

arc4random() % LEN(qa)

Ça permet d’obtenir une question au hasard parmi celles définies dans le tableau qa. LEN est la maro suivante, donnant la taille d’un tableau :

#define LEN(x)  (sizeof(x) / sizeof((x)[0]))

Et voilà, on a fait à peu près le tour :)

Le code javascript

Afin d’afficher les commentaires, un bout de javascript fait de l’AJAX pour avoir une requête en arrière plan :

/* add "<div id="bla"></div>" somewhere */
function blashowform() {
	var article = window.location.pathname;
	var xhttp = new XMLHttpRequest();
	xhttp.onreadystatechange = function() {
		if (this.readyState == 4 && this.status == 200) {
			document.getElementById("bla").innerHTML =
				this.responseText;
		}
	};
	xhttp.open("POST", "/bla?"+article, true);
	xhttp.send();
}
window.addEventListener("load", blashowform);

Bon, je ne suis pas très doué en javascript, il doit y avoir moyen de faire mieux.

Et la modération ?

Il s’agit juste de renommer les fichiers commençant par “_”. Pour ça, la commande mv suffit. J’ai écrit un petit script pour me faciliter les choses afin de ne pas avoir à entrer de mot de passe (oui, les permissions sont restreintes sur les fichiers de commentaire). J’ai donc modifié le fichier doas.conf puis créé ce script qui valide tous les commentaires en attente :

#!/bin/sh
# validate all comments
# to avoid password, add to /etc/doas.conf:
# permit nopass user as www cmd mv
dir=/var/www/bla-db/
pattern="_*.txt"
find $dir -type f -name "$pattern" | while read -r f
do
	dn=$(dirname $f)
	bn=$(basename $f)
	nbn=$(echo $bn | cut -c 2-) # remove _
	doas -u www mv $f "$dn/$nbn"
done

Pour être averti des commentaire en attente, un tâche cron appelle ce script :

#!/bin/sh

commdir=/var/www/bla-db/
moderated="_"
subject="Awaiting comments"
email=someone@foo.bar

tomoderate="$(find $commdir -type f -iname "${moderated}*.txt" -print -exec cat {} +)"

# use of sendmail because mail wrapper don't handle header
# and content-type 

if [ -n "$tomoderate" ]; then
	(
	echo "Subject: $subject"
	echo "From: $(whoami) <$(whoami)@$(hostname)>"
	echo "To: $email"
	echo "Content-Type: text/plain; charset=UTF-8"
	echo "Content-Transfer-Encoding: 8bit"
	echo ""
	
	echo "$tomoderate"
	) | /usr/sbin/sendmail -f noreply@$(hostname) -F $(whoami) $email
fi 

Ça me permet d’avoir ça dans ma boîte mail :

/var/www/bla-db/_16_html/_0.txt                                                                                                                                                
pseudo

Commentaire
bla bla bla

bla

Avenir

Bla est loin d’être très complet. Mais je vois son manque de fonctionnalités comme pratiques. J’hésite à garder un système de commentaire ici car j’aimais bien le fait qu’on m’écrive par mail. On verra.

Ceci dit, si quelqu’un l’apprécie, il voudra peut-être ajouter les fonctionnalités suivantes :

15/08/2020 16:00