edupage-cli 0.1.1 → 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 +4 -4
- data/README.md +134 -73
- data/lib/edupage/account.rb +1 -1
- data/lib/edupage/cli.rb +26 -24
- data/lib/edupage/credentials/adapter.rb +34 -0
- data/lib/edupage/credentials/adapters/env.rb +36 -0
- data/lib/edupage/credentials/adapters/macos_keychain.rb +87 -0
- data/lib/edupage/credentials/adapters/secret_service.rb +60 -0
- data/lib/edupage/credentials/adapters/windows_credential_manager.rb +127 -0
- data/lib/edupage/credentials/system_adapter.rb +78 -0
- data/lib/edupage/credentials.rb +55 -50
- data/lib/edupage/version.rb +1 -1
- metadata +21 -3
- data/lib/edupage/credentials/env.rb +0 -27
- data/lib/edupage/credentials/keychain.rb +0 -120
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 121fd1c2fcc0aa186c79565930684a7bd169589616178ed763fdd742d5330926
|
|
4
|
+
data.tar.gz: 17a1e8c2205330a58eb1208e91948b445f643c4c9078b9a43e6d0e7ac1713099
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5147f956c0b5dcd81674676a3219adda32d453fda2d766ae2d3b8dd5573d809a4298a6e8c9f57ccb76e1bb4e16d5e3f44db60dd7aaf9e2cbb791f11e762f49b0
|
|
7
|
+
data.tar.gz: 19a1ec3e5a0b9e47883d5199ddbd81f0a9bf615b5618460cd601a1831a60387ca5ab44604439ff47ce4c772a27b2fb5e4a42b7b6e133947cd7d3c098f54e833e
|
data/README.md
CHANGED
|
@@ -1,16 +1,55 @@
|
|
|
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
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+

