@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,102 @@
1
+ /* ==========================================================================
2
+ KÄÄNNÖSEHDOTUKSET
3
+ ==========================================================================
4
+ Täydentää puuttuvat käännökset ehdotuksina. Kuten kuvatekstissä, kyse on
5
+ ehdotuksesta: teksti ilmestyy kenttiin, ja muokkaaja lukee sen ennen kuin
6
+ painaa Tallenna.
7
+
8
+ Automaattista käännöstä ei tehdä missään vaiheessa. Käännös, joka menee
9
+ julkaisuun ilman että kukaan lukee sitä, on riski juuri niillä sivustoilla,
10
+ joissa tekstit on kirjoitettu tarkasti — ja niitä nämä sivustot ovat.
11
+
12
+ **Tekstit lähetetään Anthropicin rajapintaan.** Sama maininta
13
+ tietosuojaselosteeseen kuin kuvatekstiehdotuksessa.
14
+ ========================================================================== */
15
+ /** Yhdellä kerralla käännettävä määrä. Kurssi on kymmeniä rivejä, ei kirja. */
16
+ const ENINTAAN_MERKKEJA = 20000;
17
+ const KIELTEN_NIMET = {
18
+ fi: "suomi",
19
+ en: "englanti",
20
+ sv: "ruotsi",
21
+ de: "saksa",
22
+ };
23
+ function kielenNimi(koodi) {
24
+ return KIELTEN_NIMET[koodi] ?? koodi;
25
+ }
26
+ export function kaannosAvain(asetukset = {}) {
27
+ return (asetukset.avain ??
28
+ process.env.EDITORI_KAANNOS_AVAIN ??
29
+ process.env.EDITORI_KUVATEKSTI_AVAIN ??
30
+ process.env.ANTHROPIC_API_KEY);
31
+ }
32
+ function ohje(lahde, kohde) {
33
+ return [
34
+ `Käännä seuraavan JSON-olion arvot kielestä ${kielenNimi(lahde)} kielelle ${kielenNimi(kohde)}.`,
35
+ "",
36
+ "Säännöt:",
37
+ "- Säilytä rakenne täsmälleen: samat avaimet, listat listoina, yhtä monta kohtaa.",
38
+ "- Käännä vain arvot, älä avaimia.",
39
+ "- Säilytä sävy ja pituus. Älä selitä, kaunistele äläkä lisää mitään.",
40
+ "- Pidä numerot, hinnat, päivämäärät ja erisnimet ennallaan.",
41
+ "- Jos arvo on tyhjä, jätä se tyhjäksi.",
42
+ "- Vastaa pelkällä JSON-oliolla ilman koodilohkoa tai selityksiä.",
43
+ ].join("\n");
44
+ }
45
+ function samaMuoto(alkuperainen, tulos) {
46
+ if (typeof tulos !== "object" || tulos === null || Array.isArray(tulos))
47
+ return null;
48
+ const data = tulos;
49
+ const siistitty = {};
50
+ for (const [avain, arvo] of Object.entries(alkuperainen)) {
51
+ const kaannos = data[avain];
52
+ if (typeof arvo === "string") {
53
+ if (typeof kaannos === "string")
54
+ siistitty[avain] = kaannos;
55
+ }
56
+ else if (Array.isArray(kaannos)) {
57
+ /* Kappalemäärän on täsmättävä: muuten sivulle tulisi eri määrä
58
+ kappaleita kuin alkuperäisessä, ja ero jäisi huomaamatta. */
59
+ const rivit = kaannos.filter((rivi) => typeof rivi === "string");
60
+ if (rivit.length === arvo.length)
61
+ siistitty[avain] = rivit;
62
+ }
63
+ }
64
+ return Object.keys(siistitty).length > 0 ? siistitty : null;
65
+ }
66
+ /**
67
+ * Kääntää annetut tekstit, tai palauttaa `null` jos ominaisuus ei ole
68
+ * käytössä tai vastaus ei ollut odotetun muotoinen.
69
+ */
70
+ export async function kaannaTekstit(tekstit, lahdekieli, kohdekieli, asetukset = {}) {
71
+ const avain = kaannosAvain(asetukset);
72
+ if (!avain)
73
+ return null;
74
+ if (Object.keys(tekstit).length === 0)
75
+ return null;
76
+ const sisalto = JSON.stringify(tekstit);
77
+ if (sisalto.length > ENINTAAN_MERKKEJA)
78
+ return null;
79
+ try {
80
+ const { default: Anthropic } = await import("@anthropic-ai/sdk");
81
+ const asiakas = new Anthropic({ apiKey: avain });
82
+ const vastaus = await asiakas.messages.create({
83
+ model: "claude-opus-5",
84
+ max_tokens: 8000,
85
+ system: ohje(lahdekieli, kohdekieli),
86
+ messages: [{ role: "user", content: sisalto }],
87
+ });
88
+ if (vastaus.stop_reason === "refusal")
89
+ return null;
90
+ const teksti = vastaus.content
91
+ .filter((osa) => osa.type === "text")
92
+ .map((osa) => osa.text)
93
+ .join("")
94
+ .trim()
95
+ .replace(/^```(?:json)?\s*|\s*```$/g, "");
96
+ return samaMuoto(tekstit, JSON.parse(teksti));
97
+ }
98
+ catch (virhe) {
99
+ console.error("[editori] Käännösehdotus epäonnistui:", virhe);
100
+ return null;
101
+ }
102
+ }
@@ -0,0 +1,9 @@
1
+ import type { EditorinAsetukset } from "./asetukset.ts";
2
+ import { type KaannosAsetukset } from "./kaannos.ts";
3
+ import type { SisallonMaarittely } from "../maarittely.ts";
4
+ export interface KaannoksenAsetukset extends Partial<EditorinAsetukset> {
5
+ sisalto?: SisallonMaarittely;
6
+ /** `false` sulkee käännösehdotukset, vaikka avain olisi asetettu. */
7
+ kaannos?: KaannosAsetukset | false;
8
+ }
9
+ export declare function kaannosReitti(yliajot: KaannoksenAsetukset): (pyynto: Request) => Promise<Response>;
@@ -0,0 +1,42 @@
1
+ /* Käännösehdotusten reitti. Vain kirjautuneelle muokkaajalle. */
2
+ import { asetuksetTaiNull, json, muokkaajaPyynnosta } from "./apurit.js";
3
+ import { kaannaTekstit, kaannosAvain, } from "./kaannos.js";
4
+ export function kaannosReitti(yliajot) {
5
+ return async function kaanna(pyynto) {
6
+ const asetukset = asetuksetTaiNull(yliajot);
7
+ if (!asetukset)
8
+ return json({ virhe: "Editori ei ole käytössä." }, 503);
9
+ const muokkaaja = await muokkaajaPyynnosta(pyynto, asetukset);
10
+ if (!muokkaaja)
11
+ return json({ virhe: "Kirjaudu sisään." }, 401);
12
+ if (yliajot.kaannos === false || !kaannosAvain(yliajot.kaannos ?? {})) {
13
+ return json({ virhe: "Käännösehdotukset eivät ole käytössä tällä sivustolla." }, 503);
14
+ }
15
+ let data;
16
+ try {
17
+ data = (await pyynto.json());
18
+ }
19
+ catch {
20
+ return json({ virhe: "Pyyntö ei ollut kelvollista JSONia." }, 400);
21
+ }
22
+ const kielet = yliajot.sisalto?.kielet ?? [];
23
+ const { lahdekieli, kohdekieli, tekstit } = data;
24
+ /* Kielet tarkistetaan sisältömallia vasten: selain ei saa pyytää
25
+ käännöstä kielelle, jota sivustolla ei ole. */
26
+ if (typeof lahdekieli !== "string" ||
27
+ typeof kohdekieli !== "string" ||
28
+ !kielet.includes(lahdekieli) ||
29
+ !kielet.includes(kohdekieli) ||
30
+ lahdekieli === kohdekieli) {
31
+ return json({ virhe: "Kieliä ei tunnistettu." }, 400);
32
+ }
33
+ if (!tekstit || typeof tekstit !== "object" || Array.isArray(tekstit)) {
34
+ return json({ virhe: "Käännettävät tekstit puuttuvat." }, 400);
35
+ }
36
+ const kaannokset = await kaannaTekstit(tekstit, lahdekieli, kohdekieli, yliajot.kaannos ?? {});
37
+ if (!kaannokset) {
38
+ return json({ virhe: "Käännöstä ei saatu. Yritä uudelleen tai kirjoita teksti itse." }, 502);
39
+ }
40
+ return json({ kaannokset });
41
+ };
42
+ }
@@ -0,0 +1,25 @@
1
+ import { type EditorinAsetukset } from "./asetukset.ts";
2
+ /** Testejä varten. */
3
+ export declare function nollaaYritykset(): void;
4
+ /**
5
+ * Kytkee Nextin Draft Moden päälle tai pois.
6
+ *
7
+ * Draft Mode on se kytkin, jonka takia kirjautunut muokkaaja saa sivun
8
+ * palvelimelta joka kerta uudestaan ja muut kävijät saman valmiiksi
9
+ * rakennetun staattisen sivun kuin ennenkin. Ilman sitä muokkaustila
10
+ * pakottaisi koko sivuston dynaamiseksi kaikille.
11
+ *
12
+ * Huom: Draft Mode ei ole tunnistautuminen, vaan pelkkä välimuistin ohitus.
13
+ * Oikeudet tarkistetaan aina editorin omasta istunnosta.
14
+ */
15
+ export declare function muokkaustila(paalle: boolean): Promise<void>;
16
+ export interface KirjautumisenAsetukset extends Partial<EditorinAsetukset> {
17
+ /** Sivuston nimi kirjautumisviestissä. Oletuksena pyynnön verkkotunnus. */
18
+ sivustonNimi?: string;
19
+ }
20
+ export declare function kirjautumisenReitit(yliajot?: KirjautumisenAsetukset): {
21
+ kirjaudu: (pyynto: Request) => Promise<Response>;
22
+ vahvista: (pyynto: Request) => Promise<Response>;
23
+ ulos: (pyynto: Request) => Promise<Response>;
24
+ avaaMuokkaustila: (pyynto: Request) => Promise<Response>;
25
+ };
@@ -0,0 +1,183 @@
1
+ /* ==========================================================================
2
+ KIRJAUTUMISEN 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
+ export const { GET, POST } = editorinReitit();
9
+
10
+ Kulku:
11
+ 1. Muokkaaja kirjoittaa sähköpostiosoitteensa sivulla /editori.
12
+ 2. POST /api/editori/kirjaudu lähettää linkin, jos osoite on sallittu.
13
+ 3. GET /api/editori/vahvista?tunnus=… asettaa istunnon evästeen.
14
+ 4. POST /api/editori/ulos päättää istunnon.
15
+
16
+ Periaatteet:
17
+ - **Vastaus on aina sama**, oli osoite sallittu tai ei. Muuten sivustolta
18
+ voisi kysellä, kuka sitä saa muokata.
19
+ - **Salasanoja ei ole.** Sähköpostilaatikko on tunnistautuminen.
20
+ - **Linkki on lyhytikäinen ja sidottu selaimeen**, josta se pyydettiin.
21
+ - **Yritysten määrä on rajattu** samasta osoitteesta ja samalta koneelta.
22
+ ========================================================================== */
23
+ import { normalisoiOsoite, onSallittuMuokkaaja } from "./asetukset.js";
24
+ import { asetuksetTaiNull, lueEvaste, muokkaajaPyynnosta, ohjaa, onSuojattu } from "./apurit.js";
25
+ import { EVASTE_ISTUNTO, EVASTE_PYYNTO, evaste, poistaEvaste } from "./istunto.js";
26
+ import { lahetaKirjautumislinkki } from "./posti.js";
27
+ import { luoTunnus, lueTunnus, satunnainen, tiiviste } from "./tunnus.js";
28
+ /* ---- Yritysten rajoitus -------------------------------------------------
29
+ Yksinkertainen laskuri palvelimen muistissa. Se ei ole aukoton
30
+ hajautetussa ympäristössä, mutta pysäyttää tavallisen yrityksen arvailla
31
+ osoitteita tai kuormittaa sähköpostilähetystä. Oikea suoja on se, ettei
32
+ linkki mene muille kuin sallituille osoitteille.
33
+ ------------------------------------------------------------------------- */
34
+ const YRITYKSET = new Map();
35
+ const IKKUNA_MS = 15 * 60 * 1000;
36
+ const ENINTAAN = 5;
37
+ function liikaaYrityksia(avain, nyt = Date.now()) {
38
+ const aiemmat = (YRITYKSET.get(avain) ?? []).filter((aika) => nyt - aika < IKKUNA_MS);
39
+ aiemmat.push(nyt);
40
+ YRITYKSET.set(avain, aiemmat);
41
+ return aiemmat.length > ENINTAAN;
42
+ }
43
+ /** Testejä varten. */
44
+ export function nollaaYritykset() {
45
+ YRITYKSET.clear();
46
+ }
47
+ /**
48
+ * Kytkee Nextin Draft Moden päälle tai pois.
49
+ *
50
+ * Draft Mode on se kytkin, jonka takia kirjautunut muokkaaja saa sivun
51
+ * palvelimelta joka kerta uudestaan ja muut kävijät saman valmiiksi
52
+ * rakennetun staattisen sivun kuin ennenkin. Ilman sitä muokkaustila
53
+ * pakottaisi koko sivuston dynaamiseksi kaikille.
54
+ *
55
+ * Huom: Draft Mode ei ole tunnistautuminen, vaan pelkkä välimuistin ohitus.
56
+ * Oikeudet tarkistetaan aina editorin omasta istunnosta.
57
+ */
58
+ export async function muokkaustila(paalle) {
59
+ try {
60
+ const { draftMode } = await import("next/headers");
61
+ const tila = await draftMode();
62
+ if (paalle)
63
+ tila.enable();
64
+ else
65
+ tila.disable();
66
+ }
67
+ catch {
68
+ /* Nextin ulkopuolella (testit) tätä ei ole — ei este. */
69
+ }
70
+ }
71
+ export function kirjautumisenReitit(yliajot = {}) {
72
+ async function kirjaudu(pyynto) {
73
+ const asetukset = asetuksetTaiNull(yliajot);
74
+ if (!asetukset)
75
+ return ohjaa(pyynto, "ei-kaytossa");
76
+ let osoite = "";
77
+ try {
78
+ const lomake = await pyynto.formData();
79
+ const arvo = lomake.get("sahkoposti");
80
+ osoite = typeof arvo === "string" ? normalisoiOsoite(arvo) : "";
81
+ }
82
+ catch {
83
+ return ohjaa(pyynto, "virhe");
84
+ }
85
+ const kone = pyynto.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "tuntematon";
86
+ if (liikaaYrityksia(`${kone}|${osoite}`))
87
+ return ohjaa(pyynto, "liikaa");
88
+ /*
89
+ Selainsidoksen eväste asetetaan aina, myös tuntemattomalle osoitteelle.
90
+ Muuten vastaukset eroaisivat toisistaan juuri sen verran, että evästeen
91
+ olemassaolosta voisi päätellä, kuka saa muokata sivustoa.
92
+ */
93
+ const pyynnonSatunnainen = satunnainen();
94
+ const evasteet = asetukset.selainsidos
95
+ ? [
96
+ evaste(EVASTE_PYYNTO, pyynnonSatunnainen, {
97
+ maxAge: asetukset.linkinKestoMinuuttia * 60,
98
+ suojattu: onSuojattu(pyynto),
99
+ }),
100
+ ]
101
+ : [];
102
+ /* Vastaus on sama riippumatta siitä, onko osoite sallittu. */
103
+ if (!osoite.includes("@") || !onSallittuMuokkaaja(osoite, asetukset)) {
104
+ return ohjaa(pyynto, "lahetetty", evasteet);
105
+ }
106
+ const tunnus = await luoTunnus({
107
+ laji: "kirjautuminen",
108
+ sahkoposti: osoite,
109
+ vanhenee: Math.floor(Date.now() / 1000) + asetukset.linkinKestoMinuuttia * 60,
110
+ satunnainen: satunnainen(),
111
+ ...(asetukset.selainsidos ? { sidos: await tiiviste(pyynnonSatunnainen) } : {}),
112
+ }, asetukset.avain);
113
+ const linkki = new URL("/api/editori/vahvista", pyynto.url);
114
+ linkki.searchParams.set("tunnus", tunnus);
115
+ await lahetaKirjautumislinkki({
116
+ vastaanottaja: osoite,
117
+ linkki: linkki.toString(),
118
+ sivusto: yliajot.sivustonNimi ?? new URL(pyynto.url).host,
119
+ }, asetukset);
120
+ return ohjaa(pyynto, "lahetetty", evasteet);
121
+ }
122
+ async function vahvista(pyynto) {
123
+ const asetukset = asetuksetTaiNull(yliajot);
124
+ if (!asetukset)
125
+ return ohjaa(pyynto, "ei-kaytossa");
126
+ const tunnus = new URL(pyynto.url).searchParams.get("tunnus") ?? "";
127
+ const sisalto = await lueTunnus(tunnus, asetukset.avain, "kirjautuminen");
128
+ if (!sisalto || !onSallittuMuokkaaja(sisalto.sahkoposti, asetukset)) {
129
+ return ohjaa(pyynto, "vanhentunut");
130
+ }
131
+ if (sisalto.sidos) {
132
+ const selaimessa = lueEvaste(pyynto, EVASTE_PYYNTO);
133
+ if (!selaimessa || (await tiiviste(selaimessa)) !== sisalto.sidos) {
134
+ return ohjaa(pyynto, "toinen-selain");
135
+ }
136
+ }
137
+ const istunto = await luoTunnus({
138
+ laji: "istunto",
139
+ sahkoposti: sisalto.sahkoposti,
140
+ vanhenee: Math.floor(Date.now() / 1000) + asetukset.istunnonKestoPaivaa * 24 * 60 * 60,
141
+ satunnainen: satunnainen(),
142
+ }, asetukset.avain);
143
+ await muokkaustila(true);
144
+ const suojattu = onSuojattu(pyynto);
145
+ return ohjaa(pyynto, "sisalla", [
146
+ evaste(EVASTE_ISTUNTO, istunto, {
147
+ maxAge: asetukset.istunnonKestoPaivaa * 24 * 60 * 60,
148
+ suojattu,
149
+ }),
150
+ poistaEvaste(EVASTE_PYYNTO, suojattu),
151
+ ]);
152
+ }
153
+ async function ulos(pyynto) {
154
+ await muokkaustila(false);
155
+ return ohjaa(pyynto, "ulkona", [poistaEvaste(EVASTE_ISTUNTO, onSuojattu(pyynto))]);
156
+ }
157
+ /**
158
+ * Kytkee muokkaustilan päälle ja palaa sivustolle.
159
+ *
160
+ * Tarvitaan, koska Next sitoo muokkaustilan siihen julkaisuun, jossa se
161
+ * kytkettiin: jokainen uusi julkaisu katkaisee sen, vaikka muokkaajan oma
162
+ * istunto on yhä voimassa. Ilman tätä muokkaaja luulisi, että editori
163
+ * katosi — ja kirjautuisi turhaan ulos ja sisään.
164
+ */
165
+ async function avaaMuokkaustila(pyynto) {
166
+ const asetukset = asetuksetTaiNull(yliajot);
167
+ if (!asetukset)
168
+ return ohjaa(pyynto, "ei-kaytossa");
169
+ const muokkaaja = await muokkaajaPyynnosta(pyynto, asetukset);
170
+ if (!muokkaaja)
171
+ return ohjaa(pyynto, "vanhentunut");
172
+ await muokkaustila(true);
173
+ /* Kohde tulee osoitteesta, joten se pitää rajata omaan sivustoon: avoin
174
+ uudelleenohjaus olisi valmis kalasteluväline. */
175
+ const pyydetty = new URL(pyynto.url).searchParams.get("kohde") ?? "/";
176
+ const kohde = pyydetty.startsWith("/") && !pyydetty.startsWith("//") ? pyydetty : "/";
177
+ return new Response(null, {
178
+ status: 303,
179
+ headers: { Location: new URL(kohde, pyynto.url).toString() },
180
+ });
181
+ }
182
+ return { kirjaudu, vahvista, ulos, avaaMuokkaustila };
183
+ }
@@ -0,0 +1,29 @@
1
+ import { type KuvatekstiAsetukset } from "./kuvateksti.ts";
2
+ import type { EditorinAsetukset } from "./asetukset.ts";
3
+ import type { GithubAsetukset } from "./github/yhteys.ts";
4
+ export interface Kuvapyynto {
5
+ /** Kuvan tavut. */
6
+ tavut: Uint8Array;
7
+ /** Mihin kansioon kuva kuuluu, esim. "kurssit". */
8
+ kansio: string;
9
+ /** Kuvasuhde, jos kenttä on sellaisen määritellyt. */
10
+ suhde?: number;
11
+ }
12
+ export type Kuvankasittely = (pyynto: Kuvapyynto) => Promise<Uint8Array>;
13
+ export interface KuvienAsetukset extends Partial<EditorinAsetukset> {
14
+ github?: Partial<GithubAsetukset>;
15
+ /** Sivuston oma käsittely: valotus, värit, rajaus, pienennys. */
16
+ kuvankasittely?: Kuvankasittely;
17
+ /**
18
+ * Ehdotus vaihtoehtoiseksi tekstiksi. `false` sulkee ominaisuuden, vaikka
19
+ * avain olisi asetettu. Oletuksena päällä, jos avain löytyy.
20
+ */
21
+ kuvateksti?: KuvatekstiAsetukset | false;
22
+ /**
23
+ * Sivuston mediakansio repossa, oletuksena "public/kuvat". Sivustoilla,
24
+ * joilla kuvat ovat jo muualla (esim. "public/media"), tämä pidetään
25
+ * samana kuin olemassa olevilla kuvilla.
26
+ */
27
+ kuvakansio?: string;
28
+ }
29
+ export declare function kuvienReitti(yliajot: KuvienAsetukset): (pyynto: Request) => Promise<Response>;
@@ -0,0 +1,130 @@
1
+ /* ==========================================================================
2
+ KUVIEN LATAUS
3
+ ==========================================================================
4
+ Kuvien lataus on editorin suurin hyökkäyspinta: haitallinen kuvatiedosto
5
+ voi päätyä kuvankäsittelykirjastoille, joissa on ollut vakavia aukkoja.
6
+ Siksi:
7
+
8
+ - **Selain piirtää kuvan uudelleen ennen lähetystä.** Palvelimelle ei
9
+ päädy alkuperäistä tiedostoa eikä sen metatietoja (myös GPS-sijainti
10
+ jää pois). Sama hoitaa iPhonen HEIC-kuvat.
11
+ - **Palvelin tunnistaa tiedostotyypin sisällöstä, ei nimestä.** Vain
12
+ JPEG ja WebP kelpaavat; koolla ja mittasuhteilla on rajat.
13
+ - **Vain kirjautunut muokkaaja voi ladata kuvia.**
14
+ - **Polku syntyy palvelimella.** Selain ei kerro, minne tiedosto menee.
15
+
16
+ Sivustokohtainen kuvankäsittely (valotus, värit, rajaus) annetaan
17
+ asetuksissa: `kuvankasittely`. Ilman sitä kuva tallennetaan sellaisenaan
18
+ kuin selain sen lähetti.
19
+ ========================================================================== */
20
+ import { kuvanOsoite } from "../maarittely.js";
21
+ import { ehdotaKuvateksti } from "./kuvateksti.js";
22
+ import { asetuksetTaiNull, json, muokkaajaPyynnosta } from "./apurit.js";
23
+ import { valitseVarasto } from "./varasto.js";
24
+ /** Enintään 8 Mt. Selain pienentää kuvan jo ennen lähetystä. */
25
+ const SUURIN_KOKO = 8 * 1024 * 1024;
26
+ /** Tunnistaa tyypin tiedoston alkutavuista, ei nimestä eikä otsakkeesta. */
27
+ function tunnistaTyyppi(tavut) {
28
+ if (tavut.length > 3 && tavut[0] === 0xff && tavut[1] === 0xd8 && tavut[2] === 0xff)
29
+ return "jpeg";
30
+ const teksti = (alku, pituus) => String.fromCharCode(...tavut.slice(alku, alku + pituus));
31
+ if (tavut.length > 12 && teksti(0, 4) === "RIFF" && teksti(8, 4) === "WEBP")
32
+ return "webp";
33
+ return null;
34
+ }
35
+ /** Mediakansio siistittynä. Kelvoton arvo palautuu oletukseen. */
36
+ function siistiJuuri(arvo) {
37
+ const teksti = typeof arvo === "string" ? arvo.replace(/^\/+|\/+$/g, "") : "";
38
+ return /^public\/[a-z0-9][a-z0-9-]*$/.test(teksti) ? teksti : "public/kuvat";
39
+ }
40
+ function siistiKansio(arvo) {
41
+ const teksti = typeof arvo === "string" ? arvo : "";
42
+ const siisti = teksti.toLowerCase().replace(/[^a-z0-9-]/g, "");
43
+ return siisti === "" ? "yleiset" : siisti;
44
+ }
45
+ /** Tiedostonimi, joka ei osu vanhaan: vanha kuva jää välimuisteihin. */
46
+ function tiedostonimi() {
47
+ const paiva = new Date().toISOString().slice(0, 10);
48
+ const satunnainen = Math.random().toString(36).slice(2, 8);
49
+ return `${paiva}-${satunnainen}.webp`;
50
+ }
51
+ export function kuvienReitti(yliajot) {
52
+ return async function lataa(pyynto) {
53
+ const asetukset = asetuksetTaiNull(yliajot);
54
+ if (!asetukset)
55
+ return json({ virhe: "Editori ei ole käytössä." }, 503);
56
+ const muokkaaja = await muokkaajaPyynnosta(pyynto, asetukset);
57
+ if (!muokkaaja)
58
+ return json({ virhe: "Kirjaudu sisään." }, 401);
59
+ let data;
60
+ try {
61
+ data = (await pyynto.json());
62
+ }
63
+ catch {
64
+ return json({ virhe: "Pyyntö ei ollut kelvollista JSONia." }, 400);
65
+ }
66
+ if (typeof data.tiedosto !== "string" || data.tiedosto === "") {
67
+ return json({ virhe: "Kuva puuttuu." }, 400);
68
+ }
69
+ let tavut;
70
+ try {
71
+ const base64 = data.tiedosto.includes(",") ? data.tiedosto.split(",")[1] : data.tiedosto;
72
+ tavut = Uint8Array.from(atob(base64), (merkki) => merkki.charCodeAt(0));
73
+ }
74
+ catch {
75
+ return json({ virhe: "Kuvaa ei voitu lukea." }, 400);
76
+ }
77
+ if (tavut.length > SUURIN_KOKO) {
78
+ return json({ virhe: "Kuva on liian suuri. Enintään 8 Mt." }, 413);
79
+ }
80
+ if (!tunnistaTyyppi(tavut)) {
81
+ return json({ virhe: "Vain JPEG- ja WebP-kuvat kelpaavat. Kokeile tallentaa kuva uudelleen." }, 415);
82
+ }
83
+ const kansio = siistiKansio(data.kansio);
84
+ let valmis = tavut;
85
+ if (yliajot.kuvankasittely) {
86
+ try {
87
+ valmis = await yliajot.kuvankasittely({
88
+ tavut,
89
+ kansio,
90
+ ...(typeof data.suhde === "number" ? { suhde: data.suhde } : {}),
91
+ });
92
+ }
93
+ catch (virhe) {
94
+ console.error("[editori] Kuvan käsittely epäonnistui:", virhe);
95
+ return json({ virhe: "Kuvaa ei saatu käsiteltyä." }, 502);
96
+ }
97
+ }
98
+ /*
99
+ Ehdotus pyydetään alkuperäisestä kuvasta, ei käsitellystä: värikäsittely
100
+ ei muuta sitä, mitä kuvassa on, ja alkuperäinen on jo muistissa.
101
+ Ehdotus haetaan rinnakkain tallennuksen kanssa, jottei lataus hidastu.
102
+ */
103
+ const ehdotus = yliajot.kuvateksti === false
104
+ ? Promise.resolve(null)
105
+ : ehdotaKuvateksti(tavut, tunnistaTyyppi(tavut) ?? "jpeg", yliajot.kuvateksti ?? {});
106
+ /* Polku syntyy palvelimella: selain ei kerro, minne tiedosto menee.
107
+ Mediakansio tulee sivuston asetuksista, alikansio kentän tiedoista. */
108
+ const juuri = siistiJuuri(yliajot.kuvakansio);
109
+ const polku = `${juuri}/${kansio}/${tiedostonimi()}`;
110
+ try {
111
+ const varasto = valitseVarasto(yliajot.github ?? {});
112
+ await varasto.tallenna({
113
+ tiedostot: [
114
+ {
115
+ polku,
116
+ sisalto: Buffer.from(valmis).toString("base64"),
117
+ koodaus: "base64",
118
+ },
119
+ ],
120
+ viesti: `Uusi kuva: ${kansio}\n\nLadannut ${muokkaaja} sivuston editorilla.`,
121
+ tekija: { nimi: muokkaaja.split("@")[0] ?? muokkaaja, sahkoposti: muokkaaja },
122
+ });
123
+ }
124
+ catch (virhe) {
125
+ console.error("[editori] Kuvan tallennus epäonnistui:", virhe);
126
+ return json({ virhe: "Kuvaa ei saatu tallennettua." }, 502);
127
+ }
128
+ return json({ src: kuvanOsoite(polku), ehdotus: await ehdotus });
129
+ };
130
+ }
@@ -0,0 +1,15 @@
1
+ export interface KuvatekstiAsetukset {
2
+ /** API-avain. Oletuksena EDITORI_KUVATEKSTI_AVAIN tai ANTHROPIC_API_KEY. */
3
+ avain?: string;
4
+ /** Kieli, jolla ehdotus kirjoitetaan. Oletus suomi. */
5
+ kieli?: string;
6
+ }
7
+ /**
8
+ * Ehdotus vaihtoehtoiseksi tekstiksi, tai `null` jos ominaisuus ei ole
9
+ * käytössä tai kutsu ei onnistunut.
10
+ *
11
+ * Ei koskaan heitä: kuvan lataus on tärkeämpi kuin ehdotus, eikä sen pidä
12
+ * kaatua siihen, että tekstipalvelu on nurin.
13
+ */
14
+ export declare function kuvatekstiAvain(asetukset?: KuvatekstiAsetukset): string | undefined;
15
+ export declare function ehdotaKuvateksti(tavut: Uint8Array, tyyppi: "jpeg" | "webp", asetukset?: KuvatekstiAsetukset): Promise<string | null>;
@@ -0,0 +1,95 @@
1
+ /* ==========================================================================
2
+ KUVATEKSTIEHDOTUS
3
+ ==========================================================================
4
+ Ladatusta kuvasta pyydetään ehdotus vaihtoehtoiseksi tekstiksi. Ehdotus
5
+ on nimenomaan ehdotus: se kirjoitetaan kenttään, jonka muokkaaja näkee ja
6
+ voi korjata ennen tallennusta.
7
+
8
+ Miksi ehdotus eikä valmis teksti: kone kuvaa sen, mitä kuvassa näkyy
9
+ ("nainen istuu matolla"), mutta harvoin sitä, miksi kuva on sivulla.
10
+ Ihminen tietää sen. Ilman ehdotustakin kenttä on täytettävä, ja käytäntö
11
+ osoittaa, että tyhjä kenttä jää helposti tyhjäksi.
12
+
13
+ **Kuva lähetetään Anthropicin rajapintaan.** Se on syytä mainita sivuston
14
+ tietosuojaselosteessa, ja ominaisuus on pois päältä, ellei avainta ole
15
+ asetettu.
16
+ ========================================================================== */
17
+ /** Enintään tämän kokoinen kuva lähetetään. Suuremmasta ei ole hyötyä. */
18
+ const SUURIN = 4 * 1024 * 1024;
19
+ /** Ehdotus katkaistaan tähän: vaihtoehtoinen teksti on lyhyt lause. */
20
+ const ENINTAAN_MERKKEJA = 200;
21
+ function ohje(kieli) {
22
+ return [
23
+ `Kirjoita tälle kuvalle vaihtoehtoinen teksti (alt-teksti) kielellä ${kieli}.`,
24
+ "",
25
+ "Säännöt:",
26
+ "- Yksi lause, enintään noin 120 merkkiä.",
27
+ '- Älä aloita sanoilla "kuva", "valokuva" tai "kuvassa".',
28
+ "- Kerro se, minkä näkevä huomaa ensimmäisenä: kuka tai mikä, missä, mitä tapahtuu.",
29
+ "- Älä arvaa nimiä, paikkoja, tunteita tai tapahtuman tarkoitusta.",
30
+ "- Älä lisää lainausmerkkejä äläkä selityksiä. Vastaa pelkällä lauseella.",
31
+ ].join("\n");
32
+ }
33
+ /** Siistii mallin vastauksen: yksi rivi, ei lainausmerkkejä, rajattu pituus. */
34
+ function siisti(teksti) {
35
+ const yksiRivi = teksti.replace(/\s+/g, " ").trim().replace(/^["'“”]|["'“”]$/g, "");
36
+ return yksiRivi.length > ENINTAAN_MERKKEJA
37
+ ? `${yksiRivi.slice(0, ENINTAAN_MERKKEJA - 1).trimEnd()}…`
38
+ : yksiRivi;
39
+ }
40
+ /**
41
+ * Ehdotus vaihtoehtoiseksi tekstiksi, tai `null` jos ominaisuus ei ole
42
+ * käytössä tai kutsu ei onnistunut.
43
+ *
44
+ * Ei koskaan heitä: kuvan lataus on tärkeämpi kuin ehdotus, eikä sen pidä
45
+ * kaatua siihen, että tekstipalvelu on nurin.
46
+ */
47
+ export function kuvatekstiAvain(asetukset = {}) {
48
+ return asetukset.avain ?? process.env.EDITORI_KUVATEKSTI_AVAIN ?? process.env.ANTHROPIC_API_KEY;
49
+ }
50
+ export async function ehdotaKuvateksti(tavut, tyyppi, asetukset = {}) {
51
+ const avain = kuvatekstiAvain(asetukset);
52
+ if (!avain)
53
+ return null;
54
+ if (tavut.length > SUURIN)
55
+ return null;
56
+ try {
57
+ /* Tuonti vasta tässä: sivustot, jotka eivät käytä ehdotusta, eivät lataa
58
+ kirjastoa lainkaan. */
59
+ const { default: Anthropic } = await import("@anthropic-ai/sdk");
60
+ const asiakas = new Anthropic({ apiKey: avain });
61
+ const vastaus = await asiakas.messages.create({
62
+ model: "claude-opus-5",
63
+ /* Yksi lause riittää; katkaisua ei tarvitse pelätä. */
64
+ max_tokens: 300,
65
+ messages: [
66
+ {
67
+ role: "user",
68
+ content: [
69
+ {
70
+ type: "image",
71
+ source: {
72
+ type: "base64",
73
+ media_type: tyyppi === "webp" ? "image/webp" : "image/jpeg",
74
+ data: Buffer.from(tavut).toString("base64"),
75
+ },
76
+ },
77
+ { type: "text", text: ohje(asetukset.kieli ?? "suomi") },
78
+ ],
79
+ },
80
+ ],
81
+ });
82
+ if (vastaus.stop_reason === "refusal")
83
+ return null;
84
+ const teksti = vastaus.content
85
+ .filter((osa) => osa.type === "text")
86
+ .map((osa) => osa.text)
87
+ .join(" ");
88
+ const ehdotus = siisti(teksti);
89
+ return ehdotus === "" ? null : ehdotus;
90
+ }
91
+ catch (virhe) {
92
+ console.error("[editori] Kuvatekstin ehdotus epäonnistui:", virhe);
93
+ return null;
94
+ }
95
+ }
@@ -0,0 +1,6 @@
1
+ import { type SisallonMaarittely, type Sivu } from "../maarittely.ts";
2
+ export interface LukuAsetukset {
3
+ /** Sivuston juurikansio. Oletuksena prosessin työhakemisto (Nextissä sivuston juuri). */
4
+ juuri?: string;
5
+ }
6
+ export declare function lueSivu<M extends SisallonMaarittely, A extends keyof M["sivut"] & string>(maarittely: M, sivunAvain: A, kieli: M["kielet"][number], asetukset?: LukuAsetukset): Promise<Sivu<M["sivut"][A]>>;