Compare commits

1 Commits

Author SHA1 Message Date
vorpax
6be40f5a6a tina 2026-02-02 04:14:53 +01:00
30 changed files with 11458 additions and 140 deletions

36
.env.example Normal file
View File

@@ -0,0 +1,36 @@
# ============================================
# PostgreSQL
# ============================================
POSTGRES_PASSWORD=
# ============================================
# OIDC Configuration (PocketID)
# ============================================
# URL de base de votre provider OIDC
OIDC_ISSUER=https://auth.hec-ia.com
# Credentials de l'application OIDC
OIDC_CLIENT_ID=tina-cms-client
OIDC_CLIENT_SECRET=your_client_secret_here
# URL de callback (doit correspondre à la config PocketID)
OIDC_CALLBACK_URL=https://tina.hec-ia.com/auth/callback
# Scopes demandés (séparés par des espaces)
OIDC_SCOPE=openid profile email
# ============================================
# Session Security (Iron Session)
# ============================================
# Générer avec: openssl rand -base64 32
SESSION_SECRET=
# ============================================
# GitHub Integration
# ============================================
# Personal Access Token avec scope 'repo'
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Configuration du repo
GITHUB_REPO=wiki.hec-ia.com
GITHUB_OWNER=hec-ia
# ============================================
# Local Development
# ============================================
# Mettre à true pour le développement local sans backend
TINA_PUBLIC_IS_LOCAL=false

View File

@@ -44,11 +44,3 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Deploy stack
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.KEY }}
port: ${{ secrets.PORT }}
script: cd ~/

View File

@@ -1,57 +0,0 @@
name: Build and Push Docker Image - HEC IA Wiki
on:
push:
branches:
- main
- master
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix=
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') || github.ref == format('refs/heads/{0}', 'master') }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Deploy stack
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.KEY }}
port: ${{ secrets.PORT }}
script: git pull && docker compose -f compose.yaml pull && docker compose up -d

75
.github/workflows/deploy-tina.yml vendored Normal file
View File

@@ -0,0 +1,75 @@
name: Deploy TinaCMS Backend
on:
push:
branches: [main]
paths:
- "tina-backend/**"
- "tina/**"
- "docker-compose.yml"
- ".github/workflows/deploy-tina.yml"
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}/tina-backend
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./tina-backend/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Deploy to VPS
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /opt/wiki.hec-ia.com
docker-compose pull tina-backend
docker-compose up -d tina-backend
docker-compose exec -T tina-backend npx drizzle-kit migrate
docker system prune -f

11
.gitignore vendored
View File

@@ -11,8 +11,6 @@ node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-store
.hist
pnpm-debug.log*
# environment variables
@@ -29,3 +27,12 @@ pnpm-debug.log*
public/pagefind
SETUP.md
# TinaCMS
.tina/__generated__
admin
PLAN.md
QUICKSTART.md
TINA.md
.pnpm-store
.hist

View File

@@ -1,12 +0,0 @@
services:
app:
build: .
restart: unless-stopped
expose:
- 80
networks:
- public
networks:
public:
name: public
external: true

View File

@@ -1,12 +0,0 @@
services:
app:
build: .
restart: unless-stopped
expose:
- 80
networks:
- public
networks:
public:
name: public
external: true

18
docker-compose.tina.yml Normal file
View File

@@ -0,0 +1,18 @@
version: '3.8'
services:
tina:
image: node:20-alpine
working_dir: /app
volumes:
- .:/app
- /app/node_modules
ports:
- "4001:4001"
environment:
- NODE_ENV=production
command: >
sh -c "npm install -g pnpm &&
pnpm install &&
pnpm tinacms dev --host 0.0.0.0 --port 4001"
restart: unless-stopped

68
docker-compose.yml Normal file
View File

