Kérésre: minden magyarázó komment kikerült a forrásfájlokból — 89 Ruby, 16 TypeScript, 14 Vue, plusz a CSS/JS/CJS. Nem soralapú kereséssel: a Ruby-t a Ripper tokenizálta, a JS/TS/CSS-t állapotgép járta végig, hogy az URL-ekben, reguláris kifejezésekben és heredocokban álló // és # jelek helyükön maradjanak. Három komment maradt, mert nélkülük nem indul a kód: az entrypoint.sh shebangja, a vite-env.d.ts hármas perjeles referenciája, és a sanitize teszt @vitest-environment direktívája (ez utóbbi a magyarázó része nélkül). Egy helyen kódot is kellett írni: a CommandBlock másolás-hibaágán a komment volt a catch egyetlen tartalma, és üres blokkot az eslint nem enged — a copied jelző visszaállítása került a helyére. A yaml, Dockerfile, Makefile, erb és markdown fájlokat nem érintettem. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,6 @@ ActiveAdmin.register Store do
|
||||
|
||||
menu priority: 5, label: "🛒 Stores"
|
||||
|
||||
# Active first and by default: the registry is what clients actually read, so the
|
||||
# useful question on opening this page is "what is being offered right now".
|
||||
scope("Active", default: true) { |scope| scope.active }
|
||||
scope("Inactive") { |scope| scope.where(active: false) }
|
||||
scope :all
|
||||
@@ -21,8 +19,7 @@ ActiveAdmin.register Store do
|
||||
end
|
||||
column :updated_at
|
||||
actions defaults: true do |store|
|
||||
# One click, because this is the thing an admin comes to this page to do — and
|
||||
# the alternative is Edit, a checkbox and Save for a single boolean.
|
||||
|
||||
link_to store.active? ? "Hide" : "List",
|
||||
toggle_admin_store_path(store),
|
||||
method: :put
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
//= link active_admin.css
|
||||
//= link active_admin.js
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//= require active_admin/base
|
||||
|
||||
(function() {
|
||||
var PATH_INPUT_SELECTOR = 'input[id*="release_assets_attributes"][id$="_path"]:not([data-browse-added])';
|
||||
var _pickerTarget = null;
|
||||
@@ -89,7 +87,6 @@
|
||||
|
||||
var html = '';
|
||||
|
||||
// Breadcrumb
|
||||
var parts = dir ? dir.split('/').filter(function(p){return p;}) : [];
|
||||
html += '<div class="fm-breadcrumbs"><a href="#" data-dir="">softwares/</a>';
|
||||
var acc = '';
|
||||
@@ -99,19 +96,16 @@
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// Upload bar (also a drop zone)
|
||||
html += '<div class="fm-actions fm-dropzone" id="fm-picker-dropzone">';
|
||||
html += '<input type="file" id="fm-picker-file" />';
|
||||
html += '<button class="fm-btn" id="fm-picker-upload-btn">Upload</button>';
|
||||
html += '<span class="fm-drop-hint">or drag & drop files here</span>';
|
||||
html += '</div>';
|
||||
|
||||
// Table
|
||||
html += '<table class="fm-table"><thead><tr>';
|
||||
html += '<th style="width:50px">Type</th><th>Name</th><th style="width:90px">Size</th>';
|
||||
html += '<th style="width:80px"></th></tr></thead><tbody>';
|
||||
|
||||
// Parent dir
|
||||
if (dir) {
|
||||
var parentDir = dir.indexOf('/') >= 0 ? dir.substring(0, dir.lastIndexOf('/')) : '';
|
||||
html += '<tr><td class="fm-type">DIR</td>';
|
||||
@@ -139,10 +133,8 @@
|
||||
html += '</tbody></table>';
|
||||
body.innerHTML = html;
|
||||
|
||||
// Event delegation
|
||||
body.addEventListener('click', handlePickerClick);
|
||||
|
||||
// Drag & drop
|
||||
var dropzone = document.getElementById('fm-picker-dropzone');
|
||||
if (dropzone) {
|
||||
['dragenter', 'dragover'].forEach(function(evt) {
|
||||
@@ -162,21 +154,18 @@
|
||||
function handlePickerClick(e) {
|
||||
var t = e.target;
|
||||
|
||||
// Navigate directory
|
||||
if (t.tagName === 'A' && t.hasAttribute('data-dir')) {
|
||||
e.preventDefault();
|
||||
loadDir(t.getAttribute('data-dir'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Select file/folder
|
||||
if (t.hasAttribute('data-select')) {
|
||||
e.preventDefault();
|
||||
selectPath(t.getAttribute('data-select'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload button
|
||||
if (t.id === 'fm-picker-upload-btn') {
|
||||
e.preventDefault();
|
||||
var fileInput = document.getElementById('fm-picker-file');
|
||||
@@ -233,7 +222,6 @@
|
||||
return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
// Live image preview for software_images image_id selects
|
||||
function addImagePreviewListeners() {
|
||||
var selects = document.querySelectorAll('select[id*="image_id"]:not([data-preview-added])');
|
||||
selects.forEach(function(sel) {
|
||||
@@ -241,7 +229,6 @@
|
||||
sel.addEventListener('change', function() {
|
||||
var hint = sel.closest('li').querySelector('.inline-hints');
|
||||
if (!hint) {
|
||||
// Create hint element if it doesn't exist
|
||||
hint = document.createElement('p');
|
||||
hint.className = 'inline-hints';
|
||||
sel.closest('li').appendChild(hint);
|
||||
@@ -255,7 +242,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Standalone Files page drag & drop
|
||||
function initPageDropzone() {
|
||||
var dz = document.getElementById('fm-page-dropzone');
|
||||
if (!dz) return;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
@import "active_admin/base";
|
||||
@import "activeadmin_blaze_theme/theme";
|
||||
|
||||
// Force utility navigation visible (blaze theme may hide it)
|
||||
#utility_nav { display: flex !important; visibility: visible !important; }
|
||||
#utility_nav #site_link a {
|
||||
color: #fff !important;
|
||||
@@ -11,7 +10,6 @@
|
||||
&:hover { text-decoration: underline; }
|
||||
}
|
||||
|
||||
// File Manager
|
||||
.fm-breadcrumbs {
|
||||
background: #f5f5f5; padding: 10px 14px; border-radius: 4px; margin-bottom: 16px; font-family: monospace; font-size: 14px;
|
||||
a { color: #5a6268; text-decoration: none; &:hover { text-decoration: underline; } }
|
||||
@@ -37,7 +35,6 @@
|
||||
.fm-icon-danger:hover { filter: brightness(1.3); }
|
||||
.fm-danger { color: #dc3545; }
|
||||
|
||||
// Emoji action buttons in AA index tables
|
||||
.table_actions {
|
||||
a.view_link, a.edit_link, a.delete_link {
|
||||
font-size: 0 !important;
|
||||
@@ -61,14 +58,12 @@
|
||||
.fm-dropzone { transition: background 0.15s, border-color 0.15s; border: 2px dashed transparent; }
|
||||
.fm-dropzone.fm-dragover { background: #e8f4fd !important; border-color: #4a90d9 !important; }
|
||||
|
||||
// Browse button in release forms
|
||||
.fm-browse-btn {
|
||||
display: inline-block; margin-left: 8px; padding: 4px 10px; background: #6c757d; color: #fff !important; border-radius: 3px; font-size: 11px; text-decoration: none !important; vertical-align: middle;
|
||||
&:hover { background: #5a6268; color: #fff !important; }
|
||||
&:visited { color: #fff !important; }
|
||||
}
|
||||
|
||||
// Inline file picker modal
|
||||
.fm-modal-overlay {
|
||||
position: fixed; inset: 0; z-index: 9999; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ class Api::WikiController < ApiController
|
||||
end
|
||||
property :error, String, desc: "Error message (null on success)"
|
||||
end
|
||||
# GET /api/wiki/pages?tag=blog|howto[&limit=30][&body=1]
|
||||
|
||||
def index
|
||||
render json: WikiService.new.index(
|
||||
tag: params[:tag],
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
# A store a client can install from: a name and a WarpEngine catalog.
|
||||
#
|
||||
# That is the whole record, and deliberately so. A client takes identity from it — the
|
||||
# name, the catalog, and a slug derived from the catalog host — and everything else from
|
||||
# the store engine it carries. How a store behaves is fixed per installed client, which
|
||||
# knows its own machine; a copy of it here would be a second authority over decisions
|
||||
# the client has already made, including which directories it may delete from.
|
||||
#
|
||||
# This is deliberately not part of WarpEngine. The engine serves one catalog and has no
|
||||
# business knowing which stores exist for it; the registry is a property of this site,
|
||||
# which is what the graphical client asks.
|
||||
class Store < ApplicationRecord
|
||||
URL = %r{\Ahttps?://\S+\z}
|
||||
|
||||
@@ -17,10 +6,6 @@ class Store < ApplicationRecord
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
# Two different "not listed". Soft deletion is "this store is gone"; `active` is
|
||||
# "not right now" — a catalog still being set up, or one pulled from the picker for
|
||||
# a while. The client cannot tell the difference and should not have to: it lists
|
||||
# whatever /api/stores hands it.
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :ordered, -> { order(:name) }
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ class StoreSerializer < Blueprinter::Base
|
||||
include WarpEngine::TimestampFields
|
||||
|
||||
field :name
|
||||
# camelCase, as the catalog's own payloads use — one convention for a client
|
||||
# that reads both.
|
||||
|
||||
field(:catalogUrl) { |store| store.catalog_url }
|
||||
end
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
class StoreService
|
||||
# Only the active ones. An inactive store is simply absent from the registry — the
|
||||
# client has no state for "there but switched off", and inventing one would mean
|
||||
# every client release having an opinion about it.
|
||||
|
||||
def index
|
||||
StoreSerializer.render_as_hash(Store.active.ordered)
|
||||
end
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
require "net/http"
|
||||
require "json"
|
||||
|
||||
# Fetches wiki pages (blog, howto, …) from the Grav backend, filtered by tag.
|
||||
#
|
||||
# Grav exposes: GET {WIKI_GRAV_URL}/custom/pages.json?tag=<tag>[&limit=<n>][&body=1]
|
||||
# and returns:
|
||||
# {
|
||||
# "tag": "howto", "count": 24,
|
||||
# "pages": [
|
||||
# { "id", "path", "title", "description",
|
||||
# "createdAt", "updatedAt", "locale", "route", "tags",
|
||||
# # with body=1 also:
|
||||
# "render" (rendered HTML),
|
||||
# "content" (raw markdown) }
|
||||
# ]
|
||||
# }
|
||||
#
|
||||
# This replaces the frontend's direct WikiJS GraphQL calls with a simple GET,
|
||||
# and points at Grav (which is taking over from wiki.teletypegames.org).
|
||||
class WikiService
|
||||
GRAV_URL = ENV.fetch("WIKI_GRAV_URL", "http://localhost:8080").freeze
|
||||
|
||||
@@ -44,6 +27,5 @@ class WikiService
|
||||
{ "tag" => tag, "count" => 0, "pages" => [], "error" => e.message }
|
||||
end
|
||||
|
||||
# Az RSS feedek ezen a néven hívják.
|
||||
alias_method :pages, :index
|
||||
end
|
||||
|
||||
@@ -22,8 +22,6 @@ module Api
|
||||
config.hosts << ENV["WEBAPP_TECHNICAL_DOMAIN"] if ENV["WEBAPP_TECHNICAL_DOMAIN"].present?
|
||||
config.hosts << "teletypegames.org"
|
||||
|
||||
# Traefik terminates SSL so Rails sees HTTP internally; disable the origin
|
||||
# header check (CSRF token itself is still validated)
|
||||
config.action_controller.forgery_protection_origin_check = false
|
||||
|
||||
config.autoload_lib(ignore: %w[assets tasks])
|
||||
|
||||
@@ -10,6 +10,5 @@ Rails.application.configure do
|
||||
|
||||
config.log_level = :warn
|
||||
|
||||
# Rack::Test example.org hostját ne blokkolja a host authorization
|
||||
config.hosts.clear
|
||||
end
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
# Set a JS-readable cookie when an admin signs in/out.
|
||||
# Devise's remember_admin_user_token cookie is HttpOnly,
|
||||
# so the frontend cannot read it. This non-HttpOnly cookie
|
||||
# lets the frontend show/hide the Admin menu link.
|
||||
|
||||
Rails.application.config.to_prepare do
|
||||
ApplicationController.class_eval do
|
||||
after_action :sync_admin_cookie
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
# WarpEngine host configuration. to_prepare: re-runs after reloads, hence
|
||||
# assignment (not <<) to stay idempotent.
|
||||
Rails.application.config.to_prepare do
|
||||
WarpEngine.configure do |c|
|
||||
# Owner of the /build/* DB tokens. Switching to :database mode
|
||||
# (c.application_token_source = :database) must wait until CI uses DB
|
||||
# tokens — the flip invalidates UPDATE_SECRET immediately.
|
||||
|
||||
c.application_token_source = :database
|
||||
c.application_token_owner_class = "AdminUser"
|
||||
|
||||
# Woodpecker configuration extension (/build/config): the served platforms
|
||||
# and their builder images. An image bump is one line here, rolled out to
|
||||
# every repo by the deploy.
|
||||
c.ci_platforms = {
|
||||
"godot" => { builder: "git.teletypegames.org/build/godot-builder:4.7.1" },
|
||||
"phaser" => { builder: "git.teletypegames.org/build/phaser-builder:latest" },
|
||||
@@ -20,16 +13,13 @@ Rails.application.config.to_prepare do
|
||||
"ebitengine" => { builder: "git.teletypegames.org/build/ebitengine-builder:latest" },
|
||||
"tic80" => { builder: "git.teletypegames.org/build/tic80pro:latest" }
|
||||
}
|
||||
# Explicit URL: request.base_url would yield http:// behind the host nginx
|
||||
# (no X-Forwarded-Proto reaches Rails), and the resulting 301 makes the
|
||||
# pipeline's curl steps silently no-op.
|
||||
|
||||
c.ci_update_server = "https://teletypegames.org"
|
||||
c.ci_extension_public_key_url = "https://ci.teletypegames.org/api/signature/public-key"
|
||||
|
||||
# Woodpecker CI management (repo sync, secret provisioning, pipeline control)
|
||||
c.woodpecker_url = ENV["WOODPECKER_URL"] # e.g. "https://ci.teletypegames.org"
|
||||
c.woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"] # Woodpecker PAT with admin access
|
||||
c.woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"] # forge org (e.g. "games")
|
||||
c.woodpecker_url = ENV["WOODPECKER_URL"]
|
||||
c.woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"]
|
||||
c.woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"]
|
||||
|
||||
c.image_owners = [
|
||||
{
|
||||
|
||||
@@ -14,8 +14,5 @@ Rails.application.routes.draw do
|
||||
get "rss/howtos", to: "rss#howtos"
|
||||
end
|
||||
|
||||
# Utolsó sor: a host route-jai nyernek, a katalógus-útvonalakat
|
||||
# (/api/software*, /api/builds*, /api/image, /api/download, /build/*, /file/*)
|
||||
# az engine adja.
|
||||
mount WarpEngine::Engine => "/"
|
||||
end
|
||||
|
||||
@@ -9,7 +9,6 @@ class AddCascadeToSoftwareForeignKeys < ActiveRecord::Migration[8.1]
|
||||
remove_foreign_key :software_images, :softwares
|
||||
add_foreign_key :software_images, :softwares, on_delete: :cascade
|
||||
|
||||
# Keep download stats when a release is removed.
|
||||
remove_foreign_key :downloads, name: "fk_downloads_release"
|
||||
add_foreign_key :downloads, :releases, name: "fk_downloads_release", on_delete: :nullify
|
||||
end
|
||||
|
||||
@@ -20,8 +20,6 @@ class CreateReleaseAssetsAndBackfill < ActiveRecord::Migration[8.1]
|
||||
add_index :release_assets, :path, name: "idx_release_assets_path"
|
||||
add_foreign_key :release_assets, :releases, name: "fk_releases_release_assets", on_delete: :cascade
|
||||
|
||||
# A soft-deletelt release-ek asset sorai a release deleted_at-jét öröklik,
|
||||
# így az unscoped alapú admin/updater logika konzisztens marad.
|
||||
COLUMN_KINDS.each do |column, kind|
|
||||
execute <<~SQL.squish
|
||||
INSERT INTO release_assets (release_id, kind, path, deleted_at, created_at, updated_at)
|
||||
|
||||
@@ -17,7 +17,6 @@ class DropLegacyPathColumnsFromReleases < ActiveRecord::Migration[8.1]
|
||||
add_column :releases, column, :string
|
||||
end
|
||||
|
||||
# rollbacknél az adat a release_assets-ből áll vissza, nem veszik el
|
||||
COLUMN_KINDS.each do |column, kind|
|
||||
execute <<~SQL.squish
|
||||
UPDATE releases r
|
||||
|
||||
@@ -3,9 +3,7 @@ class CreateStores < ActiveRecord::Migration[8.1]
|
||||
create_table :stores, id: { type: :bigint, unsigned: true },
|
||||
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci",
|
||||
if_not_exists: true do |t|
|
||||
# A store is a WarpEngine catalog plus the repository that configures a
|
||||
# client for it. The registry lives here rather than in the engine: the
|
||||
# engine serves one catalog and knows nothing about who ships stores for it.
|
||||
|
||||
t.string :name, null: false
|
||||
t.string :catalog_url, null: false
|
||||
t.string :store_repository_url, null: false
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
class AllowStoresWithoutARepository < ActiveRecord::Migration[8.1]
|
||||
# A store repository is now optional. The client only ever needed identity from
|
||||
# it — a name, a catalog and a slug — and the engine's own defaults cover
|
||||
# everything else, so a record with a catalog URL is a complete store. A
|
||||
# repository is still honoured when there is one: it stays the authority on how
|
||||
# that store behaves.
|
||||
|
||||
def change
|
||||
change_column_null :stores, :store_repository_url, true
|
||||
end
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
class CarryTheStoreConfigInTheRegistry < ActiveRecord::Migration[8.1]
|
||||
# The store's own configuration moves into this record.
|
||||
#
|
||||
# It used to live as a `config.json` in a repository the client fetched over HTTP,
|
||||
# which made a store's behaviour depend on a second thing existing and staying
|
||||
# reachable. The registry already answers what a store *is*; carrying how it behaves
|
||||
# in the same record makes this the one source of truth, and lets a store exist with
|
||||
# no repository at all — which is the ordinary case now that the store engine ships
|
||||
# inside the client.
|
||||
#
|
||||
# Nullable, because a store that configures nothing is still a store: the client
|
||||
# falls back to the engine's built-in defaults, which need only a name and a catalog.
|
||||
|
||||
def change
|
||||
add_column :stores, :config, :json
|
||||
end
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
class AStoreIsANameAndACatalog < ActiveRecord::Migration[8.1]
|
||||
# Both extras go. A store record is a name and a catalog, and nothing else.
|
||||
#
|
||||
# `config` was added earlier today on the idea that the registry should say how each
|
||||
# store behaves. It should not: the configuration is fixed per installed client — the
|
||||
# client carries it and knows its own machine — so a copy on the server was a second
|
||||
# authority over decisions the client had already made correctly, including where it
|
||||
# may delete. Keeping two stores on one machine apart is a subfolder, which the client
|
||||
# derives itself.
|
||||
#
|
||||
# `store_repository_url` goes for the same reason it stopped being read: the store
|
||||
# engines it pointed at do not exist any more, and a URL nobody follows is a URL that
|
||||
# goes stale.
|
||||
|
||||
def change
|
||||
remove_column :stores, :config, :json
|
||||
remove_column :stores, :store_repository_url, :string
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
class AddActiveToStores < ActiveRecord::Migration[8.1]
|
||||
# A store that exists but should not be offered yet.
|
||||
#
|
||||
# Soft deletion already covered "this store is gone"; what was missing is "not yet" —
|
||||
# a catalog being set up, or one taken out of the picker for a while without losing
|
||||
# the row and its history. The client has no notion of either: it lists whatever
|
||||
# /api/stores hands it, so the filtering has to happen here.
|
||||
#
|
||||
# Default true, so every store that exists today keeps being listed. A migration that
|
||||
# silently emptied the registry would be a client with nothing to install from.
|
||||
#
|
||||
# No index: this table holds a handful of rows and is read once per client on first
|
||||
# run. An index on a two-valued column would be ceremony.
|
||||
|
||||
def change
|
||||
add_column :stores, :active, :boolean, null: false, default: true
|
||||
end
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
# This file is auto-generated from the current state of the database. Instead
|
||||
# of editing this file, please use the migrations feature of Active Record to
|
||||
# incrementally modify your database, and then regenerate this schema definition.
|
||||
#
|
||||
# This file is the source Rails uses to define your schema when running `bin/rails
|
||||
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
|
||||
# be faster and is potentially less error prone than running all of your
|
||||
# migrations from scratch. Old migrations may fail to apply correctly if those
|
||||
# migrations use external dependencies or application code.
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_08_19_121824) do
|
||||
create_table "admin_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
|
||||
@@ -16,9 +16,6 @@ end
|
||||
end
|
||||
end
|
||||
|
||||
# The store registry the graphical desktop client reads. Our own catalog is the
|
||||
# first record; anyone running this site would add their own the same way, from
|
||||
# the admin panel or here.
|
||||
Store.find_or_create_by!(name: "Teletype Games") do |store|
|
||||
store.catalog_url = ENV.fetch("STORE_CATALOG_URL", "https://teletypegames.org")
|
||||
end
|
||||
|
||||
@@ -19,8 +19,7 @@ RSpec.describe Api::StoresController, type: :request do
|
||||
get "/api/stores"
|
||||
|
||||
store = JSON.parse(response.body).first
|
||||
# Exactly two fields: a name and a catalog are the whole record, and a client
|
||||
# that starts reading a third would be reading something this site no longer says.
|
||||
|
||||
expect(store.keys).to contain_exactly("name", "catalogUrl")
|
||||
expect(store["catalogUrl"]).to eq("https://teletypegames.org")
|
||||
end
|
||||
@@ -33,9 +32,6 @@ RSpec.describe Api::StoresController, type: :request do
|
||||
expect(JSON.parse(response.body)).to be_empty
|
||||
end
|
||||
|
||||
# Two different "not listed", and a client can tell neither apart from the store
|
||||
# never having existed — which is the point. It has no state for "there but
|
||||
# switched off", so an inactive store is simply absent.
|
||||
it "leaves out inactive stores" do
|
||||
create(:store, name: "Listed")
|
||||
create(:store, name: "Not yet", catalog_url: "https://soon.example", active: false)
|
||||
|
||||
@@ -4,6 +4,6 @@ RSpec.describe Member, type: :model do
|
||||
subject { build(:member) }
|
||||
|
||||
it { should validate_presence_of(:nick) }
|
||||
# MySQL utf8mb4_0900_ai_ci collation: az egyediség DB-szinten case-insensitive
|
||||
|
||||
it { should validate_uniqueness_of(:nick).case_insensitive }
|
||||
end
|
||||
|
||||
@@ -11,7 +11,6 @@ rescue ActiveRecord::PendingMigrationError => e
|
||||
abort e.to_s.strip
|
||||
end
|
||||
|
||||
# A katalógus-factory-k (software, release, download, platform_link) az engine-ben élnek
|
||||
FactoryBot.definition_file_paths << WarpEngine::Engine.root.join("spec/factories")
|
||||
FactoryBot.reload
|
||||
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
require "rails_helper"
|
||||
require "warden/test/helpers"
|
||||
|
||||
# The pipeline admin form could not save at all: the resource never declared
|
||||
# `permit_params`, so ActiveAdmin handed unpermitted attributes to the model and Rails
|
||||
# raised ForbiddenAttributesError on every edit. No model spec could have caught that —
|
||||
# the fault was one layer up — so the check belongs here, where the host's ActiveAdmin
|
||||
# instance actually runs.
|
||||
RSpec.describe "Admin pipelines", type: :request do
|
||||
include Warden::Test::Helpers
|
||||
|
||||
@@ -27,9 +22,6 @@ RSpec.describe "Admin pipelines", type: :request do
|
||||
|
||||
after { Warden.test_reset! }
|
||||
|
||||
# This app keeps forgery protection on in the test environment, and a request spec has
|
||||
# no rendered form to take a token from. The token is not what is under test here, so it
|
||||
# is switched off for the duration and put back afterwards.
|
||||
around do |example|
|
||||
protection = ActionController::Base.allow_forgery_protection
|
||||
ActionController::Base.allow_forgery_protection = false
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
require "rails_helper"
|
||||
require "warden/test/helpers"
|
||||
|
||||
# The admin is where the flag is actually used, and none of it is reachable from a model
|
||||
# spec: the scopes, the toggle action and the batch actions are ActiveAdmin plumbing that
|
||||
# only exists once the host's admin is running. The pipelines resource shipped with a
|
||||
# missing `permit_params` and every edit raised — same layer, same lesson.
|
||||
RSpec.describe "Admin stores", type: :request do
|
||||
include Warden::Test::Helpers
|
||||
|
||||
@@ -19,8 +15,6 @@ RSpec.describe "Admin stores", type: :request do
|
||||
|
||||
after { Warden.test_reset! }
|
||||
|
||||
# Same reasoning as the pipelines spec: a request spec has no rendered form to take a
|
||||
# CSRF token from, and the token is not what is under test.
|
||||
around do |example|
|
||||
protection = ActionController::Base.allow_forgery_protection
|
||||
ActionController::Base.allow_forgery_protection = false
|
||||
@@ -48,8 +42,6 @@ RSpec.describe "Admin stores", type: :request do
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
# The one this page exists for: the form saves the flag rather than dropping it as an
|
||||
# unpermitted attribute, which fails silently — the page redirects and nothing changes.
|
||||
it "saves the flag from the form" do
|
||||
put "/admin/stores/#{listed.id}", params: { store: { active: "0" } }
|
||||
|
||||
@@ -79,8 +71,6 @@ RSpec.describe "Admin stores", type: :request do
|
||||
expect(hidden.reload).not_to be_active
|
||||
end
|
||||
|
||||
# The registry is the whole point of the flag, so the two are checked together: a
|
||||
# change made here has to be what the client sees.
|
||||
it "is what /api/stores answers with" do
|
||||
put "/admin/stores/#{listed.id}/toggle"
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { WikiPage, WikiPageWithContent, WikiPageContent } from '../lib/inte
|
||||
|
||||
import { CONFIG } from '../lib/config'
|
||||
|
||||
// Public content base — used only for building external links to wiki pages.
|
||||
const WIKI_BASE = CONFIG.wikiBase
|
||||
|
||||
interface RawWikiPage {
|
||||
@@ -19,8 +18,6 @@ interface RawWikiPage {
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
// The engines page lists the flagship engines only; the wiki marks those with
|
||||
// a `highlighted` tag next to `engine`.
|
||||
const HIGHLIGHTED_TAG = 'highlighted'
|
||||
|
||||
async function fetchPages(
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
<!-- A főoldal három beágyazott blokkja — bolt, YouTube, Spotify — ugyanaz a
|
||||
forma volt három külön osztálykészlettel megírva. Ez az egy komponens
|
||||
váltja ki mindhármat: bal oldalt a címke, jobbra egy kivezető link, alatta
|
||||
a tartalom. -->
|
||||
<template>
|
||||
<section class="media-band">
|
||||
<header class="media-band-header">
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// A platform neve önmagában elmond mindent, amit egy ikon is elmondana, ezért a
|
||||
// jelölő itt szöveg. Ikon ott marad, ahol nincs mellette szó — a build-mátrixban.
|
||||
|
||||
defineProps<{ platform: string }>()
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
<!-- Márkajel (Discord, Windows, Linux, …). A Lucide készletében nincsenek
|
||||
brand-ikonok, ezért ez a tíz saját inline SVG. currentColor-ral rajzol,
|
||||
tehát a témát külön beállítás nélkül követi.
|
||||
|
||||
Címke nélkül dekoratív (aria-hidden); ha az ikon önmagában áll szöveg
|
||||
nélkül, adj neki `label`-t. -->
|
||||
<template>
|
||||
<svg
|
||||
:width="size" :height="size" viewBox="0 0 24 24"
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
// Márkajelek SVG-útvonalai, 24×24-es rácson. A többségük a simple-icons
|
||||
// készletéből származik (CC0); a Windows- és a LinkedIn-glifet a készlet
|
||||
// védjegyokokból nem tartja, ezek saját, egyszerűsített rajzok.
|
||||
//
|
||||
// Azért inline útvonalak és nem külön csomag, mert tíz ikonért nem éri meg
|
||||
// futásidejű függőség, és így a currentColor-ral együtt követik a témát.
|
||||
export const BRAND_PATHS: Record<string, string[]> = {
|
||||
apple: [
|
||||
"M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701",
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
// Ikonok a faceliftben: a szöveg mellett álló, díszítő ikonok elmaradnak —
|
||||
// ikon csak akkor kerül a felületre, ha nincs mellette szöveg, vagy ha maga az
|
||||
// ikon az adat (build-mátrix pipa, platformjelölő).
|
||||
//
|
||||
// A készlet a Lucide (`lucide-vue-next`), névre szóló importtal a tree-shake
|
||||
// miatt; a márkajelek a BrandIcon-ban vannak, mert a Lucide-ban nincsenek.
|
||||
//
|
||||
// import { Download } from 'lucide-vue-next'
|
||||
// import { ICON } from '../components/icons'
|
||||
// <Download v-bind="ICON" />
|
||||
//
|
||||
export const ICON = { size: 16, strokeWidth: 1.75 } as const
|
||||
|
||||
// Önállóan álló vezérlők (hamburger, bezárás, téma-kapcsoló).
|
||||
export const ICON_CONTROL = { size: 20, strokeWidth: 1.75 } as const
|
||||
|
||||
export { default as BrandIcon } from './BrandIcon.vue'
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('useLoadable', () => {
|
||||
|
||||
it('re-executes after TTL expires', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { withCache } = useLoadable(100) // 100ms TTL
|
||||
const { withCache } = useLoadable(100)
|
||||
|
||||
const fn = vi.fn()
|
||||
await withCache(fn)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const DEFAULT_TTL = 15 * 60 * 1000 // 15 perc
|
||||
const DEFAULT_TTL = 15 * 60 * 1000
|
||||
|
||||
export function useLoadable(ttlMs?: number) {
|
||||
const loading = ref(false)
|
||||
|
||||
@@ -2,14 +2,6 @@ import { describe, expect, it } from 'vitest'
|
||||
import en from '../locales/en'
|
||||
import hu from '../locales/hu'
|
||||
|
||||
/**
|
||||
* The two bundles have to carry the same keys.
|
||||
*
|
||||
* A missing translation is not an error at runtime — vue-i18n falls back and the page
|
||||
* renders the key itself, so `home.store.title` appears on screen looking like a bug
|
||||
* somebody forgot to finish. That is exactly the kind of thing a person only notices
|
||||
* after switching language, which is to say: after it has shipped.
|
||||
*/
|
||||
function paths (value: unknown, prefix = ''): string[] {
|
||||
if (typeof value !== 'object' || value === null) return [prefix]
|
||||
return Object.entries(value as Record<string, unknown>)
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
<div class="app-header-inner">
|
||||
<RouterLink to="/" class="app-logo">Teletype Games</RouterLink>
|
||||
|
||||
<!-- Ikonok nélkül: a nyolc menüpont szövege megmondja, hova visz.
|
||||
Az aktív pontot aláhúzás jelöli, nem külön szín. -->
|
||||
<nav class="nav-desktop">
|
||||
<RouterLink v-for="item in navItems" :key="item.to" :to="item.to" class="nav-link">
|
||||
{{ t(item.label) }}
|
||||
@@ -136,7 +134,6 @@ function localeBtnClass(lang: string) {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
/* ---- Fejléc ---- */
|
||||
.app-header {
|
||||
@apply sticky top-0 z-40 border-b border-line bg-page/90 backdrop-blur;
|
||||
}
|
||||
@@ -176,7 +173,6 @@ function localeBtnClass(lang: string) {
|
||||
@apply lg:hidden;
|
||||
}
|
||||
|
||||
/* ---- Mobil menü ---- */
|
||||
.nav-mobile {
|
||||
@apply flex flex-col border-t border-line bg-page px-4 py-2 lg:hidden;
|
||||
}
|
||||
@@ -187,7 +183,6 @@ function localeBtnClass(lang: string) {
|
||||
@apply text-accent;
|
||||
}
|
||||
|
||||
/* ---- Lábléc ---- */
|
||||
.app-footer {
|
||||
@apply mt-16 border-t border-line bg-surface;
|
||||
}
|
||||
@@ -218,8 +213,7 @@ function localeBtnClass(lang: string) {
|
||||
.footer-license a {
|
||||
@apply text-xs;
|
||||
}
|
||||
/* A retro-kapcsoló stílusa itt él, nem a retro.css-ben: az a fájl már csak
|
||||
akkor töltődik be, amikor valaki bekapcsolja a retro módot. */
|
||||
|
||||
.retro-toggle-btn {
|
||||
@apply rounded-control border border-line px-3 py-1.5 text-xs font-medium text-fg-muted
|
||||
transition-colors hover:border-line-strong hover:text-fg
|
||||
|
||||
@@ -5,12 +5,6 @@ import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import { i18n } from '../../i18n'
|
||||
import AppLayout from '../AppLayout.vue'
|
||||
|
||||
/**
|
||||
* A váz az egyetlen darab, ami minden oldalon ott van, és a facelift éppen ezt
|
||||
* írta át: ikonok nélküli navigáció, valódi lábléc, téma-kapcsoló. Ezek olyan
|
||||
* dolgok, amiket kézzel könnyű elrontani és nehéz észrevenni — a nyelvváltó
|
||||
* mellől eltűnő kapcsoló nem dob hibát, csak nincs ott.
|
||||
*/
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }],
|
||||
@@ -54,7 +48,6 @@ describe('AppLayout', () => {
|
||||
const wrapper = await mountLayout()
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('light')
|
||||
|
||||
// Az első ikongomb a téma-kapcsoló, a második a hamburger.
|
||||
await wrapper.findAll('.icon-btn')[0].trigger('click')
|
||||
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* jsdom, és nem a projekt alapértelmezett happy-dom-ja: a DOMPurify happy-dom
|
||||
* alatt nem ismeri fel a környezetet, és változatlanul visszaadja a bemenetet —
|
||||
* a teszt így zöld lenne úgy, hogy közben semmit nem tisztít.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
/** @vitest-environment jsdom */
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { sanitize } from '../sanitize'
|
||||
|
||||
|
||||
@@ -30,10 +30,6 @@ export function getLatestStable(releases: Release[]): Release | undefined {
|
||||
return filterStableReleases(releases)[0]
|
||||
}
|
||||
|
||||
// A négy státusz közül csak a megjelent kap kiemelést, a többi semleges jelölő.
|
||||
// Az osztálynevek szándékosan teljes szövegként állnak itt: ha a sablon
|
||||
// `status-${status}`-t interpolálna, a Tailwind nem látná őket, és kiszedné a
|
||||
// bundle-ből.
|
||||
const STATUS_BADGE_CLASS: Record<string, string> = {
|
||||
released: 'status-badge status-released',
|
||||
demo: 'status-badge',
|
||||
|
||||
@@ -6,7 +6,4 @@ import App from './App.vue'
|
||||
import './styles/tokens.css'
|
||||
import './styles/global.css'
|
||||
|
||||
// A retro mód stílusai (tuicss + retro.css) az ui.store-ból töltődnek be,
|
||||
// akkor és csak akkor, ha valaki bekapcsolja.
|
||||
|
||||
createApp(App).use(createPinia()).use(router).use(i18n).mount('#app')
|
||||
|
||||
@@ -64,7 +64,7 @@ onMounted(() => store.fetch())
|
||||
.blog-container {
|
||||
@apply min-h-screen bg-page;
|
||||
}
|
||||
/* Olvasnivaló, nem kártyafal: egy hasáb, vonallal elválasztott bejegyzésekkel. */
|
||||
|
||||
.blog-list {
|
||||
@apply flex max-w-[68ch] flex-col;
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
|
||||
<SkeletonCard v-else-if="!data" :count="3" />
|
||||
|
||||
<!-- Desktop table — a pipa és az ikssz maga az adat, ezért marad ikon.
|
||||
A csoportokat vonal választja el, nem hat különböző háttérszín. -->
|
||||
<div v-else class="builds-desktop">
|
||||
<div class="table-wrap">
|
||||
<table class="builds-table">
|
||||
@@ -58,7 +56,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile cards -->
|
||||
<div v-if="data" class="builds-mobile">
|
||||
<div v-for="(info, platform) in data.platforms" :key="platform" class="builds-card">
|
||||
<h2 class="builds-card-title">{{ info.label }}</h2>
|
||||
@@ -163,7 +160,7 @@ onMounted(() => store.fetch())
|
||||
.builds-th-sub {
|
||||
@apply text-center text-[10px];
|
||||
}
|
||||
/* A csoportokat egyetlen vonal választja el — a hat háttérszín helyett. */
|
||||
|
||||
.builds-group-start {
|
||||
@apply border-l border-line;
|
||||
}
|
||||
@@ -179,12 +176,11 @@ onMounted(() => store.fetch())
|
||||
.builds-check {
|
||||
@apply mx-auto text-accent;
|
||||
}
|
||||
/* A hiányzó build nem hiba, ezért nem piros. */
|
||||
|
||||
.builds-xmark {
|
||||
@apply mx-auto text-fg-subtle opacity-50;
|
||||
}
|
||||
|
||||
/* ---- Mobil ---- */
|
||||
.builds-mobile {
|
||||
@apply flex flex-col gap-4 md:hidden;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
<header class="hero-section">
|
||||
<div class="hero-container">
|
||||
<h1 class="hero-title">{{ t('catalog.title') }}</h1>
|
||||
<!-- Playing a game needs the store, so the way to it belongs where somebody
|
||||
arrives rather than under the list they have to scroll past first. -->
|
||||
<p class="hero-subtitle">{{ t('catalog.subtitle') }}</p>
|
||||
<div class="hero-actions">
|
||||
<RouterLink to="/stores" class="btn-accent">{{ t('catalog.getTheStore') }}</RouterLink>
|
||||
@@ -13,8 +11,6 @@
|
||||
</header>
|
||||
|
||||
<main class="main-container">
|
||||
<!-- Szegmentált szűrő: egy sáv, egy kiemelt állapottal — nem hat különböző
|
||||
színű pirula. -->
|
||||
<div class="filter-bar" role="tablist">
|
||||
<button
|
||||
v-for="f in filters"
|
||||
@@ -131,8 +127,7 @@ onMounted(() => store.fetch())
|
||||
.software-card {
|
||||
@apply flex flex-col overflow-hidden rounded-card border border-line bg-surface transition-colors hover:border-line-strong;
|
||||
}
|
||||
/* A borítók egy része fehér hátterű; a semleges alap és a keret miatt sötét
|
||||
témában sem ütnek lyukat az oldalba. */
|
||||
|
||||
.software-thumb-link {
|
||||
@apply flex aspect-video items-center justify-center overflow-hidden border-b border-line bg-raised;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
|
||||
<main v-if="software" class="main-container">
|
||||
<div class="catalog-layout">
|
||||
<!-- Oldalsáv: kép és tények -->
|
||||
<aside class="catalog-sidebar">
|
||||
<div v-if="defaultImageUrl" class="catalog-image-panel">
|
||||
<img
|
||||
@@ -75,7 +74,6 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Fő oszlop -->
|
||||
<div class="catalog-main">
|
||||
<section v-if="software.story" class="card">
|
||||
<h2 class="catalog-section-heading">{{ t('catalogShow.about') }}</h2>
|
||||
@@ -105,8 +103,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Egy lista minden kiadásnak; a fejlesztői verziók jelölve. Korábban
|
||||
ugyanez két külön táblázatban élt, asztali és mobil változatban. -->
|
||||
<section class="card-bare">
|
||||
<h2 class="releases-title">{{ t('catalogShow.allReleases') }}</h2>
|
||||
<article v-for="release in allReleases" :key="release.version" class="release-item">
|
||||
@@ -206,7 +202,6 @@ const latestStable = computed(() => stableReleases.value[0] ?? null)
|
||||
const downloadUrl = (path: string) =>
|
||||
`/api/download?path=${encodeURIComponent(path.replace('/file/', ''))}`
|
||||
|
||||
// az asset-táblázat sorai és sorrendjük; a html nem letöltés, azt a Play gomb viszi
|
||||
const TABLE_KIND_ORDER = [
|
||||
'cartridge', 'source', 'docs',
|
||||
'win_x64', 'win_x86', 'linux_x64', 'linux_arm64', 'linux_x86',
|
||||
@@ -252,7 +247,7 @@ onMounted(async () => {
|
||||
.catalog-main {
|
||||
@apply flex flex-col gap-6;
|
||||
}
|
||||
/* A borítók egy része fehér hátterű, ezért semleges alap és keret kerül alájuk. */
|
||||
|
||||
.catalog-image-panel {
|
||||
@apply flex items-center justify-center rounded-card border border-line bg-raised p-6;
|
||||
}
|
||||
@@ -281,7 +276,6 @@ onMounted(async () => {
|
||||
@apply max-w-[68ch] whitespace-pre-line leading-relaxed text-fg-muted;
|
||||
}
|
||||
|
||||
/* ---- Kiadások ---- */
|
||||
.release-headline {
|
||||
@apply flex flex-wrap items-start justify-between gap-4;
|
||||
}
|
||||
@@ -307,7 +301,6 @@ onMounted(async () => {
|
||||
@apply text-xs text-fg-subtle;
|
||||
}
|
||||
|
||||
/* ---- Letöltések ---- */
|
||||
.asset-list {
|
||||
@apply mt-4 flex flex-col;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
|
||||
<SkeletonCard v-if="!error && publicRepos.length === 0 && !recentCommits.length" :count="4" />
|
||||
|
||||
<!-- Kártyafal helyett lista: a tárolók neve és leírása sorokban olvasható. -->
|
||||
<section v-if="!error && publicRepos.length > 0" class="repo-list">
|
||||
<a
|
||||
v-for="repo in publicRepos" :key="repo.name"
|
||||
|
||||
@@ -12,14 +12,12 @@
|
||||
</header>
|
||||
|
||||
<main class="main-container flex flex-col gap-12">
|
||||
<!-- E-mail -->
|
||||
<section>
|
||||
<h2 class="section-title">{{ t('contact.emailUs') }}</h2>
|
||||
<p class="section-desc">{{ t('contact.emailDesc') }}</p>
|
||||
<a :href="`mailto:${EMAIL}`" class="link text-lg">{{ EMAIL }}</a>
|
||||
</section>
|
||||
|
||||
<!-- Közösség -->
|
||||
<section>
|
||||
<h2 class="section-title">{{ t('contact.community') }}</h2>
|
||||
<p class="section-desc">{{ t('contact.communityDesc') }}</p>
|
||||
@@ -53,8 +51,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Közösségi média — itt a márkajel az, ami felismerhetővé tesz, de a
|
||||
márkaszínek nem: azok egyik témában sem viselkednek jól. -->
|
||||
<section>
|
||||
<h2 class="section-title">{{ t('contact.social') }}</h2>
|
||||
<p class="section-desc">{{ t('contact.socialDesc') }}</p>
|
||||
@@ -68,7 +64,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Spotify -->
|
||||
<MediaBand label="Teletype Games Party">
|
||||
<template #action>
|
||||
<a :href="SPOTIFY_PLAYLIST_URL" target="_blank" rel="noopener noreferrer" class="link text-sm">
|
||||
|
||||
@@ -78,8 +78,6 @@ const cards = computed(() =>
|
||||
enginePages.value.map((page) => ({ page, digest: getEngineDigest(page.content) })),
|
||||
)
|
||||
|
||||
// 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}`
|
||||
|
||||
@@ -91,8 +89,6 @@ onMounted(() => store.fetch())
|
||||
@apply min-h-screen bg-page;
|
||||
}
|
||||
|
||||
/* Kevés motor van, ezért mindegyik teljes szélességű kártyát kap. A sorszám az
|
||||
oldal karaktere — csak halványabban, hogy ne vigye el a szöveg elől a helyet. */
|
||||
.engine-list {
|
||||
@apply flex flex-col gap-6;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
</header>
|
||||
|
||||
<main class="main-container flex flex-col gap-10">
|
||||
<!-- Visszaszámláló — vékony sáv, nem féloldalnyi kártya: egy dátum és egy
|
||||
szám annyi információ, amennyi elfér egy sorban. -->
|
||||
<section v-if="nextEvent" class="countdown">
|
||||
<div class="countdown-main">
|
||||
<div>
|
||||
@@ -29,7 +27,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Kiemelt játék -->
|
||||
<section v-if="highlightedSoftware" class="featured">
|
||||
<div v-if="highlightedImageUrl" class="featured-image-panel">
|
||||
<img :src="highlightedImageUrl" :alt="highlightedSoftware.software.title" class="featured-image" />
|
||||
@@ -59,8 +56,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- A bolt a kiemelt játék alatt: aki épp olvasott egy címről, egy lépésre
|
||||
van attól, hogy futtatni akarja, és ez az a lépés. -->
|
||||
<MediaBand :label="t('home.store.label')">
|
||||
<template #action>
|
||||
<RouterLink to="/stores" class="link text-sm">{{ t('home.store.allStores') }}</RouterLink>
|
||||
@@ -98,7 +93,6 @@
|
||||
</div>
|
||||
</MediaBand>
|
||||
|
||||
<!-- Legutóbbi YouTube-videó -->
|
||||
<MediaBand :label="t('home.latestFromYoutube')">
|
||||
<template #action>
|
||||
<a href="https://www.youtube.com/@teletypegames" target="_blank" class="link text-sm">
|
||||
@@ -134,7 +128,6 @@
|
||||
</div>
|
||||
</MediaBand>
|
||||
|
||||
<!-- Spotify-lejátszási lista -->
|
||||
<MediaBand label="Teletype Games Party">
|
||||
<template #action>
|
||||
<a :href="SPOTIFY_PLAYLIST_URL" target="_blank" class="link text-sm">
|
||||
@@ -170,8 +163,6 @@ import MediaBand from '../../components/MediaBand.vue'
|
||||
import { BrandIcon } from '../../components/icons'
|
||||
import clientScreenshot from '../../assets/warpengine-client.webp'
|
||||
|
||||
// The releases page, not a versioned file: whoever follows it should get whatever is
|
||||
// newest, and this page would otherwise need a release every time the client has one.
|
||||
const CLIENT_RELEASES_URL = 'https://git.teletypegames.org/stores/warp-engine-client/releases'
|
||||
const SPOTIFY_PLAYLIST_URL = 'https://open.spotify.com/playlist/4T7hMkdiERkkW7HaPXqgfX?si=7cdaa091c3274422'
|
||||
|
||||
@@ -206,7 +197,6 @@ onUnmounted(() => eventStore.cleanup())
|
||||
@apply min-h-screen bg-page;
|
||||
}
|
||||
|
||||
/* ---- Visszaszámláló ---- */
|
||||
.countdown {
|
||||
@apply rounded-card border border-line bg-surface;
|
||||
}
|
||||
@@ -232,7 +222,6 @@ onUnmounted(() => eventStore.cleanup())
|
||||
@apply flex items-center justify-between py-1 text-sm;
|
||||
}
|
||||
|
||||
/* ---- Kiemelt játék ---- */
|
||||
.featured {
|
||||
@apply flex flex-col overflow-hidden rounded-card border border-line bg-surface md:flex-row;
|
||||
}
|
||||
@@ -258,7 +247,6 @@ onUnmounted(() => eventStore.cleanup())
|
||||
@apply mt-6 flex flex-wrap gap-3;
|
||||
}
|
||||
|
||||
/* ---- Bolt ---- */
|
||||
.store-grid {
|
||||
@apply grid grid-cols-1 gap-6 md:grid-cols-2 md:items-center;
|
||||
}
|
||||
@@ -284,7 +272,6 @@ onUnmounted(() => eventStore.cleanup())
|
||||
@apply mt-4 flex items-center gap-2 text-xs text-fg-subtle;
|
||||
}
|
||||
|
||||
/* ---- YouTube ---- */
|
||||
.yt-grid {
|
||||
@apply grid grid-cols-1 gap-6 lg:grid-cols-[1.6fr_1fr];
|
||||
}
|
||||
@@ -310,9 +297,6 @@ onUnmounted(() => eventStore.cleanup())
|
||||
@apply flex flex-wrap gap-4 text-xs text-fg-subtle;
|
||||
}
|
||||
|
||||
/* ---- Spotify ----
|
||||
A beágyazás a saját sötét felületét hozza; keretbe tesszük, hogy világos
|
||||
témában is szándékosnak látsszon, ne foltnak. */
|
||||
.spotify-embed {
|
||||
@apply block overflow-hidden rounded-card border border-line bg-raised;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// The page shows five of these — install, restart, CLI, uninstall — so the
|
||||
// block and its copy button live in one place rather than being repeated.
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Check, Copy } from 'lucide-vue-next'
|
||||
@@ -26,7 +25,7 @@ async function copy() {
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch {
|
||||
// Clipboard API needs a secure context — the command stays selectable anyway.
|
||||
copied.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -35,8 +34,7 @@ async function copy() {
|
||||
.cb {
|
||||
@apply relative block overflow-x-auto rounded-card border border-line bg-raised text-fg;
|
||||
}
|
||||
/* Explicitly margin-free: a `pre` carries a browser margin of its own, and the block is
|
||||
spaced by whatever contains it — otherwise what follows ends up against it. */
|
||||
|
||||
.cb pre {
|
||||
@apply m-0 p-4 pr-14 font-mono text-sm leading-relaxed;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
</header>
|
||||
|
||||
<main class="main-container">
|
||||
<!-- Two ways in, and almost everybody wants the first one. A thin switcher rather
|
||||
than a page of prose: whoever needs the detail follows a link to the wiki. -->
|
||||
<nav class="st-views" role="tablist" :aria-label="t('stores.title')">
|
||||
<button
|
||||
class="st-view" :class="{ 'st-view-active': view === 'client' }"
|
||||
@@ -45,8 +43,6 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Eager, with its dimensions given: it sits in the first screen, so lazy
|
||||
loading it would only buy a reflow. -->
|
||||
<figure class="st-app-shot">
|
||||
<img :src="clientScreenshot" :alt="t('stores.clientShotAlt')" width="1536" height="1110">
|
||||
</figure>
|
||||
@@ -70,8 +66,6 @@
|
||||
</div>
|
||||
|
||||
<section class="st-section">
|
||||
<!-- The spacing lives on this one container. Margins on the labels and none on
|
||||
the button row is what let the buttons sit flush against the last block. -->
|
||||
<div class="st-cmds">
|
||||
<p class="st-cmd-label">{{ t('stores.installTitle') }}</p>
|
||||
<CommandBlock :command="current.installCmd" />
|
||||
@@ -100,9 +94,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Which catalog platforms a store engine can put on a device. The app has no
|
||||
such list to show: it installs whatever the catalog offers, and says so on the
|
||||
cards themselves. -->
|
||||
<section class="st-section">
|
||||
<h2 class="st-section-title">{{ t('stores.platformsTitle') }}</h2>
|
||||
<ul class="st-platforms">
|
||||
@@ -135,9 +126,6 @@ const router = useRouter()
|
||||
|
||||
const FORGE = 'https://git.teletypegames.org/stores'
|
||||
|
||||
// The graphical client. Its own thing rather than a link on the desktop store: it
|
||||
// drives any WarpEngine store the site's registry offers, and on Windows it is the
|
||||
// only way in — there is no `curl … | sh` there.
|
||||
const CLIENT = {
|
||||
repoUrl: `${FORGE}/warp-engine-client`,
|
||||
releasesUrl: `${FORGE}/warp-engine-client/releases`,
|
||||
@@ -146,9 +134,6 @@ const CLIENT = {
|
||||
|
||||
type DeviceId = 'batocera' | 'retroarch'
|
||||
|
||||
// The devices below are the ones a store reaches through a shell installer. An
|
||||
// ordinary computer is not among them any more: there the store *is* the app in the
|
||||
// section above, which carries its own engine and needs nothing installed first.
|
||||
const BATOCERA_CLI = '/userdata/system/batocera-store/ttg-store'
|
||||
const RETROARCH_CLI = '~/.local/bin/ttg-retroarch-store'
|
||||
|
||||
@@ -158,8 +143,7 @@ const devices = [
|
||||
projectUrl: 'https://batocera.org',
|
||||
repoUrl: `${FORGE}/ttg-batocera-store`,
|
||||
wikiUrl: `${CONFIG.wikiBase}/stores/ttg-batocera-store`,
|
||||
// The installer is served straight from the forge, so this one line is the
|
||||
// whole install on the device.
|
||||
|
||||
installCmd: `curl -fsSL ${FORGE}/ttg-batocera-store/raw/branch/master/install.sh | sh`,
|
||||
afterInstallCmd: 'batocera-es-swissknife --restart',
|
||||
uninstallCmd: `curl -fsSL ${FORGE}/ttg-batocera-store/raw/branch/master/uninstall.sh | sh`,
|
||||
@@ -188,24 +172,18 @@ const devices = [
|
||||
|
||||
type ViewId = 'client' | 'others'
|
||||
|
||||
// `?view=` and `?device=` keep a link shareable, and let /batocera redirect here without
|
||||
// losing which store the visitor came for. Query only: the router does not scroll for a
|
||||
// query-only change, so switching does not throw the page back to the top.
|
||||
const initial = devices.some((d) => d.id === route.query.device)
|
||||
? (route.query.device as DeviceId)
|
||||
: 'batocera'
|
||||
const device = ref<DeviceId>(initial)
|
||||
// The client view is what most visitors want, so it is the default — except when the URL
|
||||
// names a device, which is how /batocera and /retroarch redirect here. Somebody arriving
|
||||
// from those wants the integrations, not the app.
|
||||
|
||||
const view = ref<ViewId>(
|
||||
route.query.view === 'others' || (route.query.view === undefined && route.query.device !== undefined)
|
||||
? 'others'
|
||||
: 'client',
|
||||
)
|
||||
const current = computed(() => devices.find((d) => d.id === device.value) ?? devices[0])
|
||||
// The app is not tied to a device tab: it drives whichever store the site's registry
|
||||
// offers, so it stands above the chooser rather than inside it.
|
||||
|
||||
const client = CLIENT
|
||||
|
||||
function select(id: DeviceId) {
|
||||
@@ -255,7 +233,6 @@ const platforms = [
|
||||
@apply text-accent;
|
||||
}
|
||||
|
||||
/* The switcher is meant to be almost nothing: two words and a line under the active one. */
|
||||
.st-views {
|
||||
@apply mb-6 flex gap-1 border-b border-line;
|
||||
}
|
||||
@@ -266,8 +243,7 @@ const platforms = [
|
||||
.st-view-active {
|
||||
@apply border-accent text-fg;
|
||||
}
|
||||
/* One rhythm for the whole command list, so nothing depends on a margin someone
|
||||
remembered to add. */
|
||||
|
||||
.st-cmds {
|
||||
@apply space-y-2;
|
||||
}
|
||||
|
||||
@@ -25,11 +25,8 @@ export const router = createRouter({
|
||||
...storesRouter,
|
||||
],
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
// Back and forward land where the visitor was.
|
||||
if (savedPosition) return savedPosition
|
||||
// A query-only change is not a new page. The stores page writes its switcher into the
|
||||
// URL so a link stays shareable, and throwing the reader back to the top for that
|
||||
// felt like a reload.
|
||||
|
||||
if (to.path === from.path) return false
|
||||
return { top: 0 }
|
||||
},
|
||||
|
||||
@@ -2,12 +2,9 @@ import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const storesRouter: RouteRecordRaw[] = [
|
||||
{ path: '/stores', name: 'storesIndex', component: () => import('../page/stores/StoresIndexPage.vue') },
|
||||
// The Batocera store had a page to itself until the RetroArch store joined it.
|
||||
// Both device names keep working as URLs — the old links, and the guess someone
|
||||
// makes after reading "RetroArch store" — and each lands on its own tab.
|
||||
|
||||
{ path: '/batocera', redirect: { name: 'storesIndex', query: { device: 'batocera' } } },
|
||||
{ path: '/retroarch', redirect: { name: 'storesIndex', query: { device: 'retroarch' } } },
|
||||
// `/desktop` used to name a shell store for ordinary computers. That store is the
|
||||
// app now, so the old URL lands on the app rather than on a device tab.
|
||||
|
||||
{ path: '/desktop', redirect: { name: 'storesIndex' } },
|
||||
]
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getEngineDigest } from '../engines.store'
|
||||
|
||||
// Generic markdown in the shape every engine-tagged wiki page must follow:
|
||||
// intro paragraph, then "What You Get" as the first heading with bullets.
|
||||
// Dummy text only — real content always comes from the wiki at runtime.
|
||||
const SAMPLE_MARKDOWN = `
|
||||
> Example Engine is a **sample** framework that turns markdown into landing cards.
|
||||
|
||||
|
||||
@@ -9,9 +9,6 @@ export interface EngineHighlight {
|
||||
text: string
|
||||
}
|
||||
|
||||
// Landing-page digest of a wiki engine page. Convention: every engine-tagged
|
||||
// page opens with an intro paragraph, then a "What You Get" heading as its
|
||||
// first heading, with the feature bullets underneath.
|
||||
export interface EngineDigest {
|
||||
intro: string
|
||||
highlightsTitle: string
|
||||
@@ -21,7 +18,6 @@ export interface EngineDigest {
|
||||
const HIGHLIGHTS_HEADING = 'what you get'
|
||||
const MAX_HIGHLIGHTS = 6
|
||||
|
||||
// Inline markdown (links, bold, code) stripped so the text reads as plain prose.
|
||||
function stripInline(md: string): string {
|
||||
return md
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
@@ -41,8 +37,6 @@ export function getEngineDigest(content: string): EngineDigest {
|
||||
|
||||
const heading = line.match(/^#{1,6}\s+(.*)$/)
|
||||
if (heading) {
|
||||
// The section ends at the next heading; and if the page's first heading
|
||||
// is not "What You Get", it doesn't follow the convention — no highlights.
|
||||
if (inHighlights) break
|
||||
const title = stripInline(heading[1])
|
||||
if (title.toLowerCase() !== HIGHLIGHTS_HEADING) break
|
||||
@@ -63,7 +57,6 @@ export function getEngineDigest(content: string): EngineDigest {
|
||||
continue
|
||||
}
|
||||
|
||||
// First prose line (paragraph or blockquote) before the heading is the intro.
|
||||
if (!digest.intro) {
|
||||
digest.intro = stripInline(line.replace(/^>\s*/, ''))
|
||||
}
|
||||
|
||||
@@ -14,9 +14,6 @@ export const useMemberStore = defineStore('member', () => {
|
||||
})
|
||||
}
|
||||
|
||||
// A tartalék avatarok a bundle-ben WebP-ként élnek (a PNG-k fejenként ~1,6 MB-ot
|
||||
// vittek egy ~300 px-es képért), a fájlnév viszont az adatbázisból jön, még
|
||||
// .png végződéssel — ezért itt cseréljük a kiterjesztést.
|
||||
function resolvedAvatarUrl(member: Member): string {
|
||||
if (member.imageUrl) return member.imageUrl
|
||||
const file = member.avatarFilename.replace(/\.(png|jpe?g)$/i, '.webp')
|
||||
|
||||
@@ -6,8 +6,6 @@ export type Theme = 'light' | 'dark'
|
||||
|
||||
const THEME_COOKIE = 'theme'
|
||||
|
||||
// Süti nélkül világos: az alapértelmezés legyen kiszámítható, ezért a
|
||||
// prefers-color-scheme-et szándékosan nem nézzük.
|
||||
function readTheme(): Theme {
|
||||
return getCookie(THEME_COOKIE) === 'dark' ? 'dark' : 'light'
|
||||
}
|
||||
@@ -16,15 +14,10 @@ function applyTheme(theme: Theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
}
|
||||
|
||||
// A retro mód a gyökéren is jelöli magát: a body háttere a tokenekből jön, és a
|
||||
// retro.css ott írja felül őket. A .retro-mode osztály az app gyökerén marad,
|
||||
// az viszi a formát (betűtípus, keretek).
|
||||
function applyRetro(on: boolean) {
|
||||
document.documentElement.setAttribute('data-retro', on ? '1' : '0')
|
||||
}
|
||||
|
||||
// A TuiCSS és a retro.css együtt ~500 KB, és a látogatók töredéke kapcsolja be
|
||||
// a retro módot — ezért csak akkor töltjük be, amikor tényleg kell.
|
||||
let retroStyles: Promise<unknown> | null = null
|
||||
|
||||
function loadRetroStyles(): Promise<unknown> {
|
||||
@@ -42,8 +35,6 @@ export const useUiStore = defineStore('ui', () => {
|
||||
const theme = ref<Theme>(readTheme())
|
||||
const menuOpen = ref(false)
|
||||
|
||||
// Az index.html inline scriptje már beállította ugyanezt; itt csak azért
|
||||
// ismételjük meg, hogy a store maradjon az állapot egyetlen gazdája.
|
||||
applyTheme(theme.value)
|
||||
applyRetro(isRetroMode.value)
|
||||
|
||||
|
||||
@@ -9,15 +9,11 @@ body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* A felület alapja tokenből jön, hogy a témaváltás egyetlen attribútumon
|
||||
múljon — lásd src/styles/tokens.css. */
|
||||
body {
|
||||
background-color: rgb(var(--c-page));
|
||||
color: rgb(var(--c-fg));
|
||||
}
|
||||
|
||||
/* A témaváltás nem animált: a keresztfade olcsó trükknek látszik, és a
|
||||
sok elem egyszerre úszó színe zajos. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
@@ -29,8 +25,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* A billentyűzetes fókusz mindenhol látszik — a linkeknek nincs saját
|
||||
komponensosztályuk, ezért az alapot itt kapják. */
|
||||
a:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 2px solid rgb(var(--c-accent));
|
||||
@@ -39,7 +33,6 @@ button:focus-visible {
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ---- Elrendezés ---------------------------------------------------- */
|
||||
.main-container {
|
||||
@apply mx-auto w-full max-w-6xl px-4 py-10 md:px-6 md:py-14;
|
||||
}
|
||||
@@ -48,9 +41,6 @@ button:focus-visible {
|
||||
@apply grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3;
|
||||
}
|
||||
|
||||
/* ---- Hero ----------------------------------------------------------
|
||||
Egy hero minden oldalon: cím, egy alcím, legfeljebb egy gomb. A
|
||||
gradiensek, a blur-blobok és az oldalanként külön akcentszín kiestek. */
|
||||
.hero-section {
|
||||
@apply border-b border-line bg-surface py-10 md:py-14;
|
||||
}
|
||||
@@ -75,7 +65,6 @@ button:focus-visible {
|
||||
@apply mt-6 flex flex-wrap items-center gap-3;
|
||||
}
|
||||
|
||||
/* ---- Felületek — a kártyát keret határolja, nem árnyék -------------- */
|
||||
.card {
|
||||
@apply rounded-card border border-line bg-surface p-5 md:p-6;
|
||||
}
|
||||
@@ -84,7 +73,6 @@ button:focus-visible {
|
||||
@apply rounded-card border border-line bg-surface;
|
||||
}
|
||||
|
||||
/* ---- Vezérlők ------------------------------------------------------- */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-control px-4 py-2 text-sm font-medium transition-colors
|
||||
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent;
|
||||
@@ -106,7 +94,6 @@ button:focus-visible {
|
||||
@apply text-accent underline-offset-4 transition-colors hover:underline;
|
||||
}
|
||||
|
||||
/* ---- Jelölők -------------------------------------------------------- */
|
||||
.badge {
|
||||
@apply inline-flex items-center rounded-control border border-line bg-raised px-2 py-0.5 text-xs font-medium text-fg-muted;
|
||||
}
|
||||
@@ -115,8 +102,6 @@ button:focus-visible {
|
||||
@apply badge border-accent/40 bg-accent-quiet text-accent;
|
||||
}
|
||||
|
||||
/* A négy státusz nem kap négy színcsaládot: a megjelent cím az egyetlen,
|
||||
ami kiemelést érdemel, a többi semleges. */
|
||||
.status-badge {
|
||||
@apply badge uppercase tracking-wide;
|
||||
}
|
||||
@@ -129,7 +114,6 @@ button:focus-visible {
|
||||
@apply font-mono tabular-nums;
|
||||
}
|
||||
|
||||
/* ---- Üres és hibaállapotok ------------------------------------------ */
|
||||
.empty-state {
|
||||
@apply rounded-card border border-line bg-surface px-6 py-16 text-center;
|
||||
}
|
||||
@@ -154,17 +138,11 @@ button:focus-visible {
|
||||
@apply mt-1 text-sm text-fg-muted;
|
||||
}
|
||||
|
||||
/* ---- Táblázat ------------------------------------------------------- */
|
||||
.table-wrap {
|
||||
@apply overflow-x-auto rounded-card border border-line;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Wiki-tartalom (v-html-lel érkezik a wikiből, ezért nem lehet utility-kkel
|
||||
megcímezni — minden szabály tokenre hivatkozik, hogy mindkét témában álljon)
|
||||
========================================================================== */
|
||||
.wiki-content {
|
||||
@apply max-w-[68ch] text-fg-muted;
|
||||
}
|
||||
|
||||
@@ -1,45 +1,25 @@
|
||||
/* ===================================================================
|
||||
RETRO MODE — TuiCss-stílusú megjelenés a site fölött.
|
||||
|
||||
A facelift óta ez a fájl nem fest újra minden komponenst egyenként:
|
||||
a retro egy harmadik téma. Először felülírja a szerep-tokeneket
|
||||
(tokens.css), és csak azt szabályozza külön, ami formában és nem
|
||||
színben más — betűtípus, kettős keret, blokkárnyék, tui-sáv.
|
||||
|
||||
Ezért is rövidült 1153 sorról ennyire: a korábbi változat ~500
|
||||
`!important`-tal küzdött a Tailwind-osztályok ellen, és a szelektorai
|
||||
nagyrészt olyan markupra épültek, ami már nem létezik.
|
||||
|
||||
A fájl (a TuiCSS-szel együtt) csak akkor töltődik be, ha valaki
|
||||
bekapcsolja a retro módot — lásd ui.store.
|
||||
=================================================================== */
|
||||
|
||||
/* A gyökéren a tokenek: a body háttere is innen jön. Az attribútumot az
|
||||
ui.store teszi ki, ugyanakkor, amikor a .retro-mode osztályt. */
|
||||
[data-retro='1'] {
|
||||
--c-page: 0 0 0;
|
||||
--c-surface: 0 0 168; /* tui blue-168 */
|
||||
--c-surface: 0 0 168;
|
||||
--c-raised: 0 0 168;
|
||||
--c-line: 255 255 255;
|
||||
--c-line-strong: 255 255 255;
|
||||
--c-fg: 255 255 255;
|
||||
--c-fg-muted: 200 200 200;
|
||||
--c-fg-subtle: 168 168 168;
|
||||
--c-accent: 0 168 0; /* tui green-168 */
|
||||
--c-accent-hover: 0 168 168; /* tui cyan-168 */
|
||||
--c-accent: 0 168 0;
|
||||
--c-accent-hover: 0 168 168;
|
||||
--c-accent-fg: 0 0 0;
|
||||
--c-accent-quiet: 0 0 168;
|
||||
--c-warn: 168 168 0;
|
||||
--c-danger: 168 0 0;
|
||||
}
|
||||
|
||||
/* ---- ANIMÁCIÓK ---- */
|
||||
@keyframes retro-blink {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ---- ALAP ---- */
|
||||
.retro-mode {
|
||||
font-family: "Lucida Console", monospace !important;
|
||||
background-color: #000 !important;
|
||||
@@ -58,7 +38,6 @@
|
||||
animation: retro-blink 0.9s step-start infinite;
|
||||
}
|
||||
|
||||
/* ---- FEJLÉC — tui-nav ---- */
|
||||
.retro-mode .app-header {
|
||||
background-color: #a8a8a8 !important;
|
||||
border-bottom: none !important;
|
||||
@@ -85,7 +64,6 @@
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
/* tui-shortcut: az első betű piros, mint a DOS-menükben */
|
||||
.retro-mode .nav-link::first-letter {
|
||||
color: #a80000 !important;
|
||||
font-weight: bold !important;
|
||||
@@ -118,7 +96,6 @@
|
||||
padding: 2px 6px !important;
|
||||
}
|
||||
|
||||
/* Mobil menü — tui-window */
|
||||
.retro-mode .nav-mobile {
|
||||
background-color: #a8a8a8 !important;
|
||||
border: 2px solid #000 !important;
|
||||
@@ -134,7 +111,6 @@
|
||||
background-color: #00a800 !important;
|
||||
}
|
||||
|
||||
/* ---- HERO — tui-bg-blue-black ---- */
|
||||
.retro-mode .hero-section {
|
||||
background-color: #0000a8 !important;
|
||||
background-image: url("../../node_modules/tuicss/dist/images/bg-blue-black.png") !important;
|
||||
@@ -164,7 +140,6 @@
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
/* ---- FELÜLETEK — tui-window / tui-panel (6px kettős fehér keret) ---- */
|
||||
.retro-mode .card,
|
||||
.retro-mode .card-bare,
|
||||
.retro-mode .card-link,
|
||||
@@ -186,7 +161,7 @@
|
||||
background-image: none !important;
|
||||
color: #fff !important;
|
||||
border: 6px white double !important;
|
||||
box-shadow: 10px 10px 0 #000 !important; /* tui-shadow */
|
||||
box-shadow: 10px 10px 0 #000 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
@@ -200,7 +175,6 @@
|
||||
color: #00a800 !important;
|
||||
}
|
||||
|
||||
/* ---- GOMBOK — tui-button ---- */
|
||||
.retro-mode .btn,
|
||||
.retro-mode .btn-accent,
|
||||
.retro-mode .btn-ghost,
|
||||
@@ -230,7 +204,6 @@
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* ---- LINKEK ---- */
|
||||
.retro-mode .link,
|
||||
.retro-mode a.link {
|
||||
color: #00a8a8 !important;
|
||||
@@ -241,7 +214,6 @@
|
||||
color: #00a8a8 !important;
|
||||
}
|
||||
|
||||
/* ---- JELÖLŐK — 168-as paletta ---- */
|
||||
.retro-mode .badge,
|
||||
.retro-mode .badge-accent,
|
||||
.retro-mode .status-badge,
|
||||
@@ -257,7 +229,6 @@
|
||||
background-color: #00a800 !important;
|
||||
}
|
||||
|
||||
/* ---- TÁBLÁZATOK — tui-table, striped-blue ---- */
|
||||
.retro-mode table {
|
||||
border: 2px solid #a8a8a8 !important;
|
||||
border-collapse: collapse !important;
|
||||
@@ -294,7 +265,6 @@
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* ---- PARANCSBLOKK ---- */
|
||||
.retro-mode .cb {
|
||||
background-color: #000 !important;
|
||||
border: 2px solid #a8a8a8 !important;
|
||||
@@ -306,7 +276,6 @@
|
||||
color: #00a800 !important;
|
||||
}
|
||||
|
||||
/* ---- WIKI-TARTALOM ---- */
|
||||
.retro-mode .wiki-content,
|
||||
.retro-mode .wiki-content p,
|
||||
.retro-mode .wiki-content li {
|
||||
@@ -337,7 +306,6 @@
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
/* ---- LÁBLÉC — tui-statusbar ---- */
|
||||
.retro-mode .app-footer {
|
||||
background-color: #a8a8a8 !important;
|
||||
border-top: none !important;
|
||||
@@ -364,7 +332,6 @@
|
||||
background-color: #00a800 !important;
|
||||
}
|
||||
|
||||
/* ---- KÉPEK ---- */
|
||||
.retro-mode img {
|
||||
border: 2px solid #a8a8a8 !important;
|
||||
image-rendering: pixelated;
|
||||
|
||||
@@ -1,62 +1,41 @@
|
||||
/* ===================================================================
|
||||
Design tokenek — a világos és a sötét téma ugyanazokat a szerepeket
|
||||
tölti ki, csak más értékkel. A komponensek soha nem hivatkoznak nyers
|
||||
palettanévre, csak ezekre a szerepekre (Tailwindből: bg-surface,
|
||||
text-fg-muted, border-line, bg-accent, …).
|
||||
|
||||
A témát a <html data-theme="light|dark"> attribútuma választja; azt az
|
||||
index.html inline scriptje állítja be a bundle betöltése előtt, és
|
||||
utána az ui.store tartja karban. Alapértelmezés (süti nélkül): világos.
|
||||
A prefers-color-scheme-et szándékosan nem követjük.
|
||||
|
||||
Az értékek szóközzel elválasztott RGB-csatornák, hogy a Tailwind
|
||||
alfa-variánsai (bg-accent/15) is működjenek.
|
||||
=================================================================== */
|
||||
|
||||
:root {
|
||||
/* felületek */
|
||||
--c-page: 255 255 255;
|
||||
--c-surface: 250 250 250; /* zinc-50 */
|
||||
--c-raised: 244 244 245; /* zinc-100 */
|
||||
--c-surface: 250 250 250;
|
||||
--c-raised: 244 244 245;
|
||||
|
||||
/* keretek */
|
||||
--c-line: 228 228 231; /* zinc-200 */
|
||||
--c-line-strong: 212 212 216; /* zinc-300 */
|
||||
--c-line: 228 228 231;
|
||||
--c-line-strong: 212 212 216;
|
||||
|
||||
/* szöveg */
|
||||
--c-fg: 24 24 27; /* zinc-900 · 17.7:1 */
|
||||
--c-fg-muted: 82 82 91; /* zinc-600 · 7.7:1 */
|
||||
--c-fg-subtle: 113 113 122; /* zinc-500 · 4.8:1 — csak metaadatra */
|
||||
--c-fg: 24 24 27;
|
||||
--c-fg-muted: 82 82 91;
|
||||
--c-fg-subtle: 113 113 122;
|
||||
|
||||
/* akcens: a foszforzöld világos alapon olvasható változata.
|
||||
A green-600 fehéren csak 3.3:1, ezért green-700. */
|
||||
--c-accent: 21 128 61; /* green-700 · 5.0:1 fehéren */
|
||||
--c-accent-hover: 22 101 52; /* green-800 */
|
||||
--c-accent-fg: 255 255 255; /* szöveg akcens-kitöltésen */
|
||||
--c-accent-quiet: 220 252 231; /* green-100 — halvány alap, badge */
|
||||
--c-accent: 21 128 61;
|
||||
--c-accent-hover: 22 101 52;
|
||||
--c-accent-fg: 255 255 255;
|
||||
--c-accent-quiet: 220 252 231;
|
||||
|
||||
/* állapotok */
|
||||
--c-warn: 180 83 9; /* amber-700 · 5.0:1 */
|
||||
--c-danger: 185 28 28; /* red-700 */
|
||||
--c-warn: 180 83 9;
|
||||
--c-danger: 185 28 28;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--c-page: 9 9 11; /* zinc-950 */
|
||||
--c-surface: 24 24 27; /* zinc-900 */
|
||||
--c-raised: 39 39 42; /* zinc-800 */
|
||||
--c-page: 9 9 11;
|
||||
--c-surface: 24 24 27;
|
||||
--c-raised: 39 39 42;
|
||||
|
||||
--c-line: 39 39 42; /* zinc-800 */
|
||||
--c-line-strong: 63 63 70; /* zinc-700 */
|
||||
--c-line: 39 39 42;
|
||||
--c-line-strong: 63 63 70;
|
||||
|
||||
--c-fg: 250 250 250; /* 19.1:1 */
|
||||
--c-fg-muted: 161 161 170; /* zinc-400 · 7.8:1 */
|
||||
--c-fg-subtle: 113 113 122; /* zinc-500 · 4.1:1 — csak metaadatra */
|
||||
--c-fg: 250 250 250;
|
||||
--c-fg-muted: 161 161 170;
|
||||
--c-fg-subtle: 113 113 122;
|
||||
|
||||
--c-accent: 74 222 128; /* green-400 · 11.4:1 feketén */
|
||||
--c-accent-hover: 134 239 172; /* green-300 */
|
||||
--c-accent-fg: 9 9 11; /* akcens-kitöltésen sötét szöveg kell */
|
||||
--c-accent-quiet: 20 83 45; /* green-900 */
|
||||
--c-accent: 74 222 128;
|
||||
--c-accent-hover: 134 239 172;
|
||||
--c-accent-fg: 9 9 11;
|
||||
--c-accent-quiet: 20 83 45;
|
||||
|
||||
--c-warn: 255 176 0; /* retro.amber · 10.9:1 */
|
||||
--c-danger: 248 113 113; /* red-400 */
|
||||
--c-warn: 255 176 0;
|
||||
--c-danger: 248 113 113;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
// A szemantikus színek a src/styles/tokens.css változóira mutatnak, hogy a
|
||||
// témaváltás (világos / sötét) egyetlen attribútumon múljon, és az
|
||||
// alfa-variánsok (bg-accent/15) is működjenek.
|
||||
const token = (name) => `rgb(var(${name}) / <alpha-value>)`
|
||||
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{astro,html,js,ts,jsx,tsx,vue,svelte}"
|
||||
],
|
||||
// A témát a <html data-theme="dark"> választja, nem a prefers-color-scheme.
|
||||
|
||||
darkMode: ['selector', '[data-theme="dark"]'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Szerepek — ezeket használja a markup.
|
||||
page: token('--c-page'),
|
||||
surface: token('--c-surface'),
|
||||
raised: token('--c-raised'),
|
||||
@@ -46,12 +42,12 @@ module.exports = {
|
||||
amber: '#ffb000',
|
||||
},
|
||||
},
|
||||
// Egy sugárskála: kártya, vezérlő. A kör (rounded-full) marad a Tailwindé.
|
||||
|
||||
borderRadius: {
|
||||
card: '8px',
|
||||
control: '6px',
|
||||
},
|
||||
// Árnyék csak lebegő elemre; a kártyát keret határolja, nem árnyék.
|
||||
|
||||
boxShadow: {
|
||||
pop: '0 1px 2px rgb(0 0 0 / 0.06), 0 1px 1px rgb(0 0 0 / 0.04)',
|
||||
float: '0 8px 24px rgb(0 0 0 / 0.12), 0 2px 6px rgb(0 0 0 / 0.08)',
|
||||
|
||||
@@ -10,9 +10,7 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
|
||||
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) }
|
||||
# Two kinds of token share this table: one publishes software, the other reads the
|
||||
# catalog from somebody's desktop client. They are told apart by scope, and an admin
|
||||
# looking for one is rarely looking for the other.
|
||||
|
||||
CATALOG_SCOPE_SQL = %(JSON_CONTAINS(COALESCE(scopes, '[]'), '"catalog"')).freeze
|
||||
scope("Publishing") { |scope| scope.where("NOT #{CATALOG_SCOPE_SQL}") }
|
||||
scope("Clients") { |scope| scope.where(CATALOG_SCOPE_SQL) }
|
||||
@@ -98,9 +96,6 @@ ActiveAdmin.register WarpEngine::ApplicationToken, as: "Application Token" do
|
||||
end
|
||||
|
||||
controller do
|
||||
# The plain token only exists right after creation; it travels via the
|
||||
# session to its one-time display (flash is unsuitable: the AA layout
|
||||
# renders every flash key as a message bar).
|
||||
def create
|
||||
create! do |success, _failure|
|
||||
success.html do
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
ActiveAdmin.register WarpEngine::DeviceGrant, as: "Device Sign-in" do
|
||||
# Read-only on purpose. A grant is created by a client and answered by a person on the
|
||||
# host's own page; an admin creating one by hand would be issuing somebody else a
|
||||
# credential, which is not a thing this page should make easy.
|
||||
actions :index, :show
|
||||
|
||||
menu parent: "🌀 WarpEngine", priority: 10, label: "📱 Device Sign-ins",
|
||||
@@ -45,8 +42,7 @@ ActiveAdmin.register WarpEngine::DeviceGrant, as: "Device Sign-in" do
|
||||
subject = g.subject
|
||||
subject.try(:email) || subject.try(:name) || "#{g.subject_type} ##{g.subject_id}"
|
||||
end
|
||||
# The device code itself is never shown: it is the client's live credential for as
|
||||
# long as the grant is pending, and this page is not where it should leak from.
|
||||
|
||||
row("Token") do |g|
|
||||
token = g.application_token
|
||||
next "—" if token.nil?
|
||||
|
||||
@@ -13,7 +13,6 @@ ActiveAdmin.register_page "Files" do
|
||||
entries = []
|
||||
end
|
||||
|
||||
# Picker mode: hide admin header/menu/footer
|
||||
if picker_mode
|
||||
text_node "<style>#header,#tabs,.footer,#title_bar,#utility_nav{display:none!important}#active_admin_content{margin:0;padding:0}#wrapper{margin:0}body{min-height:auto}</style>".html_safe
|
||||
end
|
||||
@@ -26,7 +25,6 @@ ActiveAdmin.register_page "Files" do
|
||||
end
|
||||
end
|
||||
|
||||
# Breadcrumbs
|
||||
div class: "fm-breadcrumbs" do
|
||||
parts = current_dir.split("/").reject(&:blank?)
|
||||
picker_params = picker_mode ? { picker: 1, field: picker_field } : {}
|
||||
@@ -39,7 +37,6 @@ ActiveAdmin.register_page "Files" do
|
||||
end
|
||||
end
|
||||
|
||||
# Action bar (also drop zone on standalone page)
|
||||
div class: "fm-actions fm-dropzone", id: "fm-page-dropzone" do
|
||||
form action: admin_files_upload_path, method: "post", enctype: "multipart/form-data", class: "fm-inline-form" do |_f|
|
||||
input type: "hidden", name: "authenticity_token", value: form_authenticity_token
|
||||
@@ -56,9 +53,7 @@ ActiveAdmin.register_page "Files" do
|
||||
end
|
||||
end
|
||||
|
||||
# File listing
|
||||
table class: "fm-table" do
|
||||
# Pre-fetch download counts for files in current directory
|
||||
file_entries = entries.select { |e| e[:type] != :directory }
|
||||
dl_counts = if file_entries.any?
|
||||
WarpEngine::Download.where(file_path: file_entries.map { |e| e[:path] })
|
||||
@@ -68,7 +63,6 @@ ActiveAdmin.register_page "Files" do
|
||||
{}
|
||||
end
|
||||
|
||||
# File icon helper
|
||||
file_icon = ->(name) do
|
||||
ext = File.extname(name).downcase
|
||||
case ext
|
||||
@@ -100,7 +94,6 @@ ActiveAdmin.register_page "Files" do
|
||||
end
|
||||
|
||||
tbody do
|
||||
# Parent directory link
|
||||
if current_dir.present?
|
||||
parent = File.dirname(current_dir)
|
||||
parent = "" if parent == "."
|
||||
@@ -145,32 +138,26 @@ ActiveAdmin.register_page "Files" do
|
||||
td class: "fm-entry-actions" do
|
||||
entry_id = entry[:path].parameterize
|
||||
|
||||
# Download (files only)
|
||||
if entry[:type] != :directory
|
||||
a "⬇️", href: "/file/#{entry[:path]}", class: "fm-icon-btn", title: "Download", download: entry[:name]
|
||||
end
|
||||
|
||||
# Rename
|
||||
a "✏️", href: "#", class: "fm-icon-btn", title: "Rename",
|
||||
onclick: "var n=prompt('New name:','#{j entry[:name]}');if(n){var f=document.getElementById('rename-#{entry_id}');f.querySelector('[name=new_name]').value=n;f.submit();}return false;"
|
||||
|
||||
# Delete
|
||||
a "🗑️", href: "#", class: "fm-icon-btn fm-icon-danger", title: "Delete",
|
||||
onclick: "if(confirm('Delete \\'#{j entry[:name]}\\'?')){document.getElementById('delete-#{entry_id}').submit();}return false;"
|
||||
|
||||
# Stats (files only)
|
||||
if entry[:type] != :directory
|
||||
a "📊", href: admin_downloads_path(q: { file_path_cont: entry[:path] }), class: "fm-icon-btn", title: "Stats"
|
||||
end
|
||||
|
||||
# Picker mode: Select button
|
||||
if picker_mode
|
||||
abs_path = File.join(WarpEngine.config.file_container_path, entry[:path])
|
||||
a "Select", href: "#", class: "fm-btn fm-btn-select",
|
||||
onclick: "var inp=window.parent.document.getElementById('#{j picker_field}');if(inp){inp.value='#{j abs_path}';}var ov=window.parent.document.querySelector('.fm-modal-overlay');if(ov){ov.remove();window.parent.document.body.style.overflow='';}return false;"
|
||||
end
|
||||
|
||||
# Hidden rename form
|
||||
form action: admin_files_rename_path, method: "post", id: "rename-#{entry_id}", style: "display:none" do
|
||||
input type: "hidden", name: "authenticity_token", value: form_authenticity_token
|
||||
input type: "hidden", name: "path", value: entry[:path]
|
||||
@@ -178,7 +165,6 @@ ActiveAdmin.register_page "Files" do
|
||||
input type: "hidden", name: "dir", value: current_dir
|
||||
end
|
||||
|
||||
# Hidden delete form
|
||||
form action: admin_files_delete_path, method: "post", id: "delete-#{entry_id}", style: "display:none" do
|
||||
input type: "hidden", name: "authenticity_token", value: form_authenticity_token
|
||||
input type: "hidden", name: "_method", value: "delete"
|
||||
@@ -197,7 +183,6 @@ ActiveAdmin.register_page "Files" do
|
||||
end
|
||||
end
|
||||
|
||||
# JSON API for AJAX file picker
|
||||
page_action :list, method: :get do
|
||||
service = WarpEngine::FileManagerService.new
|
||||
dir = params[:dir].to_s.presence || ""
|
||||
|
||||
@@ -3,8 +3,6 @@ ActiveAdmin.register WarpEngine::Image, as: "Image" do
|
||||
|
||||
menu parent: "🌀 WarpEngine", priority: 5, label: "🖼️ Images"
|
||||
|
||||
# SoftwareImage a natív használó; a hoston regisztrált image_owners
|
||||
# (WarpEngine.config) további használókat adhat hozzá (pl. TTG Member).
|
||||
used_ids = -> {
|
||||
WarpEngine::SoftwareImage.unscope(:order).distinct.pluck(:image_id) +
|
||||
WarpEngine.config.image_owners.flat_map { |o| o[:image_ids].call }
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
ActiveAdmin.register WarpEngine::Pipeline, as: "Pipeline" do
|
||||
actions :index, :show, :edit, :update
|
||||
|
||||
# Without this the edit form cannot save at all: ActiveAdmin hands unpermitted params to
|
||||
# the model and Rails raises ForbiddenAttributesError. The two fields here are the two
|
||||
# the form offers; everything else about a pipeline comes from the Woodpecker sync.
|
||||
permit_params :platform, :software_id
|
||||
|
||||
menu parent: "🌀 WarpEngine", priority: 10, label: "🚀 Pipelines"
|
||||
@@ -91,7 +88,7 @@ ActiveAdmin.register WarpEngine::Pipeline, as: "Pipeline" do
|
||||
column("Status") { |p| status_tag p["status"], class: p["status"] == "success" ? "yes" : "no" }
|
||||
column("Branch") { |p| p["branch"] }
|
||||
column("Message") { |p| p["message"]&.truncate(60) }
|
||||
# Woodpecker returns unix epoch seconds in "created"
|
||||
|
||||
column("Created") { |p| p["created"] ? Time.zone.at(p["created"]).strftime("%Y-%m-%d %H:%M") : "-" }
|
||||
end
|
||||
else
|
||||
|
||||
+1
-23
@@ -1,35 +1,16 @@
|
||||
module WarpEngine
|
||||
# Who the caller is, on the read-only side of the API.
|
||||
#
|
||||
# Distinct from UpdateAuthentication, which guards publishing: that one asks "may this
|
||||
# pipeline write to the catalog", this one asks "whose library am I looking at". The
|
||||
# answer is allowed to be nobody — an anonymous caller is a normal, supported caller,
|
||||
# and a catalog with no policy configured never needs one.
|
||||
#
|
||||
# The credential is a bearer token, because that is what a client can carry: it has no
|
||||
# cookie jar and no browser session.
|
||||
|
||||
module SubjectAuthentication
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
# The ApplicationToken behind the request, or nil.
|
||||
def current_access_token
|
||||
return @current_access_token if defined?(@current_access_token)
|
||||
|
||||
@current_access_token = resolve_access_token
|
||||
end
|
||||
|
||||
# Whoever the request is on behalf of — the host's own object, or nil.
|
||||
#
|
||||
# Two ways to be somebody, tried in that order. A bearer token is what a client
|
||||
# carries. A *browser* carries a session instead, and the engine has no idea what a
|
||||
# session is here — so a host that wants its signed-in visitors recognised on these
|
||||
# endpoints supplies a resolver:
|
||||
#
|
||||
# c.subject_resolver = ->(request) { request.env["warden"]&.user }
|
||||
#
|
||||
# Without one, a browser is simply anonymous, which is what it always was.
|
||||
def current_subject
|
||||
return @current_subject if defined?(@current_subject)
|
||||
|
||||
@@ -51,9 +32,6 @@ module WarpEngine
|
||||
record
|
||||
end
|
||||
|
||||
# A resolver that raises must not take the request with it: it runs on every read
|
||||
# endpoint, and a broken one would turn the whole API into 500s rather than into
|
||||
# anonymous requests, which is the honest fallback.
|
||||
def resolve_host_subject
|
||||
resolver = WarpEngine.config.subject_resolver
|
||||
return nil if resolver.nil?
|
||||
|
||||
+2
-11
@@ -1,7 +1,5 @@
|
||||
module WarpEngine
|
||||
# Token authentication for the publishing (/build/*) endpoints.
|
||||
# The auth source is exclusive: in :database mode the shared secret is not
|
||||
# accepted, in :env mode DB tokens are not.
|
||||
|
||||
module UpdateAuthentication
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
@@ -9,8 +7,6 @@ module WarpEngine
|
||||
|
||||
attr_reader :current_application_token
|
||||
|
||||
# The token is accepted from the X-Update-Secret header only — in the URL
|
||||
# it would leak into proxy and access logs.
|
||||
def update_authorized?(required_scope:)
|
||||
token = request.headers["X-Update-Secret"].presence
|
||||
return false if token.blank?
|
||||
@@ -23,7 +19,7 @@ module WarpEngine
|
||||
|
||||
def env_secret_authorized?(token)
|
||||
expected = WarpEngine.config.update_secret
|
||||
# With no secret configured the endpoint stays closed.
|
||||
|
||||
expected.present? && ActiveSupport::SecurityUtils.secure_compare(token, expected)
|
||||
end
|
||||
|
||||
@@ -41,9 +37,6 @@ module WarpEngine
|
||||
true
|
||||
end
|
||||
|
||||
# Ownership enforcement applies only in :database mode (there is a token)
|
||||
# with enforce_software_ownership on. An ownerless software is up for grabs
|
||||
# until the backfill — backfill before enabling the enforcement.
|
||||
def software_ownership_authorized?(name)
|
||||
return true unless WarpEngine.config.enforce_software_ownership
|
||||
|
||||
@@ -56,8 +49,6 @@ module WarpEngine
|
||||
software.owner_type == token.owner_type && software.owner_id == token.owner_id
|
||||
end
|
||||
|
||||
# A first-published (or pre-backfill, ownerless) software gets the
|
||||
# submitting token's owner. Unrestricted (internal) tokens claim nothing.
|
||||
def claim_software_ownership(name)
|
||||
token = current_application_token
|
||||
return if token.nil? || token.unrestricted?
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
module WarpEngine
|
||||
# The device authorization grant, client side (RFC 8628).
|
||||
#
|
||||
# Both actions are unauthenticated, and have to be: the whole point of the flow is
|
||||
# that the caller has no credential yet. What protects it is that a device code is
|
||||
# useless until a signed-in person approves it on the host's own page.
|
||||
|
||||
class Api::Auth::DevicesController < ApiController
|
||||
before_action :ensure_identity_configured
|
||||
|
||||
@@ -34,9 +30,6 @@ module WarpEngine
|
||||
def token
|
||||
state, plain = service.poll(device_code: params[:device_code])
|
||||
|
||||
# The token rides on the one poll that finds the grant newly approved; a client
|
||||
# that loses it starts the flow again. Keeping a plain token around to hand out
|
||||
# twice would mean storing it, which is the thing this design avoids.
|
||||
body = { state: state.to_s }
|
||||
body[:token] = plain if plain.present?
|
||||
render json: body
|
||||
@@ -50,8 +43,6 @@ module WarpEngine
|
||||
@service ||= WarpEngine::DeviceGrantService.new
|
||||
end
|
||||
|
||||
# A deployment with no configured subject class has no sign-in at all, and says so
|
||||
# the same way GET /api/service does — by not offering it.
|
||||
def ensure_identity_configured
|
||||
return if WarpEngine.identity_configured?
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
module WarpEngine
|
||||
# Signing out: a client throws away its own token.
|
||||
#
|
||||
# Revocation is a soft delete on the ApplicationToken, so the record of which device
|
||||
# signed in and when survives it. The person's own list of devices — where somebody
|
||||
# revokes a token for a laptop they no longer have — is the host's page, because it
|
||||
# needs a session and a browser.
|
||||
|
||||
class Api::Auth::TokensController < ApiController
|
||||
resource_description do
|
||||
short "Client tokens"
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
module WarpEngine
|
||||
# What this deployment is and what it can do, in one unauthenticated request.
|
||||
#
|
||||
# This is how a client stops guessing. Before it existed, everything a client knew
|
||||
# about a store was compiled into the client — which endpoints to call, whether
|
||||
# signing in was a thing here, where to send somebody who wanted to buy something.
|
||||
# Every one of those is a property of the *server*, and a client that carries them
|
||||
# can only ever serve the one store it was built for.
|
||||
|
||||
class Api::ServiceController < ApiController
|
||||
resource_description do
|
||||
short "Service descriptor"
|
||||
|
||||
@@ -5,14 +5,8 @@ module WarpEngine
|
||||
formats [ "json" ]
|
||||
end
|
||||
|
||||
# Every response the engine serves names the version that served it, so a client can
|
||||
# branch on the engine's age without a round trip to ask. Set *before* the action,
|
||||
# not after: an error handled by `rescue_from` never reaches an after_action, and a
|
||||
# client needs the version most when something came back wrong.
|
||||
before_action :set_version_header
|
||||
|
||||
# Every read-only endpoint may be called with a bearer token; none of them requires
|
||||
# one. See WarpEngine::SubjectAuthentication.
|
||||
include WarpEngine::SubjectAuthentication
|
||||
|
||||
rescue_from StandardError do |e|
|
||||
@@ -39,12 +33,7 @@ module WarpEngine
|
||||
private
|
||||
|
||||
def set_version_header
|
||||
# The name is spelled out here rather than taken from WarpEngine::VERSION_HEADER on
|
||||
# purpose. A deployed process can end up with these controllers and an older
|
||||
# `lib/` — it happened on the first deploy of this feature — and a controller that
|
||||
# needs a constant from the newer half answers 500 to every request instead of
|
||||
# serving the catalog. A response header is not worth that fragility. The constant
|
||||
# is still the documented name, and a spec holds the two together.
|
||||
|
||||
response.headers["WarpEngine-Version"] = WarpEngine::VERSION
|
||||
end
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
module WarpEngine
|
||||
module Build
|
||||
# Woodpecker configuration-extension endpoint: on every pipeline start the
|
||||
# CI server POSTs the repo's marker file and receives the platform's full
|
||||
# pipeline YAML. GET renders the same thing as a preview.
|
||||
|
||||
class ConfigsController < ApiController
|
||||
resource_description do
|
||||
short "Woodpecker CI pipeline configs"
|
||||
@@ -58,9 +56,6 @@ module WarpEngine
|
||||
)
|
||||
end
|
||||
|
||||
# The first submitted config that parses as a marker (Hash with a `platform`
|
||||
# key). The docs call the key "configuration", the example-config-service
|
||||
# uses "configs" — accept both.
|
||||
def find_marker
|
||||
configs = params[:configuration].presence || params[:configs].presence || []
|
||||
configs.each do |config|
|
||||
|
||||
@@ -9,8 +9,6 @@ module WarpEngine
|
||||
short "Build artifact upload"
|
||||
end
|
||||
|
||||
# Release file naming convention: <name>-<version>.<ext> or
|
||||
# <name>-<version>-<target>.zip — the updater looks for these too.
|
||||
NAME_FORMAT = /\A[A-Za-z0-9._-]+\z/
|
||||
|
||||
api :POST, "/build/upload", "Upload a build artifact into the artifact directory"
|
||||
|
||||
@@ -6,12 +6,9 @@ module WarpEngine
|
||||
|
||||
UPDATE_SCOPE = "update".freeze
|
||||
UPLOAD_SCOPE = "upload".freeze
|
||||
# A token held by a *client* rather than a publisher: it reads the catalog and
|
||||
# downloads artifacts, and it never publishes anything.
|
||||
|
||||
CATALOG_SCOPE = "catalog".freeze
|
||||
|
||||
# The generated token is only available in memory at creation time — the DB
|
||||
# stores nothing but the SHA256 digest and the non-secret prefix.
|
||||
attr_reader :plain_token
|
||||
|
||||
belongs_to :owner, polymorphic: true
|
||||
@@ -33,7 +30,6 @@ module WarpEngine
|
||||
Digest::SHA256.hexdigest(token)
|
||||
end
|
||||
|
||||
# The live (not deleted, not expired) token carrying the required scope, else nil.
|
||||
def self.authenticate(token, required_scope: nil)
|
||||
return nil if token.blank?
|
||||
|
||||
@@ -48,7 +44,6 @@ module WarpEngine
|
||||
expires_at.present? && expires_at <= Time.current
|
||||
end
|
||||
|
||||
# Revocation = soft delete, the audit trail stays.
|
||||
def revoke!
|
||||
update_column(:deleted_at, Time.current)
|
||||
end
|
||||
@@ -57,7 +52,6 @@ module WarpEngine
|
||||
update_column(:last_used_at, Time.current)
|
||||
end
|
||||
|
||||
# Admin form: comma separated scope list
|
||||
def scopes_string
|
||||
Array(scopes).join(", ")
|
||||
end
|
||||
@@ -70,7 +64,6 @@ module WarpEngine
|
||||
%w[created_at deleted_at expires_at id last_used_at name owner_id owner_type token_prefix unrestricted updated_at]
|
||||
end
|
||||
|
||||
# Ransack cannot filter on the polymorphic owner association.
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
[]
|
||||
end
|
||||
@@ -81,10 +74,6 @@ module WarpEngine
|
||||
self.owner_type = WarpEngine.config.application_token_owner_class if owner_type.blank?
|
||||
end
|
||||
|
||||
# Publishing tokens and client tokens share this table but not their owners: one
|
||||
# belongs to whoever ships software, the other to whoever buys it. Both classes are
|
||||
# the host's to name, and either is acceptable here — which of the two a given token
|
||||
# may do is decided by its scopes, not by its owner.
|
||||
def self.permitted_owner_types
|
||||
[ WarpEngine.config.application_token_owner_class,
|
||||
WarpEngine.config.access_token_owner_class ].compact_blank
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
module WarpEngine
|
||||
# One pending sign-in from a client that has no browser of its own.
|
||||
#
|
||||
# The shape is RFC 8628's device authorization grant, and the reason for it is that a
|
||||
# desktop client cannot host a login form without asking a person to type a password
|
||||
# into a window that is not a browser. So the client asks for a pair of codes, sends
|
||||
# the person to the host's own page with the short one, and polls with the long one
|
||||
# until somebody approves it.
|
||||
#
|
||||
# Short-lived by design: this row exists for the minute or two between "the client
|
||||
# asked" and "the person answered". What survives it is the ApplicationToken.
|
||||
|
||||
class DeviceGrant < ApplicationRecord
|
||||
self.table_name = "device_grants"
|
||||
|
||||
# No I, O, 0 or 1: this alphabet is read off one screen and typed into another, and
|
||||
# those four are where that goes wrong.
|
||||
USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".freeze
|
||||
USER_CODE_LENGTH = 8
|
||||
|
||||
@@ -33,7 +22,6 @@ module WarpEngine
|
||||
pending.find_by(user_code: normalize_user_code(code))
|
||||
end
|
||||
|
||||
# Typed by a person, so it arrives with whatever case and separators they used.
|
||||
def self.normalize_user_code(code)
|
||||
code.to_s.upcase.gsub(/[^A-Z0-9]/, "")
|
||||
end
|
||||
@@ -42,8 +30,6 @@ module WarpEngine
|
||||
def approved? = approved_at.present?
|
||||
def denied? = denied_at.present?
|
||||
|
||||
# What the polling client is told. Order matters: a denied grant is denied even
|
||||
# after it expires, because "somebody said no" is the more useful answer.
|
||||
def state
|
||||
return :denied if denied?
|
||||
return :approved if approved?
|
||||
@@ -52,13 +38,10 @@ module WarpEngine
|
||||
:pending
|
||||
end
|
||||
|
||||
# Grouped for reading aloud and for typing: WARP-K7M2.
|
||||
def formatted_user_code
|
||||
user_code.to_s.scan(/.{1,4}/).join("-")
|
||||
end
|
||||
|
||||
# Housekeeping for a host that wants it: an expired grant has nothing left to give,
|
||||
# and its issued_token would be a live secret nobody is waiting for.
|
||||
def self.sweep_expired!
|
||||
where(expires_at: ...Time.current).where.not(issued_token: nil).update_all(issued_token: nil)
|
||||
end
|
||||
@@ -79,8 +62,7 @@ module WarpEngine
|
||||
end
|
||||
|
||||
def self.generate_user_code
|
||||
# Retried rather than trusted: the alphabet is small enough that a collision is
|
||||
# a real, if rare, event, and a unique index would turn it into a 500.
|
||||
|
||||
10.times do
|
||||
candidate = Array.new(USER_CODE_LENGTH) { USER_CODE_ALPHABET.chars.sample }.join
|
||||
return candidate unless exists?(user_code: candidate)
|
||||
|
||||
@@ -2,29 +2,14 @@ module WarpEngine
|
||||
class Pipeline < ApplicationRecord
|
||||
self.table_name = "pipelines"
|
||||
|
||||
# Repos synced from Woodpecker without a matching Software land here
|
||||
# until a platform is assigned by hand.
|
||||
UNKNOWN_PLATFORM = "unknown".freeze
|
||||
|
||||
belongs_to :software, class_name: "WarpEngine::Software", optional: true
|
||||
|
||||
# Pipelines this record took the software from during the last save, by full name.
|
||||
# The admin says so out loud: a silent reassignment is what made the old behaviour
|
||||
# confusing in the first place.
|
||||
attr_reader :software_taken_from
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
# One pipeline per software, and the newest assignment wins.
|
||||
#
|
||||
# `Software#pipeline` is a `has_one`, so two pipelines pointing at the same software
|
||||
# is not an error — it is worse than one: the software keeps showing whichever row
|
||||
# comes first, and assigning it elsewhere looks like it did nothing. Rather than
|
||||
# refusing the assignment, the link moves: whoever held that software lets go of it.
|
||||
#
|
||||
# Deliberately a callback and not a unique index. Rows here are soft-deleted, and a
|
||||
# unique index counts deleted rows too, so a pipeline someone removed last year would
|
||||
# block the software from ever being linked again.
|
||||
before_save :claim_software_from_other_pipelines, if: :will_save_change_to_software_id?
|
||||
|
||||
validates :woodpecker_repo_id, presence: true, uniqueness: true
|
||||
@@ -57,10 +42,6 @@ module WarpEngine
|
||||
@software_taken_from = others.map(&:full_name)
|
||||
return if @software_taken_from.empty?
|
||||
|
||||
# Logged rather than flashed. The first attempt at this put a message on screen by
|
||||
# overriding the admin's `update` action, which bypassed the permitted-params path
|
||||
# and made every pipeline edit fail with ForbiddenAttributesError. A silent
|
||||
# reassignment is a small problem; an admin page that cannot save is a large one.
|
||||
Rails.logger.info(
|
||||
"[WarpEngine::Pipeline] #{full_name} took software #{software_id} from " \
|
||||
"#{@software_taken_from.join(', ')}"
|
||||
|
||||
@@ -2,8 +2,6 @@ module WarpEngine
|
||||
class Software < ApplicationRecord
|
||||
self.table_name = "softwares"
|
||||
|
||||
# Owner of the publishing token (e.g. AdminUser) — for 3rd-party isolation,
|
||||
# see enforce_software_ownership. nil = internal / pre-backfill software.
|
||||
belongs_to :owner, polymorphic: true, optional: true
|
||||
|
||||
has_many :software_images, foreign_key: :software_id, dependent: :destroy
|
||||
|
||||
@@ -4,7 +4,6 @@ module WarpEngine
|
||||
|
||||
FILE_PATH_TO = "/file/"
|
||||
|
||||
# A lemezen tárolt asset-útvonalak publikus /file/ URL-lé írása configból.
|
||||
def self.file_path_from
|
||||
"#{WarpEngine.config.file_container_path.chomp("/")}/"
|
||||
end
|
||||
|
||||
@@ -5,8 +5,7 @@ module WarpEngine
|
||||
field(:latestRelease) { |_, opts| opts[:latest] ? ReleaseSerializer.render_as_hash(opts[:latest], download_counts: opts[:download_counts]) : nil }
|
||||
field(:webPlayableRelease) { |_, opts| opts[:web_playable] ? ReleaseSerializer.render_as_hash(opts[:web_playable], download_counts: opts[:download_counts]) : nil }
|
||||
field(:totalDownloads) { |_, opts| opts[:total_downloads] || 0 }
|
||||
# Whether this title is gated, what it costs and where to get it. Always present —
|
||||
# see WarpEngine::Access. Under the open policy it is the constant OPEN answer.
|
||||
|
||||
field(:access) { |_, opts| (opts[:access] || WarpEngine::Access::OPEN).as_json }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,7 +14,7 @@ module WarpEngine
|
||||
field(:license) { |sw| sw.license.to_s }
|
||||
field :platform
|
||||
field :status
|
||||
# Public owner id — for the /api/software?owner_id= filter.
|
||||
|
||||
field(:ownerId) { |sw| sw.owner_id }
|
||||
field(:highlighted) { |sw| sw.highlighted ? true : false }
|
||||
field(:externalLinks) { |sw| ExternalLinkSerializer.render_as_hash(sw.external_links) }
|
||||
|
||||
+1
-8
@@ -1,14 +1,7 @@
|
||||
module WarpEngine
|
||||
module Platforms
|
||||
module Builds
|
||||
# Linux on 64-bit ARM: Raspberry Pi 4/5, Odroid, and the retro handhelds.
|
||||
# Batocera and the ES-family distributions run on these as much as on
|
||||
# x86_64, and an x64 binary installs there but will not start — which is
|
||||
# why this is a separate kind rather than something linux_x64 can cover.
|
||||
#
|
||||
# Include this in a platform service only once its pipeline actually
|
||||
# produces the artifact: registering the kind makes /api/builds report it
|
||||
# as missing for every release until then.
|
||||
|
||||
module BuildLinuxArm64
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
require "erb"
|
||||
|
||||
module WarpEngine
|
||||
# Renders the /build/config platform templates: the pipeline logic lives in
|
||||
# app/services/warp_engine/platforms/<platform>/pipeline.yaml.erb, the
|
||||
# per-platform builder images come from WarpEngine.config.ci_platforms.
|
||||
|
||||
class CiConfigService
|
||||
PLATFORM_FORMAT = /\A[a-z0-9_-]+\z/
|
||||
|
||||
# The rendered pipeline YAML, or nil when the platform is not served.
|
||||
def render(platform:, name:, update_server:)
|
||||
platform = platform.to_s
|
||||
return nil unless platform.match?(PLATFORM_FORMAT)
|
||||
|
||||
@@ -4,12 +4,7 @@ require "net/http"
|
||||
require "digest"
|
||||
|
||||
module WarpEngine
|
||||
# Verifies the signature of Woodpecker configuration-extension requests.
|
||||
# Woodpecker 3.x signs with RFC 9421 HTTP message signatures (ed25519, via
|
||||
# yaronf/httpsign): Signature-Input + Signature + Content-Digest headers,
|
||||
# covered components "@request-target" and "content-digest". Older versions
|
||||
# used draft-cavage http-signatures (a single Signature header) — kept as a
|
||||
# fallback.
|
||||
|
||||
class CiSignatureVerifier
|
||||
CAVAGE_PARAM = /(\w+)="([^"]*)"/
|
||||
|
||||
@@ -17,7 +12,7 @@ module WarpEngine
|
||||
@key_mutex = Mutex.new
|
||||
|
||||
class << self
|
||||
# The downloaded key is cached process-wide (per URL).
|
||||
|
||||
def fetch_public_key(url)
|
||||
@key_mutex.synchronize do
|
||||
@key_cache[url] ||= Net::HTTP.get(URI.parse(url))
|
||||
@@ -64,8 +59,6 @@ module WarpEngine
|
||||
nil
|
||||
end
|
||||
|
||||
# --- RFC 9421 ---
|
||||
|
||||
def rfc9421_valid?(key)
|
||||
input = @request.headers["Signature-Input"].to_s
|
||||
match = input.match(/\A\s*([\w.-]+)=(\(.*)\z/m)
|
||||
@@ -102,8 +95,6 @@ module WarpEngine
|
||||
end
|
||||
end
|
||||
|
||||
# When content-digest is a covered component, the body itself must match
|
||||
# the digest header — this is what ties the signature to the payload.
|
||||
def content_digest_valid?(components)
|
||||
return true unless components.include?("content-digest")
|
||||
|
||||
@@ -114,8 +105,6 @@ module WarpEngine
|
||||
ActiveSupport::SecurityUtils.secure_compare(digest, expected)
|
||||
end
|
||||
|
||||
# --- draft-cavage fallback ---
|
||||
|
||||
def cavage_valid?(key)
|
||||
params = cavage_params
|
||||
return false if params.nil? || params["signature"].blank?
|
||||
@@ -126,7 +115,6 @@ module WarpEngine
|
||||
key.verify(nil, Base64.decode64(params["signature"]), signing_string)
|
||||
end
|
||||
|
||||
# Parameters of the Signature header (or the "Authorization: Signature ..." form).
|
||||
def cavage_params
|
||||
header = @request.headers["Signature"].presence
|
||||
if header.nil?
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
module WarpEngine
|
||||
# The device authorization grant, from both ends.
|
||||
#
|
||||
# The client's end is #request, #poll and #revoke, all reachable over /api/auth/*.
|
||||
# The person's end is #approve and #deny, which the *host* calls from its own page —
|
||||
# approving needs a session and a logged-in human, and the engine has neither.
|
||||
|
||||
class DeviceGrantService
|
||||
class NotConfigured < StandardError; end
|
||||
class UnknownCode < StandardError; end
|
||||
|
||||
# A client asks for a code pair. Deliberately unauthenticated: there is nobody to
|
||||
# authenticate as yet, which is the whole reason this flow exists.
|
||||
def request(client_name:)
|
||||
ensure_configured!
|
||||
|
||||
WarpEngine::DeviceGrant.create!(client_name: client_name.presence&.truncate(128))
|
||||
end
|
||||
|
||||
# The client polls with the device code. Returns [state, token], where the token is
|
||||
# the plain string and is available exactly once — on the poll that finds the grant
|
||||
# newly approved. A second poll gets :approved with no token, which is the honest
|
||||
# answer: the secret was handed over and is not kept.
|
||||
def poll(device_code:)
|
||||
ensure_configured!
|
||||
|
||||
@@ -28,16 +18,11 @@ module WarpEngine
|
||||
|
||||
return [ grant.state, nil ] unless grant.state == :approved
|
||||
|
||||
# Read once, then gone: the column exists only to carry the secret across the gap
|
||||
# between the browser that approved it and the client that is polling for it.
|
||||
plain = grant.issued_token
|
||||
grant.update_columns(issued_token: nil) if plain.present?
|
||||
[ :approved, plain ]
|
||||
end
|
||||
|
||||
# The host's approval page calls this with the code a person typed and the subject
|
||||
# they are signed in as. Issuing the token here rather than on the next poll keeps
|
||||
# the decision and its consequence in one transaction.
|
||||
def approve(user_code:, subject:)
|
||||
ensure_configured!
|
||||
|
||||
@@ -73,8 +58,6 @@ module WarpEngine
|
||||
grant
|
||||
end
|
||||
|
||||
# Signing out: the client throws its own token away. Revocation is a soft delete on
|
||||
# the token, so the audit trail of who signed in from where survives it.
|
||||
def revoke(token:)
|
||||
return false if token.nil?
|
||||
|
||||
@@ -82,9 +65,6 @@ module WarpEngine
|
||||
true
|
||||
end
|
||||
|
||||
# Where a person goes to type the user code. A path is made absolute against the
|
||||
# request's own base, so a host that configured "/devices" does not have to know its
|
||||
# own hostname.
|
||||
def verification_url(base_url: nil)
|
||||
configured = WarpEngine.config.identity_verification_url.presence || "/devices"
|
||||
return configured if configured.start_with?("http://", "https://")
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
module WarpEngine
|
||||
class DownloadService
|
||||
# The access policy said no. The controller turns this into a 403 — distinct from
|
||||
# the nil that means "no such file", because telling a person their file is missing
|
||||
# when it is merely locked sends them looking for the wrong problem.
|
||||
|
||||
class Denied < StandardError; end
|
||||
|
||||
def self.container_base
|
||||
WarpEngine.config.file_container_path
|
||||
end
|
||||
|
||||
# Lazy: a container path csak az első használatkor kötelező, boot/teszt közben nem.
|
||||
def self.base_path
|
||||
Pathname.new(container_base).realpath
|
||||
end
|
||||
|
||||
# A letöltés helyét adja vissza (fájl vagy aláírt URL) és naplózza a
|
||||
# letöltést. A hely feloldása a storage adapteren megy — alapból :local,
|
||||
# tehát változatlanul lemezről.
|
||||
#
|
||||
# `subject` is whoever the request authenticated as, or nil. Under the open policy
|
||||
# it is ignored and every file is served, exactly as before.
|
||||
def locate(path:, ip:, user_agent:, referer:, subject: nil, request: nil)
|
||||
relative = path.to_s
|
||||
return nil unless storage.file?(relative)
|
||||
@@ -34,8 +25,6 @@ module WarpEngine
|
||||
expires_in: grant.expires_in)
|
||||
end
|
||||
|
||||
# Visszafelé kompatibilis felület: az abszolút fájlútvonalat adja vissza
|
||||
# (vagy nil-t). Nem lemezes adapternél nincs útvonal — ott a #locate való.
|
||||
def create(path:, ip:, user_agent:, referer:, subject: nil, request: nil)
|
||||
location = locate(path: path, ip: ip, user_agent: user_agent, referer: referer,
|
||||
subject: subject, request: request)
|
||||
@@ -50,9 +39,6 @@ module WarpEngine
|
||||
WarpEngine.storage
|
||||
end
|
||||
|
||||
# A policy that refuses returns nil; one that raises is treated as a refusal too.
|
||||
# An artifact served because the gatekeeper crashed is the one failure mode this
|
||||
# engine must not have.
|
||||
def authorize!(asset, subject, request)
|
||||
grant = WarpEngine.access_policy.authorize_download(asset: asset, subject: subject, request: request)
|
||||
raise Denied if grant.nil?
|
||||
@@ -80,8 +66,6 @@ module WarpEngine
|
||||
referer: referer&.truncate(500)
|
||||
)
|
||||
|
||||
# The same seam the publish side has: a host that wants its own record of who
|
||||
# downloaded what subscribes rather than reaching into this class.
|
||||
ActiveSupport::Notifications.instrument("warp_engine.download",
|
||||
path: relative, asset: asset, release: asset&.release,
|
||||
software: asset&.release&.software, subject: subject, download: download, ip: ip)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module WarpEngine
|
||||
class FileManagerService
|
||||
# Lazy: a container path csak az első használatkor kötelező, boot/teszt közben nem.
|
||||
|
||||
def base_path
|
||||
@base_path ||= Pathname.new(WarpEngine.config.file_container_path)
|
||||
end
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
module WarpEngine
|
||||
class FileService
|
||||
# Lazy: a container path csak az első használatkor kötelező, boot/teszt közben nem.
|
||||
|
||||
def self.base_path
|
||||
Pathname.new(WarpEngine.config.file_container_path).realpath
|
||||
end
|
||||
|
||||
# A fájlok helyét a storage adapter adja (alapból :local, azaz a lemez) —
|
||||
# így a host az objektumtárból is kiszolgálhat anélkül, hogy az engine-t
|
||||
# patchelné. Lásd WarpEngine::Storage.
|
||||
# `subject` is whoever the request authenticated as, or nil. Under the open policy
|
||||
# it is ignored and every file is served, exactly as before.
|
||||
#
|
||||
# A hosted (browser) build reaches this path as hundreds of relative requests for
|
||||
# js, wasm and images, which is why the gate here is the policy's plain yes/no
|
||||
# rather than anything signed: there is nothing to sign per file. A host serving
|
||||
# gated web builds to browsers will usually want its own session-based route in
|
||||
# front of this one — a browser has a session, and a redirect to a login page is a
|
||||
# better answer there than a bare 403.
|
||||
def show(input, subject: nil)
|
||||
relative = input.path.to_s
|
||||
|
||||
@@ -36,13 +24,8 @@ module WarpEngine
|
||||
|
||||
private
|
||||
|
||||
# Same rule and same failure mode as DownloadService: a policy that refuses or
|
||||
# raises means no file. An artifact served because the gatekeeper crashed is the
|
||||
# one failure mode this engine must not have.
|
||||
def authorize!(relative, subject)
|
||||
# The open policy authorises everything, and this path serves a browser build as
|
||||
# hundreds of requests for js, wasm and images. Asking it per file would mean a
|
||||
# LIKE query per asset for an answer that is always yes.
|
||||
|
||||
return WarpEngine::Access::Grant::OPEN if WarpEngine::AccessPolicy.open?
|
||||
|
||||
asset = WarpEngine::ReleaseAsset.where("path LIKE ?", "%#{relative.gsub('%', '\\%').gsub('_', '\\_')}%").first
|
||||
|
||||
@@ -8,8 +8,6 @@ module WarpEngine
|
||||
@client.trigger_pipeline(pipeline.woodpecker_repo_id, branch: branch)
|
||||
end
|
||||
|
||||
# Fetches one page of a pipeline's runs; the newest one refreshes the
|
||||
# cached last_pipeline_* columns (Pipelines index and the CI API).
|
||||
def list_pipelines(pipeline, page: 1)
|
||||
runs = @client.list_pipelines(pipeline.woodpecker_repo_id, page: page)
|
||||
refresh_last_pipeline(pipeline, runs.first) if page == 1 && runs.is_a?(Array)
|
||||
@@ -22,7 +20,6 @@ module WarpEngine
|
||||
|
||||
private
|
||||
|
||||
# Woodpecker returns unix epoch seconds in "created".
|
||||
def refresh_last_pipeline(pipeline, run)
|
||||
return unless run && pipeline.persisted?
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
module WarpEngine
|
||||
class PublishService
|
||||
# Az esemény neve, amit a host lehallgathat. A payload:
|
||||
# platform: String, name: String, version: String,
|
||||
# software: WarpEngine::Software, release: WarpEngine::Release
|
||||
|
||||
NOTIFICATION = "warp_engine.publish".freeze
|
||||
|
||||
def publish(input)
|
||||
@@ -13,9 +11,6 @@ module WarpEngine
|
||||
release = "WarpEngine::Platforms::#{input.platform.camelize}::Service".constantize
|
||||
.new.update(input.name, input.version)
|
||||
|
||||
# A publikálás az egyetlen pont, ahol új build kerül a katalógusba —
|
||||
# a host innen tud rá reagálni (értesítés, feed, csatorna-előléptetés)
|
||||
# anélkül, hogy modell-callbackre kellene kapaszkodnia.
|
||||
ActiveSupport::Notifications.instrument(
|
||||
NOTIFICATION,
|
||||
platform: input.platform,
|
||||
|
||||
@@ -5,7 +5,7 @@ module WarpEngine
|
||||
def build_response(software, releases, download_counts, subject: nil)
|
||||
sorted = releases.sort_by { |r| r.created_at || Time.at(0) }.reverse
|
||||
latest = sorted.reject { |r| r.version.to_s.start_with?("dev-") }.first
|
||||
# web-playable, ha az utolsó (stabil) release-nek van webes assetje
|
||||
|
||||
web_playable = latest if latest&.release_assets&.any? { |a| a.kind == "html" }
|
||||
total_downloads = releases.sum { |r| download_counts.fetch(r.id, 0) }
|
||||
|
||||
@@ -19,21 +19,14 @@ module WarpEngine
|
||||
)
|
||||
end
|
||||
|
||||
# Always present, even under the open policy: a client should never have to tell
|
||||
# "this catalog says nothing about access" from "this title is not gated". One of
|
||||
# those is a question and the other is an answer.
|
||||
def access_for(software, subject)
|
||||
WarpEngine.access_policy.access_for(software: software, subject: subject)
|
||||
rescue StandardError => e
|
||||
# A policy that raises must not take the catalog down with it. The open answer is
|
||||
# wrong here, so the closed one is what a broken policy gets: a title nobody can
|
||||
# download is recoverable, a paid title handed out for free is not.
|
||||
|
||||
Rails.logger.error("[WarpEngine::AccessPolicy] #{e.class}: #{e.message}")
|
||||
WarpEngine::Access.new(gated: true, entitled: false)
|
||||
end
|
||||
|
||||
# The titles this subject may see. A policy that raises empties the catalog rather
|
||||
# than leaking it — the safe direction — but it is still a bug, so it is logged.
|
||||
def visible_scope(subject)
|
||||
WarpEngine.access_policy.visible_software_scope(subject: subject)
|
||||
rescue StandardError => e
|
||||
@@ -41,7 +34,6 @@ module WarpEngine
|
||||
WarpEngine::Software.none
|
||||
end
|
||||
|
||||
# Egyetlen csoportosított lekérdezés release-enkénti letöltésszámokhoz (N+1 helyett).
|
||||
def download_counts_for(release_ids)
|
||||
return {} if release_ids.empty?
|
||||
WarpEngine::Download.where(release_id: release_ids).group(:release_id).count
|
||||
|
||||
@@ -2,9 +2,6 @@ module WarpEngine
|
||||
class SoftwareService
|
||||
include SoftwareResponseBuilder
|
||||
|
||||
# `subject` is whoever the request authenticated as, or nil. It decides two things:
|
||||
# which titles are listed at all (the policy's scope) and what each one's `access`
|
||||
# block says about entitlement.
|
||||
def index(owner_id: nil, subject: nil)
|
||||
softwares = visible_scope(subject)
|
||||
.includes(releases: [ :release_assets ])
|
||||
|
||||
@@ -21,8 +21,6 @@ module WarpEngine
|
||||
@token = token || WarpEngine.config.woodpecker_api_token
|
||||
end
|
||||
|
||||
# --- Repos ---
|
||||
|
||||
def list_repos
|
||||
get("/api/repos")
|
||||
end
|
||||
@@ -39,8 +37,6 @@ module WarpEngine
|
||||
delete("/api/repos/#{repo_id}")
|
||||
end
|
||||
|
||||
# --- Secrets ---
|
||||
|
||||
def list_secrets(repo_id)
|
||||
get("/api/repos/#{repo_id}/secrets")
|
||||
end
|
||||
@@ -59,8 +55,6 @@ module WarpEngine
|
||||
delete("/api/repos/#{repo_id}/secrets/#{secret_name}")
|
||||
end
|
||||
|
||||
# --- Pipelines ---
|
||||
|
||||
def list_pipelines(repo_id, page: 1, per_page: 25)
|
||||
get("/api/repos/#{repo_id}/pipelines",
|
||||
params: { page: page, perPage: per_page })
|
||||
@@ -139,8 +133,7 @@ module WarpEngine
|
||||
begin
|
||||
JSON.parse(response.body)
|
||||
rescue JSON::ParserError
|
||||
# A wrong path falls through to the Woodpecker SPA, which answers
|
||||
# 200 with index.html — surface that as an API error, not a parse one.
|
||||
|
||||
raise ApiError.new(
|
||||
"Expected JSON from #{uri.path} but got: #{response.body.truncate(80)}",
|
||||
status: response.code.to_i, body: response.body
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
WarpEngine::Engine.routes.draw do
|
||||
namespace :api do
|
||||
# What this deployment is and what it can do. A client reads it before it can have
|
||||
# a credential, so it is public and cheap.
|
||||
get "service", to: "service#show"
|
||||
|
||||
# Device sign-in, for clients that have no browser of their own (RFC 8628).
|
||||
# Inactive — 404 on every action — unless the host configured a subject class.
|
||||
namespace :auth do
|
||||
post "device", to: "devices#create"
|
||||
post "device/token", to: "devices#token"
|
||||
|
||||
@@ -3,8 +3,7 @@ class CreateApplicationTokens < ActiveRecord::Migration[8.1]
|
||||
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
|
||||
# The owner class comes from the host (WarpEngine.config.application_token_owner_class),
|
||||
# so no 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
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
class AddBuildOwnership < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# Owner of the publishing token; nil = internal / pre-backfill software.
|
||||
# The owner class comes from the host (application_token_owner_class), so no 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 = internal token: exempt from enforce_software_ownership.
|
||||
add_column :application_tokens, :unrestricted, :boolean, default: false, null: false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,26 +2,16 @@ class CreateDeviceGrants < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :device_grants, id: { type: :bigint, unsigned: true },
|
||||
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci" do |t|
|
||||
# Both codes are secrets in the sense that guessing one grants a session, but they
|
||||
# have different jobs: the device code is long and never shown to a person, the
|
||||
# user code is short enough to read off a screen and type into a browser.
|
||||
|
||||
t.string :device_code, limit: 64, null: false
|
||||
t.string :user_code, limit: 16, null: false
|
||||
# What the client called itself. Shown on the approval page, so a person can tell
|
||||
# which machine is asking, and kept on the issued token as its name.
|
||||
|
||||
t.string :client_name, limit: 128
|
||||
# The approving subject comes from the host
|
||||
# (WarpEngine.config.access_token_owner_class), so no FK — same reasoning as
|
||||
# application_tokens.owner_type.
|
||||
|
||||
t.string :subject_type, limit: 128
|
||||
t.bigint :subject_id, unsigned: true
|
||||
t.bigint :application_token_id, unsigned: true
|
||||
# The issued token, in the clear, for the seconds between "approved" and "the
|
||||
# client's next poll". It is cleared on the poll that hands it over, so this
|
||||
# column holds a live secret only while somebody is waiting for it. There is no
|
||||
# way around storing it: the approval happens in a browser and the poll arrives on
|
||||
# a different request, so the two cannot share memory. Everything else about a
|
||||
# token is stored as a digest — this is the one exception, and it is temporary.
|
||||
|
||||
t.string :issued_token, limit: 64
|
||||
t.datetime :approved_at, precision: 3
|
||||
t.datetime :denied_at, precision: 3
|
||||
|
||||
@@ -17,7 +17,6 @@ module HostApp
|
||||
config.time_zone = "UTC"
|
||||
config.active_record.default_timezone = :utc
|
||||
|
||||
# Demo stack: reachable as localhost, gitea-network hostnames, etc.
|
||||
config.hosts.clear
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user