Author SHA1 Message Date
mr.zero 9ffbc7a2ca wiki multilang support
ci/woodpecker/push/deploy Pipeline was successful
2026-08-28 20:52:49 +02:00
mr.zero 653894a876 wp version upgrade
ci/woodpecker/push/deploy Pipeline was successful
2026-08-26 00:14:51 +02:00
mr.zero 97d71a7c99 advanced pipeline fix
ci/woodpecker/push/deploy Pipeline was successful
2026-08-26 00:08:09 +02:00
mr.zero a58e91520e advanced pipeline
ci/woodpecker/push/deploy Pipeline was successful
ci/woodpecker/push/warp_engine Pipeline was successful
2026-08-25 23:50:11 +02:00
mr.zero 259de781bb ignored user agents
ci/woodpecker/push/woodpecker Pipeline failed
2026-08-25 15:09:34 +02:00
mr.zero 6f2a39fe1b X-Forwarded-For fix 3 2026-08-25 15:03:02 +02:00
mr.zero fbdfbe954e X-Forwarded-For fix 2 2026-08-25 15:00:38 +02:00
mr.zero b51bf0d094 X-Forwarded-For fix 2026-08-25 14:57:08 +02:00
mr.zero fc1076f8fc refact
ci/woodpecker/push/woodpecker Pipeline failed
2026-08-25 14:49:23 +02:00
mr.zero 3e70717e8f gitlab
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-24 13:46:15 +02:00
mr.zero c0252928c6 batocera info update 2026-08-23 21:59:57 +02:00
mr.zeroandClaude Opus 5 3a524d5003 The file manager spec gets a directory nobody else can be in
Twice in a long session the suite came back with one failure in this file, and
neither run could be reproduced afterwards — not by seed, not by repetition.
The one thing this spec has that the other 39 examples do not is a fixed path
under `tmp/`, which anything else on the machine can also be inside.

`Dir.mktmpdir` instead, so two runs cannot see each other's folders at all. If
a failure survives this, it is about the code and not about the directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:50:56 +02:00
mr.zeroandClaude Opus 5 2bf0b20b77 A checklist of the release assets that exist and the ones the CI could still build
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
Nothing said whether a release was finished. A pipeline step can fail, a
platform can gain a target, a title can be published from a laptop — and the
catalog happily serves a release with three of its seven assets. The only way
to find out was to open `/api/softwares/<name>/builds` one name at a time.

    $ bin/rails warp_engine:builds:check

    rabbitroller  ebitengine  v1.1.1  1/7
      [x] html
      [ ] win_x86
      [ ] win_x64
      [ ] linux_x64
      [ ] 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, win_x64, linux_x64, linux_arm64

    5 softwares, 5 releases, 14/32 assets present, 16 missing from CI-built kinds

The third marker is the point. Two different questions were being conflated:
what the **updater** can ingest (`Platform#expected_kinds`) and what the
**pipeline** actually produces. For six of the seven platforms they are the
same list; for ebitengine they are not — the updater accepts macOS builds, the
Woodpecker builder cannot cross-compile them (osxcross), so `mac_x64` and
`mac_arm64` would be reported as gaps on every ebitengine release, for ever.
`[ ]` is now a real gap and `[-]` is "upload it by hand or not at all", so the
list stays worth reading.

That knowledge is data, next to the templates that produce it:
`PipelineConfig::BUILT_KINDS`, reachable through the adapter as
`built_kinds(platform)`. An adapter that cannot answer — no CI configured, a
platform with no builder — returns nil, and then nothing is excused: every
missing kind counts as a gap.

`WarpEngine::AssetCoverage` computes it (`#rows`, `#missing_rows`, `#totals`)
and `AssetCoverageChecklist` renders it, so the numbers are testable without a
terminal. Options are env vars: NAME, PLATFORM, RELEASES=all, ONLY=missing, and
STRICT=1 for a non-zero exit in CI. The default is the newest release per
software, using the same non-`dev-` rule the catalog API uses for "latest".

33 new examples, including two that keep BUILT_KINDS honest: every claimed kind
has to be a kind the updater knows, and has to appear in the platform's rendered
pipeline. Adding a platform without teaching this list about it fails the suite.

