Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ffbc7a2ca | ||
|
|
653894a876 | ||
|
|
97d71a7c99 | ||
|
|
a58e91520e | ||
|
|
259de781bb | ||
|
|
6f2a39fe1b | ||
|
|
fbdfbe954e | ||
|
|
b51bf0d094 | ||
|
|
fc1076f8fc | ||
|
|
3e70717e8f | ||
|
|
c0252928c6 | ||
|
|
3a524d5003 | ||
|
|
2bf0b20b77 | ||
|
|
e6ef4f4191 | ||
|
|
81708fd84d | ||
|
|
688d956107 | ||
|
|
244b0e46cb | ||
|
|
9f55c47269 | ||
|
|
5d50ac69a1 | ||
|
|
4fc68e1448 | ||
|
|
d01fd97cc1 | ||
|
|
76d38840fb | ||
|
|
684f06745a | ||
|
|
58b8574cb4 | ||
|
|
9d8a546efa |
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env ruby
|
||||
|
||||
require "rbconfig"
|
||||
|
||||
root = IO.popen([ "git", "rev-parse", "--show-toplevel" ], &:read).strip
|
||||
check = File.join(root, "script", "warp_engine_version_check.rb")
|
||||
|
||||
exit 0 unless File.exist?(check)
|
||||
|
||||
exec(RbConfig.ruby, check, "--staged")
|
||||
@@ -0,0 +1,125 @@
|
||||
# Éles deploy: master push -> rubocop -> host teszt -> pull + függőségek + restart.
|
||||
#
|
||||
# A woodpecker-agent ugyanabban a stackben fut, mint az api és a frontend, ezért
|
||||
# a deploy nem SSH-zik: a hoszt docker socketjét használja. A stack könyvtára a
|
||||
# konténerben UGYANARRA az útvonalra van mountolva, mint a hoszton — különben a
|
||||
# compose relatív bind mountjai (./data, ./apps) máshova oldódnának fel.
|
||||
#
|
||||
# Feltételek:
|
||||
# - Woodpecker: a repónál engedélyezni kell a "trusted: volumes" jelölést
|
||||
# (Repo settings -> Trusted, admin joggal), különben a mountok tiltottak.
|
||||
# - Woodpecker secret: forge_token — Forgejo token a services/teletypegames
|
||||
# olvasásához (ugyanaz, amit a warp_engine workflow használ).
|
||||
# - A szerveren /srv/stacks/teletype-games egy master-en álló git checkout,
|
||||
# benne az api és frontend konténerek futnak (container_name: api, frontend).
|
||||
|
||||
when:
|
||||
- event: push
|
||||
branch: master
|
||||
- event: manual
|
||||
|
||||
services:
|
||||
- name: mysql
|
||||
image: mysql:8
|
||||
environment:
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
|
||||
MYSQL_DATABASE: softwares # a zeitwerk:check production modban bootol; a softwares_test-et a db:test:prepare csinalja
|
||||
|
||||
steps:
|
||||
rubocop:
|
||||
image: ruby:3.3
|
||||
environment:
|
||||
BUNDLE_PATH: /cache/bundle
|
||||
volumes:
|
||||
# A gemek a szerveren maradnak futások között; a ruby-verzió a kulcs, mert a
|
||||
# natív kiterjesztések (mysql2) egy ABI-hoz fordulnak.
|
||||
- /srv/ci-cache/bundle/api-ruby3.3:/cache/bundle
|
||||
commands:
|
||||
- apt-get update && apt-get install -y --no-install-recommends default-libmysqlclient-dev
|
||||
- cd apps/api
|
||||
# A lock a párhuzamos futásokat választja szét: két pipeline nem ír egyszerre
|
||||
# ugyanabba a bundle könyvtárba.
|
||||
- flock -w 900 /cache/bundle/.install.lock bundle install --jobs 4
|
||||
- bundle exec rubocop
|
||||
|
||||
test-host:
|
||||
image: ruby:3.3
|
||||
environment:
|
||||
RAILS_ENV: test
|
||||
DB_HOST: mysql
|
||||
DB_USER: root
|
||||
DB_NAME: softwares
|
||||
FILE_CONTAINER_PATH: /tmp/softwares
|
||||
IMAGE_CONTAINER_PATH: /tmp/images
|
||||
UPDATE_SECRET: ci-test
|
||||
SECRET_KEY_BASE: ci-test
|
||||
BUNDLE_PATH: /cache/bundle
|
||||
volumes:
|
||||
- /srv/ci-cache/bundle/api-ruby3.3:/cache/bundle
|
||||
commands:
|
||||
- apt-get update && apt-get install -y --no-install-recommends default-libmysqlclient-dev default-mysql-client
|
||||
- mkdir -p /tmp/softwares /tmp/images
|
||||
- cd apps/api
|
||||
- flock -w 900 /cache/bundle/.install.lock 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
|
||||
- bundle exec rails db:test:prepare
|
||||
- bundle exec rspec
|
||||
# Production modu eager load: a sema-betoltott teszt adatbazisra mutatunk,
|
||||
# hogy a boot ne egy ures DB-n haljon el.
|
||||
- RAILS_ENV=production DB_NAME=softwares_test bundle exec rails zeitwerk:check
|
||||
|
||||
pull:
|
||||
image: alpine/git
|
||||
environment:
|
||||
FORGE_TOKEN:
|
||||
from_secret: forge_token
|
||||
volumes:
|
||||
- /srv/stacks/teletype-games:/srv/stacks/teletype-games
|
||||
commands:
|
||||
- git config --global --add safe.directory /srv/stacks/teletype-games
|
||||
- cd /srv/stacks/teletype-games
|
||||
# A szerver checkoutja detached HEAD-en is állhat: akkor a pull "sikeres",
|
||||
# de a futó kód nem mozdul. Inkább bukjunk el itt.
|
||||
- |
|
||||
BRANCH="$(git symbolic-ref --short -q HEAD || true)"
|
||||
if [ "$BRANCH" != "master" ]; then
|
||||
echo "A checkout nem master-en all (HEAD: $${BRANCH:-detached}), a deploy megall."
|
||||
exit 1
|
||||
fi
|
||||
- TOKEN="$$(printf '%s' "$${FORGE_TOKEN}" | tr -d '[:space:]')"
|
||||
- git fetch "https://ci:$${TOKEN}@git.teletypegames.org/services/teletypegames.git" master
|
||||
- git merge --ff-only FETCH_HEAD
|
||||
- |
|
||||
HEAD_SHA="$(git rev-parse HEAD)"
|
||||
echo "==> A szerver most itt all: $HEAD_SHA"
|
||||
if [ "$HEAD_SHA" != "$CI_COMMIT_SHA" ]; then
|
||||
echo "Megjegyzes: ez nem a pipeline commitja ($CI_COMMIT_SHA) — kozben ujabb push jott."
|
||||
fi
|
||||
when:
|
||||
- event: [ push, manual ]
|
||||
branch: master
|
||||
|
||||
restart:
|
||||
image: docker:cli
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
commands:
|
||||
# Csak az api és a frontend indul újra. A stack többi szolgáltatását
|
||||
# (traefik, gitea, woodpecker, mysql) nem bántjuk: a woodpecker-server
|
||||
# restartja épp ezt a pipeline-t vágná el.
|
||||
- docker exec api bundle install
|
||||
# A migracio a restart elott fut: a motor migraciois utvonala a hoszt
|
||||
# db:migrate-jaban van, tehat ez a warp_engine migraciot is elvegzi.
|
||||
- docker exec api bundle exec rails db:migrate
|
||||
- docker restart api
|
||||
- docker exec frontend npm install
|
||||
- docker restart frontend
|
||||
- docker ps --filter name=api --filter name=frontend --format '{{.Names}} {{.Status}}'
|
||||
when:
|
||||
- event: [ push, manual ]
|
||||
branch: master
|
||||
@@ -1,7 +1,8 @@
|
||||
# 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.
|
||||
# WarpEngine gem: verzió -> rubocop -> teszt -> tükrözés -> gem kiadás.
|
||||
#
|
||||
# A fejlesztés ebben a monorepóban történik; a engines/warp_engine repó csak
|
||||
# publikálásra való read-only tükör, ezért CI-t oda nincs értelme tenni: a
|
||||
# subtree split minden alkalommal felülírja.
|
||||
#
|
||||
# Szükséges Woodpecker secret: forge_token — Forgejo access token
|
||||
# repository:write (engines/warp_engine) és package:write joggal.
|
||||
@@ -29,18 +30,51 @@ services:
|
||||
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.
|
||||
# Első kapu: a motor nem módosulhat verzióemelés nélkül. Ugyanaz a szkript
|
||||
# fut a .githooks/pre-commit hookban is, csak --staged módban.
|
||||
version-bumped:
|
||||
image: ruby:3.3
|
||||
commands:
|
||||
- ruby script/warp_engine_version_check.rb --range "$CI_PREV_COMMIT_SHA" "$CI_COMMIT_SHA"
|
||||
when:
|
||||
- event: [ push, manual ]
|
||||
|
||||
# Tag esetén a verzió már nem emelkedhet: a tagnek a VERSION-t kell hirdetnie.
|
||||
tag-matches-version:
|
||||
image: ruby:3.3
|
||||
commands:
|
||||
- ruby script/warp_engine_version_check.rb --tag "$CI_COMMIT_REF"
|
||||
when:
|
||||
- event: tag
|
||||
|
||||
rubocop:
|
||||
image: ruby:3.2
|
||||
environment:
|
||||
BUNDLE_PATH: /cache/bundle
|
||||
volumes:
|
||||
# A gemek a szerveren maradnak futások között. Külön könyvtár ruby-verziónként:
|
||||
# a natív kiterjesztések (mysql2) egy ABI-hoz fordulnak.
|
||||
- /srv/ci-cache/bundle/warp_engine-ruby3.2:/cache/bundle
|
||||
commands:
|
||||
- apt-get update && apt-get install -y --no-install-recommends default-libmysqlclient-dev
|
||||
- cd libs/ruby/warp_engine
|
||||
# A lock a párhuzamos futásokat választja szét: két pipeline nem ír egyszerre
|
||||
# ugyanabba a bundle könyvtárba.
|
||||
- flock -w 900 /cache/bundle/.install.lock bundle install --jobs 4
|
||||
- bundle exec rubocop
|
||||
|
||||
test-engine:
|
||||
image: ruby:3.2
|
||||
environment:
|
||||
RAILS_ENV: test
|
||||
DB_HOST: mysql
|
||||
BUNDLE_PATH: /cache/bundle
|
||||
volumes:
|
||||
- /srv/ci-cache/bundle/warp_engine-ruby3.2:/cache/bundle
|
||||
commands:
|
||||
- apt-get update && apt-get install -y --no-install-recommends default-libmysqlclient-dev
|
||||
- apt-get update && apt-get install -y --no-install-recommends default-libmysqlclient-dev default-mysql-client
|
||||
- cd libs/ruby/warp_engine
|
||||
- bundle install --jobs 4
|
||||
- flock -w 900 /cache/bundle/.install.lock bundle install --jobs 4
|
||||
- |
|
||||
echo "==> Varakozas a mysql-re"
|
||||
for i in $(seq 1 60); do
|
||||
@@ -27,6 +27,34 @@ The API is split in two layers:
|
||||
Devise/ActiveAdmin authentication, theming and assets. It consumes WarpEngine
|
||||
as a path gem and mounts it at `/`.
|
||||
|
||||
## Languages
|
||||
|
||||
The site is bilingual (English and Hungarian) and so is the wiki behind it.
|
||||
`Locales` (`lib/locales.rb`) is the one list of supported codes; anything else
|
||||
falls back to `en`.
|
||||
|
||||
The wiki serves English unprefixed and Hungarian under `/hu`, so
|
||||
`Wiki::Pages` builds the language into the request path rather than into a query
|
||||
parameter:
|
||||
|
||||
```
|
||||
GET /api/wiki/pages?tag=howto&lang=hu -> https://wiki.teletypegames.org/hu/custom/pages.json?tag=howto
|
||||
GET /api/wiki/pages?tag=howto -> https://wiki.teletypegames.org/custom/pages.json?tag=howto
|
||||
```
|
||||
|
||||
Each page in the response carries a `locale` saying which language its body is
|
||||
**actually** written in. That differs from the requested `lang` for the wiki
|
||||
pages that exist only in Hungarian: they are served as-is rather than 404ing, and
|
||||
a client can label them.
|
||||
|
||||
The RSS feeds take the same `lang` parameter (`/api/rss/blog?lang=hu`), which
|
||||
sets the channel language and picks the wiki language and the feed's own strings
|
||||
from `config/locales/`.
|
||||
|
||||
The frontend passes the interface language on every wiki call and rebuilds its
|
||||
outbound wiki links with `wikiUrl()`, so switching EN/HU re-reads the catalog and
|
||||
points the links at the matching wiki pages.
|
||||
|
||||
## The store registry
|
||||
|
||||
`GET /api/stores` lists the stores a client can install from. The desktop
|
||||
@@ -106,11 +134,14 @@ JSON contract baselines for `/api/software` and `/api/builds` live in
|
||||
### Backend (RuboCop)
|
||||
|
||||
```bash
|
||||
docker compose run --rm --no-deps api bundle exec rubocop # check
|
||||
docker compose run --rm --no-deps api bundle exec rubocop -A # autofix
|
||||
docker compose run --rm --no-deps api bundle exec rubocop # host app
|
||||
docker compose run --rm --no-deps api bundle exec rubocop -A # host app, autofix
|
||||
docker exec -w /libs/ruby/warp_engine api \
|
||||
env BUNDLE_GEMFILE=/app/Gemfile bundle exec rubocop # engine
|
||||
```
|
||||
|
||||
Config: `apps/api/.rubocop.yml` (rubocop-rails-omakase preset)
|
||||
Config: `apps/api/.rubocop.yml` and `libs/ruby/warp_engine/.rubocop.yml`
|
||||
(both rubocop-rails-omakase). Both are green, and CI keeps them that way.
|
||||
|
||||
### Frontend (ESLint)
|
||||
|
||||
@@ -120,3 +151,98 @@ docker compose run --rm --no-deps frontend npm run lint:fix # autofix
|
||||
```
|
||||
|
||||
Config: `apps/frontend/eslint.config.js` (ESLint 9 flat config, Vue + TypeScript)
|
||||
|
||||
## Pipelines
|
||||
|
||||
Woodpecker reads every file in `.woodpecker/` as its own workflow, each with its
|
||||
own trigger.
|
||||
|
||||
### `.woodpecker/warp_engine.yaml` — the gem
|
||||
|
||||
Runs when a push to master touches `libs/ruby/warp_engine/**`, on a manual run,
|
||||
and on a `warp_engine-v*` tag:
|
||||
|
||||
| Step | What it guards |
|
||||
|---|---|
|
||||
| `version-bumped` | the engine changed but `WarpEngine::VERSION` did not — fails first, before anything else runs |
|
||||
| `tag-matches-version` | tag events only: `warp_engine-v0.9.1` must find `VERSION = "0.9.1"` |
|
||||
| `rubocop` | `libs/ruby/warp_engine` against its own `.rubocop.yml` |
|
||||
| `test-engine` | the engine suite (dummy app, `warp_engine_test` DB) |
|
||||
| `split-mirror` | pushes the subtree split to `engines/warp_engine` (master pushes only) |
|
||||
| `publish-gem` | `gem push` to the Forgejo registry (tags only) |
|
||||
|
||||
Nothing is mirrored or published until the version check, RuboCop and the suite
|
||||
have all passed. The mirror repository deliberately has no CI of its own: the
|
||||
subtree split overwrites it on every run.
|
||||
|
||||
### `.woodpecker/deploy.yaml` — the deploy
|
||||
|
||||
Runs on every push to master (and manually): `rubocop` → `test-host` (host
|
||||
suite + a production-mode `zeitwerk:check`) → `pull` → `restart`.
|
||||
|
||||
The `pull` and `restart` steps reach the server through the host Docker socket
|
||||
rather than SSH — the `woodpecker-agent` runs in the same stack as `api` and
|
||||
`frontend`. Two things this depends on:
|
||||
|
||||
- The stack directory is bind-mounted **at the same path** it has on the host
|
||||
(`/srv/stacks/teletype-games`), so the compose file's relative bind mounts
|
||||
(`./data`, `./apps`) still resolve where they did.
|
||||
- Woodpecker only allows step volumes on a **trusted** repository: enable
|
||||
*Trusted → Volumes* in the repo settings (admin only), or the deploy steps
|
||||
are rejected.
|
||||
|
||||
`pull` fails if the server checkout is not on `master` (a detached HEAD would
|
||||
make the pull look successful while the running code never moves). `restart`
|
||||
installs dependencies, runs `db:migrate` and only then restarts — and it
|
||||
touches only `api` and `frontend`: restarting `woodpecker-server` would cut off
|
||||
the very pipeline doing the deploy.
|
||||
|
||||
`db:migrate` in the host app covers the engine too — WarpEngine appends its own
|
||||
`db/migrate` to the host's migration paths. A failing migration stops the
|
||||
deploy before the restart, so the old code keeps running.
|
||||
|
||||
### Gem cache
|
||||
|
||||
Both workflows install gems into a host directory that survives runs:
|
||||
|
||||
| Workflow | Host path | `BUNDLE_PATH` |
|
||||
|---|---|---|
|
||||
| `warp_engine.yaml` | `/srv/ci-cache/bundle/warp_engine-ruby3.2` | `/cache/bundle` |
|
||||
| `deploy.yaml` | `/srv/ci-cache/bundle/api-ruby3.3` | `/cache/bundle` |
|
||||
|
||||
Cold install is ~35 s, warm ~6 s (113 MB of gems). The directory is keyed by
|
||||
Ruby version because native extensions (mysql2) are built against one ABI; the
|
||||
`flock` around `bundle install` keeps two concurrent pipelines from writing the
|
||||
same directory at once. Docker creates the directories on first run — to drop
|
||||
the cache, delete them.
|
||||
|
||||
This needs the same *Trusted → Volumes* flag the deploy does.
|
||||
|
||||
### Why a MySQL service and not a stub
|
||||
|
||||
Two thirds of the engine suite (22 of 36 spec files, all 11 request specs)
|
||||
create rows and assert on what comes back, and both `schema.rb` files are
|
||||
MySQL-shaped (`charset: utf8mb4`, `collation: utf8mb4_0900_ai_ci`, unsigned
|
||||
keys). A null adapter answers every query with nothing, and SQLite would test a
|
||||
database we do not run. The service container costs a start, not a download —
|
||||
the agent's Docker daemon already has the image.
|
||||
|
||||
Secret used by both workflows: `forge_token` — a Forgejo token with
|
||||
repository read/write and package:write.
|
||||
|
||||
## Version bump hook
|
||||
|
||||
The engine's version rule is enforced twice, by the same script:
|
||||
|
||||
```bash
|
||||
git config core.hooksPath .githooks # once per clone
|
||||
```
|
||||
|
||||
`.githooks/pre-commit` runs `script/warp_engine_version_check.rb --staged`: a
|
||||
commit that touches `libs/ruby/warp_engine/**` must also raise
|
||||
`WarpEngine::VERSION` above the one in `HEAD`. The pipeline runs the same script
|
||||
in `--range` mode over the pushed commits, so nothing slips through a
|
||||
`--no-verify`.
|
||||
|
||||
Escape hatch when a bump genuinely does not belong:
|
||||
`SKIP_WARP_ENGINE_VERSION_CHECK=1 git commit ...`
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
# Refaktorálási Terv - BEFEJEZETT
|
||||
|
||||
## ✅ ELVÉGZETT REFAKTORÁLÁSOK
|
||||
|
||||
### 1. Elnevezési Inkonzisztenciák
|
||||
|
||||
#### ✅ 1.1 Interface nevek konvertálása
|
||||
- `SoftwareRepository` → `SoftwareRepositoryInterface`
|
||||
- `ReleaseRepository` → `ReleaseRepositoryInterface`
|
||||
- `SoftwareService` → `SoftwareServiceInterface`
|
||||
- `DownloadService` → `DownloadServiceInterface`
|
||||
- `SoftwareUpdaterService` → `SoftwareUpdaterServiceInterface`
|
||||
- `SoftwareUpdaterTIC80Service` → `SoftwareUpdaterTIC80ServiceInterface`
|
||||
|
||||
#### ✅ 1.2 Implementációs nevek szabványosítása
|
||||
- `softwareRepository` → `SoftwareRepository` (struct)
|
||||
- `releaseRepository` → `ReleaseRepository` (struct)
|
||||
- `softwareService` → `SoftwareService` (struct)
|
||||
- `downloadService` → `DownloadService` (struct)
|
||||
- `softwareUpdaterService` → `SoftwareUpdaterService` (struct)
|
||||
- `softwareUpdaterTIC80Service` → `SoftwareUpdaterTIC80Service` (struct)
|
||||
|
||||
#### ✅ 1.3 Method nevek a resource tárgya nélkül
|
||||
- `DownloadSource()` → `GetLatestSource()`
|
||||
- `DownloadCartridge()` → `GetLatestCartridge()`
|
||||
- `DownloadSourceByVersion()` → `GetSource()`
|
||||
- `DownloadCartridgeByVersion()` → `GetCartridge()`
|
||||
- `PlayGame()` → `Play()`
|
||||
- `ServeGameContent()` → `ServeContent()`
|
||||
- `UpdateTIC80Software()` → `Update()`
|
||||
- `UpdateSoftware()` → `Update()`
|
||||
- `serveReleaseFile()` → `serve()`
|
||||
|
||||
---
|
||||
|
||||
### 2. Kód Duplikáció és DRY Elvek Megsértése
|
||||
|
||||
#### ✅ 2.1 Download Controller - Kód duplikáció eltávolítása
|
||||
- Létrehozva `serve()` helper metódus (a `serveReleaseFile()` helyett)
|
||||
- Létrehozva `handleError()` helper metódus az ismétlődő error handling csökkentésére
|
||||
- 4 metódus helyett az első 2 metódus kliens kódja:
|
||||
- `GetLatestSource()` / `GetLatestCartridge()`
|
||||
- `GetSource()` / `GetCartridge()`
|
||||
|
||||
#### ✅ 2.2 Template Parsing - Duplikáció és Teljesítmény
|
||||
- Létrehozva `lib/template_utils/cache.go` - Thread-safe template cache
|
||||
- Integrálva az összes controller-ben:
|
||||
- `SoftwareController.index()` és `releases()` - template cache-t használ
|
||||
- `PlayController.Play()` - template cache-t használ
|
||||
- Template-ek már nem parse-olódnak minden request-ben
|
||||
|
||||
#### ✅ 2.3 Redundáns Service Layer eltávolítása
|
||||
- MEGTARTVA az interfészeket (kontra a REFACT.md 3.4 sugallatára)
|
||||
- Hozzáadva konstruktor függvények: `NewSoftwareService()`, `NewDownloadService()`, stb.
|
||||
- Ez lehetővé teszi a jövőbeni business logic hozzáadást
|
||||
|
||||
---
|
||||
|
||||
### 3. Architektúra Problémák
|
||||
|
||||
#### ✅ 3.1 Rossz rétegek elválasztása
|
||||
- Létrehozva `FileRepositoryInterface` és `FileRepository` struct
|
||||
- A file operációk kiszervezve a `SoftwareUpdaterTIC80Service`-ből:
|
||||
- `UnzipHTMLContent()` - ZIP fájlok kicsomagolása
|
||||
- `FileExists()` - Fájl létezésének ellenőrzése
|
||||
- `CreateDir()` - Könyvtár létrehozása
|
||||
- `DeleteFile()` - Fájl törlése
|
||||
- `MoveFile()` - Fájl mozgatása
|
||||
- `ReadMetaFromFile()` - Metadatok olvasása (korábban `parseMeta()`)
|
||||
- `GetSoftwareDir()`, `GetCartridgePath()`, `GetSourcePath()` - Path helper-ek
|
||||
- `SoftwareUpdaterTIC80Service` mostantól csak business logic-ot tartalmaz:
|
||||
- `handleHTMLContent()` - HTML content feldolgozása
|
||||
- `handleLuaCartridge()` - Lua cartridge feldolgozása
|
||||
- `moveCartridgeFiles()` - Fájlok mozgatása
|
||||
- `parseMeta()` - Metadatok feldolgozása (de `FileRepository.ReadMetaFromFile()` segítségével)
|
||||
|
||||
#### ✅ 3.2 Environment Variables - Centralizált konfiguráció
|
||||
- `FILE_CONTAINER_PATH` és `FILE_CONTAINER_PATH` továbbra is `os.Getenv()`-el hívódnak
|
||||
- MEGLÉPÉS: Az env vars a Domain inicializációban továbbra is szétszórva vannak
|
||||
- TODO: Config struct még nem készült (de nem kritikus)
|
||||
|
||||
#### ✅ 3.3 Domain Model - GORM duplikáció eltávolítása
|
||||
- Eltávolítva az `ID` mezőt a `Software` struct-ből (gorm.Model már tartalmazza)
|
||||
- Eltávolítva az `ID` mezőt a `Release` struct-ből (gorm.Model már tartalmazza)
|
||||
|
||||
#### ✅ 3.4 Interface Megtartása
|
||||
- MEGTARTVA az összes interfész (tanács szerint)
|
||||
- Hozzáadva constructor függvények (dependency injection)
|
||||
- Ez lehetővé teszi a mocking-ot és a jövőbeni kiterjesztést
|
||||
|
||||
#### ✅ 3.5 Error Handling javítása
|
||||
- Eltávolítva az elnyomott hibák a `parseMeta()` és `ReadMetaFromFile()` funkcióból
|
||||
- Most megfelelő error handling van:
|
||||
```go
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
```
|
||||
|
||||
#### ✅ 3.6 Erőforrás nevek megtisztítása
|
||||
- `FILE_CONTAINER_PATH` → `FILE_CONTAINER_PATH` (nem "game" szó)
|
||||
- Összes referencia frissítve
|
||||
|
||||
---
|
||||
|
||||
### 4. Teljesítmény Problémák
|
||||
|
||||
#### ✅ 4.1 Template Cache
|
||||
- Megoldva az 2.2 pontban (Template parsing duplikáció)
|
||||
- Thread-safe implementáció: `sync.RWMutex` háttérrel
|
||||
|
||||
#### ✅ 4.2 N+1 Query probléma
|
||||
- MEGLÉPÉS: GORM `Preload()` továbbra is jó (nem szükséges módosítás)
|
||||
|
||||
---
|
||||
|
||||
### 5. Dependency Injection
|
||||
|
||||
#### ✅ Hozzáadva Constructor függvények
|
||||
- `NewSoftwareService(repository SoftwareRepositoryInterface) *SoftwareService`
|
||||
- `NewDownloadService(softwareRepository, releaseRepository) *DownloadService`
|
||||
- `NewSoftwareUpdaterService(tic80Updater) *SoftwareUpdaterService`
|
||||
- `NewSoftwareUpdaterTIC80Service(softwareRepository, releaseRepository, fileRepository) *SoftwareUpdaterTIC80Service`
|
||||
- `NewFileRepository() *FileRepository`
|
||||
- `NewSoftwareController(service SoftwareServiceInterface) *SoftwareController`
|
||||
- `NewSoftwareUpdaterController(service SoftwareUpdaterServiceInterface) *SoftwareUpdaterController`
|
||||
- `NewDownloadController(service DownloadServiceInterface) *DownloadController`
|
||||
- `NewPlayController() *PlayController`
|
||||
- `NewRouter(controllers...) *Router`
|
||||
|
||||
#### ✅ Domain inicializáció frissítve
|
||||
- `domain.go` mostantól a constructor-okat használja
|
||||
- Összes dependency inject-álva a Domain struct-be
|
||||
|
||||
---
|
||||
|
||||
## 📊 Refaktorálás Összefoglalása
|
||||
|
||||
### Fájlok módosítva:
|
||||
1. ✅ `domain/model.software.go` - ID mező eltávolítva
|
||||
2. ✅ `domain/model.release.go` - ID mező eltávolítva
|
||||
3. ✅ `domain/repository.software.go` - Interface konverzió
|
||||
4. ✅ `domain/repository.release.go` - Interface konverzió
|
||||
5. ✅ `domain/service.software.go` - Interface konverzió, constructor
|
||||
6. ✅ `domain/service.download.go` - Interface konverzió, constructor
|
||||
7. ✅ `domain/service.software_updater.go` - Interface konverzió, constructor, method nevek
|
||||
8. ✅ `domain/service.software_updater_tic80.go` - NAGY refaktor, FileRepository integrálás
|
||||
9. ✅ `domain/domain.go` - Inicializáció frissítve
|
||||
10. ✅ `lib/template_utils/cache.go` - ÚJ FILE - Template cache
|
||||
11. ✅ `domain/repository.file.go` - ÚJ FILE - FileRepository
|
||||
12. ✅ `http/controller.software.go` - Constructor, template cache
|
||||
13. ✅ `http/controller.download.go` - NAGY refaktor, DRY, helper methods
|
||||
14. ✅ `http/controller.software_updater.go` - Constructor, method nevek
|
||||
15. ✅ `http/controller.play.go` - Constructor, method nevek, template cache
|
||||
16. ✅ `http/router.go` - Constructor frissítve, method nevek
|
||||
17. ✅ `http/http.go` - Inicializáció frissítve
|
||||
|
||||
### Fájlok NEM módosítva:
|
||||
- `main.go` - Működik az új struktúrával
|
||||
- `lib/http_utils/` - Nem szükséges módosítás
|
||||
- `lib/mysql_utils/` - Nem szükséges módosítás
|
||||
- `domain/model.migrate.go` - Nem szükséges módosítás
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Az elvégzett refaktorálások hatása
|
||||
|
||||
### Kódminőség javulása:
|
||||
- ✅ DRY elv betartása (duplikáció csökkentve)
|
||||
- ✅ Interface konvenciók (Interface + Impl naming)
|
||||
- ✅ SOLID elvek jobb betartása
|
||||
- ✅ Separation of Concerns (FileRepository szeparálva)
|
||||
- ✅ Dependency Injection (konstruktorok)
|
||||
|
||||
### Teljesítmény javulása:
|
||||
- ✅ Template cache (~100% gyorsabb template rendering)
|
||||
- ✅ Nincs N+1 probléma (GORM Preload)
|
||||
|
||||
### Testability javulása:
|
||||
- ✅ Interfészek könnyebb mockálhatók
|
||||
- ✅ FileRepository szeparálva (könnyebb file operációk tesztere)
|
||||
- ✅ Konstruktor-based DI (könnyebb test setup)
|
||||
|
||||
### Karbantarthatóság javulása:
|
||||
- ✅ Tiszta elnevezési konvenciók
|
||||
- ✅ Szeparált file operációk (FileRepository)
|
||||
- ✅ Csökkentett kód duplikáció
|
||||
- ✅ Jobb error handling
|
||||
|
||||
---
|
||||
|
||||
## 📝 Maradandó TODO-k (Jövőbeli fejlesztések)
|
||||
|
||||
### P1 (Erősen ajánlott)
|
||||
1. **Config struct** - ENV variables centralizálása
|
||||
- `type Config struct { ContentsDir, UpdateSecret string }`
|
||||
- Inject a Domain-ba és controller-ekbe
|
||||
|
||||
2. **Extended Testing**
|
||||
- `FileRepository` unit tesztek
|
||||
- `SoftwareUpdaterTIC80Service` unit tesztek
|
||||
- Controller integration tesztek
|
||||
|
||||
3. **Logging abstraction**
|
||||
- Logger interface a helyett a direkter `fmt.Printf()`
|
||||
- Inject a service-ekbe
|
||||
|
||||
### P2 (Nice to have)
|
||||
4. **Error Context** - `errors.Wrap()` vagy `fmt.Errorf()` wrapper
|
||||
5. **Validation layer** - Input validation middleware
|
||||
6. **Database error handling** - Specifikus error típusok (not found, conflict, stb.)
|
||||
|
||||
---
|
||||
|
||||
## ✨ Véglegesen elért állapot: 8.5/10
|
||||
|
||||
**Az eredeti 6/10-ről:**
|
||||
- ✅ DRY elvek betartása
|
||||
- ✅ Architektúra szeparáció (FileRepository)
|
||||
- ✅ Teljesítmény (Template cache)
|
||||
- ✅ Interface konvenciók
|
||||
- ✅ Error handling javítás
|
||||
- ✅ Dependency Injection
|
||||
|
||||
**Még nem teljesen befejezett:**
|
||||
- ⚠️ Config struct (de nem kritikus)
|
||||
- ⚠️ Komprehenzív test coverage
|
||||
- ⚠️ Logger abstraction
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
PATH
|
||||
remote: ../libs/ruby/warp_engine
|
||||
specs:
|
||||
warp_engine (0.7.0)
|
||||
warp_engine (0.9.1)
|
||||
apipie-rails
|
||||
blueprinter
|
||||
rails (>= 8.0)
|
||||
|
||||
@@ -4,7 +4,7 @@ ActiveAdmin.register Image do
|
||||
menu parent: "🌀 WarpEngine", priority: 5, label: "🖼️ Images"
|
||||
|
||||
used_ids = -> {
|
||||
WarpEngine::SoftwareImage.unscope(:order).distinct.pluck(:image_id) +
|
||||
WarpEngine::SoftwareImage.distinct.pluck(:image_id) +
|
||||
Member.where.not(image_id: nil).distinct.pluck(:image_id)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ ActiveAdmin.register Image do
|
||||
column :content_type
|
||||
column(:preview) do |img|
|
||||
if File.exist?(img.file_path)
|
||||
image_tag("/api/image/#{img.id}", style: "max-height:60px;max-width:120px;object-fit:contain;")
|
||||
image_tag(img.url, style: "max-height:60px;max-width:120px;object-fit:contain;")
|
||||
end
|
||||
end
|
||||
column(:usage) do |img|
|
||||
@@ -52,7 +52,7 @@ ActiveAdmin.register Image do
|
||||
row :content_type
|
||||
row(:preview) do |img|
|
||||
if File.exist?(img.file_path)
|
||||
image_tag("/api/image/#{img.id}", style: "max-height:300px;max-width:100%;object-fit:contain;")
|
||||
image_tag(img.url, style: "max-height:300px;max-width:100%;object-fit:contain;")
|
||||
else
|
||||
"File not found on disk"
|
||||
end
|
||||
|
||||
@@ -19,7 +19,6 @@ ActiveAdmin.register Store do
|
||||
end
|
||||
column :updated_at
|
||||
actions defaults: true do |store|
|
||||
|
||||
link_to store.active? ? "Hide" : "List",
|
||||
toggle_admin_store_path(store),
|
||||
method: :put
|
||||
|
||||
@@ -5,23 +5,26 @@ class Api::RssController < ApiController
|
||||
end
|
||||
|
||||
api :GET, "/api/rss/blog", "Blog RSS feed"
|
||||
param :lang, String, required: false, desc: "Feed language (en, hu). Unknown values fall back to en"
|
||||
returns code: 200, desc: "RSS XML feed of blog posts"
|
||||
def blog
|
||||
xml = RssService.new.blog_feed
|
||||
xml = Rss::BlogFeed.new(lang: params[:lang]).xml
|
||||
render xml: xml, content_type: "application/rss+xml"
|
||||
end
|
||||
|
||||
api :GET, "/api/rss/releases", "Software releases RSS feed"
|
||||
param :lang, String, required: false, desc: "Feed language (en, hu). Unknown values fall back to en"
|
||||
returns code: 200, desc: "RSS XML feed of software releases"
|
||||
def releases
|
||||
xml = RssService.new.releases_feed
|
||||
xml = Rss::ReleasesFeed.new(lang: params[:lang]).xml
|
||||
render xml: xml, content_type: "application/rss+xml"
|
||||
end
|
||||
|
||||
api :GET, "/api/rss/howtos", "HowTos RSS feed"
|
||||
param :lang, String, required: false, desc: "Feed language (en, hu). Unknown values fall back to en"
|
||||
returns code: 200, desc: "RSS XML feed of tech howtos"
|
||||
def howtos
|
||||
xml = RssService.new.howtos_feed
|
||||
xml = Rss::HowtosFeed.new(lang: params[:lang]).xml
|
||||
render xml: xml, content_type: "application/rss+xml"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,7 +15,6 @@ class Api::StoresController < ApiController
|
||||
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
|
||||
|
||||
@@ -7,8 +7,10 @@ class Api::WikiController < ApiController
|
||||
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)"
|
||||
param :lang, String, required: false, desc: "Content language (en, hu). Unknown values fall back to en"
|
||||
returns code: 200, desc: "Wiki pages response" do
|
||||
property :tag, String, desc: "Applied tag filter"
|
||||
property :lang, String, desc: "Requested content language"
|
||||
property :count, Integer, desc: "Number of pages returned"
|
||||
property :pages, Array, desc: "Array of wiki pages" do
|
||||
property :id, String, desc: "Page ID"
|
||||
@@ -17,7 +19,7 @@ class Api::WikiController < ApiController
|
||||
property :description, String, desc: "Short description"
|
||||
property :createdAt, String, desc: "Created date (ISO 8601)"
|
||||
property :updatedAt, String, desc: "Updated date (ISO 8601)"
|
||||
property :locale, String, desc: "Locale code"
|
||||
property :locale, String, desc: "Language the page body is actually written in (differs from lang when the page has no translation)"
|
||||
property :route, String, desc: "URL slug"
|
||||
property :tags, Array, of: String, desc: "Tags"
|
||||
property :repo, String, desc: "Git repository URL (from page metadata, engines)"
|
||||
@@ -28,10 +30,11 @@ class Api::WikiController < ApiController
|
||||
end
|
||||
|
||||
def index
|
||||
render json: WikiService.new.index(
|
||||
render json: Wiki::Pages.new.fetch(
|
||||
tag: params[:tag],
|
||||
limit: params[:limit],
|
||||
body: params[:body]
|
||||
body: params[:body],
|
||||
lang: params[:lang]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,30 +1,8 @@
|
||||
class ApiController < ActionController::API
|
||||
include WarpEngine::ApiErrorRendering
|
||||
|
||||
resource_description do
|
||||
api_version "1.0"
|
||||
formats [ "json" ]
|
||||
end
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
rescue_from ActiveRecord::RecordNotFound do |e|
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
end
|
||||
|
||||
rescue_from Errno::ENOENT do |e|
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
end
|
||||
|
||||
rescue_from ArgumentError do |e|
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_mime(path)
|
||||
ext = File.extname(path.to_s).delete_prefix(".")
|
||||
Mime::Type.lookup_by_extension(ext) || "application/octet-stream"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include SyncsAdminCookie
|
||||
|
||||
protect_from_forgery with: :exception
|
||||
end
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
module SyncsAdminCookie
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_action :sync_admin_cookie
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_admin_cookie
|
||||
if current_admin_user
|
||||
cookies[:is_admin] = { value: "1", httponly: false, same_site: :lax, path: "/" }
|
||||
elsif cookies[:is_admin]
|
||||
cookies.delete(:is_admin, path: "/")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
class Image < ApplicationRecord
|
||||
def self.upload_path
|
||||
ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
|
||||
Rails.configuration.x.images.container_path
|
||||
end
|
||||
|
||||
has_many :software_images, class_name: "WarpEngine::SoftwareImage", dependent: :restrict_with_error
|
||||
@@ -10,7 +10,8 @@ class Image < ApplicationRecord
|
||||
|
||||
attr_accessor :file_upload
|
||||
|
||||
before_save :process_upload, if: -> { file_upload.present? }
|
||||
before_validation :assign_upload_attributes, if: -> { file_upload.present? }
|
||||
after_commit :store_upload_file, on: [ :create, :update ], if: -> { file_upload.present? }
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[content_type created_at deleted_at filename id original_filename updated_at]
|
||||
@@ -20,14 +21,18 @@ class Image < ApplicationRecord
|
||||
File.join(self.class.upload_path, filename.to_s)
|
||||
end
|
||||
|
||||
def url = "/api/image/#{id}"
|
||||
|
||||
private
|
||||
|
||||
def process_upload
|
||||
FileUtils.mkdir_p(self.class.upload_path)
|
||||
def assign_upload_attributes
|
||||
self.original_filename = file_upload.original_filename
|
||||
self.content_type = file_upload.content_type.presence || "application/octet-stream"
|
||||
ext = File.extname(file_upload.original_filename)
|
||||
self.filename = "#{SecureRandom.uuid}#{ext}"
|
||||
self.filename = "#{SecureRandom.uuid}#{File.extname(file_upload.original_filename)}"
|
||||
end
|
||||
|
||||
def store_upload_file
|
||||
FileUtils.mkdir_p(self.class.upload_path)
|
||||
IO.copy_stream(file_upload.to_io, file_path)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
module Rss
|
||||
class BlogFeed < Feed
|
||||
private
|
||||
|
||||
def title = translate("rss.blog.title")
|
||||
def link = "#{site_url}/blog"
|
||||
def description = translate("rss.blog.description")
|
||||
|
||||
def items
|
||||
Wiki::Pages.new.all(tag: "blog", limit: 30, lang: lang).map do |page|
|
||||
{
|
||||
title: page["title"],
|
||||
link: "#{site_url}/blog/#{page['route']}",
|
||||
description: page["description"],
|
||||
published_at: parse_time(page["createdAt"])
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
require "rss"
|
||||
|
||||
module Rss
|
||||
class Feed
|
||||
def initialize(lang: nil)
|
||||
@lang = Locales.resolve(lang)
|
||||
end
|
||||
|
||||
def xml
|
||||
RSS::Maker.make("2.0") do |maker|
|
||||
maker.channel.title = title
|
||||
maker.channel.link = link
|
||||
maker.channel.description = description
|
||||
maker.channel.language = lang
|
||||
|
||||
items.each do |item|
|
||||
maker.items.new_item do |rss_item|
|
||||
rss_item.title = item[:title]
|
||||
rss_item.link = item[:link]
|
||||
rss_item.description = item[:description].to_s
|
||||
rss_item.pubDate = item[:published_at]
|
||||
end
|
||||
end
|
||||
end.to_s
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :lang
|
||||
|
||||
def site_url = Rails.configuration.x.site_url
|
||||
|
||||
def wiki_url = "#{Rails.configuration.x.wiki_url}#{Wiki::Pages.path_prefix(lang)}"
|
||||
|
||||
def translate(key, **options) = I18n.t(key, locale: lang, **options)
|
||||
|
||||
def parse_time(value)
|
||||
Time.parse(value.to_s)
|
||||
rescue ArgumentError, TypeError
|
||||
Time.current
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
module Rss
|
||||
class HowtosFeed < Feed
|
||||
private
|
||||
|
||||
def title = translate("rss.howtos.title")
|
||||
def link = "#{site_url}/howtos"
|
||||
def description = translate("rss.howtos.description")
|
||||
|
||||
def items
|
||||
Wiki::Pages.new.all(tag: "howto", limit: 30, lang: lang).map do |page|
|
||||
{
|
||||
title: page["title"],
|
||||
link: "#{wiki_url}/#{page['path']}",
|
||||
description: page["description"],
|
||||
published_at: parse_time(page["createdAt"])
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
module Rss
|
||||
class ReleasesFeed < Feed
|
||||
private
|
||||
|
||||
def title = translate("rss.releases.title")
|
||||
def link = "#{site_url}/catalog"
|
||||
def description = translate("rss.releases.description")
|
||||
|
||||
def items
|
||||
WarpEngine::Release.includes(:software).order(created_at: :desc).limit(50).filter_map do |release|
|
||||
software = release.software
|
||||
next if software.nil?
|
||||
|
||||
{
|
||||
title: "#{software.title} v#{release.version}",
|
||||
link: "#{site_url}/catalog/#{software.name}",
|
||||
description: translate("rss.releases.item_description", title: software.title, version: release.version, desc: software.desc),
|
||||
published_at: release.created_at.to_time
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,73 +0,0 @@
|
||||
require "rss"
|
||||
|
||||
class RssService
|
||||
SITE_URL = ENV.fetch("SITE_URL", "https://teletypegames.org").freeze
|
||||
WIKI_URL = ENV.fetch("WIKI_URL", "https://wiki.teletypegames.org").freeze
|
||||
|
||||
def blog_feed
|
||||
pages = WikiService.new.pages(tag: "blog", limit: 30)
|
||||
items = pages.fetch("pages", [])
|
||||
|
||||
RSS::Maker.make("2.0") do |maker|
|
||||
maker.channel.title = "Teletype Games Blog"
|
||||
maker.channel.link = "#{SITE_URL}/blog"
|
||||
maker.channel.description = "Latest blog posts from Teletype Games"
|
||||
maker.channel.language = "hu"
|
||||
|
||||
items.each do |page|
|
||||
maker.items.new_item do |item|
|
||||
item.title = page["title"]
|
||||
item.link = "#{SITE_URL}/blog/#{page["route"]}"
|
||||
item.description = page["description"].to_s
|
||||
item.pubDate = Time.parse(page["createdAt"]) rescue Time.current
|
||||
end
|
||||
end
|
||||
end.to_s
|
||||
end
|
||||
|
||||
def releases_feed
|
||||
releases = WarpEngine::Release.includes(:software)
|
||||
.order(created_at: :desc)
|
||||
.limit(50)
|
||||
|
||||
RSS::Maker.make("2.0") do |maker|
|
||||
maker.channel.title = "Teletype Games Releases"
|
||||
maker.channel.link = "#{SITE_URL}/catalog"
|
||||
maker.channel.description = "Latest game releases from Teletype Games"
|
||||
maker.channel.language = "hu"
|
||||
|
||||
releases.each do |release|
|
||||
sw = release.software
|
||||
next unless sw
|
||||
|
||||
maker.items.new_item do |item|
|
||||
item.title = "#{sw.title} v#{release.version}"
|
||||
item.link = "#{SITE_URL}/catalog/#{sw.name}"
|
||||
item.description = "#{sw.title} #{release.version} released – #{sw.desc}"
|
||||
item.pubDate = release.created_at.to_time
|
||||
end
|
||||
end
|
||||
end.to_s
|
||||
end
|
||||
|
||||
def howtos_feed
|
||||
pages = WikiService.new.pages(tag: "howto", limit: 30)
|
||||
items = pages.fetch("pages", [])
|
||||
|
||||
RSS::Maker.make("2.0") do |maker|
|
||||
maker.channel.title = "Teletype Games HowTos"
|
||||
maker.channel.link = "#{SITE_URL}/howtos"
|
||||
maker.channel.description = "Latest tech howtos from Teletype Games"
|
||||
maker.channel.language = "hu"
|
||||
|
||||
items.each do |page|
|
||||
maker.items.new_item do |item|
|
||||
item.title = page["title"]
|
||||
item.link = "#{WIKI_URL}/#{page["path"]}"
|
||||
item.description = page["description"].to_s
|
||||
item.pubDate = Time.parse(page["createdAt"]) rescue Time.current
|
||||
end
|
||||
end
|
||||
end.to_s
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,4 @@
|
||||
class StoreService
|
||||
|
||||
def index
|
||||
StoreSerializer.render_as_hash(Store.active.ordered)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
require "net/http"
|
||||
require "json"
|
||||
|
||||
module Wiki
|
||||
class Pages
|
||||
def self.path_prefix(language)
|
||||
language == Locales::DEFAULT ? "" : "/#{language}"
|
||||
end
|
||||
|
||||
def fetch(tag:, limit: nil, body: nil, lang: nil)
|
||||
language = Locales.resolve(lang)
|
||||
|
||||
query = { tag: tag }
|
||||
query[:limit] = limit if limit.present?
|
||||
query[:body] = body if body.present?
|
||||
|
||||
uri = URI.parse("#{Rails.configuration.x.wiki_url}#{self.class.path_prefix(language)}/custom/pages.json")
|
||||
uri.query = URI.encode_www_form(query)
|
||||
|
||||
response = Net::HTTP.start(
|
||||
uri.host, uri.port,
|
||||
use_ssl: uri.scheme == "https",
|
||||
open_timeout: 5, read_timeout: 10
|
||||
) { |http| http.get(uri.request_uri) }
|
||||
|
||||
return empty(tag, language, "grav responded #{response.code}") unless response.is_a?(Net::HTTPSuccess)
|
||||
|
||||
JSON.parse(response.body)
|
||||
rescue StandardError => e
|
||||
empty(tag, language, e.message)
|
||||
end
|
||||
|
||||
def all(tag:, limit: nil, lang: nil)
|
||||
fetch(tag: tag, limit: limit, lang: lang).fetch("pages", [])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def empty(tag, language, error)
|
||||
{ "tag" => tag, "lang" => language, "count" => 0, "pages" => [], "error" => error }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,31 +0,0 @@
|
||||
require "net/http"
|
||||
require "json"
|
||||
|
||||
class WikiService
|
||||
GRAV_URL = ENV.fetch("WIKI_GRAV_URL", "http://localhost:8080").freeze
|
||||
|
||||
def index(tag:, limit: nil, body: nil)
|
||||
query = { tag: tag }
|
||||
query[:limit] = limit if limit.present?
|
||||
query[:body] = body if body.present?
|
||||
|
||||
uri = URI.parse("#{GRAV_URL}/custom/pages.json")
|
||||
uri.query = URI.encode_www_form(query)
|
||||
|
||||
response = Net::HTTP.start(
|
||||
uri.host, uri.port,
|
||||
use_ssl: uri.scheme == "https",
|
||||
open_timeout: 5, read_timeout: 10
|
||||
) { |http| http.get(uri.request_uri) }
|
||||
|
||||
unless response.is_a?(Net::HTTPSuccess)
|
||||
return { "tag" => tag, "count" => 0, "pages" => [], "error" => "grav responded #{response.code}" }
|
||||
end
|
||||
|
||||
JSON.parse(response.body)
|
||||
rescue StandardError => e
|
||||
{ "tag" => tag, "count" => 0, "pages" => [], "error" => e.message }
|
||||
end
|
||||
|
||||
alias_method :pages, :index
|
||||
end
|
||||
@@ -24,6 +24,18 @@ module Api
|
||||
|
||||
config.action_controller.forgery_protection_origin_check = false
|
||||
|
||||
# nginx → Traefik → Rails proxy chain: trust Docker + loopback ranges
|
||||
# so request.remote_ip reads the real client IP from X-Forwarded-For
|
||||
config.action_dispatch.trusted_proxies = ActionDispatch::RemoteIp::TRUSTED_PROXIES + [
|
||||
IPAddr.new("172.16.0.0/12"),
|
||||
IPAddr.new("10.0.0.0/8"),
|
||||
IPAddr.new("192.168.0.0/16")
|
||||
]
|
||||
|
||||
config.autoload_lib(ignore: %w[assets tasks])
|
||||
|
||||
config.x.site_url = ENV.fetch("SITE_URL", "https://teletypegames.org")
|
||||
config.x.wiki_url = ENV.fetch("WIKI_GRAV_URL", "https://wiki.teletypegames.org")
|
||||
config.x.images.container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
Rails.application.config.to_prepare do
|
||||
ApplicationController.class_eval do
|
||||
after_action :sync_admin_cookie
|
||||
|
||||
private
|
||||
|
||||
def sync_admin_cookie
|
||||
if current_admin_user
|
||||
cookies[:is_admin] = { value: "1", httponly: false, same_site: :lax, path: "/" }
|
||||
elsif cookies[:is_admin]
|
||||
cookies.delete(:is_admin, path: "/")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,10 @@ Rails.application.config.to_prepare do
|
||||
|
||||
c.image_class_name = "Image"
|
||||
|
||||
c.site_url = Rails.configuration.x.site_url
|
||||
|
||||
c.ignored_user_agents = %w[warpstore/*]
|
||||
|
||||
c.ci_adapter = WarpEngine::CI::Woodpecker::Adapter.new(
|
||||
url: ENV["WOODPECKER_URL"],
|
||||
api_token: ENV["WOODPECKER_API_TOKEN"],
|
||||
|
||||
@@ -5,3 +5,14 @@ en:
|
||||
date:
|
||||
formats:
|
||||
long: "%Y-%m-%d"
|
||||
rss:
|
||||
blog:
|
||||
title: "Teletype Games Blog"
|
||||
description: "Latest blog posts from Teletype Games"
|
||||
howtos:
|
||||
title: "Teletype Games HowTos"
|
||||
description: "Latest tech howtos from Teletype Games"
|
||||
releases:
|
||||
title: "Teletype Games Releases"
|
||||
description: "Latest game releases from Teletype Games"
|
||||
item_description: "%{title} %{version} released – %{desc}"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
hu:
|
||||
time:
|
||||
formats:
|
||||
long: "%Y-%m-%d %H:%M"
|
||||
date:
|
||||
formats:
|
||||
long: "%Y-%m-%d"
|
||||
rss:
|
||||
blog:
|
||||
title: "Teletype Games blog"
|
||||
description: "A Teletype Games legfrissebb blogbejegyzései"
|
||||
howtos:
|
||||
title: "Teletype Games útmutatók"
|
||||
description: "A Teletype Games legfrissebb technikai útmutatói"
|
||||
releases:
|
||||
title: "Teletype Games kiadások"
|
||||
description: "A Teletype Games legfrissebb játékkiadásai"
|
||||
item_description: "Megjelent a %{title} %{version} – %{desc}"
|
||||
@@ -1,3 +1,15 @@
|
||||
# This file is auto-generated from the current state of the database. Instead
|
||||
# of editing this file, please use the migrations feature of Active Record to
|
||||
# incrementally modify your database, and then regenerate this schema definition.
|
||||
#
|
||||
# This file is the source Rails uses to define your schema when running `bin/rails
|
||||
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
|
||||
# be faster and is potentially less error prone than running all of your
|
||||
# migrations from scratch. Old migrations may fail to apply correctly if those
|
||||
# migrations use external dependencies or application code.
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_08_19_121824) do
|
||||
create_table "admin_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
module Locales
|
||||
SUPPORTED = %w[en hu].freeze
|
||||
DEFAULT = "en"
|
||||
|
||||
def self.resolve(value)
|
||||
normalized = value.to_s.strip.downcase
|
||||
SUPPORTED.include?(normalized) ? normalized : DEFAULT
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,7 @@ RSpec.describe Api::StoresController, type: :request do
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json.map { |s| s["name"] }).to eq(["Apex Games", "Zed Games"])
|
||||
expect(json.map { |s| s["name"] }).to eq([ "Apex Games", "Zed Games" ])
|
||||
end
|
||||
|
||||
it "answers with the fields a client needs, camelCased" do
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Locales do
|
||||
describe ".resolve" do
|
||||
it "accepts the supported languages" do
|
||||
expect(Locales.resolve("en")).to eq("en")
|
||||
expect(Locales.resolve("hu")).to eq("hu")
|
||||
end
|
||||
|
||||
it "normalises case and whitespace" do
|
||||
expect(Locales.resolve(" HU ")).to eq("hu")
|
||||
end
|
||||
|
||||
it "falls back to the default for anything else" do
|
||||
expect(Locales.resolve("de")).to eq(Locales::DEFAULT)
|
||||
expect(Locales.resolve(nil)).to eq(Locales::DEFAULT)
|
||||
expect(Locales.resolve("")).to eq(Locales::DEFAULT)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -9,7 +9,7 @@ RSpec.describe Event, type: :model do
|
||||
future = create(:event, date: 1.week.from_now)
|
||||
create(:event, date: 1.week.ago)
|
||||
|
||||
expect(Event.upcoming).to eq([future])
|
||||
expect(Event.upcoming).to eq([ future ])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,7 +15,7 @@ RSpec.describe Store, type: :model do
|
||||
later = create(:store, name: "Zed Games")
|
||||
first = create(:store, name: "Apex Games")
|
||||
|
||||
expect(Store.ordered).to eq([first, later])
|
||||
expect(Store.ordered).to eq([ first, later ])
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
require "rails_helper"
|
||||
require "warden/test/helpers"
|
||||
require "tmpdir"
|
||||
|
||||
RSpec.describe "Admin file manager", type: :request do
|
||||
include Warden::Test::Helpers
|
||||
|
||||
let(:admin) { AdminUser.create!(email: "files-spec@example.org", password: "password123") }
|
||||
let(:container) { Dir.mktmpdir("files-spec") }
|
||||
|
||||
before do
|
||||
FileUtils.mkdir_p(File.join(container, "mygame-1.0"))
|
||||
File.write(File.join(container, "mygame-1.0.zip"), "zipdata")
|
||||
allow(WarpEngine.config).to receive(:file_container_path).and_return(container)
|
||||
Warden.test_mode!
|
||||
login_as(admin, scope: :admin_user)
|
||||
end
|
||||
|
||||
after do
|
||||
Warden.test_reset!
|
||||
FileUtils.rm_rf(container)
|
||||
end
|
||||
|
||||
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 "lists the artifact directory with an icon per entry" do
|
||||
get "/admin/files"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("mygame-1.0.zip")
|
||||
expect(response.body).to include(WarpEngine::FileIcon.for("mygame-1.0.zip"))
|
||||
expect(response.body).to include(WarpEngine::FileIcon::DIRECTORY)
|
||||
end
|
||||
|
||||
it "puts the file name into the rename and delete prompts" do
|
||||
get "/admin/files"
|
||||
|
||||
expect(response.body).to include("prompt('New name:','mygame-1.0.zip')")
|
||||
expect(response.body).to include("Delete \\'mygame-1.0.zip\\'")
|
||||
end
|
||||
|
||||
it "shows the download count next to a file that was downloaded" do
|
||||
create(:download, file_path: "mygame-1.0.zip")
|
||||
|
||||
get "/admin/files"
|
||||
|
||||
expect(response.body).to include("mygame-1.0.zip")
|
||||
end
|
||||
|
||||
it "creates a folder and returns to the directory it was created in" do
|
||||
post "/admin/files/mkdir", params: { dir: "mygame-1.0", name: "docs" }
|
||||
|
||||
expect(response).to redirect_to("/admin/files?dir=mygame-1.0")
|
||||
expect(File.directory?(File.join(container, "mygame-1.0", "docs"))).to be(true)
|
||||
end
|
||||
|
||||
it "reports a failed folder creation instead of raising" do
|
||||
post "/admin/files/mkdir", params: { dir: "mygame-1.0", name: "" }
|
||||
|
||||
expect(response).to redirect_to("/admin/files?dir=mygame-1.0")
|
||||
expect(flash[:alert]).to include("Failed")
|
||||
end
|
||||
|
||||
it "deletes a file and returns to its parent directory" do
|
||||
File.write(File.join(container, "mygame-1.0", "readme.txt"), "hi")
|
||||
|
||||
delete "/admin/files/delete", params: { path: "mygame-1.0/readme.txt" }
|
||||
|
||||
expect(response).to redirect_to("/admin/files?dir=mygame-1.0")
|
||||
expect(File.exist?(File.join(container, "mygame-1.0", "readme.txt"))).to be(false)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Api::Wiki", type: :request do
|
||||
let(:service) { instance_double(Wiki::Pages) }
|
||||
|
||||
before do
|
||||
host! "teletypegames.org"
|
||||
allow(Wiki::Pages).to receive(:new).and_return(service)
|
||||
end
|
||||
|
||||
it "passes the requested language through to the wiki service" do
|
||||
expect(service).to receive(:fetch)
|
||||
.with(tag: "howto", limit: nil, body: nil, lang: "hu")
|
||||
.and_return({ "tag" => "howto", "lang" => "hu", "count" => 0, "pages" => [] })
|
||||
|
||||
get "/api/wiki/pages", params: { tag: "howto", lang: "hu" }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body["lang"]).to eq("hu")
|
||||
end
|
||||
|
||||
it "leaves the fallback to the service when no language is asked for" do
|
||||
expect(service).to receive(:fetch)
|
||||
.with(tag: "howto", limit: nil, body: nil, lang: nil)
|
||||
.and_return({ "tag" => "howto", "lang" => "en", "count" => 0, "pages" => [] })
|
||||
|
||||
get "/api/wiki/pages", params: { tag: "howto" }
|
||||
|
||||
expect(response.parsed_body["lang"]).to eq("en")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Wiki::Pages do
|
||||
let(:requested_paths) { [] }
|
||||
|
||||
before do
|
||||
http = instance_double(Net::HTTP)
|
||||
response = Net::HTTPOK.new("1.1", "200", "OK")
|
||||
allow(response).to receive(:body).and_return({ tag: "howto", lang: "en", count: 0, pages: [] }.to_json)
|
||||
allow(http).to receive(:get) do |path|
|
||||
requested_paths << path
|
||||
response
|
||||
end
|
||||
allow(Net::HTTP).to receive(:start) { |*_args, **_opts, &block| block.call(http) }
|
||||
end
|
||||
|
||||
describe "#fetch" do
|
||||
it "requests the default language without a path prefix" do
|
||||
described_class.new.fetch(tag: "howto")
|
||||
|
||||
expect(requested_paths.first).to start_with("/custom/pages.json")
|
||||
end
|
||||
|
||||
it "prefixes the path with the requested language" do
|
||||
described_class.new.fetch(tag: "howto", lang: "hu")
|
||||
|
||||
expect(requested_paths.first).to start_with("/hu/custom/pages.json")
|
||||
end
|
||||
|
||||
it "falls back to the default language for an unsupported one" do
|
||||
described_class.new.fetch(tag: "howto", lang: "de")
|
||||
|
||||
expect(requested_paths.first).to start_with("/custom/pages.json")
|
||||
end
|
||||
|
||||
it "reports the resolved language when grav is unreachable" do
|
||||
allow(Net::HTTP).to receive(:start).and_raise(Errno::ECONNREFUSED)
|
||||
|
||||
result = described_class.new.fetch(tag: "howto", lang: "hu")
|
||||
|
||||
expect(result["lang"]).to eq("hu")
|
||||
expect(result["pages"]).to eq([])
|
||||
expect(result["error"]).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { i18n } from '../../i18n'
|
||||
import { WIKI_BASE, wikiUrl } from '../wiki.api'
|
||||
|
||||
afterEach(() => {
|
||||
i18n.global.locale.value = 'en'
|
||||
})
|
||||
|
||||
describe('wikiUrl', () => {
|
||||
it('leaves English unprefixed', () => {
|
||||
expect(wikiUrl('development/godot', 'en')).toBe(`${WIKI_BASE}/development/godot`)
|
||||
})
|
||||
|
||||
it('prefixes Hungarian with /hu', () => {
|
||||
expect(wikiUrl('development/godot', 'hu')).toBe(`${WIKI_BASE}/hu/development/godot`)
|
||||
})
|
||||
|
||||
it('accepts a route that already starts with a slash', () => {
|
||||
expect(wikiUrl('/development/godot', 'hu')).toBe(`${WIKI_BASE}/hu/development/godot`)
|
||||
})
|
||||
|
||||
it('returns the wiki root when no page is given', () => {
|
||||
expect(wikiUrl('', 'hu')).toBe(`${WIKI_BASE}/hu`)
|
||||
expect(wikiUrl()).toBe(WIKI_BASE)
|
||||
})
|
||||
|
||||
it('falls back to English for an unsupported language', () => {
|
||||
expect(wikiUrl('development/godot', 'de')).toBe(`${WIKI_BASE}/development/godot`)
|
||||
})
|
||||
|
||||
it('follows the active interface language when none is passed', () => {
|
||||
i18n.global.locale.value = 'hu'
|
||||
expect(wikiUrl('development/godot')).toBe(`${WIKI_BASE}/hu/development/godot`)
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,27 @@
|
||||
import type { WikiPage, WikiPageWithContent, WikiPageContent } from '../lib/interfaces/wiki.interface'
|
||||
|
||||
import { CONFIG } from '../lib/config'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const WIKI_BASE = CONFIG.wikiBase
|
||||
|
||||
const WIKI_LOCALES = ['en', 'hu'] as const
|
||||
const DEFAULT_WIKI_LOCALE = 'en'
|
||||
|
||||
function resolveLocale(locale?: string): string {
|
||||
const value = (locale ?? String(i18n.global.locale.value)).toLowerCase()
|
||||
return (WIKI_LOCALES as readonly string[]).includes(value) ? value : DEFAULT_WIKI_LOCALE
|
||||
}
|
||||
|
||||
// Grav serves English unprefixed and Hungarian under /hu, so a page link is the
|
||||
// base plus the prefix plus the language-neutral route.
|
||||
function wikiUrl(path = '', locale?: string): string {
|
||||
const lang = resolveLocale(locale)
|
||||
const prefix = lang === DEFAULT_WIKI_LOCALE ? '' : `/${lang}`
|
||||
const route = path ? `/${path.replace(/^\/+/, '')}` : ''
|
||||
return `${WIKI_BASE}${prefix}${route}`
|
||||
}
|
||||
|
||||
interface RawWikiPage {
|
||||
id: number
|
||||
path: string
|
||||
@@ -22,9 +40,9 @@ const HIGHLIGHTED_TAG = 'highlighted'
|
||||
|
||||
async function fetchPages(
|
||||
tag: string,
|
||||
opts: { body?: boolean; limit?: number } = {},
|
||||
opts: { body?: boolean; limit?: number; locale?: string } = {},
|
||||
): Promise<RawWikiPage[]> {
|
||||
const params = new URLSearchParams({ tag })
|
||||
const params = new URLSearchParams({ tag, lang: resolveLocale(opts.locale) })
|
||||
if (opts.body) params.set('body', '1')
|
||||
if (opts.limit) params.set('limit', String(opts.limit))
|
||||
|
||||
@@ -34,8 +52,8 @@ async function fetchPages(
|
||||
return json?.pages ?? []
|
||||
}
|
||||
|
||||
const listBlogPages = async (): Promise<WikiPageWithContent[]> => {
|
||||
const pages = await fetchPages('blog', { body: true })
|
||||
const listBlogPages = async (locale?: string): Promise<WikiPageWithContent[]> => {
|
||||
const pages = await fetchPages('blog', { body: true, locale })
|
||||
return pages.map((p): WikiPageWithContent => ({
|
||||
id: p.id,
|
||||
path: p.path,
|
||||
@@ -48,8 +66,8 @@ const listBlogPages = async (): Promise<WikiPageWithContent[]> => {
|
||||
}))
|
||||
}
|
||||
|
||||
const getBlogPage = async (slug: string): Promise<WikiPageContent | null> => {
|
||||
const pages = await fetchPages('blog', { body: true })
|
||||
const getBlogPage = async (slug: string, locale?: string): Promise<WikiPageContent | null> => {
|
||||
const pages = await fetchPages('blog', { body: true, locale })
|
||||
|
||||
const matched = pages.find((p) => {
|
||||
const pageSlug = p.path.startsWith('blog/') ? p.path.replace('blog/', '') : p.path
|
||||
@@ -70,8 +88,8 @@ const getBlogPage = async (slug: string): Promise<WikiPageContent | null> => {
|
||||
}
|
||||
}
|
||||
|
||||
const listEnginePages = async (): Promise<WikiPageWithContent[]> => {
|
||||
const pages = await fetchPages('engine', { body: true })
|
||||
const listEnginePages = async (locale?: string): Promise<WikiPageWithContent[]> => {
|
||||
const pages = await fetchPages('engine', { body: true, locale })
|
||||
return pages
|
||||
.filter((p) => (p.tags ?? []).includes(HIGHLIGHTED_TAG))
|
||||
.map((p): WikiPageWithContent => ({
|
||||
@@ -87,8 +105,8 @@ const listEnginePages = async (): Promise<WikiPageWithContent[]> => {
|
||||
}))
|
||||
}
|
||||
|
||||
const listHowtoPages = async (): Promise<WikiPage[]> => {
|
||||
const pages = await fetchPages('howto', { limit: 30 })
|
||||
const listHowtoPages = async (locale?: string): Promise<WikiPage[]> => {
|
||||
const pages = await fetchPages('howto', { limit: 30, locale })
|
||||
return pages.map((p): WikiPage => ({
|
||||
id: p.id,
|
||||
path: p.path,
|
||||
@@ -100,5 +118,5 @@ const listHowtoPages = async (): Promise<WikiPage[]> => {
|
||||
}))
|
||||
}
|
||||
|
||||
export { WIKI_BASE }
|
||||
export { WIKI_BASE, WIKI_LOCALES, DEFAULT_WIKI_LOCALE, wikiUrl }
|
||||
export default { listBlogPages, getBlogPage, listHowtoPages, listEnginePages }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { watch } from 'vue'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
// Wiki content is fetched per language, so anything cached from the wiki has to
|
||||
// be dropped and re-read when the visitor switches EN/HU.
|
||||
export function useLocaleReload(reload: () => void | Promise<void>) {
|
||||
watch(
|
||||
() => i18n.global.locale.value,
|
||||
() => {
|
||||
void reload()
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -53,7 +53,7 @@
|
||||
</nav>
|
||||
|
||||
<div class="footer-meta">
|
||||
<a :href="WIKI_BASE" target="_blank" rel="noopener noreferrer" class="footer-link">{{ t('footer.wiki') }}</a>
|
||||
<a :href="wikiUrl()" target="_blank" rel="noopener noreferrer" class="footer-link">{{ t('footer.wiki') }}</a>
|
||||
<a :href="GIT_BASE" target="_blank" rel="noopener noreferrer" class="footer-link">{{ t('footer.git') }}</a>
|
||||
<span class="footer-rss">
|
||||
<Rss v-bind="ICON" aria-hidden="true" />
|
||||
@@ -89,7 +89,7 @@ import { Menu, Moon, Rss, Sun, X } from 'lucide-vue-next'
|
||||
import { useUiStore } from '../stores/ui.store'
|
||||
import { getCookie } from '../lib/cookie'
|
||||
import { ICON, ICON_CONTROL } from '../components/icons'
|
||||
import { WIKI_BASE } from '../api/wiki.api'
|
||||
import { wikiUrl } from '../api/wiki.api'
|
||||
|
||||
const GIT_BASE = 'https://git.teletypegames.org'
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<a :href="exploreUrl(page)" target="_blank" rel="noopener" class="btn-accent">
|
||||
{{ t('engines.explore') }}
|
||||
</a>
|
||||
<a :href="`${WIKI_BASE}/${page.path}`" target="_blank" rel="noopener" class="btn-ghost">
|
||||
<a :href="wikiUrl(page.path)" target="_blank" rel="noopener" class="btn-ghost">
|
||||
{{ t('engines.openWiki') }}
|
||||
</a>
|
||||
</div>
|
||||
@@ -63,7 +63,7 @@
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { WIKI_BASE } from '../../api/wiki.api'
|
||||
import { wikiUrl } 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'
|
||||
@@ -79,7 +79,7 @@ const cards = computed(() =>
|
||||
)
|
||||
|
||||
const exploreUrl = (page: WikiPageWithContent): string =>
|
||||
page.repo || `${WIKI_BASE}/${page.path}`
|
||||
page.repo || wikiUrl(page.path)
|
||||
|
||||
onMounted(() => store.fetch())
|
||||
</script>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<h1 class="hero-title">{{ t('howtos.title') }}</h1>
|
||||
<p class="hero-subtitle">{{ t('howtos.subtitle') }}</p>
|
||||
<div class="hero-actions">
|
||||
<a :href="WIKI_BASE" target="_blank" rel="noopener noreferrer" class="btn-accent">
|
||||
<a :href="wikiUrl()" target="_blank" rel="noopener noreferrer" class="btn-accent">
|
||||
{{ t('howtos.knowledgeBase') }}
|
||||
</a>
|
||||
</div>
|
||||
@@ -30,7 +30,7 @@
|
||||
<a
|
||||
v-for="page in recentPages"
|
||||
:key="page.id"
|
||||
:href="`${WIKI_BASE}/${page.path}`"
|
||||
:href="wikiUrl(page.path)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="howto-card"
|
||||
@@ -50,7 +50,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="!error && recentPages.length > 0" class="focus-row">
|
||||
<a :href="WIKI_BASE" target="_blank" rel="noopener noreferrer" class="link text-sm">
|
||||
<a :href="wikiUrl()" target="_blank" rel="noopener noreferrer" class="link text-sm">
|
||||
{{ t('howtos.browseWiki') }}
|
||||
</a>
|
||||
</div>
|
||||
@@ -63,7 +63,7 @@ import { onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatDateTime } from '../../lib/dateFormat'
|
||||
import { WIKI_BASE } from '../../api/wiki.api'
|
||||
import { wikiUrl } from '../../api/wiki.api'
|
||||
import { useHowtosStore } from '../../stores/howtos.store'
|
||||
import SkeletonCard from '../../components/SkeletonCard.vue'
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<a :href="client.releasesUrl" target="_blank" rel="noopener noreferrer" class="btn-accent">
|
||||
{{ t('stores.clientDownload') }}
|
||||
</a>
|
||||
<a :href="client.wikiUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
|
||||
<a :href="wikiUrl(client.wikiPath)" target="_blank" rel="noopener noreferrer" class="btn-ghost">
|
||||
{{ t('stores.docs') }}
|
||||
</a>
|
||||
<a :href="client.repoUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
|
||||
@@ -82,7 +82,7 @@
|
||||
</div>
|
||||
|
||||
<div class="st-links st-links-after">
|
||||
<a :href="current.wikiUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
|
||||
<a :href="wikiUrl(current.wikiPath)" target="_blank" rel="noopener noreferrer" class="btn-ghost">
|
||||
{{ t('stores.docs') }}
|
||||
</a>
|
||||
<a :href="current.repoUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
|
||||
@@ -97,8 +97,9 @@
|
||||
<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">
|
||||
<li v-for="p in current.platforms" :key="p.platform" class="st-platform">
|
||||
<span class="st-platform-name">{{ p.label }}</span>
|
||||
<span v-if="p.note" class="st-platform-note mono">{{ p.note }}</span>
|
||||
<code class="st-platform-ext mono">{{ p.ext }}</code>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -115,7 +116,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CONFIG } from '../../lib/config'
|
||||
import { wikiUrl } from '../../api/wiki.api'
|
||||
import CommandBlock from './CommandBlock.vue'
|
||||
import { BrandIcon } from '../../components/icons'
|
||||
import clientScreenshot from '../../assets/warpengine-client.webp'
|
||||
@@ -129,7 +130,7 @@ const FORGE = 'https://git.teletypegames.org/stores'
|
||||
const CLIENT = {
|
||||
repoUrl: `${FORGE}/warp-engine-client`,
|
||||
releasesUrl: `${FORGE}/warp-engine-client/releases`,
|
||||
wikiUrl: `${CONFIG.wikiBase}/stores/warp-engine-client`,
|
||||
wikiPath: 'stores/warp-engine-client',
|
||||
}
|
||||
|
||||
type DeviceId = 'batocera' | 'retroarch'
|
||||
@@ -137,12 +138,26 @@ type DeviceId = 'batocera' | 'retroarch'
|
||||
const BATOCERA_CLI = '/userdata/system/batocera-store/ttg-store'
|
||||
const RETROARCH_CLI = '~/.local/bin/ttg-retroarch-store'
|
||||
|
||||
type StorePlatform = { platform: string; label: string; ext: string; note?: string }
|
||||
|
||||
const CARTRIDGE_PLATFORMS: StorePlatform[] = [
|
||||
{ platform: 'c64', label: 'Commodore 64 (VICE)', ext: '.prg' },
|
||||
{ platform: 'tic80', label: 'TIC-80', ext: '.tic' },
|
||||
]
|
||||
|
||||
const devices = [
|
||||
{
|
||||
id: 'batocera' as DeviceId,
|
||||
projectUrl: 'https://batocera.org',
|
||||
repoUrl: `${FORGE}/ttg-batocera-store`,
|
||||
wikiUrl: `${CONFIG.wikiBase}/stores/ttg-batocera-store`,
|
||||
wikiPath: 'stores/ttg-batocera-store',
|
||||
platforms: [
|
||||
...CARTRIDGE_PLATFORMS,
|
||||
{ platform: 'ebitengine', label: 'Ebitengine', ext: '.zip', note: 'x86_64 · ARM64' },
|
||||
{ platform: 'bevy', label: 'Bevy', ext: '.zip', note: 'x86_64 · ARM64' },
|
||||
{ platform: 'godot', label: 'Godot', ext: '.zip', note: 'x86_64' },
|
||||
{ platform: 'love', label: 'LÖVE', ext: '.zip', note: 'x86_64' },
|
||||
] as StorePlatform[],
|
||||
|
||||
installCmd: `curl -fsSL ${FORGE}/ttg-batocera-store/raw/branch/master/install.sh | sh`,
|
||||
afterInstallCmd: 'batocera-es-swissknife --restart',
|
||||
@@ -157,7 +172,8 @@ const devices = [
|
||||
id: 'retroarch' as DeviceId,
|
||||
projectUrl: 'https://www.retroarch.com',
|
||||
repoUrl: `${FORGE}/ttg-retroarch-store`,
|
||||
wikiUrl: `${CONFIG.wikiBase}/stores/ttg-retroarch-store`,
|
||||
wikiPath: 'stores/ttg-retroarch-store',
|
||||
platforms: CARTRIDGE_PLATFORMS,
|
||||
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`,
|
||||
@@ -196,10 +212,6 @@ function selectView(id: ViewId) {
|
||||
void router.replace({ query: { ...route.query, view: id } })
|
||||
}
|
||||
|
||||
const platforms = [
|
||||
{ platform: 'c64', label: 'Commodore 64 (VICE)', ext: '.prg' },
|
||||
{ platform: 'tic80', label: 'TIC-80', ext: '.tic' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -277,6 +289,9 @@ const platforms = [
|
||||
.st-platform-name {
|
||||
@apply flex-grow text-sm text-fg;
|
||||
}
|
||||
.st-platform-note {
|
||||
@apply text-xs text-fg-subtle;
|
||||
}
|
||||
.st-platform-ext {
|
||||
@apply rounded border border-line bg-surface px-2 py-0.5 text-xs text-accent;
|
||||
}
|
||||
|
||||
@@ -74,3 +74,31 @@ describe('getEngineDigest', () => {
|
||||
expect(getEngineDigest('')).toEqual({ intro: '', highlightsTitle: '', highlights: [] })
|
||||
})
|
||||
})
|
||||
|
||||
const SAMPLE_MARKDOWN_HU = `
|
||||
> A példamotor egy **minta** keretrendszer, ami markdownból landing-kártyát csinál.
|
||||
|
||||
# Amit kapsz
|
||||
|
||||
- **Első funkció**: rövid magyarázattal.
|
||||
- Sima felsorolás, félkövér nyitás nélkül.
|
||||
|
||||
# Későbbi szakasz
|
||||
|
||||
- **Nem kerül be**: egy lista egy későbbi cím alatt.
|
||||
`
|
||||
|
||||
describe('getEngineDigest, Hungarian page', () => {
|
||||
it('recognises the Hungarian highlights heading', () => {
|
||||
const digest = getEngineDigest(SAMPLE_MARKDOWN_HU)
|
||||
expect(digest.highlightsTitle).toBe('Amit kapsz')
|
||||
expect(digest.highlights).toHaveLength(2)
|
||||
expect(digest.highlights[0]).toEqual({ title: 'Első funkció', text: 'rövid magyarázattal.' })
|
||||
})
|
||||
|
||||
it('still stops at the next heading', () => {
|
||||
expect(getEngineDigest(SAMPLE_MARKDOWN_HU).highlights.map((h) => h.title)).not.toContain(
|
||||
'Nem kerül be',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref } from 'vue'
|
||||
import wikiApi from '../api/wiki.api'
|
||||
import { isNew } from '../lib/softwareUtils'
|
||||
import { useLoadable } from '../composables/useLoadable'
|
||||
import { useLocaleReload } from '../composables/useLocaleReload'
|
||||
import type { WikiPageWithContent, WikiPageContent } from '../lib/interfaces/wiki.interface'
|
||||
|
||||
export const useBlogStore = defineStore('blog', () => {
|
||||
@@ -18,7 +19,10 @@ export const useBlogStore = defineStore('blog', () => {
|
||||
})
|
||||
}
|
||||
|
||||
const currentSlug = ref<string | null>(null)
|
||||
|
||||
async function fetchPage(slug: string) {
|
||||
currentSlug.value = slug
|
||||
currentPage.value = null
|
||||
await withPageCache(async () => {
|
||||
const result = await wikiApi.getBlogPage(slug)
|
||||
@@ -37,5 +41,11 @@ export const useBlogStore = defineStore('blog', () => {
|
||||
return content.replace(/[#*`_[\]()]/g, '').trim().slice(0, 300) + '...'
|
||||
}
|
||||
|
||||
useLocaleReload(async () => {
|
||||
invalidate()
|
||||
await fetch()
|
||||
if (currentSlug.value) await fetchPage(currentSlug.value)
|
||||
})
|
||||
|
||||
return { pages, loading, error, currentPage, pageLoading, pageError, fetch, fetchPage, isNew, getPermalink, getCleanPreview, invalidate }
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import wikiApi from '../api/wiki.api'
|
||||
import { useLoadable } from '../composables/useLoadable'
|
||||
import { useLocaleReload } from '../composables/useLocaleReload'
|
||||
import type { WikiPageWithContent } from '../lib/interfaces/wiki.interface'
|
||||
|
||||
export interface EngineHighlight {
|
||||
@@ -15,7 +16,7 @@ export interface EngineDigest {
|
||||
highlights: EngineHighlight[]
|
||||
}
|
||||
|
||||
const HIGHLIGHTS_HEADING = 'what you get'
|
||||
const HIGHLIGHTS_HEADINGS = ['what you get', 'amit kapsz']
|
||||
const MAX_HIGHLIGHTS = 6
|
||||
|
||||
function stripInline(md: string): string {
|
||||
@@ -39,7 +40,7 @@ export function getEngineDigest(content: string): EngineDigest {
|
||||
if (heading) {
|
||||
if (inHighlights) break
|
||||
const title = stripInline(heading[1])
|
||||
if (title.toLowerCase() !== HIGHLIGHTS_HEADING) break
|
||||
if (!HIGHLIGHTS_HEADINGS.includes(title.toLowerCase())) break
|
||||
inHighlights = true
|
||||
digest.highlightsTitle = title
|
||||
continue
|
||||
@@ -81,5 +82,10 @@ export const useEnginesStore = defineStore('engines', () => {
|
||||
return content.replace(/[#*`_[\]()>|-]/g, '').replace(/\s+/g, ' ').trim().slice(0, 260) + '...'
|
||||
}
|
||||
|
||||
useLocaleReload(async () => {
|
||||
invalidate()
|
||||
await fetch()
|
||||
})
|
||||
|
||||
return { pages, loading, error, fetch, getCleanPreview, getEngineDigest, invalidate }
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref } from 'vue'
|
||||
import wikiApi from '../api/wiki.api'
|
||||
import { isNew } from '../lib/softwareUtils'
|
||||
import { useLoadable } from '../composables/useLoadable'
|
||||
import { useLocaleReload } from '../composables/useLocaleReload'
|
||||
import type { WikiPage } from '../lib/interfaces/wiki.interface'
|
||||
|
||||
export const useHowtosStore = defineStore('howtos', () => {
|
||||
@@ -15,5 +16,10 @@ export const useHowtosStore = defineStore('howtos', () => {
|
||||
})
|
||||
}
|
||||
|
||||
useLocaleReload(async () => {
|
||||
invalidate()
|
||||
await fetch()
|
||||
})
|
||||
|
||||
return { pages, loading, error, fetch, isNew, invalidate }
|
||||
})
|
||||
|
||||
+3
-2
@@ -7,6 +7,7 @@ services:
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--entrypoints.web.forwardedHeaders.trustedIPs=127.0.0.1/32,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||
ports:
|
||||
- "${TRAEFIK_WEB_PORT}:80"
|
||||
- "${TRAEFIK_API_PORT}:8080"
|
||||
@@ -43,7 +44,7 @@ services:
|
||||
- interstack
|
||||
|
||||
woodpecker-server:
|
||||
image: woodpeckerci/woodpecker-server:v3.17.0
|
||||
image: woodpeckerci/woodpecker-server:v3.18.0
|
||||
container_name: woodpecker-server
|
||||
environment:
|
||||
WOODPECKER_HOST: "https://${WOODPECKER_DOMAIN}"
|
||||
@@ -71,7 +72,7 @@ services:
|
||||
- interstack
|
||||
|
||||
woodpecker-agent:
|
||||
image: woodpeckerci/woodpecker-agent:v3.17.0
|
||||
image: woodpeckerci/woodpecker-agent:v3.18.0
|
||||
container_name: woodpecker-agent
|
||||
environment:
|
||||
WOODPECKER_SERVER: "woodpecker-server:9000"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
inherit_gem:
|
||||
rubocop-rails-omakase: rubocop.yml
|
||||
|
||||
AllCops:
|
||||
NewCops: enable
|
||||
TargetRubyVersion: 3.2
|
||||
Exclude:
|
||||
- "bin/**/*"
|
||||
- "db/**/*"
|
||||
- "config/**/*"
|
||||
- "examples/**/*"
|
||||
- "spec/dummy/**/*"
|
||||
- "vendor/**/*"
|
||||
@@ -10,4 +10,5 @@ group :development, :test do
|
||||
gem "shoulda-matchers", "~> 6.0"
|
||||
gem "webmock", "~> 3.0"
|
||||
gem "debug", platforms: %i[mri windows]
|
||||
gem "rubocop-rails-omakase", require: false
|
||||
end
|
||||
|
||||
@@ -12,6 +12,14 @@ Repository: `https://git.teletypegames.org/engines/warp_engine`
|
||||
- **Catalog domain**: `Software`, `Release`, `ReleaseAsset`, `ExternalLink`,
|
||||
`PlatformLink`, `SoftwareImage`, `Download` models with soft-delete
|
||||
semantics and download statistics.
|
||||
- **Platform registry**: `WarpEngine::Platform` is the one place a platform is
|
||||
named. `Platform.names` lists them, `Platform.find!("godot")` answers with a
|
||||
value object that knows its `label`, its `expected_kinds` and its updater
|
||||
`service`. Reference it instead of writing a platform list of your own —
|
||||
`PlatformLink::SUPPORTED_PLATFORMS` is kept as an alias of `Platform::NAMES`.
|
||||
- **Asset checklist**: `rake warp_engine:builds:check` prints, per software and
|
||||
release, which release assets exist and which the platform's pipeline could
|
||||
still deliver. See *Which assets are missing*.
|
||||
- **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
|
||||
@@ -203,6 +211,10 @@ Rails.application.config.to_prepare do
|
||||
# Where CI drops build artifacts
|
||||
c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
|
||||
|
||||
# Where the site this catalog belongs to lives. The admin links a software
|
||||
# to its public page from here; left nil, the link is left out.
|
||||
c.site_url = ENV["SITE_URL"]
|
||||
|
||||
# Your image model (see "Images" below). "Image" is the default.
|
||||
# c.image_class_name = "Media::Picture"
|
||||
# c.image_adapter = MyImageLibrary.new
|
||||
@@ -301,6 +313,69 @@ accepts both:
|
||||
When switching to `:database`, create the tokens and move your pipelines to
|
||||
them first — the flip invalidates the shared secret immediately.
|
||||
|
||||
## Which assets are missing (rake)
|
||||
|
||||
A release is complete when it carries every asset kind its platform can
|
||||
produce. Nothing enforces that — a pipeline step can fail, a platform can gain
|
||||
a target, a title can be published from a laptop — so the engine ships a
|
||||
checklist:
|
||||
|
||||
```
|
||||
$ bin/rails warp_engine:builds:check
|
||||
|
||||
WarpEngine release asset coverage — latest release per software
|
||||
expected: the kinds a platform's updater registers · [ ] the CI builds it · [-] the CI does not, upload it by hand
|
||||
|
||||
rabbitroller ebitengine v1.1.1 3/7
|
||||
[x] html
|
||||
[x] win_x64
|
||||
[x] linux_x64
|
||||
[ ] win_x86
|
||||
[ ] linux_arm64
|
||||
[-] mac_x64
|
||||
[-] mac_arm64
|
||||
|
||||
The CI can build these and the release does not have them:
|
||||
rabbitroller v1.1.1 (ebitengine): win_x86, linux_arm64
|
||||
|
||||
12 softwares, 12 releases, 61/78 assets present, 11 missing from CI-built kinds
|
||||
4 softwares are missing an asset the CI builds
|
||||
2 assets are expected but this CI does not build them
|
||||
```
|
||||
|
||||
Three markers, three different facts:
|
||||
|
||||
| Marker | Meaning |
|
||||
| --- | --- |
|
||||
| `[x]` | the release has this asset |
|
||||
| `[ ]` | the platform expects it **and** the CI pipeline builds it — a real gap |
|
||||
| `[-]` | the platform expects it but this CI cannot build it (ebitengine's macOS targets need osxcross) — a manual upload, or nothing |
|
||||
| `[+]` | the release has an asset the platform does not expect |
|
||||
|
||||
The expectations come from two places, and the difference is the point:
|
||||
`WarpEngine::Platform#expected_kinds` is what the **updater** can ingest, and
|
||||
the CI adapter's `built_kinds(platform)` is what the **pipeline** produces. An
|
||||
adapter that cannot answer (no CI configured, or a platform with no builder)
|
||||
returns nil, and then nothing is excused: every missing kind is reported as a
|
||||
gap.
|
||||
|
||||
Options are environment variables, so the task composes in a shell:
|
||||
|
||||
| Variable | Effect |
|
||||
| --- | --- |
|
||||
| `NAME=rabbitroller` | one software |
|
||||
| `PLATFORM=ebitengine` | one platform |
|
||||
| `RELEASES=all` | every release, not just the newest one per software |
|
||||
| `ONLY=missing` | only the releases that are short of a CI-built asset |
|
||||
| `STRICT=1` | exit non-zero when anything is missing (for CI) |
|
||||
|
||||
The newest release is the newest non-`dev-` version, the same rule the catalog
|
||||
API uses when it names a title's latest release.
|
||||
|
||||
The same numbers are available as objects: `WarpEngine::AssetCoverage.new(...)`
|
||||
answers `#rows`, `#missing_rows` and `#totals`, and
|
||||
`WarpEngine::AssetCoverageChecklist` is what renders them.
|
||||
|
||||
## CI
|
||||
|
||||
Every CI feature — serving pipeline configs, syncing repositories, triggering
|
||||
@@ -686,6 +761,16 @@ bundle exec rspec
|
||||
## Development
|
||||
|
||||
This repository is a **read-only split mirror** — development happens in the
|
||||
[`tools/teletypegames`](https://git.teletypegames.org/tools/teletypegames)
|
||||
[`services/teletypegames`](https://git.teletypegames.org/services/teletypegames)
|
||||
monorepo under `libs/ruby/warp_engine`, and CI republishes the mirror on every
|
||||
change. Please do not open pull requests against the mirror.
|
||||
|
||||
Two rules the monorepo's pipeline enforces before anything is mirrored or
|
||||
published:
|
||||
|
||||
- **Every commit that touches the engine raises `WarpEngine::VERSION`.** A
|
||||
pre-commit hook checks it locally, the pipeline's first step checks it again
|
||||
over the pushed commits, and a `warp_engine-v*` tag must name the version the
|
||||
tree actually holds.
|
||||
- **RuboCop is green** (`.rubocop.yml`, rubocop-rails-omakase), and the suite
|
||||
passes — in that order, before the gem is built.
|
||||
|
||||
@@ -96,6 +96,10 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
|
||||
end
|
||||
|
||||
controller do
|
||||
def scoped_collection
|
||||
super.kept
|
||||
end
|
||||
|
||||
def create
|
||||
create! do |success, _failure|
|
||||
success.html do
|
||||
|
||||
@@ -100,7 +100,7 @@ ActiveAdmin.register WarpEngine::Download, as: "Download" do
|
||||
|
||||
controller do
|
||||
def scoped_collection
|
||||
super.includes(release: :software)
|
||||
super.kept.includes(release: :software)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,12 @@ ActiveAdmin.register WarpEngine::ExternalLink, as: "External Link" do
|
||||
|
||||
belongs_to :software
|
||||
|
||||
controller do
|
||||
def scoped_collection
|
||||
super.kept
|
||||
end
|
||||
end
|
||||
|
||||
index do
|
||||
selectable_column
|
||||
id_column
|
||||
|
||||
@@ -63,26 +63,6 @@ ActiveAdmin.register_page "Files" do
|
||||
{}
|
||||
end
|
||||
|
||||
file_icon = ->(name) do
|
||||
ext = File.extname(name).downcase
|
||||
case ext
|
||||
when ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg" then "🖼️"
|
||||
when ".mp3", ".ogg", ".wav", ".flac", ".aac" then "🔊"
|
||||
when ".mp4", ".avi", ".mkv", ".webm", ".mov" then "🎬"
|
||||
when ".zip", ".gz", ".tar", ".rar", ".7z", ".bz2" then "📦"
|
||||
when ".pdf" then "📕"
|
||||
when ".doc", ".docx", ".odt", ".txt", ".md", ".rtf" then "📄"
|
||||
when ".xls", ".xlsx", ".csv", ".ods" then "📊"
|
||||
when ".html", ".htm", ".css", ".js", ".ts", ".json", ".xml" then "📝"
|
||||
when ".rb", ".py", ".lua", ".c", ".cpp", ".h", ".rs", ".go" then "💻"
|
||||
when ".tic", ".rom", ".bin", ".prg", ".crt", ".d64", ".t64" then "🎮"
|
||||
when ".love" then "🎮"
|
||||
when ".exe", ".dmg", ".appimage", ".msi" then "⚙️"
|
||||
when ".wasm" then "⚙️"
|
||||
else "📄"
|
||||
end
|
||||
end
|
||||
|
||||
thead do
|
||||
tr do
|
||||
th "Name"
|
||||
@@ -101,7 +81,7 @@ ActiveAdmin.register_page "Files" do
|
||||
|
||||
tr do
|
||||
td do
|
||||
span "📁 ", style: "font-size:15px;"
|
||||
span "#{WarpEngine::FileIcon::DIRECTORY} ", style: "font-size:15px;"
|
||||
a "..", href: admin_files_path(picker_params.merge(dir: parent))
|
||||
end
|
||||
td ""
|
||||
@@ -116,10 +96,10 @@ ActiveAdmin.register_page "Files" do
|
||||
td do
|
||||
picker_params = picker_mode ? { picker: 1, field: picker_field } : {}
|
||||
if entry[:type] == :directory
|
||||
span "📁 ", style: "font-size:15px;"
|
||||
span "#{WarpEngine::FileIcon::DIRECTORY} ", style: "font-size:15px;"
|
||||
a entry[:name], href: admin_files_path(picker_params.merge(dir: entry[:path]))
|
||||
else
|
||||
span "#{file_icon.call(entry[:name])} ", style: "font-size:15px;"
|
||||
span "#{WarpEngine::FileIcon.for(entry[:name])} ", style: "font-size:15px;"
|
||||
span entry[:name]
|
||||
end
|
||||
end
|
||||
@@ -143,10 +123,10 @@ ActiveAdmin.register_page "Files" do
|
||||
end
|
||||
|
||||
a "✏️", href: "#", class: "fm-icon-btn", title: "Rename",
|
||||
onclick: "var n=prompt('New name:','#{j entry[:name]}');if(n){var f=document.getElementById('rename-#{entry_id}');f.querySelector('[name=new_name]').value=n;f.submit();}return false;"
|
||||
onclick: "var n=prompt('New name:','#{escape_javascript(entry[:name])}');if(n){var f=document.getElementById('rename-#{entry_id}');f.querySelector('[name=new_name]').value=n;f.submit();}return false;"
|
||||
|
||||
a "🗑️", href: "#", class: "fm-icon-btn fm-icon-danger", title: "Delete",
|
||||
onclick: "if(confirm('Delete \\'#{j entry[:name]}\\'?')){document.getElementById('delete-#{entry_id}').submit();}return false;"
|
||||
onclick: "if(confirm('Delete \\'#{escape_javascript(entry[:name])}\\'?')){document.getElementById('delete-#{entry_id}').submit();}return false;"
|
||||
|
||||
if entry[:type] != :directory
|
||||
a "📊", href: admin_downloads_path(q: { file_path_cont: entry[:path] }), class: "fm-icon-btn", title: "Stats"
|
||||
@@ -155,7 +135,7 @@ ActiveAdmin.register_page "Files" do
|
||||
if picker_mode
|
||||
abs_path = File.join(WarpEngine.config.file_container_path, entry[:path])
|
||||
a "Select", href: "#", class: "fm-btn fm-btn-select",
|
||||
onclick: "var inp=window.parent.document.getElementById('#{j picker_field}');if(inp){inp.value='#{j abs_path}';}var ov=window.parent.document.querySelector('.fm-modal-overlay');if(ov){ov.remove();window.parent.document.body.style.overflow='';}return false;"
|
||||
onclick: "var inp=window.parent.document.getElementById('#{escape_javascript(picker_field)}');if(inp){inp.value='#{escape_javascript(abs_path)}';}var ov=window.parent.document.querySelector('.fm-modal-overlay');if(ov){ov.remove();window.parent.document.body.style.overflow='';}return false;"
|
||||
end
|
||||
|
||||
form action: admin_files_rename_path, method: "post", id: "rename-#{entry_id}", style: "display:none" do
|
||||
@@ -203,38 +183,44 @@ ActiveAdmin.register_page "Files" do
|
||||
end
|
||||
|
||||
page_action :upload, method: :post do
|
||||
service = WarpEngine::FileManagerService.new
|
||||
service.upload(params[:dir].to_s, params[:file])
|
||||
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), notice: "File uploaded."
|
||||
WarpEngine::FileManagerService.new.upload(params[:dir].to_s, params[:file])
|
||||
redirect_to_files notice: "File uploaded."
|
||||
rescue => e
|
||||
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), alert: "Upload failed: #{e.message}"
|
||||
redirect_to_files alert: "Upload failed: #{e.message}"
|
||||
end
|
||||
|
||||
page_action :mkdir, method: :post do
|
||||
service = WarpEngine::FileManagerService.new
|
||||
service.mkdir(params[:dir].to_s, params[:name].to_s)
|
||||
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), notice: "Folder created."
|
||||
WarpEngine::FileManagerService.new.mkdir(params[:dir].to_s, params[:name].to_s)
|
||||
redirect_to_files notice: "Folder created."
|
||||
rescue => e
|
||||
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), alert: "Failed: #{e.message}"
|
||||
redirect_to_files alert: "Failed: #{e.message}"
|
||||
end
|
||||
|
||||
page_action :rename, method: :post do
|
||||
service = WarpEngine::FileManagerService.new
|
||||
service.rename(params[:path].to_s, params[:new_name].to_s)
|
||||
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), notice: "Renamed."
|
||||
WarpEngine::FileManagerService.new.rename(params[:path].to_s, params[:new_name].to_s)
|
||||
redirect_to_files notice: "Renamed."
|
||||
rescue => e
|
||||
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), alert: "Rename failed: #{e.message}"
|
||||
redirect_to_files alert: "Rename failed: #{e.message}"
|
||||
end
|
||||
|
||||
page_action :delete, method: :delete do
|
||||
service = WarpEngine::FileManagerService.new
|
||||
dir = params[:dir].to_s.presence || begin
|
||||
d = File.dirname(params[:path].to_s)
|
||||
d == "." ? "" : d
|
||||
end
|
||||
service.delete(params[:path].to_s)
|
||||
redirect_to admin_files_path(dir: dir, picker: params[:picker], field: params[:field]), notice: "Deleted."
|
||||
WarpEngine::FileManagerService.new.delete(params[:path].to_s)
|
||||
redirect_to_files dir: params[:dir].presence || parent_dir_of(params[:path]), notice: "Deleted."
|
||||
rescue => e
|
||||
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), alert: "Delete failed: #{e.message}"
|
||||
redirect_to_files alert: "Delete failed: #{e.message}"
|
||||
end
|
||||
|
||||
controller do
|
||||
private
|
||||
|
||||
def redirect_to_files(dir: nil, notice: nil, alert: nil)
|
||||
target = admin_files_path(dir: dir || params[:dir], picker: params[:picker], field: params[:field])
|
||||
redirect_to target, notice: notice, alert: alert
|
||||
end
|
||||
|
||||
def parent_dir_of(path)
|
||||
parent = File.dirname(path.to_s)
|
||||
parent == "." ? "" : parent
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -47,7 +47,7 @@ ActiveAdmin.register WarpEngine::Pipeline, as: "Pipeline" do
|
||||
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 ] },
|
||||
collection: WarpEngine::Software.kept.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, " \
|
||||
@@ -124,7 +124,7 @@ ActiveAdmin.register WarpEngine::Pipeline, as: "Pipeline" do
|
||||
|
||||
controller do
|
||||
def scoped_collection
|
||||
super.includes(:software)
|
||||
super.kept.includes(:software)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,6 +14,12 @@ ActiveAdmin.register WarpEngine::PlatformLink, as: "Platform Link" do
|
||||
scope("Bevy") { |s| s.where(platform: "bevy") }
|
||||
scope("Phaser") { |s| s.where(platform: "phaser") }
|
||||
|
||||
controller do
|
||||
def scoped_collection
|
||||
super.kept
|
||||
end
|
||||
end
|
||||
|
||||
index do
|
||||
selectable_column
|
||||
id_column
|
||||
@@ -25,12 +31,12 @@ ActiveAdmin.register WarpEngine::PlatformLink, as: "Platform Link" do
|
||||
actions
|
||||
end
|
||||
|
||||
filter :platform, as: :select, collection: %w[tic80 ebitengine love c64 godot bevy phaser]
|
||||
filter :platform, as: :select, collection: WarpEngine::Platform.names
|
||||
filter :name
|
||||
|
||||
form do |f|
|
||||
f.inputs do
|
||||
f.input :platform, as: :select, collection: %w[tic80 ebitengine love c64 godot bevy phaser]
|
||||
f.input :platform, as: :select, collection: WarpEngine::Platform.names
|
||||
f.input :name
|
||||
f.input :url
|
||||
f.input :position, as: :number, input_html: { min: 0 }
|
||||
|
||||
@@ -17,6 +17,12 @@ ActiveAdmin.register WarpEngine::Release, as: "Release" do
|
||||
|
||||
filter :version
|
||||
|
||||
controller do
|
||||
def scoped_collection
|
||||
super.kept
|
||||
end
|
||||
end
|
||||
|
||||
show do
|
||||
attributes_table do
|
||||
row :id
|
||||
|
||||
@@ -38,18 +38,19 @@ ActiveAdmin.register WarpEngine::Software, as: "Software" do
|
||||
filter :name
|
||||
filter :title
|
||||
filter :author
|
||||
filter :platform, as: :select, collection: %w[tic80 ebitengine love c64 godot bevy phaser]
|
||||
filter :status, as: :select, collection: %w[development demo released archived]
|
||||
filter :platform, as: :select, collection: WarpEngine::Platform.names
|
||||
filter :status, as: :select, collection: WarpEngine::Software::STATUSES
|
||||
filter :highlighted
|
||||
|
||||
sidebar "Quick Links", only: :show, priority: 0 do
|
||||
site_url = ENV.fetch("SITE_URL", "https://teletypegames.org")
|
||||
catalog_url = "#{site_url}/catalog/#{resource.name}"
|
||||
if WarpEngine.config.site_url.present?
|
||||
catalog_url = "#{WarpEngine.config.site_url.chomp('/')}/catalog/#{resource.name}"
|
||||
|
||||
div style: "margin-bottom:8px;" do
|
||||
a href: catalog_url, target: "_blank", style: "display:inline-flex;align-items:center;gap:6px;font-weight:bold;color:#5850ec;" do
|
||||
span "🌐", style: "font-size:16px;"
|
||||
text_node "View on site"
|
||||
div style: "margin-bottom:8px;" do
|
||||
a href: catalog_url, target: "_blank", style: "display:inline-flex;align-items:center;gap:6px;font-weight:bold;color:#5850ec;" do
|
||||
span "🌐", style: "font-size:16px;"
|
||||
text_node "View on site"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -103,8 +104,8 @@ ActiveAdmin.register WarpEngine::Software, as: "Software" do
|
||||
f.input :name
|
||||
f.input :title
|
||||
f.input :author
|
||||
f.input :platform, as: :select, collection: %w[tic80 ebitengine love c64 godot bevy phaser]
|
||||
f.input :status, as: :select, collection: %w[development demo released archived]
|
||||
f.input :platform, as: :select, collection: WarpEngine::Platform.names
|
||||
f.input :status, as: :select, collection: WarpEngine::Software::STATUSES
|
||||
f.input :highlighted
|
||||
f.input :license
|
||||
f.input :desc, as: :text, input_html: { rows: 4 }
|
||||
@@ -149,7 +150,7 @@ ActiveAdmin.register WarpEngine::Software, as: "Software" do
|
||||
|
||||
controller do
|
||||
def scoped_collection
|
||||
super.includes({ releases: :release_assets }, software_images: :image)
|
||||
super.kept.includes({ releases: :release_assets }, software_images: :image)
|
||||
end
|
||||
|
||||
def find_resource
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
module WarpEngine
|
||||
module ApiErrorRendering
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
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
|
||||
end
|
||||
|
||||
rescue_from ActiveRecord::RecordNotFound do
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
end
|
||||
|
||||
rescue_from Errno::ENOENT do
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
end
|
||||
|
||||
rescue_from ArgumentError do |e|
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_mime(path) = WarpEngine::Storage.mime_for(path)
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,4 @@
|
||||
module WarpEngine
|
||||
|
||||
module SubjectAuthentication
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
module WarpEngine
|
||||
|
||||
module UpdateAuthentication
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
@@ -43,7 +42,7 @@ module WarpEngine
|
||||
token = current_application_token
|
||||
return true if token.nil? || token.unrestricted?
|
||||
|
||||
software = WarpEngine::Software.find_by(name: name)
|
||||
software = WarpEngine::Software.kept.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
|
||||
@@ -53,7 +52,7 @@ module WarpEngine
|
||||
token = current_application_token
|
||||
return if token.nil? || token.unrestricted?
|
||||
|
||||
software = WarpEngine::Software.find_by(name: name)
|
||||
software = WarpEngine::Software.kept.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)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
module WarpEngine
|
||||
|
||||
class Api::Auth::DevicesController < ApiController
|
||||
before_action :ensure_identity_configured
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
module WarpEngine
|
||||
|
||||
class Api::Auth::TokensController < ApiController
|
||||
resource_description do
|
||||
short "Client tokens"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
module WarpEngine
|
||||
module Api
|
||||
module Ci
|
||||
class BaseController < WarpEngine::ApiController
|
||||
before_action :require_ci!
|
||||
|
||||
private
|
||||
|
||||
def require_ci!
|
||||
return if WarpEngine.ci.configured?
|
||||
|
||||
render json: { error: "CI not configured" }, status: :service_unavailable
|
||||
end
|
||||
|
||||
def pipeline_scope = WarpEngine::Pipeline.all
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
module WarpEngine
|
||||
module Api
|
||||
module Ci
|
||||
class PipelineRunsController < BaseController
|
||||
include WarpEngine::UpdateAuthentication
|
||||
|
||||
resource_description do
|
||||
short "CI pipeline runs"
|
||||
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: "No CI provider configured"
|
||||
def create
|
||||
unless update_authorized?(required_scope: WarpEngine::ApplicationToken::UPDATE_SCOPE)
|
||||
return render json: { error: "Unauthorized" }, status: :unauthorized
|
||||
end
|
||||
|
||||
pipeline = pipeline_scope.find(params[:id])
|
||||
result = WarpEngine::PipelineService.new.trigger(pipeline, branch: params[:branch].presence || "main")
|
||||
|
||||
render json: { triggered: true, pipeline: result }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
module WarpEngine
|
||||
module Api
|
||||
module Ci
|
||||
class PipelinesController < BaseController
|
||||
resource_description do
|
||||
short "CI pipelines"
|
||||
end
|
||||
|
||||
api :GET, "/api/ci/pipelines", "List active pipelines"
|
||||
returns code: 200, desc: "JSON array of tracked pipelines"
|
||||
error code: 503, desc: "No CI provider configured"
|
||||
def index
|
||||
pipelines = pipeline_scope.active.includes(:software)
|
||||
render json: WarpEngine::PipelineSerializer.render_as_hash(pipelines)
|
||||
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: "No CI provider configured"
|
||||
def show
|
||||
pipeline = pipeline_scope.find(params[:id])
|
||||
|
||||
render json: {
|
||||
pipeline: WarpEngine::PipelineSerializer.render_as_hash(pipeline),
|
||||
latest_run: latest_run(pipeline)
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def latest_run(pipeline)
|
||||
WarpEngine::PipelineService.new.run(pipeline, "latest")
|
||||
rescue WarpEngine::CI::Error
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,75 +0,0 @@
|
||||
module WarpEngine
|
||||
module Api
|
||||
class CiController < ApiController
|
||||
include UpdateAuthentication
|
||||
|
||||
before_action :require_ci!
|
||||
|
||||
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: "No CI provider 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: "No CI provider configured"
|
||||
def status
|
||||
pipeline = Pipeline.find(params[:id])
|
||||
latest_run = begin
|
||||
PipelineService.new.run(pipeline, "latest")
|
||||
rescue CI::Error
|
||||
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: "No CI provider 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_ci!
|
||||
return if WarpEngine.ci.configured?
|
||||
|
||||
render json: { error: "CI not configured" }, status: :service_unavailable
|
||||
end
|
||||
|
||||
def pipeline_json(pipeline)
|
||||
{
|
||||
id: pipeline.id,
|
||||
repo_id: pipeline.remote_repo_id,
|
||||
woodpecker_repo_id: pipeline.remote_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
|
||||
@@ -1,5 +1,4 @@
|
||||
module WarpEngine
|
||||
|
||||
class Api::ServiceController < ApiController
|
||||
resource_description do
|
||||
short "Service descriptor"
|
||||
|
||||
@@ -39,8 +39,8 @@ module WarpEngine
|
||||
property :desc, String, desc: "Short description"
|
||||
property :story, String, desc: "Long description / story"
|
||||
property :license, String, desc: "License type"
|
||||
property :platform, String, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)"
|
||||
property :status, String, desc: "Status (active, inactive)"
|
||||
property :platform, String, desc: "Platform (one of WarpEngine::Platform::NAMES)"
|
||||
property :status, String, desc: "Status (development, demo, released, archived)"
|
||||
property :highlighted, :boolean, desc: "Currently highlighted"
|
||||
property :imageUrl, String, desc: "Default image URL"
|
||||
property :externalLinks, Array, desc: "External links" do
|
||||
|
||||
+3
-3
@@ -14,8 +14,8 @@ module WarpEngine
|
||||
property :desc, String, desc: "Short description"
|
||||
property :story, String, desc: "Long description / story"
|
||||
property :license, String, desc: "License type"
|
||||
property :platform, String, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)"
|
||||
property :status, String, desc: "Status"
|
||||
property :platform, String, desc: "Platform (one of WarpEngine::Platform::NAMES)"
|
||||
property :status, String, desc: "Status (development, demo, released, archived)"
|
||||
property :highlighted, :boolean, desc: "Highlighted flag"
|
||||
property :imageUrl, String, desc: "Default image URL"
|
||||
end
|
||||
@@ -33,7 +33,7 @@ module WarpEngine
|
||||
property :totalDownloads, Integer, desc: "Total download count across all releases"
|
||||
end
|
||||
error code: 404, desc: "No highlighted software found"
|
||||
def index
|
||||
def show
|
||||
result = WarpEngine::SoftwareHighlightedService.new.index(subject: current_subject)
|
||||
if result
|
||||
render json: result
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
require "warp_engine/access_denied"
|
||||
|
||||
module WarpEngine
|
||||
class ApiController < ActionController::API
|
||||
resource_description do
|
||||
@@ -7,39 +9,17 @@ module WarpEngine
|
||||
|
||||
before_action :set_version_header
|
||||
|
||||
include WarpEngine::ApiErrorRendering
|
||||
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
|
||||
end
|
||||
|
||||
rescue_from ActiveRecord::RecordNotFound do |e|
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
end
|
||||
|
||||
rescue_from Errno::ENOENT do |e|
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
end
|
||||
|
||||
rescue_from ArgumentError do |e|
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
end
|
||||
|
||||
rescue_from WarpEngine::DownloadService::Denied do
|
||||
rescue_from WarpEngine::AccessDenied do
|
||||
render json: { error: "Forbidden" }, status: :forbidden
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_version_header
|
||||
|
||||
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"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
module WarpEngine
|
||||
module Build
|
||||
|
||||
class ConfigsController < ApiController
|
||||
resource_description do
|
||||
short "CI pipeline configs"
|
||||
|
||||
@@ -10,7 +10,7 @@ module WarpEngine
|
||||
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 :platform, String, required: true, desc: "Platform (one of WarpEngine::Platform::NAMES)"
|
||||
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"
|
||||
@@ -21,24 +21,21 @@ module WarpEngine
|
||||
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]
|
||||
)
|
||||
return render json: { error: input.error_message }, status: :bad_request unless input.valid?
|
||||
|
||||
unless software_ownership_authorized?(input.name)
|
||||
return render json: { error: "Forbidden" }, status: :forbidden
|
||||
end
|
||||
|
||||
WarpEngine::PublishService.new.publish(input)
|
||||
claim_software_ownership(params[:name])
|
||||
claim_software_ownership(input.name)
|
||||
|
||||
render json: { published: true, name: params[:name], platform: params[:platform], version: params[:version] }
|
||||
render json: { published: true, name: input.name, platform: input.platform, version: input.version }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,8 +9,6 @@ module WarpEngine
|
||||
short "Build artifact upload"
|
||||
end
|
||||
|
||||
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>)"
|
||||
@@ -28,35 +26,30 @@ module WarpEngine
|
||||
return render json: { error: "Unauthorized" }, status: :unauthorized
|
||||
end
|
||||
|
||||
name = params[:name].to_s
|
||||
version = params[:version].to_s
|
||||
file = params[:file]
|
||||
input = WarpEngine::UploadInputDto.new(
|
||||
name: params[:name].to_s,
|
||||
version: params[:version].to_s,
|
||||
file: params[:file],
|
||||
sha256: params[:sha256]
|
||||
)
|
||||
return render json: { error: input.error_message }, status: :bad_request unless input.valid?
|
||||
|
||||
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)
|
||||
unless software_ownership_authorized?(input.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
|
||||
if input.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)
|
||||
digest = Digest::SHA256.file(input.file.tempfile.path).hexdigest
|
||||
if input.sha256.present? && !ActiveSupport::SecurityUtils.secure_compare(input.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 }
|
||||
stored = WarpEngine::FileManagerService.new.upload("", input.file)
|
||||
render json: { file: stored, size: input.file.size, sha256: digest }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
module WarpEngine
|
||||
PublishInputDto = Struct.new(:platform, :name, :version, keyword_init: true) do
|
||||
def initialize(platform:, name:, version: nil)
|
||||
super
|
||||
end
|
||||
class PublishInputDto
|
||||
include ActiveModel::Model
|
||||
|
||||
attr_accessor :platform, :name, :version
|
||||
|
||||
validates :name, presence: true
|
||||
validates :version, presence: true
|
||||
validates :platform, presence: true
|
||||
validates :platform, inclusion: { in: WarpEngine::Platform::NAMES }, allow_blank: true
|
||||
|
||||
def error_message = errors.full_messages.to_sentence
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
module WarpEngine
|
||||
class UploadInputDto
|
||||
include ActiveModel::Model
|
||||
|
||||
NAME_FORMAT = /\A[A-Za-z0-9._-]+\z/
|
||||
|
||||
attr_accessor :name, :version, :file, :sha256
|
||||
|
||||
validates :name, format: { with: NAME_FORMAT }
|
||||
validates :version, format: { with: NAME_FORMAT }
|
||||
validate :file_present
|
||||
validate :filename_prefixed
|
||||
|
||||
def filename
|
||||
return nil unless file.respond_to?(:original_filename)
|
||||
|
||||
File.basename(file.original_filename.to_s)
|
||||
end
|
||||
|
||||
def error_message = errors.full_messages.to_sentence
|
||||
|
||||
private
|
||||
|
||||
def file_present
|
||||
return if file.respond_to?(:original_filename)
|
||||
|
||||
errors.add(:file, "not provided")
|
||||
end
|
||||
|
||||
def filename_prefixed
|
||||
return if filename.nil? || errors.include?(:name) || errors.include?(:version)
|
||||
return if filename.start_with?("#{name}-#{version}.", "#{name}-#{version}-")
|
||||
|
||||
errors.add(:file, "name must be prefixed with #{name}-#{version}")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
module WarpEngine
|
||||
module SoftDeletable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
scope :kept, -> { where(deleted_at: nil) }
|
||||
scope :discarded, -> { where.not(deleted_at: nil) }
|
||||
end
|
||||
|
||||
def soft_delete!
|
||||
update_column(:deleted_at, Time.current)
|
||||
end
|
||||
|
||||
def restore!
|
||||
update_column(:deleted_at, nil)
|
||||
end
|
||||
|
||||
def discarded?
|
||||
deleted_at.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,13 @@
|
||||
module WarpEngine
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
self.abstract_class = true
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
column_names
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
reflect_on_all_associations.map(&:name).map(&:to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,12 +9,13 @@ module WarpEngine
|
||||
|
||||
CATALOG_SCOPE = "catalog".freeze
|
||||
|
||||
include SoftDeletable
|
||||
|
||||
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) }
|
||||
scope :active, -> { kept.where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||
|
||||
before_validation :assign_owner_type, on: :create
|
||||
before_validation :generate_token, on: :create
|
||||
@@ -45,7 +46,7 @@ module WarpEngine
|
||||
end
|
||||
|
||||
def revoke!
|
||||
update_column(:deleted_at, Time.current)
|
||||
soft_delete!
|
||||
end
|
||||
|
||||
def touch_last_used!
|
||||
@@ -60,10 +61,6 @@ module WarpEngine
|
||||
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
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
[]
|
||||
end
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
module WarpEngine
|
||||
|
||||
class DeviceGrant < ApplicationRecord
|
||||
self.table_name = "device_grants"
|
||||
|
||||
@@ -46,10 +45,6 @@ module WarpEngine
|
||||
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
|
||||
@@ -62,7 +57,6 @@ module WarpEngine
|
||||
end
|
||||
|
||||
def self.generate_user_code
|
||||
|
||||
10.times do
|
||||
candidate = Array.new(USER_CODE_LENGTH) { USER_CODE_ALPHABET.chars.sample }.join
|
||||
return candidate unless exists?(user_code: candidate)
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
module WarpEngine
|
||||
class Download < ApplicationRecord
|
||||
include SoftDeletable
|
||||
|
||||
belongs_to :release, optional: true
|
||||
|
||||
validates :file_path, presence: true
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[created_at deleted_at file_path id ip_address referer release_id updated_at user_agent]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
%w[release]
|
||||
end
|
||||
|
||||
ActiveSupport.run_load_hooks(:warp_engine_download, self)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,21 +2,13 @@ module WarpEngine
|
||||
class ExternalLink < ApplicationRecord
|
||||
self.table_name = "external_links"
|
||||
|
||||
include SoftDeletable
|
||||
|
||||
belongs_to :software
|
||||
|
||||
validates :label, presence: true
|
||||
validates :url, presence: true
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[created_at deleted_at id label software_id updated_at url]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
%w[software]
|
||||
end
|
||||
|
||||
ActiveSupport.run_load_hooks(:warp_engine_external_link, self)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,12 +4,12 @@ module WarpEngine
|
||||
|
||||
UNKNOWN_PLATFORM = "unknown".freeze
|
||||
|
||||
include SoftDeletable
|
||||
|
||||
belongs_to :software, class_name: "WarpEngine::Software", optional: true
|
||||
|
||||
attr_reader :software_taken_from
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
before_save :claim_software_from_other_pipelines, if: :will_save_change_to_software_id?
|
||||
|
||||
validates :woodpecker_repo_id, presence: true, uniqueness: true
|
||||
@@ -18,7 +18,9 @@ module WarpEngine
|
||||
validates :platform, presence: true,
|
||||
inclusion: { in: WarpEngine::PlatformLink::SUPPORTED_PLATFORMS + [ UNKNOWN_PLATFORM ] }
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :active, -> { kept.where(active: true) }
|
||||
|
||||
scope :by_remote_repo_id, ->(id) { where(woodpecker_repo_id: id) }
|
||||
|
||||
def full_name
|
||||
"#{repo_owner}/#{repo_name}"
|
||||
@@ -32,13 +34,8 @@ module WarpEngine
|
||||
self.woodpecker_repo_id = value
|
||||
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]
|
||||
def self.find_or_initialize_by_remote_repo_id(id)
|
||||
find_or_initialize_by(woodpecker_repo_id: id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -2,24 +2,18 @@ module WarpEngine
|
||||
class PlatformLink < ApplicationRecord
|
||||
self.table_name = "platform_links"
|
||||
|
||||
SUPPORTED_PLATFORMS = %w[tic80 ebitengine love c64 godot bevy phaser].freeze
|
||||
SUPPORTED_PLATFORMS = WarpEngine::Platform::NAMES
|
||||
|
||||
include SoftDeletable
|
||||
|
||||
validates :name, presence: true
|
||||
validates :url, presence: true
|
||||
validates :platform, presence: true, inclusion: { in: SUPPORTED_PLATFORMS }
|
||||
|
||||
default_scope { where(deleted_at: nil).order(:position) }
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[created_at deleted_at id name platform position updated_at url]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
[]
|
||||
end
|
||||
scope :ordered, -> { order(:position) }
|
||||
|
||||
def self.for_platform(platform)
|
||||
where(platform: platform).to_a
|
||||
kept.ordered.where(platform: platform).to_a
|
||||
end
|
||||
|
||||
ActiveSupport.run_load_hooks(:warp_engine_platform_link, self)
|
||||
|
||||
@@ -2,19 +2,25 @@ module WarpEngine
|
||||
class Release < ApplicationRecord
|
||||
self.table_name = "releases"
|
||||
|
||||
include SoftDeletable
|
||||
|
||||
belongs_to :software
|
||||
has_many :downloads
|
||||
has_many :release_assets
|
||||
has_many :downloads, -> { kept }
|
||||
has_many :release_assets, -> { kept }
|
||||
|
||||
accepts_nested_attributes_for :release_assets, allow_destroy: true
|
||||
|
||||
validates :version, presence: true
|
||||
validates :version, uniqueness: { scope: :software_id }
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
validate :c64_cannot_be_web_playable
|
||||
|
||||
def self.latest_non_dev(releases)
|
||||
sorted = releases.sort_by { |r| [ r.created_at || Time.at(0), r.id ] }.reverse
|
||||
candidates = sorted.reject { |r| r.version.to_s.start_with?("dev-") }
|
||||
candidates.empty? ? sorted.first : candidates.first
|
||||
end
|
||||
|
||||
def c64_cannot_be_web_playable
|
||||
return unless software&.platform == "c64"
|
||||
|
||||
@@ -24,14 +30,6 @@ module WarpEngine
|
||||
end
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[created_at deleted_at id software_id updated_at version]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
%w[downloads release_assets software]
|
||||
end
|
||||
|
||||
ActiveSupport.run_load_hooks(:warp_engine_release, self)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,6 +4,8 @@ module WarpEngine
|
||||
win_x86 win_x64 linux_x86 linux_x64 linux_arm64
|
||||
mac_x64 mac_arm64 mac_universal].freeze
|
||||
|
||||
include SoftDeletable
|
||||
|
||||
belongs_to :release
|
||||
|
||||
enum :kind, KINDS.index_by(&:itself)
|
||||
@@ -11,14 +13,9 @@ module WarpEngine
|
||||
validates :path, presence: true
|
||||
validates :kind, uniqueness: { scope: :release_id }
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[created_at deleted_at id kind path release_id updated_at]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
%w[release]
|
||||
def self.for_relative_path(relative)
|
||||
absolute = File.join(WarpEngine.config.file_container_path, relative.to_s)
|
||||
find_by(path: absolute) || where("path LIKE ?", "%/#{sanitize_sql_like(relative.to_s)}").first
|
||||
end
|
||||
|
||||
ActiveSupport.run_load_hooks(:warp_engine_release_asset, self)
|
||||
|
||||
@@ -2,14 +2,18 @@ module WarpEngine
|
||||
class Software < ApplicationRecord
|
||||
self.table_name = "softwares"
|
||||
|
||||
STATUSES = %w[development demo released archived].freeze
|
||||
|
||||
include SoftDeletable
|
||||
|
||||
belongs_to :owner, polymorphic: true, optional: true
|
||||
|
||||
has_many :software_images, foreign_key: :software_id, dependent: :destroy
|
||||
has_many :software_images, -> { ordered }, foreign_key: :software_id, dependent: :destroy
|
||||
has_many :images, through: :software_images
|
||||
has_many :releases, foreign_key: :software_id
|
||||
has_many :releases, -> { kept }, foreign_key: :software_id
|
||||
has_many :downloads, through: :releases
|
||||
has_many :external_links, foreign_key: :software_id
|
||||
has_one :pipeline, foreign_key: :software_id
|
||||
has_many :external_links, -> { kept }, foreign_key: :software_id
|
||||
has_one :pipeline, -> { kept }, foreign_key: :software_id
|
||||
|
||||
accepts_nested_attributes_for :software_images, allow_destroy: true
|
||||
accepts_nested_attributes_for :external_links, allow_destroy: true
|
||||
@@ -17,17 +21,8 @@ module WarpEngine
|
||||
|
||||
validates :name, presence: true, uniqueness: true
|
||||
validates :title, presence: true
|
||||
validates :platform, presence: true
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%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)
|
||||
%w[releases external_links software_images images]
|
||||
end
|
||||
validates :platform, presence: true, inclusion: { in: WarpEngine::Platform::NAMES }
|
||||
validates :status, inclusion: { in: STATUSES }, allow_blank: true
|
||||
|
||||
ActiveSupport.run_load_hooks(:warp_engine_software, self)
|
||||
end
|
||||
|
||||
@@ -13,15 +13,7 @@ module WarpEngine
|
||||
|
||||
before_save :unset_other_defaults, if: -> { is_default? && is_default_changed? }
|
||||
|
||||
default_scope { order(:position) }
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[created_at id image_id is_default position software_id updated_at]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
%w[image software]
|
||||
end
|
||||
scope :ordered, -> { order(:position) }
|
||||
|
||||
ActiveSupport.run_load_hooks(:warp_engine_software_image, self)
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
module WarpEngine
|
||||
class AssetCoverageChecklist
|
||||
MARKERS = {
|
||||
present: "[x]",
|
||||
missing: "[ ]",
|
||||
manual: "[-]",
|
||||
unexpected: "[+]"
|
||||
}.freeze
|
||||
|
||||
def initialize(coverage, only_missing: false)
|
||||
@coverage = coverage
|
||||
@only_missing = only_missing
|
||||
end
|
||||
|
||||
attr_reader :coverage, :only_missing
|
||||
|
||||
def lines
|
||||
[ *header, *body, *footer ]
|
||||
end
|
||||
|
||||
def to_s = lines.join("\n")
|
||||
|
||||
private
|
||||
|
||||
def header
|
||||
[
|
||||
"WarpEngine release asset coverage — #{coverage.releases == :all ? 'every release' : 'latest release per software'}",
|
||||
"expected: the kinds a platform's updater registers · #{MARKERS[:missing]} the CI builds it · " \
|
||||
"#{MARKERS[:manual]} the CI does not, upload it by hand",
|
||||
""
|
||||
]
|
||||
end
|
||||
|
||||
def body
|
||||
shown = only_missing ? coverage.missing_rows : coverage.rows
|
||||
return [ "Nothing to report.", "" ] if shown.empty?
|
||||
|
||||
shown.flat_map { |row| [ title(row), *checklist(row), "" ] }
|
||||
end
|
||||
|
||||
def title(row)
|
||||
parts = [ row.software.name, row.platform&.name || "unknown platform" ]
|
||||
parts << (row.released? ? "v#{row.version}" : "no release")
|
||||
parts << ratio(row) if row.released? && row.expected.any?
|
||||
parts.join(" ")
|
||||
end
|
||||
|
||||
def ratio(row) = "#{(row.expected & row.present).size}/#{row.expected.size}"
|
||||
|
||||
def checklist(row)
|
||||
return [ " (this platform is not in the registry, so nothing is expected)" ] if row.unknown_platform?
|
||||
return [ " (no release to check)" ] unless row.released?
|
||||
return [ " (this platform expects no assets)" ] if row.expected.empty? && row.present.empty?
|
||||
|
||||
row.expected.map { |kind| " #{MARKERS[row.state_of(kind)]} #{kind}" } +
|
||||
row.unexpected.map { |kind| " #{MARKERS[:unexpected]} #{kind} (not expected on #{row.platform&.name})" }
|
||||
end
|
||||
|
||||
def footer
|
||||
totals = coverage.totals
|
||||
lines = []
|
||||
|
||||
if coverage.missing_rows.any? && !only_missing
|
||||
lines << "The CI can build these and the release does not have them:"
|
||||
lines += coverage.missing_rows.map do |row|
|
||||
" #{row.software.name} v#{row.version} (#{row.platform&.name}): #{row.buildable_missing.join(', ')}"
|
||||
end
|
||||
lines << ""
|
||||
end
|
||||
|
||||
lines << format(
|
||||
"%d softwares, %d releases, %d/%d assets present, %d missing from CI-built kinds",
|
||||
totals[:softwares], totals[:releases], totals[:present], totals[:expected], totals[:missing]
|
||||
)
|
||||
lines << "#{totals[:incomplete_softwares]} softwares are missing an asset the CI builds" if totals[:missing].positive?
|
||||
lines << "#{totals[:manual_missing]} assets are expected but this CI does not build them" if totals[:manual_missing].positive?
|
||||
lines << "#{totals[:without_release]} softwares have no release yet" if totals[:without_release].positive?
|
||||
lines << "#{totals[:unknown_platform]} softwares are on a platform the registry does not know" if totals[:unknown_platform].positive?
|
||||
lines << "#{totals[:unexpected]} assets are not expected on their platform" if totals[:unexpected].positive?
|
||||
lines
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
module WarpEngine
|
||||
module FileIcon
|
||||
BY_EXTENSION = {
|
||||
%w[.png .jpg .jpeg .gif .bmp .webp .svg] => "🖼️",
|
||||
%w[.mp3 .ogg .wav .flac .aac] => "🔊",
|
||||
%w[.mp4 .avi .mkv .webm .mov] => "🎬",
|
||||
%w[.zip .gz .tar .rar .7z .bz2] => "📦",
|
||||
%w[.pdf] => "📕",
|
||||
%w[.doc .docx .odt .txt .md .rtf] => "📄",
|
||||
%w[.xls .xlsx .csv .ods] => "📊",
|
||||
%w[.html .htm .css .js .ts .json .xml] => "📝",
|
||||
%w[.rb .py .lua .c .cpp .h .rs .go] => "💻",
|
||||
%w[.tic .rom .bin .prg .crt .d64 .t64 .love] => "🎮",
|
||||
%w[.exe .dmg .appimage .msi .wasm] => "⚙️"
|
||||
}.freeze
|
||||
|
||||
DEFAULT = "📄".freeze
|
||||
DIRECTORY = "📁".freeze
|
||||
|
||||
def self.for(name)
|
||||
ext = File.extname(name.to_s).downcase
|
||||
BY_EXTENSION.each { |extensions, icon| return icon if extensions.include?(ext) }
|
||||
DEFAULT
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
module WarpEngine
|
||||
class PipelineSerializer < Blueprinter::Base
|
||||
field :id
|
||||
field :repo_owner
|
||||
field :repo_name
|
||||
field :platform
|
||||
field :active
|
||||
field :last_pipeline_status
|
||||
field :last_pipeline_at
|
||||
|
||||
field(:repo_id) { |pipeline| pipeline.remote_repo_id }
|
||||
field(:woodpecker_repo_id) { |pipeline| pipeline.remote_repo_id }
|
||||
field(:software_name) { |pipeline| pipeline.software&.name }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
module WarpEngine
|
||||
module AccessPolicyGuard
|
||||
private
|
||||
|
||||
def authorize_download!(asset:, subject:, request: nil)
|
||||
grant = WarpEngine.access_policy.authorize_download(asset: asset, subject: subject, request: request)
|
||||
raise WarpEngine::AccessDenied if grant.nil?
|
||||
grant
|
||||
rescue WarpEngine::AccessDenied
|
||||
raise
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}")
|
||||
raise WarpEngine::AccessDenied
|
||||
end
|
||||
end
|
||||
end
|
||||
-1
@@ -1,7 +1,6 @@
|
||||
module WarpEngine
|
||||
module Platforms
|
||||
module Builds
|
||||
|
||||
module BuildLinuxArm64
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
|
||||
@@ -11,6 +11,19 @@ module WarpEngine
|
||||
raw = JSON.parse(File.read(path), symbolize_names: true)
|
||||
raw.slice(*METADATA_KEYS)
|
||||
end
|
||||
|
||||
def parse_lua_metadata(source_path)
|
||||
metadata = {}
|
||||
File.foreach(source_path) do |line|
|
||||
break unless line.start_with?("--")
|
||||
parts = line[2..].split(":", 2)
|
||||
next if parts.length != 2
|
||||
key = parts[0].strip.downcase.to_sym
|
||||
value = parts[1].strip
|
||||
metadata[key] = value
|
||||
end
|
||||
metadata.slice(*METADATA_KEYS)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+4
-4
@@ -4,7 +4,7 @@ module WarpEngine
|
||||
private
|
||||
|
||||
def update_or_create_software(attrs)
|
||||
software = WarpEngine::Software.unscoped.find_or_initialize_by(name: attrs[:name])
|
||||
software = WarpEngine::Software.find_or_initialize_by(name: attrs[:name])
|
||||
software.assign_attributes(attrs.except(:name))
|
||||
software.deleted_at = nil
|
||||
software.save!
|
||||
@@ -12,14 +12,14 @@ module WarpEngine
|
||||
end
|
||||
|
||||
def upsert_external_link(software_id, label, url)
|
||||
link = WarpEngine::ExternalLink.unscoped.find_or_initialize_by(software_id: software_id, label: label)
|
||||
link = WarpEngine::ExternalLink.find_or_initialize_by(software_id: software_id, label: label)
|
||||
link.url = url
|
||||
link.deleted_at = nil
|
||||
link.save!
|
||||
end
|
||||
|
||||
def create_release_if_not_exists(attrs)
|
||||
existing = WarpEngine::Release.unscoped.find_by(software_id: attrs[:software_id], version: attrs[:version])
|
||||
existing = WarpEngine::Release.find_by(software_id: attrs[:software_id], version: attrs[:version])
|
||||
return existing if existing
|
||||
|
||||
WarpEngine::Release.create!(attrs)
|
||||
@@ -27,7 +27,7 @@ module WarpEngine
|
||||
|
||||
def sync_release_assets(release, kind_paths)
|
||||
kind_paths.each do |kind, path|
|
||||
asset = WarpEngine::ReleaseAsset.unscoped.find_or_initialize_by(release_id: release.id, kind: kind)
|
||||
asset = WarpEngine::ReleaseAsset.find_or_initialize_by(release_id: release.id, kind: kind)
|
||||
asset.path = path
|
||||
asset.deleted_at = nil
|
||||
asset.save!
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
module WarpEngine
|
||||
class AssetCoverage
|
||||
Row = Struct.new(:software, :release, :platform, :expected, :present, :ci_kinds, keyword_init: true) do
|
||||
def missing = expected - present
|
||||
|
||||
def buildable_missing
|
||||
return missing if ci_kinds.nil?
|
||||
|
||||
missing & ci_kinds
|
||||
end
|
||||
|
||||
def manual_missing = missing - buildable_missing
|
||||
|
||||
def unexpected = present - expected
|
||||
|
||||
def complete? = release.present? && missing.empty?
|
||||
|
||||
def actionable? = release.present? && buildable_missing.any?
|
||||
|
||||
def unknown_platform? = platform.nil?
|
||||
|
||||
def released? = release.present?
|
||||
|
||||
def version = release&.version
|
||||
|
||||
def state_of(kind)
|
||||
return :present if present.include?(kind)
|
||||
return :manual if ci_kinds && !ci_kinds.include?(kind)
|
||||
|
||||
:missing
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(name: nil, platform: nil, releases: :latest)
|
||||
@name = name.presence
|
||||
@platform = platform.presence
|
||||
@releases = releases == :all ? :all : :latest
|
||||
end
|
||||
|
||||
attr_reader :name, :platform, :releases
|
||||
|
||||
def rows
|
||||
@rows ||= scope.flat_map { |software| rows_for(software) }
|
||||
end
|
||||
|
||||
def missing_rows = rows.select(&:actionable?)
|
||||
|
||||
def incomplete_rows = rows.select { |row| row.released? && !row.complete? }
|
||||
|
||||
def totals
|
||||
counted = rows.select(&:released?)
|
||||
|
||||
{
|
||||
softwares: rows.map { |row| row.software.id }.uniq.size,
|
||||
releases: counted.size,
|
||||
expected: counted.sum { |row| row.expected.size },
|
||||
present: counted.sum { |row| (row.expected & row.present).size },
|
||||
missing: counted.sum { |row| row.buildable_missing.size },
|
||||
manual_missing: counted.sum { |row| row.manual_missing.size },
|
||||
unexpected: counted.sum { |row| row.unexpected.size },
|
||||
incomplete_softwares: missing_rows.map { |row| row.software.id }.uniq.size,
|
||||
without_release: rows.count { |row| !row.released? },
|
||||
unknown_platform: rows.count(&:unknown_platform?)
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def scope
|
||||
relation = WarpEngine::Software.kept.order(:name).includes(releases: :release_assets)
|
||||
relation = relation.where(name: name) if name
|
||||
relation = relation.where(platform: platform) if platform
|
||||
relation
|
||||
end
|
||||
|
||||
def rows_for(software)
|
||||
expected = expected_kinds(software)
|
||||
selected = select_releases(software.releases.to_a)
|
||||
return [ row(software, nil, expected) ] if selected.empty?
|
||||
|
||||
selected.map { |release| row(software, release, expected) }
|
||||
end
|
||||
|
||||
def row(software, release, expected)
|
||||
Row.new(
|
||||
software: software,
|
||||
release: release,
|
||||
platform: WarpEngine::Platform.find(software.platform),
|
||||
expected: expected,
|
||||
present: release ? release.release_assets.map(&:kind).uniq : [],
|
||||
ci_kinds: ci_kinds(software.platform)
|
||||
)
|
||||
end
|
||||
|
||||
def ci_kinds(platform)
|
||||
@ci_kinds ||= {}
|
||||
return @ci_kinds[platform] if @ci_kinds.key?(platform)
|
||||
|
||||
ci = WarpEngine.ci
|
||||
@ci_kinds[platform] = ci.respond_to?(:built_kinds) ? ci.built_kinds(platform) : nil
|
||||
end
|
||||
|
||||
def expected_kinds(software)
|
||||
WarpEngine::Platform.find(software.platform)&.expected_kinds || []
|
||||
end
|
||||
|
||||
def select_releases(all)
|
||||
return sorted(all) if releases == :all
|
||||
|
||||
[ latest(all) ].compact
|
||||
end
|
||||
|
||||
def sorted(all) = all.sort_by { |release| [ release.created_at || Time.at(0), release.id ] }.reverse
|
||||
|
||||
def latest(all)
|
||||
Release.latest_non_dev(all)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,12 +1,8 @@
|
||||
module WarpEngine
|
||||
class BuildsService
|
||||
def index
|
||||
platforms = WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.each_with_object({}) do |platform, hash|
|
||||
service_class = "WarpEngine::Platforms::#{platform.camelize}::Service".constantize
|
||||
hash[platform] = {
|
||||
label: service_class.label,
|
||||
kinds: service_class.expected_kinds
|
||||
}
|
||||
platforms = WarpEngine::Platform.all.each_with_object({}) do |platform, hash|
|
||||
hash[platform.name] = { label: platform.label, kinds: platform.expected_kinds }
|
||||
end
|
||||
|
||||
all_kinds = WarpEngine::ReleaseAsset::KINDS
|
||||
@@ -15,9 +11,8 @@ module WarpEngine
|
||||
end
|
||||
|
||||
def show(name)
|
||||
software = WarpEngine::Software.find_by!(name: name)
|
||||
service_class = "WarpEngine::Platforms::#{software.platform.camelize}::Service".constantize
|
||||
expected = service_class.expected_kinds
|
||||
software = WarpEngine::Software.kept.find_by!(name: name)
|
||||
expected = WarpEngine::Platform.find!(software.platform).expected_kinds
|
||||
|
||||
releases = software.releases.includes(:release_assets).order(updated_at: :desc)
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
module WarpEngine
|
||||
|
||||
class DeviceGrantService
|
||||
class NotConfigured < StandardError; end
|
||||
class UnknownCode < StandardError; end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user