@@ -0,0 +1,68 @@
services:
# Service Astro existant
app:
image: node:lts
ports:
- 4321:4321
working_dir: /app
command: npm run dev -- --host 0.0.0.0
volumes:
- ./:/app
networks:
- tina-network
# PostgreSQL pour Tina
postgres:
image: postgres:16-alpine
container_name: tina-postgres
restart: unless-stopped
environment:
POSTGRES_DB: tina
POSTGRES_USER: tina
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./tina-backend/migrations:/docker-entrypoint-initdb.d:ro
networks:
- tina-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tina"]
interval: 5s
timeout: 5s
retries: 5
# Backend TinaCMS
tina-backend:
build:
context: .
dockerfile: tina-backend/Dockerfile
container_name: tina-backend
restart: unless-stopped
environment:
NODE_ENV: production
PORT: 3000
DATABASE_URL: postgresql://tina:${POSTGRES_PASSWORD}@postgres:5432/tina
OIDC_ISSUER: ${OIDC_ISSUER}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
OIDC_CALLBACK_URL: ${OIDC_CALLBACK_URL}
OIDC_SCOPE: ${OIDC_SCOPE}
SESSION_SECRET: ${SESSION_SECRET}
GITHUB_TOKEN: ${GITHUB_TOKEN}
GITHUB_REPO: ${GITHUB_REPO}
GITHUB_OWNER: ${GITHUB_OWNER}
GITHUB_BRANCH: main
depends_on:
postgres:
condition: service_healthy
networks:
- tina-network
ports:
- "3000:3000"
volumes:
postgres_data:
networks:
tina-network:
driver: bridge

View File

@@ -10,22 +10,39 @@
"astro": "astro",
"format:check": "prettier --check .",
"format": "prettier --write .",
"lint": "eslint ."
"lint": "eslint .",
"tinacms": "tinacms",
"tina:dev": "tinacms dev -c 'astro dev'",
"tina:build": "tinacms build"
},
"dependencies": {
"@astrojs/rss": "^4.0.14",
"@astrojs/sitemap": "^3.6.0",
"@fastify/cookie": "^11.0.2",
"@fastify/static": "^9.0.0",
"@resvg/resvg-js": "^2.6.2",
"@tailwindcss/vite": "^4.1.18",
"@tinacms/cli": "^2.1.3",
"@tinacms/datalayer": "^2.0.7",
"abstract-level": "^3.1.1",
"astro": "^5.16.6",
"dayjs": "^1.11.19",
"drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1",
"fastify": "^5.7.2",
"iron-session": "^8.0.4",
"lodash.kebabcase": "^4.1.1",
"openid-client": "^6.8.1",
"pg": "^8.18.0",
"remark-collapse": "^0.1.2",
"remark-toc": "^9.0.0",
"satori": "^0.18.3",
"sharp": "^0.34.5",
"slugify": "^1.6.6",
"tailwindcss": "^4.1.18"
"tailwindcss": "^4.1.18",
"tinacms": "^3.4.0",
"tinacms-gitprovider-github": "^4.0.7",
"zod": "^4.3.6"
},
"devDependencies": {
"@astrojs/check": "^0.9.6",
@@ -44,4 +61,4 @@
"typescript": "^5.9.3",
"typescript-eslint": "^8.51.0"
}
}
}

9916
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild

12
public/admin.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HEC IA Wiki - Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/admin/index.js"></script>
</body>
</html>

33
start-tina.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
# Script de démarrage TinaCMS pour HEC IA Wiki
# Usage: ./start-tina.sh [dev|prod]
set -e
MODE=${1:-dev}
if [ "$MODE" = "dev" ]; then
echo "🚀 Démarrage de TinaCMS + Astro en mode développement..."
echo "L'interface sera accessible sur http://localhost:4001/admin"
echo ""
pnpm tina:dev
elif [ "$MODE" = "prod" ]; then
echo "🚀 Démarrage de TinaCMS en mode production (self-hosted)..."
echo "Assurez-vous d'avoir configuré Pangolin et PocketID"
echo ""
pnpm tinacms dev --host 0.0.0.0 --port 4001
elif [ "$MODE" = "docker" ]; then
echo "🐳 Démarrage avec Docker Compose..."
docker-compose -f docker-compose.tina.yml up -d
echo ""
echo "TinaCMS est démarré sur http://localhost:4001"
echo "Pour voir les logs: docker-compose -f docker-compose.tina.yml logs -f"
else
echo "Usage: ./start-tina.sh [dev|prod|docker]"
echo ""
echo " dev - Mode développement local (Tina + Astro)"
echo " prod - Mode production (Tina seul, à sécuriser avec Pangolin)"
echo " docker - Démarrage via Docker Compose"
exit 1
fi

47
tina-backend/Dockerfile Normal file
View File