Ships in 0.8.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:34:23 +02:00
mr.zeroandClaude Opus 5 e6ef4f4191 The host lockfile follows the engine to 0.8.0
The path gem carries its version into apps/api/Gemfile.lock; without this the
next bundle in a fresh container is the one that updates it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:14:37 +02:00
mr.zeroandClaude Opus 5 81708fd84d The file manager spec cleans its directory before it fills it
One run out of a dozen failed on "creates a folder and returns to the
directory": the spec created its artifact directory under `tmp/` and only
removed it afterwards, so anything left behind by an interrupted run was still
there when the next one started, and `mkdir` on an existing folder answers with
the alert instead of the notice.

It removes the directory first now. The suite is green across ten consecutive
runs and every seed tried, including the one that failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:07:17 +02:00
mr.zeroandClaude Opus 5 688d956107 WarpEngine 0.8.0
ci/woodpecker/push/woodpecker Pipeline was successful
What a host gets by moving up from 0.7:

- `WarpEngine::Platform` — the platform registry. `PlatformLink::SUPPORTED_PLATFORMS`
  keeps working as an alias.
- `WarpEngine::ApiErrorRendering` — the engine's JSON error bodies, includable
  by the host's own API controllers.
- `WarpEngine::Storage.mime_for` and `ReleaseAsset.for_relative_path`.
- `c.site_url` — where the catalog's public site lives, for the admin's link to
  a title's page. nil by default, and the link is simply left out.
- `Software` now validates `platform` and `status` against their value sets. A
  host writing a status outside development/demo/released/archived with
  `update!` will start hearing about it — which is the point — and one writing
  it with `update_columns` still will not.
- Ordering left `PlatformLink`'s and `SoftwareImage`'s `default_scope` for an
  `ordered` scope. The API's output order is unchanged; code that relied on the
  implicit order of a bare `PlatformLink.all` has to ask for it.
- The generated `Image` model writes its file `after_commit` instead of
  `before_save`, and gained `#url`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:03:01 +02:00
mr.zeroandClaude Opus 5 244b0e46cb No application code reads ENV, and the wiki has one address again
The API read its environment wherever it happened to need it: `Image` in the
model, `RssService` and `WikiService` in class-level constants, the softwares
admin page in a sidebar. Two of those wanted the same wiki and disagreed about
its name — `RssService::WIKI_URL` against `WikiService::GRAV_URL` — and only
the second one is set in docker-compose, so the howtos feed had been linking to
the hard-coded default all along.

Every `ENV` read is in `config/application.rb` now, as `config.x.site_url`,
`config.x.wiki_url` and `config.x.images.container_path`, and the code asks
`Rails.configuration.x`. One place says what this app needs from its
environment, and a test can override it.

`RssService` was three copies of the same twenty-line `RSS::Maker` block. It is
`Rss::Feed` plus `Rss::BlogFeed`, `Rss::ReleasesFeed` and `Rss::HowtosFeed`,
each of which now only answers what its title, its link and its items are — and
the `Time.parse(...) rescue Time.current` modifier, which swallowed everything,
is a rescue of ArgumentError and TypeError. `WikiService` becomes `Wiki::Pages`
and loses `alias_method :pages, :index`: two names for one method meant the
controller and the feeds each called it something different.

The admin cookie was a monkey patch — `ApplicationController.class_eval` in an
initializer, adding an `after_action` that the three-line controller file gave
no hint of. It is a `SyncsAdminCookie` concern the controller includes.

And the engine stops reading the host's config keys: the "View on site" link in
the softwares admin asked for `Rails.configuration.x.site_url`, which is ours,
not its. `c.site_url` is an engine setting now, nil by default, and the link is
left out when the host does not set it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:02:46 +02:00
mr.zeroandClaude Opus 5 9f55c47269 The uploaded picture is written after the row is committed
`Image#process_upload` ran in a `before_save`: it assigned the generated file
name and copied the bytes to disk, in the same breath, before the row existed.
If the insert failed afterwards — a validation on the owning record, a
uniqueness clash, a rollback from the surrounding transaction — the file stayed
behind with nothing pointing at it. The admin's own "Orphan" scope exists to
find rows in that family; this was the other half of it, the files with no row
at all.

Split in two: `before_validation` assigns the attributes (so validations and the
generated name still see them), `after_commit` copies the bytes. A rollback now
takes the file with it, because the copy never happens.

