Compare commits
2 Commits
tina
...
partial-im
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13eca9025d | ||
|
|
8cf29fc0b5 |
36
.env.example
36
.env.example
@@ -1,36 +0,0 @@
|
|||||||
|
|
||||||
# ============================================
|
|
||||||
# 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
|
|
||||||
75
.github/workflows/deploy-tina.yml
vendored
75
.github/workflows/deploy-tina.yml
vendored
@@ -1,75 +0,0 @@
|
|||||||
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
11
.gitignore
vendored
@@ -11,6 +11,8 @@ node_modules/
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
.pnpm-store
|
||||||
|
.hist
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
# environment variables
|
# environment variables
|
||||||
@@ -27,12 +29,3 @@ pnpm-debug.log*
|
|||||||
|
|
||||||
public/pagefind
|
public/pagefind
|
||||||
SETUP.md
|
SETUP.md
|
||||||
|
|
||||||
# TinaCMS
|
|
||||||
.tina/__generated__
|
|
||||||
admin
|
|
||||||
PLAN.md
|
|
||||||
QUICKSTART.md
|
|
||||||
TINA.md
|
|
||||||
.pnpm-store
|
|
||||||
.hist
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
services:
|
services:
|
||||||
# Service Astro existant
|
|
||||||
app:
|
app:
|
||||||
image: node:lts
|
image: node:lts
|
||||||
ports:
|
ports:
|
||||||
@@ -8,61 +7,3 @@ services:
|
|||||||
command: npm run dev -- --host 0.0.0.0
|
command: npm run dev -- --host 0.0.0.0
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/app
|
- ./:/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
|
|
||||||
|
|||||||
21
package.json
21
package.json
@@ -10,39 +10,22 @@
|
|||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"lint": "eslint .",
|
"lint": "eslint ."
|
||||||
"tinacms": "tinacms",
|
|
||||||
"tina:dev": "tinacms dev -c 'astro dev'",
|
|
||||||
"tina:build": "tinacms build"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/rss": "^4.0.14",
|
"@astrojs/rss": "^4.0.14",
|
||||||
"@astrojs/sitemap": "^3.6.0",
|
"@astrojs/sitemap": "^3.6.0",
|
||||||
"@fastify/cookie": "^11.0.2",
|
|
||||||
"@fastify/static": "^9.0.0",
|
|
||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tinacms/cli": "^2.1.3",
|
|
||||||
"@tinacms/datalayer": "^2.0.7",
|
|
||||||
"abstract-level": "^3.1.1",
|
|
||||||
"astro": "^5.16.6",
|
"astro": "^5.16.6",
|
||||||
"dayjs": "^1.11.19",
|
"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",
|
"lodash.kebabcase": "^4.1.1",
|
||||||
"openid-client": "^6.8.1",
|
|
||||||
"pg": "^8.18.0",
|
|
||||||
"remark-collapse": "^0.1.2",
|
"remark-collapse": "^0.1.2",
|
||||||
"remark-toc": "^9.0.0",
|
"remark-toc": "^9.0.0",
|
||||||
"satori": "^0.18.3",
|
"satori": "^0.18.3",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"slugify": "^1.6.6",
|
"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": {
|
"devDependencies": {
|
||||||
"@astrojs/check": "^0.9.6",
|
"@astrojs/check": "^0.9.6",
|
||||||
|
|||||||
9916
pnpm-lock.yaml
generated
9916
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
|||||||
onlyBuiltDependencies:
|
|
||||||
- esbuild
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# É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"]
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
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;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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 });
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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] }),
|
|
||||||
}));
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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",
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"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
4
tina/.gitignore
vendored
@@ -1,4 +0,0 @@
|
|||||||
__generated__
|
|
||||||
node_modules
|
|
||||||
.env
|
|
||||||
*.log
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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
617
tina/config.ts
@@ -1,617 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user