@studiotitta/studio-editori 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -0
- package/dist/index.js +9 -0
- package/dist/komponentit/Editori.d.ts +1 -0
- package/dist/komponentit/Editori.js +29 -0
- package/dist/komponentit/Muokattava.d.ts +10 -0
- package/dist/komponentit/Muokattava.js +7 -0
- package/dist/komponentit/index.d.ts +2 -0
- package/dist/komponentit/index.js +5 -0
- package/dist/maarittely.d.ts +215 -0
- package/dist/maarittely.js +170 -0
- package/dist/palvelin/apurit.d.ts +26 -0
- package/dist/palvelin/apurit.js +70 -0
- package/dist/palvelin/asetukset.d.ts +26 -0
- package/dist/palvelin/asetukset.js +55 -0
- package/dist/palvelin/github/avain.d.ts +15 -0
- package/dist/palvelin/github/avain.js +121 -0
- package/dist/palvelin/github/tiedostot.d.ts +56 -0
- package/dist/palvelin/github/tiedostot.js +144 -0
- package/dist/palvelin/github/yhteys.d.ts +28 -0
- package/dist/palvelin/github/yhteys.js +89 -0
- package/dist/palvelin/index.d.ts +15 -0
- package/dist/palvelin/index.js +19 -0
- package/dist/palvelin/istunto.d.ts +34 -0
- package/dist/palvelin/istunto.js +86 -0
- package/dist/palvelin/kaannos.d.ts +12 -0
- package/dist/palvelin/kaannos.js +102 -0
- package/dist/palvelin/kaannosreitti.d.ts +9 -0
- package/dist/palvelin/kaannosreitti.js +42 -0
- package/dist/palvelin/kirjautuminen.d.ts +25 -0
- package/dist/palvelin/kirjautuminen.js +183 -0
- package/dist/palvelin/kuvat.d.ts +29 -0
- package/dist/palvelin/kuvat.js +130 -0
- package/dist/palvelin/kuvateksti.d.ts +15 -0
- package/dist/palvelin/kuvateksti.js +95 -0
- package/dist/palvelin/lue.d.ts +6 -0
- package/dist/palvelin/lue.js +41 -0
- package/dist/palvelin/posti.d.ts +9 -0
- package/dist/palvelin/posti.js +45 -0
- package/dist/palvelin/reitit.d.ts +10 -0
- package/dist/palvelin/reitit.js +71 -0
- package/dist/palvelin/tallennus.d.ts +18 -0
- package/dist/palvelin/tallennus.js +177 -0
- package/dist/palvelin/tila.d.ts +21 -0
- package/dist/palvelin/tila.js +116 -0
- package/dist/palvelin/tunnus.d.ts +30 -0
- package/dist/palvelin/tunnus.js +84 -0
- package/dist/palvelin/varasto.d.ts +18 -0
- package/dist/palvelin/varasto.js +87 -0
- package/dist/selain/EditorinPalkki.d.ts +1 -0
- package/dist/selain/EditorinPalkki.js +35 -0
- package/dist/selain/Muokkaustila.d.ts +3 -0
- package/dist/selain/Muokkaustila.js +646 -0
- package/dist/selain/PalkkiLataaja.d.ts +1 -0
- package/dist/selain/PalkkiLataaja.js +19 -0
- package/dist/selain/Paneeli.d.ts +44 -0
- package/dist/selain/Paneeli.js +259 -0
- package/dist/selain/index.d.ts +4 -0
- package/dist/selain/index.js +8 -0
- package/dist/selain/kieli.d.ts +10 -0
- package/dist/selain/kieli.js +14 -0
- package/dist/selain/kuva.d.ts +7 -0
- package/dist/selain/kuva.js +45 -0
- package/dist/selain/polku.d.ts +21 -0
- package/dist/selain/polku.js +108 -0
- package/dist/selain/tyypit.d.ts +22 -0
- package/dist/selain/tyypit.js +2 -0
- package/dist/tarkistus.d.ts +26 -0
- package/dist/tarkistus.js +324 -0
- package/package.json +59 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { onSallittuPolku } from "../maarittely.js";
|
|
4
|
+
import { kielista, tarkistaSivu } from "../tarkistus.js";
|
|
5
|
+
export async function lueSivu(maarittely, sivunAvain, kieli, asetukset = {}) {
|
|
6
|
+
const sivu = maarittely.sivut[sivunAvain];
|
|
7
|
+
if (!sivu)
|
|
8
|
+
throw new Error(`Sisältömallissa ei ole sivua "${sivunAvain}".`);
|
|
9
|
+
if (!onSallittuPolku(sivu.tiedosto)) {
|
|
10
|
+
throw new Error(`Sivun "${sivunAvain}" tiedosto "${sivu.tiedosto}" ei ole sallittu polku.`);
|
|
11
|
+
}
|
|
12
|
+
if (!maarittely.kielet.includes(kieli)) {
|
|
13
|
+
throw new Error(`Sivustolla ei ole kieltä "${kieli}".`);
|
|
14
|
+
}
|
|
15
|
+
// Polku kootaan sisalto-kansiosta alkaen, jotta Next jäljittää julkaisuun
|
|
16
|
+
// vain sen kansion eikä koko projektia. onSallittuPolku takaa etuliitteen.
|
|
17
|
+
const kansionSisalla = sivu.tiedosto.slice("sisalto/".length);
|
|
18
|
+
const polku = path.join(asetukset.juuri ?? process.cwd(), "sisalto", kansionSisalla);
|
|
19
|
+
let raaka;
|
|
20
|
+
try {
|
|
21
|
+
raaka = await readFile(polku, "utf8");
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new Error(`Sivun "${sivunAvain}" sisältötiedostoa ei löydy: ${sivu.tiedosto}`);
|
|
25
|
+
}
|
|
26
|
+
let data;
|
|
27
|
+
try {
|
|
28
|
+
data = JSON.parse(raaka);
|
|
29
|
+
}
|
|
30
|
+
catch (virhe) {
|
|
31
|
+
const syy = virhe instanceof Error ? virhe.message : String(virhe);
|
|
32
|
+
throw new Error(`Tiedosto ${sivu.tiedosto} ei ole kelvollista JSONia (${syy}). ` +
|
|
33
|
+
`Tarkista pilkut ja lainausmerkit viimeksi muokatusta kohdasta.`);
|
|
34
|
+
}
|
|
35
|
+
const { virheet } = tarkistaSivu(maarittely, sivunAvain, data);
|
|
36
|
+
if (virheet.length > 0) {
|
|
37
|
+
const rivit = virheet.map((v) => `${v.kohde}${v.kieli ? ` (${v.kieli})` : ""}: ${v.viesti}`);
|
|
38
|
+
throw new Error(`Tiedostossa ${sivu.tiedosto} on virheitä:\n- ${rivit.join("\n- ")}`);
|
|
39
|
+
}
|
|
40
|
+
return kielista(maarittely, sivu, data, kieli);
|
|
41
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { EditorinAsetukset } from "./asetukset.ts";
|
|
2
|
+
export interface Kirjautumisviesti {
|
|
3
|
+
vastaanottaja: string;
|
|
4
|
+
linkki: string;
|
|
5
|
+
/** Sivuston nimi viestissä, esim. "amritagian.fi". */
|
|
6
|
+
sivusto: string;
|
|
7
|
+
}
|
|
8
|
+
export type Lahetystulos = "lahetetty" | "loki";
|
|
9
|
+
export declare function lahetaKirjautumislinkki(viesti: Kirjautumisviesti, asetukset: EditorinAsetukset): Promise<Lahetystulos>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
KIRJAUTUMISVIESTI
|
|
3
|
+
==========================================================================
|
|
4
|
+
Viesti on tarkoituksella lyhyt ja tylsä: yksi linkki, voimassaoloaika ja
|
|
5
|
+
ohje siltä varalta, ettei pyyntö ollut oma. Ei logoja, ei markkinointia —
|
|
6
|
+
sellainen viesti päätyy herkemmin roskapostiin.
|
|
7
|
+
|
|
8
|
+
Ilman Resendin avainta viestiä ei lähetetä, vaan linkki tulostuu
|
|
9
|
+
palvelimen lokiin. Näin editoria voi kehittää paikallisesti ilman
|
|
10
|
+
sähköpostipalvelua, mutta tuotannossa avain on pakollinen.
|
|
11
|
+
========================================================================== */
|
|
12
|
+
export async function lahetaKirjautumislinkki(viesti, asetukset) {
|
|
13
|
+
const minuutit = asetukset.linkinKestoMinuuttia;
|
|
14
|
+
const teksti = [
|
|
15
|
+
`Tästä pääset muokkaamaan sivustoa ${viesti.sivusto}:`,
|
|
16
|
+
"",
|
|
17
|
+
viesti.linkki,
|
|
18
|
+
"",
|
|
19
|
+
`Linkki on voimassa ${minuutit} minuuttia ja toimii siinä selaimessa, josta sitä pyydettiin.`,
|
|
20
|
+
"",
|
|
21
|
+
"Jos et pyytänyt linkkiä, voit jättää viestin huomiotta. Kukaan ei pääse sivustolle tämän viestin perusteella ilman sähköpostiasi.",
|
|
22
|
+
].join("\n");
|
|
23
|
+
if (!asetukset.postiAvain) {
|
|
24
|
+
console.warn(`\n[editori] Kirjautumislinkki osoitteelle ${viesti.vastaanottaja}:\n${viesti.linkki}\n`);
|
|
25
|
+
return "loki";
|
|
26
|
+
}
|
|
27
|
+
const vastaus = await fetch("https://api.resend.com/emails", {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: {
|
|
30
|
+
Authorization: `Bearer ${asetukset.postiAvain}`,
|
|
31
|
+
"Content-Type": "application/json",
|
|
32
|
+
},
|
|
33
|
+
body: JSON.stringify({
|
|
34
|
+
from: asetukset.lahettaja,
|
|
35
|
+
to: [viesti.vastaanottaja],
|
|
36
|
+
subject: `Kirjautumislinkki – ${viesti.sivusto}`,
|
|
37
|
+
text: teksti,
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
if (!vastaus.ok) {
|
|
41
|
+
/* Virheen sisältö voi paljastaa osoitteita, joten lokiin vain tila. */
|
|
42
|
+
throw new Error(`Kirjautumisviestin lähetys epäonnistui (${vastaus.status}).`);
|
|
43
|
+
}
|
|
44
|
+
return "lahetetty";
|
|
45
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type KirjautumisenAsetukset } from "./kirjautuminen.ts";
|
|
2
|
+
import { type SisallonAsetukset } from "./tallennus.ts";
|
|
3
|
+
import { type KaannoksenAsetukset } from "./kaannosreitti.ts";
|
|
4
|
+
import { type KuvienAsetukset } from "./kuvat.ts";
|
|
5
|
+
export interface ReittienAsetukset extends KirjautumisenAsetukset, SisallonAsetukset, KuvienAsetukset, KaannoksenAsetukset {
|
|
6
|
+
}
|
|
7
|
+
export declare function editorinReitit(yliajot?: ReittienAsetukset): {
|
|
8
|
+
GET(pyynto: Request): Promise<Response>;
|
|
9
|
+
POST(pyynto: Request): Promise<Response>;
|
|
10
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
EDITORIN REITIT
|
|
3
|
+
==========================================================================
|
|
4
|
+
Sivusto ottaa editorin käyttöön yhdellä tiedostolla:
|
|
5
|
+
|
|
6
|
+
// app/api/editori/[...polku]/route.ts
|
|
7
|
+
import { editorinReitit } from "@studiotitta/studio-editori/palvelin";
|
|
8
|
+
import sisalto from "@/editori.config";
|
|
9
|
+
|
|
10
|
+
export const { GET, POST } = editorinReitit({ sisalto });
|
|
11
|
+
|
|
12
|
+
Osoitteet:
|
|
13
|
+
POST /api/editori/kirjaudu lähettää kirjautumislinkin
|
|
14
|
+
GET /api/editori/vahvista avaa istunnon linkistä
|
|
15
|
+
POST /api/editori/ulos päättää istunnon
|
|
16
|
+
GET /api/editori/sisalto nykyinen sisältö muokkausta varten
|
|
17
|
+
GET /api/editori/tila toimiiko editori, ja jos ei, mikä on vialla
|
|
18
|
+
GET /api/editori/muokkaa kytkee muokkaustilan päälle ja palaa sivustolle
|
|
19
|
+
POST /api/editori/tallenna muutokset yhtenä committina
|
|
20
|
+
POST /api/editori/kuva uusi kuva mediakirjastoon
|
|
21
|
+
POST /api/editori/kaannos ehdotukset puuttuviin käännöksiin
|
|
22
|
+
========================================================================== */
|
|
23
|
+
import { kirjautumisenReitit } from "./kirjautuminen.js";
|
|
24
|
+
import { sisallonReitit } from "./tallennus.js";
|
|
25
|
+
import { kaannosReitti } from "./kaannosreitti.js";
|
|
26
|
+
import { kuvienReitti } from "./kuvat.js";
|
|
27
|
+
import { tilanReitti } from "./tila.js";
|
|
28
|
+
export function editorinReitit(yliajot = {}) {
|
|
29
|
+
const kirjautuminen = kirjautumisenReitit(yliajot);
|
|
30
|
+
const sisalto = sisallonReitit(yliajot);
|
|
31
|
+
const tila = tilanReitti(yliajot);
|
|
32
|
+
const kuvat = kuvienReitti(yliajot);
|
|
33
|
+
const kaannos = kaannosReitti(yliajot);
|
|
34
|
+
function polku(pyynto) {
|
|
35
|
+
const osat = new URL(pyynto.url).pathname.split("/").filter(Boolean);
|
|
36
|
+
return osat.at(-1) ?? "";
|
|
37
|
+
}
|
|
38
|
+
const eiLoydy = () => new Response("Tuntematon osoite", { status: 404 });
|
|
39
|
+
return {
|
|
40
|
+
async GET(pyynto) {
|
|
41
|
+
switch (polku(pyynto)) {
|
|
42
|
+
case "vahvista":
|
|
43
|
+
return kirjautuminen.vahvista(pyynto);
|
|
44
|
+
case "sisalto":
|
|
45
|
+
return sisalto.lue(pyynto);
|
|
46
|
+
case "tila":
|
|
47
|
+
return tila(pyynto);
|
|
48
|
+
case "muokkaa":
|
|
49
|
+
return kirjautuminen.avaaMuokkaustila(pyynto);
|
|
50
|
+
default:
|
|
51
|
+
return eiLoydy();
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
async POST(pyynto) {
|
|
55
|
+
switch (polku(pyynto)) {
|
|
56
|
+
case "kirjaudu":
|
|
57
|
+
return kirjautuminen.kirjaudu(pyynto);
|
|
58
|
+
case "ulos":
|
|
59
|
+
return kirjautuminen.ulos(pyynto);
|
|
60
|
+
case "tallenna":
|
|
61
|
+
return sisalto.tallenna(pyynto);
|
|
62
|
+
case "kuva":
|
|
63
|
+
return kuvat(pyynto);
|
|
64
|
+
case "kaannos":
|
|
65
|
+
return kaannos(pyynto);
|
|
66
|
+
default:
|
|
67
|
+
return eiLoydy();
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SisallonMaarittely } from "../maarittely.ts";
|
|
2
|
+
import { type KaannosAsetukset } from "./kaannos.ts";
|
|
3
|
+
import { type KuvatekstiAsetukset } from "./kuvateksti.ts";
|
|
4
|
+
import type { EditorinAsetukset } from "./asetukset.ts";
|
|
5
|
+
import type { GithubAsetukset } from "./github/yhteys.ts";
|
|
6
|
+
export interface SisallonAsetukset extends Partial<EditorinAsetukset> {
|
|
7
|
+
/** Sivuston sisältömalli. Ilman tätä tallennusreitit eivät ole käytössä. */
|
|
8
|
+
sisalto?: SisallonMaarittely;
|
|
9
|
+
github?: Partial<GithubAsetukset>;
|
|
10
|
+
/** Ehdotus kuvan kuvaukseksi. `false` tai puuttuva avain = pois käytöstä. */
|
|
11
|
+
kuvateksti?: KuvatekstiAsetukset | false;
|
|
12
|
+
/** Käännösehdotukset. `false` tai puuttuva avain = pois käytöstä. */
|
|
13
|
+
kaannos?: KaannosAsetukset | false;
|
|
14
|
+
}
|
|
15
|
+
export declare function sisallonReitit(yliajot?: SisallonAsetukset): {
|
|
16
|
+
lue: (pyynto: Request) => Promise<Response>;
|
|
17
|
+
tallenna: (pyynto: Request) => Promise<Response>;
|
|
18
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
SISÄLLÖN LUKU JA TALLENNUS
|
|
3
|
+
==========================================================================
|
|
4
|
+
Kaksi reittiä, jotka selaimen muokkaustila tarvitsee:
|
|
5
|
+
|
|
6
|
+
GET /api/editori/sisalto nykyinen sisältö ja versio, josta muokkaus alkaa
|
|
7
|
+
POST /api/editori/tallenna muutokset yhtenä committina
|
|
8
|
+
|
|
9
|
+
Säännöt:
|
|
10
|
+
- **Vain kirjautunut muokkaaja.** Muut saavat 401 eikä mitään tietoa siitä,
|
|
11
|
+
mitä sivustolla on.
|
|
12
|
+
- **Palvelin tarkistaa jokaisen tallennuksen sisältömallia vasten.**
|
|
13
|
+
Selaimen tarkistus on käyttömukavuutta; tämä on se, johon luotetaan.
|
|
14
|
+
- **Vain sisältömallissa määritellyt tiedostot.** Polku tulee mallista,
|
|
15
|
+
ei koskaan selaimen lähettämästä pyynnöstä.
|
|
16
|
+
- **Ristiriita pysäyttää tallennuksen.** Mitään ei ylikirjoiteta hiljaa.
|
|
17
|
+
========================================================================== */
|
|
18
|
+
import { tarkistaSivu } from "../tarkistus.js";
|
|
19
|
+
import { asetuksetTaiNull, json, muokkaajaPyynnosta } from "./apurit.js";
|
|
20
|
+
import { kaannosAvain } from "./kaannos.js";
|
|
21
|
+
import { kuvatekstiAvain } from "./kuvateksti.js";
|
|
22
|
+
import { RistiriitaVirhe } from "./github/tiedostot.js";
|
|
23
|
+
import { valitseVarasto } from "./varasto.js";
|
|
24
|
+
/** Tekijän nimi commitiin. Sähköpostista ennen @-merkkiä, siistittynä. */
|
|
25
|
+
function tekijanNimi(sahkoposti) {
|
|
26
|
+
const alku = sahkoposti.split("@")[0] ?? sahkoposti;
|
|
27
|
+
return alku.replace(/[._-]+/g, " ").trim() || sahkoposti;
|
|
28
|
+
}
|
|
29
|
+
function commitinViesti(sivujenNimet, muokkaaja) {
|
|
30
|
+
const mita = sivujenNimet.join(", ");
|
|
31
|
+
return `Sisältömuutos: ${mita}\n\nMuokannut ${muokkaaja} sivuston editorilla.`;
|
|
32
|
+
}
|
|
33
|
+
export function sisallonReitit(yliajot = {}) {
|
|
34
|
+
function varasto() {
|
|
35
|
+
return valitseVarasto(yliajot.github ?? {});
|
|
36
|
+
}
|
|
37
|
+
async function lue(pyynto) {
|
|
38
|
+
const asetukset = asetuksetTaiNull(yliajot);
|
|
39
|
+
if (!asetukset)
|
|
40
|
+
return json({ virhe: "Editori ei ole käytössä." }, 503);
|
|
41
|
+
const muokkaaja = await muokkaajaPyynnosta(pyynto, asetukset);
|
|
42
|
+
if (!muokkaaja)
|
|
43
|
+
return json({ virhe: "Kirjaudu sisään." }, 401);
|
|
44
|
+
const malli = yliajot.sisalto;
|
|
45
|
+
if (!malli)
|
|
46
|
+
return json({ virhe: "Sivustolle ei ole määritelty sisältömallia." }, 503);
|
|
47
|
+
try {
|
|
48
|
+
const paikka = varasto();
|
|
49
|
+
const versio = await paikka.lueVersio();
|
|
50
|
+
const sivut = {};
|
|
51
|
+
/*
|
|
52
|
+
Jokaisen sivun oma versio talteen. Tallennus vertaa näitä, ei koko
|
|
53
|
+
repon versiota: muuten kuka tahansa muu muutos repossa estäisi
|
|
54
|
+
tallennuksen, vaikka se ei koskisi samaa sisältöä.
|
|
55
|
+
*/
|
|
56
|
+
const versiot = {};
|
|
57
|
+
for (const [avain, sivu] of Object.entries(malli.sivut)) {
|
|
58
|
+
const raaka = await paikka.lueTiedosto(sivu.tiedosto, versio);
|
|
59
|
+
sivut[avain] = raaka ? JSON.parse(raaka) : {};
|
|
60
|
+
versiot[avain] = await paikka.lueTiedostonVersio(sivu.tiedosto, versio);
|
|
61
|
+
}
|
|
62
|
+
/*
|
|
63
|
+
Selain saa mukaan sisältömallin: sen perusteella sivupaneeli osaa
|
|
64
|
+
näyttää oikean säätimen jokaiselle kentälle ilman, että editoriin
|
|
65
|
+
kovakoodataan mitään sivustokohtaista. Tiedostopolkuja ei lähetetä —
|
|
66
|
+
ne eivät kuulu selaimelle.
|
|
67
|
+
*/
|
|
68
|
+
const kentat = Object.fromEntries(Object.entries(malli.sivut).map(([avain, sivu]) => [
|
|
69
|
+
avain,
|
|
70
|
+
{ nimi: sivu.nimi ?? avain, kentat: sivu.kentat },
|
|
71
|
+
]));
|
|
72
|
+
/*
|
|
73
|
+
Selain saa tietää, mitkä valinnaiset ominaisuudet ovat käytössä, jotta
|
|
74
|
+
se ei tarjoa painiketta, joka vastaisi vain "ei käytössä".
|
|
75
|
+
*/
|
|
76
|
+
const ominaisuudet = {
|
|
77
|
+
kuvateksti: yliajot.kuvateksti !== false && Boolean(kuvatekstiAvain(yliajot.kuvateksti ?? {})),
|
|
78
|
+
kaannos: yliajot.kaannos !== false && Boolean(kaannosAvain(yliajot.kaannos ?? {})),
|
|
79
|
+
};
|
|
80
|
+
return json({
|
|
81
|
+
versio,
|
|
82
|
+
versiot,
|
|
83
|
+
sivut,
|
|
84
|
+
muokkaaja,
|
|
85
|
+
kielet: malli.kielet,
|
|
86
|
+
malli: kentat,
|
|
87
|
+
ominaisuudet,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
catch (virhe) {
|
|
91
|
+
console.error("[editori] Sisällön luku epäonnistui:", virhe);
|
|
92
|
+
return json({ virhe: "Sisältöä ei saatu haettua." }, 502);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function tallenna(pyynto) {
|
|
96
|
+
const asetukset = asetuksetTaiNull(yliajot);
|
|
97
|
+
if (!asetukset)
|
|
98
|
+
return json({ virhe: "Editori ei ole käytössä." }, 503);
|
|
99
|
+
const muokkaaja = await muokkaajaPyynnosta(pyynto, asetukset);
|
|
100
|
+
if (!muokkaaja)
|
|
101
|
+
return json({ virhe: "Kirjaudu sisään." }, 401);
|
|
102
|
+
const malli = yliajot.sisalto;
|
|
103
|
+
if (!malli)
|
|
104
|
+
return json({ virhe: "Sivustolle ei ole määritelty sisältömallia." }, 503);
|
|
105
|
+
let data;
|
|
106
|
+
try {
|
|
107
|
+
data = (await pyynto.json());
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return json({ virhe: "Pyyntö ei ollut kelvollista JSONia." }, 400);
|
|
111
|
+
}
|
|
112
|
+
const sivut = data.sivut;
|
|
113
|
+
if (!sivut || typeof sivut !== "object" || Array.isArray(sivut)) {
|
|
114
|
+
return json({ virhe: "Pyynnöstä puuttuvat muutettavat sivut." }, 400);
|
|
115
|
+
}
|
|
116
|
+
/* Tarkistus ensin kaikille sivuille: muokkaaja saa kaikki virheet
|
|
117
|
+
kerralla eikä yhtä kerrallaan. */
|
|
118
|
+
const virheet = [];
|
|
119
|
+
const varoitukset = [];
|
|
120
|
+
for (const [avain, sivunData] of Object.entries(sivut)) {
|
|
121
|
+
if (!Object.hasOwn(malli.sivut, avain)) {
|
|
122
|
+
virheet.push({ kohde: avain, viesti: "Tuntematon sivu." });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const tulos = tarkistaSivu(malli, avain, sivunData);
|
|
126
|
+
virheet.push(...tulos.virheet);
|
|
127
|
+
varoitukset.push(...tulos.varoitukset);
|
|
128
|
+
}
|
|
129
|
+
if (virheet.length > 0) {
|
|
130
|
+
return json({ virhe: "Sisällössä on virheitä.", virheet, varoitukset }, 422);
|
|
131
|
+
}
|
|
132
|
+
const tiedostot = Object.entries(sivut).map(([avain, sivunData]) => ({
|
|
133
|
+
/* Polku tulee sisältömallista, ei koskaan pyynnöstä. */
|
|
134
|
+
polku: malli.sivut[avain].tiedosto,
|
|
135
|
+
sisalto: `${JSON.stringify(sivunData, null, 2)}\n`,
|
|
136
|
+
}));
|
|
137
|
+
const nimet = Object.keys(sivut).map((avain) => malli.sivut[avain]?.nimi ?? avain);
|
|
138
|
+
try {
|
|
139
|
+
/* Sivun avain → tiedostopolku, koska varasto puhuu poluista. */
|
|
140
|
+
const perustuu = {};
|
|
141
|
+
for (const [avain, arvo] of Object.entries(data.perustuu ?? {})) {
|
|
142
|
+
const tiedosto = malli.sivut[avain]?.tiedosto;
|
|
143
|
+
if (tiedosto && typeof arvo === "string")
|
|
144
|
+
perustuu[tiedosto] = arvo;
|
|
145
|
+
}
|
|
146
|
+
const { versio } = await varasto().tallenna({
|
|
147
|
+
tiedostot,
|
|
148
|
+
viesti: commitinViesti(nimet, muokkaaja),
|
|
149
|
+
tekija: { nimi: tekijanNimi(muokkaaja), sahkoposti: muokkaaja },
|
|
150
|
+
...(Object.keys(perustuu).length > 0 ? { perustuu } : {}),
|
|
151
|
+
});
|
|
152
|
+
/* Uudet tiedostoversiot mukaan, jotta seuraava tallennus samasta
|
|
153
|
+
selaimesta perustuu juuri kirjoitettuun eikä vanhaan. */
|
|
154
|
+
const uudetVersiot = {};
|
|
155
|
+
const paikka = varasto();
|
|
156
|
+
for (const avain of Object.keys(sivut)) {
|
|
157
|
+
const tiedosto = malli.sivut[avain]?.tiedosto;
|
|
158
|
+
if (tiedosto)
|
|
159
|
+
uudetVersiot[avain] = await paikka.lueTiedostonVersio(tiedosto, versio);
|
|
160
|
+
}
|
|
161
|
+
return json({ tila: "tallennettu", versio, versiot: uudetVersiot, varoitukset });
|
|
162
|
+
}
|
|
163
|
+
catch (virhe) {
|
|
164
|
+
if (virhe instanceof RistiriitaVirhe) {
|
|
165
|
+
return json({
|
|
166
|
+
virhe: "Joku muu on muokannut samaa sisältöä sillä välin. Muutoksiasi ei ylikirjoitettu — " +
|
|
167
|
+
"lataa sivu uudelleen ja tee muutokset uusimpaan versioon.",
|
|
168
|
+
nykyinenVersio: virhe.nykyinenVersio,
|
|
169
|
+
muuttuneet: virhe.muuttuneet,
|
|
170
|
+
}, 409);
|
|
171
|
+
}
|
|
172
|
+
console.error("[editori] Tallennus epäonnistui:", virhe);
|
|
173
|
+
return json({ virhe: "Tallennus ei onnistunut. Muutoksesi ovat yhä selaimessa." }, 502);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { lue, tallenna };
|
|
177
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { EditorinAsetukset } from "./asetukset.ts";
|
|
2
|
+
import type { SisallonMaarittely } from "../maarittely.ts";
|
|
3
|
+
import type { GithubAsetukset } from "./github/yhteys.ts";
|
|
4
|
+
export interface Tarkistus {
|
|
5
|
+
nimi: string;
|
|
6
|
+
kunnossa: boolean;
|
|
7
|
+
/** Mitä tehdä, jos ei ole kunnossa. */
|
|
8
|
+
ohje?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface TilanAsetukset extends Partial<EditorinAsetukset> {
|
|
11
|
+
sisalto?: SisallonMaarittely;
|
|
12
|
+
github?: Partial<GithubAsetukset>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Tekee tarkistukset ilman HTTP-kerrosta, jotta sivuston oma sivu voi
|
|
16
|
+
* näyttää saman tilan ilman ylimääräistä pyyntöä itselleen.
|
|
17
|
+
*
|
|
18
|
+
* Kutsuja vastaa siitä, että tila näytetään vain kirjautuneelle.
|
|
19
|
+
*/
|
|
20
|
+
export declare function tarkistaTila(yliajot: TilanAsetukset): Promise<Tarkistus[]>;
|
|
21
|
+
export declare function tilanReitti(yliajot: TilanAsetukset): (pyynto: Request) => Promise<Response>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
EDITORIN TILA
|
|
3
|
+
==========================================================================
|
|
4
|
+
Kertoo kirjautuneelle muokkaajalle suomeksi, toimiiko editori ja jos ei,
|
|
5
|
+
mikä on vialla ja mitä pitää tehdä.
|
|
6
|
+
|
|
7
|
+
Tämä on olemassa siksi, että käyttöönoton virheet ovat aina samoja:
|
|
8
|
+
ympäristömuuttuja on jäänyt lisäämättä, uusi julkaisu on tekemättä tai
|
|
9
|
+
GitHub Appia ei ole asennettu siihen repoon. Ilman tätä ne selviäisivät
|
|
10
|
+
vain palvelimen lokista — eikä asiakas avaa Vercelin lokeja koskaan.
|
|
11
|
+
|
|
12
|
+
**Mitään salaista ei palauteta.** Avaimia ei näytetä, ja tilan näkee vain
|
|
13
|
+
kirjautunut muokkaaja.
|
|
14
|
+
========================================================================== */
|
|
15
|
+
import { asetuksetTaiNull, json, muokkaajaPyynnosta } from "./apurit.js";
|
|
16
|
+
import { valitseVarasto } from "./varasto.js";
|
|
17
|
+
function githubOhje(virhe) {
|
|
18
|
+
const viesti = virhe instanceof Error ? virhe.message : String(virhe);
|
|
19
|
+
if (viesti.includes("asetukset puuttuvat") || viesti.includes("EDITORI_GITHUB")) {
|
|
20
|
+
return `${viesti} Lisää arvo sivuston asetuksiin ja julkaise sivusto uudelleen.`;
|
|
21
|
+
}
|
|
22
|
+
if (viesti.includes("404")) {
|
|
23
|
+
return ("GitHub ei löytänyt repoa tai sovellusta ei ole asennettu siihen. Tarkista repon " +
|
|
24
|
+
"nimi ja että GitHub App on asennettu juuri tälle repolle.");
|
|
25
|
+
}
|
|
26
|
+
if (viesti.includes("401") || viesti.includes("avain")) {
|
|
27
|
+
return "GitHub ei hyväksynyt avainta. Tarkista, että avain on kopioitu kokonaan, BEGIN- ja END-rivit mukaan lukien.";
|
|
28
|
+
}
|
|
29
|
+
if (viesti.includes("403")) {
|
|
30
|
+
return "GitHub epäsi oikeuden. Tarkista, että sovelluksella on Contents: Read and write.";
|
|
31
|
+
}
|
|
32
|
+
return viesti;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Tekee tarkistukset ilman HTTP-kerrosta, jotta sivuston oma sivu voi
|
|
36
|
+
* näyttää saman tilan ilman ylimääräistä pyyntöä itselleen.
|
|
37
|
+
*
|
|
38
|
+
* Kutsuja vastaa siitä, että tila näytetään vain kirjautuneelle.
|
|
39
|
+
*/
|
|
40
|
+
export async function tarkistaTila(yliajot) {
|
|
41
|
+
{
|
|
42
|
+
const tarkistukset = [
|
|
43
|
+
{
|
|
44
|
+
nimi: "Sisältömalli",
|
|
45
|
+
kunnossa: Boolean(yliajot.sisalto),
|
|
46
|
+
ohje: "Sivusto ei kerro editorille, mitä sivuilla saa muokata (editori.config).",
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
/*
|
|
50
|
+
Varasto valitaan samalla tavalla kuin tallennuksessa: kehityksessä
|
|
51
|
+
paikalliset tiedostot kelpaavat, tuotannossa GitHub-asetusten
|
|
52
|
+
puuttuminen on virhe. Muuten tila valehtelisi kumpaankin suuntaan.
|
|
53
|
+
*/
|
|
54
|
+
let varasto = null;
|
|
55
|
+
try {
|
|
56
|
+
varasto = valitseVarasto(yliajot.github ?? {});
|
|
57
|
+
tarkistukset.push({ nimi: "Tallennuspaikan asetukset", kunnossa: true });
|
|
58
|
+
}
|
|
59
|
+
catch (virhe) {
|
|
60
|
+
tarkistukset.push({
|
|
61
|
+
nimi: "Tallennuspaikan asetukset",
|
|
62
|
+
kunnossa: false,
|
|
63
|
+
ohje: githubOhje(virhe),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (varasto) {
|
|
67
|
+
try {
|
|
68
|
+
const versio = await varasto.lueVersio();
|
|
69
|
+
tarkistukset.push({ nimi: `Yhteys: ${varasto.nimi}`, kunnossa: true });
|
|
70
|
+
/* Löytyvätkö sisältötiedostot sieltä, mistä niitä haetaan? Väärä
|
|
71
|
+
kansio on helppo virhe eikä näy mitenkään muuten. */
|
|
72
|
+
const malli = yliajot.sisalto;
|
|
73
|
+
if (malli) {
|
|
74
|
+
const puuttuvat = [];
|
|
75
|
+
for (const sivu of Object.values(malli.sivut)) {
|
|
76
|
+
if ((await varasto.lueTiedosto(sivu.tiedosto, versio)) === undefined) {
|
|
77
|
+
puuttuvat.push(sivu.tiedosto);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
tarkistukset.push({
|
|
81
|
+
nimi: "Sisältötiedostot",
|
|
82
|
+
kunnossa: puuttuvat.length === 0,
|
|
83
|
+
ohje: puuttuvat.length > 0
|
|
84
|
+
? `Ei löytynyt: ${puuttuvat.join(", ")}. Jos sivusto on repon alikansiossa, ` +
|
|
85
|
+
"tarkista kansion nimi asetuksista."
|
|
86
|
+
: undefined,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (virhe) {
|
|
91
|
+
tarkistukset.push({
|
|
92
|
+
nimi: `Yhteys: ${varasto.nimi}`,
|
|
93
|
+
kunnossa: false,
|
|
94
|
+
ohje: githubOhje(virhe),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return tarkistukset;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export function tilanReitti(yliajot) {
|
|
102
|
+
return async function tila(pyynto) {
|
|
103
|
+
const asetukset = asetuksetTaiNull(yliajot);
|
|
104
|
+
if (!asetukset)
|
|
105
|
+
return json({ virhe: "Editorin perusasetukset puuttuvat." }, 503);
|
|
106
|
+
const muokkaaja = await muokkaajaPyynnosta(pyynto, asetukset);
|
|
107
|
+
if (!muokkaaja)
|
|
108
|
+
return json({ virhe: "Kirjaudu sisään." }, 401);
|
|
109
|
+
const tarkistukset = await tarkistaTila(yliajot);
|
|
110
|
+
return json({
|
|
111
|
+
muokkaaja,
|
|
112
|
+
kunnossa: tarkistukset.every((tarkistus) => tarkistus.kunnossa),
|
|
113
|
+
tarkistukset,
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Mihin tunnus on tarkoitettu. Kirjautumislinkkiä ei voi käyttää istuntona. */
|
|
2
|
+
export type TunnuksenLaji = "kirjautuminen" | "istunto";
|
|
3
|
+
export interface TunnuksenSisalto {
|
|
4
|
+
laji: TunnuksenLaji;
|
|
5
|
+
/** Muokkaajan sähköpostiosoite pienillä kirjaimilla. */
|
|
6
|
+
sahkoposti: string;
|
|
7
|
+
/** Vanhenemisaika sekunteina (Unix-aika). */
|
|
8
|
+
vanhenee: number;
|
|
9
|
+
/** Satunnaisluku, jotta kaksi samanlaista tunnusta eivät ole identtisiä. */
|
|
10
|
+
satunnainen: string;
|
|
11
|
+
/**
|
|
12
|
+
* Selainsidos: kirjautumislinkin tiiviste siitä satunnaisluvusta, joka
|
|
13
|
+
* jäi linkkiä pyytäneen selaimen evästeeseen. Linkki toimii vain siinä
|
|
14
|
+
* selaimessa, josta se pyydettiin.
|
|
15
|
+
*/
|
|
16
|
+
sidos?: string;
|
|
17
|
+
}
|
|
18
|
+
/** Satunnainen merkkijono, esimerkiksi tunnuksen satunnaisluvuksi. */
|
|
19
|
+
export declare function satunnainen(tavuja?: number): string;
|
|
20
|
+
/** Yksisuuntainen tiiviste. Käytetään selainsidokseen. */
|
|
21
|
+
export declare function tiiviste(arvo: string): Promise<string>;
|
|
22
|
+
export declare function luoTunnus(sisalto: TunnuksenSisalto, avain: string): Promise<string>;
|
|
23
|
+
/**
|
|
24
|
+
* Lukee tunnuksen ja palauttaa sen sisällön, tai `null` jos tunnus on
|
|
25
|
+
* väärennetty, rikki, vanhentunut tai väärää lajia.
|
|
26
|
+
*
|
|
27
|
+
* Kaikki epäonnistumiset palauttavat saman `null`-arvon: kutsuja ei saa
|
|
28
|
+
* kertoa käyttäjälle, kumpi meni pieleen.
|
|
29
|
+
*/
|
|
30
|
+
export declare function lueTunnus(tunnus: string, avain: string, laji: TunnuksenLaji, nytSekunteina?: number): Promise<TunnuksenSisalto | null>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
ALLEKIRJOITETUT TUNNUKSET
|
|
3
|
+
==========================================================================
|
|
4
|
+
Editorilla ei ole tietokantaa, joten kirjautumislinkki ja istunto ovat
|
|
5
|
+
allekirjoitettuja tunnuksia: palvelin voi tarkistaa niiden aitouden
|
|
6
|
+
pelkän salaisen avaimen avulla.
|
|
7
|
+
|
|
8
|
+
tunnus = base64url(sisältö) + "." + base64url(allekirjoitus)
|
|
9
|
+
|
|
10
|
+
Allekirjoitus on HMAC-SHA-256. Sisältö on luettavissa (se ei ole salattu),
|
|
11
|
+
mutta sitä ei voi muuttaa ilman avainta. Sisällössä ei siksi koskaan ole
|
|
12
|
+
muuta kuin sähköpostiosoite, voimassaolo ja satunnaisluku.
|
|
13
|
+
|
|
14
|
+
Käytössä on Web Crypto, joka toimii sekä Nodessa että Vercelin
|
|
15
|
+
reunaympäristössä. Vertailu tehdään `crypto.subtle.verify`-kutsulla, joka
|
|
16
|
+
ei vuoda ajastuksen kautta tietoa oikeasta allekirjoituksesta.
|
|
17
|
+
========================================================================== */
|
|
18
|
+
function tekstiksi(data) {
|
|
19
|
+
const tavut = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
20
|
+
let merkit = "";
|
|
21
|
+
for (const tavu of tavut)
|
|
22
|
+
merkit += String.fromCharCode(tavu);
|
|
23
|
+
return btoa(merkit).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
24
|
+
}
|
|
25
|
+
function tavuiksi(teksti) {
|
|
26
|
+
const täydennetty = teksti.replaceAll("-", "+").replaceAll("_", "/");
|
|
27
|
+
const merkit = atob(täydennetty.padEnd(Math.ceil(täydennetty.length / 4) * 4, "="));
|
|
28
|
+
const tavut = new Uint8Array(new ArrayBuffer(merkit.length));
|
|
29
|
+
for (let i = 0; i < merkit.length; i++)
|
|
30
|
+
tavut[i] = merkit.charCodeAt(i);
|
|
31
|
+
return tavut;
|
|
32
|
+
}
|
|
33
|
+
async function avainOlioksi(avain) {
|
|
34
|
+
return crypto.subtle.importKey("raw", new TextEncoder().encode(avain), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
|
|
35
|
+
}
|
|
36
|
+
/** Satunnainen merkkijono, esimerkiksi tunnuksen satunnaisluvuksi. */
|
|
37
|
+
export function satunnainen(tavuja = 16) {
|
|
38
|
+
return tekstiksi(crypto.getRandomValues(new Uint8Array(tavuja)));
|
|
39
|
+
}
|
|
40
|
+
/** Yksisuuntainen tiiviste. Käytetään selainsidokseen. */
|
|
41
|
+
export async function tiiviste(arvo) {
|
|
42
|
+
return tekstiksi(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(arvo)));
|
|
43
|
+
}
|
|
44
|
+
export async function luoTunnus(sisalto, avain) {
|
|
45
|
+
const runko = tekstiksi(new TextEncoder().encode(JSON.stringify(sisalto)));
|
|
46
|
+
const allekirjoitus = await crypto.subtle.sign("HMAC", await avainOlioksi(avain), new TextEncoder().encode(runko));
|
|
47
|
+
return `${runko}.${tekstiksi(allekirjoitus)}`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Lukee tunnuksen ja palauttaa sen sisällön, tai `null` jos tunnus on
|
|
51
|
+
* väärennetty, rikki, vanhentunut tai väärää lajia.
|
|
52
|
+
*
|
|
53
|
+
* Kaikki epäonnistumiset palauttavat saman `null`-arvon: kutsuja ei saa
|
|
54
|
+
* kertoa käyttäjälle, kumpi meni pieleen.
|
|
55
|
+
*/
|
|
56
|
+
export async function lueTunnus(tunnus, avain, laji, nytSekunteina = Math.floor(Date.now() / 1000)) {
|
|
57
|
+
const osat = tunnus.split(".");
|
|
58
|
+
if (osat.length !== 2)
|
|
59
|
+
return null;
|
|
60
|
+
const [runko, allekirjoitus] = osat;
|
|
61
|
+
let kelpaa;
|
|
62
|
+
try {
|
|
63
|
+
kelpaa = await crypto.subtle.verify("HMAC", await avainOlioksi(avain), tavuiksi(allekirjoitus), new TextEncoder().encode(runko));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (!kelpaa)
|
|
69
|
+
return null;
|
|
70
|
+
let sisalto;
|
|
71
|
+
try {
|
|
72
|
+
sisalto = JSON.parse(new TextDecoder().decode(tavuiksi(runko)));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
if (sisalto.laji !== laji)
|
|
78
|
+
return null;
|
|
79
|
+
if (typeof sisalto.sahkoposti !== "string" || sisalto.sahkoposti === "")
|
|
80
|
+
return null;
|
|
81
|
+
if (typeof sisalto.vanhenee !== "number" || sisalto.vanhenee <= nytSekunteina)
|
|
82
|
+
return null;
|
|
83
|
+
return sisalto;
|
|
84
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Tallennus } from "./github/tiedostot.ts";
|
|
2
|
+
import { type GithubAsetukset } from "./github/yhteys.ts";
|
|
3
|
+
export interface Varasto {
|
|
4
|
+
/** Lyhyt kuvaus lokia varten. */
|
|
5
|
+
nimi: string;
|
|
6
|
+
lueVersio(): Promise<string>;
|
|
7
|
+
lueTiedosto(polku: string, versio?: string): Promise<string | undefined>;
|
|
8
|
+
/** Tiedoston oma versio: muuttuu vain, kun tämä tiedosto muuttuu. */
|
|
9
|
+
lueTiedostonVersio(polku: string, versio?: string): Promise<string>;
|
|
10
|
+
tallenna(tallennus: Tallennus): Promise<{
|
|
11
|
+
versio: string;
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Valitsee varaston: GitHub, jos asetukset ovat, muuten kehityksessä
|
|
16
|
+
* paikalliset tiedostot. Tuotannossa puuttuvat asetukset ovat virhe.
|
|
17
|
+
*/
|
|
18
|
+
export declare function valitseVarasto(yliajot?: Partial<GithubAsetukset>): Varasto;
|