The same model is generated into every host, so the fix goes into the
generator template as well as ours. While there: `Image#url` comes back from
Teletype Orbit, where it was added and never flowed back, and the two admin
previews use it instead of interpolating `/api/image/#{id}` by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:02:22 +02:00
mr.zeroandClaude Opus 5 5d50ac69a1 The file manager's rename prompt was empty: Kernel#j, not escape_javascript
The admin file manager builds its rename and delete buttons with inline
handlers, and interpolated the file name through `j`:

    onclick: "var n=prompt('New name:','#{j entry[:name]}');..."

In a view `j` is `escape_javascript`. Inside an Arbre block it is not: Arbre
resolves unknown methods through `method_missing`, and `j` is not unknown — it
is `Kernel#j`, which prints its argument as JSON to stdout and returns nil. So
every page load wrote the file names to the server log, and the browser got

    prompt('New name:','')

An admin pressing rename saw an empty prompt, and the delete confirmation asked
"Delete ''?". `escape_javascript(...)` spelled out is what those three
interpolations use now.

The page has no test, which is why nothing caught it. It has one now
(spec/requests/admin_files_spec.rb), and it asserts the file name is in both
handlers — with the icons, the folder creation, the failed folder creation and
the delete-returns-to-parent path, because those are the behaviours the
refactoring below could break silently.

Also in the page: the twenty-branch extension-to-emoji `case` moved out of the
view into `WarpEngine::FileIcon`, and the five page actions share one
`redirect_to_files` instead of repeating
`admin_files_path(dir:, picker:, field:)` six times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:02:09 +02:00
mr.zeroandClaude Opus 5 4fc68e1448 One home per rule: error bodies, mime types, asset paths, ordering
Four small duplications, each of which had already drifted or was one edit away
from it.

**The JSON error body.** The host's `ApiController` was a line-by-line copy of
the engine's four `rescue_from` blocks plus `resolve_mime`. It is a
`WarpEngine::ApiErrorRendering` concern now, included by both, so "what a
failure looks like on the wire" is decided once.

**Mime resolution.** The same three lines lived in the engine's API controller
and the host's. `WarpEngine::Storage.mime_for` owns it — next to the adapter
that hands out the files.

**Finding the asset behind a path.** `DownloadService` and `FileService` each
escaped `%` and `_` by hand and ran their own `LIKE` — the second one without
the exact-match attempt the first one had. `ReleaseAsset.for_relative_path` is
the one lookup: the absolute path first, then a suffix match anchored at `/`
rather than the old `%path%`, which could match a different file whose name
merely contained this one. Two now-unreachable helpers
(`DownloadService.base_path`, `FileService.base_path`) go with it.

