- Go 54.9%
- HTML 23.5%
- CSS 12%
- JavaScript 9.2%
- Makefile 0.3%
|
|
||
|---|---|---|
| .forgejo/workflows | ||
| build | ||
| cmd/cms | ||
| k8s | ||
| queries | ||
| ui | ||
| .gitignore | ||
| auth.go | ||
| client.go | ||
| client_test.go | ||
| compose.yaml | ||
| config.go | ||
| db.go | ||
| Dockerfile | ||
| entity.go | ||
| errors.go | ||
| flag.go | ||
| go.mod | ||
| go.sum | ||
| handler.go | ||
| handler_ui.go | ||
| LICENSE | ||
| login.go | ||
| Makefile | ||
| middleware.go | ||
| oauth.go | ||
| opts.go | ||
| README.md | ||
| response.go | ||
| response_ui.go | ||
| route.go | ||
| server.go | ||
| session.go | ||
| src.Dockerfile | ||
| test.compose.yaml | ||
| validate.go | ||
cms
CMS!!!!!
Requirements
dockerdocker composemake
Deployment Local
- Copier et configurer le fichier d'environnement :
# .env
BOTTIN_SERVER_API_POSTGRES_DATABASE='bottin'
BOTTIN_SERVER_API_POSTGRES_PASSWORD='bottin'
BOTTIN_SERVER_API_POSTGRES_USER='bottin'
BOTTIN_SERVER_API_POSTGRES_HOST='bottin-db'
BOTTIN_SERVER_API_KEY='une_cle_secrete_si_necessaire'
CMS_BOTTIN_HOST='bottin-api'
CMS_BOTTIN_PORT='1312'
CMS_BOTTIN_KEY='une_cle_secrete_si_necessaire'
CMS_SERVER_API_POSTGRES_DATABASE='cms'
CMS_SERVER_API_POSTGRES_HOST='db'
CMS_SERVER_API_POSTGRES_PASSWORD='cms'
CMS_SERVER_API_POSTGRES_USER='cms'
CMS_SERVER_API_KEY='cms'
CMS_CLIENT_API_HOST='localhost'
CMS_CLIENT_API_PORT='8080'
CMS_CLIENT_API_KEY='cms'
CMS_SERVER_UI_USERNAME='cms'
CMS_SERVER_UI_PASSWORD='cms'
CMS_SERVER_UI_OAUTH_CLIENTID='<client-id>'
CMS_SERVER_UI_OAUTH_CLIENTSECRET='<client-secret>'
CMS_SERVER_UI_OAUTH_REDIRECTURL='http://localhost:2312/auth/callback/'
CMS_SERVER_UI_OAUTH_SESSIONKEY='<openssl rand -base64 32>'
CMS_SERVER_UI_OAUTH_SECURECOOKIES='false'
# CMS_SERVER_UI_OAUTH_ALLOWEDDOMAIN='agecem.com' # optionnel
- Démarrer les conteneurs :
make deploy
- Insérer les données de seed (requiert les conteneurs en marche) :
make seed
- Accéder à l'interface :
| Service | URL |
|---|---|
| UI | http://localhost:2312 |
| API | http://localhost:8080 |
Tests
# test.env
BOTTIN_SERVER_API_POSTGRES_DATABASE='bottin'
BOTTIN_SERVER_API_POSTGRES_PASSWORD='bottin'
BOTTIN_SERVER_API_POSTGRES_USER='bottin'
BOTTIN_SERVER_API_POSTGRES_HOST='bottin-db'
BOTTIN_SERVER_API_KEY='une_cle_secrete_si_necessaire'
CMS_SERVER_API_POSTGRES_DATABASE='cms'
CMS_SERVER_API_POSTGRES_HOST='db'
CMS_SERVER_API_POSTGRES_PASSWORD='cms'
CMS_SERVER_API_POSTGRES_USER='cms'
CMS_CLIENT_API_HOST='localhost'
CMS_CLIENT_API_PORT='8080'
CMS_BOTTIN_HOST='bottin-api'
CMS_BOTTIN_PORT='1312'
CMS_BOTTIN_KEY='une_cle_secrete_si_necessaire'
Schéma des bases de données
Deux bases distinctes : cms (ce service) et bottin (service externe, bottin/v12@v12.13.0).
Légende: <PK> clé primaire · <FK> clé étrangère réelle · ? colonne nullable ·
trait plein = FK dans la même base · trait pointillé = lien logique entre les deux bases.
classDiagram
direction RL
namespace CMS {
class factures {
<PK>UUID id
FLOAT amount
DATE date
TIMESTAMP? archived_at
}
class achats {
<PK>UUID id
<FK>UUID facture_id
<FK>UUID? caisse_id
TEXT membre_id
payment_method payment_method
FLOAT? amount_received
FLOAT? change_returned
TIMESTAMP created_at
TIMESTAMP? archived_at
}
class locations {
<PK>UUID id
<FK>UUID facture_id
TEXT membre_id
DATE return_date
TIMESTAMP? returned_at
TIMESTAMP? archived_at
}
class signalements {
<PK>UUID id
<FK>UUID? achat_id
<FK>UUID? location_id
TEXT? membre_id
TEXT title
TEXT message
signal_type? signal_type
TIMESTAMP date
TIMESTAMP? archived_at
}
class stoques {
<PK>UUID id
TEXT name
FLOAT prix_membre
FLOAT prix_non_membre
INTEGER quantity
BOOLEAN can_buy
BOOLEAN can_rent
TIMESTAMP? archived_at
}
class categories {
<PK>UUID id
TEXT name UNIQUE
TEXT categorie_type
TIMESTAMP? archived_at
}
class stoques_categories {
<PK>UUID id
<FK>UUID stoque_id
<FK>UUID categorie_id CASCADE
UNIQUE stoque_id_categorie_id
}
class signalements_categories {
<PK>UUID id
<FK>UUID signalement_id
<FK>UUID categorie_id CASCADE
UNIQUE signalement_id_categorie_id
}
class stoques_achats {
<PK>UUID id
<FK>UUID achat_id
<FK>UUID stoque_id
}
class stoques_locations {
<PK>UUID id
<FK>UUID location_id
<FK>UUID stoque_id
TIMESTAMP? returned_at
}
class caisse {
<PK>UUID id
DATE date
FLOAT opening_fund
INTEGER pieces_5c, pieces_10c, pieces_25c
INTEGER pieces_1, pieces_2
INTEGER bills_5, bills_10, bills_20
INTEGER rolls_5c, rolls_10c, rolls_25c
INTEGER rolls_1, rolls_2
FLOAT? total_received
FLOAT? total_returned
TIMESTAMP created_at
TIMESTAMP opened_at
TIMESTAMP? closed_at
TIMESTAMP? archived_at
}
class historique_caisse {
<PK>UUID id
<FK>UUID caisse_id
FLOAT expected_amount
FLOAT counted_amount
FLOAT discrepancy
INTEGER pieces_5c, pieces_10c, pieces_25c
INTEGER pieces_1, pieces_2
INTEGER bills_5, bills_10, bills_20
INTEGER rolls_5c, rolls_10c, rolls_25c
INTEGER rolls_1, rolls_2
TEXT historique_type
TIMESTAMP created_at
TIMESTAMP? archived_at
}
class caisse_mouvement {
<PK>UUID id
<FK>UUID caisse_id
<FK>UUID user_id
FLOAT amount
TEXT historique_type
TEXT? note
TIMESTAMP created_at
}
class membre_cache {
<PK>TEXT membre_id
TEXT first_name
TEXT last_name
TEXT programme
}
class users {
<PK>UUID id
TEXT email UNIQUE
TEXT? google_sub UNIQUE
TEXT? name
user_role role
TIMESTAMP created_at
TIMESTAMP? last_login_at
TIMESTAMP? archived_at
}
class payment_method {
<<enum>>
debit
credit
comptant
}
class signal_type {
<<enum>>
warning
critical
neutral
}
class user_role {
<<enum>>
commis
permanence
commis_senior
admin
}
}
namespace Bottin {
class programmes {
<PK>TEXT id
TEXT name
TIMESTAMP? archived_at
}
class membres {
<PK>VARCHAR7 id
TEXT first_name
TEXT last_name
TEXT prefered_name
<FK>TEXT programme_id
TEXT_ARRAY? phones
TIMESTAMP? archived_at
}
}
factures "1..1" -- "0..n" achats : facture_id
factures "1..1" -- "0..n" locations : facture_id
caisse "0..1" -- "0..n" achats : caisse_id
caisse "1..1" -- "0..n" historique_caisse : caisse_id
caisse "1..1" -- "0..n" caisse_mouvement : caisse_id
users "1..1" -- "0..n" caisse_mouvement : user_id
achats "0..1" -- "0..n" signalements : achat_id
locations "0..1" -- "0..n" signalements : location_id
achats "1..1" -- "0..n" stoques_achats : achat_id
stoques "1..1" -- "0..n" stoques_achats : stoque_id
locations "1..1" -- "0..n" stoques_locations : location_id
stoques "1..1" -- "0..n" stoques_locations : stoque_id
stoques "1..1" -- "0..n" stoques_categories : stoque_id
categories "1..1" -- "0..n" stoques_categories : categorie_id
signalements "1..1" -- "0..n" signalements_categories : signalement_id
categories "1..1" -- "0..n" signalements_categories : categorie_id
achats ..> payment_method
signalements ..> signal_type
users ..> user_role
programmes "1..1" -- "0..n" membres : programme_id
membres "1..1" .. "0..n" achats : membre_id
membres "1..1" .. "0..n" locations : membre_id
membres "0..1" .. "0..n" signalements : membre_id
membres "1..1" .. "0..1" membre_cache : copie locale
classDef cms fill:#E8F0FE,stroke:#4A5568,color:#1A202C
classDef bottin fill:#FFF4E5,stroke:#8B5A2B,color:#1A202C
classDef enums fill:#EDE9FE,stroke:#6D28D9,color:#1A202C
cssClass "factures,achats,locations,signalements,stoques,categories,stoques_categories,signalements_categories,stoques_achats,stoques_locations,caisse,historique_caisse,caisse_mouvement,membre_cache,users" cms
cssClass "programmes,membres" bottin
cssClass "payment_method,signal_type,user_role" enums
Notes
membre_idn'est jamais une FK.achats,locations,signalementsetmembre_cacheportent unmembre_idtexte qui correspond àbottin.membres.id, mais les deux bases sont distinctes : aucune intégrité référentielle n'est appliquée par Postgres.membre_cacheest une copie de secours, peuplée lors deCreateAchats/CreateLocations. Elle sert de repli quand Bottin est injoignable ou que le membre y a été supprimé.factures->achats/locations: le schéma autorise plusieurs achats ou locations par facture, mais l'application en crée toujours exactement un.categoriesest partagée entre stoques et signalements, départagée parcategorie_type('stoque'ou'signalement'). Les deux tables de liaison suppriment en cascade quand une catégorie disparaît, mais pas quand le stoque ou le signalement disparaît.caisse/historique_caisserépètent les 14 mêmes colonnes de dénominations (pieces_*,bills_*,rolls_*), regroupées ici sur cinq lignes pour la lisibilité.- Les contraintes
CHECK(length(name) > 0, valeurs decategorie_typeet dehistorique_type) et le trigger de validation des téléphones de Bottin ne sont pas représentés sur le diagramme. - Deux types ont été renommés pour Mermaid, qui interprète les parenthèses comme une
méthode et les crochets comme un noeud :
membres.idest réellementVARCHAR(7)(écritVARCHAR7) etmembres.phonesest réellementTEXT[](écritTEXT_ARRAY).
Authentification
OAuth avec Google pour l'authentification des utilisateurs. Après la connexion, le serveur UI crée une session sécurisée à l'aide d'un cookie chiffré avec le token retourné par Google. Seuls ceux qui existent comme entrées dans la table users sont authentifiés. Le serveur UI sort le token du cookie et l'envoie à l'API dans le header Authorization. L'API valide le token pour vérifier que c'est un token de Google, une fois vérifié la requête vers l'API va être exécutée comme normal. Le token est revalidé à chaque requête, seules les clés publiques de Google sont gardées en cache.
Sur une base neuve, CMS_SERVER_API_OAUTH_BOOTSTRAPEMAILS inscrit les premiers admins au démarrage, sinon personne ne peut se connecter.
Accès direct à l'API est possible avec le CLI avec les fichiers cmd/cms/login.go et login.go. Ces deux fichiers ne sont jamais utilisés pour le workflow normal avec le serveur UI.
cmd/cms/login.go
Ce fichier nous laisse faire des commandes cobra pour login avec le CLI pour accès direct sur l'API sans le serveur UI avec les commandes login, token et logout.
login.go
Enable un accès API direct avec curl.
Login() lance un petit serveur HTTP local sur 127.0.0.1:8085 pour recevoir le code de Google, parce que Google redirige le navigateur vers localhost. Le client OAuth Google doit donc avoir deux redirect URI enregistrées, celle du serveur UI et http://localhost:8085/callback.
Pour envoyer des requêtes HTTP directement à l'API avec curl on peut soit ouvrir une session avec l'UI et login comme d'habitude ou suivre les étapes suivantes pour le faire sans le serveur UI:
- Build le binaire et les variables d'environnement
go build -o ./cms ./cmd/cms
export CMS_CLIENT_API_HOST=localhost
export CMS_CLIENT_OAUTH_CLIENTID='842665291974-h40n2c0qeqte317uv2cfujhgu7s12cqu.apps.googleusercontent.com'
export CMS_CLIENT_OAUTH_CLIENTSECRET='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'
- Exécuter votre requête avec curl
curl -H "Authorization: Bearer $(./cms token)" http://localhost:8080/v1/me/
- Un onglet sur votre navigateur va ouvrir pour login avec Google et une fois authentifié la requête HTTP va s'exécuter. Les fois suivantes le token gardé dans ~/.config/cms/token.json est réutilisé sans ouvrir le navigateur.
Ce fichier est l'équivalent de session.go pour le CLI, cmd/cms/login.go utilise des fonctions déclarées ici.
session.go
Gère les cookies sur le serveur UI qui contiennent les infos de la session comme le token, le refresh token, le rôle, l'expiration, etc. Le cookie est chiffré et pas juste signé parce qu'il contient le refresh token.
Gère différentes fonctions pour les cookies comme clear et set cookies.
Contient pendingAuth, un short lived state pour un login en attente/in progress.
expiringSoon() dit si le token expire bientôt, la constante sessionRefreshWindow est de 2 minutes.
APIClientFactory
Avant, avec le bearer token partagé, il y avait un APIClient initialisé dans server.go qui existait pour toujours avec le même token. Maintenant que chaque utilisateur a un token différent, un même APIClient ne va plus fonctionner. Il faut donc créer un nouveau APIClient avec chaque requête. voki.Caller fixe son token à la construction, il n'y a pas moyen de le changer par appel.
auth.go
Gestion de l'authentification et autorisation générale.
Verify le token sur le serveur API (signature, audience, expiration, etc.)
Verify si l'utilisateur existe dans la table users.
denyJSON() retourne des erreurs lorsque l'auth est refusé.
Fonctionnalité RBAC sur les routes de l'API avec APIAuth.RequireRole().
Claims c'est le contenu décodé du token (courriel, nom, sub, expiration). C'est ce que le TokenVerifier retourne après avoir vérifié le token.
oauth.go
Config pour OAuth par Google.
Méthodes pour créer un token, créer une session avec un token.
Expiration du token et le refresh token sont set par Google (OAuth provider).
refreshSession() est ici, il refait un token avec le refresh token sans rien demander à l'utilisateur.
middleware.go
Appel expiringSoon() chaque requête pour vérifier si le token expire dans moins de 2 minutes, si oui on refresh la session.
Si une route utilise RequireRole(), la méthode vérifie si l'utilisateur contient le rôle minimum pour l'accès. Il vérifie le rôle sur la session avec la sessionContext key. C'est juste pour l'affichage, l'API a son propre RequireRole() qui revérifie le rôle en base.