@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.
Files changed (69) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.js +9 -0
  3. package/dist/komponentit/Editori.d.ts +1 -0
  4. package/dist/komponentit/Editori.js +29 -0
  5. package/dist/komponentit/Muokattava.d.ts +10 -0
  6. package/dist/komponentit/Muokattava.js +7 -0
  7. package/dist/komponentit/index.d.ts +2 -0
  8. package/dist/komponentit/index.js +5 -0
  9. package/dist/maarittely.d.ts +215 -0
  10. package/dist/maarittely.js +170 -0
  11. package/dist/palvelin/apurit.d.ts +26 -0
  12. package/dist/palvelin/apurit.js +70 -0
  13. package/dist/palvelin/asetukset.d.ts +26 -0
  14. package/dist/palvelin/asetukset.js +55 -0
  15. package/dist/palvelin/github/avain.d.ts +15 -0
  16. package/dist/palvelin/github/avain.js +121 -0
  17. package/dist/palvelin/github/tiedostot.d.ts +56 -0
  18. package/dist/palvelin/github/tiedostot.js +144 -0
  19. package/dist/palvelin/github/yhteys.d.ts +28 -0
  20. package/dist/palvelin/github/yhteys.js +89 -0
  21. package/dist/palvelin/index.d.ts +15 -0
  22. package/dist/palvelin/index.js +19 -0
  23. package/dist/palvelin/istunto.d.ts +34 -0
  24. package/dist/palvelin/istunto.js +86 -0
  25. package/dist/palvelin/kaannos.d.ts +12 -0
  26. package/dist/palvelin/kaannos.js +102 -0
  27. package/dist/palvelin/kaannosreitti.d.ts +9 -0
  28. package/dist/palvelin/kaannosreitti.js +42 -0
  29. package/dist/palvelin/kirjautuminen.d.ts +25 -0
  30. package/dist/palvelin/kirjautuminen.js +183 -0
  31. package/dist/palvelin/kuvat.d.ts +29 -0
  32. package/dist/palvelin/kuvat.js +130 -0
  33. package/dist/palvelin/kuvateksti.d.ts +15 -0
  34. package/dist/palvelin/kuvateksti.js +95 -0
  35. package/dist/palvelin/lue.d.ts +6 -0
  36. package/dist/palvelin/lue.js +41 -0
  37. package/dist/palvelin/posti.d.ts +9 -0
  38. package/dist/palvelin/posti.js +45 -0
  39. package/dist/palvelin/reitit.d.ts +10 -0
  40. package/dist/palvelin/reitit.js +71 -0
  41. package/dist/palvelin/tallennus.d.ts +18 -0
  42. package/dist/palvelin/tallennus.js +177 -0
  43. package/dist/palvelin/tila.d.ts +21 -0
  44. package/dist/palvelin/tila.js +116 -0
  45. package/dist/palvelin/tunnus.d.ts +30 -0
  46. package/dist/palvelin/tunnus.js +84 -0
  47. package/dist/palvelin/varasto.d.ts +18 -0
  48. package/dist/palvelin/varasto.js +87 -0
  49. package/dist/selain/EditorinPalkki.d.ts +1 -0
  50. package/dist/selain/EditorinPalkki.js +35 -0
  51. package/dist/selain/Muokkaustila.d.ts +3 -0
  52. package/dist/selain/Muokkaustila.js +646 -0
  53. package/dist/selain/PalkkiLataaja.d.ts +1 -0
  54. package/dist/selain/PalkkiLataaja.js +19 -0
  55. package/dist/selain/Paneeli.d.ts +44 -0
  56. package/dist/selain/Paneeli.js +259 -0
  57. package/dist/selain/index.d.ts +4 -0
  58. package/dist/selain/index.js +8 -0
  59. package/dist/selain/kieli.d.ts +10 -0
  60. package/dist/selain/kieli.js +14 -0
  61. package/dist/selain/kuva.d.ts +7 -0
  62. package/dist/selain/kuva.js +45 -0
  63. package/dist/selain/polku.d.ts +21 -0
  64. package/dist/selain/polku.js +108 -0
  65. package/dist/selain/tyypit.d.ts +22 -0
  66. package/dist/selain/tyypit.js +2 -0
  67. package/dist/tarkistus.d.ts +26 -0
  68. package/dist/tarkistus.js +324 -0
  69. 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>;