@@ -0,0 +1,47 @@
# Étape 1: Build de l'admin Tina
FROM node:24-alpine AS tina-builder
WORKDIR /app
# Copie des fichiers nécessaires pour Tina
COPY package*.json ./
COPY tina ./tina
COPY public ./public
COPY tsconfig.json ./
# Installation des dépendances
RUN npm install
# Build de l'admin Tina
RUN npx tinacms build
# Étape 2: Build du backend Fastify
FROM node:20-alpine AS backend-builder
WORKDIR /app
COPY tina-backend/package*.json ./
RUN npm install
COPY tina-backend/tsconfig.json ./
COPY tina-backend/src ./src
COPY tina-backend/drizzle.config.ts ./
RUN npm run build
# Étape 3: Image finale
FROM node:20-alpine AS runner
WORKDIR /app
# Copie du backend buildé
COPY --from=backend-builder /app/dist ./dist
COPY --from=backend-builder /app/node_modules ./node_modules
COPY --from=backend-builder /app/package*.json ./
# Copie de l'admin Tina buildé
COPY --from=tina-builder /app/admin ./admin
EXPOSE 3000
CMD ["node", "dist/index.js"]

View File

@@ -0,0 +1,10 @@
import type { Config } from "drizzle-kit";
export default {
schema: "./src/db/schema.ts",
out: "./migrations",
driver: "pg",
dbCredentials: {
connectionString: process.env.DATABASE_URL || "",
},
} satisfies Config;

View File

@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS tina_kv (
key VARCHAR(512) NOT NULL,
value JSONB NOT NULL,
namespace VARCHAR(100) NOT NULL DEFAULT 'main',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (key, namespace)
);
CREATE INDEX IF NOT EXISTS idx_namespace ON tina_kv(namespace);
CREATE INDEX IF NOT EXISTS idx_updated ON tina_kv(updated_at);
-- Fonction pour mise à jour automatique du updated_at
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_tina_kv_updated_at
BEFORE UPDATE ON tina_kv
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

33
tina-backend/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "tina-backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate"
},
"dependencies": {
"@fastify/cookie": "^9.3.1",
"@fastify/cors": "^9.0.1",
"@fastify/static": "^7.0.1",
"@tinacms/datalayer": "^1.2.0",
"abstract-level": "^1.0.4",
"drizzle-orm": "^0.30.0",
"fastify": "^4.26.0",
"iron-session": "^8.0.1",
"openid-client": "^5.6.0",
"pg": "^8.11.0",
"tinacms-gitprovider-github": "^1.0.0",
"zod": "^3.22.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/pg": "^8.11.0",
"drizzle-kit": "^0.20.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
}

View File

@@ -0,0 +1,147 @@
import { BackendAuthProvider } from "@tinacms/datalayer";
import { FastifyRequest, FastifyReply } from "fastify";
import { getSession } from "./session";
import { Issuer, generators } from "openid-client";
// Stockage temporaire des states (en production, utiliser Redis)
const stateStore = new Map<string, { timestamp: number }>();
// Nettoyage périodique des states expirés (5 minutes)
setInterval(() => {
const now = Date.now();
for (const [state, data] of stateStore.entries()) {
if (now - data.timestamp > 5 * 60 * 1000) {
stateStore.delete(state);
}
}
}, 60 * 1000);
async function getOIDCClient() {
const issuer = await Issuer.discover(process.env.OIDC_ISSUER!);
return new issuer.Client({
client_id: process.env.OIDC_CLIENT_ID!,
client_secret: process.env.OIDC_CLIENT_SECRET!,
redirect_uris: [process.env.OIDC_CALLBACK_URL!],
response_types: ["code"],
});
}
function generateState(): string {
const state = generators.state();
stateStore.set(state, { timestamp: Date.now() });
return state;
}
function validateState(state: string): boolean {
const data = stateStore.get(state);
if (!data) return false;
// Vérifier que le state n'est pas expiré (5 minutes)
if (Date.now() - data.timestamp > 5 * 60 * 1000) {
stateStore.delete(state);
return false;
}
stateStore.delete(state);
return true;
}
export const createOIDCBackendAuth = (): BackendAuthProvider => {
return {
isAuthorized: async (req: FastifyRequest, res: FastifyReply) => {
const session = await getSession(req, res);
if (!session.user) {
return {
isAuthorized: false,
errorMessage: "Unauthorized",
errorCode: 401,
};
}
return { isAuthorized: true };
},
extraRoutes: {
"/auth/login": {
secure: false,
handler: async (req, res) => {
const client = await getOIDCClient();
const state = generateState();
const authorizationUrl = client.authorizationUrl({
scope: process.env.OIDC_SCOPE || "openid profile email",
state,
});
res.redirect(authorizationUrl);
},
},
"/auth/callback": {
secure: false,
handler: async (req, res) => {
try {
const client = await getOIDCClient();
const params = client.callbackParams(req.raw);
// Vérifier le state pour prévenir les attaques CSRF
if (!params.state || !validateState(params.state)) {
return res.status(400).send({ error: "Invalid state parameter" });
}
// Échanger le code contre des tokens
const tokenSet = await client.callback(
process.env.OIDC_CALLBACK_URL!,
params,
{ state: params.state }
);
// Valider et extraire les claims
const claims = tokenSet.claims();
// Créer la session
const session = await getSession(req, res);
session.user = {
id: claims.sub,
email: claims.email || "",
name: claims.name || claims.preferred_username || "",
};
session.accessToken = tokenSet.access_token;
session.idToken = tokenSet.id_token;
await session.save();
// Rediriger vers l'admin
res.redirect("/admin");
} catch (error) {
console.error("OIDC callback error:", error);
res.status(500).send({ error: "Authentication failed" });
}
},
},
"/auth/logout": {
secure: false,
handler: async (req, res) => {
const session = await getSession(req, res);
session.destroy();
res.redirect("/admin");
},
},
"/api/me": {
secure: false,
handler: async (req, res) => {
const session = await getSession(req, res);
if (!session.user) {
return res.status(401).send({ error: "Not authenticated" });
}
return session.user;
},
},
},
};
};

