Merge branch 'main' into hosts-v2

hosts-v2
Pyjacpp 2026-09-05 17:18:23 +02:00
commit a7a5b59fdb
No known key found for this signature in database
GPG Key ID: ED479A5A26930939
46 changed files with 33776 additions and 207 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
result
*.qcow2

View File

@ -50,11 +50,11 @@
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"lastModified": 1787559586,
"narHash": "sha256-onL0VLf9vPllmT0H/OlURIU5r5t5WIEl7t4tVNKT0Nw=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"rev": "9d0d87172c374f89da73c1cfe6d81ae62feac1f1",
"type": "github"
},
"original": {
@ -86,11 +86,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1780902259,
"narHash": "sha256-q8yYEC5f1mFlQO9RGna4LTc9QrcvWunX6FYp83munkQ=",
"lastModified": 1787848966,
"narHash": "sha256-7gDpu5hpq0rOYnYMcOWqSzquK4HA/xU8Xf3CjUBOOJA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "bd0ff2d3eac24699c3664d5966b9ef36f388e2ca",
"rev": "d57af924f160a5084293c71c2043f058bd1cdb60",
"type": "github"
},
"original": {
@ -102,11 +102,11 @@
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1777168982,
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
"lastModified": 1785031560,
"narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
"rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c",
"type": "github"
},
"original": {
@ -145,11 +145,11 @@
]
},
"locked": {
"lastModified": 1780220602,
"narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=",
"lastModified": 1786901030,
"narHash": "sha256-WSFCsDSE5ffgD2MqzkM2CYjeFiKhRF/dJUN8uedb6YE=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "db947814a175b7ca6ded66e21383d938df01c227",
"rev": "27b3b12a8e6375f28ebe122f07d230ca5459bbfa",
"type": "github"
},
"original": {

View File

@ -38,68 +38,68 @@
./modules
agenix.nixosModules.default
];
get_hosts_names = path:
attrNames (
attrsets.filterAttrs (name: type: type == "directory")
(readDir path)
) ;
get_hosts_names =
path: attrNames (attrsets.filterAttrs (name: type: type == "directory") (readDir path));
hostVM = get_hosts_names ./hosts/vm;
hostPhysique = get_hosts_names ./hosts/physiques;
get_info =
path: lists:
filter (x: x != null)
(map (name:
let filePath = path + "/${name}/info.nix";
filter (x: x != null) (
map (
name:
let
filePath = path + "/${name}/info.nix";
in
if builtins.pathExists filePath then
# let eval = evalModules {
# specialArgs = inputs;
# modules = [
# ./modules/crans
# {
# crans = import filePath;
# }
# # agenix.nixosModules.default
# ];
# };
# in
# eval.config
{
crans = import filePath;
}
# let eval = evalModules {
# specialArgs = inputs;
# modules = [
# ./modules/crans
# {
# crans = import filePath;
# }
# # agenix.nixosModules.default
# ];
# };
# in
# eval.config
{
crans = import filePath;
}
else
warn "${toString filePath} not found" null
) lists
);
) lists
);
hosts_vm_info = get_info ./hosts/vm hostVM;
hosts_physique_info = get_info ./hosts/physiques hostPhysique;
listInfoToAttrs =
path: infos:
listToAttrs (
map (info:
{
name = info.crans.name;
value = info;
}
) infos)
;
attrsToNixosSystem = path: all_hosts: hosts:
mapAttrs (name: info:
listToAttrs (
map (info: {
name = info.crans.name;
value = info;
}) infos
);
attrsToNixosSystem =
path: all_hosts: hosts:
mapAttrs (
name: info:
nixosSystem {
specialArgs = { hosts = all_hosts; inherit inputs;};
modules = [(path + "/${name}")] ++ [info] ++ baseModules
;
specialArgs = {
hosts = all_hosts;
inherit inputs;
};
modules = [ (path + "/${name}") ] ++ [ info ] ++ baseModules;
}
) hosts;
) hosts;
attrs_vm_info = listInfoToAttrs ./hosts/vm hosts_vm_info;
attrs_physique_info = listInfoToAttrs ./hosts/physiques hosts_physique_info;
attrs_all_info = attrs_physique_info // attrs_vm_info;
in
(attrsToNixosSystem ./hosts/vm attrs_all_info attrs_vm_info)
//
{
cransIso = nixosSystem {
(attrsToNixosSystem ./hosts/vm attrs_all_info attrs_vm_info)
// {
cransIso = nixosSystem {
system = "x86_64-linux";
specialArgs = inputs;
modules = [

View File

@ -4,6 +4,7 @@
environment.systemPackages = with pkgs; [
imagemagick # Pour la compression des PDFs
];
services.stirling-pdf = {
enable = true;
environment = {
@ -17,6 +18,11 @@
};
};
systemd.services.stirling-pdf.serviceConfig = {
Restart = "always";
RestartSec = "5s";
};
services.nginx = {
enable = true;

View File

@ -0,0 +1,9 @@
<?php
$magicWords = [];
$magicWords['en'] = [
'calendar' => [ 0, 'calendar' ],
];
$magicWords['fr'] = [
'calendar' => [ 0, 'calendrier' ],
];
?>

View File

@ -0,0 +1,45 @@
{
"name": "FullCalendar",
"version": "0.1.0",
"author": [
"Crans"
],
"url": "",
"license-name": "GPL-3.0+",
"description": "",
"type": "parserhook",
"requires": {
"MediaWiki": ">= 1.39.0"
},
"ResourceModules": {
"ext.fullcalendar": {
"scripts": [
"ical.es5.cjs",
"fullcalendar7/fullcalendar.global.js",
"fullcalendar7/themes/breezy/global.js",
"fullcalendar7/locales-all/global.js",
"fullcalendar7/fullcalendar-ical.js",
"init.js"
],
"styles": [
"fullcalendar7/skeleton.css",
"fullcalendar7/themes/breezy/theme.css",
"fullcalendar7/themes/breezy/palettes/rose.css"
]
}
},
"ResourceFileModulePaths": {
"localBasePath": "resources",
"remoteExtPath": "fullcalendar/resources"
},
"AutoloadClasses": {
"FullCalendar": "includes/FullCalendar.php"
},
"ExtensionMessagesFiles": {
"FullCalendarMagic": "FullCalendarMagic.i18n.php"
},
"Hooks": {
"ParserFirstCallInit": "FullCalendar::onParserFirstCallInit"
},
"manifest_version": 2
}

View File

@ -0,0 +1,21 @@
<?php
use MediaWiki\Html\Html;
class FullCalendar {
// Register any render callbacks with the parser
public static function onParserFirstCallInit( Parser $parser ) {
// Create a function hook associating the <code>example</code> magic word with renderExample()
$parser->setFunctionHook( 'calendar', [ self::class, 'renderCalendar' ] );
}
public static function renderCalendar( Parser $parser, $param1 = '', ) {
$parser->getOutput()->addModules( [ 'ext.fullcalendar' ] );
$resultHtml = Html::element( 'div', [ 'class' => 'mw-crans-fullcalendar', 'data-ics' => $param1 ] );
return [ $resultHtml, 'noparse' => true, 'isHTML' => true ];
}
}
?>

View File

@ -0,0 +1,22 @@
MIT License
Copyright (c) 2026 Adam Shaw
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,237 @@
/*!
FullCalendar iCalendar Plugin v7.0.1
Docs & License: https://fullcalendar.io/docs/icalendar
(c) 2026 Adam Shaw
*/
(function (ICAL) {
'use strict';
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
var ICAL__default = /*#__PURE__*/_interopDefaultLegacy(ICAL);
function addDays(m, n) {
let a = dateToUtcArray(m);
a[2] += n;
return arrayToUtcDate(a);
}
function dateToUtcArray(date) {
return [
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
date.getUTCMilliseconds(),
];
}
function arrayToUtcDate(a) {
// according to web standards (and Safari), a month index is required.
// massage if only given a year.
if (a.length === 1) {
a = a.concat([0]);
}
return new Date(Date.UTC(...a));
}
/* eslint-disable */
class IcalExpander {
constructor(opts) {
this.maxIterations = opts.maxIterations != null ? opts.maxIterations : 1000;
this.skipInvalidDates = opts.skipInvalidDates != null ? opts.skipInvalidDates : false;
this.jCalData = ICAL__default["default"].parse(opts.ics);
this.component = new ICAL__default["default"].Component(this.jCalData);
this.events = this.component.getAllSubcomponents('vevent').map(vevent => new ICAL__default["default"].Event(vevent));
if (this.skipInvalidDates) {
this.events = this.events.filter((evt) => {
try {
evt.startDate.toJSDate();
evt.endDate.toJSDate();
return true;
}
catch (err) {
// skipping events with invalid time
return false;
}
});
}
}
between(after, before) {
function isEventWithinRange(startTime, endTime) {
return (!after || endTime >= after.getTime()) &&
(!before || startTime <= before.getTime());
}
function getTimes(eventOrOccurrence) {
const startTime = eventOrOccurrence.startDate.toJSDate().getTime();
let endTime = eventOrOccurrence.endDate.toJSDate().getTime();
// If it is an all day event, the end date is set to 00:00 of the next day
// So we need to make it be 23:59:59 to compare correctly with the given range
if (eventOrOccurrence.endDate.isDate && (endTime > startTime)) {
endTime -= 1;
}
return { startTime, endTime };
}
const exceptions = [];
this.events.forEach((event) => {
if (event.isRecurrenceException())
exceptions.push(event);
});
const ret = {
events: [],
occurrences: [],
};
this.events.filter(e => !e.isRecurrenceException()).forEach((event) => {
const exdates = [];
event.component.getAllProperties('exdate').forEach((exdateProp) => {
const exdate = exdateProp.getFirstValue();
exdates.push(exdate.toJSDate().getTime());
});
// Recurring event is handled differently
if (event.isRecurring()) {
const iterator = event.iterator();
let next;
let i = 0;
do {
i += 1;
next = iterator.next();
if (next) {
const occurrence = event.getOccurrenceDetails(next);
const { startTime, endTime } = getTimes(occurrence);
const isOccurrenceExcluded = exdates.indexOf(startTime) !== -1;
// TODO check that within same day?
const exception = exceptions.find(ex => ex.uid === event.uid && ex.recurrenceId.toJSDate().getTime() === occurrence.startDate.toJSDate().getTime());
// We have passed the max date, stop
if (before && startTime > before.getTime())
break;
// Check that we are within our range
if (isEventWithinRange(startTime, endTime)) {
if (exception) {
ret.events.push(exception);
}
else if (!isOccurrenceExcluded) {
ret.occurrences.push(occurrence);
}
}
}
} while (next && (!this.maxIterations || i < this.maxIterations));
return;
}
// Non-recurring event:
const { startTime, endTime } = getTimes(event);
if (isEventWithinRange(startTime, endTime))
ret.events.push(event);
});
return ret;
}
before(before) {
return this.between(undefined, before);
}
after(after) {
return this.between(after);
}
all() {
return this.between();
}
}
const eventSourceDef = {
parseMeta(refined) {
if (refined.url && refined.format === 'ics') {
return {
url: refined.url,
format: 'ics',
};
}
return null;
},
fetch(arg, successCallback, // any
errorCallback) {
let meta = arg.eventSource.meta;
let { internalState } = meta;
/*
NOTE: isRefetch is a HACK. we would do the recurring-expanding in a separate plugin hook,
but we couldn't leverage built-in allDay-guessing, among other things.
*/
if (!internalState || arg.isRefetch) {
internalState = meta.internalState = {
response: null,
iCalExpanderPromise: fetch(meta.url, { method: 'GET' }).then((response) => {
return response.text().then((icsText) => {
internalState.response = response;
return new IcalExpander({
ics: icsText,
skipInvalidDates: true,
});
});
}),
};
}
internalState.iCalExpanderPromise.then((iCalExpander) => {
successCallback({
rawEvents: expandICalEvents(iCalExpander, arg.range),
response: internalState.response,
});
}, errorCallback);
},
};
function expandICalEvents(iCalExpander, range) {
// expand the range. because our `range` is timeZone-agnostic UTC
// or maybe because ical.js always produces dates in local time? i forget
let rangeStart = addDays(range.start, -1);
let rangeEnd = addDays(range.end, 1);
let iCalRes = iCalExpander.between(rangeStart, rangeEnd); // end inclusive. will give extra results
let expanded = [];
// TODO: instead of using startDate/endDate.toString to communicate allDay,
// we can query startDate/endDate.isDate. More efficient to avoid formatting/reparsing.
// single events
for (let iCalEvent of iCalRes.events) {
expanded.push({
...buildNonDateProps(iCalEvent),
start: iCalEvent.startDate.toString(),
end: (specifiesEnd(iCalEvent) && iCalEvent.endDate)
? iCalEvent.endDate.toString()
: null,
});
}
// recurring event instances
for (let iCalOccurence of iCalRes.occurrences) {
let iCalEvent = iCalOccurence.item;
expanded.push({
...buildNonDateProps(iCalEvent),
start: iCalOccurence.startDate.toString(),
end: (specifiesEnd(iCalEvent) && iCalOccurence.endDate)
? iCalOccurence.endDate.toString()
: null,
});
}
return expanded;
}
function buildNonDateProps(iCalEvent) {
return {
title: iCalEvent.summary,
url: extractEventUrl(iCalEvent),
extendedProps: {
location: iCalEvent.location,
organizer: iCalEvent.organizer,
description: iCalEvent.description,
},
};
}
function extractEventUrl(iCalEvent) {
let urlProp = iCalEvent.component.getFirstProperty('url');
return urlProp ? urlProp.getFirstValue() : '';
}
function specifiesEnd(iCalEvent) {
return Boolean(iCalEvent.component.getFirstProperty('dtend')) ||
Boolean(iCalEvent.component.getFirstProperty('duration'));
}
var plugin = {
name: 'icalendar',
eventSourceDefs: [eventSourceDef],
};
FullCalendar.globalPlugins.push(plugin);
})(ICAL);

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,469 @@
:root {
--fc-sticky-header-footer-z: 3;
--fc-popover-z: 4;
}
.fc-11 {
z-index: var(--fc-popover-z) !important;
}
.fc-HY {
isolation: isolate;
}
/*
This will prevent all ancestors from customizing their box size unless they use .contentBox
*/
.fc-jz,
.fc-jz *,
.fc-jz *:before,
.fc-jz *:after {
box-sizing: border-box !important;
}
/* classes attached to <body> */
.fc-j2,
.fc-j2 * {
cursor: not-allowed !important;
}
.fc-Ki {
-ms-overflow-style: none !important; /* IE and Edge */
scrollbar-width: none !important; /* Firefox */
}
/* Hide scrollbar for Chrome, Safari and Opera */
.fc-Ki::-webkit-scrollbar {
display: none !important;
}
.fc-ST {
flex-shrink: 0 !important;
}
.fc-Z9 .fc-1g {
display: flex !important;
flex-direction: row !important;
flex-wrap: wrap !important;
}
/* HACK for Safari. Can't do break-inside:avoid with flexbox items, likely b/c it's not standard:
https://stackoverflow.com/a/60256345 */
.fc-HC .fc-1g > * {
float: left !important;
}
[dir=rtl] .fc-HC .fc-1g > *,
.fc-HC[dir=rtl] .fc-1g > * {
float: right !important;
}
.fc-HC .fc-1g::after {
content: "" !important;
display: block !important;
clear: both !important;
}
.fc-ZB {
cursor: pointer !important;
}
.fc-Ld {
cursor: n-resize !important;
}
.fc-lJ {
cursor: s-resize !important;
}
.fc-Zm {
cursor: w-resize !important;
}
[dir=rtl] .fc-Zm {
cursor: e-resize !important;
}
.fc-n5 {
cursor: e-resize !important;
}
[dir=rtl] .fc-n5 {
cursor: w-resize !important;
}
.fc-Px {
cursor: col-resize !important;
}
.fc-QK,
.fc-eh,
.fc-M9 {
position: absolute !important;
box-sizing: content-box !important;
width: 100% !important;
height: 100% !important;
}
.fc-QK {
padding: 10px !important;
margin: -10px !important;
}
.fc-eh {
padding-left: 10px !important;
padding-right: 10px !important;
margin-left: -10px !important;
margin-right: -10px !important;
}
.fc-M9 {
padding-top: 10px !important;
padding-bottom: 10px !important;
margin-top: -10px !important;
margin-bottom: -10px !important;
}
/* QUESTION: why not use -left -right instead of negative margins/padding for ALL? */
.fc-SC {
position: absolute;
top: 0;
bottom: 0;
left: -5px;
right: -5px;
}
.fc-hi {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
.fc-F0 {
visibility: hidden;
}
/* Border Utils */
/* ------------------------------------------------------------------------------------------------- */
/* TODO: break-out to x and y. don't have "only" all */
/* TODO: instead of "border" utils, call them "borderless" to subtract? */
.fc-6s {
border: 0 !important;
}
.fc-8B {
border-left: 0 !important;
border-right: 0 !important;
border-bottom: 0 !important;
}
.fc-RJ {
border-top: 0 !important;
border-left: 0 !important;
border-right: 0 !important;
}
.fc-Hm,
.fc-ge {
border-top: 0 !important;
border-bottom: 0 !important;
}
.fc-Hm {
border-inline-end: 0 !important;
}
.fc-ge {
border-inline-start: 0 !important;
}
.fc-KC {
border-left: 0 !important;
border-right: 0 !important;
}
.fc-mE {
border-top: 0 !important;
border-bottom: 0 !important;
}
/* for matching cell start-border, assumed to be 1px, which can't be guaranteed */
.fc-FA {
border-inline-start: 1px solid transparent !important;
}
/* Flexbox Utils */
/* ------------------------------------------------------------------------------------------------- */
.fc-5g {
display: flex !important;
flex-direction: row !important;
}
.fc-Fa {
display: flex !important;
flex-direction: column !important;
}
.fc-16 {
flex-grow: 1 !important;
}
.fc-Im {
flex-grow: 1 !important;
flex-basis: 0 !important;
min-width: 0 !important;
min-height: 0 !important;
}
.fc-tZ {
min-height: 0 !important;
}
/*
TODO: use liquidX/Y elsewhere because frees up min other dimension; less collisions
*/
.fc-Pw {
flex-grow: 1 !important;
flex-basis: 0 !important;
min-width: 0 !important;
}
/* Print-Safe Utils (media:screen ONLY) */
/* ------------------------------------------------------------------------------------------------- */
.fc-Z9 .fc-kE,
.fc-Z9 .fc-Xg {
display: flex !important;
flex-direction: column !important;
}
/* Table Utils */
/* ------------------------------------------------------------------------------------------------- */
.fc-np {
padding: 0 !important;
}
.fc-gd {
margin: 0 !important;
}
.fc-hB {
margin-top: 0 !important;
margin-bottom: 0 !important;
}
.fc-uk {
margin-left: 0 !important;
margin-right: 0 !important;
}
.fc-he {
white-space: nowrap !important;
}
.fc-Bv {
white-space: pre !important;
}
/* Misc Utils */
/* ------------------------------------------------------------------------------------------------- */
.fc-U2 {
overflow-anchor: none;
}
.fc-bB {
overflow: hidden !important;
}
/*
TODO: eventually use this on daygrid/timegrid events' inner, time, and title, FOR PRINT
Needed to prevent wrapping to multiple lines, increasing height, throwing off print-positioning,
which can't rely on dynamic height detection after print-flag activated.
*/
.fc-pp {
white-space: nowrap !important;
overflow: hidden !important;
}
.fc-8A {
position: relative !important;
}
.fc-7t {
position: absolute !important;
}
.fc-pK {
inset-inline-start: 0 !important;
}
.fc-wP {
position: absolute !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
}
.fc-yW,
.fc-5j {
position: absolute !important;
left: 0 !important;
right: 0 !important;
}
.fc-1K,
.fc-bH {
position: absolute !important;
top: 0 !important;
bottom: 0 !important;
}
.fc-yW {
top: 0 !important;
}
.fc-bH {
left: 0 !important;
right: 0 !important;
width: 0 !important;
}
@media not print {
.fc-dY {
position: sticky !important;
}
.fc-aX {
position: sticky !important;
top: 0 !important;
}
/* Sticks to either left or right (the flex "start") depending on ltr/rtl */
.fc-pZ {
position: sticky !important;
inset-inline-start: 0 !important;
}
.fc-Mw {
position: sticky !important;
top: 0 !important;
z-index: var(--fc-sticky-header-footer-z) !important;
}
}
/* Only needed when padding/border must be separate from width/height */
.fc-CU {
box-sizing: content-box !important;
}
.fc-jm {
position: absolute !important;
left: -10000px !important;
}
.fc-oO {
align-items: center !important;
}
.fc-MM {
align-items: flex-start !important;
}
.fc-Qk {
align-items: flex-end !important;
}
/* Footer Scrollbar */
/* ------------------------------------------------------------------------------------------------- */
.fc-Q6 {
position: sticky !important;
bottom: 0 !important;
z-index: var(--fc-sticky-header-footer-z) !important;
}
.fc-bD > * {
margin-top: -1px !important;
}
.fc-bD > * > * {
height: 1px !important;
}
/* Print-Safe Utils */
/* ------------------------------------------------------------------------------------------------- */
.fc-HC .fc-lF {
-moz-column-break-inside: avoid !important;
break-inside: avoid !important;
}
.fc-HC .fc-kE {
display: table !important;
table-layout: fixed !important;
width: 100% !important;
border-spacing: 0 !important;
border-collapse: separate !important;
}
.fc-HC .fc-Xg {
display: table-header-group !important;
-moz-column-break-inside: avoid !important;
break-inside: avoid !important;
background: #fff !important;
z-index: 9999 !important;
position: relative !important;
}
.fc-Vf {
/* min-height when multiple rows coexist, provides sane aspect-ratio for cells */
min-height: 6em !important;
}
/* Z-index */
/* ------------------------------------------------------------------------------------------------- */
.fc-BP {
z-index: 0;
}
.fc-XV {
z-index: 1;
}
.fc-Ah:focus-visible {
z-index: 2;
}
/* INTERNAL MARKER -- used to mark an element for internal significance */
/* ------------------------------------------------------------------------------------------------- */
.fc-vz {}
.fc-4B {}
.fc-D7 {}
.fc-wp {}
.fc-Ex {}
.fc-wl {}
.fc-yM {}
.fc-2f {}
.fc-zR {}
.fc-0j {}
.fc-M7 {}
.fc-tb {}
.fc-ew {}
.fc-IZ {}
.fc-UH {}

View File

@ -0,0 +1,387 @@
/*!
FullCalendar (Vanilla JS) v7.0.1
Docs & License: https://fullcalendar.io
(c) 2026 Adam Shaw
*/
(function ({ H: joinClassNames, u, S, G: globalPlugins }) {
// usually 11px font / 12px line-height
const xxsTextClass = "fc-breezy-vQz";
// outline
const outlineWidthClass = "fc-breezy-0Bj";
const outlineWidthFocusClass = "fc-breezy-uqo";
const outlineWidthGroupFocusClass = "fc-breezy-nPR";
const outlineOffsetClass = "fc-breezy-3Xj";
const primaryOutlineColorClass = "fc-breezy-yg7";
const primaryOutlineFocusClass = `${outlineWidthFocusClass} ${primaryOutlineColorClass}`;
// neutral buttons
const strongSolidPressableClass = joinClassNames("fc-breezy-hJa", "fc-breezy-AT7", "fc-breezy-b3B");
const mutedHoverClass = "fc-breezy-qiG";
const mutedHoverPressableClass = `${mutedHoverClass} fc-breezy-WfX`;
const faintHoverClass = "fc-breezy-1EL";
const faintHoverPressableClass = `${faintHoverClass} fc-breezy-qWF fc-breezy-UTk`;
// controls
const selectedClass = `fc-breezy-GfU fc-breezy-Oiq ${primaryOutlineFocusClass}`;
const unselectedClass = `fc-breezy-t4l fc-breezy-hnE ${primaryOutlineFocusClass}`;
// primary
const primaryClass = "fc-breezy-Anp fc-breezy-ECg";
const primaryPressableClass = `${primaryClass} fc-breezy-8mf`;
const primaryPressableGroupClass = `${primaryClass} fc-breezy-t8l`;
const primaryButtonClass = `${primaryPressableClass} fc-breezy-d0j ${primaryOutlineFocusClass} ${outlineOffsetClass}`;
// secondary
const secondaryClass = "fc-breezy-nyY fc-breezy-NK3";
const secondaryPressableClass = `${secondaryClass} fc-breezy-StK`;
const secondaryButtonClass = `${secondaryPressableClass} fc-breezy-nuP ${primaryOutlineFocusClass} fc-breezy-07j`;
const secondaryButtonIconClass = "fc-breezy-XUJ fc-breezy-J7Y fc-breezy-b7A fc-breezy-tRE";
// event content
const eventMutedFgClass = "fc-breezy-WLU";
const eventFaintBgClass = "fc-breezy-GjO";
const eventFaintPressableClass = joinClassNames(eventFaintBgClass, "fc-breezy-yE8", "fc-breezy-x2T");
// interactive neutral foregrounds
const mutedFgPressableGroupClass = "fc-breezy-t4l fc-breezy-Wxh fc-breezy-x7E";
// transparent resizer for mouse
const blockPointerResizerClass = "fc-breezy-1EY fc-breezy-pps fc-breezy-vs6";
const rowPointerResizerClass = `${blockPointerResizerClass} fc-breezy-AWB fc-breezy-hza`;
const columnPointerResizerClass = `${blockPointerResizerClass} fc-breezy-MaV fc-breezy-uuA`;
// circle resizer for touch
const blockTouchResizerClass = "fc-breezy-1EY fc-breezy-3wQ fc-breezy-wsy fc-breezy-lNM fc-breezy-gmc fc-breezy-AAA";
const rowTouchResizerClass = `${blockTouchResizerClass} fc-breezy-ERR fc-breezy-Dq8`;
const columnTouchResizerClass = `${blockTouchResizerClass} fc-breezy-1V6 fc-breezy-F99`;
const getNormalDayHeaderBorderClass = (info) => joinClassNames(!info.inPopover && (info.isMajor ? "fc-breezy-wsy fc-breezy-OFc" :
!info.isNarrow && "fc-breezy-wsy fc-breezy-EAo"));
const getMutedDayHeaderBorderClass = (info) => joinClassNames(!info.inPopover && (info.isMajor ? "fc-breezy-wsy fc-breezy-OFc" :
!info.isNarrow && "fc-breezy-wsy fc-breezy-tTN"));
const getNormalDayCellBorderColorClass = (info) => (info.isMajor ? "fc-breezy-OFc" : "fc-breezy-EAo");
const getMutedDayCellBorderColorClass = (info) => (info.isMajor ? "fc-breezy-OFc" : "fc-breezy-tTN");
const tallDayCellBottomClass = "fc-breezy-mhE";
const getShortDayCellBottomClass = (info) => joinClassNames(!info.isNarrow && "fc-breezy-toR");
const mutedHoverButtonClass = joinClassNames(mutedHoverPressableClass, outlineWidthFocusClass, primaryOutlineColorClass);
const dayRowCommonClasses = {
/* Day Row > List-Item Event
----------------------------------------------------------------------------------------------- */
listItemEventClass: (info) => joinClassNames("fc-breezy-Ika fc-breezy-7A6", info.isNarrow
? "fc-breezy-148 fc-breezy-Fvv"
: "fc-breezy-rVY fc-breezy-KzJ", info.isSelected
? "fc-breezy-pZQ"
: info.isInteractive
? mutedHoverPressableClass
: mutedHoverClass),
listItemEventInnerClass: (info) => joinClassNames("fc-breezy-dl1 fc-breezy-1sP fc-breezy-XpK fc-breezy-N2M", info.isNarrow
? `fc-breezy-z5u ${xxsTextClass}`
: "fc-breezy-2rx fc-breezy-a3B"),
listItemEventTimeClass: (info) => joinClassNames(info.isNarrow ? "fc-breezy-F1o" : "fc-breezy-oQ2", "fc-breezy-t4l fc-breezy-NPw fc-breezy-TZ4 fc-breezy-pKG fc-breezy-1Zl"),
listItemEventTitleClass: (info) => joinClassNames(info.isNarrow ? "fc-breezy-F1o" : "fc-breezy-oQ2", "fc-breezy-Oiq fc-breezy-1OT fc-breezy-TZ4 fc-breezy-pKG fc-breezy-OLq", info.timeText && "fc-breezy-IPx"),
/* Day Row > Row Event
----------------------------------------------------------------------------------------------- */
rowEventClass: (info) => joinClassNames(info.isStart && (info.isNarrow ? "fc-breezy-Jzj" : "fc-breezy-Wga"), info.isEnd && (info.isNarrow ? "fc-breezy-3e1" : "fc-breezy-KYn")),
rowEventInnerClass: (info) => info.isNarrow ? "fc-breezy-z5u" : "fc-breezy-2rx",
/* Day Row > More-Link
----------------------------------------------------------------------------------------------- */
rowMoreLinkClass: (info) => joinClassNames("fc-breezy-Ika fc-breezy-wsy", info.isNarrow
? "fc-breezy-148 fc-breezy-UIT fc-breezy-Fvv"
: "fc-breezy-sI7 fc-breezy-rVY fc-breezy-d0j fc-breezy-KzJ", mutedHoverPressableClass),
rowMoreLinkInnerClass: (info) => joinClassNames(info.isNarrow
? `fc-breezy-7A6 ${xxsTextClass}`
: "fc-breezy-KUX fc-breezy-a3B", "fc-breezy-Oiq"),
};
var index = {
name: "theme-breezy",
optionDefaults: {
className: (info) => joinClassNames("fc-breezy-gmc fc-breezy-n5m", !(info.borderlessTop || info.borderlessBottom || info.borderlessX) && "fc-breezy-hny"),
viewClass: (info) => {
const hasBorderTop = !info.options.headerToolbar && !info.borderlessTop;
const hasBorderBottom = !info.options.footerToolbar && !info.borderlessBottom;
const hasBorderX = !info.borderlessX;
return joinClassNames("fc-breezy-EAo", hasBorderTop && "fc-breezy-ku3", hasBorderBottom && "fc-breezy-zi1", hasBorderX && "fc-breezy-1Wx", (hasBorderTop && hasBorderX) && "fc-breezy-wko", (hasBorderBottom && hasBorderX) && "fc-breezy-L2o", !info.isHeightAuto && "fc-breezy-pKG");
},
/* Toolbar
--------------------------------------------------------------------------------------------- */
toolbarClass: (info) => joinClassNames("fc-breezy-KRz fc-breezy-OEz fc-breezy-nYK fc-breezy-dl1 fc-breezy-1sP fc-breezy-dNl fc-breezy-XpK fc-breezy-N2M fc-breezy-Pms fc-breezy-pKG fc-breezy-EAo", !info.borderlessX && "fc-breezy-1Wx"),
headerToolbarClass: (info) => joinClassNames("fc-breezy-zi1", !info.borderlessTop && "fc-breezy-ku3", !(info.borderlessTop || info.borderlessX) && "fc-breezy-wko"),
footerToolbarClass: (info) => joinClassNames("fc-breezy-ku3", !info.borderlessBottom && "fc-breezy-zi1", !(info.borderlessBottom || info.borderlessX) && "fc-breezy-L2o"),
toolbarSectionClass: "fc-breezy-yi0 fc-breezy-dl1 fc-breezy-1sP fc-breezy-XpK fc-breezy-Pms",
toolbarTitleClass: "fc-breezy-9ZS fc-breezy-C8a fc-breezy-Oiq",
buttonGroupClass: (info) => joinClassNames("fc-breezy-dl1 fc-breezy-1sP fc-breezy-XpK", !info.hasSelection && "fc-breezy-KzJ fc-breezy-eSM"),
buttonClass: (info) => joinClassNames("fc-breezy-bCs fc-breezy-dl6 fc-breezy-dl1 fc-breezy-1sP fc-breezy-XpK fc-breezy-9yp fc-breezy-Z9U", info.isIconOnly ? "fc-breezy-Nca" : "fc-breezy-Apf", info.buttonGroup?.hasSelection ? joinClassNames("fc-breezy-KzJ fc-breezy-1OT", info.isSelected
? selectedClass
: unselectedClass) : joinClassNames("fc-breezy-C8a", info.isPrimary
? primaryButtonClass
: secondaryButtonClass, info.buttonGroup
? "fc-breezy-Ps8 fc-breezy-H1W fc-breezy-g3A fc-breezy-7ss fc-breezy-JIC"
: "fc-breezy-KzJ fc-breezy-eSM fc-breezy-wsy")),
buttons: {
prev: {
iconContent: () => chevronDown(joinClassNames(secondaryButtonIconClass, "fc-breezy-z44 fc-breezy-keW")),
},
next: {
iconContent: () => chevronDown(joinClassNames(secondaryButtonIconClass, "fc-breezy-KxI fc-breezy-ZW3")),
},
prevYear: {
iconContent: () => chevronDoubleLeft(joinClassNames(secondaryButtonIconClass, "fc-breezy-asP"))
},
nextYear: {
iconContent: () => chevronDoubleLeft(joinClassNames(secondaryButtonIconClass, "fc-breezy-jmT fc-breezy-jY6"))
},
},
/* Abstract Event
--------------------------------------------------------------------------------------------- */
eventShortHeight: 50,
eventColor: "var(--fc-breezy-event)",
eventContrastColor: "var(--fc-breezy-event-contrast)",
eventClass: (info) => joinClassNames(info.isDragging && "fc-breezy-n5m", info.event.url && "fc-breezy-JiE", info.isSelected
? joinClassNames(outlineWidthClass, info.isDragging && "fc-breezy-tkw")
: outlineWidthFocusClass, primaryOutlineColorClass),
/* Background Event
--------------------------------------------------------------------------------------------- */
backgroundEventColor: "var(--fc-breezy-background-event)",
backgroundEventClass: "fc-breezy-aPk fc-breezy-jsy fc-breezy-DO7",
backgroundEventTitleClass: (info) => joinClassNames("fc-breezy-lMo fc-breezy-L1Y", info.isNarrow
? `fc-breezy-iS4 ${xxsTextClass}`
: "fc-breezy-3N5 fc-breezy-a3B", "fc-breezy-sI1"),
/* Block Event
--------------------------------------------------------------------------------------------- */
blockEventClass: (info) => joinClassNames("fc-breezy-bCs fc-breezy-eYX fc-breezy-vwH fc-breezy-d0j fc-breezy-DO7", info.isInteractive ? eventFaintPressableClass : eventFaintBgClass, (info.isDragging && !info.isSelected) && "fc-breezy-iTG"),
blockEventInnerClass: eventMutedFgClass,
blockEventTimeClass: "fc-breezy-TZ4 fc-breezy-pKG fc-breezy-1Zl",
blockEventTitleClass: "fc-breezy-TZ4 fc-breezy-pKG fc-breezy-OLq",
/* Row Event
--------------------------------------------------------------------------------------------- */
rowEventClass: (info) => joinClassNames("fc-breezy-Ika fc-breezy-JIC", info.isStart && joinClassNames("fc-breezy-3J4", info.isNarrow ? "fc-breezy-kmj" : "fc-breezy-QUg"), info.isEnd && joinClassNames("fc-breezy-USt", info.isNarrow ? "fc-breezy-Skl" : "fc-breezy-RNO")),
rowEventBeforeClass: (info) => joinClassNames(info.isStartResizable && joinClassNames(info.isSelected ? rowTouchResizerClass : rowPointerResizerClass, "fc-breezy-11a")),
rowEventAfterClass: (info) => joinClassNames(info.isEndResizable && joinClassNames(info.isSelected ? rowTouchResizerClass : rowPointerResizerClass, "fc-breezy-Tuc")),
rowEventInnerClass: (info) => joinClassNames("fc-breezy-dl1 fc-breezy-1sP fc-breezy-XpK", info.isNarrow ? xxsTextClass : "fc-breezy-a3B"),
rowEventTimeClass: (info) => joinClassNames(info.isNarrow ? "fc-breezy-a7i" : "fc-breezy-C2j", "fc-breezy-1OT"),
rowEventTitleClass: (info) => (info.isNarrow ? "fc-breezy-oQ2" : "fc-breezy-aCI"),
/* Column Event
--------------------------------------------------------------------------------------------- */
columnEventClass: (info) => joinClassNames("fc-breezy-1Wx fc-breezy-A3h fc-breezy-9Iz", info.isStart && joinClassNames("fc-breezy-ku3 fc-breezy-wko", info.isNarrow ? "fc-breezy-sEX" : "fc-breezy-zZM"), info.isEnd && joinClassNames("fc-breezy-zi1 fc-breezy-L2o", info.isNarrow ? "fc-breezy-Ika" : "fc-breezy-vUo")),
columnEventBeforeClass: (info) => joinClassNames(info.isStartResizable && joinClassNames(info.isSelected ? columnTouchResizerClass : columnPointerResizerClass, "fc-breezy-YDC")),
columnEventAfterClass: (info) => joinClassNames(info.isEndResizable && joinClassNames(info.isSelected ? columnTouchResizerClass : columnPointerResizerClass, "fc-breezy-fJL")),
columnEventInnerClass: (info) => joinClassNames("fc-breezy-dl1", info.isShort
? "fc-breezy-1sP fc-breezy-XpK fc-breezy-NWN fc-breezy-iS4"
: joinClassNames("fc-breezy-sgX", info.isNarrow ? "fc-breezy-aCI fc-breezy-2rx" : "fc-breezy-Nca fc-breezy-Jhn"), (info.isShort || info.isNarrow) ? xxsTextClass : "fc-breezy-a3B"),
columnEventTimeClass: (info) => (!info.isShort && (info.isNarrow ? "fc-breezy-166" : "fc-breezy-4dx")),
columnEventTitleClass: (info) => joinClassNames(!info.isShort && (info.isNarrow ? "fc-breezy-2rx" : "fc-breezy-Jhn"), "fc-breezy-C8a"),
/* More-Link
--------------------------------------------------------------------------------------------- */
moreLinkClass: `${outlineWidthFocusClass} ${primaryOutlineColorClass}`,
moreLinkInnerClass: "fc-breezy-TZ4 fc-breezy-pKG",
columnMoreLinkClass: (info) => joinClassNames(info.isNarrow ? "fc-breezy-SEP" : "fc-breezy-V9v", `fc-breezy-wsy fc-breezy-d0j fc-breezy-4MR fc-breezy-KzJ ${strongSolidPressableClass} fc-breezy-vwH fc-breezy-A3h fc-breezy-9Iz`),
columnMoreLinkInnerClass: (info) => joinClassNames(info.isNarrow
? `fc-breezy-KUX ${xxsTextClass}`
: "fc-breezy-iS4 fc-breezy-a3B", "fc-breezy-sI1"),
/* Day Header
--------------------------------------------------------------------------------------------- */
dayHeaderAlign: (info) => info.inPopover ? "start" : "center",
dayHeaderClass: (info) => joinClassNames("fc-breezy-E9P", info.inPopover && "fc-breezy-zi1 fc-breezy-EAo fc-breezy-nYK"),
dayHeaderInnerClass: (info) => joinClassNames("fc-breezy-dl1 fc-breezy-1sP fc-breezy-XpK", (!info.dayNumberText && !info.inPopover)
? joinClassNames("fc-breezy-Jhn fc-breezy-Fvv fc-breezy-a3B", info.isNarrow
? "fc-breezy-aCI fc-breezy-gMS fc-breezy-t4l"
: "fc-breezy-ZrE fc-breezy-bvX fc-breezy-C8a fc-breezy-sI1", info.hasNavLink && mutedHoverButtonClass)
: (info.isToday && info.dayNumberText && !info.inPopover)
? joinClassNames("fc-breezy-bCs fc-breezy-bvX fc-breezy-hS8", info.isNarrow ? "fc-breezy-TFV" : "fc-breezy-I1A")
: joinClassNames("fc-breezy-Fvv", info.inPopover
? "fc-breezy-bvX fc-breezy-aCI fc-breezy-2rx"
: joinClassNames("fc-breezy-fn8 fc-breezy-TFV fc-breezy-ZrE", info.isNarrow ? "fc-breezy-2tF" : "fc-breezy-X6C"), info.hasNavLink && mutedHoverButtonClass)),
dayHeaderContent: (info) => ((!info.dayNumberText && !info.inPopover) ? (u(S, { children: info.text })) : (u(S, { children: info.textParts.map((textPart, i) => (u("span", { className: joinClassNames("fc-breezy-jm6", info.isNarrow ? "fc-breezy-a3B" : "fc-breezy-9yp", textPart.type === "day"
? joinClassNames("fc-breezy-dl1 fc-breezy-1sP fc-breezy-XpK", !info.isNarrow && "fc-breezy-C8a", (info.isToday && !info.inPopover)
? joinClassNames("fc-breezy-cKZ fc-breezy-AAA fc-breezy-E9P", info.isNarrow ? "fc-breezy-MSG" : "fc-breezy-n6w", info.hasNavLink
? `${primaryPressableGroupClass} ${outlineWidthGroupFocusClass} ${outlineOffsetClass} ${primaryOutlineColorClass}`
: primaryClass)
: "fc-breezy-Oiq")
: "fc-breezy-t4l"), children: textPart.value }, i))) }))),
/* Day Cell
--------------------------------------------------------------------------------------------- */
dayCellClass: (info) => joinClassNames("fc-breezy-wsy", ((info.isOther || info.isDisabled) && !info.options.businessHours) && "fc-breezy-nYK"),
dayCellTopClass: (info) => joinClassNames(info.isNarrow ? "fc-breezy-84e" : "fc-breezy-p7s", "fc-breezy-dl1 fc-breezy-1sP"),
dayCellTopInnerClass: (info) => joinClassNames("fc-breezy-dl1 fc-breezy-1sP fc-breezy-XpK fc-breezy-E9P fc-breezy-TZ4", info.isNarrow
? `fc-breezy-SEP fc-breezy-oM6 ${xxsTextClass}`
: "fc-breezy-V9v fc-breezy-TFV fc-breezy-a3B", info.isToday
? joinClassNames("fc-breezy-AAA fc-breezy-C8a", info.isNarrow ? "fc-breezy-qvL" : "fc-breezy-Wga", info.text === info.dayNumberText
? (info.isNarrow ? "fc-breezy-79F" : "fc-breezy-ilz")
: (info.isNarrow ? "fc-breezy-aCI" : "fc-breezy-Nca"), info.hasNavLink
? `${primaryPressableClass} ${outlineOffsetClass}`
: primaryClass)
: joinClassNames("fc-breezy-Skl", info.isNarrow ? "fc-breezy-aCI" : "fc-breezy-Nca", info.hasNavLink && mutedHoverPressableClass, info.isOther
? "fc-breezy-mNS"
: (info.monthText ? "fc-breezy-sI1" : "fc-breezy-t4l"), info.monthText && "fc-breezy-DIS")),
dayCellInnerClass: (info) => joinClassNames(info.inPopover && "fc-breezy-3N5"),
/* Popover
--------------------------------------------------------------------------------------------- */
popoverClass: "fc-breezy-uhF fc-breezy-wsy fc-breezy-LDJ fc-breezy-hny fc-breezy-pKG fc-breezy-1kP fc-breezy-gMS fc-breezy-aNc fc-breezy-n5m",
popoverCloseClass: `fc-breezy-bCs fc-breezy-1EY fc-breezy-SKv fc-breezy-aYN fc-breezy-KUX fc-breezy-Fvv ${mutedHoverButtonClass} fc-breezy-Z9U`,
popoverCloseContent: () => x(`fc-breezy-XUJ ${mutedFgPressableGroupClass}`),
/* Lane
--------------------------------------------------------------------------------------------- */
dayLaneClass: (info) => joinClassNames("fc-breezy-wsy", info.isMajor ? "fc-breezy-OFc" : "fc-breezy-tTN", info.isDisabled && "fc-breezy-nYK"),
dayLaneInnerClass: (info) => (info.isStack
? "fc-breezy-gMS"
: info.isNarrow ? "fc-breezy-148" : "fc-breezy-rVY"),
slotLaneClass: (info) => joinClassNames("fc-breezy-wsy fc-breezy-tTN", info.isMinor && "fc-breezy-TN2"),
/* List Day
--------------------------------------------------------------------------------------------- */
listDaysClass: "fc-breezy-8T7 fc-breezy-g0K fc-breezy-6u7 fc-breezy-E7b fc-breezy-KRz",
listDayClass: (info) => joinClassNames(!info.isLast && "fc-breezy-zi1 fc-breezy-tTN", "fc-breezy-dl1 fc-breezy-1sP fc-breezy-EF4 fc-breezy-tgZ"),
listDayHeaderClass: "fc-breezy-SEP fc-breezy-yi0 fc-breezy-vVE fc-breezy-kMV fc-breezy-MKw fc-breezy-dl1 fc-breezy-sgX fc-breezy-EF4",
listDayHeaderInnerClass: (info) => joinClassNames("fc-breezy-cJ3 fc-breezy-2rx fc-breezy-Nca fc-breezy-PtF fc-breezy-AAA fc-breezy-9yp", !info.level
? joinClassNames(info.isToday
? joinClassNames("fc-breezy-C8a", info.hasNavLink ? primaryPressableClass : primaryClass)
: joinClassNames("fc-breezy-1OT fc-breezy-Oiq", info.hasNavLink && mutedHoverPressableClass))
: joinClassNames("fc-breezy-mNS", info.hasNavLink && `${mutedHoverPressableClass} fc-breezy-i3P`)),
listDayBodyClass: "fc-breezy-9Qs fc-breezy-1El fc-breezy-2KU fc-breezy-wsy fc-breezy-EAo fc-breezy-KzJ",
/* Single Month (in Multi-Month)
--------------------------------------------------------------------------------------------- */
singleMonthClass: (info) => joinClassNames(info.multiMonthColumns > 1 && "fc-breezy-jD5", (info.multiMonthColumns === 1 && !info.isLast) && "fc-breezy-zi1 fc-breezy-EAo"),
singleMonthHeaderClass: (info) => joinClassNames(info.multiMonthColumns > 1
? "fc-breezy-x96"
: "fc-breezy-End fc-breezy-gmc fc-breezy-zi1 fc-breezy-EAo", "fc-breezy-XpK"),
singleMonthHeaderInnerClass: (info) => joinClassNames("fc-breezy-Jhn fc-breezy-Nca fc-breezy-KzJ fc-breezy-9yp fc-breezy-Oiq fc-breezy-C8a", info.hasNavLink && mutedHoverPressableClass),
/* Misc Table
--------------------------------------------------------------------------------------------- */
tableHeaderClass: "fc-breezy-gmc",
fillerClass: "fc-breezy-wsy fc-breezy-tTN",
dayNarrowWidth: 100,
dayHeaderRowClass: "fc-breezy-wsy fc-breezy-tTN",
dayRowClass: "fc-breezy-wsy fc-breezy-EAo",
slotHeaderRowClass: "fc-breezy-wsy fc-breezy-EAo",
slotHeaderInnerClass: "fc-breezy-mNS fc-breezy-XHd",
/* Misc Content
--------------------------------------------------------------------------------------------- */
navLinkClass: `${outlineWidthFocusClass} ${primaryOutlineColorClass}`,
inlineWeekNumberClass: (info) => joinClassNames("fc-breezy-1EY fc-breezy-n9G fc-breezy-iD1 fc-breezy-gmc fc-breezy-t4l fc-breezy-TZ4 fc-breezy-4In fc-breezy-zi1 fc-breezy-k8g fc-breezy-3J4 fc-breezy-Hhp", info.isNarrow
? `fc-breezy-KUX ${xxsTextClass}`
: "fc-breezy-XJa fc-breezy-a3B", info.hasNavLink
? `${mutedHoverPressableClass} fc-breezy-07j`
: mutedHoverClass),
highlightClass: "fc-breezy-xAy",
nonBusinessHoursClass: "fc-breezy-nYK",
nowIndicatorLineClass: "fc-breezy-CH7 fc-breezy-qQW fc-breezy-D9l",
nowIndicatorDotClass: "fc-breezy-aAW fc-breezy-Vpk fc-breezy-D9l fc-breezy-63n fc-breezy-AAA fc-breezy-GBJ fc-breezy-9Iz",
/* Resource Day Header
--------------------------------------------------------------------------------------------- */
resourceDayHeaderAlign: "center",
resourceDayHeaderClass: "fc-breezy-wsy",
resourceDayHeaderInnerClass: (info) => joinClassNames("fc-breezy-bvX fc-breezy-sI1 fc-breezy-C8a", info.isNarrow ? "fc-breezy-a3B" : "fc-breezy-9yp"),
/* Resource Data Grid
--------------------------------------------------------------------------------------------- */
resourceColumnHeaderClass: "fc-breezy-wsy fc-breezy-tTN fc-breezy-E9P",
resourceColumnHeaderInnerClass: "fc-breezy-bvX fc-breezy-sI1 fc-breezy-9yp",
resourceColumnResizerClass: "fc-breezy-1EY fc-breezy-AWB fc-breezy-4Tv fc-breezy-dnf",
resourceGroupHeaderClass: "fc-breezy-wsy fc-breezy-EAo fc-breezy-pZQ",
resourceGroupHeaderInnerClass: "fc-breezy-bvX fc-breezy-sI1 fc-breezy-9yp",
resourceCellClass: "fc-breezy-wsy fc-breezy-tTN",
resourceCellInnerClass: "fc-breezy-bvX fc-breezy-sI1 fc-breezy-9yp",
resourceIndentClass: "fc-breezy-Wga fc-breezy-p9t fc-breezy-E9P",
resourceExpanderClass: `fc-breezy-bCs fc-breezy-KUX fc-breezy-AAA ${mutedHoverPressableClass} ${outlineWidthFocusClass} ${primaryOutlineColorClass}`,
resourceExpanderContent: (info) => chevronDown(joinClassNames(`fc-breezy-XUJ ${mutedFgPressableGroupClass}`, !info.isExpanded && "fc-breezy-KxI fc-breezy-ZW3")),
resourceHeaderRowClass: "fc-breezy-wsy fc-breezy-EAo",
resourceRowClass: "fc-breezy-wsy fc-breezy-EAo",
resourceColumnDividerClass: "fc-breezy-USt fc-breezy-OFc",
/* Timeline Lane
--------------------------------------------------------------------------------------------- */
resourceGroupLaneClass: "fc-breezy-wsy fc-breezy-EAo fc-breezy-pZQ",
resourceLaneClass: "fc-breezy-wsy fc-breezy-EAo",
resourceLaneBottomClass: (info) => joinClassNames(info.options.eventOverlap && "fc-breezy-uuA"),
timelineBottomClass: "fc-breezy-uuA",
},
views: {
dayGrid: {
...dayRowCommonClasses,
dayHeaderClass: getNormalDayHeaderBorderClass,
dayHeaderDividerClass: "fc-breezy-zi1 fc-breezy-OFc",
dayCellClass: getNormalDayCellBorderColorClass,
dayCellBottomClass: getShortDayCellBottomClass,
backgroundEventInnerClass: "fc-breezy-dl1 fc-breezy-1sP fc-breezy-LMv",
},
multiMonth: {
...dayRowCommonClasses,
dayHeaderClass: getNormalDayHeaderBorderClass,
dayHeaderDividerClass: (info) => joinClassNames(info.multiMonthColumns === 1 && "fc-breezy-zi1 fc-breezy-OFc fc-breezy-qNs"),
dayCellClass: getNormalDayCellBorderColorClass,
dayCellBottomClass: getShortDayCellBottomClass,
tableBodyClass: (info) => joinClassNames(info.multiMonthColumns > 1 && "fc-breezy-wsy fc-breezy-EAo fc-breezy-KzJ fc-breezy-eSM fc-breezy-pKG"),
},
timeGrid: {
...dayRowCommonClasses,
dayHeaderClass: getMutedDayHeaderBorderClass,
dayHeaderDividerClass: (info) => joinClassNames("fc-breezy-zi1", info.options.allDaySlot
? "fc-breezy-EAo"
: "fc-breezy-OFc fc-breezy-rf6"),
dayCellClass: getMutedDayCellBorderColorClass,
dayCellBottomClass: tallDayCellBottomClass,
/* TimeGrid > Week Number Header
------------------------------------------------------------------------------------------- */
weekNumberHeaderClass: "fc-breezy-XpK fc-breezy-LMv",
weekNumberHeaderInnerClass: (info) => joinClassNames("fc-breezy-cGD fc-breezy-TFV fc-breezy-ZrE fc-breezy-t4l fc-breezy-Fvv fc-breezy-dl1 fc-breezy-1sP fc-breezy-XpK", info.hasNavLink && mutedHoverPressableClass, info.isNarrow ? "fc-breezy-a3B" : "fc-breezy-9yp"),
/* TimeGrid > All-Day Header
------------------------------------------------------------------------------------------- */
allDayHeaderClass: "fc-breezy-XpK",
allDayHeaderInnerClass: (info) => joinClassNames("fc-breezy-rUb fc-breezy-mNS", info.isNarrow ? xxsTextClass : "fc-breezy-a3B"),
allDayDividerClass: "fc-breezy-zi1 fc-breezy-OFc fc-breezy-rf6",
/* TimeGrid > Slot Header
------------------------------------------------------------------------------------------- */
slotHeaderClass: "fc-breezy-LMv",
slotHeaderInnerClass: (info) => joinClassNames("fc-breezy-eYX fc-breezy-GFf fc-breezy-2tF", info.isNarrow
? `fc-breezy-Cy2 ${xxsTextClass}`
: "fc-breezy-uqG fc-breezy-a3B", info.isFirst && "fc-breezy-pps"),
slotHeaderDividerClass: "fc-breezy-USt fc-breezy-tTN",
},
list: {
/* List-View > List-Item Event
------------------------------------------------------------------------------------------- */
listItemEventClass: (info) => joinClassNames("fc-breezy-bCs fc-breezy-lqx fc-breezy-XpK fc-breezy-wwb", !info.isLast && "fc-breezy-zi1 fc-breezy-tTN", info.isInteractive
? faintHoverPressableClass
: faintHoverClass),
listItemEventBeforeClass: "fc-breezy-5JF fc-breezy-lNM fc-breezy-AAA",
listItemEventInnerClass: "fc-breezy-dl1 fc-breezy-1sP fc-breezy-XpK fc-breezy-wwb fc-breezy-9yp",
listItemEventTimeClass: "fc-breezy-yi0 fc-breezy-roZ fc-breezy-kMV fc-breezy-TZ4 fc-breezy-pKG fc-breezy-IPx fc-breezy-t4l",
listItemEventTitleClass: (info) => joinClassNames("fc-breezy-1El fc-breezy-2KU fc-breezy-1OT fc-breezy-TZ4 fc-breezy-pKG fc-breezy-sI1", info.event.url && "fc-breezy-Ogp"),
/* No-Events Screen
------------------------------------------------------------------------------------------- */
noEventsClass: "fc-breezy-1El fc-breezy-dl1 fc-breezy-sgX fc-breezy-XpK fc-breezy-E9P",
noEventsInnerClass: "fc-breezy-P9h fc-breezy-t4l",
},
resourceDayGrid: {
resourceDayHeaderClass: (info) => (info.isMajor
? "fc-breezy-OFc"
: "fc-breezy-EAo"),
},
resourceTimeGrid: {
resourceDayHeaderClass: (info) => (info.isMajor
? "fc-breezy-OFc"
: "fc-breezy-tTN"),
},
timeline: {
/* Timeline > Row Event
------------------------------------------------------------------------------------------- */
rowEventClass: (info) => info.isEnd && "fc-breezy-9hC",
rowEventInnerClass: (info) => info.options.eventOverlap ? "fc-breezy-Jhn" : "fc-breezy-dl6",
/* Timeline > More-Link
------------------------------------------------------------------------------------------- */
rowMoreLinkClass: `fc-breezy-9hC fc-breezy-Ika fc-breezy-wsy fc-breezy-d0j fc-breezy-4MR fc-breezy-KzJ ${strongSolidPressableClass} fc-breezy-vwH`,
rowMoreLinkInnerClass: "fc-breezy-iS4 fc-breezy-sI1 fc-breezy-a3B",
/* Timeline > Slot Header
------------------------------------------------------------------------------------------- */
slotHeaderAlign: (info) => info.isTime ? "start" : "center",
slotHeaderClass: (info) => joinClassNames(info.level > 0 && "fc-breezy-wsy fc-breezy-tTN", "fc-breezy-LMv"),
slotHeaderInnerClass: (info) => joinClassNames("fc-breezy-GFf fc-breezy-2tF fc-breezy-a3B", info.isTime && joinClassNames("fc-breezy-eYX fc-breezy-OAt", info.isFirst && "fc-breezy-pps"), info.hasNavLink && "fc-breezy-Eu0"),
slotHeaderDividerClass: "fc-breezy-zi1 fc-breezy-OFc fc-breezy-qNs",
},
},
};
/* SVGs
------------------------------------------------------------------------------------------------- */
function chevronDown(className) {
return u("svg", { className: className, xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 20 20", fill: "currentColor", children: u("path", { fillRule: "evenodd", d: "M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z", clipRule: "evenodd" }) });
}
function chevronDoubleLeft(className) {
return u("svg", { className: className, xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 20 20", fill: "currentColor", children: u("path", { fillRule: "evenodd", d: "M4.72 9.47a.75.75 0 0 0 0 1.06l4.25 4.25a.75.75 0 1 0 1.06-1.06L6.31 10l3.72-3.72a.75.75 0 1 0-1.06-1.06L4.72 9.47Zm9.25-4.25L9.72 9.47a.75.75 0 0 0 0 1.06l4.25 4.25a.75.75 0 1 0 1.06-1.06L11.31 10l3.72-3.72a.75.75 0 0 0-1.06-1.06Z", clipRule: "evenodd" }) });
}
function x(className) {
return u("svg", { className: className, xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 20 20", fill: "currentColor", children: u("path", { d: "M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" }) });
}
globalPlugins.push(index);
})(FullCalendar.Shared);

View File

@ -0,0 +1,132 @@
:root {
/* primary */
--fc-breezy-primary: #AD1F1F;
--fc-breezy-primary-over: #AD1F1F;
--fc-breezy-primary-foreground: #ffffff;
/* secondary */
--fc-breezy-secondary: var(--fc-breezy-background);
--fc-breezy-secondary-over: var(--fc-breezy-faint);
--fc-breezy-secondary-border: var(--fc-breezy-strong-border);
--fc-breezy-secondary-foreground: var(--fc-breezy-strong-foreground);
--fc-breezy-secondary-icon: var(--fc-breezy-faint-foreground);
--fc-breezy-secondary-icon-over: var(--fc-breezy-muted-foreground);
/* calendar content */
--fc-breezy-event: #1FAD95;
--fc-breezy-event-contrast: #fff;
--fc-breezy-background-event: #1F95AD;
--fc-breezy-highlight: #ef444414;
--fc-breezy-now: #ef4444;
/* controls */
--fc-breezy-selected: var(--fc-breezy-strong);
/* popover */
--fc-breezy-popover: var(--fc-breezy-background);
--fc-breezy-popover-border: var(--fc-breezy-strong-border);
/* neutral backgrounds */
--fc-breezy-background: #ffffff;
--fc-breezy-faint: #00000005;
--fc-breezy-muted: #0000000D;
--fc-breezy-strong: #0000001A;
--fc-breezy-stronger: #00000024;
--fc-breezy-strongest: #0000002E;
/* neutral foregrounds */
--fc-breezy-foreground: #374151;
--fc-breezy-faint-foreground: #9ca3af;
--fc-breezy-muted-foreground: #6b7280;
--fc-breezy-strong-foreground: #111827;
/* neutral borders */
--fc-breezy-border: #e5e7eb;
--fc-breezy-muted-border: #f3f4f6;
--fc-breezy-strong-border: #d1d5db;
}
@media not print {
:root.skin-theme-clientpref-night {
/* secondary */
--fc-breezy-secondary: var(--fc-breezy-muted);
--fc-breezy-secondary-over: var(--fc-breezy-strong);
--fc-breezy-secondary-border: var(--fc-breezy-muted-border);
--fc-breezy-secondary-foreground: var(--fc-breezy-strong-foreground);
--fc-breezy-secondary-icon: var(--fc-breezy-muted-foreground);
--fc-breezy-secondary-icon-over: var(--fc-breezy-foreground);
/* calendar content */
--fc-breezy-highlight: #f8717114;
/* controls */
--fc-breezy-selected: var(--fc-breezy-muted);
/* popovers */
--fc-breezy-popover: #1f2937;
--fc-breezy-popover-border: var(--fc-breezy-border);
/* neutral backgrounds */
--fc-breezy-background: #111827;
--fc-breezy-faint: #ffffff08;
--fc-breezy-muted: #ffffff12;
--fc-breezy-strong: #ffffff1F;
--fc-breezy-stronger: #ffffff29;
--fc-breezy-strongest: #ffffff33;
/* neutral foregrounds */
--fc-breezy-foreground: #d1d5db;
--fc-breezy-muted-foreground: #9ca3af;
--fc-breezy-faint-foreground: #6b7280;
--fc-breezy-strong-foreground: #ffffff;
/* neutral borders */
--fc-breezy-border: #ffffff1A;
--fc-breezy-muted-border: #ffffff0D;
--fc-breezy-strong-border: #ffffff26;
}
}
@media not print and (prefers-color-scheme:dark){
/* Copie du thème night */
:root.skin-theme-clientpref-os {
/* secondary */
--fc-breezy-secondary: var(--fc-breezy-muted);
--fc-breezy-secondary-over: var(--fc-breezy-strong);
--fc-breezy-secondary-border: var(--fc-breezy-muted-border);
--fc-breezy-secondary-foreground: var(--fc-breezy-strong-foreground);
--fc-breezy-secondary-icon: var(--fc-breezy-muted-foreground);
--fc-breezy-secondary-icon-over: var(--fc-breezy-foreground);
/* calendar content */
--fc-breezy-highlight: #f8717114;
/* controls */
--fc-breezy-selected: var(--fc-breezy-muted);
/* popovers */
--fc-breezy-popover: #1f2937;
--fc-breezy-popover-border: var(--fc-breezy-border);
/* neutral backgrounds */
--fc-breezy-background: #111827;
--fc-breezy-faint: #ffffff08;
--fc-breezy-muted: #ffffff12;
--fc-breezy-strong: #ffffff1F;
--fc-breezy-stronger: #ffffff29;
--fc-breezy-strongest: #ffffff33;
/* neutral foregrounds */
--fc-breezy-foreground: #d1d5db;
--fc-breezy-muted-foreground: #9ca3af;
--fc-breezy-faint-foreground: #6b7280;
--fc-breezy-strong-foreground: #ffffff;
/* neutral borders */
--fc-breezy-border: #ffffff1A;
--fc-breezy-muted-border: #ffffff0D;
--fc-breezy-strong-border: #ffffff26;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,30 @@
console.log("Youpi Calendar !");
mw.hook('wikipage.content').add(function() {
console.log("Content loaded ?");
console.log(FullCalendar);
var elems = document.getElementsByClassName('mw-crans-fullcalendar');
for (let el of elems) {
try {
const url = new URL(el.dataset.ics);
let calendar = new FullCalendar.Calendar(el, {
headerToolbar: {
start: 'prev,today,next',
center: 'title',
end: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek,multiMonthYear',
},
locale: 'fr',
events: {
url: url.href,
format: 'ics'
}
});
calendar.render();
} catch (e) {
console.error(e);
continue;
}
}
console.log(elems);
});

View File

@ -0,0 +1,49 @@
diff --git a/sql/postgres/table_wsoauth_multiauth_mappings.sql b/sql/postgres/table_wsoauth_multiauth_mappings.sql
index d2917db..b903ba7 100644
--- a/sql/postgres/table_wsoauth_multiauth_mappings.sql
+++ b/sql/postgres/table_wsoauth_multiauth_mappings.sql
@@ -1,5 +1,5 @@
CREATE TABLE /*_*/wsoauth_multiauth_mappings (
- wsoauth_user int unsigned NOT NULL,
+ wsoauth_user int NOT NULL,
wsoauth_remote_name varchar(512) NOT NULL,
wsoauth_provider_id varchar(255) NOT NULL,
PRIMARY KEY (wsoauth_remote_name, wsoauth_provider_id)
diff --git a/src/WSOAuth.php b/src/WSOAuth.php
index 3a94c87..e077b9e 100644
--- a/src/WSOAuth.php
+++ b/src/WSOAuth.php
@@ -308,11 +308,12 @@ class WSOAuth extends PluggableAuth {
// Set $realname and $email to the values returned from the authentication provider, if they are available
$realname = $remoteUserInfo['realname'] ?? null;
$email = $remoteUserInfo['email'] ?? null;
+ $username = ucfirst( $remoteUserInfo['name'] );
- $remoteUsername = ucfirst( $remoteUserInfo['name'] );
- $localUserId = $this->getLocalAccountID( $remoteUsername );
+ $remoteUserId = $remoteUserInfo['remoteUserId'] ?? $username;
+ $localUserId = $this->getLocalAccountID( $remoteUserId );
- $this->session->set( self::WSOAUTH_REMOTE_USERNAME_SESSION_KEY, $remoteUsername );
+ $this->session->set( self::WSOAUTH_REMOTE_USERNAME_SESSION_KEY, $remoteUserId );
$this->session->save();
if ( $localUserId !== 0 ) {
@@ -326,7 +327,7 @@ class WSOAuth extends PluggableAuth {
$currentUser = RequestContext::getMain()->getUser();
$currentUserId = $currentUser->getId();
- $this->createMapping( $currentUserId, $remoteUsername );
+ $this->createMapping( $currentUserId, $remoteUserId );
// Log the account in like normal
$username = $currentUser->getName();
@@ -339,7 +340,7 @@ class WSOAuth extends PluggableAuth {
throw new ContinuationException( wfMessage( "wsoauth-remote-only-accounts-disabled" )->parse() );
}
- $desiredLocalUsername = $this->useRealNameAsUsername && $realname !== null ? $realname : $remoteUsername;
+ $desiredLocalUsername = $this->useRealNameAsUsername && $realname !== null ? $realname : $username;
$userId = User::newFromName( $desiredLocalUsername )->idForName();
if ( $userId > 0 && $this->migrateUsersByUsername ) {

View File

@ -0,0 +1,14 @@
# NoteKfetAuth
Extension pour médiawiki pour ajouter le support de l'autentification par Note
via l'extension WSOAuth.
## Installation
Il faut enregistrer l'extension comme provider
```
$wgOAuthCustomAuthProviders = [
'note' => WSOAuth\AuthenticationProvider\NoteKfetAuth::class
];
```

View File

@ -0,0 +1,25 @@
{
"name": "WSONoteKfetAuth",
"author": [
"Pyjacpp"
],
"url": "https://gitlab.crans.org/nounous/nixos/-/tree/main/hosts/vm/mediakiwi/WSONoteKfetAuth",
"description": "Implementation of the NoteKfet OAuth2 for the WSOAuth extension",
"type": "other",
"requires": {
"MediaWiki": ">= 1.35.0",
"extensions": {
"WSOAuth": ">= 9.0"
}
},
"AutoloadNamespaces": {
"WSOAuth\\AuthenticationProvider\\": "src/"
},
"config": {
"NoteKfetUrl": {
"description": "The url of the NoteKfet.",
"value": "https://note.crans.org/"
}
},
"manifest_version": 2
}

View File

@ -0,0 +1,149 @@
<?php
/**
* Copyright 2020 Marijn van Wezel
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
* documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
* Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
* LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
* CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
namespace WSOAuth\AuthenticationProvider;
use MediaWiki\User\UserIdentity;
class NoteKfetAuth extends AuthProvider {
/**
* @var string
*/
private $clientId;
/**
* @var string
*/
private $clientSecret;
/**
* @inheritDoc
*/
public function __construct(
string $clientId,
string $clientSecret,
?string $authUri,
?string $redirectUri,
array $extensionData = []
) {
$this->clientId = $clientId;
$this->clientSecret = $clientSecret;
}
/**
* @inheritDoc
*/
public function login( ?string &$key, ?string &$secret, ?string &$authUrl ): bool {
// This state is used to prevent CSRF, i.e., ensuring that authentification request
// were initiated on our website.
$state = random_int(PHP_INT_MIN, PHP_INT_MAX);
$secret = "$state";
$authUrl = $GLOBALS['wgNoteKfetUrl'] . "o/authorize/?" . http_build_query([
'client_id' => $this->clientId,
'response_type' => 'code',
'scope' => '1_1',
'state' => $secret,
]);
return true;
}
/**
* @inheritDoc
*/
public function logout( UserIdentity &$user ): void {
}
/**
* @inheritDoc
*/
public function getUser( string $key, string $secret, &$errorMessage ) {
if ( !isset( $_GET['code'] ) ) {
return false;
}
if ( !isset( $_GET['state'] ) || empty( $_GET['state'] ) || ( $_GET['state'] !== $secret ) ) {
return false;
}
try {
$token = $this->getAccessTokens( $_GET['code'] );
$userInfos = $this->getUserInfos( $token );
return [
'name' => $this->sanitizeName( "$userInfos->normalized_name (note)" ),
'realname' => $userInfos->username,
'email' => $userInfos->email,
'remoteUserId' => $userInfos->id,
];
} catch ( \Exception $e ) {
return false;
}
}
private function sanitizeName( string $name ) {
// We replace forbidden chars.
$res = preg_replace('/[#\/:<>=@\|]/', '-', $name);
$res = preg_replace(['/[\[{]/', '/[\]}]/'], ['(', ')'], $res);
$res = str_replace('_', ' ', $res);
// We remove the last controls chars possibly remaining.
return preg_replace('/[^a-zA-Z0-9 !\"$%&\'()*+,\-.;?\\\^`~]/', '', $res);
}
private function getAccessTokens( string $code ) {
$data = [
'grant_type' => 'authorization_code',
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'code' => $code,
];
$options = [
'http' => [
'method' => 'POST',
'header' => 'Content-type: application/x-www-form-urlencoded',
'content' => http_build_query($data),
],
];
$context = stream_context_create($options);
$response = file_get_contents($GLOBALS['wgNoteKfetUrl'] . 'o/token/', false, $context);
$tokens = json_decode($response);
return $tokens->access_token;
}
private function getUserInfos( string $token ) {
$options = [
'http' => [
'method' => 'GET',
'header' => "Authorization: Bearer $token",
],
];
$context = stream_context_create($options);
$response = file_get_contents($GLOBALS['wgNoteKfetUrl'] . 'api/me/', false, $context);
return json_decode($response);
}
/**
* @inheritDoc
*/
public function saveExtraAttributes( int $id ): void {
}
}

View File

@ -0,0 +1,98 @@
diff --git a/CategoryLockdown.php b/CategoryLockdown.php
index 1e17ec7..b884553 100644
--- a/CategoryLockdown.php
+++ b/CategoryLockdown.php
@@ -16,16 +16,23 @@ class CategoryLockdown {
*/
public static function onGetUserPermissionsErrors( $title, $user, $action, &$result ) {
global $wgCategoryLockdown;
+ global $wgCategoryGroupLockdown;
+ global $wgCategoryLockdownWhitelist;
$explicitGroups = MediaWikiServices::getInstance()->getUserGroupManager()->getUserGroups( $user );
$implicitGroups = MediaWikiServices::getInstance()->getUserGroupManager()->getUserImplicitGroups( $user );
- $userGroups = $explicitGroups + $implicitGroups;
+ $userGroups = array_merge( $explicitGroups, $implicitGroups );
// Rules don't apply to admins
if ( in_array( 'sysop', $userGroups ) ) {
return;
}
+ // Rules doesn’t apply to the whitelist
+ if ( in_array( $title, $wgCategoryLockdownWhitelist ) ) {
+ return;
+ }
+
$categories = array_keys( $title->getParentCategories() );
// Apply rules to the category page itself
@@ -33,16 +40,11 @@ class CategoryLockdown {
$categories[] = $title->getFullText();
}
+ // Support "Category:Top_secret", "Category:Top secret", "Top_secret" and "Top secret"
+ $categories = array_map( fn($c) => str_replace( '_', ' ', substr( $c, strpos( $c, ':' ) + 1 ) ), $categories );
$combinedGroups = [];
foreach ( $categories as $category ) {
- // Support "Category:Top_secret", "Category:Top secret", "Top_secret" and "Top secret"
- $category = substr( $category, strpos( $category, ':' ) + 1 );
- $category = str_replace( '_', ' ', $category );
$permissions = $wgCategoryLockdown[ $category ] ?? null;
- if ( !$permissions ) {
- $category = str_replace( ' ', '_', $category );
- $permissions = $wgCategoryLockdown[ $category ] ?? null;
- }
if ( !$permissions ) {
continue;
}
@@ -57,15 +59,44 @@ class CategoryLockdown {
$combinedGroups[] = $allowedGroup;
}
}
- if ( $combinedGroups ) {
- foreach ( $userGroups as $userGroup ) {
- if ( in_array( $userGroup, $combinedGroups ) ) {
- return;
- }
+
+ $allow = false;
+ foreach ( $userGroups as $userGroup ) {
+ if ( in_array( $userGroup, $combinedGroups ) ) {
+ $allow = true;
+ break;
}
+ }
+ if ( $combinedGroups && !$allow ) {
$result = [ 'categorylockdown-error', implode( ', ', $combinedGroups ) ];
return false;
}
+
+ $allow = true;
+ foreach ( $wgCategoryGroupLockdown as $group => $groupCategories ) {
+ if ( str_starts_with( $group, "!") ?
+ in_array( substr($group, 1), $userGroups ) :
+ !in_array( $group, $userGroups ) ) {
+ continue; # Skip if this group rule doesn’t match the user
+ }
+
+ $requiredCat = $groupCategories[ $action ] ?? null;
+ if ( $requiredCat == null ) {
+ continue;
+ }
+
+ $groupLocked = true;
+ foreach ( $requiredCat as $c ) {
+ if ( in_array( $c, $categories ) ) {
+ $groupLocked = false;
+ break; # One of the category is present, we can grant this action.
+ }
+ }
+ if ( $groupLocked ) {
+ $result = [ 'categorylockdown-error', implode( ', ', $requiredCat ) ];
+ return false; # This group of users need a category from groupCategories on this page to perform $action
+ }
+ }
}
/**

View File

@ -0,0 +1,28 @@
{ ... }:
{
imports = [
./hardware-configuration.nix
./mediawiki.nix
../../../modules
../../../modules/crans/nullmailer.nix
];
networking.hostName = "mediakiwi";
boot.loader.grub.devices = [ "/dev/sda" ];
crans = {
enable = true;
networking = {
id = 144;
srvNat.enable = true;
};
# Enable when deploying the real mediakiwi
resticClient.when = "06:47";
};
system.stateVersion = "25.05";
}

View File

@ -0,0 +1,24 @@
# Do not modify this file! It was generated by ‘nixos-generate-config’
# and may be overwritten by future invocations. Please make changes
# to /etc/nixos/configuration.nix instead.
{ config, lib, pkgs, modulesPath, ... }:
{
imports =
[ (modulesPath + "/profiles/qemu-guest.nix")
];
boot.initrd.availableKernelModules = [ "ata_piix" "uhci_hcd" "virtio_pci" "virtio_scsi" "sd_mod" "sr_mod" ];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ ];
boot.extraModulePackages = [ ];
fileSystems."/" =
{ device = "/dev/disk/by-uuid/45e1f37b-5bf2-47fc-86ef-e79b062c2b3c";
fsType = "ext4";
};
swapDevices = [ ];
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
}

View File

@ -0,0 +1,852 @@
{
config,
pkgs,
lib,
...
}:
let
inherit (lib)
mkDefault
mkEnableOption
mkPackageOption
mkForce
mkIf
mkMerge
mkOption
;
inherit (lib)
concatStringsSep
literalExpression
mapAttrsToList
optional
optionals
optionalString
types
;
cfg = config.services.mediawiki;
fpm = config.services.phpfpm.pools.mediawiki;
user = "mediawiki";
group =
if cfg.webserver == "apache" then
config.services.httpd.group
else if cfg.webserver == "nginx" then
config.services.nginx.group
else
"mediawiki";
cacheDir = "/var/cache/mediawiki";
stateDir = "/var/lib/mediawiki";
toolsPath = pkgs.symlinkJoin {
name = "mediawiki-path";
paths = cfg.path;
};
pkg = pkgs.stdenv.mkDerivation rec {
pname = "mediawiki-full";
inherit (src) version;
src = cfg.package;
installPhase = ''
mkdir -p $out
cp -r * $out/
substituteInPlace $out/share/mediawiki/includes/config-schema.php \
--replace-fail "/usr/bin/" "${toolsPath}/bin/" \
--replace-fail "\$path/" "${toolsPath}/bin/"
# try removing directories before symlinking to allow overwriting any builtin extension or skin
${concatStringsSep "\n" (
mapAttrsToList (k: v: ''
rm -rf $out/share/mediawiki/skins/${k}
ln -s ${v} $out/share/mediawiki/skins/${k}
'') cfg.skins
)}
${concatStringsSep "\n" (
mapAttrsToList (k: v: ''
rm -rf $out/share/mediawiki/extensions/${k}
ln -s ${
if v != null then v else "$src/share/mediawiki/extensions/${k}"
} $out/share/mediawiki/extensions/${k}
'') cfg.extensions
)}
'';
};
mediawikiScripts =
pkgs.runCommand "mediawiki-scripts"
{
nativeBuildInputs = [ pkgs.makeWrapper ];
preferLocalBuild = true;
}
''
mkdir -p $out/bin
makeWrapper ${cfg.phpPackage}/bin/php $out/bin/mediawiki-maintenance \
--set MEDIAWIKI_CONFIG ${mediawikiConfig} \
--add-flags ${pkg}/share/mediawiki/maintenance/run.php
for i in changePassword createAndPromote deleteUserEmail renameUser resetUserEmail userOptions edit nukePage update importDump run; do
script="$out/bin/mediawiki-$i"
cat <<'EOF' >"$script"
#!${pkgs.runtimeShell}
become=(exec)
if [[ "$(id -u)" != ${user} ]]; then
become=(exec /run/wrappers/bin/sudo -u ${user} --)
fi
"${"$"}{become[@]}" ${placeholder "out"}/bin/mediawiki-maintenance \
EOF
if [[ "$i" != "run" ]]; then
echo " ${pkg}/share/mediawiki/maintenance/$i.php \"\$@\"" >>"$script"
else
echo " ${pkg}/share/mediawiki/maintenance/\$1.php \"\''${@:2}\"" >>"$script"
fi
chmod +x "$script"
done
'';
dbAddr =
if cfg.database.type == "postgres" then
"${if cfg.database.socket == null then cfg.database.host else cfg.database.socket}"
else if cfg.database.socket == null then
"${cfg.database.host}:${toString cfg.database.port}"
else if cfg.database.type == "mysql" then
"${cfg.database.host}:${cfg.database.socket}"
else
throw "Unsupported database type: ${cfg.database.type} for socket: ${cfg.database.socket}";
mediawikiConfig = pkgs.writeTextFile {
name = "LocalSettings.php";
checkPhase = ''
${cfg.phpPackage}/bin/php --syntax-check "$target"
'';
text =
let
dbSettings =
if cfg.database.type == "sqlite" then
''
$wgSQLiteDataDir = "${cfg.database.path}";
''
else
''
$wgDBserver = "${dbAddr}";
$wgDBport = "${toString cfg.database.port}";
$wgDBuser = "${cfg.database.user}";
${optionalString (
cfg.database.passwordFile != null
) "$wgDBpassword = file_get_contents(\"${cfg.database.passwordFile}\");"}
${optionalString (cfg.database.type == "mysql" && cfg.database.tablePrefix != null) ''
# MySQL specific settings
$wgDBprefix = "${cfg.database.tablePrefix}";
''}
${optionalString (cfg.database.type == "mysql") ''
# MySQL table options to use during installation or update
$wgDBTableOptions = "ENGINE=InnoDB, DEFAULT CHARSET=binary";
''}
'';
in
''
<?php
# Protect against web entry
if ( !defined( 'MEDIAWIKI' ) ) {
exit;
}
$wgSitename = "${cfg.name}";
$wgMetaNamespace = false;
## The URL base path to the directory containing the wiki;
## defaults for all runtime URL paths are based off of this.
## For more information on customizing the URLs
## (like /w/index.php/Page_title to /wiki/Page_title) please see:
## https://www.mediawiki.org/wiki/Manual:Short_URL
$wgScriptPath = "${lib.optionalString (cfg.webserver == "nginx") "/w"}";
## The protocol and server name to use in fully-qualified URLs
$wgServer = "${cfg.url}";
## The URL path to static resources (images, scripts, etc.)
$wgResourceBasePath = $wgScriptPath;
${lib.optionalString (cfg.webserver == "nginx") ''
$wgArticlePath = "/wiki/$1";
$wgUsePathInfo = true;
''}
## The URL path to the logo. Make sure you change this from the default,
## or else you'll overwrite your logo when you upgrade!
$wgLogo = "$wgResourceBasePath/resources/assets/wiki.png";
## UPO means: this is also a user preference option
$wgEnableEmail = true;
$wgEnableUserEmail = true; # UPO
$wgPasswordSender = "${cfg.passwordSender}";
$wgEnotifUserTalk = false; # UPO
$wgEnotifWatchlist = false; # UPO
$wgEmailAuthentication = true;
## Database settings
$wgDBtype = "${cfg.database.type}";
$wgDBname = "${cfg.database.name}";
${dbSettings}
## Shared memory settings
$wgMainCacheType = CACHE_NONE;
$wgMemCachedServers = [];
${optionalString (cfg.uploadsDir != null) ''
$wgEnableUploads = true;
$wgUploadDirectory = "${cfg.uploadsDir}";
''}
$wgUseImageMagick = true;
# InstantCommons allows wiki to use images from https://commons.wikimedia.org
$wgUseInstantCommons = false;
# Periodically send a pingback to https://www.mediawiki.org/ with basic data
# about this MediaWiki instance. The Wikimedia Foundation shares this data
# with MediaWiki developers to help guide future development efforts.
$wgPingback = true;
## If you use ImageMagick (or any other shell command) on a
## Linux server, this will need to be set to the name of an
## available UTF-8 locale
$wgShellLocale = "C.UTF-8";
## Set $wgCacheDirectory to a writable directory on the web server
## to make your wiki go slightly faster. The directory should not
## be publicly accessible from the web.
$wgCacheDirectory = "${cacheDir}";
# Site language code, should be one of the list in ./languages/data/Names.php
$wgLanguageCode = "en";
$wgSecretKey = file_get_contents("${stateDir}/secret.key");
# Changing this will log out all existing sessions.
$wgAuthenticationTokenVersion = "";
## For attaching licensing metadata to pages, and displaying an
## appropriate copyright notice / icon. GNU Free Documentation
## License and Creative Commons licenses are supported so far.
$wgRightsPage = ""; # Set to the title of a wiki page that describes your license/copyright
$wgRightsUrl = "";
$wgRightsText = "";
$wgRightsIcon = "";
# Enable APCU caching
$wgMainCacheType = CACHE_ACCEL;
# Enabled skins.
${concatStringsSep "\n" (mapAttrsToList (k: v: "wfLoadSkin('${k}');") cfg.skins)}
# Enabled extensions.
${concatStringsSep "\n" (mapAttrsToList (k: v: "wfLoadExtension('${k}');") cfg.extensions)}
# End of automatically generated settings.
# Add more configuration options below.
${cfg.extraConfig}
'';
};
withTrailingSlash = str: if lib.hasSuffix "/" str then str else "${str}/";
in
{
options = {
services.mediawiki = {
enable = mkEnableOption "MediaWiki";
package = mkPackageOption pkgs "mediawiki" { };
# https://www.mediawiki.org/wiki/Compatibility#PHP
phpPackage = mkPackageOption pkgs "php" { } // {
default = pkgs.php83.buildEnv {
extensions = { all, enabled }: enabled ++ (with all; [ apcu ]);
};
};
finalPackage = mkOption {
type = types.package;
readOnly = true;
default = pkg;
defaultText = literalExpression "pkg";
description = ''
The final package used by the module. This is the package that will have extensions and skins installed.
'';
};
name = mkOption {
type = types.str;
default = "MediaWiki";
example = "Foobar Wiki";
description = "Name of the wiki.";
};
url = mkOption {
type = types.str;
default =
if cfg.webserver == "apache" then
"${
if
cfg.httpd.virtualHost.addSSL || cfg.httpd.virtualHost.forceSSL || cfg.httpd.virtualHost.onlySSL
then
"https"
else
"http"
}://${cfg.httpd.virtualHost.hostName}"
else if cfg.webserver == "nginx" then
let
hasSSL = host: host.forceSSL || host.addSSL;
in
"${
if hasSSL config.services.nginx.virtualHosts.${cfg.nginx.hostName} then "https" else "http"
}://${cfg.nginx.hostName}"
else
"http://localhost";
defaultText = ''
if "mediawiki uses ssl" then "{"https" else "http"}://''${cfg.hostName}" else "http://localhost";
'';
example = "https://wiki.example.org";
description = "URL of the wiki.";
};
uploadsDir = mkOption {
type = types.nullOr types.path;
default = "${stateDir}/uploads";
description = ''
This directory is used for uploads of pictures. The directory passed here is automatically
created and permissions adjusted as required.
'';
};
passwordFile = mkOption {
type = types.path;
description = ''
A file containing the initial password for the administrator account "admin".
'';
example = "/run/keys/mediawiki-password";
};
passwordSender = mkOption {
type = types.str;
default =
if cfg.webserver == "apache" then
if cfg.httpd.virtualHost.adminAddr != null then
cfg.httpd.virtualHost.adminAddr
else
config.services.httpd.adminAddr
else
"root@localhost";
defaultText = literalExpression ''
if cfg.webserver == "apache" then
if cfg.httpd.virtualHost.adminAddr != null then
cfg.httpd.virtualHost.adminAddr
else
config.services.httpd.adminAddr else "root@localhost"
'';
description = "Contact address for password reset.";
};
path = mkOption {
type = types.listOf types.package;
defaultText = lib.literalExpression "with pkgs; [ diffutils imagemagick ]";
example = lib.literalExpression "with pkgs; [ librsvg ]";
description = "Extra packages to add to the PATH of phpfpm-pool.";
};
skins = mkOption {
default = { };
type = types.attrsOf types.path;
description = ''
Attribute set of paths whose content is copied to the {file}`skins`
subdirectory of the MediaWiki installation in addition to the default skins.
'';
};
extensions = mkOption {
default = { };
type = types.attrsOf (types.nullOr types.path);
description = ''
Attribute set of paths whose content is copied to the {file}`extensions`
subdirectory of the MediaWiki installation and enabled in configuration.
Use `null` instead of path to enable extensions that are part of MediaWiki.
'';
example = literalExpression ''
{
Matomo = pkgs.fetchzip {
url = "https://github.com/DaSchTour/matomo-mediawiki-extension/archive/v4.0.1.tar.gz";
sha256 = "0g5rd3zp0avwlmqagc59cg9bbkn3r7wx7p6yr80s644mj6dlvs1b";
};
ParserFunctions = null;
}
'';
};
webserver = mkOption {
type = types.enum [
"apache"
"none"
"nginx"
];
default = "apache";
description = "Webserver to use.";
};
database = {
type = mkOption {
type = types.enum [
"mysql"
"postgres"
"mssql"
"oracle"
"sqlite"
];
default = "mysql";
description = "Database engine to use. MySQL/MariaDB is the database of choice by MediaWiki developers.";
};
host = mkOption {
type = types.nullOr types.str;
default = if cfg.database.type == "sqlite" then null else "localhost";
defaultText = ''"localhost"'';
description = "Database host address. Used only if database type is not SQLite.";
};
port = mkOption {
type = types.nullOr types.port;
default =
if cfg.database.type == "mysql" then
3306
else if cfg.database.type != "sqlite" then
5432
else
null;
defaultText = literalExpression "3306";
description = "Database host port. Used only if database type is not SQLite.";
};
name = mkOption {
type = types.str;
default = "mediawiki";
description = "Database name.";
};
user = mkOption {
type = types.nullOr types.str;
default = if cfg.database.type != "sqlite" then "mediawiki" else null;
defaultText = literalExpression ''"mediawiki"'';
description = "Database user. Used only if database type is not SQLite.";
};
path = mkOption {
type = types.nullOr types.path;
default = if cfg.database.type == "sqlite" then "${stateDir}/data" else null;
defaultText = literalExpression ''"${stateDir}/data"'';
description = "Path to store the MediaWiki database in if using SQLite.";
};
passwordFile = mkOption {
type = types.nullOr types.path;
default = null;
example = "/run/keys/mediawiki-dbpassword";
description = ''
A file containing the password corresponding to
{option}`database.user`. Used only if database type is not SQLite.
'';
};
tablePrefix = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
If you only have access to a single database and wish to install more than
one version of MediaWiki, or have other applications that also use the
database, you can give the table names a unique prefix to stop any naming
conflicts or confusion. Only used if database type is MySQL.
See <https://www.mediawiki.org/wiki/Manual:$wgDBprefix>.
'';
};
socket = mkOption {
type = types.nullOr types.path;
default =
if (cfg.database.type == "mysql" && cfg.database.createLocally) then
"/run/mysqld/mysqld.sock"
else if (cfg.database.type == "postgres" && cfg.database.createLocally) then
"/run/postgresql"
else
null;
defaultText = literalExpression "/run/mysqld/mysqld.sock";
description = "Path to the unix socket file to use for authentication. Used only if database type is not SQLite.";
};
createLocally = mkOption {
type = types.bool;
default = cfg.database.type == "mysql" || cfg.database.type == "postgres";
defaultText = literalExpression "true";
description = ''
Create the database and database user locally.
This currently only applies if database type "mysql" or "postgres" is selected.
'';
};
};
nginx.hostName = mkOption {
type = types.str;
example = literalExpression "wiki.example.com";
default = "localhost";
description = ''
The hostname to use for the nginx virtual host.
This is used to generate the nginx configuration.
'';
};
httpd.virtualHost = mkOption {
type = types.submodule { }; # (import ../web-servers/apache-httpd/vhost-options.nix);
example = literalExpression ''
{
hostName = "mediawiki.example.org";
adminAddr = "webmaster@example.org";
forceSSL = true;
enableACME = true;
}
'';
description = ''
Apache configuration can be done by adapting {option}`services.httpd.virtualHosts`.
See [](#opt-services.httpd.virtualHosts) for further information.
'';
};
poolConfig = mkOption {
type =
with types;
attrsOf (oneOf [
str
int
bool
]);
default = {
"pm" = "dynamic";
"pm.max_children" = 32;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 2;
"pm.max_spare_servers" = 4;
"pm.max_requests" = 500;
};
description = ''
Options for the MediaWiki PHP pool. See the documentation on `php-fpm.conf`
for details on configuration directives.
'';
};
extraConfig = mkOption {
type = types.lines;
description = ''
Any additional text to be appended to MediaWiki's
LocalSettings.php configuration file. For configuration
settings, see <https://www.mediawiki.org/wiki/Manual:Configuration_settings>.
'';
default = "";
example = ''
$wgEnableEmail = false;
'';
};
};
};
imports = [
(lib.mkRenamedOptionModule
[ "services" "mediawiki" "virtualHost" ]
[ "services" "mediawiki" "httpd" "virtualHost" ]
)
];
config = mkIf cfg.enable {
assertions = [
{
assertion =
cfg.database.createLocally -> (cfg.database.type == "mysql" || cfg.database.type == "postgres");
message = "services.mediawiki.createLocally is currently only supported for database type 'mysql' and 'postgres'";
}
{
assertion =
cfg.database.createLocally -> cfg.database.user == user && cfg.database.name == cfg.database.user;
message = "services.mediawiki.database.user must be set to ${user} if services.mediawiki.database.createLocally is set true";
}
{
assertion = cfg.database.createLocally -> cfg.database.socket != null;
message = "services.mediawiki.database.socket must be set if services.mediawiki.database.createLocally is set to true";
}
{
assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
message = "a password cannot be specified if services.mediawiki.database.createLocally is set to true";
}
];
warnings =
lib.optional
(
cfg.database.type == "sqlite"
&& (
cfg.database.host != null
|| cfg.database.port != null
|| cfg.database.user != null
|| cfg.database.passwordFile != null
|| cfg.database.socket != null
)
)
''
The services.mediawiki.database options host, port, user, passwordFile, and socket will be ignored because services.mediawiki.database.type is "sqlite".
''
++ lib.optional (cfg.database.type != "sqlite" && cfg.database.path != null) ''
The services.mediawiki.database.path option will be ignored because services.mediawiki.database.type is not "sqlite".
''
++ lib.optional (cfg.database.type != "mysql" && cfg.database.tablePrefix != null) ''
The services.mediawiki.database.tablePrefix option has no effect when the services.mediawiki.database.type is not "mysql".
'';
services.mediawiki = {
path = with pkgs; [
diffutils
imagemagick
];
skins = {
MonoBook = "${cfg.package}/share/mediawiki/skins/MonoBook";
Timeless = "${cfg.package}/share/mediawiki/skins/Timeless";
Vector = "${cfg.package}/share/mediawiki/skins/Vector";
};
};
services.mysql = mkIf (cfg.database.type == "mysql" && cfg.database.createLocally) {
enable = true;
package = mkDefault pkgs.mariadb;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [
{
name = cfg.database.user;
ensurePermissions = {
"${cfg.database.name}.*" = "ALL PRIVILEGES";
};
}
];
};
services.postgresql = mkIf (cfg.database.type == "postgres" && cfg.database.createLocally) {
enable = true;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [
{
name = cfg.database.user;
ensureDBOwnership = true;
}
];
};
services.phpfpm.pools.mediawiki = {
inherit user group;
phpEnv.MEDIAWIKI_CONFIG = "${mediawikiConfig}";
phpPackage = cfg.phpPackage;
settings =
(
if (cfg.webserver == "apache") then
{
"listen.owner" = config.services.httpd.user;
"listen.group" = config.services.httpd.group;
}
else if (cfg.webserver == "nginx") then
{
"listen.owner" = config.services.nginx.user;
"listen.group" = config.services.nginx.group;
}
else
{
"listen.owner" = user;
"listen.group" = group;
}
)
// cfg.poolConfig;
};
services.httpd = lib.mkIf (cfg.webserver == "apache") {
enable = true;
extraModules = [ "proxy_fcgi" ];
virtualHosts.${cfg.httpd.virtualHost.hostName} = mkMerge [
cfg.httpd.virtualHost
{
documentRoot = mkForce "${pkg}/share/mediawiki";
extraConfig = ''
<Directory "${pkg}/share/mediawiki">
<FilesMatch "\.php$">
<If "-f %{REQUEST_FILENAME}">
SetHandler "proxy:unix:${fpm.socket}|fcgi://localhost/"
</If>
</FilesMatch>
Require all granted
DirectoryIndex index.php
AllowOverride All
</Directory>
''
+ optionalString (cfg.uploadsDir != null) ''
Alias "/images" "${cfg.uploadsDir}"
<Directory "${cfg.uploadsDir}">
Require all granted
</Directory>
'';
}
];
};
# inspired by https://www.mediawiki.org/wiki/Manual:Short_URL/Nginx
services.nginx = lib.mkIf (cfg.webserver == "nginx") {
enable = true;
virtualHosts.${config.services.mediawiki.nginx.hostName} = {
root = "${pkg}/share/mediawiki";
locations = {
"~ ^/w/(index|load|api|thumb|opensearch_desc|rest|img_auth)\\.php$".extraConfig = ''
rewrite ^/w/(.*) /$1 break;
include ${config.services.nginx.package}/conf/fastcgi.conf;
fastcgi_index index.php;
fastcgi_pass unix:${config.services.phpfpm.pools.mediawiki.socket};
'';
"/w/images/".alias = withTrailingSlash cfg.uploadsDir;
# Deny access to deleted images folder
"/w/images/deleted".extraConfig = ''
deny all;
'';
# MediaWiki assets (usually images)
"~ ^/w/resources/(assets|lib|src)".extraConfig = ''
rewrite ^/w(/.*) $1 break;
add_header Cache-Control "public";
expires 7d;
'';
# Assets, scripts and styles from skins and extensions
"~ ^/w/(skins|extensions)/.+\\.(css|js|gif|jpg|jpeg|png|svg|wasm|ttf|woff|woff2)$".extraConfig = ''
rewrite ^/w(/.*) $1 break;
add_header Cache-Control "public";
expires 7d;
'';
# Handling for Mediawiki REST API, see [[mw:API:REST_API]]
"/w/rest.php/".tryFiles = "$uri $uri/ /w/rest.php?$query_string";
# Handling for the article path (pretty URLs)
"/wiki/".extraConfig = ''
rewrite ^/wiki/(?<pagename>.*)$ /w/index.php;
'';
# Explicit access to the root website, redirect to main page (adapt as needed)
"= /".extraConfig = ''
return 301 /wiki/;
'';
# Every other entry point will be disallowed.
# Add specific rules for other entry points/images as needed above this
"/".extraConfig = ''
return 404;
'';
};
};
};
systemd.tmpfiles.rules = [
"d '${stateDir}' 0750 ${user} ${group} - -"
"d '${cacheDir}' 0750 ${user} ${group} - -"
]
++ optionals (cfg.uploadsDir != null) [
"d '${cfg.uploadsDir}' 0750 ${user} ${group} - -"
"Z '${cfg.uploadsDir}' 0750 ${user} ${group} - -"
];
systemd.services.mediawiki-init = {
wantedBy = [ "multi-user.target" ];
before = [ "phpfpm-mediawiki.service" ];
after =
optional (cfg.database.type == "mysql" && cfg.database.createLocally) "mysql.service"
++ optional (cfg.database.type == "postgres" && cfg.database.createLocally) "postgresql.target";
script =
let
dbOptions =
if cfg.database.type == "sqlite" then
''
--dbpath ${lib.escapeShellArg cfg.database.path} \
--dbname ${lib.escapeShellArg cfg.database.name} \
''
else
''
--dbserver ${lib.escapeShellArg dbAddr} \
--dbport ${toString cfg.database.port} \
--dbname ${lib.escapeShellArg cfg.database.name} \
${
optionalString (
cfg.database.tablePrefix != null
) "--dbprefix ${lib.escapeShellArg cfg.database.tablePrefix}"
} \
--dbuser ${lib.escapeShellArg cfg.database.user} \
${
optionalString (
cfg.database.passwordFile != null
) "--dbpassfile ${lib.escapeShellArg cfg.database.passwordFile}"
} \
'';
in
''
if ! test -e "${stateDir}/secret.key"; then
tr -dc A-Za-z0-9 </dev/urandom 2>/dev/null | head -c 64 > ${stateDir}/secret.key
fi
${optionalString (cfg.database.type == "sqlite") "mkdir -p ${cfg.database.path}"}
echo "exit( \$this->getPrimaryDB()->tableExists( 'user' ) ? 1 : 0 );" | \
${cfg.phpPackage}/bin/php ${pkg}/share/mediawiki/maintenance/run.php eval --conf ${mediawikiConfig} && \
${cfg.phpPackage}/bin/php ${pkg}/share/mediawiki/maintenance/run.php ${pkg}/share/mediawiki/maintenance/install.php \
--confpath /tmp \
--scriptpath / \
${dbOptions} \
--dbtype ${cfg.database.type} \
--passfile ${lib.escapeShellArg cfg.passwordFile} \
${lib.escapeShellArg cfg.name} \
admin
${cfg.phpPackage}/bin/php ${pkg}/share/mediawiki/maintenance/update.php --conf ${mediawikiConfig} --quick --skip-external-dependencies
'';
serviceConfig = {
Type = "oneshot";
User = user;
Group = group;
PrivateTmp = true;
};
};
systemd.services.httpd.after =
optional (
cfg.webserver == "apache" && cfg.database.createLocally && cfg.database.type == "mysql"
) "mysql.service"
++ optional (
cfg.webserver == "apache" && cfg.database.createLocally && cfg.database.type == "postgres"
) "postgresql.target";
users.users.${user} = {
inherit group;
isSystemUser = true;
};
users.groups.${group} = { };
environment.systemPackages = [ mediawikiScripts ];
};
}

View File

@ -0,0 +1,376 @@
{
pkgs,
config,
...
}:
let
phpExtensions = config.services.mediawiki.phpPackage.extensions;
in
{
# Mauvaise config posgresql, on fixe à la mano temporairement
disabledModules = [ "services/web-apps/mediawiki.nix" ];
imports = [ ./mediawiki-patch.nix ];
age.secrets.mediawiki-admin-passwd = {
file = ../../../secrets/mediakiwi/mediawiki-admin-passwd.age;
owner = "mediawiki";
};
age.secrets.mediawiki-ldap = {
file = ../../../secrets/mediakiwi/mediawiki-ldap.age;
owner = "mediawiki";
};
age.secrets.mediawiki-oauth = {
file = ../../../secrets/mediakiwi/mediawiki-oauth.age;
owner = "mediawiki";
};
# Attention : il ne faut pas mettre de retour à la ligne à la fin du mot de passe !
age.secrets.mediawiki-db = {
file = ../../../secrets/mediakiwi/mediawiki-db-pass.age;
owner = "mediawiki";
};
environment.systemPackages = with pkgs; [
imagemagick
# For the PdfHandler extension
ghostscript
poppler-utils
# For the SyntaxHighlighting extension
python3Packages.pygments
];
services.phpfpm.pools.mediawiki.phpOptions = ''
upload_max_filesize = 512M
post_max_size = 512M
max_execution_time = 1000
max_input_time = 2000
default_socket_timeout = 2000
extension = ${phpExtensions.mbstring}/lib/php/extensions/mbstring.so
extension = ${phpExtensions.luasandbox}/lib/php/extensions/luasandbox.so
'';
services.nginx.clientMaxBodySize = "512M";
services.syslogd.enable = true;
# Tâches lourdes à gérer au fond https://www.mediawiki.org/wiki/Manual:Job_queue
systemd.services.mediawiki-jobs =
let
# Hack pour retrouver la conf
conf = config.services.phpfpm.pools.mediawiki.phpEnv.MEDIAWIKI_CONFIG;
php = "${config.services.mediawiki.phpPackage}/bin/php";
maintenanceScript = "${config.services.mediawiki.finalPackage}/share/mediawiki/maintenance/run.php";
runJobs = "MEDIAWIKI_CONFIG='${conf}' ${php} ${maintenanceScript} runJobs --maxtime=3600";
in
{
description = "Tâches lourdes de Mediawiki";
wantedBy = [ "multi-user.target" ];
after = [ "phpfpm-mediawiki.service" ];
path = [ pkgs.mediawiki ];
# Inspiré de https://www.mediawiki.org/wiki/Manual:Job_queue
script = ''
while true; do
${runJobs} --type="enotifNotify"
# --wait pour ne pas boucler pour rien
${runJobs} --wait --maxjobs=20
# Au bout de 20 jobs on attend pour éviter de surcharger le serveur
# avec des tâches de fond
sleep 5
done
'';
serviceConfig = {
Restart = "always";
RestartSec = "60s";
User = "mediawiki";
Nice = 10;
ProtectSystem = "full";
OOMScoreAdjust = 200;
};
};
services.mediawiki = {
enable = true;
name = "Wiki Crans";
nginx.hostName = "mediawiki.crans.org";
webserver = "nginx";
passwordFile = config.age.secrets.mediawiki-admin-passwd.path;
database = {
createLocally = false;
host = "tealc.adm.crans.org";
name = "mediawiki";
user = "mediawiki";
passwordFile = config.age.secrets.mediawiki-db.path;
type = "postgres";
};
extraConfig = ''
# TODO remove log error in test
error_reporting( -1 );
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
# Server settings
$wgFavicon = 'https://www.crans.org/images/favicon.ico';
$wgLogo = 'https://www.crans.org/images/crans.svg';
$wgJobRunRate = 0; # On fait les jobs manuellement dans un systemd.
$wgEnableEditRecovery = true; # Pour pouvoir récuppérer des changements non sauvegardées (1 mois par défaut en local)
$wgRestrictDisplayTitle = false; # Pour changer l’affichage des noms de pages.
# Files and Uploads
$wgMaxUploadSize = 512 * 1024 * 1024;
$wgUseImageMagick = true;
$wgImageMagickConvertCommand = '${pkgs.imagemagick}/bin/convert';
$wgStrictFileExtensions = false; # On autorise toutes les extensions sauf https://www.mediawiki.org/wiki/Manual:$wgProhibitedFileExtensions
$wgFileExtensions[] = 'pdf';
$wgFileExtensions[] = 'svg';
# Limite mémoire, quand on fait de gros importi (9M de xml), le parser
# prends plus que 50M
$wgMemoryLimit = 512 * 1024 * 1024;
# E-mail settings
$wgEnableEmail = true;
$wgSMTP = [
'host' => 'smtp.adm.crans.org',
'IDHost' => 'crans.org',
'localhost' => 'crans.org',
'port' => 25,
'auth' => false,
];
$wgPasswordSender = 'wiki@crans.org'; # C’est un alias de root (pas de réponse)
$wgEmergencyContact = 'contact@crans.org';
$wgNoReplyAddress = 'contact@crans.org'; # On reçoit les réponses aux mails
$wgEnableUserEmail = true; # On active les emails aux utilisateurices mais restreint via les ACLs
$wgAllowHTMLEmail = true;
$wgEnotifUseRealName = true; # On active les noms réels pour plus de lisibilité avec les comptes note
$wgEnotifFromEditor = false; # On ne se fait pas passer par l’utilisateurice
$wgEnotifRevealEditorAddress = false; # Pas de champ répondre avec l’adresse mail de l’utilisateurice
$wgEnotifUserTalk = true;
$wgEnotifMinorEdits = true;
$wgEnotifWatchlist = true;
# Peut-être utilisé pour les Wikistes
$wgUsersNotifiedOnAllChanges = [ 'Ninja_wiki_notifs' ];
# Auth
$wgPluggableAuth_EnableLocalLogin = false;
$LDAPAuthentication2AllowLocalLogin = true;
$LDAPProviderDomainConfigs = "${config.age.secrets.mediawiki-ldap.path}";
$wgOAuthCustomAuthProviders = [
'note' => WSOAuth\AuthenticationProvider\NoteKfetAuth::class
];
$wgPluggableAuth_Config = [
"Compte Crans" => [
'plugin' => 'LDAPAuthentication2',
'data' => [
'domain' => 'crans'
],
'buttonLabelMessage' => 'crans-connexion-crans'
],
"Note BDE" => [
'plugin' => 'WSOAuth',
'data' => require('${config.age.secrets.mediawiki-oauth.path}'),
'buttonLabelMessage' => 'crans-connexion-note-bde'
]
];
# Theme
$wgDefaultSkin = 'citizen';
$wgCitizenThemeColor = '#AD1F1F';
$wgCitizenEnableARFonts = true;
$wgCitizenEnableCJKFonts = true;
$wgLanguageCode = 'fr';
$wgLocaltimezone = 'Europe/Paris';
$wgDefaultUserOptions['timecorrection'] = 'ZoneInfo|0|' . $wgLocaltimezone;
# Access Control
$wgGroupPermissions['*']['edit'] = false; # Restrict edition for anonymous user
$wgGroupPermissions['*']['createaccount'] = false; # Restrict the creation of account to sysop only
$wgGroupPermissions['*']['autocreateaccount'] = true; # Création de comptes depuis LDAP et Note mais pas local
$wgGroupPermissions['*']['delete'] = true; # Création de comptes depuis LDAP et Note mais pas local
$wgGroupPermissions['user']['sendemail'] = false;
$wgGroupPermissions['sysop']['sendemail'] = true; # On réserve l’envoie de mail aux admins
# Relecture des pages, envoie de mails autorisés
$wgGroupPermissions['patrol'] = [ 'autopatrol' => true, 'patrol' => true, 'sendemail' => true ];
$wgCategoryLockdownWhitelist = [
"Spécial:Connexion",
"Spécial:Déconnexion",
"Spécial:Connexion/return",
"Spécial:PluggableAuthLogin",
"Spécial:Recherche",
"MediaWiki:Common.css",
"MediaWiki:Common.js"
];
$wgCategoryGroupLockdown["!user"]["read"] = [ "Page Publique" ]; # Restrict read for non-user (i.e. anonymous) on execpt for Page Publique
$wgFeed = false; # Pas de flux car ils ne sont pas authentifiés :(.
# Extensions
$wgWikiEditorRealtimePreview = true;
$wgCiteBookReferencing = true;
$wgPdfProcessor = '${pkgs.ghostscript}/bin/gs';
$wgPdfPostProcessor = $wgImageMagickConvertCommand;
$wgPdfInfo = '${pkgs.poppler-utils}/bin/pdfinfo';
$wgPdftoText = '${pkgs.poppler-utils}/bin/pdftotext';
$wgScribuntoDefaultEngine = 'luasandbox';
# 4 pour les catégories, 3 pour le reste https://www.mediawiki.org/wiki/Extension:CategoryTree
$wgCategoryTreeMaxDepth = [10 => 3, 20 => 3, 0 => 4, 100 => 3];
# SyntaxHighlight extension
$wgPygmentizePath = '${pkgs.python3Packages.pygments}/bin/pygmentize';
# Custom Namespaces
define("NS_ARCHIVE", 3000);
define("NS_ARCHIVE_TALK", 3001);
$wgExtraNamespaces = [
NS_ARCHIVE => "Archive",
NS_ARCHIVE_TALK => "Discussion_archive",
];
# Sous pages
$wgNamespacesWithSubpages[NS_MAIN] = true;
$wgNamespacesWithSubpages[NS_ARCHIVE] = true;
$wgNamespacesWithSubpages[NS_CATEGORY] = true;
$wgVisualEditorAvailableNamespaces[NS_ARCHIVE] = true;
# Pour les Popups
$wgContentNamespaces[] = NS_ARCHIVE;
$wgContentNamespaces[] = NS_USER;
# Pour la recherche
$wgNamespacesToBeSearchedDefault[NS_ARCHIVE] = true; # Temporairement pour la migration ?
$wgNamespacesToBeSearchedDefault[NS_USER] = true;
'';
skins = {
Citizen = pkgs.fetchFromGitHub {
name = "Citizen";
owner = "StarCitizenTools";
repo = "mediawiki-skins-Citizen";
tag = "v3.19.0";
sha256 = "sha256-iQS0lyvOYH45gI6fMKYV4ERK9JO35J8ktLNrQi6UsH0=";
};
};
extensions = {
# Enables embedded extensions
AbuseFilter = null; # pour faire de la modération
CategoryTree = null; # pour faire des arbres de catégories
Cite = null; # pour faire des références/footnotes
CiteThisPage = null; # pour avoir la citation (à la bibtex) d'une page
CodeEditor = null; # pour éditer des macros/scripts wiki
DiscussionTools = null; # pour des pages de discussion intéractives
Echo = null; # pour le système de notification du wiki
Gadgets = null; # pour avoir un système d'outils activable par les users
ImageMap = null; # pour mettre des widgets sur des images
Linter = null; # requis pour DiscussionTools
Math = null; # pour avoir des maths LaTeX
MultimediaViewer = null; # pour avoir un affichage sympa des images
Nuke = null; # pour purger des pages
PageImages = null; # pour set la bonne image représentant un article
ParserFunctions = null; # pour la logique et les fonctions de base du templating
PdfHandler = null; # pour afficher les pdfs
Poem = null; # pour afficher des blocks de texte respectant l'espacement (poèmes, écrits)
ReplaceText = null; # pour du méga-renommage à travers le wiki
Scribunto = null; # pour faire du scripting et des templates pour le wiki
SyntaxHighlight_GeSHi = null; # pour avoir de la coloration syntaxique
TemplateData = null; # pour faire de la doc sur les templates
TemplateStyles = null; # pour styliser les templates (bundlé pour la prochaine version de mediawiki)
TextExtracts = null; # pour set le bon text représentant un article
Thanks = null; # pour remercier des gens en notif
TitleBlacklist = null; # pour faire de la modération
VisualEditor = null; # pour éditer visuellement les pages
WikiEditor = null; # pour éditer le code wiki des pages
CategoryLockdown = pkgs.applyPatches {
src = pkgs.fetchFromGitHub {
name = "CategoryLockdown";
owner = "wikimedia";
repo = "mediawiki-extensions-CategoryLockdown";
rev = "d29e734c87697590af80f91d0cdbb4275bc3f072";
# Le SHA doit être changé à chaque nouveau commit de traduction.
# Pas de meilleure solution à ma connaissance pour suivre les releases.
sha256 = "sha256-iYRzCzWu3hndPoMIkFZOaYJkbVoAnVv/ZMZTSAcQdKo=";
};
patches = [
# Cette extension s’occupe des du contrôle d’accès du Wiki
# et a été beaucoup patché. Pensez à vérifier les changements
# et révisez le patch le cas échéant.
"${./category-lockdown.patch}"
];
};
# FullCalendar
FullCalendar = ./FullCalendar;
# Popups
Popups = pkgs.fetchFromGitHub {
name = "Popups";
owner = "wikimedia";
repo = "mediawiki-extensions-Popups";
rev = "82742d6e42750bf5b8000c90fc894b304aa95235";
# Le SHA doit être changé à chaque nouveau commit de traduction.
# Pas de meilleure solution à ma connaissance pour suivre les releases.
sha256 = "sha256-mqiDU7w3B3imMLqoTvwgnyk/vCDWeAiTEX1y4dWmNDk=";
};
# Auth
PluggableAuth = pkgs.fetchFromGitHub {
name = "PluggableAuth";
owner = "wikimedia";
repo = "mediawiki-extensions-PluggableAuth";
rev = "3b9428bd8832aeb8ef8ff1ea61b7a4afca7f0ecb";
# Le SHA doit être changé à chaque nouveau commit de traduction.
# Pas de meilleure solution à ma connaissance pour suivre les releases.
sha256 = "sha256-9PUf7KC0oz4XeN/m/5KbefNpsgRwV2NswMQZl7a/+I0=";
};
LDAPProvider = pkgs.fetchFromGitHub {
name = "LDAPProvider";
owner = "wikimedia";
repo = "mediawiki-extensions-LDAPProvider";
tag = "3.1.0";
# Le SHA doit être changé à chaque nouveau commit de traduction.
# Pas de meilleure solution à ma connaissance pour suivre les releases.
sha256 = "sha256-9/5Uph5C6VPWT7NzrPsYpM9pxZPIQneKZDPFCaYm1e8=";
};
LDAPAuthentication2 = pkgs.fetchFromGitHub {
name = "LDAPAuthentication2";
owner = "wikimedia";
repo = "mediawiki-extensions-LDAPAuthentication2";
tag = "3.1.0";
# Le SHA doit être changé à chaque nouveau commit de traduction.
# Pas de meilleure solution à ma connaissance pour suivre les releases.
sha256 = "sha256-/9VISvpvhaVq/sAHE10INlTju7+PTirFO/6U68WBOgM=";
};
LDAPGroup = pkgs.fetchFromGitHub {
name = "LDAPGroup";
owner = "wikimedia";
repo = "mediawiki-extensions-LDAPGroups";
tag = "3.1.0";
# Le SHA doit être changé à chaque nouveau commit de traduction.
# Pas de meilleure solution à ma connaissance pour suivre les releases.
sha256 = "sha256-aW1nVechxy4CfyT953LfNgeUOXZGeIkV3KwPJJwlyug=";
};
WSOAuth = pkgs.applyPatches {
src = pkgs.fetchFromGitHub {
name = "WSOAuth";
owner = "wikimedia";
repo = "mediawiki-extensions-WSOAuth";
rev = "4b8a12d26196b323be5da93480b6a75fda25b6d0";
# Le SHA doit être changé à chaque nouveau commit de traduction.
# Pas de meilleure solution à ma connaissance pour suivre les releases.
sha256 = "sha256-W2SL14U5nVUcf/aTkWuVk9+vQGd3RCtuR3bAhBMN5iY=";
};
patches = [ "${./WSOAuth.patch}" ];
};
WSONoteKfetAuth = "${./WSONoteKfetAuth}";
};
};
}

View File

@ -1,4 +1,4 @@
{ pkgs, ... }:
{ pkgs, config, ... }:
{
imports = [
@ -9,13 +9,79 @@
networking.hostName = "nextcloud";
boot.loader.grub.devices = [ "/dev/sda" ];
services.autofs = {
enable = true;
services.autofs =
let
autoMaster = pkgs.writeScript "home-nextcloud" ''
#!/usr/bin/env bash
USER=$(echo $1 | sed "s/_[1-9]*$//")
UHOME=/home-adh/$USER
autoMaster = ''
/home-nextcloud /etc/auto.master.d/home-nextcloud.sh -t60
'';
};
USERID=$(ldapsearch -LLL -b "dc=crans,dc=org" -H ldap://172.16.10.157 -D "cn=admin,dc=crans,dc=org" -y ${config.age.secrets.nextcloud_ldap_pass.path} "uid=$USER" uidNumber | grep uidNumber | awk '{print $2}')
UGROUP=$(ldapsearch -LLL -b "dc=crans,dc=org" -H ldap://172.16.10.157 -D "cn=admin,dc=crans,dc=org" -y ${config.age.secrets.nextcloud_ldap_pass.path} "uid=$USER" gidNumber | grep gidNumber | awk '{print $2}')
# On quitte si l'utilisateur $USER n'existe pas
if [ -z "$USERID" ]; then
logger -p local0.error -t autofs "user $USER n'existe pas"
exit 1
fi
# Rafraîchi les stats du dossier
ls ''\${UHOME}/NextCloud/ &>/dev/null || /bin/ls ''\${UHOME} &>/dev/null
# Création du lien symbolique Nextcloud s'il n'existe pas
if [ ! -e "''\${UHOME}/NextCloud" ] || ! (
find ''\${UHOME}/NextCloud/ -maxdepth 0 -user $USERID -group ''\${UGROUP} |
grep -q ''\${UHOME}/NextCloud/
); then
if [ ! -e "''\${UHOME}/NextCloud" ]; then
ln -fs OwnCloud ''\${UHOME}/NextCloud
fi
if [ ! -e "''\${UHOME}/NextCloud" ] || ! (
find ''\${UHOME}/NextCloud/ -maxdepth 0 -user $USERID -group ''\${UGROUP} |
grep -q ''\${UHOME}/NextCloud/
); then
logger -p local0.error -t autofs "impossible de créer le lien symbolique ''\${UHOME}/NextCloud"
exit 1
fi
logger -p local0.info -t autofs "Lien symbolique ''\${UHOME}/NextCloud -> ''\${UHOME}/OwnCloud créé"
fi
# Création du dossier OwnCloud s'il n'existe pas ou n'appartient pas a l'utilisateur
if [ ! -d "''\${UHOME}/OwnCloud" ] || ! (
find ''\${UHOME}/OwnCloud/ -maxdepth 0 -user $USERID -group ''\${UGROUP} |
grep -q ''\${UHOME}/OwnCloud/
); then
if [ ! -d "''\${UHOME}/OwnCloud" ]; then
mkdir ''\${UHOME}/OwnCloud
fi
chmod 700 ''\${UHOME}/OwnCloud &&
chown $USERID:''\${UGROUP} ''\${UHOME}/OwnCloud
if [ ! -d "''\${UHOME}/OwnCloud" ] || ! (
find ''\${UHOME}/OwnCloud/ -maxdepth 0 -user $USERID -group ''\${UGROUP} |
grep -q ''\${UHOME}/OwnCloud/
); then
logger -p local0.error -t autofs "impossible de créer le dossier ''\${UHOME}/OwnCloud"
exit 1
fi
logger -p local0.info -t autofs "Dossier ''\${UHOME}/OwnCloud créé"
fi
chmod 750 /home-nextcloud &&
chown nextcloud:nextcloud /home-nextcloud &&
echo "-fstype=fuse.bindfs,map=$USERID/nextcloud:@$UGROUP/@nextcloud,resolve-symlinks,allow_other :''\${UHOME}/NextCloud"
'';
in
{
enable = true;
autoMaster = ''
/home-nextcloud ${autoMaster} -t60
'';
};
programs.fuse.userAllowOther = true;
systemd.services.autofs = {

View File

@ -12,15 +12,19 @@
owner = "nextcloud";
group = "nextcloud";
};
# Attention : sensible au retour à la ligne final (il n’en faut pas) !
nextcloud_ldap_pass = {
file = ../../../secrets/nextcloud/nextcloud_ldap_pass.age;
};
};
services.nextcloud = {
enable = true;
package = pkgs.nextcloud33;
package = pkgs.nextcloud34;
configureRedis = true;
hostName = "nextcloud.crans.org";
https = false;
https = true;
maxUploadSize = "4G";
@ -41,11 +45,26 @@
trusted_proxies = [
# hodaur
"172.16.10.145"
# reverseproxy
"172.16.10.151"
];
};
mail_domain = "crans.org";
mail_from_address = "root";
mail_smtphost = "smtp.crans.org";
mail_smtpport = 25;
};
appstoreEnable = true;
extraAppsEnable = true;
};
# Activation CORS pour récuppérer les agendas depuis le wiki
# TODO: à mettre que sur /remote.php/dav/public-calendars/ pour éviter des requêtes malveillantes depuis le wiki.
services.nginx.virtualHosts.${config.services.nextcloud.hostName}.extraConfig = ''
add_header 'Access-Control-Allow-Origin' 'https://mediawiki.crans.org' always;
add_header 'Access-Control-Allow-Methods' 'GET' always;
add_header 'Access-Control-Allow-Credentials' 'false' always;
add_header 'Vary' 'Origin' always;
'';
}

View File

@ -1,4 +1,8 @@
{ pkgs, ... }:
{
pkgs,
lib,
...
}:
let
formatJSON = pkgs.formats.json { };
@ -84,6 +88,11 @@ let
path_regex = "^/[a-zA-Z0-9_-]*/public/.*";
action = "ALLOW";
}
{
name = "allow-phpmyadmin";
path_regex = "^/phpmyadmin/.*";
action = "ALLOW";
}
{
# geneau utilisait ses pages persos avant la mise en place d'Anubis.
# Le déploiement d'Anubis l'a impacté (cf les mails de fin 2025).
@ -99,7 +108,7 @@ let
}
{
name = "challenge-other";
path_regex = "^*";
path_regex = "^.*";
action = "CHALLENGE";
}
];
@ -128,28 +137,118 @@ let
];
};
anubisWiki = formatJSON.generate "anubis_wiki.json" {
"bots" = [
{
import = "${antiBot}";
}
{
name = "allow-mediawiki-images";
path_regex = "^/w/images/.*";
action = "ALLOW";
}
{
name = "allow-mediawiki-api";
path_regex = "^/w/api.php.*";
action = "ALLOW";
}
{
name = "challenge-other";
path_regex = ".*";
action = "CHALLENGE";
}
];
};
fetchFromCrans =
opts:
pkgs.fetchFromGitLab (
{
domain = "gitlab.adm.crans.org";
owner = "nounous";
}
// opts
);
installPartySite = pkgs.python3Packages.buildPythonApplication {
name = "site-install-party";
pyproject = false;
src = fetchFromCrans {
repo = "site-install-party";
rev = "4dd5fdfe8e588f3ed4dd6966fc9b72234ecdba17";
hash = "sha256-kiuiC99TMpLbefByUAm5hBskqKuTOa2YumKDB+jO7UY=";
};
build-system = with pkgs.python3Packages; [
mkdocs
mkdocs-material
];
# On ne met pas directement dans $out pour éviter la fixupPhase
buildPhase = ''
python3 -m mkdocs -- build -d $out/site
'';
};
homepagePkg = pkgs.stdenv.mkDerivation {
name = "site-homepage";
src = fetchFromCrans {
repo = "homepage";
rev = "a71446ad7c125241835190a4035159bc7be751bc";
hash = "sha256-dAKJpBnGeDtv0UM8gXsdqUCzbliskRMYUEQ1KGCW1Cw=";
};
nativeBuildInputs = with pkgs; [
hugo
];
buildPhase = ''
hugo build -d $out
'';
};
in
{
crans = {
reverseProxy = {
enable = true;
virtualHosts = {
"autoconfig".serveLocalFiles = ./staticsites/autoconfig;
"cas".proxyPass = "172.16.10.120";
"collabora" = {
target = "172.16.10.149";
proxyPass = "172.16.10.149";
proxyWebsockets = true;
};
"eclat" = {
anubisConfig = "${anubisMirrors}";
httpOnly = true;
target = "172.16.10.104";
proxyPass = "172.16.10.104";
};
"eclats" = {
anubisConfig = "${anubisMirrors}";
target = "172.16.10.104";
proxyPass = "172.16.10.104";
};
"element".proxyPass = "172.16.10.118";
"ethercalc" = {
proxyPass = "172.16.10.133:8000";
serverAliases = [ "excel" ];
};
"framadate".proxyPass = "172.16.10.109";
"grafana" = {
proxyPass = "172.16.10.121:3000";
serverAliases = [ "fyre" ];
};
"imprimante" = {
proxyPass = "172.16.10.131";
serverAliases = [ "helloworld" ];
};
"intranet" = {
proxyPass = "172.16.10.156";
serverAliases = [ "re2o" ];
};
"install-party" = {
anubisConfig = "${anubisChallenge}";
target = "/var/www/install-party.crans.org";
serveLocalFiles = "${installPartySite}/site";
serverAliases = [
"i-p"
"adopteunmanchot"
@ -158,38 +257,68 @@ in
};
"lists" = {
anubisConfig = "${anubisChallenge}";
target = "172.16.10.110";
proxyPass = "172.16.10.110";
};
"mediawiki" = {
anubisConfig = "${anubisChallenge}";
target = "172.16.10.144";
serverAliases = [
"mediakiwi"
];
anubisConfig = "${anubisWiki}";
proxyPass = "172.16.10.144";
serverAliases = [ "mediakiwi" ];
};
"mirrors" = {
anubisConfig = "${anubisMirrors}";
target = "172.16.10.104";
proxyPass = "172.16.10.104";
};
"mirror" = {
anubisConfig = "${anubisMirrors}";
httpOnly = true;
target = "172.16.10.104";
proxyPass = "172.16.10.104";
};
"nekorale".globalRedirect = "perso.crans.org/club-nekorale";
"nextcloud".proxyPass = "172.16.10.146";
"owncloud".proxyPass = "172.16.10.136";
"pad".proxyPass = "172.16.10.130:9001";
"pdf" = {
proxyPass = "172.16.10.140";
serverAliases = [
"livre"
"stirling"
"stirling-pdf"
];
};
"perso" = {
anubisConfig = "${anubisPerso}";
target = "172.16.10.31";
serverAliases = [
"clubs"
];
proxyPass = "172.16.10.31";
serverAliases = [ "clubs" ];
};
"pot-vieux".globalRedirect = "perso.crans.org/club-vieux";
"re2o-dev".proxyPass = "172.16.10.166";
"services".serveLocalFiles = fetchFromCrans {
repo = "services-page";
rev = "e32ac21513d88e5123eb4138835453df64aa21d0";
hash = "sha256-nc4IjA/wtj7WXnt0wZqGymGz/NR45STlMLbsTdnSzJ0=";
};
"snl".globalRedirect = "perso.crans.org/sonetlumens";
"test-melon".proxyPass = "172.16.10.150:8080";
"tmpad".proxyPass = "172.16.10.130:9002";
"vaultwarden" = {
proxyPass = "172.16.10.159";
serverAliases = [ "pass" ];
};
"webirc".proxyPass = "172.16.10.31:9000";
"webmail" = {
proxyPass = "172.16.10.107";
serverAliases = [ "roundcube" ];
};
"wiki" = {
anubisConfig = "${anubisChallenge}";
target = "172.16.10.161";
serverAliases = [
"wikipedia"
];
proxyPass = "172.16.10.161";
serverAliases = [ "wikipedia" ];
};
"_crans-org" = {
serveLocalFiles = homepagePkg;
serverAliases = [ "www" ];
};
"zero".proxyPass = "172.16.10.130";
};
};
@ -197,4 +326,14 @@ in
acme.enable = true;
};
};
# Règle particulières
services.nginx.virtualHosts = {
"www-alias-crans.org".default = true;
"_crans-org".locations = {
"/_matrix/".proxyPass = "https://matrix.crans.org/_matrix/";
"/_synapse/".proxyPass = "https://matrix.crans.org/_synapse/";
};
};
}

View File

@ -0,0 +1,23 @@
<clientConfig version="1.0">
<emailProvider id="crans.org">
<domain>crans.org</domain>
<domain>crans.eu</domain>
<domain>crans.fr</domain>
<displayName>Cr@ns Mail</displayName>
<displayShortName>Cr@ns</displayShortName>
<incomingServer type="imap">
<hostname>imap.crans.org</hostname>
<port>993</port>
<socketType>SSL</socketType>
<username>%EMAILLOCALPART%</username>
<authentication>plain</authentication>
</incomingServer>
<outgoingServer type="smtp">
<hostname>smtp.crans.org</hostname>
<port>465</port>
<socketType>SSL</socketType>
<username>%EMAILLOCALPART%</username>
<authentication>plain</authentication>
</outgoingServer>
</emailProvider>
</clientConfig>

View File

@ -1,4 +1,9 @@
{ config, lib, ... }:
{
config,
lib,
self,
...
}:
let
cfg = config.crans.users;
@ -34,6 +39,20 @@ in
users = {
mutableUsers = false;
motd =
let
shortRev = self.shortRev or self.dirtyShortRev or "inconnue";
rev = self.rev or (lib.strings.removeSuffix "-dirty" (self.dirtyRev or "inconnue"));
isDirty = !(self ? rev);
in
''
${
if isDirty then "!! DIRTY !! " else ""
}Version déployée ${shortRev}, modifiée le ${self.lastModifiedDate or "??"}.
https://gitlab.crans.org/nounous/nixos/-/commit/${rev}
'';
users.root = {
hashedPasswordFile = config.age.secrets.root-passwd-hash.path;
};

View File

@ -2,5 +2,4 @@
{
services.qemuGuest.enable = true;
boot.kernelParams = [ "console=ttyS0,115200" ];
}

View File

@ -1,4 +1,4 @@
{ lib, ... }:
{ lib, self, ... }:
{
imports = [
@ -25,6 +25,9 @@
cleanOnBoot = true;
};
# Version de la flake pour nixos-version --configuration-revision
system.configurationRevision = self.rev or self.dirtyRev or "inconnue";
# Recommandé depuis le passage à 26.05
boot.zfs.forceImportAll = lib.mkDefault false;
boot.zfs.forceImportRoot = lib.mkDefault false;

View File

@ -24,6 +24,8 @@ in
dnsPropagationCheck = false;
};
# Certificat wildcard par défaut pour les services utilisant acme
# TODO: À déplacer sur les VMs qui en ont besoin (e.g. coturn…).
certs."crans.org" = {
domain = "*.crans.org";
dnsProvider = "rfc2136";

View File

@ -6,5 +6,13 @@
recommendedOptimisation = true;
clientMaxBodySize = "512M";
# On trust nos ip internes pour bien indiquer le client au bout
# i.e. on considère pas le reverseproxy comme notre client
appendHttpConfig = ''
set_real_ip_from 172.16.0.0/16;
set_real_ip_from fd00::/56;
real_ip_header X-Real-Ip;
'';
};
}

View File

@ -1,5 +1,4 @@
{
pkgs,
lib,
config,
...
@ -8,28 +7,6 @@
let
cfg = config.crans.reverseProxy;
formatJSON = pkgs.formats.json { };
allowAll = formatJSON.generate "allow_all.json" {
bots = [
{
name = "allow_all";
path_regex = ".*";
action = "ALLOW";
}
];
};
open_graph = formatJSON.generate "opengraph.json" {
openGraph = [
{
enabled = true;
considerHost = true;
ttl = "24h";
}
];
};
mainTld = "org";
otherTld = [
"fr"
@ -43,6 +20,8 @@ let
mkOption
types
;
mkHostName = als: tld: if als == "_crans-org" then "crans.${tld}" else "${als}.crans.${tld}";
in
{
@ -65,18 +44,36 @@ in
'';
};
target = mkOption {
type = types.str;
default = "";
proxyPass = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Indique la destination. Il peut s'agir du chemin vers des fichiers statiques.
Indique la destination à proxy.
'';
example = "172.16.10.128:8000";
};
serveLocalFiles = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Chemin vers un dossier à exposer statiquement.
'';
example = "/var/local/adopter-un-manchot";
};
globalRedirect = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Si définie, toutes les requêtes sont redirigées (via 301) sur cet hôte.
'';
example = "perso.crans.org/club";
};
anubisConfig = mkOption {
type = types.str;
default = "";
type = types.nullOr types.str;
default = null;
description = ''
Chemin du fichier de configuration
'';
@ -95,7 +92,7 @@ in
type = types.bool;
default = false;
description = ''
Interdit les connexions en ssh
Interdit les connexions en https
'';
example = "true";
};
@ -134,107 +131,206 @@ in
wantedBy = [ "multi-user.target" ];
}) cfg.virtualHosts;
# On fait un certificat commun pour tous les hosts
security.acme.certs."all.crans.all" = mkIf cfg.enable {
domain = "*.crans.${mainTld}";
dnsProvider = "rfc2136";
# Contient le serveur à contacter avec le protocole
# et le mot de passe
environmentFile = config.age.secrets.acme-env.path;
extraDomainNames =
lib.concatMap (tld: [
"*.crans.${tld}"
"crans.${tld}"
]) otherTld
++ [
"crans.${mainTld}"
];
group = config.services.nginx.group;
};
# Création des dossiers de logs
system.activationScripts = {
nginx-logs = {
text =
lib.concatMapStrings (f: "mkdir -p /var/log/nginx/${f}/\n") (
[ "anubis" ] ++ (lib.map (hn: "host/${hn}") (builtins.attrNames cfg.virtualHosts))
)
+ "chown -R ${config.services.nginx.user}:${config.services.nginx.group} /var/log/nginx/\n";
};
};
age.secrets.anubis-secret = mkIf cfg.enable {
file = ../../secrets/reverseproxy/anubis-secret.age;
owner = "anubis";
};
services = mkIf cfg.enable {
anubis = {
defaultOptions.group = "nginx";
instances = lib.mapAttrs (vhostName: vhostConfig: {
enable = true;
settings = {
BIND = "/run/anubis/anubis-${vhostName}/socket.sock";
BIND_NETWORK = "unix";
METRICS_BIND = "/run/anubis/anubis-${vhostName}/anubis-${vhostName}-metrics.sock";
TARGET = "unix:///run/nginx/nginx-${vhostName}.sock";
COOKIE_DOMAIN = "crans.org";
REDIRECT_DOMAINS = "${vhostName}.crans.org";
SOCKET_MODE = "0660";
# OpenGraph config
OG_PASSTHROUGH = vhostConfig.anubisOpenGraph;
OG_EXPIRY_TIME = "24h";
OG_CACHE_CONSIDER_HOST = true;
# Policy config
POLICY_FNAME = if (vhostConfig.anubisConfig == "") then "${allowAll}" else vhostConfig.anubisConfig;
};
}) cfg.virtualHosts;
instances = lib.mapAttrs (
vhostName: vhostConfig:
let
hostname = mkHostName vhostName mainTld;
in
mkIf (vhostConfig.anubisConfig != null) {
enable = true;
settings = {
BIND = "/run/anubis/anubis-${vhostName}/socket.sock";
BIND_NETWORK = "unix";
METRICS_BIND = "/run/anubis/anubis-${vhostName}/anubis-${vhostName}-metrics.sock";
TARGET = "unix:///run/nginx/nginx-${vhostName}.sock";
COOKIE_DOMAIN = hostname;
# TODO: db pour mettre en commun les token.
# Transitoirement on les sépare
COOKIE_PREFIX = "anubis-${vhostName}";
REDIRECT_DOMAINS = hostname;
SOCKET_MODE = "0660";
# Secret partagé entre les instances
ED25519_PRIVATE_KEY_HEX_FILE = config.age.secrets.anubis-secret.path;
# OpenGraph config
OG_PASSTHROUGH = vhostConfig.anubisOpenGraph;
OG_EXPIRY_TIME = "24h";
OG_CACHE_CONSIDER_HOST = true;
# Policy config
POLICY_FNAME = vhostConfig.anubisConfig;
};
}
) cfg.virtualHosts;
};
logrotate.settings =
let
# https://github.com/NixOS/nixpkgs/blob/nixos-26.05/nixos/modules/services/web-servers/nginx/default.nix
base = {
su = "${config.services.nginx.user} ${config.services.nginx.group}";
postrotate = "[ ! -f /var/run/nginx/nginx.pid ] || kill -USR1 `cat /var/run/nginx/nginx.pid`";
frequency = "daily";
ifempty = true;
compress = true;
delaycompress = true;
dateyesterday = true;
};
in
{
nginx-anubis = base // {
files = [ "/var/log/nginx/anubis/*.log" ];
rotate = 7; # une semaine de log pour débugguer
};
nginx-hosts = base // {
files = [ "/var/log/nginx/host/*/*.log" ];
rotate = 173; # 6 mois de logs
dateext = true;
dateformat = "%Y-%m-%d";
};
};
nginx =
let
# Configuration du serveur principal.
mainConfig = lib.mapAttrs' (
configVhost =
vhostName: vhostConfig:
lib.nameValuePair (vhostName + "-anubis") {
enableACME = !vhostConfig.httpOnly;
forceSSL = !vhostConfig.httpOnly;
rejectSSL = vhostConfig.httpOnly;
locations."/" = {
proxyPass = "http://unix:/run/anubis/anubis-${vhostName}/socket.sock";
proxyWebsockets = vhostConfig.proxyWebsockets;
let
hostname = mkHostName vhostName mainTld;
sslConf = {
forceSSL = !vhostConfig.httpOnly;
rejectSSL = vhostConfig.httpOnly;
useACMEHost = mkIf (!vhostConfig.httpOnly) "all.crans.all";
acmeRoot = null;
};
serverName = "${vhostName}.crans.${mainTld}";
extraConfig = "
set_real_ip_from 172.16.0.0/16;
set_real_ip_from fd00::/56;
real_ip_header X-Real-Ip;
";
}
) cfg.virtualHosts;
# Redirections
redirectConfig = lib.mapAttrs (vhostName: vhostConfig: {
# Redirection vers d'autres machines
locations = mkIf (!lib.strings.hasPrefix "/" vhostConfig.target) {
"/favicon.ico".root = "/var/www/logo/";
"/" = {
proxyPass = "http://${vhostConfig.target}";
proxyWebsockets = vhostConfig.proxyWebsockets;
};
};
# Redirection vers des fichiers locaux
root = mkIf (lib.strings.hasPrefix "/" vhostConfig.target) vhostConfig.target;
listen = [
{ addr = "unix:/run/nginx/nginx-${vhostName}.sock"; }
];
serverName = "${vhostName}.crans.${mainTld}";
extraConfig = "
set_real_ip_from unix:;
real_ip_header X-Real-IP;
";
}) cfg.virtualHosts;
# Génération des alias
getAliases = name: config: lib.foldr (
tld: acc:
acc
++
(lib.foldr (alias: acc: acc ++ ["${alias}.crans.${tld}"]) ["${name}.crans.${tld}"] config.serverAliases)
)
(lib.foldr (alias: acc: acc ++ ["${alias}.crans.${mainTld}"]) [] config.serverAliases)
otherTld;
# Configuration des alias
aliasConfig = lib.foldr (
vhost: acc:
acc
//
lib.foldr (
alias: acc:
acc
//
{
"${vhost.name}-alias-${alias}" = rec {
rejectSSL = vhost.value.httpOnly;
forceSSL = !rejectSSL;
enableACME = !rejectSSL;
serverName = alias;
globalRedirect = "${vhost.name}.crans.${mainTld}";
};
# Conf pour l’hôte en entrée
entryExtraConf = {
extraConfig = ''
set_real_ip_from 172.16.0.0/16;
set_real_ip_from fd00::/56;
real_ip_header X-Real-Ip;
'';
}
) { } (getAliases vhost.name vhost.value)
) { } (lib.attrsToList cfg.virtualHosts);
// sslConf;
# Conf supplémentaire pour le proxy principal
vhostExtraConf =
if vhostConfig.anubisConfig != null then
# Il reçoit les requêtes d’Anubis
{
listen = [
{ addr = "unix:/run/nginx/nginx-${vhostName}.sock"; }
];
extraConfig = ''
set_real_ip_from unix:;
real_ip_header X-Real-IP;
'';
}
else
# Il est en entrée
entryExtraConf;
logHostConf = ''
access_log /var/log/nginx/host/${vhostName}/access.log;
error_log /var/log/nginx/host/${vhostName}/error.log;
'';
# Les alias : vhostName × otherTld U serverAliases × allTld
aliases =
lib.foldr
(
tld: acc:
acc
++ (lib.foldr (alias: acc: acc ++ [ (mkHostName alias tld) ]) [
(mkHostName vhostName tld)
] vhostConfig.serverAliases)
)
(lib.foldr (alias: acc: acc ++ [ (mkHostName alias mainTld) ]) [ ] vhostConfig.serverAliases)
otherTld;
in
{
# Configuration du service à proxy.
"${vhostName}" = vhostExtraConf // {
serverName = hostname;
locations."/" = mkIf (vhostConfig.proxyPass != null) {
proxyPass = "http://${vhostConfig.proxyPass}";
proxyWebsockets = vhostConfig.proxyWebsockets;
};
root = vhostConfig.serveLocalFiles;
globalRedirect = vhostConfig.globalRedirect;
extraConfig = vhostExtraConf.extraConfig + logHostConf;
};
# Entrée d’Anubis
"${vhostName}-anubis" = mkIf (vhostConfig.anubisConfig != null) (
entryExtraConf
// rec {
serverName = hostname;
locations."/" = {
proxyPass = "http://unix:/run/anubis/anubis-${vhostName}/socket.sock";
proxyWebsockets = vhostConfig.proxyWebsockets;
};
locations."/.within.website/x/cmd/anubis/api/honeypot/" = locations."/" // {
extraConfig = ''
add_header 'X-Robots-Tag' 'noindex,nofollow' always;
'';
};
extraConfig = entryExtraConf.extraConfig + ''
access_log /var/log/nginx/anubis/access.log;
error_log /var/log/nginx/anubis/error.log;
'';
}
);
}
# Les alias
// lib.mergeAttrsList (
lib.map (alias: {
"${vhostName}-alias-${alias}" = sslConf // {
serverName = alias;
globalRedirect = lib.defaultTo hostname vhostConfig.globalRedirect;
extraConfig = logHostConf;
};
}) aliases
);
in
{
enable = true;
virtualHosts = redirectConfig // aliasConfig // mainConfig;
virtualHosts = lib.concatMapAttrs configVhost cfg.virtualHosts;
};
};
};

View File

@ -40,6 +40,7 @@ let
collabora = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFa2D9fREtO2r2oIx6q9JAKFUHtxGbgEPMjkx09DQSU8 root@collabora";
jitsi = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB6jVMIZ5y2oXX9HOkw7r5UUjw95MlFaFuu7FnEC0Q8z root@jitsi";
livre = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEVfKNokHG6ig32hhQxTep+fKFmKahlDClPrX/dP4/gb root@livre";
mediakiwi = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFfcKtim7Aj+strHdg847D7KCCdoOAuasxx/2yi2TY1n root@mediakiwi";
neo = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMGfSvxqC2PJYRrxJaivVDujwlwCZ6AwH8hOSA9ktZ1V root@neo";
nextcloud = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMgSP9UmuJw8Bi2ML07WHsWvxN8akkc9XZxXyOgdjXkq root@nextcloud";
periodique = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHTdfSIL3AWIv0mjRDam6E/qsjoqwJ8QSm1Cb0xqs1s1 root@periodique";

Binary file not shown.

View File

@ -0,0 +1,33 @@
age-encryption.org/v1
-> ssh-ed25519 ZpDcxw Z/XNMwkMr7AXaMpDc/Q7ASWWwv/ID5pRpSUyx66R+2Y
8IWRR7cQVrrBFwAg0zHnbliWZWDKsa2PmdxHZsCFQLM
-> piv-p256 ewCc3w AkDTktwBj1dnAA63otR9BhIAyjcPoSww4G8/cP1zSX+j
i+ftraOs9yZ51gUumFA+zJZzgAPRtVyuPfUsA0hlXnI
-> piv-p256 6CL/Pw AsD6ZBVGl8HU3FQKnuskPk8qFHPgxK+9adJGuuzik88c
11sTCEgccvVjS29h2j3d7PwNZsdPqf4Y/9tagHbE2HM
-> ssh-ed25519 eOAUSg wdE8dGVe35YXCdpXfko+lLsJTgJUmal7OSII3r4Somw
nXbdTQ6KrGbb/FMSiELLuQexNut8qefOIcGyocbYbiA
-> ssh-rsa REaZBA
S8twRuHh+Oety2nRkiOyugu5ZgCaaoapLe4UF9CNCy0ldX87t+Y2va/WdAB2q/UU
evMdN8MOplM8UxkQy69I2BlT26M9QktFuo90ktXK35DrFwiaP8a4aKOyiKzXFdcZ
h4F7Af//lMlcwLYFARbAuXTIj5w8/Hd9IvhAbFoqtXo5+a2n6MgHdc5LvlLSjf9v
aP7+01jGcT2lSbmw8YS8+caYE3LVlUh2ThTeFQKYWm+nE78IP19/TOabmNnx8eBX
LtJggwnoNPrwmn53fn1tADI1+6QoIpTIrZOZLhAKnzDc5oasVNyjAY9NGC/U6A8e
9eaPn3KmIg6QBg8hXUCLH4JeGHFU0hIxCeLotoh1F1HEi+CY3tPuylpny08/J3AJ
i3+RvguyuJ3M5zwoMDvmavPw5mcV9tvWYhUSAWd6iZ6DIN1AHFsXQFXQuRn4P+3h
v9faikTZrt/sv4ZQgWKNoTJ6CDxcAnhDkTtlgG4afjreFvRuB7esQz8v+G6BhUcE
-> ssh-ed25519 J/iReg aY/7+a5eakXhTJcsTR6ES7dTNBbmgh5riO0fhHzN4V8
g4c9HRqdnw8oW3XZaDzUGl8FiVRe/Zwf/uewt8Henlk
-> ssh-ed25519 GNhSGw pBleL7cbEZQhQRPP6wSLIJ9SmU7xCjdanDzQvzVBuUg
d7QKEOygw4M8ne+iFiU9DxOwBGTC9JNS/2UPx9zjLXs
-> ssh-ed25519 eXMAtA PRMzXld8kf3oLoqORVVeO0THG2g6as9vTwGhznQPZBo
i+Ea7CfolHGjvoDuIeHl4bCF20718pV7WrlES+Sf2bE
-> ssh-ed25519 5hXocQ 0e9eAjPAQee/VZvL2PYm9RgoVBnkOgI/y0pblLvoRW4
PBfnrfOakv2ACKrDYs/to1zVaYldLiLldaUxn3GY/5k
-> ssh-ed25519 bRHVVA gX62eG+wJesfSBd4lXzTqNMI+FjPl6fnztXygR8HDx0
mML+owf1iLKGT9ZZkMUu2/l10eSPZEl5TwyAftOOyDA
-> ssh-ed25519 HgW9eA 3Qp1NEa89r/JsT4GKnqDPaTmJaEI1on9D5n5I84DzhE
hH9Kf6l1s1R3zT69iIXmKEB0qPE/i850WMpfb2lk3hE
--- AAABc4YYLcIgv6zjThJ9BhOkR24VZ4wMV+LPR9yTtOM
¢Bá.áZñ§—3PÀ5¸ÇwÔ!™)Ñ<>}Ú[UèB™ö±ÛråNØÚI^

View File

@ -0,0 +1,33 @@
age-encryption.org/v1
-> ssh-ed25519 ZpDcxw EYeeHlk7G0Ce8Zjz7DOA6umVE58ZNeRb1ao86/romSA
iCLH6+fsNCeM5heK2QFh9kEY44BiDOQ8um4MCNUDeuI
-> piv-p256 ewCc3w A+QtLSWeA4j0756rlETtyvJRiZR3y70+UMs/X1/u0fqq
FslsT1yQ+NNHK8m/eBLWeNcsPu2BnubNO1h/AO95UxY
-> piv-p256 6CL/Pw AxHghSnLJmHJz9rXlP1IQDoigFTkFUQiE7h3c0EMeLsc
bmI1TbflKxJ6Axg/TgDEtCV0pw7tY6V/pMl4CJGjvL8
-> ssh-ed25519 eOAUSg TPhT5qmh2Y9b0eBa41uTdIDdZEcDZtR6tEV8Jo1MuCk
gBD5CQlnxMYohS13RvzoDDQeGDhYOEcS18Xv1S/5f+M
-> ssh-rsa REaZBA
NtWXPRN/qPJ5XUZNXt64Rt3brMfbXxP2ARvXo3wpnB0ed0QPnX29OTyVm/O2VNrY
5jPouFaxWwDBuwyJ/HyJJB5SdrE188u7du/M0Z8l1/H8i2lpkp98XS64AEXkvrHo
oWFsRloCWyeIU3m1MtUdqu/c3pnqyiE1SlCnajb6MUUrhDe2CdEEgJxFsIyMwXpW
mkwtHxe9ngVebRLY3vw9OVhtqrKeKrNvSGGCE+IFn6kWuNoGD/rDI52kx76sNa0D
531FFAdsiPt1VKZi0YBaUfRP407sc6rw8R258jFCeYpa58Zud62BWsT0d464gPkD
tJQUZecCPRW0jflpVWzqZHIVurtp3Dqjp/oovmMMJ303/ywL0VdZR6PeMndGOkf+
ame4zqTwOyQoxSkU+62D/KeSzM0QlVqAZhL+lMfYkFCYizZYQZSlLAHi7nF3704W
S+nrv+FNNxLYT6zlq0hHokP9fIfUct/eFfMmV2ff9E77Tow/79TpgQnfxxdtJWJG
-> ssh-ed25519 J/iReg ZAxHEQjYmqQkMFth5Lp344f0JqwfGh4CO6DEG+uMmys
qlLlf9eLNNw3/o++6LVpy2Anj3TWkNbcJoJ/rP4JKsI
-> ssh-ed25519 GNhSGw 44kEvp1o41BaNRwFnAjKB4K3otIYhN7QN9FgCd+eSQ4
fqZM/JCIderSDDrxckv4VLZu64Dfx275UVmfKsozpKU
-> ssh-ed25519 eXMAtA mPn1SPpt7RkhMBLqUZ4mWGvqNeGtUN+pcA9jjKKGPGU
bGyck1ZRHRlzk+HVmL3e99MiVFxjTuC7v/6WHjRjH8A
-> ssh-ed25519 5hXocQ bT0MsqVcYEcB/ckkuAC0zbH5+pfs4iPso9MHhVUyx2U
jZQOFmGWnZMtdTS5ePKIvnQFiOVsZTC/A09ZmXzveho
-> ssh-ed25519 bRHVVA NSnH4xuAWnDgUIpi+csZOvq0K6u99Dt1IUGDrzZGcgQ
L9WOetohzpDPAcF/1jGPmWnBhazSDyIU/0or+il2/ug
-> ssh-ed25519 HgW9eA LdB9v8WGmExJffyQa8KuudNr97nyxshWj9j0YMDJKlI
0BCRYHWmulVjwtlYUR2Xi4rdnLSRQTLL2jn8N5rubSo
--- y4i6KM8sEq07Sx594lC7WX9hEtq9ce4Vm9tTU8loJtc
2Õ‹r};‰„Ï,"¾èívß&ê­ÀÂd¬¼}™Ž×8Uê_¼¨VY¸µÅn§h€ZúħÈgê`Nf`

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,33 @@
age-encryption.org/v1
-> ssh-ed25519 APVFfA Z+0daNofon4xgikMa0TUQjUgp9jPPrQkOZJuEJDUmzM
l/QeEQvggQtfgyrDcs9lRpvuFMEgn9i/wQFpuaVoAZ0
-> piv-p256 ewCc3w AzbC2uqqhtetX1z1XlyVxexejzk8tvPNi/3rcGgOyMSZ
cpl1dYYvqgkXbpvy0JcVabP7Ftb7cGY+IahV/yyDbhs
-> piv-p256 6CL/Pw AixE5HedgCUVyin52g5YHUQFKaSr7hlF9LfsmbQPMf1l
loUpO6pz/Qv+F0mSefpCP3IeDeJ/pq5hYlzacdAtlo4
-> ssh-ed25519 eOAUSg b49cZWhT7+NLJlrH9EyWu6GPLkST9/T5BGU1azdQoBI
dhzYN+L3k8CZRMbVgff1ofqlImyuD19PUGhhsxTHaMY
-> ssh-rsa REaZBA
CWiPTj/40DTjN2GWUsR92frMYiBP7tfSVADQ/+GCapOMNJD91MGxaiK0IocbJr0P
KpOjfdjRDG8KIYszNgaS8XkHn/CUj2KsnRV2WBg3HWSrMdD4quoOxKWbFaU3b38C
4mUK8gIkjbJFWFMSTOhAexHGYSRLl7fG/0VmRvc2ibs5CahbPsMbFDKuto7ApYhY
e/t5SsYgIkbrNpeTLolO943YCIlU/JVP5XOuEDk1ZlKPRc+igpgqFjsD3LsV56Rr
LFrjp4i5wJeA72HeH/S7nP96sAwxyLDHQrWhw3ntM0n2SRRVCVmLqNNOebUL59gy
HRHHoZosepmHzMEOHQ+CrIxuYMiUQ/AyMkZmMTjxQsLniakJWI06G54f1+aaA0F9
BhwFateWkLBNdkSdwqJpBAzh427yetzp/bac8dcTv45ylgsvCVSDpoew5NLoIuh5
I/54NC6Pr5BF0LOW7b2fR9pzk7Rv6hnSxTbeyATeMhh7nTjdcFalUI3Bd1P3RL+i
-> ssh-ed25519 J/iReg wbkKYkdu/cxnZNEGXH0a5R5jV6cQlMpWFMdQiUpWTiQ
cW3rZQMYrzhx93lsxoPhA8AVQLygKytSue3eEF/yxcc
-> ssh-ed25519 GNhSGw Hd4AOis8kPKDKrOgRZNj9pG0bHFHgLfJwo6sd1/dSFg
07UA5cZbEpf3pA7i04CDX6m9FZF5ahAorfp5GzXJSS4
-> ssh-ed25519 eXMAtA hZ278rlg0u1Bp8rkEQGFagLYg3lkXK3uSolnUbIy0Bo
k2sPaUhiCCaBUogPxrUk3Fm386DKuR7UBrGMAteykps
-> ssh-ed25519 5hXocQ sYQmrGm4eQlgE4Lkn+ZFE4uI6czxM46SMpQHFRedtQU
cIFA9Vk091Q5hFUUrjf6PRr/WS7XN1BfgpSOZN1m0xI
-> ssh-ed25519 bRHVVA 2n976nXhvJzoFjP+JFe6L1rmCEmTr4YwhTR1rqyA1y4
/X27rYKrHUB0Jltxc190yPXCiHlOXX3QEXM52BnON60
-> ssh-ed25519 HgW9eA nNf/0O1vh6+uv5i1+HEy6MgF3VUImMru8BFCcJY7R1s
fPSK5LcFR6WP25nFvJRs9RatiJ4t/LvIFYqdoOYxu/k
--- QKKNQqbCPvtps4QlI3ric0JdAcdJ9g3QkTPj+RUGAis
[xqZ†ÿ¥ªSD’ˆcO ÌfkŠÖý ”N¹±›;<3B>H]IòÉ£ÖùÎA‘€A

View File

@ -0,0 +1,36 @@
age-encryption.org/v1
-> ssh-ed25519 ZpDcxw yQPK+1aqMhup98+6GN/2i/BvUc7pmZZD4Narkdlg2A8
GTub9L+DeO3Jc/FF5RSwuSutXpirNHA/tozS4beSpEs
-> piv-p256 ewCc3w A4Lc20pRdb/qBbrBOmz5kxxzaz0efzEKy8xIA8uS5KAu
j7apD2Xca3EH8bWAz0uzKmic38+P1yje7FrLy80vb5Y
-> piv-p256 6CL/Pw AootwQpQE58CZZqPbs1/MlyfG6tf/uVQSMz1QKRjJfbZ
AlvdwLqgz+N3GkAMPL4WURg5QPL9i8W6ySiS+r4SjpU
-> ssh-ed25519 eOAUSg LXj/21Dw32+b0mudgaJWzYywsjEwvITr5FNsIhCgaUc
h8sjcz1vppK+Dg3vo2APZ0jd9xHrvdXQherpd0TSNdU
-> ssh-rsa REaZBA
am1cDikdZtSGm7FNtAwq/+hu4FtcVQTrMUPh4tRPlZ4iThra0BHgm4NLZny06Fuc
2crbyM4b2TWBK6KNCBTyGDTViuavP3QgFAPW2IEX/g7sFWqEZfGk9I01QZnnpT6x
phyNR+/3UsDEAt/6Bn0CmIiXZzPJ2uQ4CEsvK3HW9632GnnUZp6wMDgHCa7XNViL
l6GkzBg3Kp7/eI53F5wlOQ0UYj8fXeug6ZUr2wc2KT3AnBnKpYNyhO5QN0a/iKha
1uIpV0qi6Q3QctmItSUwiDi1G6LwV56ZStvrJHBJxTJKF0LmWonPNalfNORhH8wU
um9YLSYOZd3n/5vPY+odUP1sZBypF5VfrEUWagu/Dosn2REpo2HwXauc4rkKOLu7
ruZqcScgEnU3c2PI1ZYvSdpoqSh/eaMv6EFhk0qLfVMXHioIgBmdFV6j+3UUr/AK
ZaCic7P6eIzVohnyuZrk9waOCb6xGmaC5tz+7hO6iWaFHXYBuiZ0G5VgZWWvsn+b
-> ssh-ed25519 J/iReg rDz6s3YT+m0tuqBQAnV7CfIjojJK4OGc/yr4Ku79m3M
uukWbHyK5U0RNgtnUs/OB0eAUYsH0+gOuvEme056DfY
-> ssh-ed25519 GNhSGw BYacYjqeKRGu4Ohx+6ud8f2fmnyJgKg8Now3jiSYSzY
pqOaR6QGXHoS0FmNDNR+egeqvQDQvWoJzUh9kMm/ZyI
-> ssh-ed25519 eXMAtA xAjxD71MvThN7+wn9oaBPNv7X7puYYOaRBXT4rWhn34
s2WpYf3FCYmG04LvuOHTZ8Zl1UQxgDV5qK/CFJwmLhs
-> ssh-ed25519 5hXocQ 55mFRg0FGkbOzuHl4CikB74sFHKzSeC9oxlPp6byr1k
KwjBEvz8f/vHDadPg1uNAUuzbHaGIS23nXx9sltrOtk
-> ssh-ed25519 bRHVVA CikC77O5cpo+Da1Jvn5d7NFmmlgoUwO1oIBHs2wKbi0
Q1Szf2jgP2IxSmU+N5deixEsduD69ERrYFLTMdmxerc
-> ssh-ed25519 HgW9eA lq09SPlqq5znIlAB5l88U5uz20mXbHk5wltSMhJteBw
dJpOifpANfZdzNt25UKyEB2ua03LKJ4uwO62ue4mw2w
--- U13fjXS4HiYErtXV3/nucPnvTCgToNFJJr7jEH8IOME
øL[|ÏUÜY
²r
Cbº¤BY"ó %‚©[Ã\_ÿÑß÷9f 7ABñ×c“3I¹ëöÅäB
ýÌy8ìÕ#p“`Ï0ñ7‰ý¢×Já£{ð<±u'ôÁ{æG ä4˜EÕ1<C395>þTÈÅŸÖT@Ÿ*¦\÷ƒÄ„í2¥QñÈõý#[…½ KVÓVì,‘ý3Å2±ÒÉZ‚æÖ±ºÍJí‡ûÜEÔm˜à—3ÖPësª¸ëcG'wˆÃü±³–ŽcDôC®*Ÿq³Î4ûDGr‰¨‚(´ZõgnˆÅ*¥WxkŽlQÃ<51>{Áxz9ÕÛß Rʸpï0ëºZ7cÑž‚Wœmt<6D>º·pMb(OÂ

View File

@ -0,0 +1,34 @@
age-encryption.org/v1
-> ssh-ed25519 ZpDcxw VzJJsefMGzLbGcbVyZON0M590x1gM7P8wwbafDVN1hE
isRye6kXQbR2LmJI9TM6PDgN+K6P+rmP0iviY7GwF8w
-> piv-p256 ewCc3w AlLQPdPUU6Gn6S+ZilzoYqaj/YA3BaUp6YQNxED/rw+2
D291xkVjKPDl12DckPiW/A+VuxZwJRLOwBWM8vIJsFs
-> piv-p256 6CL/Pw AmWfIbAU1QtaSq2dWnwkr0wr2QIcoAruvJjP5cNWSc34
dPPOOCeMJKLc39qcXj+de0/AEJH/wR/hTMHK9oOYIgQ
-> ssh-ed25519 eOAUSg v17y+5tQDF0EYj7ciY/qO22saHBPYyHVvffyfcckSC0
TZyAvWfn0YcUS9G6dZluOzORhwaNG++1+v1k5tACrQE
-> ssh-rsa REaZBA
EOyTVfvxTMordC3FoHOnlFQF5+7iLk+XntDZan6l8Cfo+Z+8+CdOVy3mNnQqrPI8
RVL/QTQh6oWw5ujGVVZNa7JFGUpqMbhE6RCDcL/Ho5t7VRylnmOEjyWSG/HcYw25
BfTJaaSTbjI6dphBjzniRHNCps/AplHuuTp5TsCl9h/wxQdQ5kF/62sme/iSqupy
8ed5PZueaqgBm0bSOENcV6u4YWShDjsjAIo8kXLAUtGEsMUao6B3Igtpxz09kgb9
P46Kyt135Dp0d1YHVgdCXFrux56+3sJ9qTPylD93I+cGn74K2tyrtf11Atiju72m
CO89ovzQZXydgFCr5E+YAkypnEElDS4UVGqUKkMeezmM8kUdLQNlwIo2ikgMR+bi
Ypj1jtk1EJWzRJbXTy7lS4VV8ZRZHuxEQVeiibWujMCP3TAD/yIsw9ifvr58uapk
AgiW7kE6MChQ844dE6H67JPG5z5xcn2ONLJWyBPB/ACGSiuDLX9joxt/xLlMBwGD
-> ssh-ed25519 J/iReg HxEnTdqslHnj6GI59srB5XPsEkRUp9+y+R/BFVxp31k
/Ol2xcDvvd1vVy8iO+gLwg6LrN664VJEwnMzjq9hOTI
-> ssh-ed25519 GNhSGw J+/GbL5u5CP3+OTqxT8ZRmAjNfwg5q4RpaUkfrQK9SA
ylJls9MxM7+4FSP0GIn6Ll8LQNBwLM6JyQovFqePJ5c
-> ssh-ed25519 eXMAtA C/hYcW6GFh98Y/nfVXfSFXqedI990mdwYyYeyghoQRM
G2wFbOsQu25NZPBc0Q3NkA6xQXrrEH6eeggnGfdKwwU
-> ssh-ed25519 5hXocQ pdViAG8/pAB6ANXfIoTGxcmpxKM7Vc30/rTpPOkMS2U
hIAikSvQW1TbQX/sZuh79wmz6HsPHEJ5aYKDGOgth3w
-> ssh-ed25519 bRHVVA mFylt2nF7ZSaVreJY7vYRyN2+uTYgmBma5obySmKkFg
WgSRPkHDDCEvH7xAK/sWVGXH5BJMVoVD4kR8gEBSE9M
-> ssh-ed25519 HgW9eA 57LJVmiyiLaOxFxEqhk2YYzv43shHrSz3BkQJ5cm5WQ
EGcAXlGAAzscqY8qpmDDros+KU8owNLRkkx5QVSUIwo
--- kwagcoZytytUNvidfgNm2R7aQKiAA9HS8tN9fqhM0qY
<µ•D²¸íüÝ–JåžA5ŽtJŸ¶Ä볆áöÙt*=ˆþK™¨ý|é<1B>þN‰@6‡[ë)¬cJ68µQyøÚC;~BcuøxÛj³õŽÅÅq™…ý*M½‡5EA»; à °!ÜÌš?µêfr`Q¥c<C2A5>-Y’h<>ÈÞ
¬çïª*1ÑôXyW§úrÄ

View File

@ -0,0 +1,33 @@
age-encryption.org/v1
-> ssh-ed25519 GCcVXA qD1f4YdP88JvuPLS6H416O0LF+rKN/gJ+gPf0lFWIRs
B0LuHjXX0UiHHTwY7v1HqP4VF5/5yn0d/gNW4zUOC5w
-> piv-p256 ewCc3w A8Wnjz7tLNpV/m5bUzuq/MBTHn+jQiaCSUb4vFvGDH7N
gNBEsgdHogaz3OSJBelB39bGrOBt8+hkNXpAYcFcy28
-> piv-p256 6CL/Pw AoJBcpyhK+hpZOfHOBUHh0Kivvn6YJ4xepo7IWppbMuJ
f1fwsu8okH7F+6nSHowxHpTdtjU72lvd6FmwFIMmV8E
-> ssh-ed25519 eOAUSg 0bH2dDdNzKapr54HKndKJpKO5iTQtbhY957NIJQcvBo
LYVsTuKNsE4JlBxbf2e+Qo9ZjRxnz6saEHEjyfCcAjc
-> ssh-rsa REaZBA
Pho7O15lWd5/2Uy1OQmA/JzE0wmOji6IPKGj03nK9aYSQjsTTc30YhNCrkddglGX
MCClDyGDlQ0mCwHKVfpFeeSC9VQWQDDYk90dM4MF//6hHPqU/zn8dR25NyxDgyyi
DVrCFB+S09LsKI4isbDoCru6fWXfdN4Fj8I44c64dBpIVh4XhSzUtmlzmge//35C
y6EvAjVIhVTbbgnQ5CJFgnRlSPaN5BHDq76aJ3c+8Ajkjowa2YcYu7w8asi7PsIs
WH6J2qMIPvPdxOEKczGc401ysUfz9fNFKs0Xx4spduwv8I3Sv4ImURDkYmCl/k7J
rwRXbUGr8JzZu093cmkjVxDrSfb9VhqlZVJD7uc2VSTk8ntwStLWafSuE03bIN2L
on0t/4/wXtvFtwlGoDe9QGYAJpPYWM0ZUPMZfpddOz9vYvL1+6nUcDroLMvCIItI
blwJZ0970K/Je99SmI3+6mUEAw8nJ/pBFaF/LmTJecypLEalLNsu2IALyCMIdTa0
-> ssh-ed25519 J/iReg fpgi4t31rVl+KvOYd0Ph0Nd2gInJAZXNhQC5ekpv0kI
lznh3YwmqVr05vjWxf1HKRWAFZGXbypAYygjUe+ELlk
-> ssh-ed25519 GNhSGw 2cVptD/h5NWcYsjPPwQcP/b9tjJPDbhjDKh2hKhA5GM
JZvQvXkb1RRw+NHnJ+HMV5tlwiBnLf4+cwm6J2hZD9Y
-> ssh-ed25519 eXMAtA axP3hHgfgWJScGLIGpYgu0iYW1qZRQzMK9XjIeszrnc
rBzAqSVoRqj/drZSOlxSIZLgZZU/wqGML1gKCEHtcy4
-> ssh-ed25519 5hXocQ b3kjoM+FfQ434V7X8FdpIHVFjTn4sc+psj+9jyRljxU
l5rna7ap0ydVXPCSr4qrPnu2MV2n7XwoSh/T87OkuMU
-> ssh-ed25519 bRHVVA 92Rj1gMmueSHWzxsKWVs+7nF4JzuekhfaB2u5AWnazk
yg4qa9lyHsGCOXWOTm7/XH9F8JzvRLjzsxyXe88oXnE
-> ssh-ed25519 HgW9eA mpIFRqMXj08JuyYLf6Hx4p9fOLbxXZquSMAjh0e/lSU
h1kvwnu+mLOeRfVFUXgE9IDo/MQ+4gtkYwrF3JWQfwY
--- MCDm2NH5NWgwnFYQ+zJ4obfdxWpiv3BGohnIJ1bRkco
_Ö¹`ðãFËÉÑvF3[;œç197%»4¼90MÞ[ï¼Ip¢:Ÿs|ÈñC<C3B1>RÃ6,CÔè÷;á[³;<§Éû¤b(´fqÀg8îp÷p÷ ÷Dk p/ÐwîÒ¿$9Ý\