edupage-cli 0.1.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d88122f0b43b1dd402338602fa5ea159a965330816f0a5104bbc05ebc7032dde
4
- data.tar.gz: 6c648935914a8e1c78da2ed253c3f625384a61af26442ac63ce027981cf8c73c
3
+ metadata.gz: 121fd1c2fcc0aa186c79565930684a7bd169589616178ed763fdd742d5330926
4
+ data.tar.gz: 17a1e8c2205330a58eb1208e91948b445f643c4c9078b9a43e6d0e7ac1713099
5
5
  SHA512:
6
- metadata.gz: 0d533b61ff18ec78c95a8282e123cf4271eb554949277fed4391619a20b007e5c2b9dc5f7c9965ece0bb19971eff3ca73b403df894f69bc628c9022ee6a86592
7
- data.tar.gz: 188a7210840c7f1263229aaf857c89fe080f6d7376c33d198d1707f956436faca839dc4e0ede25544242ef18c1192e4c866b591d3bdf772f89cd69c31233c506
6
+ metadata.gz: 5147f956c0b5dcd81674676a3219adda32d453fda2d766ae2d3b8dd5573d809a4298a6e8c9f57ccb76e1bb4e16d5e3f44db60dd7aaf9e2cbb791f11e762f49b0
7
+ data.tar.gz: 19a1ec3e5a0b9e47883d5199ddbd81f0a9bf615b5618460cd601a1831a60387ca5ab44604439ff47ce4c772a27b2fb5e4a42b7b6e133947cd7d3c098f54e833e
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ahmed Al Hafoudh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md CHANGED
@@ -1,44 +1,117 @@
1
1
  # edupage-cli
2
2
 