View File

@@ -0,0 +1,27 @@
import { getIronSession, IronSessionOptions } from "iron-session";
import { FastifyRequest, FastifyReply } from "fastify";
export interface SessionData {
user?: {
id: string;
email: string;
name: string;
};
accessToken?: string;
idToken?: string;
}
const sessionOptions: IronSessionOptions = {
password: process.env.SESSION_SECRET!,
cookieName: "tina_session",
cookieOptions: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 jours
},
};
export async function getSession(req: FastifyRequest, res: FastifyReply) {
return getIronSession<SessionData>(req.raw, res.raw, sessionOptions);
}

View File

@@ -0,0 +1,28 @@
import { z } from "zod";
const configSchema = z.object({
// Database
DATABASE_URL: z.string().url(),
// OIDC Configuration
OIDC_ISSUER: z.string().url(),
OIDC_CLIENT_ID: z.string(),
OIDC_CLIENT_SECRET: z.string(),
OIDC_CALLBACK_URL: z.string().url(),
OIDC_SCOPE: z.string().default("openid profile email"),
// Session (Iron)
SESSION_SECRET: z.string().min(32),
// GitHub
GITHUB_TOKEN: z.string(),
GITHUB_REPO: z.string(),
GITHUB_OWNER: z.string(),
GITHUB_BRANCH: z.string().default("main"),
// Server
PORT: z.string().default("3000"),
NODE_ENV: z.enum(["development", "production"]).default("development"),
});
export const config = configSchema.parse(process.env);

View File

