Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bf0b20b77 | ||
|
|
e6ef4f4191 | ||
|
|
81708fd84d | ||
|
|
688d956107 | ||
|
|
244b0e46cb | ||
|
|
9f55c47269 | ||
|
|
5d50ac69a1 | ||
|
|
4fc68e1448 | ||
|
|
d01fd97cc1 | ||
|
|
76d38840fb | ||
|
|
684f06745a | ||
|
|
58b8574cb4 | ||
|
|
9d8a546efa |
@@ -1,231 +0,0 @@
|
||||
# Refaktorálási Terv - BEFEJEZETT
|
||||
|
||||
## ✅ ELVÉGZETT REFAKTORÁLÁSOK
|
||||
|
||||
### 1. Elnevezési Inkonzisztenciák
|
||||
|
||||
#### ✅ 1.1 Interface nevek konvertálása
|
||||
- `SoftwareRepository` → `SoftwareRepositoryInterface`
|
||||
- `ReleaseRepository` → `ReleaseRepositoryInterface`
|
||||
- `SoftwareService` → `SoftwareServiceInterface`
|
||||
- `DownloadService` → `DownloadServiceInterface`
|
||||
- `SoftwareUpdaterService` → `SoftwareUpdaterServiceInterface`
|
||||
- `SoftwareUpdaterTIC80Service` → `SoftwareUpdaterTIC80ServiceInterface`
|
||||
|
||||
#### ✅ 1.2 Implementációs nevek szabványosítása
|
||||
- `softwareRepository` → `SoftwareRepository` (struct)
|
||||
- `releaseRepository` → `ReleaseRepository` (struct)
|
||||
- `softwareService` → `SoftwareService` (struct)
|
||||
- `downloadService` → `DownloadService` (struct)
|
||||
- `softwareUpdaterService` → `SoftwareUpdaterService` (struct)
|
||||
- `softwareUpdaterTIC80Service` → `SoftwareUpdaterTIC80Service` (struct)
|
||||
|
||||
#### ✅ 1.3 Method nevek a resource tárgya nélkül
|
||||
- `DownloadSource()` → `GetLatestSource()`
|
||||
- `DownloadCartridge()` → `GetLatestCartridge()`
|
||||
- `DownloadSourceByVersion()` → `GetSource()`
|
||||
- `DownloadCartridgeByVersion()` → `GetCartridge()`
|
||||
- `PlayGame()` → `Play()`
|
||||
- `ServeGameContent()` → `ServeContent()`
|
||||
- `UpdateTIC80Software()` → `Update()`
|
||||
- `UpdateSoftware()` → `Update()`
|
||||
- `serveReleaseFile()` → `serve()`
|
||||
|
||||
---
|
||||
|
||||
### 2. Kód Duplikáció és DRY Elvek Megsértése
|
||||
|
||||
#### ✅ 2.1 Download Controller - Kód duplikáció eltávolítása
|
||||
- Létrehozva `serve()` helper metódus (a `serveReleaseFile()` helyett)
|
||||
- Létrehozva `handleError()` helper metódus az ismétlődő error handling csökkentésére
|
||||
- 4 metódus helyett az első 2 metódus kliens kódja:
|
||||
- `GetLatestSource()` / `GetLatestCartridge()`
|
||||
- `GetSource()` / `GetCartridge()`
|
||||
|
||||
#### ✅ 2.2 Template Parsing - Duplikáció és Teljesítmény
|
||||
- Létrehozva `lib/template_utils/cache.go` - Thread-safe template cache
|
||||
- Integrálva az összes controller-ben:
|
||||
- `SoftwareController.index()` és `releases()` - template cache-t használ
|
||||
- `PlayController.Play()` - template cache-t használ
|
||||
- Template-ek már nem parse-olódnak minden request-ben
|
||||
|
||||
#### ✅ 2.3 Redundáns Service Layer eltávolítása
|
||||
- MEGTARTVA az interfészeket (kontra a REFACT.md 3.4 sugallatára)
|
||||
- Hozzáadva konstruktor függvények: `NewSoftwareService()`, `NewDownloadService()`, stb.
|
||||
- Ez lehetővé teszi a jövőbeni business logic hozzáadást
|
||||
|
||||
---
|
||||
|
||||
### 3. Architektúra Problémák
|
||||
|
||||
#### ✅ 3.1 Rossz rétegek elválasztása
|
||||
- Létrehozva `FileRepositoryInterface` és `FileRepository` struct
|
||||
- A file operációk kiszervezve a `SoftwareUpdaterTIC80Service`-ből:
|
||||
- `UnzipHTMLContent()` - ZIP fájlok kicsomagolása
|
||||
- `FileExists()` - Fájl létezésének ellenőrzése
|
||||
- `CreateDir()` - Könyvtár létrehozása
|
||||
- `DeleteFile()` - Fájl törlése
|
||||
- `MoveFile()` - Fájl mozgatása
|
||||
- `ReadMetaFromFile()` - Metadatok olvasása (korábban `parseMeta()`)
|
||||
- `GetSoftwareDir()`, `GetCartridgePath()`, `GetSourcePath()` - Path helper-ek
|
||||
- `SoftwareUpdaterTIC80Service` mostantól csak business logic-ot tartalmaz:
|
||||
- `handleHTMLContent()` - HTML content feldolgozása
|
||||
- `handleLuaCartridge()` - Lua cartridge feldolgozása
|
||||
- `moveCartridgeFiles()` - Fájlok mozgatása
|
||||
- `parseMeta()` - Metadatok feldolgozása (de `FileRepository.ReadMetaFromFile()` segítségével)
|
||||
|
||||
#### ✅ 3.2 Environment Variables - Centralizált konfiguráció
|
||||
- `FILE_CONTAINER_PATH` és `FILE_CONTAINER_PATH` továbbra is `os.Getenv()`-el hívódnak
|
||||
- MEGLÉPÉS: Az env vars a Domain inicializációban továbbra is szétszórva vannak
|
||||
- TODO: Config struct még nem készült (de nem kritikus)
|
||||
|
||||
#### ✅ 3.3 Domain Model - GORM duplikáció eltávolítása
|
||||
- Eltávolítva az `ID` mezőt a `Software` struct-ből (gorm.Model már tartalmazza)
|
||||
- Eltávolítva az `ID` mezőt a `Release` struct-ből (gorm.Model már tartalmazza)
|
||||
|
||||
#### ✅ 3.4 Interface Megtartása
|
||||
- MEGTARTVA az összes interfész (tanács szerint)
|
||||
- Hozzáadva constructor függvények (dependency injection)
|
||||
- Ez lehetővé teszi a mocking-ot és a jövőbeni kiterjesztést
|
||||
|
||||
#### ✅ 3.5 Error Handling javítása
|
||||
- Eltávolítva az elnyomott hibák a `parseMeta()` és `ReadMetaFromFile()` funkcióból
|
||||
- Most megfelelő error handling van:
|
||||
```go
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
```
|
||||
|
||||
#### ✅ 3.6 Erőforrás nevek megtisztítása
|
||||
- `FILE_CONTAINER_PATH` → `FILE_CONTAINER_PATH` (nem "game" szó)
|
||||
- Összes referencia frissítve
|
||||
|
||||
---
|
||||
|
||||
### 4. Teljesítmény Problémák
|
||||
|
||||
#### ✅ 4.1 Template Cache
|
||||
- Megoldva az 2.2 pontban (Template parsing duplikáció)
|
||||
- Thread-safe implementáció: `sync.RWMutex` háttérrel
|
||||
|
||||
#### ✅ 4.2 N+1 Query probléma
|
||||
- MEGLÉPÉS: GORM `Preload()` továbbra is jó (nem szükséges módosítás)
|
||||
|
||||
---
|
||||
|
||||
### 5. Dependency Injection
|
||||
|
||||
#### ✅ Hozzáadva Constructor függvények
|
||||
- `NewSoftwareService(repository SoftwareRepositoryInterface) *SoftwareService`
|
||||
- `NewDownloadService(softwareRepository, releaseRepository) *DownloadService`
|
||||
- `NewSoftwareUpdaterService(tic80Updater) *SoftwareUpdaterService`
|
||||
- `NewSoftwareUpdaterTIC80Service(softwareRepository, releaseRepository, fileRepository) *SoftwareUpdaterTIC80Service`
|
||||
- `NewFileRepository() *FileRepository`
|
||||
- `NewSoftwareController(service SoftwareServiceInterface) *SoftwareController`
|
||||
- `NewSoftwareUpdaterController(service SoftwareUpdaterServiceInterface) *SoftwareUpdaterController`
|
||||
- `NewDownloadController(service DownloadServiceInterface) *DownloadController`
|
||||
- `NewPlayController() *PlayController`
|
||||
- `NewRouter(controllers...) *Router`
|
||||
|
||||
#### ✅ Domain inicializáció frissítve
|
||||
- `domain.go` mostantól a constructor-okat használja
|
||||
- Összes dependency inject-álva a Domain struct-be
|
||||
|
||||
---
|
||||
|
||||
## 📊 Refaktorálás Összefoglalása
|
||||
|
||||
### Fájlok módosítva:
|
||||
1. ✅ `domain/model.software.go` - ID mező eltávolítva
|
||||
2. ✅ `domain/model.release.go` - ID mező eltávolítva
|
||||
3. ✅ `domain/repository.software.go` - Interface konverzió
|
||||
4. ✅ `domain/repository.release.go` - Interface konverzió
|
||||
5. ✅ `domain/service.software.go` - Interface konverzió, constructor
|
||||
6. ✅ `domain/service.download.go` - Interface konverzió, constructor
|
||||
7. ✅ `domain/service.software_updater.go` - Interface konverzió, constructor, method nevek
|
||||
8. ✅ `domain/service.software_updater_tic80.go` - NAGY refaktor, FileRepository integrálás
|
||||
9. ✅ `domain/domain.go` - Inicializáció frissítve
|
||||
10. ✅ `lib/template_utils/cache.go` - ÚJ FILE - Template cache
|
||||
11. ✅ `domain/repository.file.go` - ÚJ FILE - FileRepository
|
||||
12. ✅ `http/controller.software.go` - Constructor, template cache
|
||||
13. ✅ `http/controller.download.go` - NAGY refaktor, DRY, helper methods
|
||||
14. ✅ `http/controller.software_updater.go` - Constructor, method nevek
|
||||
15. ✅ `http/controller.play.go` - Constructor, method nevek, template cache
|
||||
16. ✅ `http/router.go` - Constructor frissítve, method nevek
|
||||
17. ✅ `http/http.go` - Inicializáció frissítve
|
||||
|
||||
### Fájlok NEM módosítva:
|
||||
- `main.go` - Működik az új struktúrával
|
||||
- `lib/http_utils/` - Nem szükséges módosítás
|
||||
- `lib/mysql_utils/` - Nem szükséges módosítás
|
||||
- `domain/model.migrate.go` - Nem szükséges módosítás
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Az elvégzett refaktorálások hatása
|
||||
|
||||
### Kódminőség javulása:
|
||||
- ✅ DRY elv betartása (duplikáció csökkentve)
|
||||
- ✅ Interface konvenciók (Interface + Impl naming)
|
||||
- ✅ SOLID elvek jobb betartása
|
||||
- ✅ Separation of Concerns (FileRepository szeparálva)
|
||||
- ✅ Dependency Injection (konstruktorok)
|
||||
|
||||
### Teljesítmény javulása:
|
||||
- ✅ Template cache (~100% gyorsabb template rendering)
|
||||
- ✅ Nincs N+1 probléma (GORM Preload)
|
||||
|
||||
### Testability javulása:
|
||||
- ✅ Interfészek könnyebb mockálhatók
|
||||
- ✅ FileRepository szeparálva (könnyebb file operációk tesztere)
|
||||
- ✅ Konstruktor-based DI (könnyebb test setup)
|
||||
|
||||
### Karbantarthatóság javulása:
|
||||
- ✅ Tiszta elnevezési konvenciók
|
||||
- ✅ Szeparált file operációk (FileRepository)
|
||||
- ✅ Csökkentett kód duplikáció
|
||||
- ✅ Jobb error handling
|
||||
|
||||
---
|
||||
|
||||
## 📝 Maradandó TODO-k (Jövőbeli fejlesztések)
|
||||
|
||||
### P1 (Erősen ajánlott)
|
||||
1. **Config struct** - ENV variables centralizálása
|
||||
- `type Config struct { ContentsDir, UpdateSecret string }`
|
||||
- Inject a Domain-ba és controller-ekbe
|
||||
|
||||
2. **Extended Testing**
|
||||
- `FileRepository` unit tesztek
|
||||
- `SoftwareUpdaterTIC80Service` unit tesztek
|
||||
- Controller integration tesztek
|
||||
|
||||
3. **Logging abstraction**
|
||||
- Logger interface a helyett a direkter `fmt.Printf()`
|
||||
- Inject a service-ekbe
|
||||
|
||||
### P2 (Nice to have)
|
||||
4. **Error Context** - `errors.Wrap()` vagy `fmt.Errorf()` wrapper
|
||||
5. **Validation layer** - Input validation middleware
|
||||
6. **Database error handling** - Specifikus error típusok (not found, conflict, stb.)
|
||||
|
||||
---
|
||||
|
||||
## ✨ Véglegesen elért állapot: 8.5/10
|
||||
|
||||
**Az eredeti 6/10-ről:**
|
||||
- ✅ DRY elvek betartása
|
||||
- ✅ Architektúra szeparáció (FileRepository)
|
||||
- ✅ Teljesítmény (Template cache)
|
||||
- ✅ Interface konvenciók
|
||||
- ✅ Error handling javítás
|
||||
- ✅ Dependency Injection
|
||||
|
||||
**Még nem teljesen befejezett:**
|
||||
- ⚠️ Config struct (de nem kritikus)
|
||||
- ⚠️ Komprehenzív test coverage
|
||||
- ⚠️ Logger abstraction
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
PATH
|
||||
remote: ../libs/ruby/warp_engine
|
||||
specs:
|
||||
warp_engine (0.7.0)
|
||||
warp_engine (0.8.0)
|
||||
apipie-rails
|
||||
blueprinter
|
||||
rails (>= 8.0)
|
||||
|
||||
@@ -4,7 +4,7 @@ ActiveAdmin.register Image do
|
||||
menu parent: "🌀 WarpEngine", priority: 5, label: "🖼️ Images"
|
||||
|
||||
used_ids = -> {
|
||||
WarpEngine::SoftwareImage.unscope(:order).distinct.pluck(:image_id) +
|
||||
WarpEngine::SoftwareImage.distinct.pluck(:image_id) +
|
||||
Member.where.not(image_id: nil).distinct.pluck(:image_id)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ ActiveAdmin.register Image do
|
||||
column :content_type
|
||||
column(:preview) do |img|
|
||||
if File.exist?(img.file_path)
|
||||
image_tag("/api/image/#{img.id}", style: "max-height:60px;max-width:120px;object-fit:contain;")
|
||||
image_tag(img.url, style: "max-height:60px;max-width:120px;object-fit:contain;")
|
||||
end
|
||||
end
|
||||
column(:usage) do |img|
|
||||
@@ -52,7 +52,7 @@ ActiveAdmin.register Image do
|
||||
row :content_type
|
||||
row(:preview) do |img|
|
||||
if File.exist?(img.file_path)
|
||||
image_tag("/api/image/#{img.id}", style: "max-height:300px;max-width:100%;object-fit:contain;")
|
||||
image_tag(img.url, style: "max-height:300px;max-width:100%;object-fit:contain;")
|
||||
else
|
||||
"File not found on disk"
|
||||
end
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,30 +1,8 @@
|
||||
class ApiController < ActionController::API
|
||||
include WarpEngine::ApiErrorRendering
|
||||
|
||||
resource_description do
|
||||
api_version "1.0"
|
||||
formats [ "json" ]
|
||||
end
|
||||
|
||||
rescue_from StandardError do |e|
|
||||
Rails.logger.error("[#{self.class.name}] #{e.class}: #{e.message}")
|
||||
render json: { error: "Internal server error" }, status: :internal_server_error
|
||||
end
|
||||
|
||||
rescue_from ActiveRecord::RecordNotFound do |e|
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
end
|
||||
|
||||
rescue_from Errno::ENOENT do |e|
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
end
|
||||
|
||||
rescue_from ArgumentError do |e|
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_mime(path)
|
||||
ext = File.extname(path.to_s).delete_prefix(".")
|
||||
Mime::Type.lookup_by_extension(ext) || "application/octet-stream"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include SyncsAdminCookie
|
||||
|
||||
protect_from_forgery with: :exception
|
||||
end
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
module SyncsAdminCookie
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_action :sync_admin_cookie
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_admin_cookie
|
||||
if current_admin_user
|
||||
cookies[:is_admin] = { value: "1", httponly: false, same_site: :lax, path: "/" }
|
||||
elsif cookies[:is_admin]
|
||||
cookies.delete(:is_admin, path: "/")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
class Image < ApplicationRecord
|
||||
def self.upload_path
|
||||
ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
|
||||
Rails.configuration.x.images.container_path
|
||||
end
|
||||
|
||||
has_many :software_images, class_name: "WarpEngine::SoftwareImage", dependent: :restrict_with_error
|
||||
@@ -10,7 +10,8 @@ class Image < ApplicationRecord
|
||||
|
||||
attr_accessor :file_upload
|
||||
|
||||
before_save :process_upload, if: -> { file_upload.present? }
|
||||
before_validation :assign_upload_attributes, if: -> { file_upload.present? }
|
||||
after_commit :store_upload_file, on: [ :create, :update ], if: -> { file_upload.present? }
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[content_type created_at deleted_at filename id original_filename updated_at]
|
||||
@@ -20,14 +21,18 @@ class Image < ApplicationRecord
|
||||
File.join(self.class.upload_path, filename.to_s)
|
||||
end
|
||||
|
||||
def url = "/api/image/#{id}"
|
||||
|
||||
private
|
||||
|
||||
def process_upload
|
||||
FileUtils.mkdir_p(self.class.upload_path)
|
||||
def assign_upload_attributes
|
||||
self.original_filename = file_upload.original_filename
|
||||
self.content_type = file_upload.content_type.presence || "application/octet-stream"
|
||||
ext = File.extname(file_upload.original_filename)
|
||||
self.filename = "#{SecureRandom.uuid}#{ext}"
|
||||
self.filename = "#{SecureRandom.uuid}#{File.extname(file_upload.original_filename)}"
|
||||
end
|
||||
|
||||
def store_upload_file
|
||||
FileUtils.mkdir_p(self.class.upload_path)
|
||||
IO.copy_stream(file_upload.to_io, file_path)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
module Rss
|
||||
class BlogFeed < Feed
|
||||
private
|
||||
|
||||
def title = "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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
|
||||
@@ -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('New name:','mygame-1.0.zip')")
|
||||
expect(response.body).to include("Delete \\'mygame-1.0.zip\\'")
|
||||
end
|
||||
|
||||
it "shows the download count next to a file that was downloaded" do
|
||||
create(:download, file_path: "mygame-1.0.zip")
|
||||
|
||||
get "/admin/files"
|
||||
|
||||
expect(response.body).to include("mygame-1.0.zip")
|
||||
end
|
||||
|
||||
it "creates a folder and returns to the directory it was created in" do
|
||||
post "/admin/files/mkdir", params: { dir: "mygame-1.0", name: "docs" }
|
||||
|
||||
expect(response).to redirect_to("/admin/files?dir=mygame-1.0")
|
||||
expect(File.directory?(File.join(container, "mygame-1.0", "docs"))).to be(true)
|
||||
end
|
||||
|
||||
it "reports a failed folder creation instead of raising" do
|
||||
post "/admin/files/mkdir", params: { dir: "mygame-1.0", name: "" }
|
||||
|
||||
expect(response).to redirect_to("/admin/files?dir=mygame-1.0")
|
||||
expect(flash[:alert]).to include("Failed")
|
||||
end
|
||||
|
||||
it "deletes a file and returns to its parent directory" do
|
||||
File.write(File.join(container, "mygame-1.0", "readme.txt"), "hi")
|
||||
|
||||
delete "/admin/files/delete", params: { path: "mygame-1.0/readme.txt" }
|
||||
|
||||
expect(response).to redirect_to("/admin/files?dir=mygame-1.0")
|
||||
expect(File.exist?(File.join(container, "mygame-1.0", "readme.txt"))).to be(false)
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -14,8 +14,8 @@ module WarpEngine
|
||||
property :desc, String, desc: "Short description"
|
||||
property :story, String, desc: "Long description / story"
|
||||
property :license, String, desc: "License type"
|
||||
property :platform, String, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)"
|
||||
property :status, String, desc: "Status"
|
||||
property :platform, String, desc: "Platform (one of WarpEngine::Platform::NAMES)"
|
||||
property :status, String, desc: "Status (development, demo, released, archived)"
|
||||
property :highlighted, :boolean, desc: "Highlighted flag"
|
||||
property :imageUrl, String, desc: "Default image URL"
|
||||
end
|
||||
@@ -33,7 +33,7 @@ module WarpEngine
|
||||
property :totalDownloads, Integer, desc: "Total download count across all releases"
|
||||
end
|
||||
error code: 404, desc: "No highlighted software found"
|
||||
def index
|
||||
def show
|
||||
result = WarpEngine::SoftwareHighlightedService.new.index(subject: current_subject)
|
||||
if result
|
||||
render json: result
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user