3
3
  Read-only prístup k účtu na [Edupage](https://www.edupage.org) - ako Ruby knižnica, CLI,
4
- REST API a MCP server. Všetky štyri povrchy stoja na tom istom kóde a drží ich v súlade
5
- parity test.
4
+ REST API a MCP server. Všetky štyri rozhrania používajú rovnaký kód a ich zhodu
5
+ stráži parity test.
6
6
 
7
- Edupage nemá verejné API. Každá stránka je server-rendered HTML s JSON-om zabaleným
8
- v `<script>` blokoch a session si navyše drží skryté kurzory "aktuálne dieťa" a
9
- "aktuálny školský rok", ktoré treba nastaviť skôr, než má fetch vôbec zmysel. Toto
10
- všetko rieši knižnica za teba.
7
+ ![edupage v termináli](docs/screenshot.png)
8
+
9
+ Edupage nemá verejné API. Každá stránka je HTML vygenerované na serveri s JSON-om
10
+ vloženým do blokov `<script>`. Session si navyše interne pamätá "aktuálne dieťa"
11
+ a "aktuálny školský rok", ktoré treba nastaviť pred načítaním dát. Toto všetko
12
+ rieši knižnica za teba.
11
13
 
12
14
  ## Inštalácia
13
15
 
16
+ ### Inštalácia jedným príkazom
17
+
18
+ **macOS (funguje aj na Linuxe)**
19
+
20
+ ```bash
21
+ curl -fsSL https://raw.githubusercontent.com/alhafoudh/edupage-cli/main/install.sh | sh
22
+ ```
23
+
24
+ Skript použije Homebrew, ak je dostupný, inak nainštaluje gem cez Ruby 3.2 alebo novšie.
25
+ Ak nie je dostupná ani jedna možnosť, skončí s pokynmi. Homebrew ani Ruby sám neinštaluje.
26
+
27
+ **Windows (PowerShell)**
28
+
29
+ ```powershell
30
+ irm https://raw.githubusercontent.com/alhafoudh/edupage-cli/main/install.ps1 | iex
31
+ ```
32
+
33
+ Skript použije existujúce Ruby 3.2 alebo novšie. Ak Ruby nie je nainštalované vôbec,
34
+ nainštaluje Ruby+Devkit 3.4 cez winget a nástroje MSYS2 na zostavovanie cez `ridk install`. Potom
35
+ nainštaluje gem edupage-cli. Na Windows na ARM navyše nainštaluje libxml2/libxslt z MSYS2
36
+ a zostaví s nimi nokogiri, pretože pre arm64 Windows nie je dostupný predkompilovaný gem.
37
+
38
+ Potom sa prihlás cez `edupage login`. Heslo sa uloží do Správcu poverení Windows
39
+ (Credential Manager). Prihlasovacie údaje môžeš nastaviť aj cez premenné prostredia:
40
+
41
+ ```powershell
42
+ setx EDUPAGE_USERNAME "tvoje_pouzivatelske_meno"
43
+ setx EDUPAGE_PASSWORD "tvoje_heslo"
44
+ setx EDUPAGE_SCHOOL "tvoja_skola"
45
+ ```
46
+
47
+ Po nastavení otvor nové okno terminálu, aby sa premenné načítali.
48
+
49
+ edupage-cli si môžeš nainštalovať aj manuálne nasledujúcimi spôsobmi.
50
+
51
+ ### Ručná inštalácia
52
+
53
+ Cez Homebrew na macOS aj Linuxe:
54
+
55
+ ```bash
56
+ brew install alhafoudh/edupage/edupage-cli
57
+ edupage login
58
+ ```
59
+
60
+ Formula je v tape [alhafoudh/homebrew-edupage](https://github.com/alhafoudh/homebrew-edupage).
61
+ Zostavuje sa zo zdrojového kódu označeného tagom, používa Ruby z Homebrew a gemy si
62
+ ukladá oddelene, takže nezasahuje do ostatných inštalácií Ruby. Pri prvej inštalácii sa kompiluje zopár natívnych
63
+ gemov (puma, nio4r), čo trvá pár desiatok sekúnd.
64
+
65
+ Alebo ako gem, ak už Ruby 3.2+ máš:
66
+
67
+ ```bash
68
+ gem install edupage-cli
69
+ ```
70
+
71
+ Alebo priamo z checkoutu:
72
+
14
73
  ```bash
15
74
  bundle install
16
75
  bundle exec exe/edupage login
17
76
  ```
18
77
 
19
- `login` heslo najprv overí voči Edupage a až potom ho uloží do macOS keychainu, takže
20
- sa tam nikdy nedostane preklep.
78
+ `login` najprv overí heslo prihlásením do Edupage a až potom ho uloží do úložiska
79
+ hesiel operačného systému, takže heslo s preklepom neuloží.
21
80
 
22
- ## Credentials
81
+ ## Prihlasovacie údaje
23
82
 
24
83
  Hľadajú sa v tomto poradí:
25
84
 
26
85
  | Poradie | Zdroj |
27
86
  |---|---|
28
87
  | 1 | `EDUPAGE_USERNAME`, `EDUPAGE_PASSWORD`, `EDUPAGE_SCHOOL` |
29
- | 2 | macOS keychain (service `edupage-cli`), cez `edupage login` |
88
+ | 2 | `--username`, `--school`, potom `~/.config/edupage-cli/config.yml` (len meno a škola) |
89
+ | 3 | úložisko hesiel systému, cez `edupage login` (len heslo) |
90
+
91
+ Úložisko hesiel podľa systému:
92
+
93
+ | Systém | Úložisko |
94
+ |---|---|
95
+ | macOS | keychain (service `edupage-cli`) |
96
+ | Windows | Správca poverení, generic credential `edupage-cli:<meno>` |
97
+ | Linux | Secret Service (GNOME Keyring, KWallet) cez `secret-tool`, atribúty `service=edupage-cli`, `account=<meno>` |
30
98
 
31
- `edupage auth` ukáže, ktorý zdroj sa práve používa, a upozorní, keď `EDUPAGE_PASSWORD`
32
- prekrýva heslo v keychaine - inak by to vyzeralo, že `edupage login` nič neurobil.
99
+ Na Linuxe treba mať nainštalovaný `secret-tool` (balík `libsecret-tools` alebo `libsecret`)
100
+ a bežiacu D-Bus session. Bez nich ostáva len `EDUPAGE_PASSWORD`.
33
101
 
34
- Netajné nastavenia sú v `~/.config/edupage-cli/config.yml`, session a cache stránok
102
+ Keď je nastavená `EDUPAGE_PASSWORD`, úložisko hesiel sa vôbec nepoužije, ani sa do neho
103
+ nenahliada. `edupage auth` ukáže, ktorý zdroj sa práve používa, a pri úložisku vtedy napíše
104
+ `not consulted` - inak by to vyzeralo, že `edupage login` nič neurobil.
105
+
106
+ Nastavenia bez citlivých údajov sú v `~/.config/edupage-cli/config.yml`, session a cache stránok
35
107
  v `~/.cache/edupage-cli/`.
36
108
 
37
- ## Reťazec
109
+ ## Hierarchia výberu
38
110
 
39
- Všetko visí na `account > škola > študent > ročník` a žiadna úroveň sa nedá preskočiť.
40
- Každá sa rozhoduje rovnako: explicitný výber vyhráva, jediná možnosť sa vezme ticho,
41
- čokoľvek iné skončí chybou so zoznamom možností.
111
+ Výber sa riadi hierarchiou `account > škola > študent > školský rok` a žiadna úroveň
112
+ sa nedá preskočiť. Na každej úrovni platí rovnaké pravidlo: prednosť má explicitný
113
+ výber, jediná možnosť sa vyberie automaticky a v ostatných prípadoch príkaz skončí
114
+ chybou so zoznamom možností.
42
115
 
43
116
  ```
44
117
  $ edupage students
@@ -47,11 +120,12 @@ No school selected. Pick one:
47
120
  --school zusdemo Základná umelecká škola Demo
48
121
  ```
49
122
 
50
- Default v configu sa **za výber nepočíta**: `default_school` hovorí len to, na ktorý
51
- `*.edupage.org` host sa prihlásiť, nikdy nie to, čie dáta čítaš.
123
+ Predvolená hodnota v configu sa **za výber nepočíta**: `default_school` určuje iba
124
+ server `*.edupage.org`, na ktorý sa prihlásiť, nie to, čie dáta čítaš.
52
125
 
53
- Jedinou výnimkou je ročník - ten sa doplní na aktuálny, lebo "teraz" je jednoznačné.
54
- Výstup vždy povie, ktorý rok použil, a keď je prázdny, ukáže, kde dáta sú:
126
+ Jedinou výnimkou je školský rok - automaticky sa vyberie aktuálny, lebo "teraz" je
127
+ jednoznačné. Vo výstupe je vždy uvedené, ktorý rok sa použil, a ak preň nie sú žiadne
128
+ dáta, výstup ukáže, v ktorých rokoch sú:
55
129
 
56
130
  ```
57
131
  $ edupage grades --school zsdemo --student Jana
@@ -78,8 +152,9 @@ Globálne prepínače: `--school --student --year --username --json --yaml --no-
78
152
  --verbose`. Meno študenta stačí zadať ako unikátny prefix, takže `--student Jana`
79
153
  postačuje.
80
154
 
81
- Tabuľkový výstup má hlavičku s tým, z akej školy, študenta a roka dáta pochádzajú;
82
- `--json` a `--yaml` ju nemajú, aby zostali bajt na bajt zhodné s REST a MCP odpoveďami.
155
+ Tabuľkový výstup má hlavičku s údajmi o škole, študentovi a školskom roku. Výstupy
156
+ `--json` a `--yaml` hlavičku nemajú, aby boli bajt po bajte zhodné s odpoveďami REST API
157
+ a MCP.
83
158
 
84
159
  ```
85
160
  $ edupage grades --school zsdemo --student Jana --year 2025
@@ -95,8 +170,8 @@ year : 2025/2026
95
170
  └──────────────────┴───────────────────────────────┴───────┴────────┴─────────────────────────┘
96
171
  ```
97
172
 
98
- Tabuľka sa prispôsobí šírke terminálu: keď sa nezmestí, uberá sa vždy najširšiemu
99
- stĺpcu a dlhý text sa zalomí, takže dátumy a známky zostanú celé.
173
+ Tabuľka sa prispôsobí šírke terminálu: keď sa nezmestí, vždy sa zúži najširší
174
+ stĺpec a dlhý text sa zalomí, takže dátumy a známky zostanú celé.
100
175
 
101
176
  Servisné príkazy: `edupage auth`, `edupage session status|refresh|logout`,
102
177
  `edupage cache info|clear`, `edupage config path|get|set`.
@@ -119,8 +194,9 @@ jana.grades # skratka pre aktuálny rok
119
194
  ```
120
195
 
121
196
  Kolekcie sú lazy a reťaziteľné: `where order limit offset find_by first count`.
122
- `where(name: ...)` hľadá naprieč menom, skratkou aj id záznamu; každý iný kľúč porovnáva
123
- svoj atribút a berie hodnotu, regexp, range, pole alebo lambdu.
197
+ `where(name: ...)` hľadá v mene, skratke aj id záznamu; pri ostatných kľúčoch sa
198
+ porovnáva príslušný atribút. Ako podmienku možno zadať hodnotu, regulárny výraz, rozsah,
199
+ pole alebo lambdu.
124
200
 
125
201
  ## Server
126
202
 
@@ -130,7 +206,7 @@ edupage mcp # MCP cez stdio, pre editory a desktop klientov
130
206
  ```
131
207
 
132
208
  Počúva na `127.0.0.1` a pri prvom spustení si vygeneruje bearer token do configu.
133
- Všetky rúty sú `GET`, všetky MCP tooly sú označené ako read-only.
209
+ Všetky trasy sú `GET`, všetky MCP tooly sú označené ako read-only.
134
210
 
135
211
  ```bash
136
212
  curl -H "Authorization: Bearer $TOKEN" \
@@ -140,7 +216,7 @@ curl -H "Authorization: Bearer $TOKEN" \
140
216
  ### Zapojenie MCP
141
217
 
142
218
  `edupage mcp-add` zaregistruje server u klienta, `edupage mcp-config` iba vypíše
143
- `mcpServers` záznam, keď si ho chceš umiestniť sám.
219
+ záznam `mcpServers`, ak si ho chceš do configu pridať ručne.
144
220
 
145
221
  ```bash
146
222
  edupage mcp-add claude-code --scope user # cez `claude mcp add`
@@ -153,26 +229,30 @@ edupage mcp-config # len JSON a nič iné
153
229
  edupage mcp-config --http
154
230
  ```
155
231
 
156
- Pridávanie je **idempotentné**: zhodný záznam nechá tak, odlišný zosúladí a chýbajúci
232
+ Pridávanie je **idempotentné**: zhodný záznam ponechá, odlišný upraví a chýbajúci
157
233
  doplní. Config Claude Desktopu sa zlučuje, nie prepisuje - ostatné servery aj nesúvisiace
158
234
  nastavenia zostanú - a predošlá verzia sa odloží ako `.bak`.
159
235
 
160
- Pre lokálny nástroj je lepší default stdio: proces vlastní klient, takže netreba nič
161
- autentifikovať ani držať bežiaci server. `--http` mieri na `/mcp` bežiaceho
162
- `edupage server` a nesie token v `Authorization` hlavičke, čo sa hodí, keď jeden proces
163
- zdieľa viac klientov.
236
+ Pre lokálny nástroj je lepšie predvolené stdio: proces spravuje klient, takže netreba
237
+ riešiť autentifikáciu ani udržiavať server v chode. `--http` sa pripája na `/mcp`
238
+ spusteného `edupage server` a posiela token v hlavičke `Authorization`, čo sa hodí, keď
239
+ jeden proces zdieľa viac klientov.
164
240
 
165
- Ani jeden variant nepripína školu ani študenta - to sú úrovne reťazca a model si ich má
166
- zvoliť pri každom volaní.
241
+ Ani jeden variant nenastavuje školu ani študenta napevno - sú to úrovne hierarchie
242
+ a model si ich má zvoliť pri každom volaní.
167
243
 
168
- Vygenerovaný stdio záznam používa absolútne cesty a nastavuje `BUNDLE_GEMFILE`, lebo MCP
169
- klienti spúšťajú servery z vlastného pracovného adresára. A spúšťajú ich aj **bez
170
- nastaveného locale**, čo je dôvod, prečo každý súbor otvárame explicitne ako UTF-8
171
- a nie cez `Encoding.default_external`.
244
+ Vygenerovaný stdio záznam používa absolútne cesty a pri behu z checkoutu nastavuje
245
+ `BUNDLE_GEMFILE`, lebo MCP klienti spúšťajú servery z vlastného pracovného adresára.
246
+ Pri inštalácii cez Homebrew ukazuje na `$(brew --prefix)/opt/edupage-cli/bin/edupage`,
247
+ nie na verziovanú cestu v `Cellar`, takže záznam zostane platný aj po `brew upgrade`. Zariaďuje to
248
+ premenná `EDUPAGE_EXECUTABLE`, ktorú nastavuje wrapper z formuly; rovnako ju môže
249
+ použiť akýkoľvek iný balíčkovač. MCP klienti navyše spúšťajú servery aj **bez
250
+ nastaveného locale**, preto každý súbor otvárame výslovne ako UTF-8 a nespoliehame sa
251
+ na `Encoding.default_external`.
172
252
 
173
- ## Ako povrchy zostávajú v súlade
253
+ ## Ako udržiavame rozhrania v súlade
174
254
 
175
- Resources sú deklarované raz, v `lib/edupage/registry/resources.rb`:
255
+ Zdroje sú deklarované na jednom mieste, v `lib/edupage/registry/resources.rb`:
176
256
 
177
257
  ```ruby
178
258
  resource :grades do
@@ -183,31 +263,34 @@ resource :grades do
183
263
  end
184
264
  ```
185
265
 
186
- Z toho sa vygeneruje CLI príkaz, REST rúta aj MCP tool a `spec/registry_parity_spec.rb`
187
- spadne, ak niektorý chýba alebo sa mu rozídu parametre. Výstup `--json`, telo REST
266
+ Z toho sa vygeneruje CLI príkaz, REST trasa aj MCP tool a `spec/registry_parity_spec.rb`
267
+ spadne, ak niektorý z nich chýba alebo má odlišné parametre. Výstup `--json`, telo REST
188
268
  odpovede aj výsledok MCP toolu idú cez jeden serializer, takže sú bajt na bajt zhodné.
189
269
 
190
- `scope` zároveň deklaruje, na ktorých úrovniach reťazca resource stojí, takže ho všetky
191
- tri povrchy vynucujú rovnako: REST ich nesie ako segmenty cesty, MCP input schéma ich
192
- označí ako required a CLI odmietne so zoznamom možností.
270
+ `scope` zároveň určuje, ktoré úrovne hierarchie zdroj vyžaduje, a všetky tri rozhrania
271
+ ich vyžadujú rovnako: v REST sú súčasťou cesty, vstupná schéma MCP ich označí ako povinné
272
+ a CLI príkaz bez nich odmietne a vypíše zoznam možností.
193
273
 
194
274
  ## Poznámky k samotnému Edupage
195
275
 
196
- Veci, ktoré stojí za to vedieť - všetky overené proti živému účtu:
197
-
198
- - **Jeden login môže pokrývať viac škôl.** `mauth` vráti jednu session na každú školu.
199
- - **Session drží aktuálne dieťa a aktuálny školský rok.** Prepnutie ktoréhokoľvek z nich
200
- je side effect na zdieľanom stave servera, takže prebieha pod file lockom a každá
201
- odpoveď sa kontroluje proti tomu, čo sa pýtalo.
202
- - **Tieto prepnutia zaostávajú.** Edupage prepnutie potvrdí okamžite, ale ešte request
203
- alebo dva servíruje stránku predošlého dieťaťa či roka, takže sa fetch opakuje, kým sa
204
- stránka nezhoduje. Vrátiť zaostávajúcu stránku by potichu znamenalo rozvrh iného dieťaťa.
276
+ Veci, ktoré stojí za to vedieť - všetky overené na živom účte:
277
+
278
+ - **Jedným účtom sa dá prihlásiť do viacerých škôl.** `mauth` vráti jednu session na
279
+ každú školu.
280
+ - **Session drží aktuálne dieťa a aktuálny školský rok.** Prepnutie dieťaťa aj roka mení
281
+ zdieľaný stav na serveri, preto prebieha pod súborovým zámkom a pri každej odpovedi sa
282
+ overuje, či zodpovedá požadovanému dieťaťu a roku.
283
+ - **Prepnutie sa prejaví s oneskorením.** Edupage prepnutie potvrdí okamžite, ale ešte
284
+ request alebo dva vracia stránku predošlého dieťaťa či roka, takže sa fetch opakuje,
285
+ kým stránka nesedí. Ak by sa vrátila neaktuálna stránka, dostal by si bez upozornenia
286
+ rozvrh iného dieťaťa.
205
287
  - **Číselníky sa medzi rokmi menia.** Zoznam tried za 2025 nie je ten istý ako za 2026,
206
288
  preto je rok súčasťou každého cache kľúča.
207
289
  - **Timeline je spoločná pre všetky deti rodiča** a delí sa lokálne podľa mapy
208
290
  `childGroups`.
209
- - **Domáce úlohy majú dva tvary** - zadané triede alebo jednému žiakovi. Treba oba: na
210
- účte, na ktorom to vzniklo, je 15 zo 16 úloh jedného dieťaťa toho druhého druhu.
291
+ - **Domáce úlohy sa zadávajú buď celej triede, alebo jednotlivým žiakom.** Treba
292
+ podporovať oba prípady: na účte, na ktorom to vzniklo, je 15 zo 16 úloh jedného
293
+ dieťaťa zadaných individuálne.
211
294
 
212
295
  ## Vývoj
213
296
 
@@ -215,16 +298,16 @@ Veci, ktoré stojí za to vedieť - všetky overené proti živému účtu:
215
298
  bundle exec rspec
216
299
  ```
217
300
 
218
- Testy bežia proti ručne písaným payloadom, nie proti nahratým stránkam: tie skutočné
301
+ Testy používajú ručne pripravené payloady, nie uložené stránky: tie skutočné
219
302
  majú stovky kilobajtov a sú v nich mená cudzích detí.
220
303
 
221
304
  Keby predsa len vznikla VCR kazeta, `spec/support/cassette_scrubber.rb` z nej pred
222
- zápisom na disk vyhádže osobné údaje - mená, názvy škôl, subdomény aj e-maily, a to
223
- v tele odpovede, v URI aj v hlavičkách. Mená nezoberie z pevného zoznamu, ale **z odpovede
224
- samotnej** - z polí, kam ich Edupage vždy dáva - a potom nahradí každý ich výskyt vrátane
305
+ zápisom na disk odstráni osobné údaje - mená, názvy škôl, subdomény aj e-maily, a to
306
+ v tele odpovede, v URI aj v hlavičkách. Mená neberie z pevného zoznamu, ale **priamo
307
+ z odpovede** - z polí, kam ich Edupage vždy dáva - a potom nahradí každý ich výskyt vrátane
225
308
  tých vo voľnom texte správ. Pseudonym je odvodený z pôvodnej hodnoty, takže ten istý
226
- človek je v každej kazete ten istý vymyslený človek a krížové odkazy v payloade
227
- zostanú platné; späť sa z toho dostať nedá. Keďže slovenčina skloňuje, hľadá sa aj
309
+ človek má v každej kazete rovnaký pseudonym a vzájomné odkazy v payloade zostanú
310
+ platné; pôvodnú hodnotu sa z pseudonymu spätne získať nedá. Keďže slovenčina skloňuje, hľadá sa aj
228
311
  kmeň mena, takže zmiznú aj tvary ako `Janu` či `Kováčovej`, nielen základný tvar.
229
312
 
230
313
  Overiť sa to dá proti živému účtu:
@@ -237,14 +320,26 @@ EDUPAGE_LIVE_USERNAME=you@example.com EDUPAGE_LIVE_SCHOOL=yourschool \
237
320
  Ten test si zoznam mien, ktoré sa v kazete nesmú objaviť, **načíta zo živého účtu**, nie
238
321
  z ručne napísaného zoznamu - spadne teda aj vtedy, keď pribudne spolužiak alebo druhá
239
322
  škola, ktorú scrubber nevie nájsť. Zároveň kontroluje, že sa kazeta uložila ako čitateľný
240
- text: Edupage servíruje stránky gzipnuté a komprimované telo by prešlo každou kontrolou
241
- na meno, hoci by v ňom boli všetky.
323
+ text: Edupage posiela stránky gzipnuté a kontrola mien by v komprimovanom tele nič
324
+ nenašla, hoci by v ňom všetky mená zostali.
325
+
326
+ CI beží na Ruby 3.2 až 4.0 na Linuxe, macOS a Windows. Každý adapter úložiska hesiel sa
327
+ testuje proti skutočnému úložisku na svojom systéme: macOS job si vytvorí vlastný odomknutý
328
+ keychain, Linux job spustí gnome-keyring v D-Bus session a Windows job použije Správcu
329
+ poverení. Na ostatných systémoch sa tieto testy preskočia.
330
+
331
+ ### Vydanie
242
332
 
243
- CI beží na Ruby 3.2, 3.3 a 3.4 na Linuxe, plus jeden macOS job, ktorý si vytvorí vlastný
244
- odomknutý keychain, aby sa keychain testy naozaj spustili a nepreskočili.
333
+ Vydanie spustí zmena `Edupage::VERSION` v `lib/edupage/version.rb`. Keď CI na `main`
334
+ prejde, release workflow pushne gem na rubygems.org, vytvorí tag a GitHub release
335
+ a nakoniec aktualizuje formulu v tape
336
+ [alhafoudh/homebrew-edupage](https://github.com/alhafoudh/homebrew-edupage) na nový tag
337
+ (cez deploy key v secrete `HOMEBREW_TAP_DEPLOY_KEY`). Tap má vlastné CI, ktoré formulu
338
+ postaví a otestuje na macOS (arm64, Intel) aj Linuxe (x64, arm64). Každý krok je
339
+ idempotentný, takže zlyhaný beh stačí spustiť znova.
245
340
 
246
341
  ## Rozsah
247
342
 
248
- Read-only, zámerne. Nič tu do Edupage nezapisuje - žiadne odpovede na správy, žiadne
343
+ Zámerne iba na čítanie. Do Edupage sa nič nezapisuje - žiadne odpovede na správy, žiadne
249
344
  označovanie úloh za hotové, žiadne podpisovanie známok. `spec/registry_parity_spec.rb`
250
345
  aj samostatný CI job overujú, že sa v `lib/` neobjaví write endpoint.
@@ -111,7 +111,7 @@ module Edupage
111
111
  { username: username, name: name, schools: schools.map(&:origin) }
112
112
  end
113
113
 
114
- # Forgets the stored sessions. The keychain password is untouched.
114
+ # Forgets the stored sessions. The stored password is untouched.
115
115
  def logout!
116
116
  @store.delete(username)
117
117
  end
data/lib/edupage/cli.rb CHANGED
@@ -45,11 +45,11 @@ module Edupage
45
45
 
46
46
  # --- credentials -------------------------------------------------------------------
47
47
 
48
- desc "login", "Verify credentials and store the password in the macOS keychain"
48
+ desc "login", "Verify credentials and store the password in the OS credential store"
49
49
  method_option :username, type: :string, desc: "Edupage login (email)"
50
50
  method_option :stdin, type: :boolean, desc: "Read the password from stdin instead of prompting"
51
51
  def login
52
- keychain = require_keychain!
52
+ store = require_system_adapter!
53
53
  username = options[:username] || Edupage.config.default_username ||
54
54
  ask("Username (email):")
55
55
  school = options[:school] || Edupage.config.default_school || ask("School (e.g. zsdemo):")
@@ -57,23 +57,26 @@ module Edupage
57
57
 
58
58
  raise Error, "No password given" if password.empty?
59
59
 
60
- # Verified before it is stored, so a typo never ends up in the keychain.
60
+ # Verified before it is stored, so a typo never ends up in the store.
61
61
  users = Client.mauth(school: school, username: username, password: password)
62
- keychain.store(username: username, password: password)
62
+ store.store(username: username, password: password)
63
63
 
64
- say "Stored password for #{username}."
64
+ say "Stored password for #{username} in the #{store.display_name}."
65
65
  say "Schools: #{users.map { |u| u[:origin] }.join(", ")}"
66
66
  remember_defaults(username, school)
67
+ return unless Credentials.new.password_from_env?
68
+
69
+ say "EDUPAGE_PASSWORD is set and wins over the stored password until you unset it.", :yellow
67
70
  end
68
71
 
69
- desc "logout", "Remove the stored password from the keychain"
72
+ desc "logout", "Remove the stored password from the OS credential store"
70
73
  method_option :username, type: :string
71
74
  def logout
72
- keychain = require_keychain!
75
+ store = require_system_adapter!
73
76
  username = options[:username] || Edupage.config.default_username or
74
77
  raise MissingCredentialsError, "No username; pass --username."
75
78
 
76
- say(keychain.delete(username: username) ? "Removed password for #{username}." : "Nothing stored for #{username}.")
79
+ say(store.delete(username: username) ? "Removed password for #{username}." : "Nothing stored for #{username}.")
77
80
  say "Session cache is separate; use `edupage session logout` to drop it.", :yellow
78
81
  end
79
82
 
@@ -87,12 +90,8 @@ module Edupage
87
90
  auth_row("school", safe { credentials.school }, credentials.school_source),
88
91
  auth_row("password", credentials.password_source ? "(set)" : "(missing)",
89
92
  credentials.password_source),
90
- auth_row("keychain", keychain_status(username), nil)
93
+ auth_row("credential store", credential_store_status(credentials, username), nil)
91
94
  ])
92
-
93
- return unless credentials.password_shadowed?
94
-
95
- say "EDUPAGE_PASSWORD is set and overrides the keychain entry.", :yellow
96
95
  end
97
96
 
98
97
  # --- session and cache ---------------------------------------------------------------
@@ -115,7 +114,7 @@ module Edupage
115
114
  say "Logged in again: #{account.schools.map(&:origin).join(", ")}"
116
115
  when "logout"
117
116
  store.delete(username)
118
- say "Dropped stored sessions for #{username}. The keychain password is untouched."
117
+ say "Dropped stored sessions for #{username}. The stored password is untouched."
119
118
  else
120
119
  raise Error, "Unknown session subcommand #{subcommand.inspect}"
121
120
  end
@@ -310,12 +309,9 @@ module Edupage
310
309
  format: options[:json] ? :json : (options[:yaml] ? :yaml : :table))
311
310
  end
312
311
 
313
- def require_keychain!
314
- unless Credentials::Keychain.available?
315
- raise Thor::Error, "The macOS keychain is unavailable on #{RUBY_PLATFORM}; use EDUPAGE_PASSWORD."
316
- end
317
-
318
- Credentials::Keychain.new
312
+ def require_system_adapter!
313
+ Credentials.system_adapter or
314
+ raise Thor::Error, "No OS credential store is available on #{RUBY_PLATFORM}; use EDUPAGE_PASSWORD."
319
315
  end
320
316
 
321
317
  def ask_password
@@ -375,8 +371,10 @@ module Edupage
375
371
  }
376
372
  end
377
373
 
374
+ # Packagers that run edupage through a wrapper (the Homebrew formula) name the
375
+ # stable entry point here, so the registered path survives upgrades.
378
376
  def executable_path
379
- File.expand_path($PROGRAM_NAME)
377
+ ENV["EDUPAGE_EXECUTABLE"] || File.expand_path($PROGRAM_NAME)
380
378
  end
381
379
 
382
380
  def bundler_gemfile
@@ -523,11 +521,17 @@ module Edupage
523
521
  "'#{part.gsub("'", %q('\''))}'"
524
522
  end
525
523
 
526
- def keychain_status(username)
527
- return "unavailable on #{RUBY_PLATFORM}" unless Credentials::Keychain.available?
528
- return "available (service edupage-cli), but no username to look up" unless username
524
+ # Reads the outcome off the password resolution instead of asking the store again,
525
+ # so with EDUPAGE_PASSWORD set the store is never touched.
526
+ def credential_store_status(credentials, username)
527
+ return "not consulted (EDUPAGE_PASSWORD set)" if credentials.password_from_env?
528
+
529
+ adapter = Credentials.system_adapter
530
+ return "unavailable on #{RUBY_PLATFORM}" unless adapter
531
+ return "#{adapter.display_name}, but no username to look up" unless username
529
532
 
530
- Credentials::Keychain.new.stored?(username: username) ? "stored for #{username}" : "nothing stored for #{username}"
533
+ stored = credentials.password_source == adapter.source_for(:password)
534
+ "#{adapter.display_name}, #{stored ? "stored" : "nothing stored"} for #{username}"
531
535
  end
532
536
 
533
537
  def auth_row(label, value, source)
@@ -0,0 +1,34 @@
1
+ module Edupage
2
+ class Credentials
3
+ # Common interface of every credential source.
4
+ #
5
+ # Credentials asks adapters in a fixed order and stops at the first value, so an
6
+ # adapter only ever runs when every adapter before it came up empty. A source that
7
+ # does not hold a field just returns nil for it.
8
+ class Adapter
9
+ class << self
10
+ def available?
11
+ raise NotImplementedError, "#{name}.available? is not implemented"
12
+ end
13
+
14
+ def display_name
15
+ raise NotImplementedError, "#{name}.display_name is not implemented"
16
+ end
17
+ end
18
+
19
+ def display_name = self.class.display_name
20
+
21
+ def username(**) = nil
22
+ def school(**) = nil
23
+ def password(**) = nil
24
+
25
+ # Whether `edupage login` can store a password here.
26
+ def writable? = false
27
+
28
+ # Label shown by `edupage auth` next to a value this adapter supplied.
29
+ def source_for(_field)
30
+ raise NotImplementedError, "#{self.class.name}#source_for is not implemented"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,36 @@
1
+ module Edupage
2
+ class Credentials
3
+ module Adapters
4
+ # Highest-priority source: plain environment variables. Whatever it supplies wins,
5
+ # and nothing after it is consulted for that field.
6
+ class Env < Adapter
7
+ VARS = {
8
+ username: "EDUPAGE_USERNAME",
9
+ password: "EDUPAGE_PASSWORD",
10
+ school: "EDUPAGE_SCHOOL"
11
+ }.freeze
12
+
13
+ class << self
14
+ def available? = true
15
+ def display_name = "environment"
16
+ end
17
+
18
+ def initialize(env = ENV)
19
+ super()
20
+ @env = env
21
+ end
22
+
23
+ def username(**) = @env[VARS[:username]]
24
+ def password(**) = @env[VARS[:password]]
25
+
26
+ # Accepts either "zsdemo" or "zsdemo.edupage.org".
27
+ def school(**)
28
+ value = @env[VARS[:school]]
29
+ value&.sub(/\.edupage\.org\z/, "")
30
+ end
31
+
32
+ def source_for(field) = "ENV: #{VARS[field]}"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,87 @@
1
+ require "open3"
2
+ require "rbconfig"
3
+
4
+ module Edupage
5
+ class Credentials
6
+ module Adapters
7
+ # The macOS keychain, driven through /usr/bin/security.
8
+ #
9
+ # The password is never passed in argv (where `ps` would expose it). `security`
10
+ # reads it from stdin instead, prompting twice, so writes send the value twice.
11
+ #
12
+ # `security add-generic-password` exits 0 even when the two reads disagree, storing
13
+ # an empty password - which is what the read-back in SystemAdapter#store catches.
14
+ class MacosKeychain < SystemAdapter
15
+ SECURITY = "/usr/bin/security".freeze
16
+ # Runs a command in a new session, without a controlling terminal. macOS ships no
17
+ # setsid(1), so Ruby itself does the setsid before exec.
18
+ DETACH = [RbConfig.ruby, "-e", "Process.setsid; exec(*ARGV)"].freeze
19
+
20
+ class << self
21
+ def available?
22
+ RUBY_PLATFORM.include?("darwin") && File.executable?(SECURITY)
23
+ end
24
+
25
+ def display_name = "macOS keychain"
26
+ end
27
+
28
+ def source_for(_field) = "keychain"
29
+
30
+ private
31
+
32
+ def read(username)
33
+ # -g, not -w: for non-ASCII passwords `-w` prints bare hex, which is
34
+ # indistinguishable from an ASCII password that happens to look like hex
35
+ # ("deadbeef"). -g prefixes the hex form with 0x, so it can be told apart.
36
+ _out, err, status = Open3.capture3(
37
+ SECURITY, "find-generic-password", "-s", service, "-a", username, "-g"
38
+ )
39
+ return nil unless status.success?
40
+
41
+ parse_password(err)
42
+ end
43
+
44
+ def write(username, secret)
45
+ # -w last with no value: `security` prompts on stdin rather than taking argv.
46
+ # It prefers /dev/tty over stdin when it has a terminal, so it runs detached
47
+ # from ours; otherwise it would ignore stdin_data and prompt the user instead.
48
+ _out, err, status = Open3.capture3(
49
+ *DETACH, SECURITY, "add-generic-password", "-s", service, "-a", username,
50
+ "-l", "#{service} (#{username})", "-U", "-w",
51
+ stdin_data: "#{secret}\n#{secret}\n"
52
+ )
53
+ raise Error, "Failed to store password in keychain: #{err.strip}" unless status.success?
54
+ end
55
+
56
+ def remove(username)
57
+ _out, _err, status = Open3.capture3(
58
+ SECURITY, "delete-generic-password", "-s", service, "-a", username
59
+ )
60
+ status.success?
61
+ end
62
+
63
+ # `security -g` writes one of these to stderr:
64
+ # password: "plain-ascii"
65
+ # password: 0x73336372 "s3cr3t-\303\241"
66
+ # The hex form is exact bytes, so it is preferred whenever present.
67
+ def parse_password(stderr)
68
+ line = stderr.lines.find { |l| l.start_with?("password:") }
69
+ return nil unless line
70
+
71
+ if (hex = line[/password: 0x([0-9A-Fa-f]+)/, 1])
72
+ [hex].pack("H*").force_encoding(Encoding::UTF_8)
73
+ elsif (quoted = line[/password: "(.*)"\s*\z/m, 1])
74
+ unescape(quoted)
75
+ end
76
+ end
77
+
78
+ def unescape(str)
79
+ str.gsub(/\\(\d{3}|.)/) do
80
+ esc = Regexp.last_match(1)
81
+ esc.match?(/\A\d{3}\z/) ? esc.to_i(8).chr : esc
82
+ end.force_encoding(Encoding::UTF_8)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,60 @@
1
+ require "open3"
2
+
3
+ module Edupage
4
+ class Credentials
5
+ module Adapters
6
+ # The freedesktop Secret Service (GNOME Keyring, KWallet), driven through
7
+ # `secret-tool` from libsecret.
8
+ #
9
+ # Entries carry the attributes service=<service> and account=<username>. The
10
+ # password goes in on stdin, never in argv; with a pipe rather than a terminal on
11
+ # stdin, `secret-tool store` reads it verbatim instead of prompting.
12
+ #
13
+ # Without a D-Bus session (a plain SSH login, a container) lookups fail; that reads
14
+ # as "nothing stored" so ENV-less runs still get the usual missing-password hint.
15
+ class SecretService < SystemAdapter
16
+ class << self
17
+ def available?
18
+ RUBY_PLATFORM.include?("linux") && !executable.nil?
19
+ end
20
+
21
+ def display_name = "Secret Service (libsecret)"
22
+
23
+ def executable
24
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
25
+ .map { |dir| File.join(dir, "secret-tool") }
26
+ .find { |path| File.executable?(path) }
27
+ end
28
+ end
29
+
30
+ def source_for(_field) = "secret service"
31
+
32
+ private
33
+
34
+ def attributes(username) = ["service", service, "account", username]
35
+
36
+ def read(username)
37
+ # Prints the secret as-is, without a trailing newline when stdout is a pipe.
38
+ out, _err, status = Open3.capture3(self.class.executable, "lookup", *attributes(username))
39
+ status.success? ? out.force_encoding(Encoding::UTF_8) : nil
40
+ end
41
+
42
+ def write(username, secret)
43
+ _out, err, status = Open3.capture3(
44
+ self.class.executable, "store", "--label", "#{service} (#{username})", *attributes(username),
45
+ stdin_data: secret
46
+ )
47
+ raise Error, "Failed to store password in #{display_name}: #{err.strip}" unless status.success?
48
+ end
49
+
50
+ # `secret-tool clear` exits 0 whether or not anything matched, so whether there
51
+ # was something to remove is checked first.
52
+ def remove(username)
53
+ existed = stored?(username: username)
54
+ Open3.capture3(self.class.executable, "clear", *attributes(username))
55
+ existed
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,127 @@
1
+ module Edupage
2
+ class Credentials
3
+ module Adapters
4
+ # Windows Credential Manager, called through advapi32 with Fiddle.
5
+ #
6
+ # Windows ships no command that reads a stored password back (cmdkey only writes
7
+ # one), so this talks to the Cred* API directly: no subprocess, and the password
8
+ # never appears in argv.
9
+ #
10
+ # Entries are generic credentials named "<service>:<username>". The blob holds the
11
+ # password as UTF-8 bytes; Credential Manager treats it as opaque.
12
+ class WindowsCredentialManager < SystemAdapter
13
+ CRED_TYPE_GENERIC = 1
14
+ CRED_PERSIST_LOCAL_MACHINE = 2
15
+
16
+ class << self
17
+ def available? = Gem.win_platform?
18
+ def display_name = "Windows Credential Manager"
19
+
20
+ # Built on first use, so Fiddle is never loaded on other platforms or when ENV
21
+ # already supplied the password.
22
+ def api
23
+ @api ||= begin
24
+ require "fiddle/import"
25
+
26
+ Module.new do
27
+ extend Fiddle::Importer
28
+
29
+ dlload "advapi32.dll"
30
+ extern "int CredReadW(void*, uint32_t, uint32_t, void*)"
31
+ extern "int CredWriteW(void*, uint32_t)"
32
+ extern "int CredDeleteW(void*, uint32_t, uint32_t)"
33
+ extern "void CredFree(void*)"
34
+ end
35
+ end
36
+ end
37
+
38
+ # CREDENTIALW. FILETIME is two DWORDs; Fiddle works out the padding before
39
+ # each pointer, so the layout matches on both x64 and arm64.
40
+ def credential
41
+ @credential ||= api.struct([
42
+ "uint32_t flags",
43
+ "uint32_t type",
44
+ "void* target_name",
45
+ "void* comment",
46
+ "uint32_t last_written_low",
47
+ "uint32_t last_written_high",
48
+ "uint32_t blob_size",
49
+ "void* blob",
50
+ "uint32_t persist",
51
+ "uint32_t attribute_count",
52
+ "void* attributes",
53
+ "void* target_alias",
54
+ "void* user_name"
55
+ ])
56
+ end
57
+ end
58
+
59
+ def source_for(_field) = "credential manager"
60
+
61
+ def target_name(username) = "#{service}:#{username}"
62
+
63
+ private
64
+
65
+ # Each entry point fetches the API first: that is what requires Fiddle, so it has
66
+ # to happen before any other Fiddle constant is touched.
67
+ def read(username)
68
+ api = self.class.api
69
+ slot = Fiddle::Pointer.malloc(Fiddle::SIZEOF_VOIDP, Fiddle::RUBY_FREE)
70
+ return nil if api.CredReadW(wide(target_name(username)), CRED_TYPE_GENERIC, 0, slot).zero?
71
+
72
+ address = slot.ptr.to_i
73
+ begin
74
+ entry = self.class.credential.new(address)
75
+ Fiddle::Pointer.new(entry.blob.to_i)[0, entry.blob_size].force_encoding(Encoding::UTF_8)
76
+ ensure
77
+ api.CredFree(address)
78
+ end
79
+ end
80
+
81
+ def write(username, secret)
82
+ api = self.class.api
83
+ # Locals keep the buffers alive until CredWriteW has copied them.
84
+ target = wide(target_name(username))
85
+ user = wide(username)
86
+ blob = secret.b
87
+
88
+ entry = self.class.credential.malloc(Fiddle::RUBY_FREE)
89
+ entry.flags = 0
90
+ entry.type = CRED_TYPE_GENERIC
91
+ entry.target_name = target.to_i
92
+ entry.comment = 0
93
+ entry.last_written_low = 0
94
+ entry.last_written_high = 0
95
+ entry.blob_size = blob.bytesize
96
+ entry.blob = Fiddle::Pointer[blob].to_i
97
+ entry.persist = CRED_PERSIST_LOCAL_MACHINE
98
+ entry.attribute_count = 0
99
+ entry.attributes = 0
100
+ entry.target_alias = 0
101
+ entry.user_name = user.to_i
102
+
103
+ return unless api.CredWriteW(entry.to_ptr, 0).zero?
104
+
105
+ raise Error, "Failed to store password in #{display_name} (Win32 error #{Fiddle.win32_last_error})"
106
+ end
107
+
108
+ # Whether there is anything to remove is checked up front rather than read off
109
+ # ERROR_NOT_FOUND (1168): on Windows arm64 with Ruby 4.0, Fiddle reports the last error
110
+ # as 0 after a failed CredDeleteW.
111
+ def remove(username)
112
+ return false unless stored?(username: username)
113
+
114
+ api = self.class.api
115
+ return true unless api.CredDeleteW(wide(target_name(username)), CRED_TYPE_GENERIC, 0).zero?
116
+
117
+ raise Error, "Failed to remove password from #{display_name} (Win32 error #{Fiddle.win32_last_error.inspect})"
118
+ end
119
+
120
+ # A NUL-terminated UTF-16LE copy of +str+, as the W functions expect.
121
+ def wide(str)
122
+ Fiddle::Pointer["#{str}\0".encode(Encoding::UTF_16LE)]
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,78 @@
1
+ module Edupage
2
+ class Credentials
3
+ # Base for the operating system's own credential stores. They hold only the
4
+ # password, keyed by service and username; username and school come from ENV,
5
+ # flags or config.
6
+ #
7
+ # Subclasses implement the private #read, #write and #remove. Everything that has to
8
+ # hold for every store - no lookups where the store does not exist, no empty
9
+ # passwords, a read-back after every write - lives here.
10
+ class SystemAdapter < Adapter
11
+ SERVICE = "edupage-cli".freeze
12
+
13
+ attr_reader :service
14
+
15
+ def initialize(service: SERVICE)
16
+ super()
17
+ @service = service
18
+ end
19
+
20
+ def writable? = true
21
+
22
+ def password(username: nil, **)
23
+ return nil if username.nil? || username.empty?
24
+ return nil unless self.class.available?
25
+
26
+ value = read(username)
27
+ value && !value.empty? ? value : nil
28
+ end
29
+
30
+ def store(username:, password:)
31
+ ensure_available!
32
+ secret = password.to_s
33
+ raise ArgumentError, "password must not be empty" if secret.empty?
34
+
35
+ write(username, secret)
36
+
37
+ # Stores can report success and still persist something else (see
38
+ # MacosKeychain), so a write only counts once it reads back intact.
39
+ unless password(username: username) == secret
40
+ raise Error, "#{display_name} reported success but stored a different password"
41
+ end
42
+
43
+ true
44
+ end
45
+
46
+ # True when something was stored and is now gone.
47
+ def delete(username:)
48
+ ensure_available!
49
+ remove(username)
50
+ end
51
+
52
+ def stored?(username:)
53
+ !password(username: username).nil?
54
+ end
55
+
56
+ private
57
+
58
+ def read(_username)
59
+ raise NotImplementedError, "#{self.class.name}#read is not implemented"
60
+ end
61
+
62
+ def write(_username, _secret)
63
+ raise NotImplementedError, "#{self.class.name}#write is not implemented"
64
+ end
65
+
66
+ def remove(_username)
67
+ raise NotImplementedError, "#{self.class.name}#remove is not implemented"
68
+ end
69
+
70
+ def ensure_available!
71
+ return if self.class.available?
72
+
73
+ raise UnsupportedPlatformError,
74
+ "The #{display_name} is not available on #{RUBY_PLATFORM}. Use EDUPAGE_PASSWORD instead."
75
+ end
76
+ end
77
+ end
78
+ end
@@ -1,25 +1,39 @@
1
1
  module Edupage
2
- # Resolves username / password / school from an ordered list of providers.
2
+ # Resolves username / password / school from credential adapters.
3
3
  #
4
- # ENV always wins over the keychain. That ordering is deliberate but surprising
5
- # (an `edupage login` looks like a no-op while EDUPAGE_PASSWORD is exported), so
6
- # #password_source is reported by `edupage auth status`.
4
+ # ENV wins over everything. When it supplies a value nothing else is consulted, and the
5
+ # operating system's credential store in particular is never touched: no keychain
6
+ # prompt, no subprocess, no FFI load. That store is looked up lazily and only as the
7
+ # last resort for the password. Username and school fall back to --username/--school
8
+ # and then config instead.
7
9
  class Credentials
8
10
  Resolved = Struct.new(:value, :source, keyword_init: true)
9
11
 
12
+ # Tried in order; the first one available on this machine is the system store.
13
+ SYSTEM_ADAPTERS = [
14
+ Adapters::MacosKeychain,
15
+ Adapters::WindowsCredentialManager,
16
+ Adapters::SecretService
17
+ ].freeze
18
+
19
+ class << self
20
+ # The credential store of this operating system, or nil when there is none.
21
+ def system_adapter
22
+ SYSTEM_ADAPTERS.find(&:available?)&.new
23
+ end
24
+ end
25
+
10
26
  attr_reader :username_override, :school_override
11
27
 
12
- def initialize(username: nil, school: nil, providers: nil, config: Edupage.config)
28
+ # +system+ is a callable rather than an adapter so that building Credentials never
29
+ # touches the store; it is called at most once, and only when ENV has no password.
30
+ def initialize(username: nil, school: nil, env: Adapters::Env.new,
31
+ system: -> { Credentials.system_adapter }, config: Edupage.config)
13
32
  @username_override = username
14
33
  @school_override = school
15
34
  @config = config
16
- @providers = providers || default_providers
17
- end
18
-
19
- def default_providers
20
- [Credentials::Env.new].tap do |list|
21
- list << Credentials::Keychain.new if Credentials::Keychain.available?
22
- end
35
+ @env = env
36
+ @system = system
23
37
  end
24
38
 
25
39
  def username
@@ -41,20 +55,9 @@ module Edupage
41
55
  def password_source = resolved_password.source
42
56
  def school_source = resolved_school.source
43
57
 
44
- # True when ENV supplies the password while a lower-priority provider also holds
45
- # one, i.e. a stored password is being silently shadowed. Providers are compared by
46
- # the source they report rather than by class, so any provider chain works.
47
- def password_shadowed?
48
- winning = resolved_password.source
49
- return false unless winning&.start_with?("ENV:")
50
-
51
- user = resolved_username.value
52
- @providers.any? do |provider|
53
- next false if provider.source_for(:password) == winning
54
-
55
- value = provider.password(username: user)
56
- value && !value.empty?
57
- end
58
+ # True when ENV supplies the password, i.e. the system store is not consulted.
59
+ def password_from_env?
60
+ !from(@env, :password).nil?
58
61
  end
59
62
 
60
63
  def to_h
@@ -68,37 +71,39 @@ module Edupage
68
71
  private
69
72
 
70
73
  def resolved_username
71
- @resolved_username ||= begin
72
- from_providers(:username) ||
73
- wrap(@username_override, "--username") ||
74
- wrap(@config.default_username, "config") ||
75
- Resolved.new(value: nil, source: nil)
76
- end
74
+ @resolved_username ||=
75
+ from(@env, :username) ||
76
+ wrap(@username_override, "--username") ||
77
+ wrap(@config.default_username, "config") ||
78
+ Resolved.new(value: nil, source: nil)
77
79
  end
78
80
 
79
81
  def resolved_password
80
82
  @resolved_password ||=
81
- from_providers(:password, username: resolved_username.value) ||
83
+ from(@env, :password) ||
84
+ from(system_adapter, :password, username: resolved_username.value) ||
82
85
  Resolved.new(value: nil, source: nil)
83
86
  end
84
87
 
85
88
  def resolved_school
86
- @resolved_school ||= begin
87
- from_providers(:school) ||
88
- wrap(@school_override, "--school") ||
89
- wrap(@config.default_school, "config") ||
90
- Resolved.new(value: nil, source: nil)
91
- end
89
+ @resolved_school ||=
90
+ from(@env, :school) ||
91
+ wrap(@school_override, "--school") ||
92
+ wrap(@config.default_school, "config") ||
93
+ Resolved.new(value: nil, source: nil)
92
94
  end
93
95
 
94
- # An override beats a lower-priority provider but never beats ENV, so overrides are
95
- # only consulted after the provider chain comes up empty.
96
- def from_providers(field, **args)
97
- @providers.each do |provider|
98
- value = provider.public_send(field, **args)
99
- return Resolved.new(value: value, source: provider.source_for(field)) if value && !value.empty?
100
- end
101
- nil
96
+ def system_adapter
97
+ return @system_adapter if defined?(@system_adapter)
98
+
99
+ @system_adapter = @system.call
100
+ end
101
+
102
+ def from(adapter, field, **args)
103
+ return nil unless adapter
104
+
105
+ value = adapter.public_send(field, **args)
106
+ Resolved.new(value: value, source: adapter.source_for(field)) if value && !value.empty?
102
107
  end
103
108
 
104
109
  def wrap(value, source)
@@ -108,10 +113,10 @@ module Edupage
108
113
  end
109
114
 
110
115
  def missing_password_message
111
- if Credentials::Keychain.available?
112
- "No password. Run `edupage login`, or set EDUPAGE_PASSWORD."
116
+ if (adapter = system_adapter)
117
+ "No password. Run `edupage login` to store it in the #{adapter.display_name}, or set EDUPAGE_PASSWORD."
113
118
  else
114
- "No password and the macOS keychain is unavailable on this platform. Set EDUPAGE_PASSWORD."
119
+ "No password and no OS credential store is available on #{RUBY_PLATFORM}. Set EDUPAGE_PASSWORD."
115
120
  end
116
121
  end
117
122
  end
@@ -1,3 +1,3 @@
1
1
  module Edupage
2
- VERSION = "0.1.0".freeze
2
+ VERSION = "0.2.0".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: edupage-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ahmed Al Hafoudh
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: fiddle
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.1'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: mcp
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -130,6 +144,7 @@ executables:
130
144
  extensions: []
131
145
  extra_rdoc_files: []
132
146
  files:
147
+ - LICENSE
133
148
  - README.md
134
149
  - exe/edupage
135
150
  - lib/edupage.rb
@@ -141,8 +156,12 @@ files:
141
156
  - lib/edupage/client.rb
142
157
  - lib/edupage/config.rb
143
158
  - lib/edupage/credentials.rb
144
- - lib/edupage/credentials/env.rb
145
- - lib/edupage/credentials/keychain.rb
159
+ - lib/edupage/credentials/adapter.rb
160
+ - lib/edupage/credentials/adapters/env.rb
161
+ - lib/edupage/credentials/adapters/macos_keychain.rb
162
+ - lib/edupage/credentials/adapters/secret_service.rb
163
+ - lib/edupage/credentials/adapters/windows_credential_manager.rb
164
+ - lib/edupage/credentials/system_adapter.rb
146
165
  - lib/edupage/errors.rb
147
166
  - lib/edupage/model.rb
148
167
  - lib/edupage/models/assignment.rb
@@ -1,27 +0,0 @@
1
- module Edupage
2
- class Credentials
3
- # Highest-priority provider: plain environment variables.
4
- class Env
5
- VARS = {
6
- username: "EDUPAGE_USERNAME",
7
- password: "EDUPAGE_PASSWORD",
8
- school: "EDUPAGE_SCHOOL"
9
- }.freeze
10
-
11
- def initialize(env = ENV)
12
- @env = env
13
- end
14
-
15
- def username(**) = @env[VARS[:username]]
16
- def password(**) = @env[VARS[:password]]
17
-
18
- # Accepts either "zsdemo" or "zsdemo.edupage.org".
19
- def school(**)
20
- value = @env[VARS[:school]]
21
- value&.sub(/\.edupage\.org\z/, "")
22
- end
23
-
24
- def source_for(field) = "ENV: #{VARS[field]}"
25
- end
26
- end
27
- end
@@ -1,120 +0,0 @@
1
- require "open3"
2
- require "rbconfig"
3
-
4
- module Edupage
5
- class Credentials
6
- # Secondary provider: the macOS keychain, driven through /usr/bin/security.
7
- #
8
- # The password is never passed in argv (where `ps` would expose it). `security`
9
- # reads it from stdin instead, prompting twice, so writes send the value twice.
10
- #
11
- # `security add-generic-password` exits 0 even when the two reads disagree, storing
12
- # an empty password - so #store always reads the value back before reporting success.
13
- class Keychain
14
- SERVICE = "edupage-cli".freeze
15
- SECURITY = "/usr/bin/security".freeze
16
- # Runs a command in a new session, without a controlling terminal. macOS ships no
17
- # setsid(1), so Ruby itself does the setsid before exec.
18
- DETACH = [RbConfig.ruby, "-e", "Process.setsid; exec(*ARGV)"].freeze
19
-
20
- class << self
21
- def available?
22
- RUBY_PLATFORM.include?("darwin") && File.executable?(SECURITY)
23
- end
24
- end
25
-
26
- def initialize(service: SERVICE)
27
- @service = service
28
- end
29
-
30
- # Only the password lives in the keychain; the other fields come from ENV or config.
31
- def username(**) = nil
32
- def school(**) = nil
33
-
34
- def password(username: nil, **)
35
- return nil if username.nil? || username.empty?
36
- return nil unless self.class.available?
37
-
38
- # -g, not -w: for non-ASCII passwords `-w` prints bare hex, which is
39
- # indistinguishable from an ASCII password that happens to look like hex
40
- # ("deadbeef"). -g prefixes the hex form with 0x, so it can be told apart.
41
- _out, err, status = Open3.capture3(
42
- SECURITY, "find-generic-password", "-s", @service, "-a", username, "-g"
43
- )
44
- return nil unless status.success?
45
-
46
- value = parse_password(err)
47
- value && !value.empty? ? value : nil
48
- end
49
-
50
- def store(username:, password:)
51
- ensure_available!
52
- secret = password.to_s
53
- raise ArgumentError, "password must not be empty" if secret.empty?
54
-
55
- # -w last with no value: `security` prompts on stdin rather than taking argv.
56
- # It prefers /dev/tty over stdin when it has a terminal, so it runs detached
57
- # from ours; otherwise it would ignore stdin_data and prompt the user instead.
58
- _out, err, status = Open3.capture3(
59
- *DETACH, SECURITY, "add-generic-password", "-s", @service, "-a", username,
60
- "-l", "#{@service} (#{username})", "-U", "-w",
61
- stdin_data: "#{secret}\n#{secret}\n"
62
- )
63
- raise Error, "Failed to store password in keychain: #{err.strip}" unless status.success?
64
-
65
- # Guard against the silent-empty-write path described above.
66
- unless password(username: username) == secret
67
- raise Error, "Keychain reported success but stored a different password"
68
- end
69
-
70
- true
71
- end
72
-
73
- def delete(username:)
74
- ensure_available!
75
-
76
- _out, _err, status = Open3.capture3(
77
- SECURITY, "delete-generic-password", "-s", @service, "-a", username
78
- )
79
- status.success?
80
- end
81
-
82
- def stored?(username:)
83
- !password(username: username).nil?
84
- end
85
-
86
- def source_for(_field) = "keychain"
87
-
88
- private
89
-
90
- # `security -g` writes one of these to stderr:
91
- # password: "plain-ascii"
92
- # password: 0x73336372 "s3cr3t-\303\241"
93
- # The hex form is exact bytes, so it is preferred whenever present.
94
- def parse_password(stderr)
95
- line = stderr.lines.find { |l| l.start_with?("password:") }
96
- return nil unless line
97
-
98
- if (hex = line[/password: 0x([0-9A-Fa-f]+)/, 1])
99
- [hex].pack("H*").force_encoding(Encoding::UTF_8)
100
- elsif (quoted = line[/password: "(.*)"\s*\z/m, 1])
101
- unescape(quoted)
102
- end
103
- end
104
-
105
- def unescape(str)
106
- str.gsub(/\\(\d{3}|.)/) do
107
- esc = Regexp.last_match(1)
108
- esc.match?(/\A\d{3}\z/) ? esc.to_i(8).chr : esc
109
- end.force_encoding(Encoding::UTF_8)
110
- end
111
-
112
- def ensure_available!
113
- return if self.class.available?
114
-
115
- raise UnsupportedPlatformError,
116
- "The macOS keychain is not available on #{RUBY_PLATFORM}. Use EDUPAGE_PASSWORD instead."
117
- end
118
- end
119
- end
120
- end