@@ -0,0 +1,234 @@
import { AbstractLevel, AbstractIterator, AbstractDatabaseOptions } from "abstract-level";
import { db } from "../db";
import { tinaKv } from "../db/schema";
import { eq, and, gte, lt, sql } from "drizzle-orm";
// Types
interface NodeCallback<T> {
(err: Error | null, result?: T): void;
}
interface IteratorOptions {
gt?: string;
gte?: string;
lt?: string;
lte?: string;
reverse?: boolean;
limit?: number;
keys?: boolean;
values?: boolean;
}
interface PostgresLevelOptions {
connectionString: string;
namespace?: string;
}
// Iterator personnalisé pour PostgreSQL
class PostgresIterator extends AbstractIterator<string, any> {
private namespace: string;
private options: IteratorOptions;
private results: { key: string; value: any }[] = [];
private currentIndex = 0;
private initialized = false;
constructor(db: any, options: IteratorOptions, namespace: string) {
super(db);
this.options = options;
this.namespace = namespace;
}
private async initialize(): Promise<void> {
if (this.initialized) return;
// Construire la requête
let query = db
.select({
key: tinaKv.key,
value: tinaKv.value,
})
.from(tinaKv)
.where(eq(tinaKv.namespace, this.namespace));
// Gestion des bounds
const conditions = [];
if (this.options.gt !== undefined) {
conditions.push(sql`${tinaKv.key} > ${this.options.gt}`);
}
if (this.options.gte !== undefined) {
conditions.push(sql`${tinaKv.key} >= ${this.options.gte}`);
}
if (this.options.lt !== undefined) {
conditions.push(sql`${tinaKv.key} < ${this.options.lt}`);
}
if (this.options.lte !== undefined) {
conditions.push(sql`${tinaKv.key} <= ${this.options.lte}`);
}
if (conditions.length > 0) {
query = query.where(and(...conditions, eq(tinaKv.namespace, this.namespace)));
}
// Ordre
if (this.options.reverse) {
query = query.orderBy(sql`${tinaKv.key} DESC`);
} else {
query = query.orderBy(tinaKv.key);
}
// Limite
if (this.options.limit !== undefined && this.options.limit > 0) {
query = query.limit(this.options.limit);
}
this.results = await query;
this.initialized = true;
}
async _next(callback: NodeCallback<{ key: string; value: any }>): Promise<void> {
try {
await this.initialize();
if (this.currentIndex >= this.results.length) {
return callback(null, undefined as any);
}
const item = this.results[this.currentIndex++];
// Gestion des options keys/values
const result: { key?: string; value?: any } = {};
if (this.options.keys !== false) {
result.key = item.key;
}
if (this.options.values !== false) {
result.value = item.value;
}
callback(null, result as { key: string; value: any });
} catch (error) {
callback(error as Error);
}
}
_seek(target: string): void {
this.currentIndex = this.results.findIndex(r => r.key >= target);
if (this.currentIndex < 0) {
this.currentIndex = this.results.length;
}
}
_end(callback: NodeCallback<void>): void {
this.results = [];
this.initialized = false;
callback(null);
}
}
// Classe principale PostgreSQL Level
export class PostgresLevel extends AbstractLevel<string, any> {
private namespace: string;
constructor(options: PostgresLevelOptions) {
// @ts-ignore - abstract-level constructor accepte des options
super({
keyEncoding: "utf8",
valueEncoding: "json",
});
this.namespace = options.namespace || "main";
}
async _put(key: string, value: any): Promise<void> {
await db
.insert(tinaKv)
.values({
key,
value,
namespace: this.namespace,
})
.onConflictDoUpdate({
target: [tinaKv.key, tinaKv.namespace],
set: { value },
});
}
async _get(key: string): Promise<any> {
const result = await db
.select({ value: tinaKv.value })
.from(tinaKv)
.where(and(eq(tinaKv.key, key), eq(tinaKv.namespace, this.namespace)))
.limit(1);
if (result.length === 0) {
const error = new Error(`Key not found: ${key}`) as Error & { notFound?: boolean; status?: number };
error.notFound = true;
error.status = 404;
throw error;
}
return result[0].value;
}
async _del(key: string): Promise<void> {
await db
.delete(tinaKv)
.where(and(eq(tinaKv.key, key), eq(tinaKv.namespace, this.namespace)));
}
async _batch(operations: Array<{ type: "put" | "del"; key: string; value?: any }>): Promise<void> {
// Utiliser une transaction pour les opérations batch
await db.transaction(async (tx) => {
for (const op of operations) {
if (op.type === "put") {
await tx
.insert(tinaKv)
.values({
key: op.key,
value: op.value,
namespace: this.namespace,
})
.onConflictDoUpdate({
target: [tinaKv.key, tinaKv.namespace],
set: { value: op.value },
});
} else if (op.type === "del") {
await tx
.delete(tinaKv)
.where(and(eq(tinaKv.key, op.key), eq(tinaKv.namespace, this.namespace)));
}
}
});
}
_iterator(options: IteratorOptions): PostgresIterator {
return new PostgresIterator(this, options, this.namespace);
}
async _clear(options: { gt?: string; gte?: string; lt?: string; lte?: string }): Promise<void> {
let query = db.delete(tinaKv).where(eq(tinaKv.namespace, this.namespace));
const conditions = [];
if (options.gt !== undefined) {
conditions.push(sql`${tinaKv.key} > ${options.gt}`);
}
if (options.gte !== undefined) {
conditions.push(sql`${tinaKv.key} >= ${options.gte}`);
}
if (options.lt !== undefined) {
conditions.push(sql`${tinaKv.key} < ${options.lt}`);
}
if (options.lte !== undefined) {
conditions.push(sql`${tinaKv.key} <= ${options.lte}`);
}
if (conditions.length > 0) {
query = db
.delete(tinaKv)
.where(and(...conditions, eq(tinaKv.namespace, this.namespace)));
}
await query;
}
}