**Ordering.** `PlatformLink` and `SoftwareImage` ordered inside their
`default_scope`, which leaks into every association and aggregate — the proof
was already in the tree: `admin/images.rb` had to write
`SoftwareImage.unscope(:order).distinct.pluck(:image_id)`, because MySQL will
not order a DISTINCT by a column it does not select. Ordering is a scope you
ask for now (`ordered`), the two places that need it ask (`for_platform`, the
`software_images` association, so the API's image order is unchanged), and the
`unscope` is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:01:53 +02:00
mr.zeroandClaude Opus 5 d01fd97cc1 Every CI action is a CRUD action, and the URLs do not move
`CiController` had three actions named after verbs — `pipelines`, `status`,
`trigger` — and built its JSON by hand in a private `pipeline_json`, in a
codebase where everything else is serialized by Blueprinter. Triggering a
build is the creation of a run, so it is a resource of its own:

    GET  /api/ci/pipelines             -> Api::Ci::PipelinesController#index
    GET  /api/ci/pipelines/:id/status  -> Api::Ci::PipelinesController#show
    POST /api/ci/pipelines/:id/trigger -> Api::Ci::PipelineRunsController#create

The addresses are untouched on purpose: they are what the CI and the scripts
already call, and renaming them would be a breaking change for a version that
does not need one. What changed is the shape behind them — three standard
actions across two controllers, `require_ci!` and the pipeline scope in a
shared base, and a `PipelineSerializer` that carries the fields the endpoint
already answered with, `repo_id` and `woodpecker_repo_id` included (the
duplicate stays: a client may read either, and ci_controller_spec asserts they
agree).

`/api/software/highlighted` answers with `#show` now rather than `#index`: it
returns one title, and the action name should say so. Same address, same body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:01:34 +02:00
mr.zeroandClaude Opus 5 76d38840fb The build endpoints state their input rules once
`POST /build/publish` opened with three guard clauses and `POST /build/upload`
with eight, each one a `return render json: { error: ... }` — name present,
version present, name format, version format, file present, filename prefix,
size, digest. That is a validation layer written by hand, in a place where it
cannot be unit tested: exercising it needs a request.

`PublishInputDto` was already there and was a bare `Struct` with no rules at
all, so the controller carried them. It is an `ActiveModel::Model` now, with
the presence and platform-inclusion validations on it, and `UploadInputDto`
joins it with the name and version formats and the `<name>-<version>` filename
convention. Each controller reads:

    return render json: { error: input.error_message }, status: :bad_request unless input.valid?

Size and digest keep their own explicit checks, because they are not the same
answer: 413 tells a caller to stop, 422 tells it to retry a truncated upload,
and a single error bag cannot say which. Every status code the endpoints
answered before, they answer now — build_publish_controller_spec and
build_uploads_controller_spec pin all of them, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:01:10 +02:00
mr.zeroandClaude Opus 5 684f06745a A platform is named once: WarpEngine::Platform
The list of platforms lived on `PlatformLink::SUPPORTED_PLATFORMS` — a model
about links to a platform's website — and was copied around it four times in
the admin (`%w[tic80 ebitengine love c64 godot bevy phaser]`), spelled out in
three apipie descriptions, and turned into a class name by string
interpolation in three services:

    "WarpEngine::Platforms::#{platform.camelize}::Service".constantize

Seven places that had to agree, and nothing that made them.

`WarpEngine::Platform` is now that one place. `Platform.names` is the list,
`Platform.find!("godot")` answers with a value object that knows its `label`,
its `expected_kinds` and its updater `service`, and the constantize is gone —
the registry holds the reference. `PublishService` and `BuildsService` ask it,
the admin selects read `Platform.names`, and
`PlatformLink::SUPPORTED_PLATFORMS` stays as an alias of `Platform::NAMES` so
a host pinned to 0.7 keeps working.

`Software` also defends its own value sets now. It validated neither `status`
nor `platform`, so a mistyped platform only surfaced later, at publish time,
as "Unsupported platform" — after the row existed. And `status` had three
different answers depending on where you looked: the admin offered
development/demo/released/archived, the API documentation claimed
"active, inactive", and the database holds the first four. `Software::STATUSES`
is the list, the inclusion validations enforce both, and the documentation
names the values the database actually has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:00:55 +02:00
Zsolt Tasnadi 58b8574cb4 schema update 2026-08-23 01:11:52 +02:00
mr.zero 9d8a546efa delete REFACT.md 2026-08-23 01:02:31 +02:00
148 changed files with 3886 additions and 895 deletions
+10
View File
@@ -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")
+125
View File
@@ -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
+129 -3
View File
@@ -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 ...`
-231
View File
@@ -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 -1
View File
@@ -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)
+3 -3
View File
@@ -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
-1
View File
@@ -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
+2 -24
View File
@@ -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
+11 -6
View File
@@ -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
+20
View File
@@ -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
+43
View File
@@ -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
+20
View File
@@ -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
-73
View File
@@ -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
View File
@@ -1,5 +1,4 @@
class StoreService
def index
StoreSerializer.render_as_hash(Store.active.ordered)
end
+43
View File
@@ -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
-31
View File
@@ -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
+12
View File
@@ -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"],
+11
View File
@@ -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}"
+18
View File
@@ -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}"
+12
View File
@@ -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
+9
View File
@@ -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
+20
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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(&#39;New name:&#39;,&#39;mygame-1.0.zip&#39;)")
expect(response.body).to include("Delete \\&#39;mygame-1.0.zip\\&#39;")
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
+31
View File
@@ -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
+46
View File
@@ -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`)
})
})
+29 -11
View File
@@ -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()
},
)
}
+2 -2
View File
@@ -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',
)
})
})
+10
View File
@@ -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 }
})
+8 -2
View File
@@ -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 }
})
+6
View File
@@ -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
View File
@@ -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"
+13
View File
@@ -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/**/*"
+1
View File
@@ -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
+86 -1
View File
@@ -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
+1 -1
View File
@@ -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
+32 -46
View File
@@ -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
+2 -2
View File
@@ -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
+12 -11
View File
@@ -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
@@ -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,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,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