Compare commits
82
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c75c5acfe2 | ||
|
|
9dadb276a5 | ||
|
|
24d6add527 | ||
|
|
066451f31c | ||
|
|
65f337bcf4 | ||
|
|
c1741f64f3 | ||
|
|
9f31c85256 | ||
|
|
f8dee01c81 | ||
|
|
021f2b9b07 | ||
|
|
9255a11254 | ||
|
|
581d3c4371 | ||
|
|
7c0f1479b4 | ||
|
|
a99834abf0 | ||
|
|
7ea8303b1a | ||
|
|
a0a7ff63e1 | ||
|
|
3d5ba3f31d | ||
|
|
bf10aa87e8 | ||
|
|
f8c9cfe996 | ||
|
|
466aeac6ca | ||
|
|
5517133e25 | ||
|
|
4dccb9a815 | ||
|
|
c85148d238 | ||
|
|
201e422be6 | ||
|
|
3eedabce43 | ||
|
|
f1536d8b39 | ||
|
|
ce904489d4 | ||
|
|
22ee724c09 | ||
|
|
6b24707c8d | ||
|
|
71a5c15b4c | ||
|
|
e8c3e2f792 | ||
|
|
87e23545d1 | ||
|
|
2b62070557 | ||
|
|
d11610b6c7 | ||
|
|
9cbb909f08 | ||
|
|
2d750a4ce7 | ||
|
|
0a7e2d14e2 | ||
|
|
763a092640 | ||
|
|
ec43d2ae46 | ||
|
|
afd1fe50c4 | ||
|
|
254d339656 | ||
|
|
9b89550766 | ||
|
|
712fdbc97b | ||
|
|
23bbccd2b1 | ||
|
|
d82bb6f468 | ||
|
|
a0ced02345 | ||
|
|
546201c886 | ||
|
|
c853cfadbc | ||
|
|
b1139a43bc | ||
|
|
bae6fc06a6 | ||
|
|
731b267aa1 | ||
|
|
508e869080 | ||
|
|
435d22b71d | ||
|
|
f499b7f2af | ||
|
|
267a13b600 | ||
|
|
b530dcd50d | ||
|
|
068c4db3f8 | ||
|
|
565c086a0d | ||
|
|
dd69dd79ab | ||
|
|
fd0b64850f | ||
|
|
61ad1a87f2 | ||
|
|
7c9c8ff510 | ||
|
|
4b32252d2b | ||
|
|
dc45f2eb35 | ||
|
|
c067d303bb | ||
|
|
0c981b4590 | ||
|
|
f8ff7c394c | ||
|
|
3ade17ce9a | ||
|
|
d0ce26c0a3 | ||
|
|
b2c780b697 | ||
|
|
a217bcc14e | ||
|
|
b69eff7866 | ||
|
|
7737a59850 | ||
|
|
1501f7f0b6 | ||
|
|
a15f0ce24b | ||
|
|
f2c07c83c5 | ||
|
|
2358fe22ab | ||
|
|
6833ab2070 | ||
|
|
b09610bc33 | ||
|
|
211dcccbf3 | ||
|
|
b1b619befa | ||
|
|
af779b0988 | ||
|
|
b4d0198d2a |
@@ -0,0 +1,92 @@
|
||||
# Read-only split mirror: a libs/ruby/warp_engine alkönyvtárat kitükrözi a
|
||||
# engines/warp_engine repóba (fejlesztés itt, a monorepóban történik; a tükör
|
||||
# csak publikálásra való). Tag-elt release (warp_engine-v*) esetén a gem a
|
||||
# Forgejo rubygems registry-be is felmegy.
|
||||
#
|
||||
# Szükséges Woodpecker secret: forge_token — Forgejo access token
|
||||
# repository:write (engines/warp_engine) és package:write joggal.
|
||||
|
||||
when:
|
||||
- event: push
|
||||
branch: master
|
||||
path: "libs/ruby/warp_engine/**"
|
||||
- event: manual
|
||||
- event: tag
|
||||
ref: refs/tags/warp_engine-v*
|
||||
|
||||
clone:
|
||||
git:
|
||||
image: woodpeckerci/plugin-git
|
||||
settings:
|
||||
partial: false
|
||||
depth: 0 # a subtree splithez teljes history kell
|
||||
|
||||
services:
|
||||
- name: mysql
|
||||
image: mysql:8
|
||||
environment:
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
|
||||
MYSQL_DATABASE: warp_engine_test
|
||||
|
||||
steps:
|
||||
# A motor tesztje kapuzza a tobbit: ha bukik, se a tukrozes, se a gem
|
||||
# kiadasa nem fut le. A mirror repoba nincs ertelme CI-t tenni, mert azt a
|
||||
# subtree split minden alkalommal feluliria.
|
||||
test-engine:
|
||||
image: ruby:3.2
|
||||
environment:
|
||||
RAILS_ENV: test
|
||||
DB_HOST: mysql
|
||||
commands:
|
||||
- apt-get update && apt-get install -y --no-install-recommends default-libmysqlclient-dev
|
||||
- cd libs/ruby/warp_engine
|
||||
- bundle install --jobs 4
|
||||
- |
|
||||
echo "==> Varakozas a mysql-re"
|
||||
for i in $(seq 1 60); do
|
||||
if mysqladmin ping -h mysql --silent 2>/dev/null; then break; fi
|
||||
sleep 2
|
||||
done
|
||||
- |
|
||||
echo "==> Teszt adatbazis elokeszitese"
|
||||
bundle exec rake app:db:test:prepare
|
||||
- bundle exec rspec
|
||||
|
||||
split-mirror:
|
||||
image: alpine/git
|
||||
environment:
|
||||
FORGE_TOKEN:
|
||||
from_secret: forge_token
|
||||
commands:
|
||||
- apk add --no-cache git-subtree
|
||||
- git subtree split --prefix=libs/ruby/warp_engine HEAD -b warp-engine-split
|
||||
# a secretbe másolt token végén lehet sortörés — levágjuk
|
||||
- TOKEN="$$(printf '%s' "$${FORGE_TOKEN}" | tr -d '[:space:]')"
|
||||
- git push --force "https://ci:$${TOKEN}@git.teletypegames.org/engines/warp_engine.git" warp-engine-split:master
|
||||
when:
|
||||
- event: push
|
||||
branch: master
|
||||
- event: manual
|
||||
|
||||
publish-gem:
|
||||
image: ruby:3.3-slim
|
||||
environment:
|
||||
FORGE_TOKEN:
|
||||
from_secret: forge_token
|
||||
commands:
|
||||
- cd libs/ruby/warp_engine
|
||||
- gem build warp_engine.gemspec
|
||||
- mkdir -p ~/.gem
|
||||
- TOKEN="$$(printf '%s' "$${FORGE_TOKEN}" | tr -d '[:space:]')"
|
||||
# Nevesített kulcs, nem a hoszt-URL: a RubyGems a credentials fájl
|
||||
# kulcsait normalizálja (a pontokból __ lesz, záró perjelet kap), így egy
|
||||
# URL-kulcs sosem egyezik a --host értékével. Ilyenkor a gem push
|
||||
# bejelentkezni próbál a RubyGems sign_in végpontján, amit a Gitea nem
|
||||
# ismer — ez adta a "404 page not found"-ot.
|
||||
- |
|
||||
printf -- '---\n:gitea: Bearer %s\n' "$${TOKEN}" > ~/.gem/credentials
|
||||
- chmod 600 ~/.gem/credentials
|
||||
- gem push --key gitea --host https://git.teletypegames.org/api/packages/engines/rubygems warp_engine-*.gem
|
||||
when:
|
||||
- event: tag
|
||||
ref: refs/tags/warp_engine-v*
|
||||
@@ -17,12 +17,51 @@ The API is split in two layers:
|
||||
|
||||
- **WarpEngine** (`libs/ruby/warp_engine`) owns the software catalog: models
|
||||
(softwares, releases, release assets, images, platform links, download stats),
|
||||
the CI-callable `/update` endpoint, the public read-only JSON API
|
||||
the CI-callable `/build/*` publishing endpoints, the public read-only JSON API
|
||||
(`/api/software*`, `/api/builds*`, `/api/image`, `/api/download`, `/file/*`)
|
||||
and the catalog ActiveAdmin resources. See its [README](libs/ruby/warp_engine/README.md).
|
||||
- **The host app** (`apps/api`) owns everything TTG-specific: members, events,
|
||||
wiki proxy, RSS feeds, Devise/ActiveAdmin authentication, theming and assets.
|
||||
It consumes WarpEngine as a path gem and mounts it at `/`.
|
||||
the store registry, wiki proxy, RSS feeds, Devise/ActiveAdmin authentication,
|
||||
theming and assets. It consumes WarpEngine as a path gem and mounts it at `/`.
|
||||
|
||||
## The store registry
|
||||
|
||||
`GET /api/stores` lists the stores a client can install from. The desktop
|
||||
graphical client reads it on first run, which is why it is **public**: a client has
|
||||
nobody to log in as.
|
||||
|
||||
```json
|
||||
[
|
||||
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org", "storeRepositoryUrl": null }
|
||||
]
|
||||
```
|
||||
|
||||
A `Store` row has two required fields — `name` and `catalog_url` — plus an
|
||||
**optional** `store_repository_url`, maintained from **ActiveAdmin ▸ 🛒 Stores**;
|
||||
`db/seeds.rb` creates our own.
|
||||
|
||||
**A store does not need a repository.** The store engine's own defaults already
|
||||
cover the host-to-asset mapping, the install modes, the platforms and the
|
||||
behaviour; what they cannot know is identity — a slug, a name and a catalog — and
|
||||
that is what this row carries. With no repository the client derives the slug from
|
||||
the catalog host, writes a small config and installs. Given one, it reads that
|
||||
repository's `config.json` and points it at `catalog_url`, and that file remains the
|
||||
authority on how the store behaves; a repository without a config file is treated
|
||||
as no repository at all.
|
||||
|
||||
Every WarpEngine API response also carries a `WarpEngine-Version` header — the registry
|
||||
above is the host's own endpoint and does not, because it is not part of the engine.
|
||||
|
||||
This lives in the host app **on purpose, not in WarpEngine**. The engine serves
|
||||
one catalog and has no business knowing which stores exist for it; who ships a
|
||||
store for a catalog is a property of the site.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Model | `apps/api/app/models/store.rb` |
|
||||
| Endpoint | `apps/api/app/controllers/api/stores_controller.rb` |
|
||||
| Admin | `apps/api/app/admin/stores.rb` |
|
||||
| Client | [`warp-engine-client`](https://git.teletypegames.org/stores/warp-engine-client) |
|
||||
|
||||
## Development environment
|
||||
|
||||
|
||||
+95
-94
@@ -1,7 +1,7 @@
|
||||
PATH
|
||||
remote: ../libs/ruby/warp_engine
|
||||
specs:
|
||||
warp_engine (0.1.0)
|
||||
warp_engine (0.5.2)
|
||||
apipie-rails
|
||||
blueprinter
|
||||
rails (>= 8.0)
|
||||
@@ -10,31 +10,31 @@ PATH
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
action_text-trix (2.1.18)
|
||||
action_text-trix (2.1.19)
|
||||
railties
|
||||
actioncable (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actioncable (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actionmailbox (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activestorage (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actionmailer (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
actionview (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actionpack (8.1.3.1)
|
||||
actionview (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
@@ -42,21 +42,21 @@ GEM
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.1.3)
|
||||
actiontext (8.1.3.1)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actionpack (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activestorage (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actionview (8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activeadmin (3.5.1)
|
||||
activeadmin (3.5.2)
|
||||
arbre (~> 1.2, >= 1.2.1)
|
||||
csv
|
||||
formtastic (>= 3.1)
|
||||
@@ -68,22 +68,22 @@ GEM
|
||||
ransack (>= 4.0)
|
||||
activeadmin_blaze_theme (1.1.0)
|
||||
activeadmin (>= 2.0, < 4)
|
||||
activejob (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
activejob (8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
activerecord (8.1.3)
|
||||
activemodel (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
activemodel (8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
activerecord (8.1.3.1)
|
||||
activemodel (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
activestorage (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.1.3)
|
||||
activesupport (8.1.3.1)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
@@ -107,18 +107,18 @@ GEM
|
||||
bcrypt (3.1.22)
|
||||
bigdecimal (4.1.2)
|
||||
blueprinter (1.3.0)
|
||||
bootsnap (1.24.3)
|
||||
msgpack (~> 1.2)
|
||||
bootsnap (1.25.0)
|
||||
msgpack (~> 1.5)
|
||||
builder (3.3.0)
|
||||
concurrent-ruby (1.3.6)
|
||||
concurrent-ruby (1.3.8)
|
||||
connection_pool (3.0.2)
|
||||
crass (1.0.6)
|
||||
csv (3.3.5)
|
||||
crass (1.0.7)
|
||||
csv (3.3.6)
|
||||
date (3.5.1)
|
||||
debug (1.11.1)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
devise (5.0.3)
|
||||
devise (5.0.4)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
railties (>= 7.0)
|
||||
@@ -126,7 +126,7 @@ GEM
|
||||
warden (~> 1.2.3)
|
||||
diff-lcs (1.6.2)
|
||||
drb (2.2.3)
|
||||
erb (6.0.4)
|
||||
erb (6.0.7)
|
||||
erubi (1.13.1)
|
||||
factory_bot (6.6.0)
|
||||
activesupport (>= 6.1.0)
|
||||
@@ -138,19 +138,19 @@ GEM
|
||||
formtastic (6.0.0)
|
||||
actionpack (>= 7.2.0)
|
||||
formtastic_i18n (0.7.0)
|
||||
globalid (1.3.0)
|
||||
globalid (1.4.0)
|
||||
activesupport (>= 6.1)
|
||||
has_scope (0.9.0)
|
||||
actionpack (>= 7.0)
|
||||
activesupport (>= 7.0)
|
||||
i18n (1.14.8)
|
||||
i18n (1.15.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
inherited_resources (1.14.0)
|
||||
actionpack (>= 6.0)
|
||||
has_scope (>= 0.6)
|
||||
railties (>= 6.0)
|
||||
responders (>= 2)
|
||||
io-console (0.8.2)
|
||||
io-console (0.9.1)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
@@ -160,7 +160,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.19.5)
|
||||
json (2.21.2)
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
kaminari-actionview (= 1.2.2)
|
||||
@@ -173,27 +173,27 @@ GEM
|
||||
activerecord
|
||||
kaminari-core (= 1.2.2)
|
||||
kaminari-core (1.2.2)
|
||||
language_server-protocol (3.17.0.5)
|
||||
language_server-protocol (3.17.0.6)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
loofah (2.25.1)
|
||||
loofah (2.25.2)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.9.0)
|
||||
mail (2.9.1)
|
||||
logger
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.1.0)
|
||||
marcel (1.2.1)
|
||||
mini_mime (1.1.5)
|
||||
minitest (6.0.6)
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.0)
|
||||
msgpack (1.8.4)
|
||||
mysql2 (0.5.7)
|
||||
bigdecimal
|
||||
net-imap (0.6.4)
|
||||
net-imap (0.6.6)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -203,23 +203,20 @@ GEM
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.3-arm64-darwin)
|
||||
nokogiri (1.19.4-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
orm_adapter (0.5.0)
|
||||
parallel (1.28.0)
|
||||
parser (3.3.11.1)
|
||||
parallel (2.1.0)
|
||||
parser (3.3.12.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pp (0.6.3)
|
||||
pp (0.6.4)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.9.0)
|
||||
psych (5.3.1)
|
||||
date
|
||||
stringio
|
||||
puma (8.0.1)
|
||||
puma (8.0.2)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.6)
|
||||
@@ -230,30 +227,30 @@ GEM
|
||||
rack (>= 1.3)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
rails (8.1.3)
|
||||
actioncable (= 8.1.3)
|
||||
actionmailbox (= 8.1.3)
|
||||
actionmailer (= 8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
actiontext (= 8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activemodel (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
rails (8.1.3.1)
|
||||
actioncable (= 8.1.3.1)
|
||||
actionmailbox (= 8.1.3.1)
|
||||
actionmailer (= 8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
actiontext (= 8.1.3.1)
|
||||
actionview (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activemodel (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activestorage (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.1.3)
|
||||
railties (= 8.1.3.1)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.7.0)
|
||||
loofah (~> 2.25)
|
||||
rails-html-sanitizer (1.7.1)
|
||||
loofah (~> 2.25, >= 2.25.2)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
railties (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
railties (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -266,12 +263,17 @@ GEM
|
||||
activerecord (>= 7.2)
|
||||
activesupport (>= 7.2)
|
||||
i18n
|
||||
rdoc (7.2.0)
|
||||
rbs (4.1.2)
|
||||
logger
|
||||
prism (>= 1.6.0)
|
||||
tsort
|
||||
rdoc (8.0.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
prism (>= 1.6.0)
|
||||
rbs (>= 4.0.0)
|
||||
tsort
|
||||
regexp_parser (2.12.0)
|
||||
reline (0.6.3)
|
||||
reline (0.7.0)
|
||||
io-console (~> 0.5)
|
||||
responders (3.2.0)
|
||||
actionpack (>= 7.0)
|
||||
@@ -296,7 +298,7 @@ GEM
|
||||
rspec-support (3.13.7)
|
||||
rss (0.3.3)
|
||||
rexml
|
||||
rubocop (1.86.1)
|
||||
rubocop (1.89.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
@@ -307,14 +309,14 @@ GEM
|
||||
rubocop-ast (>= 1.49.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.49.1)
|
||||
rubocop-ast (1.50.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.7)
|
||||
rubocop-performance (1.26.1)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
rubocop-rails (2.34.3)
|
||||
rubocop-rails (2.36.0)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
@@ -346,9 +348,8 @@ GEM
|
||||
actionpack (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
sprockets (>= 3.0.0)
|
||||
stringio (3.2.0)
|
||||
thor (1.5.0)
|
||||
tilt (2.7.0)
|
||||
tilt (2.8.0)
|
||||
timeout (0.6.1)
|
||||
tsort (0.2.0)
|
||||
tzinfo (2.0.6)
|
||||
@@ -360,11 +361,11 @@ GEM
|
||||
useragent (0.16.11)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
websocket-driver (0.8.0)
|
||||
websocket-driver (0.8.2)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
zeitwerk (2.7.5)
|
||||
zeitwerk (2.8.3)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-22
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
ActiveAdmin.register Store do
|
||||
permit_params :name, :catalog_url
|
||||
|
||||
menu priority: 5, label: "🛒 Stores"
|
||||
|
||||
index do
|
||||
selectable_column
|
||||
id_column
|
||||
column :name
|
||||
column :catalog_url do |store|
|
||||
link_to store.catalog_url, store.catalog_url, target: "_blank", rel: "noopener"
|
||||
end
|
||||
column :updated_at
|
||||
actions
|
||||
end
|
||||
|
||||
filter :name
|
||||
filter :catalog_url
|
||||
|
||||
show do
|
||||
attributes_table do
|
||||
row :id
|
||||
row :name
|
||||
row :catalog_url do |store|
|
||||
link_to store.catalog_url, store.catalog_url, target: "_blank", rel: "noopener"
|
||||
end
|
||||
row :created_at
|
||||
row :updated_at
|
||||
end
|
||||
para do
|
||||
"Listed by GET /api/stores, which the graphical client reads on first run. A name " \
|
||||
"and a catalog are the whole record: the client carries its own store engine " \
|
||||
"and configures itself from this much, deriving the store's slug from the " \
|
||||
"catalog host."
|
||||
end
|
||||
end
|
||||
|
||||
form do |f|
|
||||
f.inputs do
|
||||
f.input :name, hint: "What the client shows in its store picker"
|
||||
f.input :catalog_url, hint: "Base URL of the WarpEngine catalog, e.g. https://teletypegames.org"
|
||||
end
|
||||
f.actions
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
class Api::StoresController < ApiController
|
||||
resource_description do
|
||||
short "Stores"
|
||||
end
|
||||
|
||||
api :GET, "/api/stores", "List the stores a client can install from"
|
||||
desc <<~DESC
|
||||
The registry the graphical desktop client reads on first run: which catalogs
|
||||
exist. Public on purpose: a client has nobody to log in as.
|
||||
|
||||
A name and a catalog URL are the whole record. How a store behaves is fixed per
|
||||
installed client — it carries its own store engine and knows its own machine — so
|
||||
the registry says what a store *is* and nothing about how it works.
|
||||
DESC
|
||||
returns code: 200, desc: "Array of stores" do
|
||||
property :name, String, desc: "Display name of the store"
|
||||
property :catalogUrl, String, desc: "Base URL of the WarpEngine catalog it serves"
|
||||
|
||||
end
|
||||
def index
|
||||
render json: StoreService.new.index
|
||||
end
|
||||
end
|
||||
@@ -4,7 +4,7 @@ class Api::WikiController < ApiController
|
||||
end
|
||||
|
||||
api :GET, "/api/wiki/pages", "List wiki pages filtered by tag"
|
||||
param :tag, String, required: false, desc: "Filter by tag (blog, howto)"
|
||||
param :tag, String, required: false, desc: "Filter by tag (blog, howto, engine)"
|
||||
param :limit, :number, required: false, desc: "Limit number of results"
|
||||
param :body, String, required: false, desc: "Include body content (1 = yes)"
|
||||
returns code: 200, desc: "Wiki pages response" do
|
||||
@@ -20,6 +20,7 @@ class Api::WikiController < ApiController
|
||||
property :locale, String, desc: "Locale code"
|
||||
property :route, String, desc: "URL slug"
|
||||
property :tags, Array, of: String, desc: "Tags"
|
||||
property :repo, String, desc: "Git repository URL (from page metadata, engines)"
|
||||
property :render, String, desc: "Rendered HTML content"
|
||||
property :content, String, desc: "Raw markdown content"
|
||||
end
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# A store a client can install from: a name and a WarpEngine catalog.
|
||||
#
|
||||
# That is the whole record, and deliberately so. A client takes identity from it — the
|
||||
# name, the catalog, and a slug derived from the catalog host — and everything else from
|
||||
# the store engine it carries. How a store behaves is fixed per installed client, which
|
||||
# knows its own machine; a copy of it here would be a second authority over decisions
|
||||
# the client has already made, including which directories it may delete from.
|
||||
#
|
||||
# This is deliberately not part of WarpEngine. The engine serves one catalog and has no
|
||||
# business knowing which stores exist for it; the registry is a property of this site,
|
||||
# which is what the graphical client asks.
|
||||
class Store < ApplicationRecord
|
||||
URL = %r{\Ahttps?://\S+\z}
|
||||
|
||||
validates :name, presence: true
|
||||
validates :catalog_url, presence: true, format: { with: URL, message: "must be an http(s) URL" }
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
scope :ordered, -> { order(:name) }
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[id name catalog_url created_at updated_at]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
class StoreSerializer < Blueprinter::Base
|
||||
include WarpEngine::TimestampFields
|
||||
|
||||
field :name
|
||||
# camelCase, as the catalog's own payloads use — one convention for a client
|
||||
# that reads both.
|
||||
field(:catalogUrl) { |store| store.catalog_url }
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class StoreService
|
||||
def index
|
||||
StoreSerializer.render_as_hash(Store.ordered)
|
||||
end
|
||||
end
|
||||
@@ -43,4 +43,7 @@ class WikiService
|
||||
rescue StandardError => e
|
||||
{ "tag" => tag, "count" => 0, "pages" => [], "error" => e.message }
|
||||
end
|
||||
|
||||
# Az RSS feedek ezen a néven hívják.
|
||||
alias_method :pages, :index
|
||||
end
|
||||
|
||||
@@ -1,7 +1,36 @@
|
||||
# WarpEngine host-konfiguráció. to_prepare: reload után is újrafut, ezért
|
||||
# értékadás (nem <<), hogy idempotens legyen.
|
||||
# WarpEngine host configuration. to_prepare: re-runs after reloads, hence
|
||||
# assignment (not <<) to stay idempotent.
|
||||
Rails.application.config.to_prepare do
|
||||
WarpEngine.configure do |c|
|
||||
# Owner of the /build/* DB tokens. Switching to :database mode
|
||||
# (c.application_token_source = :database) must wait until CI uses DB
|
||||
# tokens — the flip invalidates UPDATE_SECRET immediately.
|
||||
c.application_token_source = :database
|
||||
c.application_token_owner_class = "AdminUser"
|
||||
|
||||
# Woodpecker configuration extension (/build/config): the served platforms
|
||||
# and their builder images. An image bump is one line here, rolled out to
|
||||
# every repo by the deploy.
|
||||
c.ci_platforms = {
|
||||
"godot" => { builder: "git.teletypegames.org/build/godot-builder:4.7.1" },
|
||||
"phaser" => { builder: "git.teletypegames.org/build/phaser-builder:latest" },
|
||||
"love" => { builder: "git.teletypegames.org/build/love-builder:latest" },
|
||||
"bevy" => { builder: "git.teletypegames.org/build/bevy-builder:latest" },
|
||||
"c64" => { builder: "git.teletypegames.org/build/c64-builder:latest" },
|
||||
"ebitengine" => { builder: "git.teletypegames.org/build/ebitengine-builder:latest" },
|
||||
"tic80" => { builder: "git.teletypegames.org/build/tic80pro:latest" }
|
||||
}
|
||||
# Explicit URL: request.base_url would yield http:// behind the host nginx
|
||||
# (no X-Forwarded-Proto reaches Rails), and the resulting 301 makes the
|
||||
# pipeline's curl steps silently no-op.
|
||||
c.ci_update_server = "https://teletypegames.org"
|
||||
c.ci_extension_public_key_url = "https://ci.teletypegames.org/api/signature/public-key"
|
||||
|
||||
# Woodpecker CI management (repo sync, secret provisioning, pipeline control)
|
||||
c.woodpecker_url = ENV["WOODPECKER_URL"] # e.g. "https://ci.teletypegames.org"
|
||||
c.woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"] # Woodpecker PAT with admin access
|
||||
c.woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"] # forge org (e.g. "games")
|
||||
|
||||
c.image_owners = [
|
||||
{
|
||||
label: "member",
|
||||
|
||||
@@ -7,6 +7,7 @@ Rails.application.routes.draw do
|
||||
get "swagger", to: "swagger#index"
|
||||
get "events", to: "events#index"
|
||||
get "members", to: "members#index"
|
||||
get "stores", to: "stores#index"
|
||||
get "wiki/pages", to: "wiki#index"
|
||||
get "rss/blog", to: "rss#blog"
|
||||
get "rss/releases", to: "rss#releases"
|
||||
@@ -14,7 +15,7 @@ Rails.application.routes.draw do
|
||||
end
|
||||
|
||||
# Utolsó sor: a host route-jai nyernek, a katalógus-útvonalakat
|
||||
# (/api/software*, /api/builds*, /api/image, /api/download, /update, /file/*)
|
||||
# (/api/software*, /api/builds*, /api/image, /api/download, /build/*, /file/*)
|
||||
# az engine adja.
|
||||
mount WarpEngine::Engine => "/"
|
||||
end
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
class CreateStores < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :stores, id: { type: :bigint, unsigned: true },
|
||||
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci",
|
||||
if_not_exists: true do |t|
|
||||
# A store is a WarpEngine catalog plus the repository that configures a
|
||||
# client for it. The registry lives here rather than in the engine: the
|
||||
# engine serves one catalog and knows nothing about who ships stores for it.
|
||||
t.string :name, null: false
|
||||
t.string :catalog_url, null: false
|
||||
t.string :store_repository_url, null: false
|
||||
t.datetime :deleted_at, precision: 3
|
||||
t.timestamps precision: 3
|
||||
|
||||
t.index :deleted_at, name: "idx_stores_deleted_at"
|
||||
t.index :name, name: "idx_stores_name"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
class AllowStoresWithoutARepository < ActiveRecord::Migration[8.1]
|
||||
# A store repository is now optional. The client only ever needed identity from
|
||||
# it — a name, a catalog and a slug — and the engine's own defaults cover
|
||||
# everything else, so a record with a catalog URL is a complete store. A
|
||||
# repository is still honoured when there is one: it stays the authority on how
|
||||
# that store behaves.
|
||||
def change
|
||||
change_column_null :stores, :store_repository_url, true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
class CarryTheStoreConfigInTheRegistry < ActiveRecord::Migration[8.1]
|
||||
# The store's own configuration moves into this record.
|
||||
#
|
||||
# It used to live as a `config.json` in a repository the client fetched over HTTP,
|
||||
# which made a store's behaviour depend on a second thing existing and staying
|
||||
# reachable. The registry already answers what a store *is*; carrying how it behaves
|
||||
# in the same record makes this the one source of truth, and lets a store exist with
|
||||
# no repository at all — which is the ordinary case now that the store engine ships
|
||||
# inside the client.
|
||||
#
|
||||
# Nullable, because a store that configures nothing is still a store: the client
|
||||
# falls back to the engine's built-in defaults, which need only a name and a catalog.
|
||||
def change
|
||||
add_column :stores, :config, :json
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
class AStoreIsANameAndACatalog < ActiveRecord::Migration[8.1]
|
||||
# Both extras go. A store record is a name and a catalog, and nothing else.
|
||||
#
|
||||
# `config` was added earlier today on the idea that the registry should say how each
|
||||
# store behaves. It should not: the configuration is fixed per installed client — the
|
||||
# client carries it and knows its own machine — so a copy on the server was a second
|
||||
# authority over decisions the client had already made correctly, including where it
|
||||
# may delete. Keeping two stores on one machine apart is a subfolder, which the client
|
||||
# derives itself.
|
||||
#
|
||||
# `store_repository_url` goes for the same reason it stopped being read: the store
|
||||
# engines it pointed at do not exist any more, and a URL nobody follows is a URL that
|
||||
# goes stale.
|
||||
def change
|
||||
remove_column :stores, :config, :json
|
||||
remove_column :stores, :store_repository_url, :string
|
||||
end
|
||||
end
|
||||
+69
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_08_19_120000) do
|
||||
create_table "admin_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "deleted_at", precision: 3
|
||||
@@ -27,6 +27,42 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
|
||||
t.index ["reset_password_token"], name: "index_admin_users_on_reset_password_token", unique: true
|
||||
end
|
||||
|
||||
create_table "application_tokens", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", precision: 3
|
||||
t.datetime "deleted_at", precision: 3
|
||||
t.datetime "expires_at", precision: 3
|
||||
t.datetime "last_used_at", precision: 3
|
||||
t.string "name", limit: 128, null: false
|
||||
t.bigint "owner_id", null: false, unsigned: true
|
||||
t.string "owner_type", limit: 128, null: false
|
||||
t.json "scopes"
|
||||
t.string "token_digest", limit: 64, null: false
|
||||
t.string "token_prefix", limit: 12, null: false
|
||||
t.boolean "unrestricted", default: false, null: false
|
||||
t.datetime "updated_at", precision: 3
|
||||
t.index ["deleted_at"], name: "idx_application_tokens_deleted_at"
|
||||
t.index ["owner_type", "owner_id"], name: "idx_application_tokens_owner"
|
||||
t.index ["token_digest"], name: "idx_application_tokens_token_digest", unique: true
|
||||
end
|
||||
|
||||
create_table "device_grants", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "application_token_id", unsigned: true
|
||||
t.datetime "approved_at", precision: 3
|
||||
t.string "client_name", limit: 128
|
||||
t.datetime "created_at", precision: 3
|
||||
t.datetime "denied_at", precision: 3
|
||||
t.string "device_code", limit: 64, null: false
|
||||
t.datetime "expires_at", precision: 3, null: false
|
||||
t.string "issued_token", limit: 64
|
||||
t.bigint "subject_id", unsigned: true
|
||||
t.string "subject_type", limit: 128
|
||||
t.datetime "updated_at", precision: 3
|
||||
t.string "user_code", limit: 16, null: false
|
||||
t.index ["device_code"], name: "idx_device_grants_device_code", unique: true
|
||||
t.index ["expires_at"], name: "idx_device_grants_expires_at"
|
||||
t.index ["user_code"], name: "idx_device_grants_user_code", unique: true
|
||||
end
|
||||
|
||||
create_table "downloads", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", precision: 3
|
||||
t.datetime "deleted_at", precision: 3
|
||||
@@ -178,6 +214,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
|
||||
t.index ["image_id"], name: "index_members_on_image_id"
|
||||
end
|
||||
|
||||
create_table "pipelines", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.boolean "active", default: true, null: false
|
||||
t.datetime "created_at", precision: 3
|
||||
t.datetime "deleted_at", precision: 3
|
||||
t.datetime "last_pipeline_at", precision: 3
|
||||
t.string "last_pipeline_status", limit: 32
|
||||
t.string "platform", limit: 32, null: false
|
||||
t.string "repo_name", limit: 128, null: false
|
||||
t.string "repo_owner", limit: 128, null: false
|
||||
t.bigint "software_id", unsigned: true
|
||||
t.datetime "updated_at", precision: 3
|
||||
t.bigint "woodpecker_repo_id", null: false, unsigned: true
|
||||
t.index ["deleted_at"], name: "idx_pipelines_deleted"
|
||||
t.index ["repo_owner", "repo_name"], name: "idx_pipelines_owner_name", unique: true
|
||||
t.index ["software_id"], name: "idx_pipelines_software"
|
||||
t.index ["woodpecker_repo_id"], name: "idx_pipelines_wp_id", unique: true
|
||||
end
|
||||
|
||||
create_table "platform_links", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", precision: 3
|
||||
t.datetime "deleted_at", precision: 3
|
||||
@@ -232,6 +286,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
|
||||
t.boolean "highlighted", default: false
|
||||
t.string "license", limit: 128
|
||||
t.string "name", limit: 128
|
||||
t.bigint "owner_id", unsigned: true
|
||||
t.string "owner_type", limit: 128
|
||||
t.string "platform", limit: 128
|
||||
t.string "site"
|
||||
t.string "status", limit: 20, default: "development"
|
||||
@@ -240,12 +296,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
|
||||
t.datetime "updated_at", precision: 3
|
||||
t.index ["deleted_at"], name: "idx_softwares_deleted_at"
|
||||
t.index ["name"], name: "idx_softwares_name", unique: true
|
||||
t.index ["owner_type", "owner_id"], name: "idx_softwares_owner"
|
||||
end
|
||||
|
||||
create_table "stores", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.string "catalog_url", null: false
|
||||
t.datetime "created_at", precision: 3, null: false
|
||||
t.datetime "deleted_at", precision: 3
|
||||
t.string "name", null: false
|
||||
t.datetime "updated_at", precision: 3, null: false
|
||||
t.index ["deleted_at"], name: "idx_stores_deleted_at"
|
||||
t.index ["name"], name: "idx_stores_name"
|
||||
end
|
||||
|
||||
add_foreign_key "admin_users", "members"
|
||||
add_foreign_key "downloads", "releases", name: "fk_downloads_release", on_delete: :nullify
|
||||
add_foreign_key "external_links", "softwares", name: "fk_softwares_external_links", on_delete: :cascade
|
||||
add_foreign_key "members", "images"
|
||||
add_foreign_key "pipelines", "softwares", name: "fk_pipelines_software", on_delete: :nullify
|
||||
add_foreign_key "release_assets", "releases", name: "fk_releases_release_assets", on_delete: :cascade
|
||||
add_foreign_key "releases", "softwares", name: "fk_softwares_releases", on_delete: :cascade
|
||||
add_foreign_key "software_images", "images"
|
||||
|
||||
@@ -15,3 +15,10 @@ end
|
||||
m.avatar_filename = attrs[:avatar_filename]
|
||||
end
|
||||
end
|
||||
|
||||
# The store registry the graphical desktop client reads. Our own catalog is the
|
||||
# first record; anyone running this site would add their own the same way, from
|
||||
# the admin panel or here.
|
||||
Store.find_or_create_by!(name: "Teletype Games") do |store|
|
||||
store.catalog_url = ENV.fetch("STORE_CATALOG_URL", "https://teletypegames.org")
|
||||
end
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Api::StoresController, type: :request do
|
||||
describe "GET /api/stores" do
|
||||
it "lists stores without a login, in name order" do
|
||||
create(:store, name: "Zed Games", catalog_url: "https://zed.example")
|
||||
create(:store, name: "Apex Games", catalog_url: "https://apex.example")
|
||||
|
||||
get "/api/stores"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json.map { |s| s["name"] }).to eq(["Apex Games", "Zed Games"])
|
||||
end
|
||||
|
||||
it "answers with the fields a client needs, camelCased" do
|
||||
create(:store)
|
||||
|
||||
get "/api/stores"
|
||||
|
||||
store = JSON.parse(response.body).first
|
||||
# Exactly two fields: a name and a catalog are the whole record, and a client
|
||||
# that starts reading a third would be reading something this site no longer says.
|
||||
expect(store.keys).to contain_exactly("name", "catalogUrl")
|
||||
expect(store["catalogUrl"]).to eq("https://teletypegames.org")
|
||||
end
|
||||
|
||||
it "leaves out soft-deleted stores" do
|
||||
create(:store, name: "Gone").update!(deleted_at: Time.current)
|
||||
|
||||
get "/api/stores"
|
||||
|
||||
expect(JSON.parse(response.body)).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
FactoryBot.define do
|
||||
factory :store do
|
||||
name { "Teletype Games" }
|
||||
catalog_url { "https://teletypegames.org" }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Store, type: :model do
|
||||
it { should validate_presence_of(:name) }
|
||||
it { should validate_presence_of(:catalog_url) }
|
||||
|
||||
it "rejects a catalog url that is not http(s)" do
|
||||
store = build(:store, catalog_url: "git@example.org:thing.git")
|
||||
expect(store).not_to be_valid
|
||||
expect(store.errors[:catalog_url]).to include("must be an http(s) URL")
|
||||
end
|
||||
|
||||
describe ".ordered" do
|
||||
it "lists stores by name" do
|
||||
later = create(:store, name: "Zed Games")
|
||||
first = create(:store, name: "Apex Games")
|
||||
|
||||
expect(Store.ordered).to eq([first, later])
|
||||
end
|
||||
end
|
||||
|
||||
it "hides soft-deleted stores" do
|
||||
store = create(:store)
|
||||
store.update!(deleted_at: Time.current)
|
||||
|
||||
expect(Store.all).to be_empty
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,56 @@
|
||||
require "rails_helper"
|
||||
require "warden/test/helpers"
|
||||
|
||||
# The pipeline admin form could not save at all: the resource never declared
|
||||
# `permit_params`, so ActiveAdmin handed unpermitted attributes to the model and Rails
|
||||
# raised ForbiddenAttributesError on every edit. No model spec could have caught that —
|
||||
# the fault was one layer up — so the check belongs here, where the host's ActiveAdmin
|
||||
# instance actually runs.
|
||||
RSpec.describe "Admin pipelines", type: :request do
|
||||
include Warden::Test::Helpers
|
||||
|
||||
let(:admin) { AdminUser.create!(email: "pipelines-spec@example.org", password: "password123") }
|
||||
let(:software) { create(:software) }
|
||||
let!(:holder) do
|
||||
WarpEngine::Pipeline.create!(woodpecker_repo_id: 990_001, repo_owner: "spec", repo_name: "holder",
|
||||
platform: "tic80", software: software)
|
||||
end
|
||||
let!(:taker) do
|
||||
WarpEngine::Pipeline.create!(woodpecker_repo_id: 990_002, repo_owner: "spec", repo_name: "taker",
|
||||
platform: "tic80")
|
||||
end
|
||||
|
||||
before do
|
||||
Warden.test_mode!
|
||||
login_as(admin, scope: :admin_user)
|
||||
end
|
||||
|
||||
after { Warden.test_reset! }
|
||||
|
||||
# This app keeps forgery protection on in the test environment, and a request spec has
|
||||
# no rendered form to take a token from. The token is not what is under test here, so it
|
||||
# is switched off for the duration and put back afterwards.
|
||||
around do |example|
|
||||
protection = ActionController::Base.allow_forgery_protection
|
||||
ActionController::Base.allow_forgery_protection = false
|
||||
example.run
|
||||
ActionController::Base.allow_forgery_protection = protection
|
||||
end
|
||||
|
||||
it "saves the form" do
|
||||
put "/admin/pipelines/#{taker.id}", params: { pipeline: { platform: "godot" } }
|
||||
|
||||
expect(response).to have_http_status(:found)
|
||||
expect(taker.reload.platform).to eq("godot")
|
||||
end
|
||||
|
||||
it "moves the software off the pipeline that had it" do
|
||||
put "/admin/pipelines/#{taker.id}",
|
||||
params: { pipeline: { platform: "tic80", software_id: software.id } }
|
||||
|
||||
expect(response).to have_http_status(:found)
|
||||
expect(taker.reload.software).to eq(software)
|
||||
expect(holder.reload.software).to be_nil
|
||||
expect(software.reload.pipeline).to eq(taker)
|
||||
end
|
||||
end
|
||||
Generated
+1576
-1190
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ interface RawWikiPage {
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
locale: string
|
||||
repo?: string | null
|
||||
}
|
||||
|
||||
async function fetchPages(
|
||||
@@ -67,6 +68,21 @@ const getBlogPage = async (slug: string): Promise<WikiPageContent | null> => {
|
||||
}
|
||||
}
|
||||
|
||||
const listEnginePages = async (): Promise<WikiPageWithContent[]> => {
|
||||
const pages = await fetchPages('engine', { body: true })
|
||||
return pages.map((p): WikiPageWithContent => ({
|
||||
id: p.id,
|
||||
path: p.path,
|
||||
title: p.title || p.path,
|
||||
description: p.description ?? '',
|
||||
content: p.content ?? '',
|
||||
updatedAt: p.updatedAt,
|
||||
createdAt: p.createdAt,
|
||||
locale: p.locale,
|
||||
repo: p.repo ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
const listHowtoPages = async (): Promise<WikiPage[]> => {
|
||||
const pages = await fetchPages('howto', { limit: 30 })
|
||||
return pages.map((p): WikiPage => ({
|
||||
@@ -81,4 +97,4 @@ const listHowtoPages = async (): Promise<WikiPage[]> => {
|
||||
}
|
||||
|
||||
export { WIKI_BASE }
|
||||
export default { listBlogPages, getBlogPage, listHowtoPages }
|
||||
export default { listBlogPages, getBlogPage, listHowtoPages, listEnginePages }
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 878 KiB |
@@ -4,6 +4,7 @@ export default {
|
||||
catalog: 'Catalog',
|
||||
blog: 'Blog',
|
||||
howtos: 'How-tos',
|
||||
engines: 'Engines',
|
||||
code: 'Code',
|
||||
team: 'Team',
|
||||
contact: 'Contact us',
|
||||
@@ -29,7 +30,7 @@ export default {
|
||||
startsOn: 'Starts on:',
|
||||
upcomingEvents: 'Upcoming Events',
|
||||
featuredGame: 'Featured Game',
|
||||
playInBrowser: '▶ Play in Browser',
|
||||
playInBrowser: 'Play in Browser',
|
||||
viewProjectDetails: 'View Project Details',
|
||||
latestFromYoutube: 'Latest from YouTube',
|
||||
visitChannel: 'Visit Channel ↗',
|
||||
@@ -132,7 +133,7 @@ export default {
|
||||
about: 'About',
|
||||
latestStable: 'Latest Stable',
|
||||
released: 'Released:',
|
||||
playNow: '▶ Play Now',
|
||||
playNow: 'Play Now',
|
||||
download: 'Download',
|
||||
source: 'Source',
|
||||
docs: 'Docs',
|
||||
@@ -159,6 +160,7 @@ export default {
|
||||
win_x64: 'Windows (64-bit)',
|
||||
linux_x86: 'Linux (32-bit)',
|
||||
linux_x64: 'Linux (64-bit)',
|
||||
linux_arm64: 'Linux (ARM 64-bit)',
|
||||
mac_x64: 'macOS (Intel)',
|
||||
mac_arm64: 'macOS (Apple Silicon)',
|
||||
mac_universal: 'macOS',
|
||||
@@ -168,6 +170,17 @@ export default {
|
||||
title: 'Our Team',
|
||||
subtitle: 'Meet the brilliant minds behind Teletype Games.',
|
||||
},
|
||||
engines: {
|
||||
badge: 'In-house Tech',
|
||||
titleLead: 'Our',
|
||||
titleAccent: 'Engines',
|
||||
subtitle: 'The engines and frameworks we build, maintain and ship our games and services on.',
|
||||
errorTitle: 'Failed to connect to Wiki',
|
||||
noPagesTitle: 'No engines found',
|
||||
noPagesDesc: "It seems like there aren't any engine pages available on the wiki at the moment.",
|
||||
explore: 'Explore',
|
||||
openWiki: 'Open in Wiki',
|
||||
},
|
||||
howtos: {
|
||||
badge: 'Knowledge Base',
|
||||
title: 'Tech HowTo Center',
|
||||
@@ -185,6 +198,37 @@ export default {
|
||||
title: 'Build Matrix',
|
||||
subtitle: 'Which engines build for which platforms.',
|
||||
},
|
||||
stores: {
|
||||
title: 'Stores',
|
||||
subtitle: 'Our games on your own machine, kept up to date.',
|
||||
viewClient: 'WarpEngine Client',
|
||||
viewOthers: 'Others',
|
||||
|
||||
clientDesc: 'The app: the catalog as a grid of cards, one click to install a game into your application menu, one to play it. It sets the store up itself on first run, and on Windows it is the way in.',
|
||||
clientDownload: 'Download',
|
||||
clientPlatforms: 'Linux, macOS and Windows · needs Python 3',
|
||||
clientShotAlt: 'The WarpEngine Client window: the store picker and category filters on the left, and the catalog as a grid of game cards with install and play buttons.',
|
||||
|
||||
docs: 'Documentation',
|
||||
repo: 'Git repository',
|
||||
installTitle: 'Install',
|
||||
installThen: 'Then',
|
||||
useTitle: 'Use it',
|
||||
uninstallTitle: 'Remove it',
|
||||
platformsTitle: 'What gets installed',
|
||||
copy: 'Copy',
|
||||
|
||||
batocera: {
|
||||
name: 'Batocera',
|
||||
site: 'Batocera project',
|
||||
tagline: 'The plug-and-play retro distribution',
|
||||
},
|
||||
retroarch: {
|
||||
name: 'RetroArch',
|
||||
site: 'RetroArch',
|
||||
tagline: 'Anything that runs RetroArch',
|
||||
},
|
||||
},
|
||||
code: {
|
||||
title: 'Codebase',
|
||||
subtitle: 'Explore our collection of open-source projects, study our source code, and contribute to our independent game development tools.',
|
||||
|
||||
@@ -4,6 +4,7 @@ export default {
|
||||
catalog: 'Katalógus',
|
||||
blog: 'Blog',
|
||||
howtos: 'Hogyan csináld',
|
||||
engines: 'Motorok',
|
||||
code: 'Kód',
|
||||
team: 'Csapat',
|
||||
contact: 'Kapcsolat',
|
||||
@@ -29,7 +30,7 @@ export default {
|
||||
startsOn: 'Kezdete:',
|
||||
upcomingEvents: 'Közelgő események',
|
||||
featuredGame: 'Kiemelt játék',
|
||||
playInBrowser: '▶ Játék böngészőben',
|
||||
playInBrowser: 'Játék böngészőben',
|
||||
viewProjectDetails: 'Projekt részletei',
|
||||
latestFromYoutube: 'Legújabb YouTube-ról',
|
||||
visitChannel: 'Csatorna megtekintése ↗',
|
||||
@@ -119,7 +120,7 @@ export default {
|
||||
},
|
||||
author: 'Szerző:',
|
||||
platform: 'Platform:',
|
||||
play: '▶ Játék',
|
||||
play: 'Játék',
|
||||
moreInfo: 'Részletek',
|
||||
downloads: 'letöltés',
|
||||
},
|
||||
@@ -132,7 +133,7 @@ export default {
|
||||
about: 'Leírás',
|
||||
latestStable: 'Legújabb stabil',
|
||||
released: 'Kiadva:',
|
||||
playNow: '▶ Játék most',
|
||||
playNow: 'Játék most',
|
||||
download: 'Letöltés',
|
||||
source: 'Forráskód',
|
||||
docs: 'Dokumentáció',
|
||||
@@ -143,7 +144,7 @@ export default {
|
||||
notFound: 'A játék nem található',
|
||||
failedToLoad: 'Nem sikerült betölteni:',
|
||||
loading: 'Betöltés...',
|
||||
play: '▶ Játék',
|
||||
play: 'Játék',
|
||||
downloads: 'letöltés',
|
||||
links: 'Linkek',
|
||||
platformLinks: 'Platform linkek',
|
||||
@@ -159,6 +160,7 @@ export default {
|
||||
win_x64: 'Windows (64 bit)',
|
||||
linux_x86: 'Linux (32 bit)',
|
||||
linux_x64: 'Linux (64 bit)',
|
||||
linux_arm64: 'Linux (ARM 64 bit)',
|
||||
mac_x64: 'macOS (Intel)',
|
||||
mac_arm64: 'macOS (Apple Silicon)',
|
||||
mac_universal: 'macOS',
|
||||
@@ -168,6 +170,17 @@ export default {
|
||||
title: 'Csapatunk',
|
||||
subtitle: 'Ismerd meg a Teletype Games mögött álló zseniális elméket.',
|
||||
},
|
||||
engines: {
|
||||
badge: 'Saját technológia',
|
||||
titleLead: 'Saját',
|
||||
titleAccent: 'motorjaink',
|
||||
subtitle: 'Az általunk épített és karbantartott motorok és keretrendszerek, amelyekre a játékaink és szolgáltatásaink épülnek.',
|
||||
errorTitle: 'Nem sikerült csatlakozni a Wikihez',
|
||||
noPagesTitle: 'Nem találhatók motorok',
|
||||
noPagesDesc: 'Úgy tűnik, jelenleg nincsenek motoroldalak a wikin.',
|
||||
explore: 'Felfedezés',
|
||||
openWiki: 'Megnyitás a Wikiben',
|
||||
},
|
||||
howtos: {
|
||||
badge: 'Tudásbázis',
|
||||
title: 'Tech HowTo Központ',
|
||||
@@ -185,6 +198,37 @@ export default {
|
||||
title: 'Build mátrix',
|
||||
subtitle: 'Melyik engine melyik platformra fordít.',
|
||||
},
|
||||
stores: {
|
||||
title: 'Store-ok',
|
||||
subtitle: 'A játékaink a saját gépeden, mindig frissen.',
|
||||
viewClient: 'WarpEngine Client',
|
||||
viewOthers: 'Egyéb',
|
||||
|
||||
clientDesc: 'Az alkalmazás: a katalógus kártyákban, egy kattintás a telepítés az alkalmazásmenübe, egy az indítás. Első indításkor magát a store-t is beállítja, Windowson pedig ez az út.',
|
||||
clientDownload: 'Letöltés',
|
||||
clientPlatforms: 'Linux, macOS és Windows · Python 3 kell hozzá',
|
||||
clientShotAlt: 'A WarpEngine Client ablaka: balra a store-választó és a kategóriaszűrők, jobbra a katalógus játékkártyákban, telepítés és indítás gombokkal.',
|
||||
|
||||
docs: 'Dokumentáció',
|
||||
repo: 'Git repó',
|
||||
installTitle: 'Telepítés',
|
||||
installThen: 'Utána',
|
||||
useTitle: 'Használat',
|
||||
uninstallTitle: 'Eltávolítás',
|
||||
platformsTitle: 'Mi kerül fel',
|
||||
copy: 'Másolás',
|
||||
|
||||
batocera: {
|
||||
name: 'Batocera',
|
||||
site: 'Batocera projekt',
|
||||
tagline: 'A dugd-be-és-megy retro disztró',
|
||||
},
|
||||
retroarch: {
|
||||
name: 'RetroArch',
|
||||
site: 'RetroArch',
|
||||
tagline: 'Bármi, amin fut a RetroArch',
|
||||
},
|
||||
},
|
||||
code: {
|
||||
title: 'Kódbázis',
|
||||
subtitle: 'Fedezd fel nyílt forráskódú projektek gyűjteményét, tanulmányozd forráskódunkat, és járulj hozzá független játékfejlesztő eszközeinkhez.',
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<RouterLink to="/catalog" class="nav-link"><i class="fa-solid fa-gamepad nav-icon"></i>{{ t('nav.catalog') }}</RouterLink>
|
||||
<RouterLink to="/blog" class="nav-link"><i class="fa-solid fa-newspaper nav-icon"></i>{{ t('nav.blog') }}</RouterLink>
|
||||
<RouterLink to="/howtos" class="nav-link"><i class="fa-solid fa-lightbulb nav-icon"></i>{{ t('nav.howtos') }}</RouterLink>
|
||||
<RouterLink to="/engines" class="nav-link"><i class="fa-solid fa-cubes nav-icon"></i>{{ t('nav.engines') }}</RouterLink>
|
||||
<RouterLink to="/code" class="nav-link"><i class="fa-solid fa-code nav-icon"></i>{{ t('nav.code') }}</RouterLink>
|
||||
<RouterLink to="/team" class="nav-link"><i class="fa-solid fa-users nav-icon"></i>{{ t('nav.team') }}</RouterLink>
|
||||
<RouterLink to="/contact" class="nav-link"><i class="fa-solid fa-envelope nav-icon"></i>{{ t('nav.contact') }}</RouterLink>
|
||||
@@ -33,6 +34,7 @@
|
||||
<RouterLink to="/catalog" class="nav-mobile-link"><i class="fa-solid fa-gamepad nav-icon"></i>{{ t('nav.catalog') }}</RouterLink>
|
||||
<RouterLink to="/blog" class="nav-mobile-link"><i class="fa-solid fa-newspaper nav-icon"></i>{{ t('nav.blog') }}</RouterLink>
|
||||
<RouterLink to="/howtos" class="nav-mobile-link"><i class="fa-solid fa-lightbulb nav-icon"></i>{{ t('nav.howtos') }}</RouterLink>
|
||||
<RouterLink to="/engines" class="nav-mobile-link"><i class="fa-solid fa-cubes nav-icon"></i>{{ t('nav.engines') }}</RouterLink>
|
||||
<RouterLink to="/code" class="nav-mobile-link"><i class="fa-solid fa-code nav-icon"></i>{{ t('nav.code') }}</RouterLink>
|
||||
<RouterLink to="/team" class="nav-mobile-link"><i class="fa-solid fa-users nav-icon"></i>{{ t('nav.team') }}</RouterLink>
|
||||
<RouterLink to="/contact" class="nav-mobile-link"><i class="fa-solid fa-envelope nav-icon"></i>{{ t('nav.contact') }}</RouterLink>
|
||||
@@ -114,13 +116,13 @@ function switchLocale(lang: string) {
|
||||
@apply text-xl font-bold;
|
||||
}
|
||||
.nav-desktop {
|
||||
@apply hidden md:flex items-center space-x-4;
|
||||
@apply hidden lg:flex items-center space-x-1;
|
||||
}
|
||||
.nav-link {
|
||||
@apply p-2 hover:text-purple-300 transition-colors duration-200;
|
||||
@apply px-2.5 py-2 text-sm hover:text-purple-300 transition-colors duration-200 whitespace-nowrap;
|
||||
}
|
||||
.nav-link-admin {
|
||||
@apply p-2 hover:text-yellow-300 transition-colors duration-200;
|
||||
@apply px-2.5 py-2 text-sm hover:text-yellow-300 transition-colors duration-200 whitespace-nowrap;
|
||||
}
|
||||
.locale-switcher {
|
||||
@apply flex items-center gap-1 text-sm font-bold ml-2 border-l border-gray-600 pl-4;
|
||||
@@ -131,14 +133,19 @@ function switchLocale(lang: string) {
|
||||
.locale-separator {
|
||||
@apply text-gray-600;
|
||||
}
|
||||
/* Az ikonok viszik a legtöbb helyet a sok menüpont mellett — desktopon csak
|
||||
extra széles kijelzőn jelennek meg, a mobil menüben mindig. */
|
||||
.nav-desktop .nav-icon {
|
||||
@apply hidden xl:inline-block;
|
||||
}
|
||||
.nav-icon {
|
||||
@apply mr-1.5 text-xs opacity-70;
|
||||
}
|
||||
.hamburger-btn {
|
||||
@apply md:hidden p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 rounded-md;
|
||||
@apply lg:hidden p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 rounded-md;
|
||||
}
|
||||
.nav-mobile {
|
||||
@apply md:hidden absolute top-full left-0 w-full bg-gray-800 flex flex-col items-center py-4 space-y-2 z-50;
|
||||
@apply lg:hidden absolute top-full left-0 w-full bg-gray-800 flex flex-col items-center py-4 space-y-2 z-50;
|
||||
}
|
||||
.nav-mobile-link {
|
||||
@apply block p-2 w-full text-center hover:bg-gray-700 transition-colors duration-200;
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface WikiPage {
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
locale: string
|
||||
repo?: string | null
|
||||
}
|
||||
|
||||
export interface WikiPageWithContent extends WikiPage {
|
||||
|
||||
@@ -120,6 +120,7 @@ const columnGroups = [
|
||||
color: 'group-amber',
|
||||
kinds: [
|
||||
{ kind: 'linux_x64', sub: '64 bit' },
|
||||
{ kind: 'linux_arm64', sub: 'ARM 64 bit' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -66,6 +66,10 @@
|
||||
<RouterLink to="/builds" class="builds-link">
|
||||
<i class="fa-solid fa-table-cells"></i> {{ t('builds.title') }}
|
||||
</RouterLink>
|
||||
<span class="builds-link-sep">|</span>
|
||||
<RouterLink to="/stores" class="builds-link">
|
||||
<i class="fa-solid fa-gamepad"></i> {{ t('stores.title') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -162,9 +166,12 @@ onMounted(() => store.fetch())
|
||||
@apply text-xs text-gray-400 font-medium ml-1;
|
||||
}
|
||||
.builds-link-row {
|
||||
@apply text-center mt-10;
|
||||
@apply flex flex-wrap items-center justify-center gap-3 mt-10;
|
||||
}
|
||||
.builds-link {
|
||||
@apply text-sm text-gray-400 hover:text-purple-600 font-medium transition-colors;
|
||||
}
|
||||
.builds-link-sep {
|
||||
@apply text-gray-200 text-sm;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
<span class="latest-release-badge">{{ t('catalogShow.latestStable') }}</span>
|
||||
</div>
|
||||
<h2 class="latest-release-version">{{ latestStable.version }}</h2>
|
||||
<p class="latest-release-date"><i class="fa-solid fa-calendar mr-1"></i>{{ t('catalogShow.released') }} {{ formatDateTime(latestStable.UpdatedAt) }}</p>
|
||||
<p class="latest-release-date"><i class="fa-solid fa-calendar mr-1"></i>{{ t('catalogShow.released') }} {{ formatDateTime(latestStable.updatedAt) }}</p>
|
||||
</div>
|
||||
|
||||
<a v-if="latestStable.htmlFolderPath" :href="latestStable.htmlFolderPath" target="_blank" class="latest-release-play-btn"><i class="fa-solid fa-play mr-2"></i>{{ t('catalogShow.playNow') }}</a>
|
||||
@@ -155,7 +155,7 @@
|
||||
</div>
|
||||
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="release-play-link">{{ t('catalogShow.play') }}</a>
|
||||
</td>
|
||||
<td class="release-date-col">{{ formatDateTime(release.UpdatedAt) }}</td>
|
||||
<td class="release-date-col">{{ formatDateTime(release.updatedAt) }}</td>
|
||||
</tr>
|
||||
<tr v-if="tableAssets(release).length">
|
||||
<td colspan="2" class="px-8 pb-4 pt-0">
|
||||
@@ -185,7 +185,7 @@
|
||||
<div class="dev-release-version">{{ release.version }}</div>
|
||||
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="release-play-link">{{ t('catalogShow.play') }}</a>
|
||||
</td>
|
||||
<td class="release-date-col">{{ formatDateTime(release.UpdatedAt) }}</td>
|
||||
<td class="release-date-col">{{ formatDateTime(release.updatedAt) }}</td>
|
||||
</tr>
|
||||
<tr v-if="tableAssets(release).length" class="bg-yellow-50/10">
|
||||
<td colspan="2" class="px-8 pb-4 pt-0">
|
||||
@@ -218,7 +218,7 @@
|
||||
<div class="mobile-release-header">
|
||||
<div>
|
||||
<div class="mobile-release-version">{{ release.version }}</div>
|
||||
<div class="mobile-release-date">{{ formatDateTime(release.UpdatedAt) }}</div>
|
||||
<div class="mobile-release-date">{{ formatDateTime(release.updatedAt) }}</div>
|
||||
</div>
|
||||
<span v-if="release.version.startsWith('dev-')" class="dev-badge">Dev</span>
|
||||
</div>
|
||||
@@ -314,7 +314,7 @@ const downloadUrl = (path: string) =>
|
||||
// az asset-táblázat sorai és sorrendjük; a html nem letöltés, azt a Play gomb viszi
|
||||
const TABLE_KIND_ORDER = [
|
||||
'cartridge', 'source', 'docs',
|
||||
'win_x64', 'win_x86', 'linux_x64', 'linux_x86',
|
||||
'win_x64', 'win_x86', 'linux_x64', 'linux_arm64', 'linux_x86',
|
||||
'mac_universal', 'mac_arm64', 'mac_x64',
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div class="engines-container">
|
||||
<header class="hero-section-slate">
|
||||
<div class="engines-header-decor">
|
||||
<div class="hero-decor-blob -top-24 -left-24 h-96 w-96 bg-emerald-600"></div>
|
||||
<div class="hero-decor-blob -bottom-24 -right-24 h-96 w-96 bg-teal-600"></div>
|
||||
</div>
|
||||
|
||||
<div class="hero-container">
|
||||
<div class="hero-badge">{{ t('engines.badge') }}</div>
|
||||
<h1 class="hero-title">
|
||||
{{ t('engines.titleLead') }} <span class="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-300">{{ t('engines.titleAccent') }}</span>
|
||||
</h1>
|
||||
<p class="hero-subtitle mb-10">{{ t('engines.subtitle') }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="engines-main">
|
||||
<div v-if="error" class="error-banner" role="alert">
|
||||
<i class="fa-solid fa-triangle-exclamation text-2xl text-yellow-500"></i>
|
||||
<div>
|
||||
<h3 class="error-banner-title">{{ t('engines.errorTitle') }}</h3>
|
||||
<p class="error-banner-desc">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SkeletonCard v-else-if="loading" :count="3" />
|
||||
|
||||
<div v-else-if="enginePages.length === 0" class="empty-state">
|
||||
<div class="empty-state-icon"><i class="fa-solid fa-inbox"></i></div>
|
||||
<h2 class="empty-state-title">{{ t('engines.noPagesTitle') }}</h2>
|
||||
<p class="empty-state-desc">{{ t('engines.noPagesDesc') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="engine-list">
|
||||
<article v-for="({ page, digest }, index) in cards" :key="page.id" class="engine-card group">
|
||||
<div class="engine-card-index">{{ String(index + 1).padStart(2, '0') }}</div>
|
||||
|
||||
<div class="engine-card-body">
|
||||
<h2 class="engine-card-title">
|
||||
<a :href="exploreUrl(page)" target="_blank" rel="noopener">{{ page.title }}</a>
|
||||
</h2>
|
||||
|
||||
<p v-if="page.description" class="engine-card-desc">{{ page.description }}</p>
|
||||
<p v-if="digest.intro" class="engine-card-preview">{{ digest.intro }}</p>
|
||||
<p v-else-if="page.content" class="engine-card-preview">{{ getCleanPreview(page.content) }}</p>
|
||||
|
||||
<section v-if="digest.highlights.length" class="engine-highlights">
|
||||
<h3 v-if="digest.highlightsTitle" class="engine-highlights-title">{{ digest.highlightsTitle }}</h3>
|
||||
<ul class="engine-highlights-grid">
|
||||
<li v-for="(item, i) in digest.highlights" :key="i" class="engine-highlight">
|
||||
<span class="engine-highlight-icon"><i class="fa-solid fa-check"></i></span>
|
||||
<p class="engine-highlight-body">
|
||||
<strong v-if="item.title" class="engine-highlight-lead">{{ item.title }}</strong>
|
||||
<template v-if="item.title && item.text"> — </template>{{ item.text }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="engine-card-actions">
|
||||
<a :href="exploreUrl(page)" target="_blank" rel="noopener" class="engine-explore-btn">
|
||||
<i class="fa-solid fa-code-branch text-sm"></i>
|
||||
{{ t('engines.explore') }}
|
||||
<i class="fa-solid fa-arrow-right text-sm"></i>
|
||||
</a>
|
||||
<a :href="`${WIKI_BASE}/${page.path}`" target="_blank" rel="noopener" class="engine-wiki-link">
|
||||
<i class="fa-solid fa-book mr-1"></i>{{ t('engines.openWiki') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { WIKI_BASE } from '../../api/wiki.api'
|
||||
import { useEnginesStore, getEngineDigest } from '../../stores/engines.store'
|
||||
import type { WikiPageWithContent } from '../../lib/interfaces/wiki.interface'
|
||||
import SkeletonCard from '../../components/SkeletonCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const store = useEnginesStore()
|
||||
const { pages: enginePages, loading, error } = storeToRefs(store)
|
||||
const { getCleanPreview } = store
|
||||
|
||||
const cards = computed(() =>
|
||||
enginePages.value.map((page) => ({ page, digest: getEngineDigest(page.content) })),
|
||||
)
|
||||
|
||||
// Explore points at the engine's git repository (from wiki metadata);
|
||||
// pages without one fall back to their wiki page.
|
||||
const exploreUrl = (page: WikiPageWithContent): string =>
|
||||
page.repo || `${WIKI_BASE}/${page.path}`
|
||||
|
||||
onMounted(() => store.fetch())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.engines-container {
|
||||
@apply bg-slate-950 min-h-screen pb-24;
|
||||
}
|
||||
.engines-header-decor {
|
||||
@apply absolute inset-0 opacity-30;
|
||||
}
|
||||
.engines-main {
|
||||
@apply max-w-5xl mx-auto px-4 md:px-8 mt-16 relative z-10;
|
||||
}
|
||||
.banner-base {
|
||||
@apply max-w-7xl mx-auto border-l-4 p-6 rounded-r-xl shadow-lg mb-12 flex items-start gap-4;
|
||||
}
|
||||
.error-banner { @apply banner-base bg-red-50 border-red-500; }
|
||||
.error-banner-title { @apply text-red-800 font-bold text-lg; }
|
||||
.error-banner-desc { @apply text-red-700 mt-1; }
|
||||
.empty-state {
|
||||
@apply max-w-7xl mx-auto bg-slate-900 rounded-2xl shadow-xl p-12 text-center border border-slate-800;
|
||||
}
|
||||
.empty-state-icon { @apply text-6xl mb-4; }
|
||||
.empty-state-title { @apply text-2xl font-bold text-white mb-2; }
|
||||
.empty-state-desc { @apply text-slate-400 max-w-md mx-auto; }
|
||||
|
||||
/* Few engines, so every one of them gets a full-width, poster-like card. */
|
||||
.engine-list {
|
||||
@apply flex flex-col gap-10;
|
||||
}
|
||||
.engine-card {
|
||||
@apply relative overflow-hidden bg-slate-900 rounded-3xl border border-slate-800 border-l-4 border-l-emerald-500 shadow-2xl transition-all hover:border-l-teal-300 hover:-translate-y-1;
|
||||
}
|
||||
.engine-card-index {
|
||||
@apply absolute -top-6 right-4 text-[9rem] leading-none font-black text-slate-800/60 select-none pointer-events-none transition-colors;
|
||||
}
|
||||
.engine-card:hover .engine-card-index {
|
||||
@apply text-slate-800;
|
||||
}
|
||||
.engine-card-body {
|
||||
@apply relative p-8 md:p-12;
|
||||
}
|
||||
.engine-card-title {
|
||||
@apply text-3xl md:text-5xl font-black text-white mb-4 leading-tight;
|
||||
}
|
||||
.engine-card-title a {
|
||||
@apply hover:text-emerald-400 transition-colors;
|
||||
}
|
||||
.engine-card-desc {
|
||||
@apply text-lg md:text-xl text-emerald-100/90 font-semibold mb-3 leading-relaxed max-w-3xl;
|
||||
}
|
||||
.engine-card-preview {
|
||||
@apply text-base text-slate-400 leading-relaxed mb-8 max-w-3xl;
|
||||
}
|
||||
.engine-highlights {
|
||||
@apply mb-10;
|
||||
}
|
||||
.engine-highlights-title {
|
||||
@apply text-xs font-bold uppercase tracking-[0.2em] text-emerald-400 mb-5;
|
||||
}
|
||||
.engine-highlights-grid {
|
||||
@apply grid md:grid-cols-2 gap-x-10 gap-y-4 max-w-4xl;
|
||||
}
|
||||
.engine-highlight {
|
||||
@apply flex items-start gap-3;
|
||||
}
|
||||
.engine-highlight-icon {
|
||||
@apply mt-1 flex h-5 w-5 flex-none items-center justify-center rounded-md bg-emerald-500/15 text-emerald-400 text-[10px];
|
||||
}
|
||||
.engine-highlight-body {
|
||||
@apply text-sm text-slate-400 leading-relaxed;
|
||||
}
|
||||
.engine-highlight-lead {
|
||||
@apply text-white font-semibold;
|
||||
}
|
||||
.engine-card-actions {
|
||||
@apply flex flex-wrap items-center gap-6;
|
||||
}
|
||||
.engine-explore-btn {
|
||||
@apply inline-flex items-center gap-2 bg-emerald-500 text-slate-950 px-6 py-3 rounded-xl font-bold hover:bg-emerald-400 transition-all shadow-lg shadow-emerald-900/40 group-hover:translate-x-1;
|
||||
}
|
||||
.engine-wiki-link {
|
||||
@apply text-slate-400 font-semibold hover:text-white transition-colors;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="cb">
|
||||
<pre><code>{{ command }}</code></pre>
|
||||
<button class="cb-copy" :title="t('stores.copy')" @click="copy">
|
||||
<i :class="copied ? 'fa-solid fa-check' : 'fa-regular fa-copy'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// The page shows five of these — install, restart, CLI, uninstall — so the
|
||||
// block and its copy button live in one place rather than being repeated.
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { command } = defineProps<{ command: string }>()
|
||||
const { t } = useI18n()
|
||||
const copied = ref(false)
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch {
|
||||
// Clipboard API needs a secure context — the command stays selectable anyway.
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cb {
|
||||
@apply relative block bg-gray-900 text-gray-100 rounded-xl overflow-x-auto;
|
||||
}
|
||||
/* Explicitly margin-free: a `pre` carries a browser margin of its own, and the block is
|
||||
spaced by whatever contains it — otherwise what follows ends up against it. */
|
||||
.cb pre {
|
||||
@apply m-0 p-4 pr-14 font-mono text-sm leading-relaxed;
|
||||
}
|
||||
.cb-copy {
|
||||
@apply absolute top-2 right-2 w-9 h-9 rounded-lg bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white transition-colors;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,343 @@
|
||||
<template>
|
||||
<header class="hero-section-gradient from-emerald-700 to-teal-800 py-10">
|
||||
<div class="hero-container">
|
||||
<h1 class="hero-title">{{ t('stores.title') }}</h1>
|
||||
<p class="hero-subtitle text-emerald-50">{{ t('stores.subtitle') }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-container py-8 px-4 mt-0">
|
||||
<!-- Two ways in, and almost everybody wants the first one. A thin switcher rather
|
||||
than a page of prose: whoever needs the detail follows a link to the wiki. -->
|
||||
<nav class="st-views" role="tablist" :aria-label="t('stores.title')">
|
||||
<button
|
||||
class="st-view" :class="{ 'st-view-active': view === 'client' }"
|
||||
role="tab" :aria-selected="view === 'client'" @click="selectView('client')"
|
||||
>
|
||||
<i class="fa-solid fa-window-maximize"></i> {{ t('stores.viewClient') }}
|
||||
</button>
|
||||
<button
|
||||
class="st-view" :class="{ 'st-view-active': view === 'others' }"
|
||||
role="tab" :aria-selected="view === 'others'" @click="selectView('others')"
|
||||
>
|
||||
<i class="fa-solid fa-terminal"></i> {{ t('stores.viewOthers') }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section v-if="view === 'client'" class="st-section st-app">
|
||||
<div class="st-app-grid">
|
||||
<div>
|
||||
<p class="st-app-lead">{{ t('stores.clientDesc') }}</p>
|
||||
<div class="st-links">
|
||||
<a
|
||||
:href="client.releasesUrl" target="_blank" rel="noopener noreferrer"
|
||||
class="st-link st-link-cta"
|
||||
>
|
||||
<i class="fa-solid fa-download"></i> {{ t('stores.clientDownload') }}
|
||||
</a>
|
||||
<a :href="client.wikiUrl" target="_blank" rel="noopener noreferrer" class="st-link st-link-indigo">
|
||||
<i class="fa-solid fa-book"></i> {{ t('stores.docs') }}
|
||||
</a>
|
||||
<a :href="client.repoUrl" target="_blank" rel="noopener noreferrer" class="st-link st-link-dark">
|
||||
<i class="fa-solid fa-code-branch"></i> {{ t('stores.repo') }}
|
||||
</a>
|
||||
</div>
|
||||
<p class="st-app-platforms">
|
||||
<i class="fa-brands fa-linux"></i> <i class="fa-brands fa-apple"></i>
|
||||
<i class="fa-brands fa-windows"></i> {{ t('stores.clientPlatforms') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Eager, with its dimensions given: it sits in the first screen, so lazy
|
||||
loading it would only buy a reflow. -->
|
||||
<figure class="st-app-shot">
|
||||
<img :src="clientScreenshot" :alt="t('stores.clientShotAlt')" width="2304" height="1664">
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template v-else>
|
||||
<div class="st-devices" role="tablist" :aria-label="t('stores.viewOthers')">
|
||||
<button
|
||||
v-for="d in devices"
|
||||
:key="d.id"
|
||||
class="st-device"
|
||||
:class="{ 'st-device-active': d.id === device }"
|
||||
role="tab"
|
||||
:aria-selected="d.id === device"
|
||||
@click="select(d.id)"
|
||||
>
|
||||
<i :class="d.icon" class="st-device-icon"></i>
|
||||
<span class="st-device-name">{{ t(`stores.${d.id}.name`) }}</span>
|
||||
<span class="st-device-tagline">{{ t(`stores.${d.id}.tagline`) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="st-section">
|
||||
<!-- The spacing lives on this one container. Margins on the labels and none on
|
||||
the button row is what let the buttons sit flush against the last block. -->
|
||||
<div class="st-cmds">
|
||||
<p class="st-cmd-label">{{ t('stores.installTitle') }}</p>
|
||||
<CommandBlock :command="current.installCmd" />
|
||||
<template v-if="current.afterInstallCmd">
|
||||
<p class="st-cmd-label">{{ t('stores.installThen') }}</p>
|
||||
<CommandBlock :command="current.afterInstallCmd" />
|
||||
</template>
|
||||
|
||||
<p class="st-cmd-label">{{ t('stores.useTitle') }}</p>
|
||||
<CommandBlock :command="current.cliSnippet" />
|
||||
|
||||
<p class="st-cmd-label">{{ t('stores.uninstallTitle') }}</p>
|
||||
<CommandBlock :command="current.uninstallCmd" />
|
||||
</div>
|
||||
|
||||
<div class="st-links st-links-after">
|
||||
<a :href="current.wikiUrl" target="_blank" rel="noopener noreferrer" class="st-link st-link-indigo">
|
||||
<i class="fa-solid fa-book"></i> {{ t('stores.docs') }}
|
||||
</a>
|
||||
<a :href="current.repoUrl" target="_blank" rel="noopener noreferrer" class="st-link st-link-dark">
|
||||
<i class="fa-solid fa-code-branch"></i> {{ t('stores.repo') }}
|
||||
</a>
|
||||
<a :href="current.projectUrl" target="_blank" rel="noopener noreferrer" class="st-link st-link-emerald">
|
||||
<i :class="current.icon"></i> {{ t(`stores.${device}.site`) }}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Which catalog platforms a store engine can put on a device. The app has no
|
||||
such list to show: it installs whatever the catalog offers, and says so on the
|
||||
cards themselves. -->
|
||||
<section class="st-section">
|
||||
<h2 class="st-section-title">{{ t('stores.platformsTitle') }}</h2>
|
||||
<ul class="st-platforms">
|
||||
<li v-for="p in platforms" :key="p.platform" class="st-platform">
|
||||
<i :class="p.icon" class="st-platform-icon"></i>
|
||||
<span class="st-platform-name">{{ p.label }}</span>
|
||||
<code class="st-platform-ext">{{ p.ext }}</code>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
<div class="st-back">
|
||||
<RouterLink to="/catalog" class="st-back-link">{{ t('catalogShow.back') }}</RouterLink>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CONFIG } from '../../lib/config'
|
||||
import CommandBlock from './CommandBlock.vue'
|
||||
import clientScreenshot from '../../assets/warpengine-client.png'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const FORGE = 'https://git.teletypegames.org/stores'
|
||||
|
||||
// The graphical client. Its own thing rather than a link on the desktop store: it
|
||||
// drives any WarpEngine store the site's registry offers, and on Windows it is the
|
||||
// only way in — there is no `curl … | sh` there.
|
||||
const CLIENT = {
|
||||
repoUrl: `${FORGE}/warp-engine-client`,
|
||||
releasesUrl: `${FORGE}/warp-engine-client/releases`,
|
||||
wikiUrl: `${CONFIG.wikiBase}/stores/warp-engine-client`,
|
||||
}
|
||||
|
||||
type DeviceId = 'batocera' | 'retroarch'
|
||||
|
||||
// The devices below are the ones a store reaches through a shell installer. An
|
||||
// ordinary computer is not among them any more: there the store *is* the app in the
|
||||
// section above, which carries its own engine and needs nothing installed first.
|
||||
const BATOCERA_CLI = '/userdata/system/batocera-store/ttg-store'
|
||||
const RETROARCH_CLI = '~/.local/bin/ttg-retroarch-store'
|
||||
|
||||
const devices = [
|
||||
{
|
||||
id: 'batocera' as DeviceId,
|
||||
icon: 'fa-solid fa-tv',
|
||||
projectUrl: 'https://batocera.org',
|
||||
repoUrl: `${FORGE}/ttg-batocera-store`,
|
||||
wikiUrl: `${CONFIG.wikiBase}/stores/ttg-batocera-store`,
|
||||
// The installer is served straight from the forge, so this one line is the
|
||||
// whole install on the device.
|
||||
installCmd: `curl -fsSL ${FORGE}/ttg-batocera-store/raw/branch/master/install.sh | sh`,
|
||||
afterInstallCmd: 'batocera-es-swissknife --restart',
|
||||
uninstallCmd: `curl -fsSL ${FORGE}/ttg-batocera-store/raw/branch/master/uninstall.sh | sh`,
|
||||
cliSnippet: [
|
||||
`${BATOCERA_CLI} list # compatible catalog entries`,
|
||||
`${BATOCERA_CLI} sync # download everything new`,
|
||||
`${BATOCERA_CLI} remove c64demo`,
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'retroarch' as DeviceId,
|
||||
icon: 'fa-solid fa-gamepad',
|
||||
projectUrl: 'https://www.retroarch.com',
|
||||
repoUrl: `${FORGE}/ttg-retroarch-store`,
|
||||
wikiUrl: `${CONFIG.wikiBase}/stores/ttg-retroarch-store`,
|
||||
installCmd: `curl -fsSL ${FORGE}/ttg-retroarch-store/raw/branch/master/install.sh | sh`,
|
||||
afterInstallCmd: '',
|
||||
uninstallCmd: `curl -fsSL ${FORGE}/ttg-retroarch-store/raw/branch/master/uninstall.sh | sh`,
|
||||
cliSnippet: [
|
||||
`${RETROARCH_CLI} paths # where things go, and whether the cores are there`,
|
||||
`${RETROARCH_CLI} list # compatible catalog entries`,
|
||||
`${RETROARCH_CLI} sync # download everything new`,
|
||||
`${RETROARCH_CLI} remove c64demo`,
|
||||
].join('\n'),
|
||||
},
|
||||
]
|
||||
|
||||
type ViewId = 'client' | 'others'
|
||||
|
||||
// `?view=` and `?device=` keep a link shareable, and let /batocera redirect here without
|
||||
// losing which store the visitor came for. Query only: the router does not scroll for a
|
||||
// query-only change, so switching does not throw the page back to the top.
|
||||
const initial = devices.some((d) => d.id === route.query.device)
|
||||
? (route.query.device as DeviceId)
|
||||
: 'batocera'
|
||||
const device = ref<DeviceId>(initial)
|
||||
// The client view is what most visitors want, so it is the default — except when the URL
|
||||
// names a device, which is how /batocera and /retroarch redirect here. Somebody arriving
|
||||
// from those wants the integrations, not the app.
|
||||
const view = ref<ViewId>(
|
||||
route.query.view === 'others' || (route.query.view === undefined && route.query.device !== undefined)
|
||||
? 'others'
|
||||
: 'client',
|
||||
)
|
||||
const current = computed(() => devices.find((d) => d.id === device.value) ?? devices[0])
|
||||
// The app is not tied to a device tab: it drives whichever store the site's registry
|
||||
// offers, so it stands above the chooser rather than inside it.
|
||||
const client = CLIENT
|
||||
|
||||
function select(id: DeviceId) {
|
||||
device.value = id
|
||||
void router.replace({ query: { ...route.query, device: id } })
|
||||
}
|
||||
|
||||
function selectView(id: ViewId) {
|
||||
view.value = id
|
||||
void router.replace({ query: { ...route.query, view: id } })
|
||||
}
|
||||
|
||||
const platforms = [
|
||||
{ platform: 'c64', label: 'Commodore 64 (VICE)', ext: '.prg', icon: 'fa-solid fa-floppy-disk' },
|
||||
{ platform: 'tic80', label: 'TIC-80', ext: '.tic', icon: 'fa-solid fa-tv' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.st-links {
|
||||
@apply flex flex-wrap gap-3;
|
||||
}
|
||||
.st-link {
|
||||
@apply inline-flex items-center gap-2 font-bold py-2.5 px-5 rounded-xl text-sm text-white transition-all active:scale-95;
|
||||
}
|
||||
.st-link-dark { @apply bg-slate-800 hover:bg-slate-900 shadow-lg shadow-slate-800/20; }
|
||||
.st-link-indigo { @apply bg-indigo-600 hover:bg-indigo-700 shadow-lg shadow-indigo-600/20; }
|
||||
.st-link-emerald { @apply bg-emerald-600 hover:bg-emerald-700 shadow-lg shadow-emerald-600/20; }
|
||||
|
||||
.st-section {
|
||||
@apply bg-white rounded-2xl shadow-xl border border-gray-100 p-6 md:p-8 mt-6;
|
||||
}
|
||||
.st-section-title {
|
||||
@apply flex items-center gap-3 text-xl font-bold text-gray-900 mb-2;
|
||||
}
|
||||
|
||||
.st-devices {
|
||||
@apply grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6;
|
||||
}
|
||||
.st-device {
|
||||
@apply flex flex-col items-start gap-1 text-left bg-gray-50 border-2 border-gray-100 rounded-xl px-4 py-3 transition-all hover:border-emerald-200 active:scale-95;
|
||||
}
|
||||
.st-device-active {
|
||||
@apply bg-emerald-50 border-emerald-500;
|
||||
}
|
||||
.st-device-icon {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
.st-device-active .st-device-icon {
|
||||
@apply text-emerald-600;
|
||||
}
|
||||
.st-device-name {
|
||||
@apply font-bold text-gray-900;
|
||||
}
|
||||
.st-device-tagline {
|
||||
@apply text-sm text-gray-500;
|
||||
}
|
||||
|
||||
/* The switcher is meant to be almost nothing: two words and a line under the active one. */
|
||||
.st-views {
|
||||
@apply flex gap-1 border-b border-gray-200 mb-6;
|
||||
}
|
||||
.st-view {
|
||||
@apply flex items-center gap-2 px-4 py-2 -mb-px text-sm font-semibold text-gray-500 border-b-2 border-transparent transition-colors hover:text-gray-800;
|
||||
}
|
||||
.st-view-active {
|
||||
@apply text-emerald-700 border-emerald-500;
|
||||
}
|
||||
/* One rhythm for the whole command list, so nothing depends on a margin someone
|
||||
remembered to add. */
|
||||
.st-cmds {
|
||||
@apply space-y-2;
|
||||
}
|
||||
.st-cmd-label {
|
||||
@apply text-xs font-semibold uppercase tracking-wide text-gray-400 pt-3;
|
||||
}
|
||||
.st-cmd-label:first-child {
|
||||
@apply pt-0;
|
||||
}
|
||||
.st-links-after {
|
||||
@apply mt-6;
|
||||
}
|
||||
.st-engine-link {
|
||||
@apply text-indigo-600 hover:underline font-mono text-xs;
|
||||
}
|
||||
/* The app leads the page, so it is the one section that looks like an offer rather
|
||||
than a paragraph. */
|
||||
.st-app {
|
||||
@apply bg-gradient-to-br from-emerald-50 to-teal-50 border-emerald-200;
|
||||
}
|
||||
.st-app-lead {
|
||||
@apply text-gray-700 text-lg leading-relaxed mb-4;
|
||||
}
|
||||
.st-app-platforms {
|
||||
@apply flex items-center gap-2 text-sm text-gray-500 mt-4;
|
||||
}
|
||||
.st-app-grid {
|
||||
@apply grid gap-6 items-start lg:grid-cols-2;
|
||||
}
|
||||
.st-app-shot {
|
||||
@apply rounded-xl overflow-hidden border border-emerald-200/70 shadow-lg bg-gray-900;
|
||||
}
|
||||
.st-app-shot img {
|
||||
@apply block w-full h-auto;
|
||||
}
|
||||
.st-link-cta {
|
||||
@apply bg-emerald-600 text-white hover:bg-emerald-700 text-base px-5 py-2.5 font-semibold;
|
||||
}
|
||||
.st-platforms {
|
||||
@apply grid grid-cols-1 sm:grid-cols-2 gap-3;
|
||||
}
|
||||
.st-platform {
|
||||
@apply flex items-center gap-3 bg-gray-50 border border-gray-100 rounded-xl px-4 py-3;
|
||||
}
|
||||
.st-platform-icon {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
.st-platform-name {
|
||||
@apply font-medium text-gray-800 flex-grow;
|
||||
}
|
||||
.st-platform-ext {
|
||||
@apply font-mono text-xs text-purple-600 bg-purple-50 px-2 py-0.5 rounded;
|
||||
}
|
||||
.st-back {
|
||||
@apply mt-8 text-center;
|
||||
}
|
||||
.st-back-link {
|
||||
@apply text-gray-400 hover:text-gray-600 text-sm font-medium transition-colors;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const enginesRouter: RouteRecordRaw[] = [
|
||||
{ path: '/engines', name: 'enginesIndex', component: () => import('../page/engines/EnginesIndexPage.vue') },
|
||||
]
|
||||
@@ -4,9 +4,11 @@ import { blogRouter } from './blog.router'
|
||||
import { catalogRouter } from './catalog.router'
|
||||
import { codeRouter } from './code.router'
|
||||
import { contactRouter } from './contact.router'
|
||||
import { enginesRouter } from './engines.router'
|
||||
import { howtosRouter } from './howtos.router'
|
||||
import { teamRouter } from './team.router'
|
||||
import { buildsRouter } from './builds.router'
|
||||
import { storesRouter } from './stores.router'
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -16,11 +18,19 @@ export const router = createRouter({
|
||||
...catalogRouter,
|
||||
...codeRouter,
|
||||
...contactRouter,
|
||||
...enginesRouter,
|
||||
...howtosRouter,
|
||||
...teamRouter,
|
||||
...buildsRouter,
|
||||
...storesRouter,
|
||||
],
|
||||
scrollBehavior() {
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
// Back and forward land where the visitor was.
|
||||
if (savedPosition) return savedPosition
|
||||
// A query-only change is not a new page. The stores page writes its switcher into the
|
||||
// URL so a link stays shareable, and throwing the reader back to the top for that
|
||||
// felt like a reload.
|
||||
if (to.path === from.path) return false
|
||||
return { top: 0 }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const storesRouter: RouteRecordRaw[] = [
|
||||
{ path: '/stores', name: 'storesIndex', component: () => import('../page/stores/StoresIndexPage.vue') },
|
||||
// The Batocera store had a page to itself until the RetroArch store joined it.
|
||||
// Both device names keep working as URLs — the old links, and the guess someone
|
||||
// makes after reading "RetroArch store" — and each lands on its own tab.
|
||||
{ path: '/batocera', redirect: { name: 'storesIndex', query: { device: 'batocera' } } },
|
||||
{ path: '/retroarch', redirect: { name: 'storesIndex', query: { device: 'retroarch' } } },
|
||||
// `/desktop` used to name a shell store for ordinary computers. That store is the
|
||||
// app now, so the old URL lands on the app rather than on a device tab.
|
||||
{ path: '/desktop', redirect: { name: 'storesIndex' } },
|
||||
]
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getEngineDigest } from '../engines.store'
|
||||
|
||||
// Generic markdown in the shape every engine-tagged wiki page must follow:
|
||||
// intro paragraph, then "What You Get" as the first heading with bullets.
|
||||
// Dummy text only — real content always comes from the wiki at runtime.
|
||||
const SAMPLE_MARKDOWN = `
|
||||
> Example Engine is a **sample** framework that turns markdown into landing cards.
|
||||
|
||||
# What You Get
|
||||
|
||||
- **First feature**: some \`inline code\` — with a longer explanation.
|
||||
- **Second feature**: a [link](https://example.org) inside the text.
|
||||
- Plain bullet without a bold lead.
|
||||
|
||||
# Later Section
|
||||
|
||||
- **Not picked up**: a list under a later heading.
|
||||
`
|
||||
|
||||
describe('getEngineDigest', () => {
|
||||
it('extracts the intro from the leading blockquote with inline markdown stripped', () => {
|
||||
const digest = getEngineDigest(SAMPLE_MARKDOWN)
|
||||
expect(digest.intro).toBe(
|
||||
'Example Engine is a sample framework that turns markdown into landing cards.',
|
||||
)
|
||||
})
|
||||
|
||||
it('takes the What You Get heading as the highlights title', () => {
|
||||
expect(getEngineDigest(SAMPLE_MARKDOWN).highlightsTitle).toBe('What You Get')
|
||||
})
|
||||
|
||||
it('parses bold-lead bullets into title and text', () => {
|
||||
const [first, second] = getEngineDigest(SAMPLE_MARKDOWN).highlights
|
||||
expect(first).toEqual({
|
||||
title: 'First feature',
|
||||
text: 'some inline code — with a longer explanation.',
|
||||
})
|
||||
expect(second).toEqual({
|
||||
title: 'Second feature',
|
||||
text: 'a link inside the text.',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps bullets without a bold lead as plain text', () => {
|
||||
const third = getEngineDigest(SAMPLE_MARKDOWN).highlights[2]
|
||||
expect(third).toEqual({ title: '', text: 'Plain bullet without a bold lead.' })
|
||||
})
|
||||
|
||||
it('stops at the heading after the What You Get section', () => {
|
||||
expect(getEngineDigest(SAMPLE_MARKDOWN).highlights).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('matches the heading case-insensitively', () => {
|
||||
const digest = getEngineDigest('# WHAT you get\n- **A**: b.')
|
||||
expect(digest.highlights).toEqual([{ title: 'A', text: 'b.' }])
|
||||
})
|
||||
|
||||
it('yields no highlights when the first heading is not What You Get', () => {
|
||||
const digest = getEngineDigest('Intro line.\n\n# Features\n- **A**: b.\n\n# What You Get\n- **C**: d.')
|
||||
expect(digest.intro).toBe('Intro line.')
|
||||
expect(digest.highlights).toEqual([])
|
||||
})
|
||||
|
||||
it('caps the highlights at six items', () => {
|
||||
const many = '# What You Get\n' + Array.from({ length: 9 }, (_, i) => `- item ${i}`).join('\n')
|
||||
expect(getEngineDigest(many).highlights).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('uses the first paragraph as intro when there is no blockquote', () => {
|
||||
const digest = getEngineDigest('Just a plain paragraph.\n\n---\n\n# What You Get\n- one')
|
||||
expect(digest.intro).toBe('Just a plain paragraph.')
|
||||
expect(digest.highlights).toEqual([{ title: '', text: 'one' }])
|
||||
})
|
||||
|
||||
it('returns an empty digest for empty content', () => {
|
||||
expect(getEngineDigest('')).toEqual({ intro: '', highlightsTitle: '', highlights: [] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import wikiApi from '../api/wiki.api'
|
||||
import { useLoadable } from '../composables/useLoadable'
|
||||
import type { WikiPageWithContent } from '../lib/interfaces/wiki.interface'
|
||||
|
||||
export interface EngineHighlight {
|
||||
title: string
|
||||
text: string
|
||||
}
|
||||
|
||||
// Landing-page digest of a wiki engine page. Convention: every engine-tagged
|
||||
// page opens with an intro paragraph, then a "What You Get" heading as its
|
||||
// first heading, with the feature bullets underneath.
|
||||
export interface EngineDigest {
|
||||
intro: string
|
||||
highlightsTitle: string
|
||||
highlights: EngineHighlight[]
|
||||
}
|
||||
|
||||
const HIGHLIGHTS_HEADING = 'what you get'
|
||||
const MAX_HIGHLIGHTS = 6
|
||||
|
||||
// Inline markdown (links, bold, code) stripped so the text reads as plain prose.
|
||||
function stripInline(md: string): string {
|
||||
return md
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||
.replace(/[*_`]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function getEngineDigest(content: string): EngineDigest {
|
||||
const digest: EngineDigest = { intro: '', highlightsTitle: '', highlights: [] }
|
||||
let inHighlights = false
|
||||
|
||||
for (const raw of (content || '').split('\n')) {
|
||||
const line = raw.trim()
|
||||
if (!line || /^([-*_])\1{2,}$/.test(line)) continue
|
||||
|
||||
const heading = line.match(/^#{1,6}\s+(.*)$/)
|
||||
if (heading) {
|
||||
// The section ends at the next heading; and if the page's first heading
|
||||
// is not "What You Get", it doesn't follow the convention — no highlights.
|
||||
if (inHighlights) break
|
||||
const title = stripInline(heading[1])
|
||||
if (title.toLowerCase() !== HIGHLIGHTS_HEADING) break
|
||||
inHighlights = true
|
||||
digest.highlightsTitle = title
|
||||
continue
|
||||
}
|
||||
|
||||
if (inHighlights) {
|
||||
const bullet = line.match(/^[-*]\s+(.*)$/)
|
||||
if (!bullet) continue
|
||||
const lead = bullet[1].match(/^\*\*([^*]+)\*\*\s*[:—–-]?\s*(.*)$/)
|
||||
digest.highlights.push(
|
||||
lead
|
||||
? { title: stripInline(lead[1]), text: stripInline(lead[2]) }
|
||||
: { title: '', text: stripInline(bullet[1]) },
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// First prose line (paragraph or blockquote) before the heading is the intro.
|
||||
if (!digest.intro) {
|
||||
digest.intro = stripInline(line.replace(/^>\s*/, ''))
|
||||
}
|
||||
}
|
||||
|
||||
digest.highlights = digest.highlights.slice(0, MAX_HIGHLIGHTS)
|
||||
return digest
|
||||
}
|
||||
|
||||
export const useEnginesStore = defineStore('engines', () => {
|
||||
const pages = ref<WikiPageWithContent[]>([])
|
||||
const { loading, error, withCache, invalidate } = useLoadable()
|
||||
|
||||
async function fetch() {
|
||||
await withCache(async () => {
|
||||
pages.value = await wikiApi.listEnginePages()
|
||||
})
|
||||
}
|
||||
|
||||
function getCleanPreview(content: string): string {
|
||||
if (!content) return ''
|
||||
return content.replace(/[#*`_[\]()>|-]/g, '').replace(/\s+/g, ' ').trim().slice(0, 260) + '...'
|
||||
}
|
||||
|
||||
return { pages, loading, error, fetch, getCleanPreview, getEngineDigest, invalidate }
|
||||
})
|
||||
+10
-22
@@ -43,7 +43,7 @@ services:
|
||||
- interstack
|
||||
|
||||
woodpecker-server:
|
||||
image: woodpeckerci/woodpecker-server:v3.16.0
|
||||
image: woodpeckerci/woodpecker-server:v3.17.0
|
||||
container_name: woodpecker-server
|
||||
environment:
|
||||
WOODPECKER_HOST: "https://${WOODPECKER_DOMAIN}"
|
||||
@@ -54,6 +54,10 @@ services:
|
||||
WOODPECKER_GITEA_SECRET: ${GITEA_CLIENT_SECRET}
|
||||
WOODPECKER_SERVER_ADDR: ":8000"
|
||||
WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET}
|
||||
# Global configuration extension: every pipeline start asks the update
|
||||
# server for the config; non-marker repos fall back to their own YAML (204).
|
||||
WOODPECKER_CONFIG_EXTENSION_ENDPOINT: "https://${WEBAPP_DOMAIN}/build/config"
|
||||
WOODPECKER_ADMIN: mr.zero
|
||||
volumes:
|
||||
- ./data/woodpecker:/var/lib/woodpecker
|
||||
labels:
|
||||
@@ -67,7 +71,7 @@ services:
|
||||
- interstack
|
||||
|
||||
woodpecker-agent:
|
||||
image: woodpeckerci/woodpecker-agent:v3.16.0
|
||||
image: woodpeckerci/woodpecker-agent:v3.17.0
|
||||
container_name: woodpecker-agent
|
||||
environment:
|
||||
WOODPECKER_SERVER: "woodpecker-server:9000"
|
||||
@@ -162,6 +166,9 @@ services:
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@teletype.hu}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-password123}
|
||||
- WIKI_GRAV_URL=${WIKI_GRAV_URL:-https://wiki.teletypegames.org}
|
||||
- WOODPECKER_URL=${WOODPECKER_URL:-http://woodpecker-server:8000}
|
||||
- WOODPECKER_API_TOKEN=${WOODPECKER_API_TOKEN:-}
|
||||
- WOODPECKER_REPO_OWNER=${WOODPECKER_REPO_OWNER:-}
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
@@ -178,7 +185,7 @@ services:
|
||||
- "traefik.http.middlewares.api-cors.headers.accesscontrolallowheaders=Content-Type,Authorization,Accept,X-Requested-With"
|
||||
- "traefik.http.middlewares.api-cors.headers.accesscontrolmaxage=3600"
|
||||
- "traefik.http.middlewares.api-cors.headers.addvaryheader=true"
|
||||
- "traefik.http.routers.api.rule=Host(`${WEBAPP_DOMAIN}`) && (PathPrefix(`/api`) || PathPrefix(`/file`) || PathPrefix(`/update`) || PathPrefix(`/admin`))"
|
||||
- "traefik.http.routers.api.rule=Host(`${WEBAPP_DOMAIN}`) && (PathPrefix(`/api`) || PathPrefix(`/file`) || PathPrefix(`/build`) || PathPrefix(`/admin`))"
|
||||
- "traefik.http.routers.api.entrypoints=web"
|
||||
- "traefik.http.routers.api.priority=10"
|
||||
- "traefik.http.routers.api.middlewares=api-cors"
|
||||
@@ -192,25 +199,6 @@ services:
|
||||
- proxy
|
||||
- interstack
|
||||
|
||||
droparea:
|
||||
image: linuxserver/openssh-server
|
||||
container_name: droparea
|
||||
environment:
|
||||
PUID: 1
|
||||
PGID: 1
|
||||
TZ: Europe/Budapest
|
||||
SUDO_ACCESS: "false"
|
||||
PASSWORD_ACCESS: "true"
|
||||
USER_NAME: drop
|
||||
USER_PASSWORD: ${DROP_PASSWORD}
|
||||
volumes:
|
||||
- ./data/softwares:/home/drop
|
||||
ports:
|
||||
- "${DROPAREA_SSH_PORT}:2222"
|
||||
networks:
|
||||
- proxy
|
||||
- interstack
|
||||
|
||||
volumes:
|
||||
gitea:
|
||||
woodpecker:
|
||||
|
||||
@@ -13,7 +13,6 @@ PHPMYADMIN_DOMAIN=db.teletype.hu
|
||||
|
||||
# Ports
|
||||
GITEA_SSH_PORT=2222
|
||||
DROPAREA_SSH_PORT=2223
|
||||
TRAEFIK_WEB_PORT=9100
|
||||
TRAEFIK_API_PORT=9101
|
||||
|
||||
@@ -23,7 +22,6 @@ WOODPECKER_AGENT_SECRET=
|
||||
MYSQL_ROOT_PASSWORD=
|
||||
DB_PASSWORD=
|
||||
UPDATE_SECRET=
|
||||
DROP_PASSWORD=
|
||||
WEBAPP_WIKIJS_TOKEN=
|
||||
DISCORD_INVITE_LINK=
|
||||
|
||||
|
||||
@@ -2,3 +2,4 @@ log/
|
||||
spec/dummy/log/
|
||||
spec/dummy/tmp/
|
||||
Gemfile.lock
|
||||
.bundle/
|
||||
|
||||
@@ -8,5 +8,6 @@ group :development, :test do
|
||||
gem "rspec-rails", "~> 7.0"
|
||||
gem "factory_bot_rails"
|
||||
gem "shoulda-matchers", "~> 6.0"
|
||||
gem "webmock", "~> 3.0"
|
||||
gem "debug", platforms: %i[mri windows]
|
||||
end
|
||||
|
||||
+553
-46
@@ -1,89 +1,596 @@
|
||||
# WarpEngine
|
||||
|
||||
Mountable Rails engine: a retro software catalog with a CI-pipeline-callable
|
||||
release updater, a public read-only JSON API, and ActiveAdmin resources that
|
||||
load into the host application's admin.
|
||||
A mountable Rails engine that turns any Rails application into a retro
|
||||
software catalog: catalog models, a CI-pipeline-callable release updater, a
|
||||
public read-only JSON API, and optional ActiveAdmin resources that plug into
|
||||
your app's existing admin.
|
||||
|
||||
## What it provides
|
||||
Repository: `https://git.teletypegames.org/engines/warp_engine`
|
||||
|
||||
- **Models**: `Software`, `Release`, `ReleaseAsset`, `ExternalLink`,
|
||||
`PlatformLink`, `Image`, `SoftwareImage`, `Download` (all under
|
||||
`WarpEngine::`, with unprefixed table names)
|
||||
- **Updater**: `GET /update?platform=&name=&version=` (auth via the
|
||||
`X-Update-Secret` header or `?secret=`) — CI copies build artifacts under
|
||||
`file_container_path` using the `<name>-<version>*` naming convention, then
|
||||
calls the endpoint; the updater extracts archives, parses metadata, and
|
||||
upserts the Software/Release/ReleaseAsset/ExternalLink records.
|
||||
Supported platforms: tic80, ebitengine, love, c64, godot, bevy, phaser.
|
||||
- **Public API**: `/api/software`, `/api/software/highlighted`, `/api/builds`,
|
||||
`/api/softwares/:name/builds`, `/api/image/:id`, `/api/download?path=`,
|
||||
`/file/*path`
|
||||
- **Admin**: ActiveAdmin resource files (softwares with a 3-level nested form,
|
||||
releases, external links, platform links, images with orphan management, a
|
||||
Files file-manager page with picker mode, download stats) — loaded into the
|
||||
host's single ActiveAdmin instance.
|
||||
## Features
|
||||
|
||||
- **Catalog domain**: `Software`, `Release`, `ReleaseAsset`, `ExternalLink`,
|
||||
`PlatformLink`, `Image`, `SoftwareImage`, `Download` models with soft-delete
|
||||
semantics and download statistics.
|
||||
- **CI-callable updater**: your build pipeline uploads artifacts over HTTP
|
||||
and calls one endpoint — WarpEngine extracts archives, parses metadata
|
||||
and upserts the catalog records. Supported platforms out of the
|
||||
box: TIC-80, Ebitengine, LÖVE, C64, Godot, Bevy, Phaser. Authenticated by
|
||||
a shared secret or by per-owner database tokens with expiry and scopes
|
||||
(`ApplicationToken`, managed in the admin).
|
||||
- **Pluggable access**: a host supplies a policy and the catalog gains prices,
|
||||
entitlements and gated downloads — and *says so* in its API, so clients can
|
||||
show a paid title as paid instead of failing at the download. Default `:open`
|
||||
is the catalog as it always was.
|
||||
- **Client sign-in**: an RFC 8628 device authorization grant for clients with no
|
||||
browser of their own, over the host's own user model. Off unless configured.
|
||||
- **Pluggable storage**: artifacts are served through a storage adapter
|
||||
(`:local` by default); a host can serve them from an object store without
|
||||
patching the engine.
|
||||
- **Publish events**: every published release emits
|
||||
`ActiveSupport::Notifications` (`warp_engine.publish`), so hosts can react
|
||||
to new builds without model callbacks.
|
||||
- **Public JSON API**: catalog listing, highlighted title, per-platform build
|
||||
matrix, image serving, download tracking, and a static file server for
|
||||
web-playable builds.
|
||||
- **Admin (optional)**: if the host runs ActiveAdmin, WarpEngine contributes
|
||||
ready-made resources — a catalog editor with nested release/asset forms, an
|
||||
image library with orphan cleanup, a file manager with a picker mode, and
|
||||
download statistics. Without ActiveAdmin the engine runs headless
|
||||
(API + updater only).
|
||||
- **Woodpecker CI management (optional)**: with a Woodpecker API token
|
||||
configured, the admin also gains repo sync, per-repo pipeline history
|
||||
with manual triggers, and automatic provisioning of application tokens
|
||||
as Woodpecker secrets.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Rails >= 8.0
|
||||
- A relational database (developed and tested against MySQL 8)
|
||||
- Optional: ActiveAdmin + Devise in the host app for the admin UI
|
||||
|
||||
## Example stack (docker compose)
|
||||
|
||||
`examples/compose` boots everything the engine's workflow assumes, end to
|
||||
end: the catalog app itself and — behind a compose profile — a Gitea forge
|
||||
with Woodpecker CI, so you can watch a pipeline publish a release into the
|
||||
catalog.
|
||||
|
||||
| Service | Role | Where |
|
||||
| --- | --- | --- |
|
||||
| `app` | Minimal Rails host with the engine mounted as a path gem (headless: API + updater) | `http://localhost:8080` |
|
||||
| `mysql` | Catalog database | internal |
|
||||
| `gitea` | Git forge (profile `ci`) | `http://gitea:3000` |
|
||||
| `woodpecker` + agent | CI wired to gitea (profile `ci`) | `http://woodpecker:8000` |
|
||||
|
||||
### Quickstart — catalog only
|
||||
|
||||
```sh
|
||||
cd examples/compose
|
||||
cp .env.example .env # defaults work for a throwaway local demo
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
The first boot takes a few minutes: the app container bundles, runs
|
||||
`rails g warp_engine:install` and `rails db:prepare`, then serves on
|
||||
`http://localhost:8080`:
|
||||
|
||||
- `http://localhost:8080/api/software` — the (empty) catalog
|
||||
- `http://localhost:8080/api/builds` — the platform build matrix
|
||||
- `http://localhost:8080/api/docs` — apipie API docs
|
||||
|
||||
### Publish a release by hand
|
||||
|
||||
The updater contract is nothing but a handful of HTTP calls, so you can play
|
||||
the role of the CI pipeline yourself:
|
||||
|
||||
```sh
|
||||
# 1. Fake a build: metadata, a web build and a windows artifact, named by
|
||||
# convention (the love platform requires the .html.zip web build)
|
||||
cat > demo-0.1.0.metadata.json <<'JSON'
|
||||
{ "name": "demo", "title": "Demo Game", "author": "You", "desc": "Hello", "license": "MIT" }
|
||||
JSON
|
||||
echo '<h1>demo</h1>' > index.html && zip demo-0.1.0.html.zip index.html
|
||||
echo hello > game.bin && zip demo-0.1.0-win-x64.zip game.bin
|
||||
|
||||
# 2. Upload them (one request per file)
|
||||
for f in demo-0.1.0.*; do
|
||||
curl -fs -H "X-Update-Secret: example-update-secret" \
|
||||
-F "file=@$f" "http://localhost:8080/build/upload?name=demo&version=0.1.0"
|
||||
done
|
||||
|
||||
# 3. Publish the release
|
||||
curl -X POST -H "X-Update-Secret: example-update-secret" \
|
||||
"http://localhost:8080/build/publish?platform=love&name=demo&version=0.1.0"
|
||||
```
|
||||
|
||||
`GET /api/software` now lists *Demo Game* with `html` and `win_x64` assets,
|
||||
`http://localhost:8080/file/demo-0.1.0/index.html` serves the extracted web
|
||||
build, and `GET /api/download?path=demo-0.1.0-win-x64.zip` serves the
|
||||
artifact while logging a download record.
|
||||
|
||||
### Full loop — forge + CI (profile `ci`)
|
||||
|
||||
gitea and woodpecker address each other by service name, so let your browser
|
||||
resolve those names too:
|
||||
|
||||
```sh
|
||||
echo "127.0.0.1 gitea woodpecker" | sudo tee -a /etc/hosts
|
||||
```
|
||||
|
||||
1. `docker compose --profile ci up -d gitea`, open `http://gitea:3000`,
|
||||
finish the install wizard (SQLite is fine) and create your admin user.
|
||||
2. In gitea: *Settings → Applications → Manage OAuth2 Applications*, create
|
||||
an app with redirect URI `http://woodpecker:8000/authorize`; copy the
|
||||
client id/secret into `WOODPECKER_GITEA_CLIENT` / `WOODPECKER_GITEA_SECRET`
|
||||
in `.env`.
|
||||
3. `docker compose --profile ci up -d` — then log in at
|
||||
`http://woodpecker:8000` (OAuth via gitea) and enable your repository.
|
||||
|
||||
A pipeline publishes a release exactly like the by-hand steps above — build,
|
||||
upload, publish:
|
||||
|
||||
```yaml
|
||||
# .woodpecker.yaml in a game repo hosted on the example gitea
|
||||
steps:
|
||||
publish:
|
||||
image: alpine
|
||||
environment:
|
||||
UPDATE_SECRET:
|
||||
from_secret: update_secret
|
||||
commands:
|
||||
- apk add --no-cache curl zip
|
||||
- # ... build your game, produce mygame-1.0.0.metadata.json + artifacts ...
|
||||
- for f in mygame-1.0.0.*; do curl -fs -H "X-Update-Secret: $UPDATE_SECRET" -F "file=@$f" "http://app:3000/build/upload?name=mygame&version=1.0.0"; done
|
||||
- curl -fs -X POST -H "X-Update-Secret: $UPDATE_SECRET" "http://app:3000/build/publish?platform=love&name=mygame&version=1.0.0"
|
||||
```
|
||||
|
||||
(The agent attaches pipeline containers to the stack network, so `app`
|
||||
resolves. For real projects, the per-platform
|
||||
[`tools/*-tools`](https://git.teletypegames.org) repos ship ready-made
|
||||
Makefile + pipeline templates implementing this contract.)
|
||||
|
||||
Tear the stack down with `docker compose --profile ci down -v`.
|
||||
|
||||
## Installation
|
||||
|
||||
From the git repository:
|
||||
|
||||
```ruby
|
||||
# Gemfile
|
||||
gem "warp_engine", path: "../../libs/ruby/warp_engine"
|
||||
gem "warp_engine", git: "https://git.teletypegames.org/engines/warp_engine.git"
|
||||
```
|
||||
|
||||
Or from the Forgejo rubygems registry, which is where **tagged releases** land — a
|
||||
version rather than whatever a branch happens to hold:
|
||||
|
||||
```ruby
|
||||
source "https://git.teletypegames.org/api/packages/engines/rubygems" do
|
||||
gem "warp_engine", "~> 0.5"
|
||||
end
|
||||
```
|
||||
|
||||
The registry is publicly readable, so no credential is needed to install from it. Use a
|
||||
**block-scoped** source rather than a second global one: with two global sources Bundler
|
||||
cannot say which gem came from where.
|
||||
|
||||
Then:
|
||||
|
||||
```sh
|
||||
rails g warp_engine:install # initializer + create_warp_engine_tables migration
|
||||
rails db:migrate
|
||||
```
|
||||
|
||||
```ruby
|
||||
# config/routes.rb — keep it the last entry so host routes win
|
||||
# config/routes.rb — keep it the last entry so your own routes win
|
||||
mount WarpEngine::Engine => "/"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```ruby
|
||||
# config/initializers/warp_engine.rb
|
||||
Rails.application.config.to_prepare do
|
||||
WarpEngine.configure do |c|
|
||||
# Where CI drops build artifacts and where images are stored
|
||||
c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
|
||||
c.image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
|
||||
c.update_secret = ENV["UPDATE_SECRET"] # nil => /update rejects everything
|
||||
# If host models also reference catalog images:
|
||||
c.image_owners = [
|
||||
{
|
||||
label: "member",
|
||||
image_ids: -> { Member.where.not(image_id: nil).distinct.pluck(:image_id) },
|
||||
usage_label: ->(image) { "member" if Member.where(image_id: image.id).exists? }
|
||||
}
|
||||
]
|
||||
|
||||
# Shared secret for the /build/* endpoints.
|
||||
# nil => the endpoints reject every request.
|
||||
c.update_secret = ENV["UPDATE_SECRET"]
|
||||
|
||||
# Authentication source for /build/* — an exclusive choice:
|
||||
# :env — the shared secret above is accepted (default)
|
||||
# :database — only WarpEngine::ApplicationToken records with the
|
||||
# "update" scope are accepted; the shared secret stops
|
||||
# working the moment you switch.
|
||||
# :database mode also requires the owner class every token belongs to:
|
||||
# c.application_token_source = :database
|
||||
# c.application_token_owner_class = "AdminUser"
|
||||
|
||||
# Size cap for /build/upload and the admin file manager, in bytes (default 500MB).
|
||||
# c.max_upload_size = 500 * 1024 * 1024
|
||||
|
||||
# Owner isolation: a database token may only upload/publish softwares
|
||||
# owned by its own owner (unrestricted tokens are exempt). Enable only
|
||||
# after backfilling owners — ownerless softwares are claimable by anyone.
|
||||
# c.enforce_software_ownership = true
|
||||
|
||||
# Who may see a title and who may download it (see "Access" below).
|
||||
# :open (default) lists everything and serves everything.
|
||||
# c.access_policy = MyStore::AccessPolicy.new
|
||||
|
||||
# Client sign-in (see "Client sign-in" below). nil (default) means there is
|
||||
# none: /api/auth/* answers 404 and GET /api/service reports auth: null.
|
||||
# c.access_token_owner_class = "User"
|
||||
# c.identity_verification_url = "/devices"
|
||||
#
|
||||
# How to recognise a caller with a session instead of a bearer token.
|
||||
# c.subject_resolver = ->(request) { request.env["warden"]&.user }
|
||||
|
||||
# If your app's own models reference catalog images, register them so the
|
||||
# admin Images page counts them as "in use":
|
||||
# c.image_owners = [
|
||||
# {
|
||||
# label: "member",
|
||||
# image_ids: -> { Member.where.not(image_id: nil).distinct.pluck(:image_id) },
|
||||
# usage_label: ->(image) { "member" if Member.where(image_id: image.id).exists? }
|
||||
# }
|
||||
# ]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Host expectations
|
||||
## The updater contract
|
||||
|
||||
- **ActiveAdmin + Devise live in the host**: authentication, theme, assets and
|
||||
the `/admin` routes are the host's responsibility; the engine only appends
|
||||
its resource files to `ActiveAdmin.application.load_paths`.
|
||||
- **Files picker JS**: the file-picker next to release-asset path inputs relies
|
||||
on a few lines of JS in the host's `active_admin.js` (an iframe pointing at
|
||||
`/admin/files?picker=1&field=<dom_id>`) — copy that over to a new host too.
|
||||
- **apipie**: if the host generates apipie docs, add the engine to the matcher:
|
||||
`"#{WarpEngine::Engine.root}/app/controllers/**/*.rb"`.
|
||||
Publishing a release from CI is two steps:
|
||||
|
||||
1. **Upload** build artifacts into `file_container_path`, named by convention:
|
||||
`<name>-<version>.metadata.json`, `<name>-<version>.html.zip`,
|
||||
`<name>-<version>-win-x64.zip`, `<name>-<version>.tic`, ... (each platform
|
||||
declares which asset kinds it expects — see `GET /api/builds`). Push them
|
||||
over HTTP — one request per file, `upload` scope, optional `sha256`
|
||||
integrity check:
|
||||
|
||||
```sh
|
||||
curl -H "X-Update-Secret: $UPDATE_SECRET" \
|
||||
-F "file=@mygame-1.2.0.html.zip" \
|
||||
"https://your-host/build/upload?name=mygame&version=1.2.0"
|
||||
```
|
||||
|
||||
2. **Publish the release**:
|
||||
|
||||
```sh
|
||||
curl -X POST -H "X-Update-Secret: $UPDATE_SECRET" \
|
||||
"https://your-host/build/publish?platform=tic80&name=mygame&version=1.2.0"
|
||||
```
|
||||
|
||||
WarpEngine extracts the archives, parses the metadata (JSON, or the Lua
|
||||
comment header for TIC-80), and upserts the `Software`, `ExternalLink`,
|
||||
`Release` and `ReleaseAsset` records in a single transaction. Previously
|
||||
deleted records are resurrected on re-ingest.
|
||||
|
||||
### Updater authentication
|
||||
|
||||
The `X-Update-Secret` header carries one of two credentials, selected by
|
||||
`application_token_source` — the modes are exclusive, the endpoint never
|
||||
accepts both:
|
||||
|
||||
- **`:env`** (default): the single shared secret from `update_secret`.
|
||||
- **`:database`**: `WarpEngine::ApplicationToken` records. Each token
|
||||
belongs to an owner (the class named by `application_token_owner_class`,
|
||||
e.g. `AdminUser`), carries a free-form scope list — publishing requires
|
||||
the `"update"` scope, `/build/upload` the `"upload"` scope — and an
|
||||
optional expiry. Tokens are created in the admin
|
||||
(*App Tokens*): the plain token is generated server-side and shown exactly
|
||||
once after creation; only its SHA256 digest is stored. Deleting a token in
|
||||
the admin revokes it (soft delete), and `last_used_at` records when each
|
||||
token last authenticated successfully.
|
||||
|
||||
When switching to `:database`, create the tokens and move your pipelines to
|
||||
them first — the flip invalidates the shared secret immediately.
|
||||
|
||||
## CI pipeline configs (Woodpecker)
|
||||
|
||||
WarpEngine can act as a [Woodpecker configuration extension](https://woodpecker-ci.org/docs/usage/extensions/configuration-extension):
|
||||
instead of a copy-pasted `.woodpecker.yaml` in every game repo, the repo holds a
|
||||
one-line marker and the engine serves the full per-platform pipeline
|
||||
(version → build → upload → publish, calling `/build/upload` + `/build/publish`
|
||||
with the `application_token` Woodpecker secret):
|
||||
|
||||
```yaml
|
||||
# .woodpecker.yaml in a game repo
|
||||
platform: godot
|
||||
```
|
||||
|
||||
- `POST /build/config` — the extension endpoint Woodpecker calls on every
|
||||
pipeline start (httpsig/ed25519-signed request, verified against
|
||||
`ci_extension_public_key(_url)`). Non-marker configs get a `204` so the
|
||||
repo's own YAML keeps running — opt-in migration, and putting a full
|
||||
pipeline back into the repo is the opt-out.
|
||||
- `GET /build/config?platform=godot` — renders the same pipeline as a preview.
|
||||
|
||||
Configuration: `ci_platforms` maps platform names to builder images
|
||||
(`{ "godot" => { builder: "..." }, "tic80" => { builder: ..., exporter: ... } }`);
|
||||
an empty map (default) disables the feature. Set the Woodpecker side with
|
||||
`WOODPECKER_CONFIG_EXTENSION_ENDPOINT=https://your-host/build/config` (or
|
||||
per-repo in Settings → Extensions). Templates live in
|
||||
`app/services/warp_engine/platforms/<platform>/pipeline.yaml.erb`.
|
||||
|
||||
## Woodpecker CI management
|
||||
|
||||
Beyond serving pipeline configs, WarpEngine can drive the Woodpecker REST API
|
||||
itself. Set `woodpecker_url` and `woodpecker_api_token` — while either is nil
|
||||
(the default), every management feature stays inactive and the admin pages
|
||||
hide themselves:
|
||||
|
||||
```ruby
|
||||
c.woodpecker_url = ENV["WOODPECKER_URL"] # e.g. "https://ci.example.org"
|
||||
c.woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"] # PAT of a Woodpecker *instance admin*
|
||||
c.woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"] # forge org the game repos live under
|
||||
```
|
||||
|
||||
What it unlocks (all surfaced in the admin):
|
||||
|
||||
- **Repo sync** (*Pipelines → Sync from Woodpecker*): mirrors the Woodpecker
|
||||
repo list into `Pipeline` records, auto-matching each repo to a catalog
|
||||
`Software` by name; repos that disappear from Woodpecker are deactivated.
|
||||
Platform and software links are editable by hand afterwards.
|
||||
- **Pipeline history**: each entry on the *Pipelines* page lists its recent
|
||||
runs with a manual *Trigger* action; the newest run refreshes the cached
|
||||
last-pipeline status shown on the Pipelines index. The software's admin
|
||||
page links to its pipelines from the Quick Links sidebar.
|
||||
- **Secret provisioning**: database application tokens are pushed to the
|
||||
repos as the `application_token` Woodpecker secret — creating a token
|
||||
provisions it to its owner's repos (unrestricted tokens to all active
|
||||
repos), deleting a token removes the secret, and *Rotate* creates a
|
||||
replacement token, provisions it everywhere and revokes the old one in a
|
||||
single step.
|
||||
|
||||
The API token must belong to a Woodpecker **instance admin** — listing the
|
||||
server's repos is an admin-only endpoint (anything less yields
|
||||
`403 User not authorized`). Add the user to `WOODPECKER_ADMIN` on the
|
||||
Woodpecker server, then log out and back in: the admin flag is written to
|
||||
the user record at login, a server restart alone is not enough.
|
||||
|
||||
## Storage
|
||||
|
||||
Build artifacts are served through a storage adapter. The default is the
|
||||
local filesystem under `file_container_path` — byte for byte the behaviour
|
||||
the engine always had:
|
||||
|
||||
```ruby
|
||||
c.storage_adapter = :local # default
|
||||
```
|
||||
|
||||
A host that keeps its artifacts elsewhere (an object store behind a CDN, for
|
||||
example) can plug in its own object instead of patching the engine. The
|
||||
contract is three methods:
|
||||
|
||||
```ruby
|
||||
class MyObjectStore
|
||||
def file?(relative_path) = ... # true/false
|
||||
def directory?(relative_path) = ... # true/false
|
||||
|
||||
# Return a WarpEngine::Storage::Location:
|
||||
# Location.file(absolute_path) — the engine will send_file it
|
||||
# Location.redirect(url) — the engine will redirect (signed URL)
|
||||
def locate(relative_path, filename: nil, expires_in: nil) = ...
|
||||
end
|
||||
|
||||
c.storage_adapter = MyObjectStore.new
|
||||
```
|
||||
|
||||
`GET /api/download` and `GET /file/*` both go through the adapter, so a
|
||||
signing adapter turns them into redirects without any further change.
|
||||
`WarpEngine::DownloadService#create` still returns an absolute path (and
|
||||
`nil` when there is none), so existing callers keep working;
|
||||
`#locate` is the new entry point that can also hand back a redirect.
|
||||
|
||||
**Serving only.** Ingestion — `POST /build/upload`, archive extraction and
|
||||
the admin file manager — still writes to the local disk. A remote adapter
|
||||
needs its own upload path today.
|
||||
|
||||
## Access
|
||||
|
||||
Who may see a title, and who may download it. The default answers "everyone" to
|
||||
both — every software listed, every artifact served, no prices — which is the
|
||||
catalog the engine always had:
|
||||
|
||||
```ruby
|
||||
c.access_policy = :open # default
|
||||
```
|
||||
|
||||
A host that sells supplies a policy instead. The contract is three methods:
|
||||
|
||||
```ruby
|
||||
class MyStore::AccessPolicy
|
||||
# Which titles GET /api/software lists at all.
|
||||
def visible_software_scope(subject: nil) = ... # an ActiveRecord scope
|
||||
|
||||
# What a client is told about one title.
|
||||
def access_for(software:, subject: nil)
|
||||
WarpEngine::Access.new(
|
||||
gated: true, entitled: false, # needs an entitlement; this caller has none
|
||||
price_cents: 1490, currency: "EUR",
|
||||
purchase_url: "https://shop.example/games/slug",
|
||||
web_url: "https://shop.example/play/slug" # nil keeps the engine's own /file/ path
|
||||
)
|
||||
end
|
||||
|
||||
# nil refuses the download; a Grant allows it.
|
||||
def authorize_download(asset:, subject:, request:) = WarpEngine::Access::Grant.new
|
||||
end
|
||||
|
||||
c.access_policy = MyStore::AccessPolicy.new
|
||||
```
|
||||
|
||||
`subject` is whoever the request authenticated as, or `nil` for an anonymous
|
||||
caller — deliberately untyped, because the engine has no user model and whose
|
||||
object this is belongs to the host.
|
||||
|
||||
Every catalog entry carries an `access` block, **including under the open
|
||||
policy**, so a client never has to tell "this catalog says nothing" from "this
|
||||
title is not gated":
|
||||
|
||||
```json
|
||||
"access": { "gated": false, "entitled": true, "price": null,
|
||||
"purchaseUrl": null, "webUrl": null }
|
||||
```
|
||||
|
||||
The vocabulary is generic on purpose. A client reads more than one store, and a
|
||||
word from any one host's domain would make it specific to that host.
|
||||
|
||||
**A policy that raises is treated as a refusal**: an empty catalog and a denied
|
||||
download, logged. An artifact served because the gatekeeper crashed is the one
|
||||
failure mode this engine must not have.
|
||||
|
||||
## Client sign-in
|
||||
|
||||
A desktop client has no cookie jar and no browser session, so it cannot host a
|
||||
login form without asking somebody to type a password into a window that is not
|
||||
a browser. The engine implements the device authorization grant (RFC 8628)
|
||||
instead — but only where a host has said whose tokens these are:
|
||||
|
||||
```ruby
|
||||
c.access_token_owner_class = "User" # nil (default): no sign-in at all
|
||||
c.identity_verification_url = "/devices" # your page where a person types the code
|
||||
c.device_code_ttl = 600
|
||||
c.device_code_interval = 5
|
||||
```
|
||||
|
||||
With `access_token_owner_class` unset, `/api/auth/*` answers 404 and
|
||||
`GET /api/service` reports `auth: null`, so a client offers no sign-in.
|
||||
|
||||
The flow:
|
||||
|
||||
1. the client `POST`s `/api/auth/device` and shows the `userCode` it gets back;
|
||||
2. the person opens `verificationUrl` in a browser and types that code;
|
||||
3. **your page** calls `WarpEngine::DeviceGrantService#approve(user_code:, subject:)`
|
||||
with the signed-in user — approving needs a session and HTML, neither of
|
||||
which is the engine's business;
|
||||
4. the client's next `POST /api/auth/device/token` carries the token away. It is
|
||||
handed over exactly once and never stored in the clear afterwards.
|
||||
|
||||
### Recognising a browser
|
||||
|
||||
A bearer token is what a *client* carries; a browser carries a session, and the
|
||||
engine has no idea what a session is. A host that wants its signed-in visitors
|
||||
recognised on these endpoints too — so that clicking a download link on the site
|
||||
works the same way the client's download does — says how:
|
||||
|
||||
```ruby
|
||||
c.subject_resolver = ->(request) { request.env["warden"]&.user }
|
||||
```
|
||||
|
||||
Without one, a request with no bearer token is anonymous, which is what the
|
||||
read-only API always did. A resolver that raises is logged and treated as
|
||||
anonymous rather than taking the request down with it.
|
||||
|
||||
The token is a `WarpEngine::ApplicationToken` with the `catalog` scope, sent as
|
||||
`Authorization: Bearer …`. `DELETE /api/auth/token` revokes it (signing out),
|
||||
and the admin lists both kinds of token and the sign-ins behind them.
|
||||
|
||||
## Publish events
|
||||
|
||||
Publishing a release emits an `ActiveSupport::Notifications` event, so a host
|
||||
can react to a new build without hanging a callback on the models:
|
||||
|
||||
```ruby
|
||||
ActiveSupport::Notifications.subscribe("warp_engine.publish") do |*, payload|
|
||||
payload[:software] # WarpEngine::Software
|
||||
payload[:release] # WarpEngine::Release
|
||||
payload[:platform] # "godot"
|
||||
payload[:name] # "mygame"
|
||||
payload[:version] # "1.2.0"
|
||||
end
|
||||
```
|
||||
|
||||
Hosts that must support older engine versions can feature-detect with
|
||||
`WarpEngine.respond_to?(:instruments_publish?) && WarpEngine.instruments_publish?`.
|
||||
|
||||
Downloads emit one too — `warp_engine.download`, with `path`, `asset`,
|
||||
`release`, `software`, `subject` and the `Download` record — so a host can keep
|
||||
its own account of who fetched what without reaching into `DownloadService`.
|
||||
|
||||
## Public API
|
||||
|
||||
| Endpoint | Purpose |
|
||||
| --- | --- |
|
||||
| `GET /api/service` | What this deployment is: version, whether the catalog gates, and how to sign in (or that you cannot) |
|
||||
| `GET /api/software` | Full catalog with releases, assets, links, download counts and an `access` block; `?owner_id=` filters to one publisher |
|
||||
| `GET /api/software/highlighted` | The currently highlighted title |
|
||||
| `GET /api/builds` | Expected asset kinds per platform (build matrix) |
|
||||
| `GET /api/softwares/:name/builds` | Actual vs. missing build assets per release |
|
||||
| `GET /api/image/:id` | Serves catalog images |
|
||||
| `GET /api/download?path=` | Serves an artifact and logs a download record |
|
||||
| `GET /file/*path` | Serves static build output (web-playable games, docs) |
|
||||
| `POST /api/auth/device` | Starts a device sign-in; returns the code pair (404 without a client identity) |
|
||||
| `POST /api/auth/device/token` | Polls a device sign-in for its token |
|
||||
| `DELETE /api/auth/token` | Revokes the bearer token on the request (signing out) |
|
||||
|
||||
Every read endpoint accepts an optional `Authorization: Bearer …`; none requires
|
||||
one. What it changes is what the access policy is asked about — an anonymous
|
||||
caller is a normal, supported caller.
|
||||
|
||||
### `WarpEngine-Version`
|
||||
|
||||
Every response above carries the engine's version in a `WarpEngine-Version` header, so a
|
||||
client can branch on the engine's age without a round trip to ask:
|
||||
|
||||
```
|
||||
$ curl -sI https://teletypegames.org/api/software | grep -i warpengine
|
||||
WarpEngine-Version: 0.5.0
|
||||
```
|
||||
|
||||
Set before the action runs rather than after, which means an error response carries it
|
||||
too — a client needs the version most when something came back wrong. The name is
|
||||
`WarpEngine::VERSION_HEADER`, so nothing spells it out twice.
|
||||
|
||||
## Admin integration
|
||||
|
||||
The host owns the single ActiveAdmin instance — authentication (Devise),
|
||||
theme, assets and the `/admin` routes. WarpEngine only appends its resource
|
||||
files to `ActiveAdmin.application.load_paths`. Two things to copy into a new
|
||||
host:
|
||||
|
||||
- the small file-picker JS for release-asset path inputs (an iframe pointing
|
||||
at `/admin/files?picker=1&field=<dom_id>`) in your `active_admin.js`;
|
||||
- if you generate apipie docs, add
|
||||
`"#{WarpEngine::Engine.root}/app/controllers/**/*.rb"` to your
|
||||
`api_controllers_matcher`.
|
||||
|
||||
## Behavioral notes
|
||||
|
||||
- Every model is soft-deleted (`default_scope { where(deleted_at: nil) }`);
|
||||
the updater "resurrects" re-submitted, previously deleted records via
|
||||
`.unscoped`.
|
||||
- The JSON shape is intentionally bug-compatible with the former Go backend
|
||||
(Go zero-time timestamps, camelCase keys, legacy flat path fields).
|
||||
- Every model is soft-deleted (`default_scope { where(deleted_at: nil) }`).
|
||||
- The JSON shape is stable and intentionally bug-compatible with the project's
|
||||
former Go backend (Go zero-time timestamps, camelCase keys, legacy flat
|
||||
path fields).
|
||||
- Model extension points: `ActiveSupport.on_load(:warp_engine_<model>)` hooks.
|
||||
- **A software has one pipeline, and the newest assignment wins.** `Software#pipeline` is
|
||||
a `has_one`, so two pipelines pointing at the same software is not an error the database
|
||||
catches — it is a link that silently does nothing, with the software still showing
|
||||
whichever row came first. Assigning a software that another pipeline holds therefore
|
||||
*moves* it: the previous holder is left without one, the admin says which one it took it
|
||||
from, and `Pipeline#software_taken_from` carries that list for anything else that cares.
|
||||
Deliberately a callback rather than a unique index: rows here are soft-deleted, and a
|
||||
unique index counts deleted rows, so a pipeline removed last year would block its
|
||||
software from ever being linked again.
|
||||
|
||||
## Tests
|
||||
|
||||
The engine ships an RSpec suite running against a bundled dummy app:
|
||||
|
||||
```sh
|
||||
bundle install
|
||||
bundle exec rake app:db:prepare RAILS_ENV=test # warp_engine_test DB for the dummy app
|
||||
bundle exec rake app:db:prepare RAILS_ENV=test
|
||||
bundle exec rspec
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
This repository is a **read-only split mirror** — development happens in the
|
||||
[`tools/teletypegames`](https://git.teletypegames.org/tools/teletypegames)
|
||||
monorepo under `libs/ruby/warp_engine`, and CI republishes the mirror on every
|
||||
change. Please do not open pull requests against the mirror.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
|
||||
actions :index, :show, :new, :create, :edit, :update, :destroy
|
||||
permit_params :name, :owner_id, :expires_at, :scopes_string, :unrestricted
|
||||
|
||||
menu parent: "🌀 WarpEngine", priority: 9, label: "🎟️ App Tokens"
|
||||
|
||||
config.sort_order = "created_at_desc"
|
||||
config.batch_actions = false
|
||||
|
||||
scope :all, default: true
|
||||
scope("Active") { |scope| scope.where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||
scope("Expired") { |scope| scope.where("expires_at <= ?", Time.current) }
|
||||
# Two kinds of token share this table: one publishes software, the other reads the
|
||||
# catalog from somebody's desktop client. They are told apart by scope, and an admin
|
||||
# looking for one is rarely looking for the other.
|
||||
CATALOG_SCOPE_SQL = %(JSON_CONTAINS(COALESCE(scopes, '[]'), '"catalog"')).freeze
|
||||
scope("Publishing") { |scope| scope.where("NOT #{CATALOG_SCOPE_SQL}") }
|
||||
scope("Clients") { |scope| scope.where(CATALOG_SCOPE_SQL) }
|
||||
|
||||
index do
|
||||
id_column
|
||||
column :name
|
||||
column("Token") { |t| code "#{t.token_prefix}…", style: "font-family:monospace;" }
|
||||
column("Owner") { |t| t.owner.try(:email) || t.owner.try(:name) || "#{t.owner_type} ##{t.owner_id}" }
|
||||
column("Scopes") { |t| t.scopes_string }
|
||||
column :unrestricted
|
||||
column :expires_at
|
||||
column :last_used_at
|
||||
column :created_at
|
||||
actions
|
||||
end
|
||||
|
||||
filter :name_cont, label: "Name"
|
||||
filter :token_prefix_cont, label: "Token prefix"
|
||||
filter :expires_at
|
||||
filter :last_used_at
|
||||
|
||||
form do |f|
|
||||
owner_class = WarpEngine.config.application_token_owner_class&.safe_constantize
|
||||
f.inputs do
|
||||
if f.object.new_record?
|
||||
if owner_class
|
||||
f.input :owner_id, as: :select, label: owner_class.name,
|
||||
collection: owner_class.all.map { |o| [ o.try(:email) || o.try(:name) || "##{o.id}", o.id ] },
|
||||
include_blank: false
|
||||
else
|
||||
f.template.concat(f.template.content_tag(:li,
|
||||
"application_token_owner_class is not configured — tokens cannot be created.",
|
||||
class: "flash flash_error"))
|
||||
end
|
||||
end
|
||||
f.input :name
|
||||
f.input :scopes_string, label: "Scopes (comma separated)",
|
||||
hint: %(The "update" scope is required for /build/publish, the "upload" scope for /build/upload, the "catalog" scope for a client reading the API. Client tokens are normally issued by device sign-in rather than created here.)
|
||||
f.input :unrestricted, hint: "Internal token: exempt from owner isolation (enforce_software_ownership)."
|
||||
f.input :expires_at, hint: "Leave empty for a token that never expires."
|
||||
end
|
||||
f.actions
|
||||
end
|
||||
|
||||
action_item :rotate, only: :show do
|
||||
if WarpEngine.woodpecker_configured?
|
||||
link_to "Rotate Token", rotate_admin_application_token_path(resource),
|
||||
method: :post, data: { confirm: "This will revoke the current token, create a new one, and push it to Woodpecker. Continue?" }
|
||||
end
|
||||
end
|
||||
|
||||
member_action :rotate, method: :post do
|
||||
result = WarpEngine::SecretSyncService.new.rotate(resource)
|
||||
if result[:rotated]
|
||||
session[:warp_engine_plain_token] = result[:new_token].plain_token
|
||||
redirect_to resource_path(result[:new_token]),
|
||||
notice: "Token rotated and synced to #{result.dig(:sync_result, :synced)&.size || 0} pipeline(s)"
|
||||
else
|
||||
redirect_to resource_path(resource),
|
||||
alert: "Rotation failed: #{result[:reason]}"
|
||||
end
|
||||
end
|
||||
|
||||
show do
|
||||
if (plain = controller.instance_variable_get(:@plain_token))
|
||||
panel "⚠️ Token — shown only once, copy it now!" do
|
||||
pre plain, style: "font-family:monospace;font-size:14px;padding:8px;background:#fff3cd;user-select:all;"
|
||||
end
|
||||
end
|
||||
attributes_table do
|
||||
row :id
|
||||
row :name
|
||||
row("Token") { |t| code "#{t.token_prefix}… (SHA256 digest stored)" }
|
||||
row("Owner") { |t| "#{t.owner_type} ##{t.owner_id} — #{t.owner.try(:email) || t.owner.try(:name)}" }
|
||||
row("Scopes") { |t| t.scopes_string }
|
||||
row :unrestricted
|
||||
row :expires_at
|
||||
row :last_used_at
|
||||
row :created_at
|
||||
row :updated_at
|
||||
end
|
||||
end
|
||||
|
||||
controller do
|
||||
# The plain token only exists right after creation; it travels via the
|
||||
# session to its one-time display (flash is unsuitable: the AA layout
|
||||
# renders every flash key as a message bar).
|
||||
def create
|
||||
create! do |success, _failure|
|
||||
success.html do
|
||||
session[:warp_engine_plain_token] = resource.plain_token
|
||||
|
||||
if WarpEngine.woodpecker_configured?
|
||||
service = WarpEngine::SecretSyncService.new
|
||||
pipelines = service.pipelines_for_token(resource)
|
||||
if pipelines.any?
|
||||
result = service.provision(resource.plain_token, pipelines: pipelines)
|
||||
flash[:notice] = "Token created and synced to #{result[:synced].size} pipeline(s)."
|
||||
if result[:failed].any?
|
||||
flash[:alert] = "Failed to sync to #{result[:failed].size} pipeline(s)."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
redirect_to resource_path(resource) and return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
@plain_token = session.delete(:warp_engine_plain_token)
|
||||
show!
|
||||
end
|
||||
|
||||
def destroy
|
||||
if WarpEngine.woodpecker_configured?
|
||||
WarpEngine::SecretSyncService.new.deprovision(resource)
|
||||
end
|
||||
resource.revoke!
|
||||
redirect_to collection_path, notice: "Token revoked and Woodpecker secrets cleaned up."
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,62 @@
|
||||
ActiveAdmin.register WarpEngine::DeviceGrant, as: "Device Sign-in" do
|
||||
# Read-only on purpose. A grant is created by a client and answered by a person on the
|
||||
# host's own page; an admin creating one by hand would be issuing somebody else a
|
||||
# credential, which is not a thing this page should make easy.
|
||||
actions :index, :show
|
||||
|
||||
menu parent: "🌀 WarpEngine", priority: 10, label: "📱 Device Sign-ins",
|
||||
if: proc { WarpEngine.identity_configured? }
|
||||
|
||||
config.sort_order = "created_at_desc"
|
||||
config.batch_actions = false
|
||||
|
||||
scope :all, default: true
|
||||
scope("Pending") { |scope| scope.where(approved_at: nil, denied_at: nil).where(expires_at: Time.current..) }
|
||||
scope("Approved") { |scope| scope.where.not(approved_at: nil) }
|
||||
scope("Denied") { |scope| scope.where.not(denied_at: nil) }
|
||||
|
||||
index do
|
||||
id_column
|
||||
column("Code") { |g| code g.formatted_user_code, style: "font-family:monospace;" }
|
||||
column("Device") { |g| g.client_name }
|
||||
column("State") { |g| status_tag g.state.to_s }
|
||||
column("Who") do |g|
|
||||
next "—" if g.subject_id.blank?
|
||||
|
||||
subject = g.subject
|
||||
subject.try(:email) || subject.try(:name) || "#{g.subject_type} ##{g.subject_id}"
|
||||
end
|
||||
column :expires_at
|
||||
column :created_at
|
||||
end
|
||||
|
||||
filter :client_name_cont, label: "Device"
|
||||
filter :created_at
|
||||
filter :expires_at
|
||||
|
||||
show do
|
||||
attributes_table do
|
||||
row("User code") { |g| code g.formatted_user_code, style: "font-family:monospace;" }
|
||||
row("Device") { |g| g.client_name }
|
||||
row("State") { |g| status_tag g.state.to_s }
|
||||
row("Who") do |g|
|
||||
next "—" if g.subject_id.blank?
|
||||
|
||||
subject = g.subject
|
||||
subject.try(:email) || subject.try(:name) || "#{g.subject_type} ##{g.subject_id}"
|
||||
end
|
||||
# The device code itself is never shown: it is the client's live credential for as
|
||||
# long as the grant is pending, and this page is not where it should leak from.
|
||||
row("Token") do |g|
|
||||
token = g.application_token
|
||||
next "—" if token.nil?
|
||||
|
||||
link_to "#{token.token_prefix}… (#{token.name})", admin_application_token_path(token)
|
||||
end
|
||||
row :approved_at
|
||||
row :denied_at
|
||||
row :expires_at
|
||||
row :created_at
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
ActiveAdmin.register WarpEngine::Download, as: "Download" do
|
||||
actions :index, :show
|
||||
|
||||
menu priority: 7, label: "📊 Download Stats"
|
||||
menu parent: "🌀 WarpEngine", priority: 7, label: "📊 Download Stats"
|
||||
|
||||
scope :all, default: true
|
||||
scope("Today") { |scope| scope.where("downloads.created_at >= ?", Date.current.beginning_of_day) }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ActiveAdmin.register_page "Files" do
|
||||
menu priority: 6, label: "📁 Files"
|
||||
menu parent: "🌀 WarpEngine", priority: 6, label: "📁 Files"
|
||||
|
||||
content do
|
||||
service = WarpEngine::FileManagerService.new
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
ActiveAdmin.register WarpEngine::Image, as: "Image" do
|
||||
permit_params :file_upload
|
||||
|
||||
menu priority: 5, label: "🖼️ Images"
|
||||
menu parent: "🌀 WarpEngine", priority: 5, label: "🖼️ Images"
|
||||
|
||||
# SoftwareImage a natív használó; a hoston regisztrált image_owners
|
||||
# (WarpEngine.config) további használókat adhat hozzá (pl. TTG Member).
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
ActiveAdmin.register WarpEngine::Pipeline, as: "Pipeline" do
|
||||
actions :index, :show, :edit, :update
|
||||
|
||||
# Without this the edit form cannot save at all: ActiveAdmin hands unpermitted params to
|
||||
# the model and Rails raises ForbiddenAttributesError. The two fields here are the two
|
||||
# the form offers; everything else about a pipeline comes from the Woodpecker sync.
|
||||
permit_params :platform, :software_id
|
||||
|
||||
menu parent: "🌀 WarpEngine", priority: 10, label: "🚀 Pipelines"
|
||||
|
||||
config.sort_order = "repo_name_asc"
|
||||
config.batch_actions = false
|
||||
|
||||
scope :all, default: true
|
||||
scope("Active") { |s| s.where(active: true) }
|
||||
scope("Inactive") { |s| s.where(active: false) }
|
||||
|
||||
WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.each do |p|
|
||||
scope(p.capitalize) { |s| s.where(platform: p) }
|
||||
end
|
||||
|
||||
index do
|
||||
id_column
|
||||
column :repo_owner
|
||||
column :repo_name
|
||||
column :platform
|
||||
column("Software") { |r| r.software ? link_to(r.software.title, admin_software_path(r.software)) : "-" }
|
||||
column(:active) { |r| status_tag(r.active ? "active" : "inactive", class: r.active ? "yes" : "no") }
|
||||
column("Pipeline") { |r|
|
||||
if r.last_pipeline_status
|
||||
status_tag r.last_pipeline_status,
|
||||
class: r.last_pipeline_status == "success" ? "yes" : "no"
|
||||
else
|
||||
"-"
|
||||
end
|
||||
}
|
||||
column :last_pipeline_at
|
||||
actions defaults: true do |pipeline|
|
||||
if pipeline.active && WarpEngine.woodpecker_configured?
|
||||
item "Trigger", trigger_admin_pipeline_path(pipeline), method: :post, class: "member_link"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
filter :repo_name
|
||||
filter :platform, as: :select, collection: WarpEngine::PlatformLink::SUPPORTED_PLATFORMS
|
||||
filter :active
|
||||
|
||||
form do |f|
|
||||
f.inputs do
|
||||
f.input :platform, as: :select, collection: WarpEngine::PlatformLink::SUPPORTED_PLATFORMS
|
||||
f.input :software_id, as: :select,
|
||||
collection: WarpEngine::Software.order(:title).map { |s| [ s.title, s.id ] },
|
||||
include_blank: "- none -",
|
||||
hint: "One pipeline per software. Picking one that another pipeline already " \
|
||||
"has moves the link here — that pipeline is left without a software, " \
|
||||
"and the move is written to the log."
|
||||
end
|
||||
f.actions
|
||||
end
|
||||
|
||||
sidebar "Details", only: :show do
|
||||
attributes_table_for resource do
|
||||
row :id
|
||||
row :woodpecker_repo_id
|
||||
row :repo_owner
|
||||
row :repo_name
|
||||
row :platform
|
||||
row("Software") { |r| r.software ? link_to(r.software.title, admin_software_path(r.software)) : "-" }
|
||||
row(:active) { |r| status_tag(r.active ? "active" : "inactive", class: r.active ? "yes" : "no") }
|
||||
row :last_pipeline_status
|
||||
row :last_pipeline_at
|
||||
row :created_at
|
||||
row :updated_at
|
||||
end
|
||||
end
|
||||
|
||||
show do
|
||||
panel "Pipelines" do
|
||||
if !WarpEngine.woodpecker_configured?
|
||||
para "Woodpecker is not configured. Set woodpecker_url and woodpecker_api_token in the WarpEngine initializer.",
|
||||
style: "color:#999;"
|
||||
elsif !resource.active
|
||||
para "This repository is inactive.", style: "color:#999;"
|
||||
else
|
||||
begin
|
||||
pipelines = WarpEngine::PipelineService.new.list_pipelines(resource, page: 1)
|
||||
if pipelines.is_a?(Array) && pipelines.any?
|
||||
table_for pipelines.first(10) do
|
||||
column("Number") { |p| p["number"] }
|
||||
column("Status") { |p| status_tag p["status"], class: p["status"] == "success" ? "yes" : "no" }
|
||||
column("Branch") { |p| p["branch"] }
|
||||
column("Message") { |p| p["message"]&.truncate(60) }
|
||||
# Woodpecker returns unix epoch seconds in "created"
|
||||
column("Created") { |p| p["created"] ? Time.zone.at(p["created"]).strftime("%Y-%m-%d %H:%M") : "-" }
|
||||
end
|
||||
else
|
||||
para "No pipelines found.", style: "color:#999;"
|
||||
end
|
||||
rescue => e
|
||||
para "Error fetching pipelines: #{e.message}", style: "color:red;"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
member_action :trigger, method: :post do
|
||||
pipeline = WarpEngine::Pipeline.find(params[:id])
|
||||
WarpEngine::PipelineService.new.trigger(pipeline)
|
||||
redirect_to resource_path(pipeline), notice: "Pipeline triggered for #{pipeline.full_name}"
|
||||
rescue => e
|
||||
redirect_to resource_path(pipeline), alert: "Trigger failed: #{e.message}"
|
||||
end
|
||||
|
||||
collection_action :sync, method: :post do
|
||||
result = WarpEngine::PipelineSyncService.new.sync_all
|
||||
redirect_to collection_path,
|
||||
notice: "Synced: #{result[:created].size} new, #{result[:updated].size} updated, #{result[:deactivated].size} deactivated"
|
||||
rescue => e
|
||||
redirect_to collection_path, alert: "Sync failed: #{e.message}"
|
||||
end
|
||||
|
||||
action_item :sync_repos, only: :index do
|
||||
if WarpEngine.woodpecker_configured?
|
||||
link_to "Sync from Woodpecker", sync_admin_pipelines_path, method: :post
|
||||
end
|
||||
end
|
||||
|
||||
controller do
|
||||
def scoped_collection
|
||||
super.includes(:software)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
ActiveAdmin.register WarpEngine::PlatformLink, as: "Platform Link" do
|
||||
permit_params :name, :url, :platform, :position
|
||||
|
||||
menu priority: 5, label: "🔗 Platform Links"
|
||||
menu parent: "🌀 WarpEngine", priority: 5, label: "🔗 Platform Links"
|
||||
|
||||
config.sort_order = "platform_asc"
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ ActiveAdmin.register WarpEngine::Software, as: "Software" do
|
||||
releases_attributes: [ :id, :version, :_destroy,
|
||||
{ release_assets_attributes: [ :id, :kind, :path, :_destroy ] } ]
|
||||
|
||||
menu priority: 2, label: "🎮 Softwares"
|
||||
menu parent: "🌀 WarpEngine", priority: 2, label: "🎮 Softwares"
|
||||
|
||||
actions :all, except: [ :edit ]
|
||||
|
||||
@@ -52,6 +52,15 @@ ActiveAdmin.register WarpEngine::Software, as: "Software" do
|
||||
text_node "View on site"
|
||||
end
|
||||
end
|
||||
|
||||
if resource.pipeline
|
||||
div style: "margin-bottom:8px;" do
|
||||
a href: admin_pipeline_path(resource.pipeline), style: "display:inline-flex;align-items:center;gap:6px;font-weight:bold;color:#5850ec;" do
|
||||
span "🚀", style: "font-size:16px;"
|
||||
text_node "Pipelines"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
sidebar "Download Statistics", only: :show do
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
module WarpEngine
|
||||
# Who the caller is, on the read-only side of the API.
|
||||
#
|
||||
# Distinct from UpdateAuthentication, which guards publishing: that one asks "may this
|
||||
# pipeline write to the catalog", this one asks "whose library am I looking at". The
|
||||
# answer is allowed to be nobody — an anonymous caller is a normal, supported caller,
|
||||
# and a catalog with no policy configured never needs one.
|
||||
#
|
||||
# The credential is a bearer token, because that is what a client can carry: it has no
|
||||
# cookie jar and no browser session.
|
||||
module SubjectAuthentication
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
# The ApplicationToken behind the request, or nil.
|
||||
def current_access_token
|
||||
return @current_access_token if defined?(@current_access_token)
|
||||
|
||||
@current_access_token = resolve_access_token
|
||||
end
|
||||
|
||||
# Whoever the request is on behalf of — the host's own object, or nil.
|
||||
#
|
||||
# Two ways to be somebody, tried in that order. A bearer token is what a client
|
||||
# carries. A *browser* carries a session instead, and the engine has no idea what a
|
||||
# session is here — so a host that wants its signed-in visitors recognised on these
|
||||
# endpoints supplies a resolver:
|
||||
#
|
||||
# c.subject_resolver = ->(request) { request.env["warden"]&.user }
|
||||
#
|
||||
# Without one, a browser is simply anonymous, which is what it always was.
|
||||
def current_subject
|
||||
return @current_subject if defined?(@current_subject)
|
||||
|
||||
@current_subject = current_access_token&.owner || resolve_host_subject
|
||||
end
|
||||
|
||||
def resolve_access_token
|
||||
return nil unless WarpEngine.identity_configured?
|
||||
|
||||
token = bearer_token
|
||||
return nil if token.blank?
|
||||
|
||||
record = WarpEngine::ApplicationToken.authenticate(
|
||||
token, required_scope: WarpEngine::ApplicationToken::CATALOG_SCOPE
|
||||
)
|
||||
return nil if record.nil?
|
||||
|
||||
record.touch_last_used!
|
||||
record
|
||||
end
|
||||
|
||||
# A resolver that raises must not take the request with it: it runs on every read
|
||||
# endpoint, and a broken one would turn the whole API into 500s rather than into
|
||||
# anonymous requests, which is the honest fallback.
|
||||
def resolve_host_subject
|
||||
resolver = WarpEngine.config.subject_resolver
|
||||
return nil if resolver.nil?
|
||||
|
||||
resolver.call(request)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WarpEngine::SubjectAuthentication] subject_resolver #{e.class}: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def bearer_token
|
||||
header = request.headers["Authorization"].to_s
|
||||
return nil unless header.start_with?("Bearer ")
|
||||
|
||||
header.delete_prefix("Bearer ").strip.presence
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
module WarpEngine
|
||||
# Token authentication for the publishing (/build/*) endpoints.
|
||||
# The auth source is exclusive: in :database mode the shared secret is not
|
||||
# accepted, in :env mode DB tokens are not.
|
||||
module UpdateAuthentication
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
attr_reader :current_application_token
|
||||
|
||||
# The token is accepted from the X-Update-Secret header only — in the URL
|
||||
# it would leak into proxy and access logs.
|
||||
def update_authorized?(required_scope:)
|
||||
token = request.headers["X-Update-Secret"].presence
|
||||
return false if token.blank?
|
||||
|
||||
case WarpEngine.config.application_token_source
|
||||
when :database then database_token_authorized?(token, required_scope)
|
||||
else env_secret_authorized?(token)
|
||||
end
|
||||
end
|
||||
|
||||
def env_secret_authorized?(token)
|
||||
expected = WarpEngine.config.update_secret
|
||||
# With no secret configured the endpoint stays closed.
|
||||
expected.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected)
|
||||
end
|
||||
|
||||
def database_token_authorized?(token, required_scope)
|
||||
if WarpEngine.config.application_token_owner_class.blank?
|
||||
Rails.logger.error("[#{self.class.name}] application_token_source=:database but application_token_owner_class is not set — rejecting every request")
|
||||
return false
|
||||
end
|
||||
|
||||
record = WarpEngine::ApplicationToken.authenticate(token, required_scope: required_scope)
|
||||
return false if record.nil?
|
||||
|
||||
record.touch_last_used!
|
||||
@current_application_token = record
|
||||
true
|
||||
end
|
||||
|
||||
# Ownership enforcement applies only in :database mode (there is a token)
|
||||
# with enforce_software_ownership on. An ownerless software is up for grabs
|
||||
# until the backfill — backfill before enabling the enforcement.
|
||||
def software_ownership_authorized?(name)
|
||||
return true unless WarpEngine.config.enforce_software_ownership
|
||||
|
||||
token = current_application_token
|
||||
return true if token.nil? || token.unrestricted?
|
||||
|
||||
software = WarpEngine::Software.find_by(name: name)
|
||||
return true if software.nil? || software.owner_id.nil?
|
||||
|
||||
software.owner_type == token.owner_type && software.owner_id == token.owner_id
|
||||
end
|
||||
|
||||
# A first-published (or pre-backfill, ownerless) software gets the
|
||||
# submitting token's owner. Unrestricted (internal) tokens claim nothing.
|
||||
def claim_software_ownership(name)
|
||||
token = current_application_token
|
||||
return if token.nil? || token.unrestricted?
|
||||
|
||||
software = WarpEngine::Software.find_by(name: name)
|
||||
return if software.nil? || software.owner_id.present?
|
||||
|
||||
software.update_columns(owner_type: token.owner_type, owner_id: token.owner_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
module WarpEngine
|
||||
# The device authorization grant, client side (RFC 8628).
|
||||
#
|
||||
# Both actions are unauthenticated, and have to be: the whole point of the flow is
|
||||
# that the caller has no credential yet. What protects it is that a device code is
|
||||
# useless until a signed-in person approves it on the host's own page.
|
||||
class Api::Auth::DevicesController < ApiController
|
||||
before_action :ensure_identity_configured
|
||||
|
||||
resource_description do
|
||||
short "Device sign-in"
|
||||
end
|
||||
|
||||
api :POST, "/api/auth/device", "Start a device sign-in and get a code pair"
|
||||
param :client_name, String, required: false, desc: "What to call this device in the person's account"
|
||||
returns code: 200, desc: "The code pair and where to take it"
|
||||
error code: 404, desc: "This deployment has no client sign-in"
|
||||
def create
|
||||
grant = service.request(client_name: params[:client_name])
|
||||
|
||||
render json: {
|
||||
deviceCode: grant.device_code,
|
||||
userCode: grant.formatted_user_code,
|
||||
verificationUrl: service.verification_url(base_url: request.base_url),
|
||||
interval: WarpEngine.config.device_code_interval.to_i,
|
||||
expiresIn: (grant.expires_at - Time.current).to_i
|
||||
}
|
||||
end
|
||||
|
||||
api :POST, "/api/auth/device/token", "Poll a device sign-in for its token"
|
||||
param :device_code, String, required: true, desc: "The device code from POST /api/auth/device"
|
||||
returns code: 200, desc: "state is one of pending, approved, denied, expired"
|
||||
error code: 404, desc: "No such device code, or no client sign-in here"
|
||||
def token
|
||||
state, plain = service.poll(device_code: params[:device_code])
|
||||
|
||||
# The token rides on the one poll that finds the grant newly approved; a client
|
||||
# that loses it starts the flow again. Keeping a plain token around to hand out
|
||||
# twice would mean storing it, which is the thing this design avoids.
|
||||
body = { state: state.to_s }
|
||||
body[:token] = plain if plain.present?
|
||||
render json: body
|
||||
rescue WarpEngine::DeviceGrantService::UnknownCode
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def service
|
||||
@service ||= WarpEngine::DeviceGrantService.new
|
||||
end
|
||||
|
||||
# A deployment with no configured subject class has no sign-in at all, and says so
|
||||
# the same way GET /api/service does — by not offering it.
|
||||
def ensure_identity_configured
|
||||
return if WarpEngine.identity_configured?
|
||||
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
module WarpEngine
|
||||
# Signing out: a client throws away its own token.
|
||||
#
|
||||
# Revocation is a soft delete on the ApplicationToken, so the record of which device
|
||||
# signed in and when survives it. The person's own list of devices — where somebody
|
||||
# revokes a token for a laptop they no longer have — is the host's page, because it
|
||||
# needs a session and a browser.
|
||||
class Api::Auth::TokensController < ApiController
|
||||
resource_description do
|
||||
short "Client tokens"
|
||||
end
|
||||
|
||||
api :DELETE, "/api/auth/token", "Revoke the bearer token this request carries"
|
||||
returns code: 204, desc: "Revoked"
|
||||
error code: 401, desc: "No usable bearer token on the request"
|
||||
def destroy
|
||||
token = current_access_token
|
||||
return head(:unauthorized) if token.nil?
|
||||
|
||||
WarpEngine::DeviceGrantService.new.revoke(token: token)
|
||||
head :no_content
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,74 @@
|
||||
module WarpEngine
|
||||
module Api
|
||||
class CiController < ApiController
|
||||
include UpdateAuthentication
|
||||
|
||||
before_action :require_woodpecker!
|
||||
|
||||
resource_description do
|
||||
short "CI pipeline management"
|
||||
end
|
||||
|
||||
api :GET, "/api/ci/pipelines", "List active pipelines"
|
||||
returns code: 200, desc: "JSON array of tracked pipelines"
|
||||
error code: 503, desc: "Woodpecker not configured"
|
||||
def pipelines
|
||||
records = Pipeline.active.includes(:software)
|
||||
render json: records.map { |p| pipeline_json(p) }
|
||||
end
|
||||
|
||||
api :GET, "/api/ci/pipelines/:id/status", "Get a pipeline with its latest run"
|
||||
param :id, :number, required: true, desc: "Pipeline id"
|
||||
returns code: 200, desc: "JSON with pipeline and latest run data"
|
||||
error code: 503, desc: "Woodpecker not configured"
|
||||
def status
|
||||
pipeline = Pipeline.find(params[:id])
|
||||
latest_run = begin
|
||||
PipelineService.new.pipeline_detail(pipeline, "latest")
|
||||
rescue WoodpeckerClient::ApiError
|
||||
nil
|
||||
end
|
||||
render json: { pipeline: pipeline_json(pipeline), latest_run: latest_run }
|
||||
end
|
||||
|
||||
api :POST, "/api/ci/pipelines/:id/trigger", "Trigger a pipeline"
|
||||
header "X-Update-Secret", "Shared secret or application token (update scope)", required: true
|
||||
param :id, :number, required: true, desc: "Pipeline id"
|
||||
param :branch, String, required: false, desc: "Branch to build (default: main)"
|
||||
returns code: 200, desc: "JSON with triggered run data"
|
||||
error code: 401, desc: "Invalid secret"
|
||||
error code: 503, desc: "Woodpecker not configured"
|
||||
def trigger
|
||||
unless update_authorized?(required_scope: ApplicationToken::UPDATE_SCOPE)
|
||||
return render json: { error: "Unauthorized" }, status: :unauthorized
|
||||
end
|
||||
|
||||
pipeline = Pipeline.find(params[:id])
|
||||
result = PipelineService.new.trigger(pipeline, branch: params[:branch] || "main")
|
||||
render json: { triggered: true, pipeline: result }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def require_woodpecker!
|
||||
return if WarpEngine.woodpecker_configured?
|
||||
|
||||
render json: { error: "Woodpecker not configured" }, status: :service_unavailable
|
||||
end
|
||||
|
||||
def pipeline_json(pipeline)
|
||||
{
|
||||
id: pipeline.id,
|
||||
woodpecker_repo_id: pipeline.woodpecker_repo_id,
|
||||
repo_owner: pipeline.repo_owner,
|
||||
repo_name: pipeline.repo_name,
|
||||
platform: pipeline.platform,
|
||||
active: pipeline.active,
|
||||
software_name: pipeline.software&.name,
|
||||
last_pipeline_status: pipeline.last_pipeline_status,
|
||||
last_pipeline_at: pipeline.last_pipeline_at
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -8,23 +8,29 @@ module WarpEngine
|
||||
api :GET, "/api/download", "Download a file by path"
|
||||
param :path, String, required: true, desc: "File path to download"
|
||||
returns code: 200, desc: "File binary data"
|
||||
returns code: 302, desc: "Redirect to the storage location (non-local storage adapter)"
|
||||
error code: 400, desc: "Path is blank"
|
||||
error code: 403, desc: "The access policy refused this caller"
|
||||
error code: 404, desc: "File not found"
|
||||
def show
|
||||
path = params[:path]
|
||||
return render(json: { error: "Path is required" }, status: :bad_request) if path.blank?
|
||||
|
||||
full_path = WarpEngine::DownloadService.new.create(
|
||||
location = WarpEngine::DownloadService.new.locate(
|
||||
path: path,
|
||||
ip: request.remote_ip,
|
||||
user_agent: request.user_agent,
|
||||
referer: request.referer
|
||||
referer: request.referer,
|
||||
subject: current_subject,
|
||||
request: request
|
||||
)
|
||||
|
||||
if full_path
|
||||
send_file full_path, disposition: "attachment", type: resolve_mime(full_path)
|
||||
else
|
||||
if location.nil?
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
elsif location.redirect?
|
||||
redirect_to location.url, allow_other_host: true
|
||||
else
|
||||
send_file location.path, disposition: "attachment", type: resolve_mime(location.path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
module WarpEngine
|
||||
# What this deployment is and what it can do, in one unauthenticated request.
|
||||
#
|
||||
# This is how a client stops guessing. Before it existed, everything a client knew
|
||||
# about a store was compiled into the client — which endpoints to call, whether
|
||||
# signing in was a thing here, where to send somebody who wanted to buy something.
|
||||
# Every one of those is a property of the *server*, and a client that carries them
|
||||
# can only ever serve the one store it was built for.
|
||||
class Api::ServiceController < ApiController
|
||||
resource_description do
|
||||
short "Service descriptor"
|
||||
end
|
||||
|
||||
api :GET, "/api/service", "What this WarpEngine deployment offers"
|
||||
desc <<~DESC
|
||||
Public on purpose: a client reads this *before* it can have a credential.
|
||||
|
||||
`auth` is null where the host has configured no client identity — the catalog is
|
||||
open, there is nobody to sign in as, and a client should not offer to. Where it is
|
||||
present, `auth.device` describes the device authorization grant a client with no
|
||||
browser of its own uses to sign in.
|
||||
|
||||
`catalog.gated` says whether any title here can require an entitlement. A client
|
||||
can render a store that never gates differently from one that sometimes does,
|
||||
without having to read the whole catalog first to find out.
|
||||
DESC
|
||||
returns code: 200, desc: "The descriptor" do
|
||||
property :engine, String, desc: "Always 'warp_engine'"
|
||||
property :version, String, desc: "Engine version, same value as the WarpEngine-Version header"
|
||||
property :catalog, Hash, desc: "Catalog properties" do
|
||||
property :gated, :boolean, desc: "Whether titles here can require an entitlement"
|
||||
end
|
||||
property :auth, Hash, desc: "How to sign in, or null where there is no sign-in"
|
||||
end
|
||||
def show
|
||||
render json: {
|
||||
engine: "warp_engine",
|
||||
version: WarpEngine::VERSION,
|
||||
catalog: { gated: !WarpEngine::AccessPolicy.open? },
|
||||
auth: auth_descriptor
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def auth_descriptor
|
||||
return nil unless WarpEngine.identity_configured?
|
||||
|
||||
service = WarpEngine::DeviceGrantService.new
|
||||
{
|
||||
schemes: [ "bearer" ],
|
||||
device: {
|
||||
authorizeUrl: warp_engine.api_auth_device_url,
|
||||
tokenUrl: warp_engine.api_auth_device_token_url,
|
||||
revokeUrl: warp_engine.api_auth_token_url,
|
||||
verificationUrl: service.verification_url(base_url: request.base_url),
|
||||
interval: WarpEngine.config.device_code_interval.to_i
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -29,6 +29,7 @@ module WarpEngine
|
||||
end
|
||||
|
||||
api :GET, "/api/software", "List all software entries with releases"
|
||||
param :owner_id, :number, required: false, desc: "Filter to the softwares of one owner (publisher)"
|
||||
returns code: 200, desc: "Wrapper object with softwares array" do
|
||||
property :softwares, Array, desc: "Array of software entries" do
|
||||
property :ID, Integer, desc: "Software ID"
|
||||
@@ -55,7 +56,7 @@ module WarpEngine
|
||||
end
|
||||
end
|
||||
def index
|
||||
render json: WarpEngine::SoftwareService.new.index
|
||||
render json: WarpEngine::SoftwareService.new.index(owner_id: params[:owner_id], subject: current_subject)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ module WarpEngine
|
||||
end
|
||||
error code: 404, desc: "No highlighted software found"
|
||||
def index
|
||||
result = WarpEngine::SoftwareHighlightedService.new.index
|
||||
result = WarpEngine::SoftwareHighlightedService.new.index(subject: current_subject)
|
||||
if result
|
||||
render json: result
|
||||
else
|
||||
|
||||
@@ -5,6 +5,16 @@ module WarpEngine
|
||||
formats [ "json" ]
|
||||
end
|
||||
|
||||
# Every response the engine serves names the version that served it, so a client can
|
||||
# branch on the engine's age without a round trip to ask. Set *before* the action,
|
||||
# not after: an error handled by `rescue_from` never reaches an after_action, and a
|
||||
# client needs the version most when something came back wrong.
|
||||
before_action :set_version_header
|
||||
|
||||
# Every read-only endpoint may be called with a bearer token; none of them requires
|
||||
# one. See WarpEngine::SubjectAuthentication.
|
||||
include WarpEngine::SubjectAuthentication
|
||||
|
||||
rescue_from StandardError do |e|
|
||||
Rails.logger.error("[#{self.class.name}] #{e.class}: #{e.message}")
|
||||
render json: { error: "Internal server error" }, status: :internal_server_error
|
||||
@@ -22,8 +32,22 @@ module WarpEngine
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
end
|
||||
|
||||
rescue_from WarpEngine::DownloadService::Denied do
|
||||
render json: { error: "Forbidden" }, status: :forbidden
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_version_header
|
||||
# The name is spelled out here rather than taken from WarpEngine::VERSION_HEADER on
|
||||
# purpose. A deployed process can end up with these controllers and an older
|
||||
# `lib/` — it happened on the first deploy of this feature — and a controller that
|
||||
# needs a constant from the newer half answers 500 to every request instead of
|
||||
# serving the catalog. A response header is not worth that fragility. The constant
|
||||
# is still the documented name, and a spec holds the two together.
|
||||
response.headers["WarpEngine-Version"] = WarpEngine::VERSION
|
||||
end
|
||||
|
||||
def resolve_mime(path)
|
||||
ext = File.extname(path.to_s).delete_prefix(".")
|
||||
Mime::Type.lookup_by_extension(ext) || "application/octet-stream"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
module WarpEngine
|
||||
module Build
|
||||
# Woodpecker configuration-extension endpoint: on every pipeline start the
|
||||
# CI server POSTs the repo's marker file and receives the platform's full
|
||||
# pipeline YAML. GET renders the same thing as a preview.
|
||||
class ConfigsController < ApiController
|
||||
resource_description do
|
||||
short "Woodpecker CI pipeline configs"
|
||||
end
|
||||
|
||||
api :GET, "/build/config", "Preview the generated pipeline config for a platform"
|
||||
param :platform, String, required: true, desc: "Platform (a configured ci_platforms key, e.g. tic80)"
|
||||
param :name, String, required: false, desc: "Software name substituted into the pipeline (default: example)"
|
||||
returns code: 200, desc: "Pipeline YAML (text/yaml)"
|
||||
error code: 404, desc: "Unknown platform"
|
||||
def show
|
||||
yaml = render_config(platform: params[:platform], name: params[:name].presence || "example")
|
||||
return render json: { error: "Unknown platform" }, status: :not_found if yaml.nil?
|
||||
|
||||
render plain: yaml, content_type: "text/yaml"
|
||||
end
|
||||
|
||||
api :POST, "/build/config", "Woodpecker configuration extension endpoint"
|
||||
description <<~DESC
|
||||
Called by the Woodpecker server on every pipeline start (httpsig-signed request).
|
||||
If the repo's .woodpecker.yaml is a marker (has a `platform:` key), responds with
|
||||
the generated pipeline; otherwise responds 204 so the repo's own config runs.
|
||||
DESC
|
||||
returns code: 200, desc: %(JSON: {"configs": [{"name": ..., "data": "<pipeline YAML>"}]})
|
||||
returns code: 204, desc: "Not a marker config — keep the repo's own configuration"
|
||||
error code: 403, desc: "Missing or invalid request signature"
|
||||
error code: 422, desc: "Marker requests an unknown platform"
|
||||
def create
|
||||
unless WarpEngine::CiSignatureVerifier.new(request).valid?
|
||||
return render json: { error: "Invalid signature" }, status: :forbidden
|
||||
end
|
||||
|
||||
marker = find_marker
|
||||
return head :no_content if marker.nil?
|
||||
|
||||
platform = marker["platform"].to_s
|
||||
name = marker["name"].presence || repo_name
|
||||
yaml = render_config(platform: platform, name: name)
|
||||
if yaml.nil?
|
||||
return render json: { error: "Unknown platform: #{platform}" }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
render json: { configs: [ { name: platform, data: yaml } ] }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_config(platform:, name:)
|
||||
WarpEngine::CiConfigService.new.render(
|
||||
platform: platform,
|
||||
name: name,
|
||||
update_server: WarpEngine.config.ci_update_server.presence || request.base_url
|
||||
)
|
||||
end
|
||||
|
||||
# The first submitted config that parses as a marker (Hash with a `platform`
|
||||
# key). The docs call the key "configuration", the example-config-service
|
||||
# uses "configs" — accept both.
|
||||
def find_marker
|
||||
configs = params[:configuration].presence || params[:configs].presence || []
|
||||
configs.each do |config|
|
||||
data = config[:data].to_s
|
||||
parsed = begin
|
||||
YAML.safe_load(data)
|
||||
rescue Psych::Exception
|
||||
nil
|
||||
end
|
||||
return parsed if parsed.is_a?(Hash) && parsed.key?("platform")
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def repo_name
|
||||
params.dig(:repo, :name).to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
module WarpEngine
|
||||
module Build
|
||||
class PublishController < ApiController
|
||||
include UpdateAuthentication
|
||||
|
||||
resource_description do
|
||||
short "Build release publishing"
|
||||
end
|
||||
|
||||
api :POST, "/build/publish", "Register an uploaded build as a release"
|
||||
header "X-Update-Secret", "Shared secret or application token (update scope)", required: true
|
||||
param :name, String, required: true, desc: "Software name"
|
||||
param :platform, String, required: true, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)"
|
||||
param :version, String, required: true, desc: "Version string"
|
||||
returns code: 200, desc: "JSON with the published name/platform/version"
|
||||
error code: 401, desc: "Invalid secret"
|
||||
error code: 403, desc: "Token does not own this software"
|
||||
error code: 400, desc: "Missing or invalid arguments"
|
||||
def create
|
||||
unless update_authorized?(required_scope: WarpEngine::ApplicationToken::UPDATE_SCOPE)
|
||||
return render json: { error: "Unauthorized" }, status: :unauthorized
|
||||
end
|
||||
|
||||
%i[name platform version].each do |key|
|
||||
return render json: { error: "#{key.to_s.capitalize} not provided" }, status: :bad_request if params[key].blank?
|
||||
end
|
||||
|
||||
unless software_ownership_authorized?(params[:name])
|
||||
return render json: { error: "Forbidden" }, status: :forbidden
|
||||
end
|
||||
|
||||
input = WarpEngine::PublishInputDto.new(
|
||||
platform: params[:platform],
|
||||
name: params[:name],
|
||||
version: params[:version]
|
||||
)
|
||||
|
||||
WarpEngine::PublishService.new.publish(input)
|
||||
claim_software_ownership(params[:name])
|
||||
|
||||
render json: { published: true, name: params[:name], platform: params[:platform], version: params[:version] }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,65 @@
|
||||
require "digest"
|
||||
|
||||
module WarpEngine
|
||||
module Build
|
||||
class UploadsController < ApiController
|
||||
include UpdateAuthentication
|
||||
|
||||
resource_description do
|
||||
short "Build artifact upload"
|
||||
end
|
||||
|
||||
# Release file naming convention: <name>-<version>.<ext> or
|
||||
# <name>-<version>-<target>.zip — the updater looks for these too.
|
||||
NAME_FORMAT = /\A[A-Za-z0-9._-]+\z/
|
||||
|
||||
api :POST, "/build/upload", "Upload a build artifact into the artifact directory"
|
||||
header "X-Update-Secret", "Shared secret or application token (upload scope)", required: true
|
||||
param :name, String, required: true, desc: "Software name (filename must be prefixed with <name>-<version>)"
|
||||
param :version, String, required: true, desc: "Version string"
|
||||
param :file, File, required: true, desc: "Artifact file (multipart)"
|
||||
param :sha256, String, required: false, desc: "Expected SHA256 hex digest; on mismatch the upload is rejected"
|
||||
returns code: 200, desc: "JSON with stored file name, size and sha256"
|
||||
error code: 401, desc: "Invalid secret"
|
||||
error code: 403, desc: "Token does not own this software"
|
||||
error code: 400, desc: "Missing or invalid arguments"
|
||||
error code: 413, desc: "File larger than max_upload_size"
|
||||
error code: 422, desc: "SHA256 mismatch"
|
||||
def create
|
||||
unless update_authorized?(required_scope: WarpEngine::ApplicationToken::UPLOAD_SCOPE)
|
||||
return render json: { error: "Unauthorized" }, status: :unauthorized
|
||||
end
|
||||
|
||||
name = params[:name].to_s
|
||||
version = params[:version].to_s
|
||||
file = params[:file]
|
||||
|
||||
return render json: { error: "Invalid name" }, status: :bad_request unless name.match?(NAME_FORMAT)
|
||||
return render json: { error: "Invalid version" }, status: :bad_request unless version.match?(NAME_FORMAT)
|
||||
return render json: { error: "File not provided" }, status: :bad_request unless file.respond_to?(:original_filename)
|
||||
|
||||
unless software_ownership_authorized?(name)
|
||||
return render json: { error: "Forbidden" }, status: :forbidden
|
||||
end
|
||||
|
||||
filename = File.basename(file.original_filename.to_s)
|
||||
unless filename.start_with?("#{name}-#{version}.", "#{name}-#{version}-")
|
||||
return render json: { error: "Filename must be prefixed with #{name}-#{version}" }, status: :bad_request
|
||||
end
|
||||
|
||||
max = WarpEngine.config.max_upload_size
|
||||
if file.size > max
|
||||
return render json: { error: "File too large (max #{max / (1024 * 1024)}MB)" }, status: :payload_too_large
|
||||
end
|
||||
|
||||
digest = Digest::SHA256.file(file.tempfile.path).hexdigest
|
||||
if params[:sha256].present? && !ActiveSupport::SecurityUtils.secure_compare(params[:sha256].downcase, digest)
|
||||
return render json: { error: "SHA256 mismatch" }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
stored = WarpEngine::FileManagerService.new.upload("", file)
|
||||
render json: { file: stored, size: file.size, sha256: digest }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -9,9 +9,12 @@ module WarpEngine
|
||||
param :path, String, required: true, desc: "File path"
|
||||
returns code: 200, desc: "File binary data"
|
||||
returns code: 301, desc: "Redirect to file URL"
|
||||
error code: 403, desc: "The access policy refused this caller"
|
||||
error code: 404, desc: "File not found"
|
||||
def show
|
||||
result = WarpEngine::FileService.new.show(WarpEngine::FileShowInputDto.new(path: params[:path]))
|
||||
result = WarpEngine::FileService.new.show(
|
||||
WarpEngine::FileShowInputDto.new(path: params[:path]), subject: current_subject
|
||||
)
|
||||
case result.type
|
||||
when :redirect then redirect_to result.url, status: :moved_permanently
|
||||
when :file then send_file result.path, disposition: "inline", type: resolve_mime(result.path)
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
module WarpEngine
|
||||
class UpdateController < ApiController
|
||||
resource_description do
|
||||
short "Software updater"
|
||||
formats [ "text" ]
|
||||
end
|
||||
|
||||
rescue_from ArgumentError do |e|
|
||||
render plain: e.message, status: :bad_request
|
||||
end
|
||||
|
||||
rescue_from StandardError do |e|
|
||||
Rails.logger.error("[UpdateController] #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
||||
render plain: "Internal server error", status: :internal_server_error
|
||||
end
|
||||
|
||||
api :GET, "/update", "Update software version in database"
|
||||
param :secret, String, required: true, desc: "Authorization secret"
|
||||
param :platform, String, required: false, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)"
|
||||
param :name, String, required: false, desc: "Software name"
|
||||
param :version, String, required: true, desc: "Version string"
|
||||
returns code: 200, desc: "Plain text 'Updated'"
|
||||
error code: 401, desc: "Invalid secret"
|
||||
error code: 400, desc: "Version not provided or invalid arguments"
|
||||
error code: 500, desc: "Internal server error"
|
||||
def update
|
||||
return render plain: "Unauthorized", status: :unauthorized unless authorized?
|
||||
return render plain: "Version not provided", status: :bad_request if params[:version].blank?
|
||||
|
||||
input = WarpEngine::UpdateInputDto.new(
|
||||
platform: params[:platform],
|
||||
name: params[:name],
|
||||
version: params[:version]
|
||||
)
|
||||
|
||||
WarpEngine::UpdateService.new.update(input)
|
||||
render plain: "Updated"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authorized?
|
||||
secret = request.headers["X-Update-Secret"].presence || params[:secret]
|
||||
expected = WarpEngine.config.update_secret
|
||||
# Konfigurálatlan secret esetén az endpoint zárva marad.
|
||||
expected.present? && secret == expected
|
||||
end
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
UpdateInputDto = Struct.new(:platform, :name, :version, keyword_init: true) do
|
||||
PublishInputDto = Struct.new(:platform, :name, :version, keyword_init: true) do
|
||||
def initialize(platform:, name:, version: nil)
|
||||
super
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
module WarpEngine
|
||||
class ApplicationJob < ActiveJob::Base
|
||||
retry_on WoodpeckerClient::ConnectionError, wait: 30.seconds, attempts: 3
|
||||
discard_on WoodpeckerClient::ApiError do |job, error|
|
||||
Rails.logger.error("[#{job.class.name}] discarded: #{error.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
module WarpEngine
|
||||
class PipelineSyncJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
PipelineSyncService.new.sync_all
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,112 @@
|
||||
require "digest"
|
||||
|
||||
module WarpEngine
|
||||
class ApplicationToken < ApplicationRecord
|
||||
self.table_name = "application_tokens"
|
||||
|
||||
UPDATE_SCOPE = "update".freeze
|
||||
UPLOAD_SCOPE = "upload".freeze
|
||||
# A token held by a *client* rather than a publisher: it reads the catalog and
|
||||
# downloads artifacts, and it never publishes anything.
|
||||
CATALOG_SCOPE = "catalog".freeze
|
||||
|
||||
# The generated token is only available in memory at creation time — the DB
|
||||
# stores nothing but the SHA256 digest and the non-secret prefix.
|
||||
attr_reader :plain_token
|
||||
|
||||
belongs_to :owner, polymorphic: true
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||
|
||||
before_validation :assign_owner_type, on: :create
|
||||
before_validation :generate_token, on: :create
|
||||
after_initialize { self.scopes = [] if new_record? && scopes.nil? }
|
||||
|
||||
validates :name, presence: true
|
||||
validates :token_digest, presence: true, uniqueness: true
|
||||
validates :token_prefix, presence: true
|
||||
validates :scopes, presence: true
|
||||
validate :owner_type_matches_configuration
|
||||
|
||||
def self.digest(token)
|
||||
Digest::SHA256.hexdigest(token)
|
||||
end
|
||||
|
||||
# The live (not deleted, not expired) token carrying the required scope, else nil.
|
||||
def self.authenticate(token, required_scope: nil)
|
||||
return nil if token.blank?
|
||||
|
||||
record = active.find_by(token_digest: digest(token))
|
||||
return nil if record.nil?
|
||||
return nil if required_scope.present? && !Array(record.scopes).include?(required_scope)
|
||||
|
||||
record
|
||||
end
|
||||
|
||||
def expired?
|
||||
expires_at.present? && expires_at <= Time.current
|
||||
end
|
||||
|
||||
# Revocation = soft delete, the audit trail stays.
|
||||
def revoke!
|
||||
update_column(:deleted_at, Time.current)
|
||||
end
|
||||
|
||||
def touch_last_used!
|
||||
update_column(:last_used_at, Time.current)
|
||||
end
|
||||
|
||||
# Admin form: comma separated scope list
|
||||
def scopes_string
|
||||
Array(scopes).join(", ")
|
||||
end
|
||||
|
||||
def scopes_string=(value)
|
||||
self.scopes = value.to_s.split(",").map(&:strip).reject(&:blank?).uniq
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[created_at deleted_at expires_at id last_used_at name owner_id owner_type token_prefix unrestricted updated_at]
|
||||
end
|
||||
|
||||
# Ransack cannot filter on the polymorphic owner association.
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
[]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assign_owner_type
|
||||
self.owner_type = WarpEngine.config.application_token_owner_class if owner_type.blank?
|
||||
end
|
||||
|
||||
# Publishing tokens and client tokens share this table but not their owners: one
|
||||
# belongs to whoever ships software, the other to whoever buys it. Both classes are
|
||||
# the host's to name, and either is acceptable here — which of the two a given token
|
||||
# may do is decided by its scopes, not by its owner.
|
||||
def self.permitted_owner_types
|
||||
[ WarpEngine.config.application_token_owner_class,
|
||||
WarpEngine.config.access_token_owner_class ].compact_blank
|
||||
end
|
||||
|
||||
def generate_token
|
||||
return if token_digest.present?
|
||||
|
||||
@plain_token = SecureRandom.hex(24)
|
||||
self.token_prefix = @plain_token.first(8)
|
||||
self.token_digest = self.class.digest(@plain_token)
|
||||
end
|
||||
|
||||
def owner_type_matches_configuration
|
||||
permitted = self.class.permitted_owner_types
|
||||
if permitted.empty?
|
||||
errors.add(:base, "application_token_owner_class is not configured")
|
||||
elsif !permitted.include?(owner_type)
|
||||
errors.add(:owner_type, "must be #{permitted.join(' or ')}")
|
||||
end
|
||||
end
|
||||
|
||||
ActiveSupport.run_load_hooks(:warp_engine_application_token, self)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,97 @@
|
||||
module WarpEngine
|
||||
# One pending sign-in from a client that has no browser of its own.
|
||||
#
|
||||
# The shape is RFC 8628's device authorization grant, and the reason for it is that a
|
||||
# desktop client cannot host a login form without asking a person to type a password
|
||||
# into a window that is not a browser. So the client asks for a pair of codes, sends
|
||||
# the person to the host's own page with the short one, and polls with the long one
|
||||
# until somebody approves it.
|
||||
#
|
||||
# Short-lived by design: this row exists for the minute or two between "the client
|
||||
# asked" and "the person answered". What survives it is the ApplicationToken.
|
||||
class DeviceGrant < ApplicationRecord
|
||||
self.table_name = "device_grants"
|
||||
|
||||
# No I, O, 0 or 1: this alphabet is read off one screen and typed into another, and
|
||||
# those four are where that goes wrong.
|
||||
USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".freeze
|
||||
USER_CODE_LENGTH = 8
|
||||
|
||||
belongs_to :application_token, class_name: "WarpEngine::ApplicationToken", optional: true
|
||||
belongs_to :subject, polymorphic: true, optional: true
|
||||
|
||||
validates :device_code, presence: true, uniqueness: true
|
||||
validates :user_code, presence: true, uniqueness: true
|
||||
validates :expires_at, presence: true
|
||||
|
||||
scope :pending, -> { where(approved_at: nil, denied_at: nil).where(expires_at: Time.current..) }
|
||||
|
||||
before_validation :generate_codes, on: :create
|
||||
before_validation :set_expiry, on: :create
|
||||
|
||||
def self.find_pending_by_user_code(code)
|
||||
pending.find_by(user_code: normalize_user_code(code))
|
||||
end
|
||||
|
||||
# Typed by a person, so it arrives with whatever case and separators they used.
|
||||
def self.normalize_user_code(code)
|
||||
code.to_s.upcase.gsub(/[^A-Z0-9]/, "")
|
||||
end
|
||||
|
||||
def expired? = expires_at <= Time.current
|
||||
def approved? = approved_at.present?
|
||||
def denied? = denied_at.present?
|
||||
|
||||
# What the polling client is told. Order matters: a denied grant is denied even
|
||||
# after it expires, because "somebody said no" is the more useful answer.
|
||||
def state
|
||||
return :denied if denied?
|
||||
return :approved if approved?
|
||||
return :expired if expired?
|
||||
|
||||
:pending
|
||||
end
|
||||
|
||||
# Grouped for reading aloud and for typing: WARP-K7M2.
|
||||
def formatted_user_code
|
||||
user_code.to_s.scan(/.{1,4}/).join("-")
|
||||
end
|
||||
|
||||
# Housekeeping for a host that wants it: an expired grant has nothing left to give,
|
||||
# and its issued_token would be a live secret nobody is waiting for.
|
||||
def self.sweep_expired!
|
||||
where(expires_at: ...Time.current).where.not(issued_token: nil).update_all(issued_token: nil)
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[approved_at client_name created_at denied_at expires_at id subject_id subject_type updated_at user_code]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
[]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_codes
|
||||
self.device_code = SecureRandom.hex(32) if device_code.blank?
|
||||
self.user_code = self.class.generate_user_code if user_code.blank?
|
||||
end
|
||||
|
||||
def self.generate_user_code
|
||||
# Retried rather than trusted: the alphabet is small enough that a collision is
|
||||
# a real, if rare, event, and a unique index would turn it into a 500.
|
||||
10.times do
|
||||
candidate = Array.new(USER_CODE_LENGTH) { USER_CODE_ALPHABET.chars.sample }.join
|
||||
return candidate unless exists?(user_code: candidate)
|
||||
end
|
||||
raise "could not generate a free device user code"
|
||||
end
|
||||
|
||||
def set_expiry
|
||||
self.expires_at ||= WarpEngine.config.device_code_ttl.to_i.seconds.from_now
|
||||
end
|
||||
|
||||
ActiveSupport.run_load_hooks(:warp_engine_device_grant, self)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
module WarpEngine
|
||||
class Pipeline < ApplicationRecord
|
||||
self.table_name = "pipelines"
|
||||
|
||||
# Repos synced from Woodpecker without a matching Software land here
|
||||
# until a platform is assigned by hand.
|
||||
UNKNOWN_PLATFORM = "unknown".freeze
|
||||
|
||||
belongs_to :software, class_name: "WarpEngine::Software", optional: true
|
||||
|
||||
# Pipelines this record took the software from during the last save, by full name.
|
||||
# The admin says so out loud: a silent reassignment is what made the old behaviour
|
||||
# confusing in the first place.
|
||||
attr_reader :software_taken_from
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
# One pipeline per software, and the newest assignment wins.
|
||||
#
|
||||
# `Software#pipeline` is a `has_one`, so two pipelines pointing at the same software
|
||||
# is not an error — it is worse than one: the software keeps showing whichever row
|
||||
# comes first, and assigning it elsewhere looks like it did nothing. Rather than
|
||||
# refusing the assignment, the link moves: whoever held that software lets go of it.
|
||||
#
|
||||
# Deliberately a callback and not a unique index. Rows here are soft-deleted, and a
|
||||
# unique index counts deleted rows too, so a pipeline someone removed last year would
|
||||
# block the software from ever being linked again.
|
||||
before_save :claim_software_from_other_pipelines, if: :will_save_change_to_software_id?
|
||||
|
||||
validates :woodpecker_repo_id, presence: true, uniqueness: true
|
||||
validates :repo_owner, presence: true
|
||||
validates :repo_name, presence: true
|
||||
validates :platform, presence: true,
|
||||
inclusion: { in: WarpEngine::PlatformLink::SUPPORTED_PLATFORMS + [ UNKNOWN_PLATFORM ] }
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
|
||||
def full_name
|
||||
"#{repo_owner}/#{repo_name}"
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[active created_at deleted_at id last_pipeline_at last_pipeline_status
|
||||
platform repo_name repo_owner software_id woodpecker_repo_id]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
%w[software]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def claim_software_from_other_pipelines
|
||||
return if software_id.blank?
|
||||
|
||||
others = Pipeline.where(software_id: software_id).where.not(id: id)
|
||||
@software_taken_from = others.map(&:full_name)
|
||||
return if @software_taken_from.empty?
|
||||
|
||||
# Logged rather than flashed. The first attempt at this put a message on screen by
|
||||
# overriding the admin's `update` action, which bypassed the permitted-params path
|
||||
# and made every pipeline edit fail with ForbiddenAttributesError. A silent
|
||||
# reassignment is a small problem; an admin page that cannot save is a large one.
|
||||
Rails.logger.info(
|
||||
"[WarpEngine::Pipeline] #{full_name} took software #{software_id} from " \
|
||||
"#{@software_taken_from.join(', ')}"
|
||||
)
|
||||
others.update_all(software_id: nil, updated_at: Time.current)
|
||||
end
|
||||
|
||||
ActiveSupport.run_load_hooks(:warp_engine_pipeline, self)
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
module WarpEngine
|
||||
class ReleaseAsset < ApplicationRecord
|
||||
KINDS = %w[cartridge source html docs
|
||||
win_x86 win_x64 linux_x86 linux_x64
|
||||
win_x86 win_x64 linux_x86 linux_x64 linux_arm64
|
||||
mac_x64 mac_arm64 mac_universal].freeze
|
||||
|
||||
belongs_to :release
|
||||
|
||||
@@ -2,11 +2,16 @@ module WarpEngine
|
||||
class Software < ApplicationRecord
|
||||
self.table_name = "softwares"
|
||||
|
||||
# Owner of the publishing token (e.g. AdminUser) — for 3rd-party isolation,
|
||||
# see enforce_software_ownership. nil = internal / pre-backfill software.
|
||||
belongs_to :owner, polymorphic: true, optional: true
|
||||
|
||||
has_many :software_images, foreign_key: :software_id, dependent: :destroy
|
||||
has_many :images, through: :software_images
|
||||
has_many :releases, foreign_key: :software_id
|
||||
has_many :downloads, through: :releases
|
||||
has_many :external_links, foreign_key: :software_id
|
||||
has_one :pipeline, foreign_key: :software_id
|
||||
|
||||
accepts_nested_attributes_for :software_images, allow_destroy: true
|
||||
accepts_nested_attributes_for :external_links, allow_destroy: true
|
||||
@@ -19,7 +24,7 @@ module WarpEngine
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[author created_at desc highlighted id license name platform site status story title updated_at]
|
||||
%w[author created_at desc highlighted id license name owner_id owner_type platform site status story title updated_at]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
|
||||
@@ -5,5 +5,8 @@ module WarpEngine
|
||||
field(:latestRelease) { |_, opts| opts[:latest] ? ReleaseSerializer.render_as_hash(opts[:latest], download_counts: opts[:download_counts]) : nil }
|
||||
field(:webPlayableRelease) { |_, opts| opts[:web_playable] ? ReleaseSerializer.render_as_hash(opts[:web_playable], download_counts: opts[:download_counts]) : nil }
|
||||
field(:totalDownloads) { |_, opts| opts[:total_downloads] || 0 }
|
||||
# Whether this title is gated, what it costs and where to get it. Always present —
|
||||
# see WarpEngine::Access. Under the open policy it is the constant OPEN answer.
|
||||
field(:access) { |_, opts| (opts[:access] || WarpEngine::Access::OPEN).as_json }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,6 +14,8 @@ module WarpEngine
|
||||
field(:license) { |sw| sw.license.to_s }
|
||||
field :platform
|
||||
field :status
|
||||
# Public owner id — for the /api/software?owner_id= filter.
|
||||
field(:ownerId) { |sw| sw.owner_id }
|
||||
field(:highlighted) { |sw| sw.highlighted ? true : false }
|
||||
field(:externalLinks) { |sw| ExternalLinkSerializer.render_as_hash(sw.external_links) }
|
||||
field(:platformLinks) { |sw| PlatformLinkSerializer.render_as_hash(WarpEngine::PlatformLink.for_platform(sw.platform)) }
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ require "zip"
|
||||
require "fileutils"
|
||||
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module ArchiveExtraction
|
||||
private
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Builds
|
||||
module BuildCartridge
|
||||
extend ActiveSupport::Concern
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Builds
|
||||
module BuildDocs
|
||||
extend ActiveSupport::Concern
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
module WarpEngine
|
||||
module Platforms
|
||||
module Builds
|
||||
# Linux on 64-bit ARM: Raspberry Pi 4/5, Odroid, and the retro handhelds.
|
||||
# Batocera and the ES-family distributions run on these as much as on
|
||||
# x86_64, and an x64 binary installs there but will not start — which is
|
||||
# why this is a separate kind rather than something linux_x64 can cover.
|
||||
#
|
||||
# Include this in a platform service only once its pipeline actually
|
||||
# produces the artifact: registering the kind makes /api/builds report it
|
||||
# as missing for every release until then.
|
||||
module BuildLinuxArm64
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
register_expected_kind "linux_arm64"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def linux_arm64_asset_path(versioned)
|
||||
path = full_path("#{versioned}-linux-arm64.zip")
|
||||
{ "linux_arm64" => path } if File.file?(path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Builds
|
||||
module BuildLinuxX64
|
||||
extend ActiveSupport::Concern
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Builds
|
||||
module BuildMacArm64
|
||||
extend ActiveSupport::Concern
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Builds
|
||||
module BuildMacUniversal
|
||||
extend ActiveSupport::Concern
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Builds
|
||||
module BuildMacX64
|
||||
extend ActiveSupport::Concern
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Builds
|
||||
module BuildSource
|
||||
extend ActiveSupport::Concern
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Builds
|
||||
module BuildWeb
|
||||
extend ActiveSupport::Concern
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Builds
|
||||
module BuildWinX64
|
||||
extend ActiveSupport::Concern
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Builds
|
||||
module BuildWinX86
|
||||
extend ActiveSupport::Concern
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
require "json"
|
||||
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module MetadataParsing
|
||||
METADATA_KEYS = %i[name title author desc site repo license].freeze
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module SoftwarePersistence
|
||||
private
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
module WarpEngine
|
||||
module SoftwareUpdater
|
||||
module Platforms
|
||||
module Updatable
|
||||
extend ActiveSupport::Concern
|
||||
include ArchiveExtraction
|
||||
@@ -9,7 +9,7 @@ module WarpEngine
|
||||
class_methods do
|
||||
def platform(value = nil)
|
||||
@platform = value if value
|
||||
@platform ||= name.demodulize.delete_suffix("Service").downcase
|
||||
@platform ||= name.deconstantize.demodulize.downcase
|
||||
end
|
||||
|
||||
def label(value = nil)
|
||||
@@ -2,7 +2,7 @@ module WarpEngine
|
||||
class BuildsService
|
||||
def index
|
||||
platforms = WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.each_with_object({}) do |platform, hash|
|
||||
service_class = "WarpEngine::SoftwareUpdater::#{platform.camelize}Service".constantize
|
||||
service_class = "WarpEngine::Platforms::#{platform.camelize}::Service".constantize
|
||||
hash[platform] = {
|
||||
label: service_class.label,
|
||||
kinds: service_class.expected_kinds
|
||||
@@ -16,7 +16,7 @@ module WarpEngine
|
||||
|
||||
def show(name)
|
||||
software = WarpEngine::Software.find_by!(name: name)
|
||||
service_class = "WarpEngine::SoftwareUpdater::#{software.platform.camelize}Service".constantize
|
||||
service_class = "WarpEngine::Platforms::#{software.platform.camelize}::Service".constantize
|
||||
expected = service_class.expected_kinds
|
||||
|
||||
releases = software.releases.includes(:release_assets).order(updated_at: :desc)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
require "erb"
|
||||
|
||||
module WarpEngine
|
||||
# Renders the /build/config platform templates: the pipeline logic lives in
|
||||
# app/services/warp_engine/platforms/<platform>/pipeline.yaml.erb, the
|
||||
# per-platform builder images come from WarpEngine.config.ci_platforms.
|
||||
class CiConfigService
|
||||
PLATFORM_FORMAT = /\A[a-z0-9_-]+\z/
|
||||
|
||||
# The rendered pipeline YAML, or nil when the platform is not served.
|
||||
def render(platform:, name:, update_server:)
|
||||
platform = platform.to_s
|
||||
return nil unless platform.match?(PLATFORM_FORMAT)
|
||||
|
||||
spec = platform_spec(platform)
|
||||
return nil if spec.nil?
|
||||
|
||||
path = templates_dir.join(platform, "pipeline.yaml.erb")
|
||||
return nil unless path.exist?
|
||||
|
||||
ERB.new(path.read, trim_mode: "-").result_with_hash(
|
||||
name: name.to_s,
|
||||
update_server: update_server.to_s,
|
||||
builder: spec[:builder],
|
||||
exporter: spec[:exporter]
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def platform_spec(platform)
|
||||
spec = WarpEngine.config.ci_platforms.stringify_keys[platform]
|
||||
spec&.symbolize_keys
|
||||
end
|
||||
|
||||
def templates_dir
|
||||
WarpEngine::Engine.root.join("app", "services", "warp_engine", "platforms")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,154 @@
|
||||
require "openssl"
|
||||
require "base64"
|
||||
require "net/http"
|
||||
require "digest"
|
||||
|
||||
module WarpEngine
|
||||
# Verifies the signature of Woodpecker configuration-extension requests.
|
||||
# Woodpecker 3.x signs with RFC 9421 HTTP message signatures (ed25519, via
|
||||
# yaronf/httpsign): Signature-Input + Signature + Content-Digest headers,
|
||||
# covered components "@request-target" and "content-digest". Older versions
|
||||
# used draft-cavage http-signatures (a single Signature header) — kept as a
|
||||
# fallback.
|
||||
class CiSignatureVerifier
|
||||
CAVAGE_PARAM = /(\w+)="([^"]*)"/
|
||||
|
||||
@key_cache = {}
|
||||
@key_mutex = Mutex.new
|
||||
|
||||
class << self
|
||||
# The downloaded key is cached process-wide (per URL).
|
||||
def fetch_public_key(url)
|
||||
@key_mutex.synchronize do
|
||||
@key_cache[url] ||= Net::HTTP.get(URI.parse(url))
|
||||
end
|
||||
end
|
||||
|
||||
def reset_key_cache!
|
||||
@key_mutex.synchronize { @key_cache = {} }
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(request)
|
||||
@request = request
|
||||
end
|
||||
|
||||
def valid?
|
||||
pem = public_key_pem
|
||||
if pem.blank?
|
||||
Rails.logger.error("[CiSignatureVerifier] no ci_extension_public_key(_url) configured — rejecting request")
|
||||
return false
|
||||
end
|
||||
|
||||
key = OpenSSL::PKey.read(pem)
|
||||
if @request.headers["Signature-Input"].present?
|
||||
rfc9421_valid?(key)
|
||||
else
|
||||
cavage_valid?(key)
|
||||
end
|
||||
rescue OpenSSL::PKey::PKeyError, ArgumentError => e
|
||||
Rails.logger.error("[CiSignatureVerifier] #{e.class}: #{e.message}")
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def public_key_pem
|
||||
config = WarpEngine.config
|
||||
return config.ci_extension_public_key if config.ci_extension_public_key.present?
|
||||
return nil if config.ci_extension_public_key_url.blank?
|
||||
|
||||
self.class.fetch_public_key(config.ci_extension_public_key_url)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[CiSignatureVerifier] public key fetch failed: #{e.class}: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
# --- RFC 9421 ---
|
||||
|
||||
def rfc9421_valid?(key)
|
||||
input = @request.headers["Signature-Input"].to_s
|
||||
match = input.match(/\A\s*([\w.-]+)=(\(.*)\z/m)
|
||||
return false if match.nil?
|
||||
|
||||
label, inner = match[1], match[2]
|
||||
components = inner[/\((.*?)\)/m, 1].to_s.scan(/"([^"]*)"/).flatten
|
||||
return false if components.empty?
|
||||
|
||||
signature = @request.headers["Signature"].to_s[/#{Regexp.escape(label)}=:([A-Za-z0-9+\/=]+):/, 1]
|
||||
return false if signature.blank?
|
||||
return false unless content_digest_valid?(components)
|
||||
|
||||
lines = components.map do |component|
|
||||
value = component_value(component)
|
||||
return false if value.nil?
|
||||
%("#{component}": #{value})
|
||||
end
|
||||
lines << %("@signature-params": #{inner})
|
||||
|
||||
key.verify(nil, Base64.decode64(signature), lines.join("\n"))
|
||||
end
|
||||
|
||||
def component_value(name)
|
||||
case name
|
||||
when "@request-target" then @request.fullpath
|
||||
when "@method" then @request.request_method
|
||||
when "@target-uri" then @request.original_url
|
||||
when "@authority" then @request.host_with_port
|
||||
when "@path" then @request.path
|
||||
when "@query" then "?#{@request.query_string}"
|
||||
when /\A@/ then nil
|
||||
else @request.headers[name]
|
||||
end
|
||||
end
|
||||
|
||||
# When content-digest is a covered component, the body itself must match
|
||||
# the digest header — this is what ties the signature to the payload.
|
||||
def content_digest_valid?(components)
|
||||
return true unless components.include?("content-digest")
|
||||
|
||||
digest = @request.headers["Content-Digest"].to_s[/sha-256=:([A-Za-z0-9+\/=]+):/, 1]
|
||||
return false if digest.blank?
|
||||
|
||||
expected = Digest::SHA256.base64digest(@request.raw_post)
|
||||
ActiveSupport::SecurityUtils.secure_compare(digest, expected)
|
||||
end
|
||||
|
||||
# --- draft-cavage fallback ---
|
||||
|
||||
def cavage_valid?(key)
|
||||
params = cavage_params
|
||||
return false if params.nil? || params["signature"].blank?
|
||||
|
||||
signing_string = cavage_signing_string(params.fetch("headers", "date"))
|
||||
return false if signing_string.nil?
|
||||
|
||||
key.verify(nil, Base64.decode64(params["signature"]), signing_string)
|
||||
end
|
||||
|
||||
# Parameters of the Signature header (or the "Authorization: Signature ..." form).
|
||||
def cavage_params
|
||||
header = @request.headers["Signature"].presence
|
||||
if header.nil?
|
||||
auth = @request.headers["Authorization"].to_s
|
||||
header = auth.delete_prefix("Signature ") if auth.start_with?("Signature ")
|
||||
end
|
||||
return nil if header.blank?
|
||||
|
||||
header.scan(CAVAGE_PARAM).to_h
|
||||
end
|
||||
|
||||
def cavage_signing_string(headers_list)
|
||||
lines = headers_list.split(" ").map do |name|
|
||||
if name == "(request-target)"
|
||||
"(request-target): #{@request.request_method.downcase} #{@request.fullpath}"
|
||||
else
|
||||
value = @request.headers[name]
|
||||
return nil if value.nil?
|
||||
"#{name.downcase}: #{value}"
|
||||
end
|
||||
end
|
||||
lines.join("\n")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,102 @@
|
||||
module WarpEngine
|
||||
# The device authorization grant, from both ends.
|
||||
#
|
||||
# The client's end is #request, #poll and #revoke, all reachable over /api/auth/*.
|
||||
# The person's end is #approve and #deny, which the *host* calls from its own page —
|
||||
# approving needs a session and a logged-in human, and the engine has neither.
|
||||
class DeviceGrantService
|
||||
class NotConfigured < StandardError; end
|
||||
class UnknownCode < StandardError; end
|
||||
|
||||
# A client asks for a code pair. Deliberately unauthenticated: there is nobody to
|
||||
# authenticate as yet, which is the whole reason this flow exists.
|
||||
def request(client_name:)
|
||||
ensure_configured!
|
||||
|
||||
WarpEngine::DeviceGrant.create!(client_name: client_name.presence&.truncate(128))
|
||||
end
|
||||
|
||||
# The client polls with the device code. Returns [state, token], where the token is
|
||||
# the plain string and is available exactly once — on the poll that finds the grant
|
||||
# newly approved. A second poll gets :approved with no token, which is the honest
|
||||
# answer: the secret was handed over and is not kept.
|
||||
def poll(device_code:)
|
||||
ensure_configured!
|
||||
|
||||
grant = WarpEngine::DeviceGrant.find_by(device_code: device_code.to_s)
|
||||
raise UnknownCode if grant.nil?
|
||||
|
||||
return [ grant.state, nil ] unless grant.state == :approved
|
||||
|
||||
# Read once, then gone: the column exists only to carry the secret across the gap
|
||||
# between the browser that approved it and the client that is polling for it.
|
||||
plain = grant.issued_token
|
||||
grant.update_columns(issued_token: nil) if plain.present?
|
||||
[ :approved, plain ]
|
||||
end
|
||||
|
||||
# The host's approval page calls this with the code a person typed and the subject
|
||||
# they are signed in as. Issuing the token here rather than on the next poll keeps
|
||||
# the decision and its consequence in one transaction.
|
||||
def approve(user_code:, subject:)
|
||||
ensure_configured!
|
||||
|
||||
grant = WarpEngine::DeviceGrant.find_pending_by_user_code(user_code)
|
||||
raise UnknownCode if grant.nil?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
token = WarpEngine::ApplicationToken.create!(
|
||||
name: grant.client_name.presence || "Client",
|
||||
owner_type: WarpEngine.config.access_token_owner_class,
|
||||
owner_id: subject.id,
|
||||
scopes: [ WarpEngine::ApplicationToken::CATALOG_SCOPE ]
|
||||
)
|
||||
grant.update!(
|
||||
subject_type: WarpEngine.config.access_token_owner_class,
|
||||
subject_id: subject.id,
|
||||
application_token: token,
|
||||
issued_token: token.plain_token,
|
||||
approved_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
grant
|
||||
end
|
||||
|
||||
def deny(user_code:)
|
||||
ensure_configured!
|
||||
|
||||
grant = WarpEngine::DeviceGrant.find_pending_by_user_code(user_code)
|
||||
raise UnknownCode if grant.nil?
|
||||
|
||||
grant.update!(denied_at: Time.current)
|
||||
grant
|
||||
end
|
||||
|
||||
# Signing out: the client throws its own token away. Revocation is a soft delete on
|
||||
# the token, so the audit trail of who signed in from where survives it.
|
||||
def revoke(token:)
|
||||
return false if token.nil?
|
||||
|
||||
token.revoke!
|
||||
true
|
||||
end
|
||||
|
||||
# Where a person goes to type the user code. A path is made absolute against the
|
||||
# request's own base, so a host that configured "/devices" does not have to know its
|
||||
# own hostname.
|
||||
def verification_url(base_url: nil)
|
||||
configured = WarpEngine.config.identity_verification_url.presence || "/devices"
|
||||
return configured if configured.start_with?("http://", "https://")
|
||||
return configured if base_url.blank?
|
||||
|
||||
"#{base_url.to_s.chomp('/')}/#{configured.delete_prefix('/')}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_configured!
|
||||
raise NotConfigured unless WarpEngine.identity_configured?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,10 @@
|
||||
module WarpEngine
|
||||
class DownloadService
|
||||
# The access policy said no. The controller turns this into a 403 — distinct from
|
||||
# the nil that means "no such file", because telling a person their file is missing
|
||||
# when it is merely locked sends them looking for the wrong problem.
|
||||
class Denied < StandardError; end
|
||||
|
||||
def self.container_base
|
||||
WarpEngine.config.file_container_path
|
||||
end
|
||||
@@ -9,26 +14,77 @@ module WarpEngine
|
||||
Pathname.new(container_base).realpath
|
||||
end
|
||||
|
||||
def create(path:, ip:, user_agent:, referer:)
|
||||
sanitized = path.to_s
|
||||
base_path = self.class.base_path
|
||||
full_path = base_path.join(sanitized).realpath
|
||||
return nil unless full_path.to_s.start_with?(base_path.to_s)
|
||||
return nil unless File.file?(full_path)
|
||||
# A letöltés helyét adja vissza (fájl vagy aláírt URL) és naplózza a
|
||||
# letöltést. A hely feloldása a storage adapteren megy — alapból :local,
|
||||
# tehát változatlanul lemezről.
|
||||
#
|
||||
# `subject` is whoever the request authenticated as, or nil. Under the open policy
|
||||
# it is ignored and every file is served, exactly as before.
|
||||
def locate(path:, ip:, user_agent:, referer:, subject: nil, request: nil)
|
||||
relative = path.to_s
|
||||
return nil unless storage.file?(relative)
|
||||
|
||||
escaped = sanitized.gsub("%", "\\%").gsub("_", "\\_")
|
||||
asset = WarpEngine::ReleaseAsset.find_by(path: File.join(self.class.container_base, sanitized)) ||
|
||||
WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{escaped}%").first
|
||||
asset = find_asset(relative)
|
||||
grant = authorize!(asset, subject, request)
|
||||
|
||||
WarpEngine::Download.create!(
|
||||
file_path: sanitized,
|
||||
log_download(relative, asset: asset, ip: ip, user_agent: user_agent, referer: referer, subject: subject)
|
||||
|
||||
storage.locate(relative,
|
||||
filename: grant.filename.presence || File.basename(relative),
|
||||
expires_in: grant.expires_in)
|
||||
end
|
||||
|
||||
# Visszafelé kompatibilis felület: az abszolút fájlútvonalat adja vissza
|
||||
# (vagy nil-t). Nem lemezes adapternél nincs útvonal — ott a #locate való.
|
||||
def create(path:, ip:, user_agent:, referer:, subject: nil, request: nil)
|
||||
location = locate(path: path, ip: ip, user_agent: user_agent, referer: referer,
|
||||
subject: subject, request: request)
|
||||
return nil if location.nil?
|
||||
|
||||
location.file? ? location.path : nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def storage
|
||||
WarpEngine.storage
|
||||
end
|
||||
|
||||
# A policy that refuses returns nil; one that raises is treated as a refusal too.
|
||||
# An artifact served because the gatekeeper crashed is the one failure mode this
|
||||
# engine must not have.
|
||||
def authorize!(asset, subject, request)
|
||||
grant = WarpEngine.access_policy.authorize_download(asset: asset, subject: subject, request: request)
|
||||
raise Denied if grant.nil?
|
||||
|
||||
grant
|
||||
rescue Denied
|
||||
raise
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}")
|
||||
raise Denied
|
||||
end
|
||||
|
||||
def find_asset(relative)
|
||||
escaped = relative.gsub("%", "\\%").gsub("_", "\\_")
|
||||
WarpEngine::ReleaseAsset.find_by(path: File.join(self.class.container_base, relative)) ||
|
||||
WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{escaped}%").first
|
||||
end
|
||||
|
||||
def log_download(relative, asset:, ip:, user_agent:, referer:, subject:)
|
||||
download = WarpEngine::Download.create!(
|
||||
file_path: relative,
|
||||
release: asset&.release,
|
||||
ip_address: ip,
|
||||
user_agent: user_agent&.truncate(500),
|
||||
referer: referer&.truncate(500)
|
||||
)
|
||||
|
||||
full_path
|
||||
# The same seam the publish side has: a host that wants its own record of who
|
||||
# downloaded what subscribes rather than reaching into this class.
|
||||
ActiveSupport::Notifications.instrument("warp_engine.download",
|
||||
path: relative, asset: asset, release: asset&.release,
|
||||
software: asset&.release&.software, subject: subject, download: download, ip: ip)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,10 +22,9 @@ module WarpEngine
|
||||
end
|
||||
end
|
||||
|
||||
MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB
|
||||
|
||||
def upload(relative_dir, uploaded_file)
|
||||
raise ArgumentError, "File too large (max 100MB)" if uploaded_file.size > MAX_UPLOAD_SIZE
|
||||
max = WarpEngine.config.max_upload_size
|
||||
raise ArgumentError, "File too large (max #{max / (1024 * 1024)}MB)" if uploaded_file.size > max
|
||||
dir = safe_path!(relative_dir)
|
||||
raise ArgumentError, "Not a directory" unless dir.directory?
|
||||
|
||||
|
||||
@@ -5,31 +5,64 @@ module WarpEngine
|
||||
Pathname.new(WarpEngine.config.file_container_path).realpath
|
||||
end
|
||||
|
||||
def show(input)
|
||||
full_path = base_path.join(input.path.to_s)
|
||||
return FileResultDto.not_found unless safe_path?(full_path)
|
||||
# A fájlok helyét a storage adapter adja (alapból :local, azaz a lemez) —
|
||||
# így a host az objektumtárból is kiszolgálhat anélkül, hogy az engine-t
|
||||
# patchelné. Lásd WarpEngine::Storage.
|
||||
# `subject` is whoever the request authenticated as, or nil. Under the open policy
|
||||
# it is ignored and every file is served, exactly as before.
|
||||
#
|
||||
# A hosted (browser) build reaches this path as hundreds of relative requests for
|
||||
# js, wasm and images, which is why the gate here is the policy's plain yes/no
|
||||
# rather than anything signed: there is nothing to sign per file. A host serving
|
||||
# gated web builds to browsers will usually want its own session-based route in
|
||||
# front of this one — a browser has a session, and a redirect to a login page is a
|
||||
# better answer there than a bare 403.
|
||||
def show(input, subject: nil)
|
||||
relative = input.path.to_s
|
||||
|
||||
if File.directory?(full_path)
|
||||
index_path = full_path.join("index.html")
|
||||
return FileResultDto.not_found unless File.file?(index_path)
|
||||
return FileResultDto.redirect("/file/#{input.path.to_s.chomp("/")}/index.html")
|
||||
if storage.directory?(relative)
|
||||
index = File.join(relative.chomp("/"), "index.html")
|
||||
return FileResultDto.not_found unless storage.file?(index)
|
||||
|
||||
return FileResultDto.redirect("/file/#{index}")
|
||||
end
|
||||
|
||||
if File.file?(full_path)
|
||||
FileResultDto.file(full_path)
|
||||
else
|
||||
FileResultDto.not_found
|
||||
end
|
||||
return FileResultDto.not_found unless storage.file?(relative)
|
||||
|
||||
authorize!(relative, subject)
|
||||
|
||||
to_result(storage.locate(relative, filename: File.basename(relative)))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def base_path
|
||||
@base_path ||= self.class.base_path
|
||||
# Same rule and same failure mode as DownloadService: a policy that refuses or
|
||||
# raises means no file. An artifact served because the gatekeeper crashed is the
|
||||
# one failure mode this engine must not have.
|
||||
def authorize!(relative, subject)
|
||||
# The open policy authorises everything, and this path serves a browser build as
|
||||
# hundreds of requests for js, wasm and images. Asking it per file would mean a
|
||||
# LIKE query per asset for an answer that is always yes.
|
||||
return WarpEngine::Access::Grant::OPEN if WarpEngine::AccessPolicy.open?
|
||||
|
||||
asset = WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{relative.gsub('%', '\\%').gsub('_', '\\_')}%").first
|
||||
grant = WarpEngine.access_policy.authorize_download(asset: asset, subject: subject, request: nil)
|
||||
raise WarpEngine::DownloadService::Denied if grant.nil?
|
||||
|
||||
grant
|
||||
rescue WarpEngine::DownloadService::Denied
|
||||
raise
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}")
|
||||
raise WarpEngine::DownloadService::Denied
|
||||
end
|
||||
|
||||
def safe_path?(path)
|
||||
File.exist?(path) && Pathname.new(path).realpath.to_s.start_with?(base_path.to_s)
|
||||
def storage
|
||||
WarpEngine.storage
|
||||
end
|
||||
|
||||
def to_result(location)
|
||||
location.redirect? ? FileResultDto.redirect(location.url) : FileResultDto.file(location.path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user