View File

@@ -0,0 +1,9 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export const db = drizzle(pool, { schema });

View File

@@ -0,0 +1,11 @@
import { pgTable, varchar, jsonb, timestamp, primaryKey } from "drizzle-orm/pg-core";
export const tinaKv = pgTable("tina_kv", {
key: varchar("key", { length: 512 }).notNull(),
value: jsonb("value").notNull(),
namespace: varchar("namespace", { length: 100 }).notNull().default("main"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
}, (table) => ({
pk: primaryKey({ columns: [table.key, table.namespace] }),
}));

79
tina-backend/src/index.ts Normal file
View File

@@ -0,0 +1,79 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import cookie from "@fastify/cookie";
import staticFiles from "@fastify/static";
import path from "path";
import { fileURLToPath } from "url";
import { TinaNodeBackend, createDatabase } from "@tinacms/datalayer";
import { GitHubProvider } from "tinacms-gitprovider-github";
import { createOIDCBackendAuth } from "./auth/oidc-backend.js";
import { PostgresLevel } from "./database/postgres-adapter.js";
import { config } from "./config.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const fastify = Fastify({
logger: true,
});
// Plugins
await fastify.register(cors, {
origin: true,
credentials: true,
});
await fastify.register(cookie);
// Static files (Tina Admin build)
await fastify.register(staticFiles, {
root: path.join(__dirname, "../admin"),
prefix: "/admin/",
});
// Database Adapter
const dbAdapter = new PostgresLevel({
connectionString: config.DATABASE_URL,
namespace: config.GITHUB_BRANCH,
});
// Git Provider
const gitProvider = new GitHubProvider({
repo: config.GITHUB_REPO,
owner: config.GITHUB_OWNER,
token: config.GITHUB_TOKEN,
branch: config.GITHUB_BRANCH,
});
// Create Database
const database = createDatabase({
gitProvider,
databaseAdapter: dbAdapter,
});
// Tina Backend Handler
const tinaBackend = TinaNodeBackend({
authProvider: createOIDCBackendAuth(),
databaseClient: database,
});
// Routes
fastify.all("/api/tina/*", async (request, reply) => {
await tinaBackend(request.raw, reply.raw);
});
// Route racine pour /admin
fastify.get("/admin", async (request, reply) => {
return reply.sendFile("index.html");
});
// Health check
fastify.get("/health", async () => {
return { status: "ok" };
});
// Start server
fastify.listen({
port: parseInt(config.PORT),
host: "0.0.0.0",
});

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

4
tina/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
__generated__
node_modules
.env
*.log

View File

@@ -0,0 +1,27 @@
import { AbstractAuthProvider } from "tinacms";
export class OIDCAuthProvider extends AbstractAuthProvider {
async authenticate(): Promise<any> {
window.location.href = "/auth/login";
}
async getUser(): Promise<any> {
// Appel à une route /api/me pour récupérer l'utilisateur
const res = await fetch("/api/me", {
credentials: "include",
});
if (res.ok) {
return await res.json();
}
return null;
}
async getToken(): Promise<{ id_token: string }> {
// Le token est géré par les cookies, pas besoin de le retourner
return { id_token: "" };
}
async logout(): Promise<void> {
window.location.href = "/auth/logout";
}
}

617
tina/config.ts Normal file
View File