|
|
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
|
+
|
|
14
53
|
Cez Homebrew na macOS aj Linuxe:
|
|
15
54
|
|
|
16
55
|
```bash
|
|
@@ -18,9 +57,9 @@ brew install alhafoudh/edupage/edupage-cli
|
|
|
18
57
|
edupage login
|
|
19
58
|
```
|
|
20
59
|
|
|
21
|
-
Formula
|
|
22
|
-
|
|
23
|
-
nezasahuje do
|
|
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
|
|
24
63
|
gemov (puma, nio4r), čo trvá pár desiatok sekúnd.
|
|
25
64
|
|
|
26
65
|
Alebo ako gem, ak už Ruby 3.2+ máš:
|
|
@@ -36,29 +75,43 @@ bundle install
|
|
|
36
75
|
bundle exec exe/edupage login
|
|
37
76
|
```
|
|
38
77
|
|
|
39
|
-
`login`
|
|
40
|
-
|
|
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ží.
|
|
41
80
|
|
|
42
|
-
##
|
|
81
|
+
## Prihlasovacie údaje
|
|
43
82
|
|
|
44
83
|
Hľadajú sa v tomto poradí:
|
|
45
84
|
|
|
46
85
|
| Poradie | Zdroj |
|
|
47
86
|
|---|---|
|
|
48
87
|
| 1 | `EDUPAGE_USERNAME`, `EDUPAGE_PASSWORD`, `EDUPAGE_SCHOOL` |
|
|
49
|
-
| 2 |
|
|
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) |
|
|
50
90
|
|
|
51
|
-
|
|
52
|
-
prekrýva heslo v keychaine - inak by to vyzeralo, že `edupage login` nič neurobil.
|
|
91
|
+
Úložisko hesiel podľa systému:
|
|
53
92
|
|
|
54
|
-
|
|
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>` |
|
|
98
|
+
|
|
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`.
|
|
101
|
+
|
|
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
|
|
55
107
|
v `~/.cache/edupage-cli/`.
|
|
56
108
|
|
|
57
|
-
##
|
|
109
|
+
## Hierarchia výberu
|
|
58
110
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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í.
|
|
62
115
|
|
|
63
116
|
```
|
|
64
117
|
$ edupage students
|
|
@@ -67,11 +120,12 @@ No school selected. Pick one:
|
|
|
67
120
|
--school zusdemo Základná umelecká škola Demo
|
|
68
121
|
```
|
|
69
122
|
|
|
70
|
-
|
|
71
|
-
`*.edupage.org
|
|
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š.
|
|
72
125
|
|
|
73
|
-
Jedinou výnimkou je
|
|
74
|
-
|
|
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ú:
|
|
75
129
|
|
|
76
130
|
```
|
|
77
131
|
$ edupage grades --school zsdemo --student Jana
|
|
@@ -98,8 +152,9 @@ Globálne prepínače: `--school --student --year --username --json --yaml --no-
|
|
|
98
152
|
--verbose`. Meno študenta stačí zadať ako unikátny prefix, takže `--student Jana`
|
|
99
153
|
postačuje.
|
|
100
154
|
|
|
101
|
-
Tabuľkový výstup má hlavičku s
|
|
102
|
-
`--json` a `--yaml`
|
|
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.
|
|
103
158
|
|
|
104
159
|
```
|
|
105
160
|
$ edupage grades --school zsdemo --student Jana --year 2025
|
|
@@ -115,8 +170,8 @@ year : 2025/2026
|
|
|
115
170
|
└──────────────────┴───────────────────────────────┴───────┴────────┴─────────────────────────┘
|
|
116
171
|
```
|
|
117
172
|
|
|
118
|
-
Tabuľka sa prispôsobí šírke terminálu: keď sa nezmestí,
|
|
119
|
-
|
|
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é.
|
|
120
175
|
|
|
121
176
|
Servisné príkazy: `edupage auth`, `edupage session status|refresh|logout`,
|
|
122
177
|
`edupage cache info|clear`, `edupage config path|get|set`.
|
|
@@ -139,8 +194,9 @@ jana.grades # skratka pre aktuálny rok
|
|
|
139
194
|
```
|
|
140
195
|
|
|
141
196
|
Kolekcie sú lazy a reťaziteľné: `where order limit offset find_by first count`.
|
|
142
|
-
`where(name: ...)` hľadá
|
|
143
|
-
|
|
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.
|
|
144
200
|
|
|
145
201
|
## Server
|
|
146
202
|
|
|
@@ -150,7 +206,7 @@ edupage mcp # MCP cez stdio, pre editory a desktop klientov
|
|
|
150
206
|
```
|
|
151
207
|
|
|
152
208
|
Počúva na `127.0.0.1` a pri prvom spustení si vygeneruje bearer token do configu.
|
|
153
|
-
Všetky
|
|
209
|
+
Všetky trasy sú `GET`, všetky MCP tooly sú označené ako read-only.
|
|
154
210
|
|
|
155
211
|
```bash
|
|
156
212
|
curl -H "Authorization: Bearer $TOKEN" \
|
|
@@ -160,7 +216,7 @@ curl -H "Authorization: Bearer $TOKEN" \
|
|
|
160
216
|
### Zapojenie MCP
|
|
161
217
|
|
|
162
218
|
`edupage mcp-add` zaregistruje server u klienta, `edupage mcp-config` iba vypíše
|
|
163
|
-
`mcpServers
|
|
219
|
+
záznam `mcpServers`, ak si ho chceš do configu pridať ručne.
|
|
164
220
|
|
|
165
221
|
```bash
|
|
166
222
|
edupage mcp-add claude-code --scope user # cez `claude mcp add`
|
|
@@ -173,30 +229,30 @@ edupage mcp-config # len JSON a nič iné
|
|
|
173
229
|
edupage mcp-config --http
|
|
174
230
|
```
|
|
175
231
|
|
|
176
|
-
Pridávanie je **idempotentné**: zhodný záznam
|
|
232
|
+
Pridávanie je **idempotentné**: zhodný záznam ponechá, odlišný upraví a chýbajúci
|
|
177
233
|
doplní. Config Claude Desktopu sa zlučuje, nie prepisuje - ostatné servery aj nesúvisiace
|
|
178
234
|
nastavenia zostanú - a predošlá verzia sa odloží ako `.bak`.
|
|
179
235
|
|
|
180
|
-
Pre lokálny nástroj je
|
|
181
|
-
|
|
182
|
-
`edupage server` a
|
|
183
|
-
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.
|
|
184
240
|
|
|
185
|
-
Ani jeden variant
|
|
186
|
-
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í.
|
|
187
243
|
|
|
188
244
|
Vygenerovaný stdio záznam používa absolútne cesty a pri behu z checkoutu nastavuje
|
|
189
245
|
`BUNDLE_GEMFILE`, lebo MCP klienti spúšťajú servery z vlastného pracovného adresára.
|
|
190
246
|
Pri inštalácii cez Homebrew ukazuje na `$(brew --prefix)/opt/edupage-cli/bin/edupage`,
|
|
191
|
-
nie na verziovanú cestu v `Cellar`, takže záznam
|
|
247
|
+
nie na verziovanú cestu v `Cellar`, takže záznam zostane platný aj po `brew upgrade`. Zariaďuje to
|
|
192
248
|
premenná `EDUPAGE_EXECUTABLE`, ktorú nastavuje wrapper z formuly; rovnako ju môže
|
|
193
|
-
použiť akýkoľvek iný balíčkovač.
|
|
194
|
-
nastaveného locale**,
|
|
195
|
-
|
|
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`.
|
|
196
252
|
|
|
197
|
-
## Ako
|
|
253
|
+
## Ako udržiavame rozhrania v súlade
|
|
198
254
|
|
|
199
|
-
|
|
255
|
+
Zdroje sú deklarované na jednom mieste, v `lib/edupage/registry/resources.rb`:
|
|
200
256
|
|
|
201
257
|
```ruby
|
|
202
258
|
resource :grades do
|
|
@@ -207,31 +263,34 @@ resource :grades do
|
|
|
207
263
|
end
|
|
208
264
|
```
|
|
209
265
|
|
|
210
|
-
Z toho sa vygeneruje CLI príkaz, REST
|
|
211
|
-
spadne, ak niektorý chýba alebo
|
|
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
|
|
212
268
|
odpovede aj výsledok MCP toolu idú cez jeden serializer, takže sú bajt na bajt zhodné.
|
|
213
269
|
|
|
214
|
-
`scope` zároveň
|
|
215
|
-
|
|
216
|
-
|
|
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í.
|
|
217
273
|
|
|
218
274
|
## Poznámky k samotnému Edupage
|
|
219
275
|
|
|
220
|
-
Veci, ktoré stojí za to vedieť - všetky overené
|
|
221
|
-
|
|
222
|
-
- **
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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.
|
|
229
287
|
- **Číselníky sa medzi rokmi menia.** Zoznam tried za 2025 nie je ten istý ako za 2026,
|
|
230
288
|
preto je rok súčasťou každého cache kľúča.
|
|
231
289
|
- **Timeline je spoločná pre všetky deti rodiča** a delí sa lokálne podľa mapy
|
|
232
290
|
`childGroups`.
|
|
233
|
-
- **Domáce úlohy
|
|
234
|
-
účte, na ktorom to vzniklo, je 15 zo 16 úloh jedného
|
|
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.
|
|
235
294
|
|
|
236
295
|
## Vývoj
|
|
237
296
|
|
|
@@ -239,16 +298,16 @@ Veci, ktoré stojí za to vedieť - všetky overené proti živému účtu:
|
|
|
239
298
|
bundle exec rspec
|
|
240
299
|
```
|
|
241
300
|
|
|
242
|
-
Testy
|
|
301
|
+
Testy používajú ručne pripravené payloady, nie uložené stránky: tie skutočné
|
|
243
302
|
majú stovky kilobajtov a sú v nich mená cudzích detí.
|
|
244
303
|
|
|
245
304
|
Keby predsa len vznikla VCR kazeta, `spec/support/cassette_scrubber.rb` z nej pred
|
|
246
|
-
zápisom na disk
|
|
247
|
-
v tele odpovede, v URI aj v hlavičkách. Mená
|
|
248
|
-
|
|
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
|
|
249
308
|
tých vo voľnom texte správ. Pseudonym je odvodený z pôvodnej hodnoty, takže ten istý
|
|
250
|
-
človek
|
|
251
|
-
|
|
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
|
|
252
311
|
kmeň mena, takže zmiznú aj tvary ako `Janu` či `Kováčovej`, nielen základný tvar.
|
|
253
312
|
|
|
254
313
|
Overiť sa to dá proti živému účtu:
|
|
@@ -261,17 +320,19 @@ EDUPAGE_LIVE_USERNAME=you@example.com EDUPAGE_LIVE_SCHOOL=yourschool \
|
|
|
261
320
|
Ten test si zoznam mien, ktoré sa v kazete nesmú objaviť, **načíta zo živého účtu**, nie
|
|
262
321
|
z ručne napísaného zoznamu - spadne teda aj vtedy, keď pribudne spolužiak alebo druhá
|
|
263
322
|
škola, ktorú scrubber nevie nájsť. Zároveň kontroluje, že sa kazeta uložila ako čitateľný
|
|
264
|
-
text: Edupage
|
|
265
|
-
|
|
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.
|
|
266
325
|
|
|
267
|
-
CI beží na Ruby 3.2
|
|
268
|
-
|
|
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.
|
|
269
330
|
|
|
270
331
|
### Vydanie
|
|
271
332
|
|
|
272
333
|
Vydanie spustí zmena `Edupage::VERSION` v `lib/edupage/version.rb`. Keď CI na `main`
|
|
273
334
|
prejde, release workflow pushne gem na rubygems.org, vytvorí tag a GitHub release
|
|
274
|
-
a nakoniec
|
|
335
|
+
a nakoniec aktualizuje formulu v tape
|
|
275
336
|
[alhafoudh/homebrew-edupage](https://github.com/alhafoudh/homebrew-edupage) na nový tag
|
|
276
337
|
(cez deploy key v secrete `HOMEBREW_TAP_DEPLOY_KEY`). Tap má vlastné CI, ktoré formulu
|
|
277
338
|
postaví a otestuje na macOS (arm64, Intel) aj Linuxe (x64, arm64). Každý krok je
|
|
@@ -279,6 +340,6 @@ idempotentný, takže zlyhaný beh stačí spustiť znova.
|
|
|
279
340
|
|
|
280
341
|
## Rozsah
|
|
281
342
|
|
|
282
|
-
|
|
343
|
+
Zámerne iba na čítanie. Do Edupage sa nič nezapisuje - žiadne odpovede na správy, žiadne
|
|
283
344
|
označovanie úloh za hotové, žiadne podpisovanie známok. `spec/registry_parity_spec.rb`
|
|
284
345
|
aj samostatný CI job overujú, že sa v `lib/` neobjaví write endpoint.
|
data/lib/edupage/account.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
72
|
+
desc "logout", "Remove the stored password from the OS credential store"
|
|
70
73
|
method_option :username, type: :string
|
|
71
74
|
def logout
|
|
72
|
-
|
|
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(
|
|
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("
|
|
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
|
|
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
|
|
314
|
-
|
|
315
|
-
raise Thor::Error, "
|
|
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
|
|
@@ -525,11 +521,17 @@ module Edupage
|
|
|
525
521
|
"'#{part.gsub("'", %q('\''))}'"
|
|
526
522
|
end
|
|
527
523
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
|
531
532
|
|
|
532
|
-
|
|
533
|
+
stored = credentials.password_source == adapter.source_for(:password)
|
|
534
|
+
"#{adapter.display_name}, #{stored ? "stored" : "nothing stored"} for #{username}"
|
|
533
535
|
end
|
|
534
536
|
|
|
535
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
|
data/lib/edupage/credentials.rb
CHANGED
|
@@ -1,25 +1,39 @@
|
|
|
1
1
|
module Edupage
|
|
2
|
-
# Resolves username / password / school from
|
|
2
|
+
# Resolves username / password / school from credential adapters.
|
|
3
3
|
#
|
|
4
|
-
# ENV
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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
|
-
|
|
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
|
-
@
|
|
17
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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 ||=
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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 ||=
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
@
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
112
|
-
"No password. Run `edupage login
|
|
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
|
|
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
|
data/lib/edupage/version.rb
CHANGED
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.
|
|
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
|
|
@@ -142,8 +156,12 @@ files:
|
|
|
142
156
|
- lib/edupage/client.rb
|
|
143
157
|
- lib/edupage/config.rb
|
|
144
158
|
- lib/edupage/credentials.rb
|
|
145
|
-
- lib/edupage/credentials/
|
|
146
|
-
- lib/edupage/credentials/
|
|
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
|
|
147
165
|
- lib/edupage/errors.rb
|
|
148
166
|
- lib/edupage/model.rb
|
|
149
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
|