Author SHA1 Message Date
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
64 changed files with 1220 additions and 633 deletions
-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.8.0)
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
@@ -7,21 +7,21 @@ class Api::RssController < ApiController
api :GET, "/api/rss/blog", "Blog RSS feed"
returns code: 200, desc: "RSS XML feed of blog posts"
def blog
xml = RssService.new.blog_feed
xml = Rss::BlogFeed.new.xml
render xml: xml, content_type: "application/rss+xml"
end
api :GET, "/api/rss/releases", "Software releases RSS feed"
returns code: 200, desc: "RSS XML feed of software releases"
def releases
xml = RssService.new.releases_feed
xml = Rss::ReleasesFeed.new.xml
render xml: xml, content_type: "application/rss+xml"
end
api :GET, "/api/rss/howtos", "HowTos RSS feed"
returns code: 200, desc: "RSS XML feed of tech howtos"
def howtos
xml = RssService.new.howtos_feed
xml = Rss::HowtosFeed.new.xml
render xml: xml, content_type: "application/rss+xml"
end
end
@@ -28,7 +28,7 @@ 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]
+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 = "Teletype Games Blog"
def link = "#{site_url}/blog"
def description = "Latest blog posts from Teletype Games"
def items
Wiki::Pages.new.all(tag: "blog", limit: 30).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
+35
View File
@@ -0,0 +1,35 @@
require "rss"
module Rss
class Feed
def xml
RSS::Maker.make("2.0") do |maker|
maker.channel.title = title
maker.channel.link = link
maker.channel.description = description
maker.channel.language = "hu"
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
def site_url = Rails.configuration.x.site_url
def wiki_url = Rails.configuration.x.wiki_url
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 = "Teletype Games HowTos"
def link = "#{site_url}/howtos"
def description = "Latest tech howtos from Teletype Games"
def items
Wiki::Pages.new.all(tag: "howto", limit: 30).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 = "Teletype Games Releases"
def link = "#{site_url}/catalog"
def description = "Latest game releases from Teletype Games"
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: "#{software.title} #{release.version} released #{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
+37
View File
@@ -0,0 +1,37 @@
require "net/http"
require "json"
module Wiki
class Pages
def fetch(tag:, limit: nil, body: nil)
query = { tag: tag }
query[:limit] = limit if limit.present?
query[:body] = body if body.present?
uri = URI.parse("#{Rails.configuration.x.wiki_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) }
return empty(tag, "grav responded #{response.code}") unless response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body)
rescue StandardError => e
empty(tag, e.message)
end
def all(tag:, limit: nil)
fetch(tag: tag, limit: limit).fetch("pages", [])
end
private
def empty(tag, error)
{ "tag" => tag, "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
+4
View File
@@ -25,5 +25,9 @@ module Api
config.action_controller.forgery_protection_origin_check = false
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,8 @@ Rails.application.config.to_prepare do
c.image_class_name = "Image"
c.site_url = Rails.configuration.x.site_url
c.ci_adapter = WarpEngine::CI::Woodpecker::Adapter.new(
url: ENV["WOODPECKER_URL"],
api_token: ENV["WOODPECKER_API_TOKEN"],
+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
@@ -0,0 +1,77 @@
require "rails_helper"
require "warden/test/helpers"
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) { Rails.root.join("tmp/files-spec").to_s }
before do
FileUtils.rm_rf(container)
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
+75
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
+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
@@ -25,12 +25,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 }
+11 -10
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 }
@@ -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
@@ -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
@@ -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
@@ -7,25 +7,9 @@ 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
render json: { error: "Forbidden" }, status: :forbidden
end
@@ -33,13 +17,7 @@ module WarpEngine
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
@@ -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
@@ -2,13 +2,14 @@ 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
validates :name, presence: true
validates :url, presence: true
validates :platform, presence: true, inclusion: { in: SUPPORTED_PLATFORMS }
default_scope { where(deleted_at: nil).order(:position) }
default_scope { where(deleted_at: nil) }
scope :ordered, -> { order(:position) }
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at id name platform position updated_at url]
@@ -19,7 +20,7 @@ module WarpEngine
end
def self.for_platform(platform)
where(platform: platform).to_a
ordered.where(platform: platform).to_a
end
ActiveSupport.run_load_hooks(:warp_engine_platform_link, self)
@@ -13,6 +13,11 @@ module WarpEngine
default_scope { where(deleted_at: nil) }
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
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at id kind path release_id updated_at]
end
@@ -2,9 +2,11 @@ module WarpEngine
class Software < ApplicationRecord
self.table_name = "softwares"
STATUSES = %w[development demo released archived].freeze
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 :downloads, through: :releases
@@ -17,7 +19,8 @@ module WarpEngine
validates :name, presence: true, uniqueness: true
validates :title, presence: true
validates :platform, presence: true
validates :platform, presence: true, inclusion: { in: WarpEngine::Platform::NAMES }
validates :status, inclusion: { in: STATUSES }, allow_blank: true
default_scope { where(deleted_at: nil) }
@@ -13,7 +13,7 @@ module WarpEngine
before_save :unset_other_defaults, if: -> { is_default? && is_default_changed? }
default_scope { order(:position) }
scope :ordered, -> { order(:position) }
def self.ransackable_attributes(auth_object = nil)
%w[created_at id image_id is_default position software_id updated_at]
@@ -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,121 @@
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.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)
candidates = all.reject { |release| release.version.to_s.start_with?("dev-") }
candidates = all if candidates.empty?
sorted(candidates).first
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
@@ -16,8 +12,7 @@ module WarpEngine
def show(name)
software = WarpEngine::Software.find_by!(name: name)
service_class = "WarpEngine::Platforms::#{software.platform.camelize}::Service".constantize
expected = service_class.expected_kinds
expected = WarpEngine::Platform.find!(software.platform).expected_kinds
releases = software.releases.includes(:release_assets).order(updated_at: :desc)
@@ -3,14 +3,6 @@ module WarpEngine
class Denied < StandardError; end
def self.container_base
WarpEngine.config.file_container_path
end
def self.base_path
Pathname.new(container_base).realpath
end
def locate(path:, ip:, user_agent:, referer:, subject: nil, request: nil)
relative = path.to_s
return nil unless storage.file?(relative)
@@ -52,9 +44,7 @@ module WarpEngine
end
def find_asset(relative)
escaped = relative.gsub("%", "\\%").gsub("_", "\\_")
WarpEngine::ReleaseAsset.find_by(path: File.join(self.class.container_base, relative)) ||
WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{escaped}%").first
WarpEngine::ReleaseAsset.for_relative_path(relative)
end
def log_download(relative, asset:, ip:, user_agent:, referer:, subject:)
@@ -1,10 +1,6 @@
module WarpEngine
class FileService
def self.base_path
Pathname.new(WarpEngine.config.file_container_path).realpath
end
def show(input, subject: nil)
relative = input.path.to_s
@@ -28,7 +24,7 @@ module WarpEngine
return WarpEngine::Access::Grant::OPEN if WarpEngine::AccessPolicy.open?
asset = WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{relative.gsub('%', '\\%').gsub('_', '\\_')}%").first
asset = WarpEngine::ReleaseAsset.for_relative_path(relative)
grant = WarpEngine.access_policy.authorize_download(asset: asset, subject: subject, request: nil)
raise WarpEngine::DownloadService::Denied if grant.nil?
@@ -4,12 +4,7 @@ module WarpEngine
NOTIFICATION = "warp_engine.publish".freeze
def publish(input)
unless WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.include?(input.platform)
raise ArgumentError, "Unsupported platform: #{input.platform}"
end
release = "WarpEngine::Platforms::#{input.platform.camelize}::Service".constantize
.new.update(input.name, input.version)
release = WarpEngine::Platform.find!(input.platform).service.update(input.name, input.version)
ActiveSupport::Notifications.instrument(
NOTIFICATION,
+4 -4
View File
@@ -9,14 +9,14 @@ WarpEngine::Engine.routes.draw do
end
get "software", to: "software#index"
get "software/highlighted", to: "software_highlighted#index"
get "software/highlighted", to: "software_highlighted#show"
get "download", to: "downloads#show"
get "builds", to: "builds#index"
get "softwares/:name/builds", to: "software_builds#show"
get "ci/pipelines", to: "ci#pipelines"
get "ci/pipelines/:id/status", to: "ci#status"
post "ci/pipelines/:id/trigger", to: "ci#trigger"
get "ci/pipelines", to: "ci/pipelines#index"
get "ci/pipelines/:id/status", to: "ci/pipelines#show"
post "ci/pipelines/:id/trigger", to: "ci/pipeline_runs#create"
end
post "build/upload", to: "build/uploads#create"
@@ -9,7 +9,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]
@@ -19,14 +20,18 @@ class Image < ApplicationRecord
File.join(self.class.upload_path, filename.to_s)
end
def url = "/api/image/#{id}"
private
def process_upload
FileUtils.mkdir_p(self.class.upload_path)
def assign_upload_attributes
self.original_filename = file_upload.original_filename
self.content_type = file_upload.content_type.presence || "application/octet-stream"
ext = File.extname(file_upload.original_filename)
self.filename = "#{SecureRandom.uuid}#{ext}"
self.filename = "#{SecureRandom.uuid}#{File.extname(file_upload.original_filename)}"
end
def store_upload_file
FileUtils.mkdir_p(self.class.upload_path)
IO.copy_stream(file_upload.to_io, file_path)
end
end
@@ -0,0 +1,22 @@
namespace :warp_engine do
namespace :builds do
desc "Checklist of which release assets exist and which the platform expects " \
"(NAME=slug PLATFORM=godot RELEASES=all ONLY=missing STRICT=1)"
task check: :environment do
coverage = WarpEngine::AssetCoverage.new(
name: ENV["NAME"],
platform: ENV["PLATFORM"],
releases: ENV["RELEASES"] == "all" ? :all : :latest
)
checklist = WarpEngine::AssetCoverageChecklist.new(
coverage, only_missing: ENV["ONLY"] == "missing"
)
puts checklist.to_s
if ENV["STRICT"] == "1" && coverage.totals[:missing].positive?
abort "\nSTRICT: #{coverage.totals[:missing]} expected assets are missing."
end
end
end
end
+1
View File
@@ -5,6 +5,7 @@ require "apipie-rails"
require "warp_engine/version"
require "warp_engine/configuration"
require "warp_engine/platform"
require "warp_engine/storage"
require "warp_engine/access"
require "warp_engine/images"
@@ -43,6 +43,7 @@ module WarpEngine
def name = "none"
def configured? = false
def platforms = {}
def built_kinds(_platform) = nil
def update_server = nil
def repos = raise(NotConfigured, MESSAGE)
@@ -28,6 +28,8 @@ module WarpEngine
def platforms = @config.platforms
def built_kinds(platform) = @config.built_kinds(platform)
def client
@client ||= Client.new(url: @url, token: @api_token)
end
@@ -7,6 +7,16 @@ module WarpEngine
class PipelineConfig
PLATFORM_FORMAT = /\A[a-z0-9_-]+\z/
BUILT_KINDS = {
"tic80" => %w[cartridge source html docs win_x64 linux_x64 mac_x64],
"ebitengine" => %w[html win_x86 win_x64 linux_x64 linux_arm64],
"godot" => %w[html win_x86 win_x64 linux_x64 mac_universal],
"love" => %w[html win_x64 linux_x64 mac_universal],
"bevy" => %w[html win_x64 linux_x64 linux_arm64],
"c64" => %w[cartridge],
"phaser" => %w[html]
}.freeze
def initialize(platforms: {})
@platforms = platforms.to_h { |key, spec| [ key.to_s, spec.to_h.symbolize_keys ] }
end
@@ -33,6 +43,13 @@ module WarpEngine
)
end
def built_kinds(platform)
platform = platform.to_s
return nil unless @platforms.key?(platform)
BUILT_KINDS[platform]
end
def self.templates_dir
Pathname.new(__dir__).join("platforms")
end
@@ -1,7 +1,8 @@
module WarpEngine
class Configuration
attr_accessor :file_container_path,
attr_accessor :site_url,
:file_container_path,
:update_secret,
:application_token_source,
:application_token_owner_class,
@@ -19,6 +20,7 @@ module WarpEngine
:device_code_interval
def initialize
@site_url = nil
@file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
@update_secret = ENV["UPDATE_SECRET"]
@application_token_source = :env
@@ -0,0 +1,48 @@
module WarpEngine
class Platform
NAMES = %w[tic80 ebitengine love c64 godot bevy phaser].freeze
class << self
def names = NAMES
def all = NAMES.map { |name| new(name) }
def exists?(name) = NAMES.include?(name.to_s)
def find(name)
return nil unless exists?(name)
new(name.to_s)
end
def find!(name)
find(name) || raise(ArgumentError, "Unsupported platform: #{name}")
end
end
attr_reader :name
def initialize(name)
@name = name.to_s
end
def service_class
"WarpEngine::Platforms::#{name.camelize}::Service".constantize
end
def service = service_class.new
def label = service_class.label
def expected_kinds = service_class.expected_kinds
def to_s = name
def ==(other)
other.is_a?(Platform) ? name == other.name : name == other.to_s
end
alias eql? ==
def hash = name.hash
end
end
@@ -43,6 +43,11 @@ module WarpEngine
end
class << self
def mime_for(path)
ext = File.extname(path.to_s).delete_prefix(".")
Mime::Type.lookup_by_extension(ext) || "application/octet-stream"
end
def adapter
configured = WarpEngine.config.storage_adapter
@@ -1,5 +1,5 @@
module WarpEngine
VERSION = "0.7.0"
VERSION = "0.8.0"
VERSION_HEADER = "WarpEngine-Version".freeze
end
@@ -0,0 +1,54 @@
require "rails_helper"
RSpec.describe WarpEngine::CI::Woodpecker::PipelineConfig do
BUILT_KINDS = described_class::BUILT_KINDS
def self.fragment_for(kind)
case kind
when "html" then ".html.zip"
when "docs" then "-docs.zip"
when "cartridge", "source" then nil
else kind.tr("_", "-")
end
end
def config_for(platform)
described_class.new(platforms: { platform => { builder: "builder:latest", exporter: "exporter:latest" } })
end
it "knows every platform in the registry" do
expect(BUILT_KINDS.keys).to match_array(WarpEngine::Platform.names)
end
WarpEngine::Platform::NAMES.each do |platform|
context platform do
let(:kinds) { BUILT_KINDS.fetch(platform) }
let(:rendered) do
config_for(platform).render(platform: platform, name: "example", update_server: "https://example.test")
end
it "claims only kinds the updater knows how to ingest" do
expect(kinds - WarpEngine::Platform.find(platform).expected_kinds).to be_empty
end
it "claims only kinds the pipeline actually packages" do
kinds.each do |kind|
fragment = self.class.fragment_for(kind)
next if fragment.nil?
expect(rendered).to include(fragment), "#{platform}: nothing in the pipeline produces #{kind}"
end
end
end
end
describe "#built_kinds" do
it "answers with the kinds of a platform it has a builder for" do
expect(config_for("tic80").built_kinds("tic80")).to include("cartridge", "html", "win_x64")
end
it "answers nil for a platform with no builder configured, so nothing is excused" do
expect(config_for("tic80").built_kinds("godot")).to be_nil
end
end
end
@@ -28,11 +28,21 @@ RSpec.describe WarpEngine::PlatformLink, type: :model do
expect(WarpEngine::PlatformLink.all).to eq([active])
end
it "orders by position" do
it "does not order: ordering is asked for, not inherited" do
second = create(:platform_link, position: 2)
first = create(:platform_link, position: 1)
expect(WarpEngine::PlatformLink.all).to eq([first, second])
expect(WarpEngine::PlatformLink.ordered).to eq([ first, second ])
expect(WarpEngine::PlatformLink.all.to_sql).not_to include("ORDER BY")
end
end
describe ".ordered" do
it "is what .for_platform uses, so the API keeps its link order" do
second = create(:platform_link, platform: "tic80", position: 2)
first = create(:platform_link, platform: "tic80", position: 1)
expect(WarpEngine::PlatformLink.for_platform("tic80")).to eq([ first, second ])
end
end
@@ -0,0 +1,52 @@
require "rails_helper"
RSpec.describe WarpEngine::AssetCoverageChecklist do
let(:software) { create(:software, name: "rabbitroller", platform: "ebitengine") }
before do
release = create(:release, software: software, version: "1.1.1")
WarpEngine::ReleaseAsset.create!(release: release, kind: "html", path: "/s/rabbitroller-1.1.1")
ci = instance_double(WarpEngine::CI::Woodpecker::Adapter)
allow(ci).to receive(:built_kinds) { |platform| WarpEngine::CI::Woodpecker::PipelineConfig::BUILT_KINDS[platform] }
allow(WarpEngine).to receive(:ci).and_return(ci)
end
def output(**options) = described_class.new(WarpEngine::AssetCoverage.new(**options)).to_s
it "checks off what is there and leaves the rest open" do
lines = output.lines.map(&:chomp)
expect(lines).to include("rabbitroller ebitengine v1.1.1 1/7")
expect(lines).to include(" [x] html")
expect(lines).to include(" [ ] linux_arm64")
expect(lines).to include(" [-] mac_arm64")
end
it "lists what the CI could build and the release does not have" do
expect(output).to include("The CI can build these and the release does not have them:")
expect(output).to include("rabbitroller v1.1.1 (ebitengine): win_x86, win_x64, linux_x64, linux_arm64")
end
it "counts the assets and says how many the CI does not build" do
expect(output).to include("1 softwares, 1 releases, 1/7 assets present, 4 missing from CI-built kinds")
expect(output).to include("2 assets are expected but this CI does not build them")
end
it "shows only the incomplete releases when asked" do
complete = create(:software, name: "pong", platform: "c64")
release = create(:release, software: complete, version: "0.1")
WarpEngine::ReleaseAsset.create!(release: release, kind: "cartridge", path: "/s/pong-0.1.prg")
text = described_class.new(WarpEngine::AssetCoverage.new, only_missing: true).to_s
expect(text).to include("rabbitroller")
expect(text).not_to include("pong")
end
it "says so when there is nothing to report" do
text = described_class.new(WarpEngine::AssetCoverage.new(name: "nothing-here")).to_s
expect(text).to include("Nothing to report.")
end
end
@@ -0,0 +1,139 @@
require "rails_helper"
RSpec.describe WarpEngine::AssetCoverage do
def release_with(software:, version:, kinds:, created_at: Time.current)
release = create(:release, software: software, version: version, created_at: created_at)
kinds.each { |kind| WarpEngine::ReleaseAsset.create!(release: release, kind: kind, path: "/s/#{version}-#{kind}") }
release
end
describe "#rows" do
it "says which expected kinds are present and which are missing" do
software = create(:software, name: "impostor", platform: "tic80")
release_with(software: software, version: "1.0", kinds: %w[cartridge source html])
row = described_class.new.rows.first
expect(row.software).to eq(software)
expect(row.expected).to eq(WarpEngine::Platform.find("tic80").expected_kinds)
expect(row.present).to contain_exactly("cartridge", "source", "html")
expect(row.missing).to contain_exactly("docs", "win_x64", "linux_x64", "mac_x64")
expect(row).not_to be_complete
end
it "calls a release with every expected asset complete" do
software = create(:software, name: "pong", platform: "c64")
release_with(software: software, version: "0.1.0", kinds: %w[cartridge])
row = described_class.new.rows.first
expect(row).to be_complete
expect(row.missing).to be_empty
expect(row).not_to be_actionable
end
it "reports an asset the platform does not expect" do
software = create(:software, name: "phasergame", platform: "phaser")
release_with(software: software, version: "1.0", kinds: %w[html win_x64])
row = described_class.new.rows.first
expect(row.unexpected).to eq([ "win_x64" ])
end
it "reports a software with no release at all" do
create(:software, name: "empty", platform: "godot")
row = described_class.new.rows.first
expect(row).not_to be_released
expect(row).not_to be_actionable
end
it "expects nothing from a platform the registry does not know" do
software = create(:software, name: "amigagame", platform: "tic80")
software.update_column(:platform, "amiga")
release_with(software: software, version: "1.0", kinds: [])
row = described_class.new.rows.first
expect(row).to be_unknown_platform
expect(row.expected).to be_empty
end
it "checks the newest release by default, and every release on demand" do
software = create(:software, name: "rabbitroller", platform: "ebitengine")
release_with(software: software, version: "1.0.0", kinds: %w[html], created_at: 2.days.ago)
release_with(software: software, version: "1.1.1", kinds: %w[html win_x64], created_at: 1.day.ago)
expect(described_class.new.rows.map(&:version)).to eq([ "1.1.1" ])
expect(described_class.new(releases: :all).rows.map(&:version)).to eq([ "1.1.1", "1.0.0" ])
end
it "does not call a dev build the latest release" do
software = create(:software, name: "rabbitroller", platform: "ebitengine")
release_with(software: software, version: "1.1.1", kinds: %w[html], created_at: 2.days.ago)
release_with(software: software, version: "dev-abc123-master", kinds: %w[html], created_at: 1.hour.ago)
expect(described_class.new.rows.map(&:version)).to eq([ "1.1.1" ])
end
it "narrows to one software or one platform" do
create(:software, name: "impostor", platform: "tic80")
create(:software, name: "rabbitroller", platform: "ebitengine")
expect(described_class.new(name: "impostor").rows.map { |r| r.software.name }).to eq([ "impostor" ])
expect(described_class.new(platform: "ebitengine").rows.map { |r| r.software.name }).to eq([ "rabbitroller" ])
end
end
describe "what the CI can build" do
let(:software) { create(:software, name: "rabbitroller", platform: "ebitengine") }
before { release_with(software: software, version: "1.1.1", kinds: %w[html]) }
it "separates the kinds the pipeline builds from the ones it cannot" do
ci = instance_double(WarpEngine::CI::Woodpecker::Adapter)
allow(ci).to receive(:built_kinds).with("ebitengine")
.and_return(%w[html win_x86 win_x64 linux_x64 linux_arm64])
allow(WarpEngine).to receive(:ci).and_return(ci)
row = described_class.new.rows.first
expect(row.buildable_missing).to contain_exactly("win_x86", "win_x64", "linux_x64", "linux_arm64")
expect(row.manual_missing).to contain_exactly("mac_x64", "mac_arm64")
expect(row.state_of("linux_arm64")).to eq(:missing)
expect(row.state_of("mac_x64")).to eq(:manual)
expect(row.state_of("html")).to eq(:present)
end
it "treats every missing kind as buildable when the CI cannot say" do
ci = instance_double(WarpEngine::CI::Woodpecker::Adapter)
allow(ci).to receive(:built_kinds).and_return(nil)
allow(WarpEngine).to receive(:ci).and_return(ci)
row = described_class.new.rows.first
expect(row.buildable_missing).to eq(row.missing)
expect(row.manual_missing).to be_empty
end
end
describe "#totals" do
it "counts releases, assets and the softwares that are short of one" do
tic80 = create(:software, name: "impostor", platform: "tic80")
release_with(software: tic80, version: "1.0", kinds: %w[cartridge source html docs win_x64 linux_x64 mac_x64])
c64 = create(:software, name: "pong", platform: "c64")
release_with(software: c64, version: "0.1", kinds: [])
totals = described_class.new.totals
expect(totals[:softwares]).to eq(2)
expect(totals[:releases]).to eq(2)
expect(totals[:expected]).to eq(8)
expect(totals[:present]).to eq(7)
expect(totals[:missing]).to eq(1)
expect(totals[:incomplete_softwares]).to eq(1)
end
end
end