@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,121 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
GITHUB APPIN AVAIN JA TUNNISTAUTUMINEN
|
|
3
|
+
==========================================================================
|
|
4
|
+
Editori kirjoittaa sivuston repoon GitHub Appina, ei kenenkään
|
|
5
|
+
henkilökohtaisella tunnuksella. Sovellus todistaa itsensä lyhytikäisellä
|
|
6
|
+
JWT:llä, joka on allekirjoitettu sen yksityisellä avaimella.
|
|
7
|
+
|
|
8
|
+
GitHub antaa avaimen `.pem`-tiedostona vanhassa PKCS#1-muodossa
|
|
9
|
+
("BEGIN RSA PRIVATE KEY"), mutta selainten ja Vercelin Web Crypto osaa
|
|
10
|
+
vain uudempaa PKCS#8:aa ("BEGIN PRIVATE KEY"). Muunnos tehdään täällä,
|
|
11
|
+
jottei kenenkään tarvitse ajaa openssl-komentoja ennen käyttöönottoa.
|
|
12
|
+
========================================================================== */
|
|
13
|
+
/** DER-pituuskenttä. */
|
|
14
|
+
function pituus(tavuja) {
|
|
15
|
+
if (tavuja < 0x80)
|
|
16
|
+
return [tavuja];
|
|
17
|
+
const osat = [];
|
|
18
|
+
let jaljella = tavuja;
|
|
19
|
+
while (jaljella > 0) {
|
|
20
|
+
osat.unshift(jaljella & 0xff);
|
|
21
|
+
jaljella >>= 8;
|
|
22
|
+
}
|
|
23
|
+
return [0x80 | osat.length, ...osat];
|
|
24
|
+
}
|
|
25
|
+
/** PKCS#1-avain PKCS#8-kuoreen, jotta Web Crypto suostuu lukemaan sen. */
|
|
26
|
+
function pkcs1Pkcs8ksi(pkcs1) {
|
|
27
|
+
const versio = [0x02, 0x01, 0x00];
|
|
28
|
+
/* AlgorithmIdentifier: rsaEncryption (1.2.840.113549.1.1.1), parametrit NULL */
|
|
29
|
+
const algoritmi = [
|
|
30
|
+
0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00,
|
|
31
|
+
];
|
|
32
|
+
const avainOctetString = [0x04, ...pituus(pkcs1.length), ...pkcs1];
|
|
33
|
+
const sisalto = [...versio, ...algoritmi, ...avainOctetString];
|
|
34
|
+
const kokonaisuus = [0x30, ...pituus(sisalto.length), ...sisalto];
|
|
35
|
+
const tulos = new Uint8Array(new ArrayBuffer(kokonaisuus.length));
|
|
36
|
+
tulos.set(kokonaisuus);
|
|
37
|
+
return tulos;
|
|
38
|
+
}
|
|
39
|
+
function base64Tavuiksi(base64) {
|
|
40
|
+
const merkit = atob(base64.replace(/\s+/g, ""));
|
|
41
|
+
const tavut = new Uint8Array(new ArrayBuffer(merkit.length));
|
|
42
|
+
for (let i = 0; i < merkit.length; i++)
|
|
43
|
+
tavut[i] = merkit.charCodeAt(i);
|
|
44
|
+
return tavut;
|
|
45
|
+
}
|
|
46
|
+
function base64ksi(data) {
|
|
47
|
+
const tavut = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
48
|
+
let merkit = "";
|
|
49
|
+
for (const tavu of tavut)
|
|
50
|
+
merkit += String.fromCharCode(tavu);
|
|
51
|
+
return btoa(merkit).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Lukee avaimen ympäristömuuttujan arvosta.
|
|
55
|
+
*
|
|
56
|
+
* Hyväksyy kolme muotoa, koska kaikkia näkee käytännössä:
|
|
57
|
+
* - PEM sellaisenaan (rivinvaihdot tallessa)
|
|
58
|
+
* - PEM, jossa rivinvaihdot on kirjoitettu merkkeinä \n
|
|
59
|
+
* - koko PEM base64-koodattuna yhdelle riville
|
|
60
|
+
*/
|
|
61
|
+
export async function lueYksityinenAvain(arvo) {
|
|
62
|
+
/* Arvoon tarttuu liitettäessä herkästi lainausmerkit tai \n-merkkijonot,
|
|
63
|
+
eikä kukaan huomaa sitä silmämääräisesti. Siivotaan ne pois. */
|
|
64
|
+
let pem = arvo.trim().replace(/^["']|["']$/g, "").replaceAll("\\n", "\n").trim();
|
|
65
|
+
if (!pem.includes("BEGIN")) {
|
|
66
|
+
/* Pelkkä base64 kelpaa kahdella tapaa: se voi olla koko PEM pakattuna
|
|
67
|
+
yhdelle riville tai suoraan avaimen sisältö ilman BEGIN-rivejä. */
|
|
68
|
+
let purettu;
|
|
69
|
+
try {
|
|
70
|
+
purettu = new TextDecoder().decode(base64Tavuiksi(pem));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
purettu = undefined;
|
|
74
|
+
}
|
|
75
|
+
if (purettu?.includes("BEGIN")) {
|
|
76
|
+
pem = purettu;
|
|
77
|
+
}
|
|
78
|
+
else if (/^[A-Za-z0-9+/=\s-_]+$/.test(pem) && pem.replace(/\s+/g, "").length > 100) {
|
|
79
|
+
/* Näyttää avaimen sisällöltä ilman kuoria: kokeillaan sellaisenaan. */
|
|
80
|
+
pem = `-----BEGIN PRIVATE KEY-----\n${pem}\n-----END PRIVATE KEY-----`;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
throw new Error(`GitHub Appin yksityistä avainta ei voitu lukea: arvossa ei ole BEGIN-riviä eikä se ` +
|
|
84
|
+
`ole base64:ää (pituus ${pem.length} merkkiä). Kopioi .pem-tiedoston koko sisältö ` +
|
|
85
|
+
`uudelleen, BEGIN- ja END-rivit mukaan lukien.`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const pkcs1 = pem.includes("BEGIN RSA PRIVATE KEY");
|
|
89
|
+
const runko = pem
|
|
90
|
+
.replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "")
|
|
91
|
+
.replace(/-----END [A-Z ]*PRIVATE KEY-----/, "");
|
|
92
|
+
let tavut;
|
|
93
|
+
try {
|
|
94
|
+
tavut = base64Tavuiksi(runko);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
throw new Error("GitHub Appin yksityinen avain ei ole kelvollista base64:ää.");
|
|
98
|
+
}
|
|
99
|
+
if (pkcs1)
|
|
100
|
+
tavut = pkcs1Pkcs8ksi(tavut);
|
|
101
|
+
try {
|
|
102
|
+
return await crypto.subtle.importKey("pkcs8", tavut, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["sign"]);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
throw new Error("GitHub Appin yksityistä avainta ei kelpuutettu. Tarkista, että arvo on koko " +
|
|
106
|
+
".pem-tiedoston sisältö BEGIN- ja END-rivit mukaan lukien.");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* GitHub Appin JWT. Voimassa GitHubin sallimat 10 minuuttia, mutta
|
|
111
|
+
* käytämme yhdeksää: GitHub hylkää tunnuksen, jos palvelimen kello on
|
|
112
|
+
* hieman edellä.
|
|
113
|
+
*/
|
|
114
|
+
export async function luoAppJwt(appId, avain) {
|
|
115
|
+
const nyt = Math.floor(Date.now() / 1000);
|
|
116
|
+
const otsake = base64ksi(new TextEncoder().encode(JSON.stringify({ alg: "RS256", typ: "JWT" })));
|
|
117
|
+
const sisalto = base64ksi(new TextEncoder().encode(JSON.stringify({ iat: nyt - 60, exp: nyt + 9 * 60, iss: appId })));
|
|
118
|
+
const runko = `${otsake}.${sisalto}`;
|
|
119
|
+
const allekirjoitus = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", avain, new TextEncoder().encode(runko));
|
|
120
|
+
return `${runko}.${base64ksi(allekirjoitus)}`;
|
|
121
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type GithubAsetukset } from "./yhteys.ts";
|
|
2
|
+
export interface Tiedosto {
|
|
3
|
+
/** Polku repon juuresta, esim. "sisalto/etusivu.json". */
|
|
4
|
+
polku: string;
|
|
5
|
+
sisalto: string;
|
|
6
|
+
/** base64 kuville, utf-8 tekstille. Oletus utf-8. */
|
|
7
|
+
koodaus?: "utf-8" | "base64";
|
|
8
|
+
}
|
|
9
|
+
export interface Tekija {
|
|
10
|
+
nimi: string;
|
|
11
|
+
sahkoposti: string;
|
|
12
|
+
}
|
|
13
|
+
export declare class RistiriitaVirhe extends Error {
|
|
14
|
+
/** Repon nykyinen versio. */
|
|
15
|
+
nykyinenVersio: string;
|
|
16
|
+
/** Tiedostot, jotka ovat muuttuneet muokkauksen aikana. */
|
|
17
|
+
muuttuneet: string[];
|
|
18
|
+
constructor(nykyinenVersio: string, muuttuneet: string[]);
|
|
19
|
+
}
|
|
20
|
+
/** Haaran nykyinen versio (commitin tunnus). */
|
|
21
|
+
export declare function lueVersio(asetukset: GithubAsetukset): Promise<string>;
|
|
22
|
+
/**
|
|
23
|
+
* Lukee sisältötiedoston GitHubista. Palauttaa `undefined`, jos tiedostoa
|
|
24
|
+
* ei ole — uusi sivu on normaali tilanne, ei virhe.
|
|
25
|
+
*/
|
|
26
|
+
export interface TiedostonTiedot {
|
|
27
|
+
sisalto: string;
|
|
28
|
+
/** Tiedoston oma versio. Muuttuu vain, kun tämä tiedosto muuttuu. */
|
|
29
|
+
versio: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function lueTiedostonTiedot(asetukset: GithubAsetukset, polku: string, versio?: string): Promise<TiedostonTiedot | undefined>;
|
|
32
|
+
export declare function lueTiedosto(asetukset: GithubAsetukset, polku: string, versio?: string): Promise<string | undefined>;
|
|
33
|
+
export interface Tallennus {
|
|
34
|
+
tiedostot: Tiedosto[];
|
|
35
|
+
viesti: string;
|
|
36
|
+
tekija: Tekija;
|
|
37
|
+
/**
|
|
38
|
+
* Tiedostokohtaiset versiot, joista muokkaus alkoi: polku → versio.
|
|
39
|
+
*
|
|
40
|
+
* Vertailu tehdään tiedostoittain eikä koko repon versiona. Muuten
|
|
41
|
+
* mikä tahansa muu muutos repossa — vaikka koodipäivitys, jolla ei ole
|
|
42
|
+
* sisällön kanssa mitään tekemistä — estäisi tallennuksen.
|
|
43
|
+
*/
|
|
44
|
+
perustuu?: Record<string, string>;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Tallentaa tiedostot yhtenä committina.
|
|
48
|
+
*
|
|
49
|
+
* Kulku on gitin oma: sisällöt talteen, uusi puu vanhan päälle, commit ja
|
|
50
|
+
* lopuksi haaran siirto. Viimeinen askel tehdään ilman pakotusta, joten
|
|
51
|
+
* kahden yhtäaikaisen tallennuksen jälkimmäinen hylätään sen sijaan että
|
|
52
|
+
* se pyyhkisi ensimmäisen.
|
|
53
|
+
*/
|
|
54
|
+
export declare function tallennaTiedostot(asetukset: GithubAsetukset, tallennus: Tallennus): Promise<{
|
|
55
|
+
versio: string;
|
|
56
|
+
}>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
SISÄLTÖTIEDOSTOT GITHUBISSA
|
|
3
|
+
==========================================================================
|
|
4
|
+
Sisältö on sivuston repossa JSON-tiedostoina, ja git on samalla
|
|
5
|
+
versiohistoria. Tallennus on **yksi commit**: kaikki muuttuneet tiedostot
|
|
6
|
+
menevät kerralla, tai ei mitään. Puolikasta tallennusta ei synny.
|
|
7
|
+
|
|
8
|
+
Ristiriita tunnistetaan versiosta, josta muokkaus alkoi. Jos joku muu on
|
|
9
|
+
tallentanut välissä, tallennus pysähtyy eikä ylikirjoita mitään.
|
|
10
|
+
========================================================================== */
|
|
11
|
+
import { onSallittuKuvapolku, onSallittuPolku } from "../../maarittely.js";
|
|
12
|
+
import { github, GithubVirhe } from "./yhteys.js";
|
|
13
|
+
export class RistiriitaVirhe extends Error {
|
|
14
|
+
/** Repon nykyinen versio. */
|
|
15
|
+
nykyinenVersio;
|
|
16
|
+
/** Tiedostot, jotka ovat muuttuneet muokkauksen aikana. */
|
|
17
|
+
muuttuneet;
|
|
18
|
+
constructor(nykyinenVersio, muuttuneet) {
|
|
19
|
+
super("Joku muu on tallentanut muutoksia sillä välin.");
|
|
20
|
+
this.name = "RistiriitaVirhe";
|
|
21
|
+
this.nykyinenVersio = nykyinenVersio;
|
|
22
|
+
this.muuttuneet = muuttuneet;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function varmistaPolku(polku) {
|
|
26
|
+
if (!onSallittuPolku(polku) && !onSallittuKuvapolku(polku)) {
|
|
27
|
+
throw new Error(`Polku "${polku}" ei ole sallittu sisältötiedosto.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Sisältömallin polku repon poluksi.
|
|
32
|
+
*
|
|
33
|
+
* Sivusto voi olla repon juuressa tai omassa kansiossaan. Etuliite tulee
|
|
34
|
+
* aina asetuksista, ei koskaan pyynnöstä, ja polku tarkistetaan ennen
|
|
35
|
+
* etuliitteen lisäämistä.
|
|
36
|
+
*/
|
|
37
|
+
function repopolku(asetukset, polku) {
|
|
38
|
+
varmistaPolku(polku);
|
|
39
|
+
return asetukset.juuri ? `${asetukset.juuri}/${polku}` : polku;
|
|
40
|
+
}
|
|
41
|
+
/** Haaran nykyinen versio (commitin tunnus). */
|
|
42
|
+
export async function lueVersio(asetukset) {
|
|
43
|
+
const viite = await github(asetukset, `/repos/${asetukset.repo}/git/ref/heads/${asetukset.haara}`);
|
|
44
|
+
return viite.object.sha;
|
|
45
|
+
}
|
|
46
|
+
export async function lueTiedostonTiedot(asetukset, polku, versio) {
|
|
47
|
+
const kohde = repopolku(asetukset, polku);
|
|
48
|
+
const viite = versio ?? asetukset.haara;
|
|
49
|
+
try {
|
|
50
|
+
const vastaus = await github(asetukset, `/repos/${asetukset.repo}/contents/${kohde}?ref=${encodeURIComponent(viite)}`);
|
|
51
|
+
if (!vastaus.content || vastaus.encoding !== "base64")
|
|
52
|
+
return undefined;
|
|
53
|
+
return {
|
|
54
|
+
sisalto: new TextDecoder().decode(Uint8Array.from(atob(vastaus.content.replace(/\s+/g, "")), (merkki) => merkki.charCodeAt(0))),
|
|
55
|
+
versio: vastaus.sha ?? "",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
catch (virhe) {
|
|
59
|
+
if (virhe instanceof GithubVirhe && virhe.tila === 404)
|
|
60
|
+
return undefined;
|
|
61
|
+
throw virhe;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function lueTiedosto(asetukset, polku, versio) {
|
|
65
|
+
return (await lueTiedostonTiedot(asetukset, polku, versio))?.sisalto;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Tallentaa tiedostot yhtenä committina.
|
|
69
|
+
*
|
|
70
|
+
* Kulku on gitin oma: sisällöt talteen, uusi puu vanhan päälle, commit ja
|
|
71
|
+
* lopuksi haaran siirto. Viimeinen askel tehdään ilman pakotusta, joten
|
|
72
|
+
* kahden yhtäaikaisen tallennuksen jälkimmäinen hylätään sen sijaan että
|
|
73
|
+
* se pyyhkisi ensimmäisen.
|
|
74
|
+
*/
|
|
75
|
+
export async function tallennaTiedostot(asetukset, tallennus) {
|
|
76
|
+
if (tallennus.tiedostot.length === 0)
|
|
77
|
+
throw new Error("Tallennettavia tiedostoja ei ole.");
|
|
78
|
+
const kohteet = tallennus.tiedostot.map((tiedosto) => ({
|
|
79
|
+
polku: repopolku(asetukset, tiedosto.polku),
|
|
80
|
+
sisalto: tiedosto.sisalto,
|
|
81
|
+
koodaus: tiedosto.koodaus ?? "utf-8",
|
|
82
|
+
}));
|
|
83
|
+
/*
|
|
84
|
+
Yritetään muutaman kerran: haaran kärki voi siirtyä juuri tämän
|
|
85
|
+
tallennuksen aikana (toinen muokkaaja, koodipäivitys, rinnakkainen kuvan
|
|
86
|
+
lataus). Silloin gitin oma suojaus hylkää kirjoituksen, ja oikea vastaus
|
|
87
|
+
on rakentaa commit uudelleen tuoreen kärjen päälle — ei kaataa
|
|
88
|
+
tallennusta eikä pakottaa kirjoitusta vanhan päälle.
|
|
89
|
+
*/
|
|
90
|
+
let viimeisinVirhe;
|
|
91
|
+
for (let yritys = 0; yritys < 3; yritys++) {
|
|
92
|
+
try {
|
|
93
|
+
return await kirjoitaKerran(asetukset, tallennus, kohteet, yritys === 0);
|
|
94
|
+
}
|
|
95
|
+
catch (virhe) {
|
|
96
|
+
if (virhe instanceof RistiriitaVirhe)
|
|
97
|
+
throw virhe;
|
|
98
|
+
const siirtyi = virhe instanceof GithubVirhe && (virhe.tila === 422 || virhe.tila === 409);
|
|
99
|
+
if (!siirtyi)
|
|
100
|
+
throw virhe;
|
|
101
|
+
viimeisinVirhe = virhe;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
throw viimeisinVirhe;
|
|
105
|
+
}
|
|
106
|
+
/** Yksi kirjoituskierros: sisällöt, puu, commit ja haaran siirto. */
|
|
107
|
+
async function kirjoitaKerran(asetukset, tallennus, kohteet, tarkistaRistiriita) {
|
|
108
|
+
const nykyinen = await lueVersio(asetukset);
|
|
109
|
+
if (tarkistaRistiriita && tallennus.perustuu) {
|
|
110
|
+
const muuttuneet = [];
|
|
111
|
+
for (const [polku, alkuperainen] of Object.entries(tallennus.perustuu)) {
|
|
112
|
+
const nyt = (await lueTiedostonTiedot(asetukset, polku, nykyinen))?.versio ?? "";
|
|
113
|
+
if (nyt !== alkuperainen)
|
|
114
|
+
muuttuneet.push(polku);
|
|
115
|
+
}
|
|
116
|
+
if (muuttuneet.length > 0)
|
|
117
|
+
throw new RistiriitaVirhe(nykyinen, muuttuneet);
|
|
118
|
+
}
|
|
119
|
+
const blobit = await Promise.all(kohteet.map(async (tiedosto) => {
|
|
120
|
+
const blob = await github(asetukset, `/repos/${asetukset.repo}/git/blobs`, {
|
|
121
|
+
menetelma: "POST",
|
|
122
|
+
runko: { content: tiedosto.sisalto, encoding: tiedosto.koodaus },
|
|
123
|
+
});
|
|
124
|
+
return { path: tiedosto.polku, mode: "100644", type: "blob", sha: blob.sha };
|
|
125
|
+
}));
|
|
126
|
+
const puu = await github(asetukset, `/repos/${asetukset.repo}/git/trees`, {
|
|
127
|
+
menetelma: "POST",
|
|
128
|
+
runko: { base_tree: nykyinen, tree: blobit },
|
|
129
|
+
});
|
|
130
|
+
const commit = await github(asetukset, `/repos/${asetukset.repo}/git/commits`, {
|
|
131
|
+
menetelma: "POST",
|
|
132
|
+
runko: {
|
|
133
|
+
message: tallennus.viesti,
|
|
134
|
+
tree: puu.sha,
|
|
135
|
+
parents: [nykyinen],
|
|
136
|
+
author: { name: tallennus.tekija.nimi, email: tallennus.tekija.sahkoposti },
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
await github(asetukset, `/repos/${asetukset.repo}/git/refs/heads/${asetukset.haara}`, {
|
|
140
|
+
menetelma: "PATCH",
|
|
141
|
+
runko: { sha: commit.sha, force: false },
|
|
142
|
+
});
|
|
143
|
+
return { versio: commit.sha };
|
|
144
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface GithubAsetukset {
|
|
2
|
+
/** GitHub Appin numerotunnus. */
|
|
3
|
+
appId: string;
|
|
4
|
+
/** Yksityinen avain PEM-muodossa (tai base64). */
|
|
5
|
+
yksityinenAvain: string;
|
|
6
|
+
/** Repo muodossa "omistaja/repo". */
|
|
7
|
+
repo: string;
|
|
8
|
+
/** Haara, johon tallennetaan. Oletuksena main. */
|
|
9
|
+
haara: string;
|
|
10
|
+
/**
|
|
11
|
+
* Sivuston kansio repossa, jos sivusto ei ole repon juuressa
|
|
12
|
+
* (esim. "esimerkki" tai "sivusto"). Tyhjä = juuri.
|
|
13
|
+
*/
|
|
14
|
+
juuri: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function lueGithubAsetukset(yliajot?: Partial<GithubAsetukset>): GithubAsetukset;
|
|
17
|
+
/** Testejä varten. */
|
|
18
|
+
export declare function nollaaTunnukset(): void;
|
|
19
|
+
export declare class GithubVirhe extends Error {
|
|
20
|
+
/** HTTP-tila, jonka GitHub palautti. */
|
|
21
|
+
tila: number;
|
|
22
|
+
constructor(message: string, tila: number);
|
|
23
|
+
}
|
|
24
|
+
/** Pyyntö asennuksen tunnuksella. Tätä käyttävät tiedosto-operaatiot. */
|
|
25
|
+
export declare function github<T>(asetukset: GithubAsetukset, polku: string, lisa?: {
|
|
26
|
+
menetelma?: string;
|
|
27
|
+
runko?: unknown;
|
|
28
|
+
}): Promise<T>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
GITHUB-YHTEYS
|
|
3
|
+
==========================================================================
|
|
4
|
+
Kaikki liikenne GitHubiin kulkee tämän tiedoston kautta: asennuksen
|
|
5
|
+
tunnus haetaan kerran ja pidetään muistissa, ja pyynnöt tehdään yhdellä
|
|
6
|
+
apufunktiolla, joka kääntää virheet suomeksi.
|
|
7
|
+
|
|
8
|
+
Asennuksen tunnus on voimassa tunnin. Sitä ei tallenneta mihinkään —
|
|
9
|
+
palvelimen muisti riittää, ja uudessa instanssissa haetaan uusi.
|
|
10
|
+
========================================================================== */
|
|
11
|
+
import { luoAppJwt, lueYksityinenAvain } from "./avain.js";
|
|
12
|
+
export function lueGithubAsetukset(yliajot = {}) {
|
|
13
|
+
const appId = yliajot.appId ?? process.env.EDITORI_GITHUB_APP_ID ?? "";
|
|
14
|
+
const yksityinenAvain = yliajot.yksityinenAvain ?? process.env.EDITORI_GITHUB_AVAIN ?? "";
|
|
15
|
+
const repo = yliajot.repo ?? process.env.EDITORI_GITHUB_REPO ?? "";
|
|
16
|
+
const haara = yliajot.haara ?? process.env.EDITORI_GITHUB_HAARA ?? "main";
|
|
17
|
+
const juuri = (yliajot.juuri ?? process.env.EDITORI_GITHUB_JUURI ?? "").replace(/^\/+|\/+$/g, "");
|
|
18
|
+
const puuttuu = [];
|
|
19
|
+
if (!appId)
|
|
20
|
+
puuttuu.push("EDITORI_GITHUB_APP_ID");
|
|
21
|
+
if (!yksityinenAvain)
|
|
22
|
+
puuttuu.push("EDITORI_GITHUB_AVAIN");
|
|
23
|
+
if (!repo)
|
|
24
|
+
puuttuu.push("EDITORI_GITHUB_REPO");
|
|
25
|
+
if (puuttuu.length > 0) {
|
|
26
|
+
throw new Error(`Editorin GitHub-asetukset puuttuvat: ${puuttuu.join(", ")}.`);
|
|
27
|
+
}
|
|
28
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) {
|
|
29
|
+
throw new Error(`EDITORI_GITHUB_REPO pitää olla muodossa "omistaja/repo", nyt "${repo}".`);
|
|
30
|
+
}
|
|
31
|
+
if (juuri !== "" && !/^[\w.-]+(?:\/[\w.-]+)*$/.test(juuri)) {
|
|
32
|
+
throw new Error(`EDITORI_GITHUB_JUURI ei kelpaa: "${juuri}".`);
|
|
33
|
+
}
|
|
34
|
+
return { appId, yksityinenAvain, repo, haara, juuri };
|
|
35
|
+
}
|
|
36
|
+
const TUNNUKSET = new Map();
|
|
37
|
+
/** Testejä varten. */
|
|
38
|
+
export function nollaaTunnukset() {
|
|
39
|
+
TUNNUKSET.clear();
|
|
40
|
+
}
|
|
41
|
+
async function asennuksenTunnus(asetukset) {
|
|
42
|
+
const muistissa = TUNNUKSET.get(asetukset.repo);
|
|
43
|
+
/* Uusitaan viisi minuuttia ennen vanhenemista, jottei kesken tallennuksen
|
|
44
|
+
jäädä tunnuksen kanssa yksin. */
|
|
45
|
+
if (muistissa && muistissa.vanhenee - 5 * 60 * 1000 > Date.now())
|
|
46
|
+
return muistissa.tunnus;
|
|
47
|
+
const avain = await lueYksityinenAvain(asetukset.yksityinenAvain);
|
|
48
|
+
const jwt = await luoAppJwt(asetukset.appId, avain);
|
|
49
|
+
const asennus = await githubPyynto(`/repos/${asetukset.repo}/installation`, { tunnus: jwt });
|
|
50
|
+
const tunnus = await githubPyynto(`/app/installations/${asennus.id}/access_tokens`, { tunnus: jwt, menetelma: "POST" });
|
|
51
|
+
TUNNUKSET.set(asetukset.repo, {
|
|
52
|
+
tunnus: tunnus.token,
|
|
53
|
+
vanhenee: new Date(tunnus.expires_at).getTime(),
|
|
54
|
+
});
|
|
55
|
+
return tunnus.token;
|
|
56
|
+
}
|
|
57
|
+
/* ---- Pyynnöt ------------------------------------------------------------- */
|
|
58
|
+
export class GithubVirhe extends Error {
|
|
59
|
+
/** HTTP-tila, jonka GitHub palautti. */
|
|
60
|
+
tila;
|
|
61
|
+
constructor(message, tila) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.name = "GithubVirhe";
|
|
64
|
+
this.tila = tila;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function githubPyynto(polku, asetukset) {
|
|
68
|
+
const vastaus = await fetch(`https://api.github.com${polku}`, {
|
|
69
|
+
method: asetukset.menetelma ?? "GET",
|
|
70
|
+
headers: {
|
|
71
|
+
Authorization: `Bearer ${asetukset.tunnus}`,
|
|
72
|
+
Accept: "application/vnd.github+json",
|
|
73
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
74
|
+
"User-Agent": "studio-editori",
|
|
75
|
+
...(asetukset.runko === undefined ? {} : { "Content-Type": "application/json" }),
|
|
76
|
+
},
|
|
77
|
+
...(asetukset.runko === undefined ? {} : { body: JSON.stringify(asetukset.runko) }),
|
|
78
|
+
});
|
|
79
|
+
if (!vastaus.ok) {
|
|
80
|
+
const teksti = await vastaus.text().catch(() => "");
|
|
81
|
+
throw new GithubVirhe(`GitHub vastasi virheellä ${vastaus.status} (${polku}): ${teksti.slice(0, 300)}`, vastaus.status);
|
|
82
|
+
}
|
|
83
|
+
return (await vastaus.json());
|
|
84
|
+
}
|
|
85
|
+
/** Pyyntö asennuksen tunnuksella. Tätä käyttävät tiedosto-operaatiot. */
|
|
86
|
+
export async function github(asetukset, polku, lisa = {}) {
|
|
87
|
+
const tunnus = await asennuksenTunnus(asetukset);
|
|
88
|
+
return githubPyynto(polku, { tunnus, ...lisa });
|
|
89
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { lueSivu, type LukuAsetukset } from "./lue.ts";
|
|
2
|
+
export { lueAsetukset, normalisoiOsoite, onSallittuMuokkaaja, type EditorinAsetukset, } from "./asetukset.ts";
|
|
3
|
+
export { EVASTE_ISTUNTO, EVASTE_PYYNTO, evaste, kirjautunutMuokkaaja, onMuokkaustila, poistaEvaste, type EvasteenAsetukset, } from "./istunto.ts";
|
|
4
|
+
export { editorinReitit, type ReittienAsetukset } from "./reitit.ts";
|
|
5
|
+
export { kirjautumisenReitit, nollaaYritykset, type KirjautumisenAsetukset } from "./kirjautuminen.ts";
|
|
6
|
+
export { sisallonReitit, type SisallonAsetukset } from "./tallennus.ts";
|
|
7
|
+
export { tarkistaTila, tilanReitti, type Tarkistus, type TilanAsetukset } from "./tila.ts";
|
|
8
|
+
export { ehdotaKuvateksti, type KuvatekstiAsetukset } from "./kuvateksti.ts";
|
|
9
|
+
export { kaannaTekstit, kaannosAvain, type Kaannettava, type KaannosAsetukset } from "./kaannos.ts";
|
|
10
|
+
export { kaannosReitti, type KaannoksenAsetukset } from "./kaannosreitti.ts";
|
|
11
|
+
export { kuvienReitti, type Kuvankasittely, type Kuvapyynto, type KuvienAsetukset, } from "./kuvat.ts";
|
|
12
|
+
export { lueGithubAsetukset, nollaaTunnukset, GithubVirhe, type GithubAsetukset, } from "./github/yhteys.ts";
|
|
13
|
+
export { lueTiedosto, lueVersio, tallennaTiedostot, RistiriitaVirhe, type Tallennus, type Tiedosto, } from "./github/tiedostot.ts";
|
|
14
|
+
export { lahetaKirjautumislinkki, type Kirjautumisviesti, type Lahetystulos } from "./posti.ts";
|
|
15
|
+
export { luoTunnus, lueTunnus, satunnainen, tiiviste, type TunnuksenLaji, type TunnuksenSisalto, } from "./tunnus.ts";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Studio Editori — palvelinosa. Käyttää Noden ja Nextin palvelinrajapintoja,
|
|
3
|
+
joten tätä ei saa tuoda selaimessa ajettavaan koodiin.
|
|
4
|
+
*/
|
|
5
|
+
export { lueSivu } from "./lue.js";
|
|
6
|
+
export { lueAsetukset, normalisoiOsoite, onSallittuMuokkaaja, } from "./asetukset.js";
|
|
7
|
+
export { EVASTE_ISTUNTO, EVASTE_PYYNTO, evaste, kirjautunutMuokkaaja, onMuokkaustila, poistaEvaste, } from "./istunto.js";
|
|
8
|
+
export { editorinReitit } from "./reitit.js";
|
|
9
|
+
export { kirjautumisenReitit, nollaaYritykset } from "./kirjautuminen.js";
|
|
10
|
+
export { sisallonReitit } from "./tallennus.js";
|
|
11
|
+
export { tarkistaTila, tilanReitti } from "./tila.js";
|
|
12
|
+
export { ehdotaKuvateksti } from "./kuvateksti.js";
|
|
13
|
+
export { kaannaTekstit, kaannosAvain } from "./kaannos.js";
|
|
14
|
+
export { kaannosReitti } from "./kaannosreitti.js";
|
|
15
|
+
export { kuvienReitti, } from "./kuvat.js";
|
|
16
|
+
export { lueGithubAsetukset, nollaaTunnukset, GithubVirhe, } from "./github/yhteys.js";
|
|
17
|
+
export { lueTiedosto, lueVersio, tallennaTiedostot, RistiriitaVirhe, } from "./github/tiedostot.js";
|
|
18
|
+
export { lahetaKirjautumislinkki } from "./posti.js";
|
|
19
|
+
export { luoTunnus, lueTunnus, satunnainen, tiiviste, } from "./tunnus.js";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type EditorinAsetukset } from "./asetukset.ts";
|
|
2
|
+
/** Istunnon eväste. */
|
|
3
|
+
export declare const EVASTE_ISTUNTO = "editori_istunto";
|
|
4
|
+
/** Kirjautumispyynnön eväste, joka sitoo linkin tähän selaimeen. */
|
|
5
|
+
export declare const EVASTE_PYYNTO = "editori_pyynto";
|
|
6
|
+
export interface EvasteenAsetukset {
|
|
7
|
+
maxAge: number;
|
|
8
|
+
/** Secure-lippu. Pois vain paikallisessa kehityksessä (http://localhost). */
|
|
9
|
+
suojattu: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Rakentaa Set-Cookie-otsakkeen.
|
|
13
|
+
*
|
|
14
|
+
* `SameSite=Lax` on tarkoituksella: kirjautumislinkki tulee sähköpostista
|
|
15
|
+
* eli toisesta ympäristöstä, ja `Strict` estäisi evästeen lähtemisen juuri
|
|
16
|
+
* siinä pyynnössä. Lax riittää, koska tallentavat pyynnöt ovat POSTeja.
|
|
17
|
+
*/
|
|
18
|
+
export declare function evaste(nimi: string, arvo: string, asetukset: EvasteenAsetukset): string;
|
|
19
|
+
/** Tyhjentää evästeen. */
|
|
20
|
+
export declare function poistaEvaste(nimi: string, suojattu: boolean): string;
|
|
21
|
+
/**
|
|
22
|
+
* Kirjautuneen muokkaajan sähköpostiosoite, tai `null`. Tätä käytetään
|
|
23
|
+
* sivuston palvelinkomponenteissa: kirjautuneelle näytetään muokkaustila,
|
|
24
|
+
* muille tavallinen sivu.
|
|
25
|
+
*/
|
|
26
|
+
export declare function kirjautunutMuokkaaja(yliajot?: Partial<EditorinAsetukset>): Promise<string | null>;
|
|
27
|
+
/**
|
|
28
|
+
* Onko selaimen muokkaustila päällä?
|
|
29
|
+
*
|
|
30
|
+
* Eri asia kuin kirjautuminen: istunto voi olla voimassa, vaikka
|
|
31
|
+
* muokkaustila on katkennut uuden julkaisun myötä. Sivuston sivu voi tämän
|
|
32
|
+
* avulla tarjota "Avaa muokkaustila" -painiketta.
|
|
33
|
+
*/
|
|
34
|
+
export declare function onMuokkaustila(): Promise<boolean>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
ISTUNTO
|
|
3
|
+
==========================================================================
|
|
4
|
+
Muokkaajan istunto on allekirjoitettu tunnus HttpOnly-evästeessä. Selain
|
|
5
|
+
ei näe sen sisältöä eikä JavaScript pääse siihen käsiksi.
|
|
6
|
+
|
|
7
|
+
Eväste on välttämätön istuntoeväste: se asetetaan vasta, kun joku
|
|
8
|
+
kirjautuu sisään, eikä tavallinen kävijä saa sitä koskaan. Suostumusta ei
|
|
9
|
+
siis tarvita, mutta **sivuston tietosuojaselosteessa se mainitaan.**
|
|
10
|
+
========================================================================== */
|
|
11
|
+
import { lueAsetukset } from "./asetukset.js";
|
|
12
|
+
import { lueTunnus } from "./tunnus.js";
|
|
13
|
+
/** Istunnon eväste. */
|
|
14
|
+
export const EVASTE_ISTUNTO = "editori_istunto";
|
|
15
|
+
/** Kirjautumispyynnön eväste, joka sitoo linkin tähän selaimeen. */
|
|
16
|
+
export const EVASTE_PYYNTO = "editori_pyynto";
|
|
17
|
+
/**
|
|
18
|
+
* Rakentaa Set-Cookie-otsakkeen.
|
|
19
|
+
*
|
|
20
|
+
* `SameSite=Lax` on tarkoituksella: kirjautumislinkki tulee sähköpostista
|
|
21
|
+
* eli toisesta ympäristöstä, ja `Strict` estäisi evästeen lähtemisen juuri
|
|
22
|
+
* siinä pyynnössä. Lax riittää, koska tallentavat pyynnöt ovat POSTeja.
|
|
23
|
+
*/
|
|
24
|
+
export function evaste(nimi, arvo, asetukset) {
|
|
25
|
+
const osat = [
|
|
26
|
+
`${nimi}=${arvo}`,
|
|
27
|
+
"Path=/",
|
|
28
|
+
"HttpOnly",
|
|
29
|
+
"SameSite=Lax",
|
|
30
|
+
`Max-Age=${asetukset.maxAge}`,
|
|
31
|
+
];
|
|
32
|
+
if (asetukset.suojattu)
|
|
33
|
+
osat.push("Secure");
|
|
34
|
+
return osat.join("; ");
|
|
35
|
+
}
|
|
36
|
+
/** Tyhjentää evästeen. */
|
|
37
|
+
export function poistaEvaste(nimi, suojattu) {
|
|
38
|
+
return evaste(nimi, "", { maxAge: 0, suojattu });
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Kirjautuneen muokkaajan sähköpostiosoite, tai `null`. Tätä käytetään
|
|
42
|
+
* sivuston palvelinkomponenteissa: kirjautuneelle näytetään muokkaustila,
|
|
43
|
+
* muille tavallinen sivu.
|
|
44
|
+
*/
|
|
45
|
+
export async function kirjautunutMuokkaaja(yliajot = {}) {
|
|
46
|
+
let asetukset;
|
|
47
|
+
try {
|
|
48
|
+
asetukset = lueAsetukset(yliajot);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
/* Puuttuva asetus ei saa kaataa julkista sivua — se tarkoittaa vain,
|
|
52
|
+
ettei kukaan voi olla kirjautuneena. */
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
/*
|
|
56
|
+
Next tuodaan vasta tässä, ei tiedoston alussa: `next/headers` ratkeaa
|
|
57
|
+
vain Nextin kääntäjän kautta, ja paketin palvelinosaa pitää voida lukea
|
|
58
|
+
myös tavallisessa Nodessa (testit, työkalut).
|
|
59
|
+
*/
|
|
60
|
+
const { cookies } = await import("next/headers");
|
|
61
|
+
const tunnus = (await cookies()).get(EVASTE_ISTUNTO)?.value;
|
|
62
|
+
if (!tunnus)
|
|
63
|
+
return null;
|
|
64
|
+
const sisalto = await lueTunnus(tunnus, asetukset.avain, "istunto");
|
|
65
|
+
if (!sisalto)
|
|
66
|
+
return null;
|
|
67
|
+
/* Oikeuden voi poistaa milloin tahansa: jos osoite ei ole enää sallittujen
|
|
68
|
+
listassa, vanha istunto lakkaa toimimasta heti. */
|
|
69
|
+
return asetukset.sallitut.includes(sisalto.sahkoposti) ? sisalto.sahkoposti : null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Onko selaimen muokkaustila päällä?
|
|
73
|
+
*
|
|
74
|
+
* Eri asia kuin kirjautuminen: istunto voi olla voimassa, vaikka
|
|
75
|
+
* muokkaustila on katkennut uuden julkaisun myötä. Sivuston sivu voi tämän
|
|
76
|
+
* avulla tarjota "Avaa muokkaustila" -painiketta.
|
|
77
|
+
*/
|
|
78
|
+
export async function onMuokkaustila() {
|
|
79
|
+
try {
|
|
80
|
+
const { draftMode } = await import("next/headers");
|
|
81
|
+
return (await draftMode()).isEnabled;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface KaannosAsetukset {
|
|
2
|
+
/** API-avain. Oletuksena EDITORI_KAANNOS_AVAIN, EDITORI_KUVATEKSTI_AVAIN tai ANTHROPIC_API_KEY. */
|
|
3
|
+
avain?: string;
|
|
4
|
+
}
|
|
5
|
+
/** Käännettävä sisältö: kentän avain → teksti tai lista kappaleita. */
|
|
6
|
+
export type Kaannettava = Record<string, string | string[]>;
|
|
7
|
+
export declare function kaannosAvain(asetukset?: KaannosAsetukset): string | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Kääntää annetut tekstit, tai palauttaa `null` jos ominaisuus ei ole
|
|
10
|
+
* käytössä tai vastaus ei ollut odotetun muotoinen.
|
|
11
|
+
*/
|
|
12
|
+
export declare function kaannaTekstit(tekstit: Kaannettava, lahdekieli: string, kohdekieli: string, asetukset?: KaannosAsetukset): Promise<Kaannettava | null>;
|