@@ -0,0 +1,617 @@
import { defineConfig, LocalAuthProvider } from "tinacms";
import { OIDCAuthProvider } from "./auth/oidc-provider";
const isLocal = process.env.TINA_PUBLIC_IS_LOCAL === "true";
export default defineConfig({
branch: "main",
// Self-hosted: pas de clientId/token
contentApiUrlOverride: isLocal
? undefined
: "https://tina.hec-ia.com/api/tina/gql",
authProvider: isLocal
? new LocalAuthProvider()
: new OIDCAuthProvider(),
build: {
outputFolder: "admin",
publicFolder: "public",
},
media: {
tina: {
mediaRoot: "uploads",
publicFolder: "public",
},
},
search: {
tina: {
indexerToken: "", // Mode local
stopwordLanguages: ["fra"],
},
},
schema: {
collections: [
{
name: "blog",
label: "Articles de blog",
path: "src/data/blog",
format: "md",
ui: {
filename: {
readonly: false,
slugify: (values) => {
return values?.title
?.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-");
},
},
},
fields: [
{
type: "string" as const,
name: "title",
label: "Titre",
isTitle: true,
required: true,
},
{
type: "string" as const,
name: "description",
label: "Description",
required: true,
ui: {
component: "textarea",
},
},
{
type: "string" as const,
name: "author",
label: "Auteur",
required: false,
},
{
type: "datetime" as const,
name: "pubDatetime",
label: "Date de publication",
required: true,
},
{
type: "datetime" as const,
name: "modDatetime",
label: "Date de modification",
required: false,
},
{
type: "string" as const,
name: "tags",
label: "Tags",
list: true,
required: false,
},
{
type: "boolean" as const,
name: "draft",
label: "Brouillon",
required: false,
},
{
type: "boolean" as const,
name: "featured",
label: "Mis en avant",
required: false,
},
{
type: "string" as const,
name: "canonicalURL",
label: "URL canonique",
required: false,
},
{
type: "boolean" as const,
name: "hideEditPost",
label: "Cacher le lien d'édition",
required: false,
},
{
type: "string" as const,
name: "timezone",
label: "Fuseau horaire",
required: false,
},
{
type: "image" as const,
name: "ogImage",
label: "Image Open Graph",
required: false,
},
{
type: "rich-text" as const,
name: "body",
label: "Contenu",
isBody: true,
},
],
},
{
name: "events",
label: "Événements",
path: "src/data/events",
format: "md",
ui: {
filename: {
readonly: false,
slugify: (values) => {
return values?.title
?.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-");
},
},
},
fields: [
{
type: "string" as const,
name: "title",
label: "Titre",
isTitle: true,
required: true,
},
{
type: "string" as const,
name: "description",
label: "Description",
required: true,
ui: {
component: "textarea",
},
},
{
type: "string" as const,
name: "author",
label: "Auteur",
required: false,
},
{
type: "datetime" as const,
name: "pubDatetime",
label: "Date de publication",
required: true,
},
{
type: "datetime" as const,
name: "modDatetime",
label: "Date de modification",
required: false,
},
{
type: "string" as const,
name: "tags",
label: "Tags",
list: true,
required: false,
},
{
type: "boolean" as const,
name: "draft",
label: "Brouillon",
required: false,
},
{
type: "boolean" as const,
name: "featured",
label: "Mis en avant",
required: false,
},
{
type: "string" as const,
name: "canonicalURL",
label: "URL canonique",
required: false,
},
{
type: "boolean" as const,
name: "hideEditPost",
label: "Cacher le lien d'édition",
required: false,
},
{
type: "string" as const,
name: "timezone",
label: "Fuseau horaire",
required: false,
},
{
type: "datetime" as const,
name: "eventDate",
label: "Date de l'événement",
required: true,
},
{
type: "datetime" as const,
name: "eventEndDate",
label: "Date de fin",
required: false,
},
{
type: "string" as const,
name: "location",
label: "Lieu",
required: false,
},
{
type: "string" as const,
name: "registrationLink",
label: "Lien d'inscription",
required: false,
},
{
type: "image" as const,
name: "ogImage",
label: "Image Open Graph",
required: false,
},
{
type: "rich-text" as const,
name: "body",
label: "Contenu",
isBody: true,
},
],
},
{
name: "workshops",
label: "Ateliers",
path: "src/data/workshops",
format: "md",
ui: {
filename: {
readonly: false,
slugify: (values) => {
return values?.title
?.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-");
},
},
},
fields: [
{
type: "string" as const,
name: "title",
label: "Titre",
isTitle: true,
required: true,
},
{
type: "string" as const,
name: "description",
label: "Description",
required: true,
ui: {
component: "textarea",
},
},
{
type: "string" as const,
name: "author",
label: "Auteur",
required: false,
},
{
type: "datetime" as const,
name: "pubDatetime",
label: "Date de publication",
required: true,
},
{
type: "datetime" as const,
name: "modDatetime",
label: "Date de modification",
required: false,
},
{
type: "string" as const,
name: "tags",
label: "Tags",
list: true,
required: false,
},
{
type: "boolean" as const,
name: "draft",
label: "Brouillon",
required: false,
},
{
type: "boolean" as const,
name: "featured",
label: "Mis en avant",
required: false,
},
{
type: "string" as const,
name: "canonicalURL",
label: "URL canonique",
required: false,
},
{
type: "boolean" as const,
name: "hideEditPost",
label: "Cacher le lien d'édition",
required: false,
},
{
type: "string" as const,
name: "timezone",
label: "Fuseau horaire",
required: false,
},
{
type: "datetime" as const,
name: "workshopDate",
label: "Date de l'atelier",
required: true,
},
{
type: "string" as const,
name: "duration",
label: "Durée",
required: false,
},
{
type: "string" as const,
name: "level",
label: "Niveau",
options: [
{ label: "Débutant", value: "beginner" },
{ label: "Intermédiaire", value: "intermediate" },
{ label: "Avancé", value: "advanced" },
],
required: false,
},
{
type: "string" as const,
name: "materials",
label: "Matériaux/Supports",
required: false,
},
{
type: "image" as const,
name: "ogImage",
label: "Image Open Graph",
required: false,
},
{
type: "rich-text" as const,
name: "body",
label: "Contenu",
isBody: true,
},
],
},
{
name: "news",
label: "Actualités",
path: "src/data/news",
format: "md",
ui: {
filename: {
readonly: false,
slugify: (values) => {
return values?.title
?.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-");
},
},
},
fields: [
{
type: "string" as const,
name: "title",
label: "Titre",
isTitle: true,
required: true,
},
{
type: "string" as const,
name: "description",
label: "Description",
required: true,
ui: {
component: "textarea",
},
},
{
type: "string" as const,
name: "author",
label: "Auteur",
required: false,
},
{
type: "datetime" as const,
name: "pubDatetime",
label: "Date de publication",
required: true,
},
{
type: "datetime" as const,
name: "modDatetime",
label: "Date de modification",
required: false,
},
{
type: "string" as const,
name: "tags",
label: "Tags",
list: true,
required: false,
},
{
type: "boolean" as const,
name: "draft",
label: "Brouillon",
required: false,
},
{
type: "boolean" as const,
name: "featured",
label: "Mis en avant",
required: false,
},
{
type: "string" as const,
name: "canonicalURL",
label: "URL canonique",
required: false,
},
{
type: "boolean" as const,
name: "hideEditPost",
label: "Cacher le lien d'édition",
required: false,
},
{
type: "string" as const,
name: "timezone",
label: "Fuseau horaire",
required: false,
},
{
type: "image" as const,
name: "ogImage",
label: "Image Open Graph",
required: false,
},
{
type: "rich-text" as const,
name: "body",
label: "Contenu",
isBody: true,
},
],
},
{
name: "technical",
label: "Articles techniques",
path: "src/data/technical",
format: "md",
ui: {
filename: {
readonly: false,
slugify: (values) => {
return values?.title
?.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-");
},
},
},
fields: [
{
type: "string" as const,
name: "title",
label: "Titre",
isTitle: true,
required: true,
},
{
type: "string" as const,
name: "description",
label: "Description",
required: true,
ui: {
component: "textarea",
},
},
{
type: "string" as const,
name: "author",
label: "Auteur",
required: false,
},
{
type: "datetime" as const,
name: "pubDatetime",
label: "Date de publication",
required: true,
},
{
type: "datetime" as const,
name: "modDatetime",
label: "Date de modification",
required: false,
},
{
type: "string" as const,
name: "tags",
label: "Tags",
list: true,
required: false,
},
{
type: "boolean" as const,
name: "draft",
label: "Brouillon",
required: false,
},
{
type: "boolean" as const,
name: "featured",
label: "Mis en avant",
required: false,
},
{
type: "string" as const,
name: "canonicalURL",
label: "URL canonique",
required: false,
},
{
type: "boolean" as const,
name: "hideEditPost",
label: "Cacher le lien d'édition",
required: false,
},
{
type: "string" as const,
name: "timezone",
label: "Fuseau horaire",
required: false,
},
{
type: "string" as const,
name: "difficulty",
label: "Difficulté",
options: [
{ label: "Débutant", value: "beginner" },
{ label: "Intermédiaire", value: "intermediate" },
{ label: "Avancé", value: "advanced" },
],
required: false,
},
{
type: "string" as const,
name: "readingTime",
label: "Temps de lecture",
required: false,
},
{
type: "image" as const,
name: "ogImage",
label: "Image Open Graph",
required: false,
},
{
type: "rich-text" as const,
name: "body",
label: "Contenu",
isBody: true,
},
],
},
],
},
});

1
tina/tina-lock.json Normal file

File diff suppressed because one or more lines are too long