Author SHA1 Message Date
mr.zero 0c981b4590 build endpoints
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-05 20:12:35 +02:00
mr.zero f8ff7c394c Drop the unused UPDATE_SECRET_SOURCE env plumbing 2026-08-05 19:01:44 +02:00
mr.zero 3ade17ce9a enable db secret mode 2026-08-05 18:55:32 +02:00
mr.zero d0ce26c0a3 Rename the update auth switch to application_token_source and drop its ENV default
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-05 18:53:04 +02:00
mr.zero b2c780b697 db mode for update secrets
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-05 18:46:49 +02:00
mr.zero a217bcc14e Add DB-backed application tokens for the update endpoint
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-05 18:30:20 +02:00
mr.zero b69eff7866 Drop the date and new badge from the engine cards
Engines are evergreen products, not dated posts — the meta row, its
styles, the unused isNew wiring and the new-badge translations go.
2026-08-05 07:47:28 +02:00
mr.zero 7737a59850 Compact the top navigation and translate Engines as Motorok
The nav has too many items for the old spacing: smaller type, tighter
gaps, icons only on xl+ screens, and the hamburger now takes over below
lg (the desktop row did not fit between md and lg anymore).

The Hungarian engine strings drop the hyphenated loanword forms:
Engine-ek -> Motorok, Saját Engine-jeink -> Saját motorjaink.
2026-08-05 07:44:06 +02:00
mr.zero 1501f7f0b6 Drop the engines RSS feed
The engines listing is a handful of curated pages, not a stream — no feed
needed. Removes the route, controller action, RssService#engines_feed and
the footer link. The WikiService#pages alias stays (blog/howtos feeds use
it).
2026-08-05 07:40:25 +02:00
mr.zero a15f0ce24b Point the engine Explore action at the git repository
The engine pages' repo metadata (new in the wiki pages API) drives the
Explore button and card title links, with the wiki page as fallback. The
never-deployed /engines/:slug detail page and its store/api plumbing are
gone, and the engines RSS feed links to the repos too.
2026-08-05 07:36:58 +02:00
mr.zero f2c07c83c5 Serve engine-tagged wiki pages on a new /engines section
Frontend: /engines index + /engines/:slug detail routes, nav menu item and
en/hu translations. Engine pages are few, so the index uses an emphasized
poster-style design (dark slate, emerald accents, numbered full-width cards
with content preview) instead of the blog/howtos layouts. Slugs are the last
wiki path segment, since engine pages live scattered in the wiki tree.

API: /api/rss/engines feed linking to the site's engine pages, and a
WikiService#pages alias for #index — RssService called the alias-less name,
so the blog and howtos feeds were raising NoMethodError.
2026-08-05 07:29:07 +02:00
mr.zero 2358fe22ab Fix Release date field casing in CatalogShowPage
release.UpdatedAt does not exist on the Release interface (or in the API
response) — the release dates rendered as invalid values and vue-tsc failed
the build.
2026-08-05 07:28:47 +02:00
mr.zero 6833ab2070 Add a runnable example compose stack for WarpEngine
ci/woodpecker/push/woodpecker Pipeline was successful
examples/compose boots everything the engine's workflow assumes: mysql, a
minimal Rails host consuming the engine as a path gem, an SSH drop area
sharing the softwares volume with the app, and — behind the ci profile —
gitea plus woodpecker (agent attached to the stack network so pipeline
steps reach droparea/app by service name).

host_app doubles as a reference for a brand-new host: Gemfile, the two
initializers, apipie + engine mounts in routes.rb; on first boot the
entrypoint runs the install generator and db:prepare.

The README gains a detailed bring-up walkthrough: quickstart, publishing
a release by hand over scp + /update (verified end to end from a clean
slate), and the full gitea/woodpecker OAuth wiring.
2026-08-05 06:56:12 +02:00
mr.zero b09610bc33 Rewrite WarpEngine README for standalone consumers
ci/woodpecker/push/woodpecker Pipeline was successful
The README is what the tools/warp_engine mirror shows: present the engine as
a standalone product installed from git or the gem registry, with the
monorepo workflow reduced to a short Development note.
2026-08-04 20:16:46 +02:00
mr.zeroandClaude Fable 5 211dcccbf3 Trim whitespace from the forge token before use
ci/woodpecker/manual/woodpecker Pipeline was successful
A trailing newline pasted into the Woodpecker secret broke the push URL
("credential url cannot be parsed"); strip all whitespace from the token in
both the mirror and the gem-publish steps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 20:07:13 +02:00
mr.zeroandClaude Fable 5 b1b619befa Allow manual pipeline runs for the split mirror
ci/woodpecker/manual/woodpecker Pipeline failed
Path-filtered push events hide the workflow for unrelated pushes and manual
restarts have no changed-files list; add event: manual so the mirror can be
triggered from the Woodpecker UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 20:03:35 +02:00
mr.zeroandClaude Fable 5 af779b0988 Fix YAML parse error in the gem-publish step
The credentials printf line contains ': ' which YAML reads as a mapping;
use a literal block scalar for that command.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:57:15 +02:00
mr.zeroandClaude Fable 5 b4d0198d2a Add CI split-mirror pipeline for WarpEngine
- .woodpecker.yaml: on master pushes touching libs/ruby/warp_engine, split the
  subtree and force-push it to the read-only tools/warp_engine mirror; on
  warp_engine-v* tags, build and push the gem to the Forgejo rubygems registry
- engine README documents the monorepo-first workflow and the mirror

Requires a `forge_token` Woodpecker secret (repository:write + package:write).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:47:15 +02:00
62 changed files with 1879 additions and 171 deletions
+57
View File
@@ -0,0 +1,57 @@
# Read-only split mirror: a libs/ruby/warp_engine alkönyvtárat kitükrözi a
# tools/warp_engine repóba (fejlesztés itt, a monorepóban történik; a tükör
# csak publikálásra való). Tag-elt release (warp_engine-v*) esetén a gem a
# Forgejo rubygems registry-be is felmegy.
#
# Szükséges Woodpecker secret: forge_token — Forgejo access token
# repository:write (tools/warp_engine) és package:write joggal.
when:
- event: push
branch: master
path: "libs/ruby/warp_engine/**"
- event: manual
- event: tag
ref: refs/tags/warp_engine-v*
clone:
git:
image: woodpeckerci/plugin-git
settings:
partial: false
depth: 0 # a subtree splithez teljes history kell
steps:
split-mirror:
image: alpine/git
environment:
FORGE_TOKEN:
from_secret: forge_token
commands:
- apk add --no-cache git-subtree
- git subtree split --prefix=libs/ruby/warp_engine HEAD -b warp-engine-split
# a secretbe másolt token végén lehet sortörés — levágjuk
- TOKEN="$$(printf '%s' "$${FORGE_TOKEN}" | tr -d '[:space:]')"
- git push --force "https://ci:$${TOKEN}@git.teletypegames.org/tools/warp_engine.git" warp-engine-split:master
when:
- event: push
branch: master
- event: manual
publish-gem:
image: ruby:3.3-slim
environment:
FORGE_TOKEN:
from_secret: forge_token
commands:
- cd libs/ruby/warp_engine
- gem build warp_engine.gemspec
- mkdir -p ~/.gem
- TOKEN="$$(printf '%s' "$${FORGE_TOKEN}" | tr -d '[:space:]')"
- |
printf -- '---\n:https://git.teletypegames.org/api/packages/tools/rubygems: Bearer %s\n' "$${TOKEN}" > ~/.gem/credentials
- chmod 600 ~/.gem/credentials
- gem push --host https://git.teletypegames.org/api/packages/tools/rubygems warp_engine-*.gem
when:
- event: tag
ref: refs/tags/warp_engine-v*
+1 -1
View File
@@ -17,7 +17,7 @@ The API is split in two layers:
- **WarpEngine** (`libs/ruby/warp_engine`) owns the software catalog: models
(softwares, releases, release assets, images, platform links, download stats),
the CI-callable `/update` endpoint, the public read-only JSON API
the CI-callable `/build/*` publishing endpoints, the public read-only JSON API
(`/api/software*`, `/api/builds*`, `/api/image`, `/api/download`, `/file/*`)
and the catalog ActiveAdmin resources. See its [README](libs/ruby/warp_engine/README.md).
- **The host app** (`apps/api`) owns everything TTG-specific: members, events,
@@ -4,7 +4,7 @@ class Api::WikiController < ApiController
end
api :GET, "/api/wiki/pages", "List wiki pages filtered by tag"
param :tag, String, required: false, desc: "Filter by tag (blog, howto)"
param :tag, String, required: false, desc: "Filter by tag (blog, howto, engine)"
param :limit, :number, required: false, desc: "Limit number of results"
param :body, String, required: false, desc: "Include body content (1 = yes)"
returns code: 200, desc: "Wiki pages response" do
@@ -20,6 +20,7 @@ class Api::WikiController < ApiController
property :locale, String, desc: "Locale code"
property :route, String, desc: "URL slug"
property :tags, Array, of: String, desc: "Tags"
property :repo, String, desc: "Git repository URL (from page metadata, engines)"
property :render, String, desc: "Rendered HTML content"
property :content, String, desc: "Raw markdown content"
end
+3
View File
@@ -43,4 +43,7 @@ class WikiService
rescue StandardError => e
{ "tag" => tag, "count" => 0, "pages" => [], "error" => e.message }
end
# Az RSS feedek ezen a néven hívják.
alias_method :pages, :index
end
@@ -2,6 +2,12 @@
# értékadás (nem <<), hogy idempotens legyen.
Rails.application.config.to_prepare do
WarpEngine.configure do |c|
# A /build/* DB-tokenjeinek tulajdonosa. A :database módra váltás
# (c.application_token_source = :database) csak azután jöhet, hogy a CI már
# DB-tokent használ — az átkapcsolás azonnal érvényteleníti az UPDATE_SECRET-et.
c.application_token_source = :database
c.application_token_owner_class = "AdminUser"
c.image_owners = [
{
label: "member",
+1 -1
View File
@@ -14,7 +14,7 @@ Rails.application.routes.draw do
end
# Utolsó sor: a host route-jai nyernek, a katalógus-útvonalakat
# (/api/software*, /api/builds*, /api/image, /api/download, /update, /file/*)
# (/api/software*, /api/builds*, /api/image, /api/download, /build/*, /file/*)
# az engine adja.
mount WarpEngine::Engine => "/"
end
+22 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
ActiveRecord::Schema[8.1].define(version: 2026_08_05_000003) do
create_table "admin_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "deleted_at", precision: 3
@@ -27,6 +27,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
t.index ["reset_password_token"], name: "index_admin_users_on_reset_password_token", unique: true
end
create_table "application_tokens", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
t.datetime "expires_at", precision: 3
t.datetime "last_used_at", precision: 3
t.string "name", limit: 128, null: false
t.bigint "owner_id", null: false, unsigned: true
t.string "owner_type", limit: 128, null: false
t.json "scopes"
t.string "token_digest", limit: 64, null: false
t.string "token_prefix", limit: 12, null: false
t.boolean "unrestricted", default: false, null: false
t.datetime "updated_at", precision: 3
t.index ["deleted_at"], name: "idx_application_tokens_deleted_at"
t.index ["owner_type", "owner_id"], name: "idx_application_tokens_owner"
t.index ["token_digest"], name: "idx_application_tokens_token_digest", unique: true
end
create_table "downloads", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
@@ -232,6 +250,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
t.boolean "highlighted", default: false
t.string "license", limit: 128
t.string "name", limit: 128
t.bigint "owner_id", unsigned: true
t.string "owner_type", limit: 128
t.string "platform", limit: 128
t.string "site"
t.string "status", limit: 20, default: "development"
@@ -240,6 +260,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_04_000002) do
t.datetime "updated_at", precision: 3
t.index ["deleted_at"], name: "idx_softwares_deleted_at"
t.index ["name"], name: "idx_softwares_name", unique: true
t.index ["owner_type", "owner_id"], name: "idx_softwares_owner"
end
add_foreign_key "admin_users", "members"
+17 -1
View File
@@ -15,6 +15,7 @@ interface RawWikiPage {
updatedAt: string
createdAt: string
locale: string
repo?: string | null
}
async function fetchPages(
@@ -67,6 +68,21 @@ const getBlogPage = async (slug: string): Promise<WikiPageContent | null> => {
}
}
const listEnginePages = async (): Promise<WikiPageWithContent[]> => {
const pages = await fetchPages('engine', { body: true })
return pages.map((p): WikiPageWithContent => ({
id: p.id,
path: p.path,
title: p.title || p.path,
description: p.description ?? '',
content: p.content ?? '',
updatedAt: p.updatedAt,
createdAt: p.createdAt,
locale: p.locale,
repo: p.repo ?? null,
}))
}
const listHowtoPages = async (): Promise<WikiPage[]> => {
const pages = await fetchPages('howto', { limit: 30 })
return pages.map((p): WikiPage => ({
@@ -81,4 +97,4 @@ const listHowtoPages = async (): Promise<WikiPage[]> => {
}
export { WIKI_BASE }
export default { listBlogPages, getBlogPage, listHowtoPages }
export default { listBlogPages, getBlogPage, listHowtoPages, listEnginePages }
+12
View File
@@ -4,6 +4,7 @@ export default {
catalog: 'Catalog',
blog: 'Blog',
howtos: 'How-tos',
engines: 'Engines',
code: 'Code',
team: 'Team',
contact: 'Contact us',
@@ -168,6 +169,17 @@ export default {
title: 'Our Team',
subtitle: 'Meet the brilliant minds behind Teletype Games.',
},
engines: {
badge: 'In-house Tech',
titleLead: 'Our',
titleAccent: 'Engines',
subtitle: 'The engines and frameworks we build, maintain and ship our games and services on.',
errorTitle: 'Failed to connect to Wiki',
noPagesTitle: 'No engines found',
noPagesDesc: "It seems like there aren't any engine pages available on the wiki at the moment.",
explore: 'Explore',
openWiki: 'Open in Wiki',
},
howtos: {
badge: 'Knowledge Base',
title: 'Tech HowTo Center',
+12
View File
@@ -4,6 +4,7 @@ export default {
catalog: 'Katalógus',
blog: 'Blog',
howtos: 'Hogyan csináld',
engines: 'Motorok',
code: 'Kód',
team: 'Csapat',
contact: 'Kapcsolat',
@@ -168,6 +169,17 @@ export default {
title: 'Csapatunk',
subtitle: 'Ismerd meg a Teletype Games mögött álló zseniális elméket.',
},
engines: {
badge: 'Saját technológia',
titleLead: 'Saját',
titleAccent: 'motorjaink',
subtitle: 'Az általunk épített és karbantartott motorok és keretrendszerek, amelyekre a játékaink és szolgáltatásaink épülnek.',
errorTitle: 'Nem sikerült csatlakozni a Wikihez',
noPagesTitle: 'Nem találhatók motorok',
noPagesDesc: 'Úgy tűnik, jelenleg nincsenek motoroldalak a wikin.',
explore: 'Felfedezés',
openWiki: 'Megnyitás a Wikiben',
},
howtos: {
badge: 'Tudásbázis',
title: 'Tech HowTo Központ',
+12 -5
View File
@@ -9,6 +9,7 @@
<RouterLink to="/catalog" class="nav-link"><i class="fa-solid fa-gamepad nav-icon"></i>{{ t('nav.catalog') }}</RouterLink>
<RouterLink to="/blog" class="nav-link"><i class="fa-solid fa-newspaper nav-icon"></i>{{ t('nav.blog') }}</RouterLink>
<RouterLink to="/howtos" class="nav-link"><i class="fa-solid fa-lightbulb nav-icon"></i>{{ t('nav.howtos') }}</RouterLink>
<RouterLink to="/engines" class="nav-link"><i class="fa-solid fa-cubes nav-icon"></i>{{ t('nav.engines') }}</RouterLink>
<RouterLink to="/code" class="nav-link"><i class="fa-solid fa-code nav-icon"></i>{{ t('nav.code') }}</RouterLink>
<RouterLink to="/team" class="nav-link"><i class="fa-solid fa-users nav-icon"></i>{{ t('nav.team') }}</RouterLink>
<RouterLink to="/contact" class="nav-link"><i class="fa-solid fa-envelope nav-icon"></i>{{ t('nav.contact') }}</RouterLink>
@@ -33,6 +34,7 @@
<RouterLink to="/catalog" class="nav-mobile-link"><i class="fa-solid fa-gamepad nav-icon"></i>{{ t('nav.catalog') }}</RouterLink>
<RouterLink to="/blog" class="nav-mobile-link"><i class="fa-solid fa-newspaper nav-icon"></i>{{ t('nav.blog') }}</RouterLink>
<RouterLink to="/howtos" class="nav-mobile-link"><i class="fa-solid fa-lightbulb nav-icon"></i>{{ t('nav.howtos') }}</RouterLink>
<RouterLink to="/engines" class="nav-mobile-link"><i class="fa-solid fa-cubes nav-icon"></i>{{ t('nav.engines') }}</RouterLink>
<RouterLink to="/code" class="nav-mobile-link"><i class="fa-solid fa-code nav-icon"></i>{{ t('nav.code') }}</RouterLink>
<RouterLink to="/team" class="nav-mobile-link"><i class="fa-solid fa-users nav-icon"></i>{{ t('nav.team') }}</RouterLink>
<RouterLink to="/contact" class="nav-mobile-link"><i class="fa-solid fa-envelope nav-icon"></i>{{ t('nav.contact') }}</RouterLink>
@@ -114,13 +116,13 @@ function switchLocale(lang: string) {
@apply text-xl font-bold;
}
.nav-desktop {
@apply hidden md:flex items-center space-x-4;
@apply hidden lg:flex items-center space-x-1;
}
.nav-link {
@apply p-2 hover:text-purple-300 transition-colors duration-200;
@apply px-2.5 py-2 text-sm hover:text-purple-300 transition-colors duration-200 whitespace-nowrap;
}
.nav-link-admin {
@apply p-2 hover:text-yellow-300 transition-colors duration-200;
@apply px-2.5 py-2 text-sm hover:text-yellow-300 transition-colors duration-200 whitespace-nowrap;
}
.locale-switcher {
@apply flex items-center gap-1 text-sm font-bold ml-2 border-l border-gray-600 pl-4;
@@ -131,14 +133,19 @@ function switchLocale(lang: string) {
.locale-separator {
@apply text-gray-600;
}
/* Az ikonok viszik a legtöbb helyet a sok menüpont mellett — desktopon csak
extra széles kijelzőn jelennek meg, a mobil menüben mindig. */
.nav-desktop .nav-icon {
@apply hidden xl:inline-block;
}
.nav-icon {
@apply mr-1.5 text-xs opacity-70;
}
.hamburger-btn {
@apply md:hidden p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 rounded-md;
@apply lg:hidden p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 rounded-md;
}
.nav-mobile {
@apply md:hidden absolute top-full left-0 w-full bg-gray-800 flex flex-col items-center py-4 space-y-2 z-50;
@apply lg:hidden absolute top-full left-0 w-full bg-gray-800 flex flex-col items-center py-4 space-y-2 z-50;
}
.nav-mobile-link {
@apply block p-2 w-full text-center hover:bg-gray-700 transition-colors duration-200;
@@ -6,6 +6,7 @@ export interface WikiPage {
updatedAt: string
createdAt: string
locale: string
repo?: string | null
}
export interface WikiPageWithContent extends WikiPage {
@@ -96,7 +96,7 @@
<span class="latest-release-badge">{{ t('catalogShow.latestStable') }}</span>
</div>
<h2 class="latest-release-version">{{ latestStable.version }}</h2>
<p class="latest-release-date"><i class="fa-solid fa-calendar mr-1"></i>{{ t('catalogShow.released') }} {{ formatDateTime(latestStable.UpdatedAt) }}</p>
<p class="latest-release-date"><i class="fa-solid fa-calendar mr-1"></i>{{ t('catalogShow.released') }} {{ formatDateTime(latestStable.updatedAt) }}</p>
</div>
<a v-if="latestStable.htmlFolderPath" :href="latestStable.htmlFolderPath" target="_blank" class="latest-release-play-btn"><i class="fa-solid fa-play mr-2"></i>{{ t('catalogShow.playNow') }}</a>
@@ -155,7 +155,7 @@
</div>
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="release-play-link">{{ t('catalogShow.play') }}</a>
</td>
<td class="release-date-col">{{ formatDateTime(release.UpdatedAt) }}</td>
<td class="release-date-col">{{ formatDateTime(release.updatedAt) }}</td>
</tr>
<tr v-if="tableAssets(release).length">
<td colspan="2" class="px-8 pb-4 pt-0">
@@ -185,7 +185,7 @@
<div class="dev-release-version">{{ release.version }}</div>
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="release-play-link">{{ t('catalogShow.play') }}</a>
</td>
<td class="release-date-col">{{ formatDateTime(release.UpdatedAt) }}</td>
<td class="release-date-col">{{ formatDateTime(release.updatedAt) }}</td>
</tr>
<tr v-if="tableAssets(release).length" class="bg-yellow-50/10">
<td colspan="2" class="px-8 pb-4 pt-0">
@@ -218,7 +218,7 @@
<div class="mobile-release-header">
<div>
<div class="mobile-release-version">{{ release.version }}</div>
<div class="mobile-release-date">{{ formatDateTime(release.UpdatedAt) }}</div>
<div class="mobile-release-date">{{ formatDateTime(release.updatedAt) }}</div>
</div>
<span v-if="release.version.startsWith('dev-')" class="dev-badge">Dev</span>
</div>
@@ -0,0 +1,147 @@
<template>
<div class="engines-container">
<header class="hero-section-slate">
<div class="engines-header-decor">
<div class="hero-decor-blob -top-24 -left-24 h-96 w-96 bg-emerald-600"></div>
<div class="hero-decor-blob -bottom-24 -right-24 h-96 w-96 bg-teal-600"></div>
</div>
<div class="hero-container">
<div class="hero-badge">{{ t('engines.badge') }}</div>
<h1 class="hero-title">
{{ t('engines.titleLead') }} <span class="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-300">{{ t('engines.titleAccent') }}</span>
</h1>
<p class="hero-subtitle mb-10">{{ t('engines.subtitle') }}</p>
</div>
</header>
<main class="engines-main">
<div v-if="error" class="error-banner" role="alert">
<i class="fa-solid fa-triangle-exclamation text-2xl text-yellow-500"></i>
<div>
<h3 class="error-banner-title">{{ t('engines.errorTitle') }}</h3>
<p class="error-banner-desc">{{ error }}</p>
</div>
</div>
<SkeletonCard v-else-if="loading" :count="3" />
<div v-else-if="enginePages.length === 0" class="empty-state">
<div class="empty-state-icon"><i class="fa-solid fa-inbox"></i></div>
<h2 class="empty-state-title">{{ t('engines.noPagesTitle') }}</h2>
<p class="empty-state-desc">{{ t('engines.noPagesDesc') }}</p>
</div>
<div v-else class="engine-list">
<article v-for="(page, index) in enginePages" :key="page.id" class="engine-card group">
<div class="engine-card-index">{{ String(index + 1).padStart(2, '0') }}</div>
<div class="engine-card-body">
<h2 class="engine-card-title">
<a :href="exploreUrl(page)" target="_blank" rel="noopener">{{ page.title }}</a>
</h2>
<p v-if="page.description" class="engine-card-desc">{{ page.description }}</p>
<p v-if="page.content" class="engine-card-preview">{{ getCleanPreview(page.content) }}</p>
<div class="engine-card-actions">
<a :href="exploreUrl(page)" target="_blank" rel="noopener" class="engine-explore-btn">
<i class="fa-solid fa-code-branch text-sm"></i>
{{ t('engines.explore') }}
<i class="fa-solid fa-arrow-right text-sm"></i>
</a>
<a :href="`${WIKI_BASE}/${page.path}`" target="_blank" rel="noopener" class="engine-wiki-link">
<i class="fa-solid fa-book mr-1"></i>{{ t('engines.openWiki') }}
</a>
</div>
</div>
</article>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { WIKI_BASE } from '../../api/wiki.api'
import { useEnginesStore } from '../../stores/engines.store'
import type { WikiPageWithContent } from '../../lib/interfaces/wiki.interface'
import SkeletonCard from '../../components/SkeletonCard.vue'
const { t } = useI18n()
const store = useEnginesStore()
const { pages: enginePages, loading, error } = storeToRefs(store)
const { getCleanPreview } = store
// Explore points at the engine's git repository (from wiki metadata);
// pages without one fall back to their wiki page.
const exploreUrl = (page: WikiPageWithContent): string =>
page.repo || `${WIKI_BASE}/${page.path}`
onMounted(() => store.fetch())
</script>
<style scoped>
.engines-container {
@apply bg-slate-950 min-h-screen pb-24;
}
.engines-header-decor {
@apply absolute inset-0 opacity-30;
}
.engines-main {
@apply max-w-5xl mx-auto px-4 md:px-8 mt-16 relative z-10;
}
.banner-base {
@apply max-w-7xl mx-auto border-l-4 p-6 rounded-r-xl shadow-lg mb-12 flex items-start gap-4;
}
.error-banner { @apply banner-base bg-red-50 border-red-500; }
.error-banner-title { @apply text-red-800 font-bold text-lg; }
.error-banner-desc { @apply text-red-700 mt-1; }
.empty-state {
@apply max-w-7xl mx-auto bg-slate-900 rounded-2xl shadow-xl p-12 text-center border border-slate-800;
}
.empty-state-icon { @apply text-6xl mb-4; }
.empty-state-title { @apply text-2xl font-bold text-white mb-2; }
.empty-state-desc { @apply text-slate-400 max-w-md mx-auto; }
/* Few engines, so every one of them gets a full-width, poster-like card. */
.engine-list {
@apply flex flex-col gap-10;
}
.engine-card {
@apply relative overflow-hidden bg-slate-900 rounded-3xl border border-slate-800 border-l-4 border-l-emerald-500 shadow-2xl transition-all hover:border-l-teal-300 hover:-translate-y-1;
}
.engine-card-index {
@apply absolute -top-6 right-4 text-[9rem] leading-none font-black text-slate-800/60 select-none pointer-events-none transition-colors;
}
.engine-card:hover .engine-card-index {
@apply text-slate-800;
}
.engine-card-body {
@apply relative p-8 md:p-12;
}
.engine-card-title {
@apply text-3xl md:text-5xl font-black text-white mb-4 leading-tight;
}
.engine-card-title a {
@apply hover:text-emerald-400 transition-colors;
}
.engine-card-desc {
@apply text-lg md:text-xl text-emerald-100/90 font-semibold mb-3 leading-relaxed max-w-3xl;
}
.engine-card-preview {
@apply text-base text-slate-400 leading-relaxed mb-8 max-w-3xl;
}
.engine-card-actions {
@apply flex flex-wrap items-center gap-6;
}
.engine-explore-btn {
@apply inline-flex items-center gap-2 bg-emerald-500 text-slate-950 px-6 py-3 rounded-xl font-bold hover:bg-emerald-400 transition-all shadow-lg shadow-emerald-900/40 group-hover:translate-x-1;
}
.engine-wiki-link {
@apply text-slate-400 font-semibold hover:text-white transition-colors;
}
</style>
@@ -0,0 +1,5 @@
import type { RouteRecordRaw } from 'vue-router'
export const enginesRouter: RouteRecordRaw[] = [
{ path: '/engines', name: 'enginesIndex', component: () => import('../page/engines/EnginesIndexPage.vue') },
]
+2
View File
@@ -4,6 +4,7 @@ import { blogRouter } from './blog.router'
import { catalogRouter } from './catalog.router'
import { codeRouter } from './code.router'
import { contactRouter } from './contact.router'
import { enginesRouter } from './engines.router'
import { howtosRouter } from './howtos.router'
import { teamRouter } from './team.router'
import { buildsRouter } from './builds.router'
@@ -16,6 +17,7 @@ export const router = createRouter({
...catalogRouter,
...codeRouter,
...contactRouter,
...enginesRouter,
...howtosRouter,
...teamRouter,
...buildsRouter,
+23
View File
@@ -0,0 +1,23 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import wikiApi from '../api/wiki.api'
import { useLoadable } from '../composables/useLoadable'
import type { WikiPageWithContent } from '../lib/interfaces/wiki.interface'
export const useEnginesStore = defineStore('engines', () => {
const pages = ref<WikiPageWithContent[]>([])
const { loading, error, withCache, invalidate } = useLoadable()
async function fetch() {
await withCache(async () => {
pages.value = await wikiApi.listEnginePages()
})
}
function getCleanPreview(content: string): string {
if (!content) return ''
return content.replace(/[#*`_[\]()>|-]/g, '').replace(/\s+/g, ' ').trim().slice(0, 260) + '...'
}
return { pages, loading, error, fetch, getCleanPreview, invalidate }
})
+1 -1
View File
@@ -178,7 +178,7 @@ services:
- "traefik.http.middlewares.api-cors.headers.accesscontrolallowheaders=Content-Type,Authorization,Accept,X-Requested-With"
- "traefik.http.middlewares.api-cors.headers.accesscontrolmaxage=3600"
- "traefik.http.middlewares.api-cors.headers.addvaryheader=true"
- "traefik.http.routers.api.rule=Host(`${WEBAPP_DOMAIN}`) && (PathPrefix(`/api`) || PathPrefix(`/file`) || PathPrefix(`/update`) || PathPrefix(`/admin`))"
- "traefik.http.routers.api.rule=Host(`${WEBAPP_DOMAIN}`) && (PathPrefix(`/api`) || PathPrefix(`/file`) || PathPrefix(`/build`) || PathPrefix(`/admin`))"
- "traefik.http.routers.api.entrypoints=web"
- "traefik.http.routers.api.priority=10"
- "traefik.http.routers.api.middlewares=api-cors"
+272 -46
View File
@@ -1,89 +1,315 @@
# WarpEngine
Mountable Rails engine: a retro software catalog with a CI-pipeline-callable
release updater, a public read-only JSON API, and ActiveAdmin resources that
load into the host application's admin.
A mountable Rails engine that turns any Rails application into a retro
software catalog: catalog models, a CI-pipeline-callable release updater, a
public read-only JSON API, and optional ActiveAdmin resources that plug into
your app's existing admin.
## What it provides
Repository: `https://git.teletypegames.org/tools/warp_engine`
- **Models**: `Software`, `Release`, `ReleaseAsset`, `ExternalLink`,
`PlatformLink`, `Image`, `SoftwareImage`, `Download` (all under
`WarpEngine::`, with unprefixed table names)
- **Updater**: `GET /update?platform=&name=&version=` (auth via the
`X-Update-Secret` header or `?secret=`) — CI copies build artifacts under
`file_container_path` using the `<name>-<version>*` naming convention, then
calls the endpoint; the updater extracts archives, parses metadata, and
upserts the Software/Release/ReleaseAsset/ExternalLink records.
Supported platforms: tic80, ebitengine, love, c64, godot, bevy, phaser.
- **Public API**: `/api/software`, `/api/software/highlighted`, `/api/builds`,
`/api/softwares/:name/builds`, `/api/image/:id`, `/api/download?path=`,
`/file/*path`
- **Admin**: ActiveAdmin resource files (softwares with a 3-level nested form,
releases, external links, platform links, images with orphan management, a
Files file-manager page with picker mode, download stats) — loaded into the
host's single ActiveAdmin instance.
## Features
- **Catalog domain**: `Software`, `Release`, `ReleaseAsset`, `ExternalLink`,
`PlatformLink`, `Image`, `SoftwareImage`, `Download` models with soft-delete
semantics and download statistics.
- **CI-callable updater**: your build pipeline drops artifacts into a
directory and calls one endpoint — WarpEngine extracts archives, parses
metadata and upserts the catalog records. Supported platforms out of the
box: TIC-80, Ebitengine, LÖVE, C64, Godot, Bevy, Phaser. Authenticated by
a shared secret or by per-owner database tokens with expiry and scopes
(`ApplicationToken`, managed in the admin).
- **Public JSON API**: catalog listing, highlighted title, per-platform build
matrix, image serving, download tracking, and a static file server for
web-playable builds.
- **Admin (optional)**: if the host runs ActiveAdmin, WarpEngine contributes
ready-made resources — a catalog editor with nested release/asset forms, an
image library with orphan cleanup, a file manager with a picker mode, and
download statistics. Without ActiveAdmin the engine runs headless
(API + updater only).
## Requirements
- Rails >= 8.0
- A relational database (developed and tested against MySQL 8)
- Optional: ActiveAdmin + Devise in the host app for the admin UI
## Example stack (docker compose)
`examples/compose` boots everything the engine's workflow assumes, end to
end: the catalog app itself, the SSH drop area the updater contract feeds
from and — behind a compose profile — a Gitea forge with Woodpecker CI, so
you can watch a pipeline publish a release into the catalog.
| Service | Role | Where |
| --- | --- | --- |
| `app` | Minimal Rails host with the engine mounted as a path gem (headless: API + updater) | `http://localhost:8080` |
| `mysql` | Catalog database | internal |
| `droparea` | SSH server where pipelines drop build artifacts; shares the `softwares` volume with `app` | `ssh drop@localhost -p 2222` |
| `gitea` | Git forge (profile `ci`) | `http://gitea:3000` |
| `woodpecker` + agent | CI wired to gitea (profile `ci`) | `http://woodpecker:8000` |
### Quickstart — catalog + drop area
```sh
cd examples/compose
cp .env.example .env # defaults work for a throwaway local demo
docker compose up --build
```
The first boot takes a few minutes: the app container bundles, runs
`rails g warp_engine:install` and `rails db:prepare`, then serves on
`http://localhost:8080`:
- `http://localhost:8080/api/software` — the (empty) catalog
- `http://localhost:8080/api/builds` — the platform build matrix
- `http://localhost:8080/api/docs` — apipie API docs
### Publish a release by hand
The updater contract is nothing but a handful of HTTP calls, so you can play
the role of the CI pipeline yourself:
```sh
# 1. Fake a build: metadata, a web build and a windows artifact, named by
# convention (the love platform requires the .html.zip web build)
cat > demo-0.1.0.metadata.json <<'JSON'
{ "name": "demo", "title": "Demo Game", "author": "You", "desc": "Hello", "license": "MIT" }
JSON
echo '<h1>demo</h1>' > index.html && zip demo-0.1.0.html.zip index.html
echo hello > game.bin && zip demo-0.1.0-win-x64.zip game.bin
# 2. Upload them (one request per file)
for f in demo-0.1.0.*; do
curl -fs -H "X-Update-Secret: example-update-secret" \
-F "file=@$f" "http://localhost:8080/build/upload?name=demo&version=0.1.0"
done
# 3. Publish the release
curl -X POST -H "X-Update-Secret: example-update-secret" \
"http://localhost:8080/build/publish?platform=love&name=demo&version=0.1.0"
```
(Dropping the files in over the SSH drop area — `scp -P 2222 demo-0.1.0.*
drop@localhost:drop/`, password `DROP_PASSWORD` from `.env` — works just as
well; the updater only cares that the files end up in `file_container_path`.)
`GET /api/software` now lists *Demo Game* with `html` and `win_x64` assets,
`http://localhost:8080/file/demo-0.1.0/index.html` serves the extracted web
build, and `GET /api/download?path=demo-0.1.0-win-x64.zip` serves the
artifact while logging a download record.
### Full loop — forge + CI (profile `ci`)
gitea and woodpecker address each other by service name, so let your browser
resolve those names too:
```sh
echo "127.0.0.1 gitea woodpecker" | sudo tee -a /etc/hosts
```
1. `docker compose --profile ci up -d gitea`, open `http://gitea:3000`,
finish the install wizard (SQLite is fine) and create your admin user.
2. In gitea: *Settings → Applications → Manage OAuth2 Applications*, create
an app with redirect URI `http://woodpecker:8000/authorize`; copy the
client id/secret into `WOODPECKER_GITEA_CLIENT` / `WOODPECKER_GITEA_SECRET`
in `.env`.
3. `docker compose --profile ci up -d` — then log in at
`http://woodpecker:8000` (OAuth via gitea) and enable your repository.
A pipeline publishes a release exactly like the by-hand steps above — build,
upload, publish:
```yaml
# .woodpecker.yaml in a game repo hosted on the example gitea
steps:
publish:
image: alpine
environment:
UPDATE_SECRET:
from_secret: update_secret
commands:
- apk add --no-cache curl zip
- # ... build your game, produce mygame-1.0.0.metadata.json + artifacts ...
- for f in mygame-1.0.0.*; do curl -fs -H "X-Update-Secret: $UPDATE_SECRET" -F "file=@$f" "http://app:3000/build/upload?name=mygame&version=1.0.0"; done
- curl -fs -X POST -H "X-Update-Secret: $UPDATE_SECRET" "http://app:3000/build/publish?platform=love&name=mygame&version=1.0.0"
```
(The agent attaches pipeline containers to the stack network, so `app`
resolves. For real projects, the per-platform
[`tools/*-tools`](https://git.teletypegames.org) repos ship ready-made
Makefile + pipeline templates implementing this contract.)
Tear the stack down with `docker compose --profile ci down -v`.
## Installation
From the git repository:
```ruby
# Gemfile
gem "warp_engine", path: "../../libs/ruby/warp_engine"
gem "warp_engine", git: "https://git.teletypegames.org/tools/warp_engine.git"
```
Or from the Forgejo rubygems registry (tagged releases):
```ruby
source "https://git.teletypegames.org/api/packages/tools/rubygems" do
gem "warp_engine"
end
```
Then:
```sh
rails g warp_engine:install # initializer + create_warp_engine_tables migration
rails db:migrate
```
```ruby
# config/routes.rb — keep it the last entry so host routes win
# config/routes.rb — keep it the last entry so your own routes win
mount WarpEngine::Engine => "/"
```
## Configuration
```ruby
# config/initializers/warp_engine.rb
Rails.application.config.to_prepare do
WarpEngine.configure do |c|
# Where CI drops build artifacts and where images are stored
c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
c.image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
c.update_secret = ENV["UPDATE_SECRET"] # nil => /update rejects everything
# If host models also reference catalog images:
c.image_owners = [
{
label: "member",
image_ids: -> { Member.where.not(image_id: nil).distinct.pluck(:image_id) },
usage_label: ->(image) { "member" if Member.where(image_id: image.id).exists? }
}
]
# Shared secret for the /build/* endpoints.
# nil => the endpoints reject every request.
c.update_secret = ENV["UPDATE_SECRET"]
# Authentication source for /build/* — an exclusive choice:
# :env — the shared secret above is accepted (default)
# :database — only WarpEngine::ApplicationToken records with the
# "update" scope are accepted; the shared secret stops
# working the moment you switch.
# :database mode also requires the owner class every token belongs to:
# c.application_token_source = :database
# c.application_token_owner_class = "AdminUser"
# Size cap for /build/upload and the admin file manager, in bytes (default 500MB).
# c.max_upload_size = 500 * 1024 * 1024
# Owner isolation: a database token may only upload/publish softwares
# owned by its own owner (unrestricted tokens are exempt). Enable only
# after backfilling owners — ownerless softwares are claimable by anyone.
# c.enforce_software_ownership = true
# If your app's own models reference catalog images, register them so the
# admin Images page counts them as "in use":
# c.image_owners = [
# {
# label: "member",
# image_ids: -> { Member.where.not(image_id: nil).distinct.pluck(:image_id) },
# usage_label: ->(image) { "member" if Member.where(image_id: image.id).exists? }
# }
# ]
end
end
```
## Host expectations
## The updater contract
- **ActiveAdmin + Devise live in the host**: authentication, theme, assets and
the `/admin` routes are the host's responsibility; the engine only appends
its resource files to `ActiveAdmin.application.load_paths`.
- **Files picker JS**: the file-picker next to release-asset path inputs relies
on a few lines of JS in the host's `active_admin.js` (an iframe pointing at
`/admin/files?picker=1&field=<dom_id>`) — copy that over to a new host too.
- **apipie**: if the host generates apipie docs, add the engine to the matcher:
`"#{WarpEngine::Engine.root}/app/controllers/**/*.rb"`.
Publishing a release from CI is two steps:
1. **Upload** build artifacts into `file_container_path`, named by convention:
`<name>-<version>.metadata.json`, `<name>-<version>.html.zip`,
`<name>-<version>-win-x64.zip`, `<name>-<version>.tic`, ... (each platform
declares which asset kinds it expects — see `GET /api/builds`). Either
drop the files in over the shared volume (SSH drop area), or push them
over HTTP — one request per file, `upload` scope, optional `sha256`
integrity check:
```sh
curl -H "X-Update-Secret: $UPDATE_SECRET" \
-F "file=@mygame-1.2.0.html.zip" \
"https://your-host/build/upload?name=mygame&version=1.2.0"
```
2. **Publish the release**:
```sh
curl -X POST -H "X-Update-Secret: $UPDATE_SECRET" \
"https://your-host/build/publish?platform=tic80&name=mygame&version=1.2.0"
```
WarpEngine extracts the archives, parses the metadata (JSON, or the Lua
comment header for TIC-80), and upserts the `Software`, `ExternalLink`,
`Release` and `ReleaseAsset` records in a single transaction. Previously
deleted records are resurrected on re-ingest.
### Updater authentication
The `X-Update-Secret` header carries one of two credentials, selected by
`application_token_source` — the modes are exclusive, the endpoint never
accepts both:
- **`:env`** (default): the single shared secret from `update_secret`.
- **`:database`**: `WarpEngine::ApplicationToken` records. Each token
belongs to an owner (the class named by `application_token_owner_class`,
e.g. `AdminUser`), carries a free-form scope list — publishing requires
the `"update"` scope, `/build/upload` the `"upload"` scope — and an
optional expiry. Tokens are created in the admin
(*App Tokens*): the plain token is generated server-side and shown exactly
once after creation; only its SHA256 digest is stored. Deleting a token in
the admin revokes it (soft delete), and `last_used_at` records when each
token last authenticated successfully.
When switching to `:database`, create the tokens and move your pipelines to
them first — the flip invalidates the shared secret immediately.
## Public API
| Endpoint | Purpose |
| --- | --- |
| `GET /api/software` | Full catalog with releases, assets, links, download counts; `?owner_id=` filters to one publisher |
| `GET /api/software/highlighted` | The currently highlighted title |
| `GET /api/builds` | Expected asset kinds per platform (build matrix) |
| `GET /api/softwares/:name/builds` | Actual vs. missing build assets per release |
| `GET /api/image/:id` | Serves catalog images |
| `GET /api/download?path=` | Serves an artifact and logs a download record |
| `GET /file/*path` | Serves static build output (web-playable games, docs) |
## Admin integration
The host owns the single ActiveAdmin instance — authentication (Devise),
theme, assets and the `/admin` routes. WarpEngine only appends its resource
files to `ActiveAdmin.application.load_paths`. Two things to copy into a new
host:
- the small file-picker JS for release-asset path inputs (an iframe pointing
at `/admin/files?picker=1&field=<dom_id>`) in your `active_admin.js`;
- if you generate apipie docs, add
`"#{WarpEngine::Engine.root}/app/controllers/**/*.rb"` to your
`api_controllers_matcher`.
## Behavioral notes
- Every model is soft-deleted (`default_scope { where(deleted_at: nil) }`);
the updater "resurrects" re-submitted, previously deleted records via
`.unscoped`.
- The JSON shape is intentionally bug-compatible with the former Go backend
(Go zero-time timestamps, camelCase keys, legacy flat path fields).
- Every model is soft-deleted (`default_scope { where(deleted_at: nil) }`).
- The JSON shape is stable and intentionally bug-compatible with the project's
former Go backend (Go zero-time timestamps, camelCase keys, legacy flat
path fields).
- Model extension points: `ActiveSupport.on_load(:warp_engine_<model>)` hooks.
## Tests
The engine ships an RSpec suite running against a bundled dummy app:
```sh
bundle install
bundle exec rake app:db:prepare RAILS_ENV=test # warp_engine_test DB for the dummy app
bundle exec rake app:db:prepare RAILS_ENV=test
bundle exec rspec
```
## Development
This repository is a **read-only split mirror** — development happens in the
[`tools/teletypegames`](https://git.teletypegames.org/tools/teletypegames)
monorepo under `libs/ruby/warp_engine`, and CI republishes the mirror on every
change. Please do not open pull requests against the mirror.
@@ -0,0 +1,99 @@
ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
actions :index, :show, :new, :create, :edit, :update, :destroy
permit_params :name, :owner_id, :expires_at, :scopes_string, :unrestricted
menu priority: 9, label: "🎟️ App Tokens"
config.sort_order = "created_at_desc"
config.batch_actions = false
scope :all, default: true
scope("Active") { |scope| scope.where("expires_at IS NULL OR expires_at > ?", Time.current) }
scope("Expired") { |scope| scope.where("expires_at <= ?", Time.current) }
index do
id_column
column :name
column("Token") { |t| code "#{t.token_prefix}", style: "font-family:monospace;" }
column("Owner") { |t| t.owner.try(:email) || t.owner.try(:name) || "#{t.owner_type} ##{t.owner_id}" }
column("Scopes") { |t| t.scopes_string }
column :unrestricted
column :expires_at
column :last_used_at
column :created_at
actions
end
filter :name_cont, label: "Name"
filter :token_prefix_cont, label: "Token prefix"
filter :expires_at
filter :last_used_at
form do |f|
owner_class = WarpEngine.config.application_token_owner_class&.safe_constantize
f.inputs do
if f.object.new_record?
if owner_class
f.input :owner_id, as: :select, label: owner_class.name,
collection: owner_class.all.map { |o| [ o.try(:email) || o.try(:name) || "##{o.id}", o.id ] },
include_blank: false
else
f.template.concat(f.template.content_tag(:li,
"application_token_owner_class nincs beállítva — token nem hozható létre.",
class: "flash flash_error"))
end
end
f.input :name
f.input :scopes_string, label: "Scopes (comma separated)",
hint: %(A /build/publish (és a legacy /update) végponthoz az "update", a /build/upload-hoz az "upload" scope kell.)
f.input :unrestricted, hint: "Belső token: az owner-izoláció (enforce_software_ownership) nem vonatkozik rá."
f.input :expires_at, hint: "Üresen hagyva sosem jár le."
end
f.actions
end
show do
if (plain = controller.instance_variable_get(:@plain_token))
panel "⚠️ Token — csak most látható, másold ki!" do
pre plain, style: "font-family:monospace;font-size:14px;padding:8px;background:#fff3cd;user-select:all;"
end
end
attributes_table do
row :id
row :name
row("Token") { |t| code "#{t.token_prefix}… (SHA256 digest tárolva)" }
row("Owner") { |t| "#{t.owner_type} ##{t.owner_id}#{t.owner.try(:email) || t.owner.try(:name)}" }
row("Scopes") { |t| t.scopes_string }
row :unrestricted
row :expires_at
row :last_used_at
row :created_at
row :updated_at
end
end
controller do
# A plain token csak közvetlenül a létrehozás után létezik; a session-ön át
# jut el az egyszeri megjelenítésig (a flash nem jó: az AA layout minden
# flash kulcsot üzenetsávként renderel).
def create
create! do |success, _failure|
success.html do
session[:warp_engine_plain_token] = resource.plain_token
redirect_to resource_path(resource) and return
end
end
end
def show
@plain_token = session.delete(:warp_engine_plain_token)
show!
end
# Revoke = soft delete, audit-nyommal.
def destroy
resource.revoke!
redirect_to collection_path, notice: "Token revoked."
end
end
end
@@ -0,0 +1,71 @@
module WarpEngine
# Token-hitelesítés a publikáló (/build/*) endpointokhoz.
# A hitelesítési forrás kizárólagos: :database módban a shared secret nem
# érvényes, :env módban a DB-tokenek nem.
module UpdateAuthentication
extend ActiveSupport::Concern
private
attr_reader :current_application_token
# A token kizárólag az X-Update-Secret headerből jöhet — URL-ben a secret
# proxy- és access-logokba szivárogna.
def update_authorized?(required_scope:)
token = request.headers["X-Update-Secret"].presence
return false if token.blank?
case WarpEngine.config.application_token_source
when :database then database_token_authorized?(token, required_scope)
else env_secret_authorized?(token)
end
end
def env_secret_authorized?(token)
expected = WarpEngine.config.update_secret
# Konfigurálatlan secret esetén az endpoint zárva marad.
expected.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected)
end
def database_token_authorized?(token, required_scope)
if WarpEngine.config.application_token_owner_class.blank?
Rails.logger.error("[#{self.class.name}] application_token_source=:database, de application_token_owner_class nincs beállítva — minden kérés elutasítva")
return false
end
record = WarpEngine::ApplicationToken.authenticate(token, required_scope: required_scope)
return false if record.nil?
record.touch_last_used!
@current_application_token = record
true
end
# Owner-kényszer: csak :database módban (van token) és bekapcsolt
# enforce_software_ownership mellett szűr. Owner nélküli software a
# backfillig szabad préda — a kényszer bekapcsolása előtt kell backfillelni.
def software_ownership_authorized?(name)
return true unless WarpEngine.config.enforce_software_ownership
token = current_application_token
return true if token.nil? || token.unrestricted?
software = WarpEngine::Software.find_by(name: name)
return true if software.nil? || software.owner_id.nil?
software.owner_type == token.owner_type && software.owner_id == token.owner_id
end
# Az először publikált (vagy backfill előtti, gazdátlan) software a beküldő
# token ownerét kapja. Unrestricted (belső) token nem foglal ownert.
def claim_software_ownership(name)
token = current_application_token
return if token.nil? || token.unrestricted?
software = WarpEngine::Software.find_by(name: name)
return if software.nil? || software.owner_id.present?
software.update_columns(owner_type: token.owner_type, owner_id: token.owner_id)
end
end
end
@@ -29,6 +29,7 @@ module WarpEngine
end
api :GET, "/api/software", "List all software entries with releases"
param :owner_id, :number, required: false, desc: "Filter to the softwares of one owner (publisher)"
returns code: 200, desc: "Wrapper object with softwares array" do
property :softwares, Array, desc: "Array of software entries" do
property :ID, Integer, desc: "Software ID"
@@ -55,7 +56,7 @@ module WarpEngine
end
end
def index
render json: WarpEngine::SoftwareService.new.index
render json: WarpEngine::SoftwareService.new.index(owner_id: params[:owner_id])
end
end
end
@@ -0,0 +1,45 @@
module WarpEngine
module Build
class PublishController < ApiController
include UpdateAuthentication
resource_description do
short "Build release publishing"
end
api :POST, "/build/publish", "Register an uploaded build as a release"
header "X-Update-Secret", "Shared secret or application token (update scope)", required: true
param :name, String, required: true, desc: "Software name"
param :platform, String, required: true, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)"
param :version, String, required: true, desc: "Version string"
returns code: 200, desc: "JSON with the published name/platform/version"
error code: 401, desc: "Invalid secret"
error code: 403, desc: "Token does not own this software"
error code: 400, desc: "Missing or invalid arguments"
def create
unless update_authorized?(required_scope: WarpEngine::ApplicationToken::UPDATE_SCOPE)
return render json: { error: "Unauthorized" }, status: :unauthorized
end
%i[name platform version].each do |key|
return render json: { error: "#{key.to_s.capitalize} not provided" }, status: :bad_request if params[key].blank?
end
unless software_ownership_authorized?(params[:name])
return render json: { error: "Forbidden" }, status: :forbidden
end
input = WarpEngine::UpdateInputDto.new(
platform: params[:platform],
name: params[:name],
version: params[:version]
)
WarpEngine::UpdateService.new.update(input)
claim_software_ownership(params[:name])
render json: { published: true, name: params[:name], platform: params[:platform], version: params[:version] }
end
end
end
end
@@ -0,0 +1,65 @@
require "digest"
module WarpEngine
module Build
class UploadsController < ApiController
include UpdateAuthentication
resource_description do
short "Build artifact upload"
end
# A release-fájlnevek kötött konvenciója: <name>-<version>.<ext> vagy
# <name>-<version>-<target>.zip — az updater is ezeket keresi.
NAME_FORMAT = /\A[A-Za-z0-9._-]+\z/
api :POST, "/build/upload", "Upload a build artifact into the drop area"
header "X-Update-Secret", "Shared secret or application token (upload scope)", required: true
param :name, String, required: true, desc: "Software name (filename must be prefixed with <name>-<version>)"
param :version, String, required: true, desc: "Version string"
param :file, File, required: true, desc: "Artifact file (multipart)"
param :sha256, String, required: false, desc: "Expected SHA256 hex digest; on mismatch the upload is rejected"
returns code: 200, desc: "JSON with stored file name, size and sha256"
error code: 401, desc: "Invalid secret"
error code: 403, desc: "Token does not own this software"
error code: 400, desc: "Missing or invalid arguments"
error code: 413, desc: "File larger than max_upload_size"
error code: 422, desc: "SHA256 mismatch"
def create
unless update_authorized?(required_scope: WarpEngine::ApplicationToken::UPLOAD_SCOPE)
return render json: { error: "Unauthorized" }, status: :unauthorized
end
name = params[:name].to_s
version = params[:version].to_s
file = params[:file]
return render json: { error: "Invalid name" }, status: :bad_request unless name.match?(NAME_FORMAT)
return render json: { error: "Invalid version" }, status: :bad_request unless version.match?(NAME_FORMAT)
return render json: { error: "File not provided" }, status: :bad_request unless file.respond_to?(:original_filename)
unless software_ownership_authorized?(name)
return render json: { error: "Forbidden" }, status: :forbidden
end
filename = File.basename(file.original_filename.to_s)
unless filename.start_with?("#{name}-#{version}.", "#{name}-#{version}-")
return render json: { error: "Filename must be prefixed with #{name}-#{version}" }, status: :bad_request
end
max = WarpEngine.config.max_upload_size
if file.size > max
return render json: { error: "File too large (max #{max / (1024 * 1024)}MB)" }, status: :payload_too_large
end
digest = Digest::SHA256.file(file.tempfile.path).hexdigest
if params[:sha256].present? && !ActiveSupport::SecurityUtils.secure_compare(params[:sha256].downcase, digest)
return render json: { error: "SHA256 mismatch" }, status: :unprocessable_entity
end
stored = WarpEngine::FileManagerService.new.upload("", file)
render json: { file: stored, size: file.size, sha256: digest }
end
end
end
end
@@ -1,49 +0,0 @@
module WarpEngine
class UpdateController < ApiController
resource_description do
short "Software updater"
formats [ "text" ]
end
rescue_from ArgumentError do |e|
render plain: e.message, status: :bad_request
end
rescue_from StandardError do |e|
Rails.logger.error("[UpdateController] #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
render plain: "Internal server error", status: :internal_server_error
end
api :GET, "/update", "Update software version in database"
param :secret, String, required: true, desc: "Authorization secret"
param :platform, String, required: false, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)"
param :name, String, required: false, desc: "Software name"
param :version, String, required: true, desc: "Version string"
returns code: 200, desc: "Plain text 'Updated'"
error code: 401, desc: "Invalid secret"
error code: 400, desc: "Version not provided or invalid arguments"
error code: 500, desc: "Internal server error"
def update
return render plain: "Unauthorized", status: :unauthorized unless authorized?
return render plain: "Version not provided", status: :bad_request if params[:version].blank?
input = WarpEngine::UpdateInputDto.new(
platform: params[:platform],
name: params[:name],
version: params[:version]
)
WarpEngine::UpdateService.new.update(input)
render plain: "Updated"
end
private
def authorized?
secret = request.headers["X-Update-Secret"].presence || params[:secret]
expected = WarpEngine.config.update_secret
# Konfigurálatlan secret esetén az endpoint zárva marad.
expected.present? && secret == expected
end
end
end
@@ -0,0 +1,100 @@
require "digest"
module WarpEngine
class ApplicationToken < ApplicationRecord
self.table_name = "application_tokens"
UPDATE_SCOPE = "update".freeze
UPLOAD_SCOPE = "upload".freeze
# A generált token csak létrehozáskor, memóriában érhető el — a DB-ben
# kizárólag a SHA256 digest és a nem-titkos prefix tárolódik.
attr_reader :plain_token
belongs_to :owner, polymorphic: true
default_scope { where(deleted_at: nil) }
scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
before_validation :assign_owner_type, on: :create
before_validation :generate_token, on: :create
after_initialize { self.scopes = [] if new_record? && scopes.nil? }
validates :name, presence: true
validates :token_digest, presence: true, uniqueness: true
validates :token_prefix, presence: true
validates :scopes, presence: true
validate :owner_type_matches_configuration
def self.digest(token)
Digest::SHA256.hexdigest(token)
end
# Az élő (nem törölt, nem lejárt), a kért scope-pal rendelkező token, különben nil.
def self.authenticate(token, required_scope: nil)
return nil if token.blank?
record = active.find_by(token_digest: digest(token))
return nil if record.nil?
return nil if required_scope.present? && !Array(record.scopes).include?(required_scope)
record
end
def expired?
expires_at.present? && expires_at <= Time.current
end
# Visszavonás = soft delete, az audit-nyom megmarad.
def revoke!
update_column(:deleted_at, Time.current)
end
def touch_last_used!
update_column(:last_used_at, Time.current)
end
# Admin form: vesszővel elválasztott scope-lista
def scopes_string
Array(scopes).join(", ")
end
def scopes_string=(value)
self.scopes = value.to_s.split(",").map(&:strip).reject(&:blank?).uniq
end
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at expires_at id last_used_at name owner_id owner_type token_prefix unrestricted updated_at]
end
# A polimorf owner asszociációra a Ransack nem tud szűrni.
def self.ransackable_associations(auth_object = nil)
[]
end
private
def assign_owner_type
self.owner_type = WarpEngine.config.application_token_owner_class if owner_type.blank?
end
def generate_token
return if token_digest.present?
@plain_token = SecureRandom.hex(24)
self.token_prefix = @plain_token.first(8)
self.token_digest = self.class.digest(@plain_token)
end
def owner_type_matches_configuration
expected = WarpEngine.config.application_token_owner_class
if expected.blank?
errors.add(:base, "application_token_owner_class is not configured")
elsif owner_type != expected
errors.add(:owner_type, "must be #{expected}")
end
end
ActiveSupport.run_load_hooks(:warp_engine_application_token, self)
end
end
@@ -2,6 +2,10 @@ module WarpEngine
class Software < ApplicationRecord
self.table_name = "softwares"
# A publikáló token ownere (pl. AdminUser) — 3rd party izolációhoz, ld.
# enforce_software_ownership. nil = belső / backfill előtti software.
belongs_to :owner, polymorphic: true, optional: true
has_many :software_images, foreign_key: :software_id, dependent: :destroy
has_many :images, through: :software_images
has_many :releases, foreign_key: :software_id
@@ -19,7 +23,7 @@ module WarpEngine
default_scope { where(deleted_at: nil) }
def self.ransackable_attributes(auth_object = nil)
%w[author created_at desc highlighted id license name platform site status story title updated_at]
%w[author created_at desc highlighted id license name owner_id owner_type platform site status story title updated_at]
end
def self.ransackable_associations(auth_object = nil)
@@ -14,6 +14,8 @@ module WarpEngine
field(:license) { |sw| sw.license.to_s }
field :platform
field :status
# Publikus owner-azonosító — az /api/software?owner_id= szűrőhöz.
field(:ownerId) { |sw| sw.owner_id }
field(:highlighted) { |sw| sw.highlighted ? true : false }
field(:externalLinks) { |sw| ExternalLinkSerializer.render_as_hash(sw.external_links) }
field(:platformLinks) { |sw| PlatformLinkSerializer.render_as_hash(WarpEngine::PlatformLink.for_platform(sw.platform)) }
@@ -22,10 +22,9 @@ module WarpEngine
end
end
MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB
def upload(relative_dir, uploaded_file)
raise ArgumentError, "File too large (max 100MB)" if uploaded_file.size > MAX_UPLOAD_SIZE
max = WarpEngine.config.max_upload_size
raise ArgumentError, "File too large (max #{max / (1024 * 1024)}MB)" if uploaded_file.size > max
dir = safe_path!(relative_dir)
raise ArgumentError, "Not a directory" unless dir.directory?
@@ -2,8 +2,9 @@ module WarpEngine
class SoftwareService
include SoftwareResponseBuilder
def index
def index(owner_id: nil)
softwares = WarpEngine::Software.includes(releases: [ :release_assets ]).includes(:external_links, :software_images).all
softwares = softwares.where(owner_id: owner_id) if owner_id.present?
counts = download_counts_for(softwares.flat_map { |sw| sw.releases.map(&:id) })
{ softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a, counts) } }
end
+3 -1
View File
@@ -8,6 +8,8 @@ WarpEngine::Engine.routes.draw do
get "softwares/:name/builds", to: "software_builds#show"
end
get "update", to: "update#update"
post "build/upload", to: "build/uploads#create"
post "build/publish", to: "build/publish#create"
get "file/*path", to: "files#show", format: false
end
@@ -0,0 +1,22 @@
class CreateApplicationTokens < ActiveRecord::Migration[8.1]
def change
create_table :application_tokens, id: { type: :bigint, unsigned: true },
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.string :name, limit: 128, null: false
# Az owner osztályát a host adja (WarpEngine.config.application_token_owner_class),
# ezért nem lehet FK.
t.string :owner_type, limit: 128, null: false
t.bigint :owner_id, null: false, unsigned: true
t.string :token_digest, limit: 64, null: false
t.string :token_prefix, limit: 12, null: false
t.json :scopes
t.datetime :expires_at, precision: 3
t.datetime :last_used_at, precision: 3
t.datetime :deleted_at, precision: 3
t.timestamps precision: 3, null: true
t.index :token_digest, name: "idx_application_tokens_token_digest", unique: true
t.index [ :owner_type, :owner_id ], name: "idx_application_tokens_owner"
t.index :deleted_at, name: "idx_application_tokens_deleted_at"
end
end
end
@@ -0,0 +1,12 @@
class AddBuildOwnership < ActiveRecord::Migration[8.1]
def change
# A publikáló token ownere; nil = belső / backfill előtti software.
# Az owner osztályát a host adja (application_token_owner_class), ezért nem lehet FK.
add_column :softwares, :owner_type, :string, limit: 128
add_column :softwares, :owner_id, :bigint, unsigned: true
add_index :softwares, [ :owner_type, :owner_id ], name: "idx_softwares_owner"
# unrestricted = belső token: az enforce_software_ownership nem vonatkozik rá.
add_column :application_tokens, :unrestricted, :boolean, default: false, null: false
end
end
@@ -0,0 +1,24 @@
# Copy to .env and adjust. Every value except the OAuth pair has a working
# default for a throwaway local demo, so the quickstart runs without edits.
MYSQL_ROOT_PASSWORD=warpengine
# Shared secret for the /build/* endpoints (X-Update-Secret header).
UPDATE_SECRET=example-update-secret
# Password of the "drop" user on the artifact drop area (SSH, port 2222).
DROP_PASSWORD=drop
# Published ports.
APP_PORT=8080
DROPAREA_SSH_PORT=2222
GITEA_SSH_PORT=2223
# --- profile "ci" only -------------------------------------------------------
WOODPECKER_AGENT_SECRET=example-agent-secret
# OAuth2 application created in gitea (Settings -> Applications), redirect URI
# http://woodpecker:8000/authorize — required before the ci profile starts.
WOODPECKER_GITEA_CLIENT=
WOODPECKER_GITEA_SECRET=
@@ -0,0 +1,131 @@
# Example stack: everything WarpEngine needs to come alive, end to end.
#
# mysql the catalog database
# app a minimal Rails host with the engine mounted from this
# repo checkout (headless: API + updater, no ActiveAdmin)
# droparea SSH server where build pipelines drop artifacts; shares
# the "softwares" volume with the app
# gitea (profile "ci") the git forge
# woodpecker (profile "ci") CI server + agent, wired to gitea
#
# Quickstart (catalog + drop area only):
# cp .env.example .env
# docker compose up --build
#
# Full loop with forge + CI:
# docker compose --profile ci up --build
#
# See ../../README.md ("Example stack") or the WarpEngine wiki page for the
# full walkthrough, including the one-time gitea/woodpecker OAuth wiring.
services:
mysql:
image: mysql:8
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 20s
retries: 10
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-warpengine}
MYSQL_DATABASE: warp_engine_example
volumes:
- mysql-data:/var/lib/mysql
app:
build: ./host_app
ports:
- "${APP_PORT:-8080}:3000"
environment:
RAILS_ENV: development
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-warpengine}
UPDATE_SECRET: ${UPDATE_SECRET:-example-update-secret}
FILE_CONTAINER_PATH: /softwares
IMAGE_CONTAINER_PATH: /images
depends_on:
mysql:
condition: service_healthy
volumes:
- ./host_app:/app
- ../..:/warp_engine # the engine itself, consumed as a path gem
- bundle:/usr/local/bundle
- softwares:/softwares
- images:/images
# SSH landing zone for build artifacts. Pipelines (or you, with scp) upload
# into ~/drop here; the app sees the same files under /softwares.
droparea:
image: linuxserver/openssh-server
environment:
PUID: 1
PGID: 1
SUDO_ACCESS: "false"
PASSWORD_ACCESS: "true"
USER_NAME: drop
USER_PASSWORD: ${DROP_PASSWORD:-drop}
ports:
- "${DROPAREA_SSH_PORT:-2222}:2222"
volumes:
# A subdir of the drop user's home (/config), so sshd's own state files
# never end up in the catalog directory.
- softwares:/config/drop
# --- profile "ci": the forge + CI producing releases for the catalog ------
#
# gitea and woodpecker refer to each other by their service names, so your
# browser needs to resolve those names too:
# echo "127.0.0.1 gitea woodpecker" | sudo tee -a /etc/hosts
# gitea: http://gitea:3000 woodpecker: http://woodpecker:8000
gitea:
image: gitea/gitea:1.27.0
profiles: ["ci"]
environment:
DISABLE_REGISTRATION: "true"
ROOT_URL: "http://gitea:3000"
ports:
- "3000:3000"
- "${GITEA_SSH_PORT:-2223}:22"
volumes:
- gitea-data:/data
woodpecker:
image: woodpeckerci/woodpecker-server:v3.16.0
profiles: ["ci"]
environment:
WOODPECKER_HOST: "http://woodpecker:8000"
WOODPECKER_OPEN: "true"
WOODPECKER_GITEA: "true"
WOODPECKER_GITEA_URL: "http://gitea:3000"
# Create an OAuth2 app in gitea first — see the README walkthrough.
WOODPECKER_GITEA_CLIENT: ${WOODPECKER_GITEA_CLIENT:-}
WOODPECKER_GITEA_SECRET: ${WOODPECKER_GITEA_SECRET:-}
WOODPECKER_SERVER_ADDR: ":8000"
WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:-example-agent-secret}
ports:
- "8000:8000"
volumes:
- woodpecker-data:/var/lib/woodpecker
woodpecker-agent:
image: woodpeckerci/woodpecker-agent:v3.16.0
profiles: ["ci"]
environment:
WOODPECKER_SERVER: "woodpecker:9000"
WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:-example-agent-secret}
# Attach pipeline containers to the stack network so steps can reach
# gitea, droparea and the app by service name.
WOODPECKER_BACKEND_DOCKER_NETWORK: warp-example
volumes:
- /var/run/docker.sock:/var/run/docker.sock
networks:
default:
name: warp-example
volumes:
mysql-data:
bundle:
softwares:
images:
gitea-data:
woodpecker-data:
@@ -0,0 +1,6 @@
# Generated on first boot by the entrypoint (install generator + db:prepare).
db/migrate/
db/schema.rb
log/
tmp/
Gemfile.lock
@@ -0,0 +1,20 @@
FROM ruby:3.3-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
default-libmysqlclient-dev \
git \
tzdata \
libyaml-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# The app source, the engine checkout (/warp_engine) and the bundle are all
# mounted at runtime by the compose file; the entrypoint bundles on first boot.
ENTRYPOINT ["./bin/docker-entrypoint"]
EXPOSE 3000
CMD ["bin/rails", "server", "-b", "0.0.0.0", "-p", "3000"]
@@ -0,0 +1,10 @@
source "https://rubygems.org"
gem "rails", "~> 8.0.0"
gem "mysql2", "~> 0.5"
gem "puma"
# In this example stack the engine comes straight from the repo checkout the
# compose file mounts at /warp_engine. A real host would use the git source or
# the Forgejo rubygems registry instead — see the engine README.
gem "warp_engine", path: ENV.fetch("WARP_ENGINE_PATH", "/warp_engine")
@@ -0,0 +1,3 @@
require_relative "config/application"
Rails.application.load_tasks
@@ -0,0 +1,16 @@
#!/bin/sh
set -e
bundle check || bundle install
# First boot: run the install generator the way a real host would. It creates
# the create_warp_engine_tables migration; --skip leaves our initializer alone.
if ! ls db/migrate/*create_warp_engine_tables* >/dev/null 2>&1; then
bin/rails generate warp_engine:install --skip
fi
bin/rails db:prepare
rm -f tmp/pids/server.pid
exec "$@"
@@ -0,0 +1,4 @@
#!/usr/bin/env ruby
APP_PATH = File.expand_path("../config/application", __dir__)
require_relative "../config/boot"
require "rails/commands"
@@ -0,0 +1,4 @@
require_relative "config/environment"
run Rails.application
Rails.application.load_server
@@ -0,0 +1,23 @@
require_relative "boot"
require "rails"
require "active_model/railtie"
require "active_record/railtie"
require "action_controller/railtie"
require "action_view/railtie"
require "action_dispatch/railtie"
Bundler.require(*Rails.groups)
require "warp_engine"
module HostApp
class Application < Rails::Application
config.load_defaults 8.0
config.time_zone = "UTC"
config.active_record.default_timezone = :utc
# Demo stack: reachable as localhost, gitea-network hostnames, etc.
config.hosts.clear
end
end
@@ -0,0 +1,3 @@
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
require "bundler/setup"
@@ -0,0 +1,8 @@
development:
adapter: mysql2
encoding: utf8mb4
username: root
password: <%= ENV.fetch("MYSQL_ROOT_PASSWORD", "warpengine") %>
host: mysql
port: 3306
database: warp_engine_example
@@ -0,0 +1,3 @@
require_relative "application"
Rails.application.initialize!
@@ -0,0 +1,10 @@
Rails.application.configure do
config.enable_reloading = true
config.eager_load = false
config.consider_all_requests_local = true
config.active_record.migration_error = :page_load
config.active_record.verbose_query_logs = true
config.logger = ActiveSupport::Logger.new($stdout)
end
@@ -0,0 +1,11 @@
Apipie.configure do |config|
config.app_name = "WarpEngine Example Host"
config.api_base_url = ""
config.doc_base_url = "/api/docs"
config.api_controllers_matcher = [
"#{WarpEngine::Engine.root}/app/controllers/**/*.rb"
]
config.validate = false
config.translate = false
config.default_version = "1.0"
end
@@ -0,0 +1,9 @@
Rails.application.config.to_prepare do
WarpEngine.configure do |c|
c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
c.image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
# Beállítatlan secret esetén a /build/* endpointok minden kérést elutasítanak.
c.update_secret = ENV["UPDATE_SECRET"]
end
end
@@ -0,0 +1,6 @@
Rails.application.routes.draw do
apipie
# Keep the engine mount the last entry so the host's own routes win.
mount WarpEngine::Engine => "/"
end
@@ -11,9 +11,14 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0]
t.string :site
t.string :status, limit: 20, default: "development"
t.boolean :highlighted, default: false
# A publikáló token ownere (enforce_software_ownership) — nem lehet FK,
# az owner osztályát a host adja.
t.string :owner_type, limit: 128
t.bigint :owner_id
t.datetime :deleted_at, precision: 3
t.timestamps precision: 3, null: true
t.index :name, unique: true
t.index [ :owner_type, :owner_id ]
t.index :deleted_at
end
@@ -75,6 +80,24 @@ class CreateWarpEngineTables < ActiveRecord::Migration[8.0]
t.index [ :software_id, :position ]
end
create_table :application_tokens do |t|
t.string :name, limit: 128, null: false
t.string :owner_type, limit: 128, null: false
t.bigint :owner_id, null: false
t.string :token_digest, limit: 64, null: false
t.string :token_prefix, limit: 12, null: false
t.json :scopes
# Belső token: az enforce_software_ownership nem vonatkozik rá.
t.boolean :unrestricted, default: false, null: false
t.datetime :expires_at, precision: 3
t.datetime :last_used_at, precision: 3
t.datetime :deleted_at, precision: 3
t.timestamps precision: 3, null: true
t.index :token_digest, unique: true
t.index [ :owner_type, :owner_id ]
t.index :deleted_at
end
create_table :downloads do |t|
t.string :file_path, null: false
t.references :release, foreign_key: { on_delete: :nullify }, index: false
@@ -5,10 +5,26 @@ Rails.application.config.to_prepare do
# c.file_container_path = "/softwares"
# c.image_container_path = "/images"
# A /update endpoint shared secretje (default: ENV["UPDATE_SECRET"]).
# Beállítatlan secret esetén az endpoint minden kérést elutasít.
# A /build/* endpointok shared secretje (default: ENV["UPDATE_SECRET"]).
# Beállítatlan secret esetén az endpointok minden kérést elutasítanak.
# c.update_secret = ENV["UPDATE_SECRET"]
# A /build/* hitelesítési forrása — kizárólagos választás:
# :env — a fenti shared secret érvényes (default)
# :database — csak DB-tárolt WarpEngine::ApplicationToken érvényes
# ("update" scope-pal); a shared secret ilyenkor NEM működik.
# A :database módhoz kötelező a tokenek tulajdonos-osztálya is:
# c.application_token_source = :database
# c.application_token_owner_class = "AdminUser"
# A /build/upload (és az admin file manager) méretplafonja bájtban (default: 500MB).
# c.max_upload_size = 500 * 1024 * 1024
# Owner-izoláció: DB-token csak a saját ownerének szoftvereit
# uploadolhatja/publisholhatja (unrestricted token kivétel). Csak azután
# kapcsold be, hogy a meglévő szoftverek ownert kaptak (backfill)!
# c.enforce_software_ownership = true
# Ha a host modelljei is hivatkoznak katalógus-képekre, regisztráld őket,
# hogy az admin Images oldal orphan-detektálása figyelembe vegye:
# c.image_owners = [
@@ -4,16 +4,33 @@ module WarpEngine
# label: String
# image_ids: -> { Array<Integer> } — az owner által használt image id-k
# usage_label: ->(image) { String vagy nil } — megjelenítendő címke, ha használja
# application_token_source: a /build/* endpointok hitelesítési forrása, kizárólagos.
# :env — a shared secret (update_secret) érvényes, a DB-tokenek nem
# :database — csak WarpEngine::ApplicationToken érvényes, a shared secret nem
# application_token_owner_class: a tokenek kötelező tulajdonosának osztályneve
# (pl. "AdminUser"); nil esetén a :database mód minden kérést elutasít.
# max_upload_size: a /build/upload (és az admin file manager) fájlméret-plafonja bájtban.
# enforce_software_ownership: ha true, egy DB-token csak a saját ownerének
# szoftvereit uploadolhatja/publisholhatja (unrestricted token kivétel).
# Bekapcsolás CSAK backfill után: gazdátlan software-t bármely token elvihet.
attr_accessor :file_container_path,
:image_container_path,
:update_secret,
:application_token_source,
:application_token_owner_class,
:max_upload_size,
:enforce_software_ownership,
:image_owners
def initialize
@file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
@image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
@update_secret = ENV["UPDATE_SECRET"]
@image_owners = []
@file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
@image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
@update_secret = ENV["UPDATE_SECRET"]
@application_token_source = :env
@application_token_owner_class = nil
@max_upload_size = 500 * 1024 * 1024
@enforce_software_ownership = false
@image_owners = []
end
end
end
@@ -0,0 +1,3 @@
# Csak a dummy app tesztjeihez: az ApplicationToken owner szerepét tölti be.
class TestOwner < ActiveRecord::Base
end
@@ -0,0 +1,10 @@
# Csak a tesztekhez: az ApplicationToken owner-e (a hostban ez pl. AdminUser).
class CreateTestOwners < ActiveRecord::Migration[8.1]
def change
create_table :test_owners, id: { type: :bigint, unsigned: true },
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
t.string :name, limit: 128
t.timestamps precision: 3, null: true
end
end
end
+40 -2
View File
@@ -1,5 +1,34 @@
# A katalógus-táblák a host schema.rb-vel megegyező definícióval.
ActiveRecord::Schema[8.1].define(version: 1) do
# 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_05_000003) do
create_table "application_tokens", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
t.datetime "expires_at", precision: 3
t.datetime "last_used_at", precision: 3
t.string "name", limit: 128, null: false
t.bigint "owner_id", null: false, unsigned: true
t.string "owner_type", limit: 128, null: false
t.json "scopes"
t.string "token_digest", limit: 64, null: false
t.string "token_prefix", limit: 12, null: false
t.boolean "unrestricted", default: false, null: false
t.datetime "updated_at", precision: 3
t.index ["deleted_at"], name: "idx_application_tokens_deleted_at"
t.index ["owner_type", "owner_id"], name: "idx_application_tokens_owner"
t.index ["token_digest"], name: "idx_application_tokens_token_digest", unique: true
end
create_table "downloads", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
@@ -89,6 +118,8 @@ ActiveRecord::Schema[8.1].define(version: 1) do
t.boolean "highlighted", default: false
t.string "license", limit: 128
t.string "name", limit: 128
t.bigint "owner_id", unsigned: true
t.string "owner_type", limit: 128
t.string "platform", limit: 128
t.string "site"
t.string "status", limit: 20, default: "development"
@@ -97,6 +128,13 @@ ActiveRecord::Schema[8.1].define(version: 1) do
t.datetime "updated_at", precision: 3
t.index ["deleted_at"], name: "idx_softwares_deleted_at"
t.index ["name"], name: "idx_softwares_name", unique: true
t.index ["owner_type", "owner_id"], name: "idx_softwares_owner"
end
create_table "test_owners", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.string "name", limit: 128
t.datetime "updated_at", precision: 3
end
add_foreign_key "downloads", "releases", name: "fk_downloads_release", on_delete: :nullify
@@ -0,0 +1,21 @@
# A TestOwner csak a dummy appban létezik — host-oldali használatnál az owner-t
# felül kell írni (pl. owner: create(:admin_user)).
FactoryBot.define do
factory :test_owner, class: "TestOwner" do
name { "test owner" }
end
factory :application_token, class: "WarpEngine::ApplicationToken" do
name { "CI token" }
scopes { [ "update" ] }
association :owner, factory: :test_owner
trait :expired do
expires_at { 1.hour.ago }
end
trait :unrestricted do
unrestricted { true }
end
end
end
@@ -0,0 +1,124 @@
require "rails_helper"
RSpec.describe WarpEngine::ApplicationToken, type: :model do
before do
allow(WarpEngine.config).to receive(:application_token_owner_class).and_return("TestOwner")
end
describe "token generation" do
it "generates a plain token on create and stores only its digest and prefix" do
token = create(:application_token)
expect(token.plain_token).to match(/\A\h{48}\z/)
expect(token.token_prefix).to eq(token.plain_token.first(8))
expect(token.token_digest).to eq(Digest::SHA256.hexdigest(token.plain_token))
end
it "does not expose the plain token on a reloaded record" do
token = create(:application_token)
expect(described_class.find(token.id).plain_token).to be_nil
end
end
describe "validations" do
it "requires an owner" do
token = build(:application_token, owner: nil)
expect(token).not_to be_valid
expect(token.errors[:owner]).to be_present
end
it "requires a name" do
expect(build(:application_token, name: nil)).not_to be_valid
end
it "requires at least one scope" do
expect(build(:application_token, scopes: [])).not_to be_valid
end
it "fills owner_type from the configuration" do
token = create(:application_token)
expect(token.owner_type).to eq("TestOwner")
end
it "rejects an owner_type differing from the configuration" do
token = build(:application_token, owner_type: "WarpEngine::Software")
expect(token).not_to be_valid
expect(token.errors[:owner_type]).to be_present
end
it "rejects creation when no owner class is configured" do
allow(WarpEngine.config).to receive(:application_token_owner_class).and_return(nil)
token = build(:application_token, owner_type: "TestOwner")
expect(token).not_to be_valid
expect(token.errors[:base]).to be_present
end
end
describe "#scopes_string" do
it "round-trips a comma separated list" do
token = build(:application_token)
token.scopes_string = "update, deploy,update ,"
expect(token.scopes).to eq(%w[update deploy])
expect(token.scopes_string).to eq("update, deploy")
end
end
describe ".authenticate" do
it "returns the token for a valid plain token and scope" do
token = create(:application_token)
expect(described_class.authenticate(token.plain_token, required_scope: "update")).to eq(token)
end
it "returns nil for a blank or unknown token" do
create(:application_token)
expect(described_class.authenticate(nil)).to be_nil
expect(described_class.authenticate("")).to be_nil
expect(described_class.authenticate("nem-letezo")).to be_nil
end
it "returns nil when the required scope is missing" do
token = create(:application_token, scopes: [ "deploy" ])
expect(described_class.authenticate(token.plain_token, required_scope: "update")).to be_nil
end
it "returns nil for an expired token" do
token = create(:application_token, :expired)
expect(described_class.authenticate(token.plain_token, required_scope: "update")).to be_nil
end
it "returns nil for a revoked token" do
token = create(:application_token)
token.revoke!
expect(described_class.authenticate(token.plain_token, required_scope: "update")).to be_nil
end
end
describe "#revoke!" do
it "soft deletes the token" do
token = create(:application_token)
token.revoke!
expect(described_class.find_by(id: token.id)).to be_nil
expect(described_class.unscoped.find(token.id).deleted_at).to be_present
end
end
describe "#touch_last_used!" do
it "stamps last_used_at" do
token = create(:application_token)
expect { token.touch_last_used! }.to change { token.reload.last_used_at }.from(nil)
end
end
end
@@ -0,0 +1,122 @@
require "rails_helper"
RSpec.describe "POST /build/publish", type: :request do
before do
allow(WarpEngine.config).to receive(:update_secret).and_return("s3cret")
end
def stub_updater
updater = instance_double(WarpEngine::SoftwareUpdater::Tic80Service)
allow(WarpEngine::SoftwareUpdater::Tic80Service).to receive(:new).and_return(updater)
allow(updater).to receive(:update)
updater
end
def publish(headers: { "X-Update-Secret" => "s3cret" }, params: {})
post "/build/publish", headers: headers,
params: { name: "game", platform: "tic80", version: "1.0" }.merge(params)
end
it "rejects requests without a secret" do
publish(headers: {})
expect(response).to have_http_status(:unauthorized)
end
it "does not accept the secret as a query param" do
post "/build/publish", params: { secret: "s3cret", name: "game", platform: "tic80", version: "1.0" }
expect(response).to have_http_status(:unauthorized)
end
it "runs the updater and returns the published release" do
updater = stub_updater
expect(updater).to receive(:update).with("game", "1.0")
publish
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body)).to include("published" => true, "name" => "game",
"platform" => "tic80", "version" => "1.0")
end
it "requires name, platform and version" do
%i[name platform version].each do |key|
publish(params: { key => "" })
expect(response).to have_http_status(:bad_request)
end
end
it "rejects an unsupported platform" do
publish(params: { platform: "amiga" })
expect(response).to have_http_status(:bad_request)
end
context "with application_token_source :database" do
before do
allow(WarpEngine.config).to receive(:application_token_source).and_return(:database)
allow(WarpEngine.config).to receive(:application_token_owner_class).and_return("TestOwner")
stub_updater
end
it "accepts a token with the update scope" do
token = create(:application_token)
publish(headers: { "X-Update-Secret" => token.plain_token })
expect(response).to have_http_status(:ok)
end
it "rejects the ENV shared secret" do
publish
expect(response).to have_http_status(:unauthorized)
end
context "with enforce_software_ownership" do
before { allow(WarpEngine.config).to receive(:enforce_software_ownership).and_return(true) }
let(:token) { create(:application_token) }
it "rejects publishing another owner's software" do
create(:software, name: "game", owner: create(:test_owner))
publish(headers: { "X-Update-Secret" => token.plain_token })
expect(response).to have_http_status(:forbidden)
end
it "claims an ownerless software for the token owner" do
software = create(:software, name: "game")
publish(headers: { "X-Update-Secret" => token.plain_token })
expect(response).to have_http_status(:ok)
expect(software.reload.owner).to eq(token.owner)
end
it "does not claim ownership with an unrestricted token" do
software = create(:software, name: "game")
internal = create(:application_token, :unrestricted)
publish(headers: { "X-Update-Secret" => internal.plain_token })
expect(response).to have_http_status(:ok)
expect(software.reload.owner_id).to be_nil
end
it "keeps the existing owner on republish" do
owner = create(:test_owner)
software = create(:software, name: "game", owner: owner)
token = create(:application_token, owner: owner)
publish(headers: { "X-Update-Secret" => token.plain_token })
expect(response).to have_http_status(:ok)
expect(software.reload.owner).to eq(owner)
end
end
end
end
@@ -0,0 +1,161 @@
require "rails_helper"
require "tmpdir"
require "digest"
RSpec.describe "POST /build/upload", type: :request do
let(:tmpdir) { Dir.mktmpdir }
before do
allow(WarpEngine.config).to receive(:update_secret).and_return("s3cret")
allow(WarpEngine.config).to receive(:file_container_path).and_return(tmpdir)
end
after { FileUtils.rm_rf(tmpdir) }
def artifact(filename, content: "zipdata")
path = File.join(Dir.mktmpdir, filename)
File.write(path, content)
Rack::Test::UploadedFile.new(path, "application/zip")
end
def upload(file:, name: "game", version: "1.0", headers: { "X-Update-Secret" => "s3cret" }, extra: {})
post "/build/upload", headers: headers,
params: { name: name, version: version, file: file }.merge(extra)
end
it "rejects requests without a secret" do
upload(file: artifact("game-1.0.html.zip"), headers: {})
expect(response).to have_http_status(:unauthorized)
end
it "does not accept the secret as a query param" do
post "/build/upload", params: { secret: "s3cret", name: "game", version: "1.0",
file: artifact("game-1.0.html.zip") }
expect(response).to have_http_status(:unauthorized)
end
it "stores a valid artifact and returns its digest" do
file = artifact("game-1.0.html.zip", content: "zipdata")
upload(file: file)
expect(response).to have_http_status(:ok)
body = JSON.parse(response.body)
expect(body["file"]).to eq("game-1.0.html.zip")
expect(body["sha256"]).to eq(Digest::SHA256.hexdigest("zipdata"))
expect(File.read(File.join(tmpdir, "game-1.0.html.zip"))).to eq("zipdata")
end
it "accepts binary target artifacts with the <name>-<version>- prefix" do
upload(file: artifact("game-1.0-win-x64.zip"))
expect(response).to have_http_status(:ok)
end
it "rejects a filename outside the <name>-<version> convention" do
upload(file: artifact("other-2.0.html.zip"))
expect(response).to have_http_status(:bad_request)
expect(File.exist?(File.join(tmpdir, "other-2.0.html.zip"))).to be(false)
end
it "requires name and version" do
post "/build/upload", headers: { "X-Update-Secret" => "s3cret" },
params: { file: artifact("game-1.0.html.zip") }
expect(response).to have_http_status(:bad_request)
end
it "requires a file" do
post "/build/upload", headers: { "X-Update-Secret" => "s3cret" },
params: { name: "game", version: "1.0" }
expect(response).to have_http_status(:bad_request)
end
it "rejects a file over max_upload_size" do
allow(WarpEngine.config).to receive(:max_upload_size).and_return(3)
upload(file: artifact("game-1.0.html.zip", content: "toolarge"))
expect(response).to have_http_status(:payload_too_large)
end
it "verifies a provided sha256 and rejects a mismatch" do
upload(file: artifact("game-1.0.html.zip"), extra: { sha256: "0" * 64 })
expect(response).to have_http_status(:unprocessable_entity)
expect(File.exist?(File.join(tmpdir, "game-1.0.html.zip"))).to be(false)
end
it "accepts a matching sha256" do
upload(file: artifact("game-1.0.html.zip", content: "zipdata"),
extra: { sha256: Digest::SHA256.hexdigest("zipdata") })
expect(response).to have_http_status(:ok)
end
context "with application_token_source :database" do
before do
allow(WarpEngine.config).to receive(:application_token_source).and_return(:database)
allow(WarpEngine.config).to receive(:application_token_owner_class).and_return("TestOwner")
end
it "accepts a token with the upload scope" do
token = create(:application_token, scopes: [ "update", "upload" ])
upload(file: artifact("game-1.0.html.zip"), headers: { "X-Update-Secret" => token.plain_token })
expect(response).to have_http_status(:ok)
expect(token.reload.last_used_at).to be_present
end
it "rejects a token without the upload scope" do
token = create(:application_token, scopes: [ "update" ])
upload(file: artifact("game-1.0.html.zip"), headers: { "X-Update-Secret" => token.plain_token })
expect(response).to have_http_status(:unauthorized)
end
context "with enforce_software_ownership" do
before { allow(WarpEngine.config).to receive(:enforce_software_ownership).and_return(true) }
let(:token) { create(:application_token, scopes: [ "upload" ]) }
it "allows uploading to the token owner's software" do
create(:software, name: "game", owner: token.owner)
upload(file: artifact("game-1.0.html.zip"), headers: { "X-Update-Secret" => token.plain_token })
expect(response).to have_http_status(:ok)
end
it "rejects uploading to another owner's software" do
create(:software, name: "game", owner: create(:test_owner))
upload(file: artifact("game-1.0.html.zip"), headers: { "X-Update-Secret" => token.plain_token })
expect(response).to have_http_status(:forbidden)
end
it "allows an unrestricted token regardless of owner" do
create(:software, name: "game", owner: create(:test_owner))
internal = create(:application_token, :unrestricted, scopes: [ "upload" ])
upload(file: artifact("game-1.0.html.zip"), headers: { "X-Update-Secret" => internal.plain_token })
expect(response).to have_http_status(:ok)
end
it "allows uploading to an ownerless software" do
create(:software, name: "game")
upload(file: artifact("game-1.0.html.zip"), headers: { "X-Update-Secret" => token.plain_token })
expect(response).to have_http_status(:ok)
end
end
end
end
@@ -30,6 +30,21 @@ RSpec.describe "GET /api/software", type: :request do
expect(counts[other.id]).to eq(1)
end
it "filters by owner_id and exposes ownerId" do
owner = create(:test_owner)
mine = create(:software, name: "mine", owner: owner)
create(:software, name: "other", owner: create(:test_owner))
create(:software, name: "ownerless")
get "/api/software", params: { owner_id: owner.id }
json = JSON.parse(response.body)
expect(json["softwares"].length).to eq(1)
expect(json["softwares"].first["software"]["name"]).to eq("mine")
expect(json["softwares"].first["software"]["ownerId"]).to eq(owner.id)
expect(mine.reload.owner).to eq(owner)
end
it "excludes soft-deleted software" do
create(:software, deleted_at: Time.current)
@@ -1,46 +0,0 @@
require "rails_helper"
RSpec.describe "GET /update", type: :request do
before do
allow(WarpEngine.config).to receive(:update_secret).and_return("s3cret")
end
it "rejects requests without a secret" do
get "/update", params: { platform: "tic80", name: "game", version: "1.0" }
expect(response).to have_http_status(:unauthorized)
end
it "rejects requests with a wrong secret" do
get "/update", params: { secret: "wrong", platform: "tic80", name: "game", version: "1.0" }
expect(response).to have_http_status(:unauthorized)
end
it "rejects every request when no secret is configured" do
allow(WarpEngine.config).to receive(:update_secret).and_return(nil)
get "/update", params: { secret: "", platform: "tic80", name: "game", version: "1.0" }
expect(response).to have_http_status(:unauthorized)
end
it "requires a version" do
get "/update", headers: { "X-Update-Secret" => "s3cret" }, params: { platform: "tic80", name: "game" }
expect(response).to have_http_status(:bad_request)
expect(response.body).to eq("Version not provided")
end
it "runs the updater with a valid secret" do
updater = instance_double(WarpEngine::SoftwareUpdater::Tic80Service)
allow(WarpEngine::SoftwareUpdater::Tic80Service).to receive(:new).and_return(updater)
expect(updater).to receive(:update).with("game", "1.0")
get "/update", headers: { "X-Update-Secret" => "s3cret" },
params: { platform: "tic80", name: "game", version: "1.0" }
expect(response).to have_http_status(:ok)
expect(response.body).to eq("Updated")
end
end