DVX_GUI/server/lib/packages.lua
2026-10-20 17:24:15 -05:00

275 lines
12 KiB
Lua

-- The MIT License (MIT)
--
-- Copyright (C) 2026 Scott Duensing
--
-- Permission is hereby granted, free of charge, to any person obtaining a copy
-- of this software and associated documentation files (the "Software"), to
-- deal in the Software without restriction, including without limitation the
-- rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
-- sell copies of the Software, and to permit persons to whom the Software is
-- furnished to do so, subject to the following conditions:
--
-- The above copyright notice and this permission notice shall be included in
-- all copies or substantial portions of the Software.
--
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-- FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
-- IN THE SOFTWARE.
-- packages.lua -- the DVX package catalogue: packages, their versions, what each needs, and the
-- review that publishes a version.
--
-- What a package is comes from its manifest, PACKAGE.INI, which the service never takes on the
-- uploader's word: the DVX package tool (dvxpkg check, the same code DVX installs with) checks the
-- manifest and every file's size and SHA-256 against it, and hands back the manifest exactly as it
-- is in the package. Only then is it read here, for what the catalogue shows. The rules about
-- what a package may be live in that one place, not here.
--
-- A package (vendor.name) belongs to the account that first uploaded it; DVX's own (kpunch.*)
-- belong to the operator. A version is pending until the operator approves it with the
-- repository's signature (made on the operator's own computer: the service holds no secret key)
-- or rejects it. Only approved versions of published packages are listed or handed out.
local packages = {}
packages.DVX_VENDOR = "kpunch" -- DVX's own packages: the operator's to upload
packages.VERSION_PARTS = 3 -- major.minor.patch
packages.RANK_STEP = 65536 -- each version part is 0..65535
-- The fields of [package] the catalogue keeps (the manifest is kept whole as well).
local FIELDS = { "name", "kind", "summary", "publisher", "author", "licence", "homepage", "dvx" }
-- A version's text ("1.2" or "1.2.0") as the catalogue writes it and its rank (for ordering), or
-- nil. The rules are DVX's (pkgVersionParse): one to VERSION_PARTS parts, each digits, none empty.
function packages.versionOf(text)
local parts = {}
for part in (tostring(text or "") .. "."):gmatch("([^.]*)%.") do
local n = part:match("^%d+$") and tonumber(part)
if not n or n >= packages.RANK_STEP then
return nil
end
parts[#parts + 1] = n
end
if #parts > packages.VERSION_PARTS then
return nil
end
while #parts < packages.VERSION_PARTS do
parts[#parts + 1] = 0
end
local rank = (parts[1] * packages.RANK_STEP + parts[2]) * packages.RANK_STEP + parts[3]
return string.format("%d.%d.%d", parts[1], parts[2], parts[3]), rank
end
-- A manifest's text (as dvxpkg check gives it) read into { fields..., depends = {{id, min}} }.
-- The tool has already checked it; this only reads it.
function packages.readManifest(text)
local m = { depends = {} }
local section = nil
-- As DVX reads it (pkgIni.c): a line starting with ; or # is a comment, section names are
-- any case, and keys and values lose the blanks around them
for line in (text .. "\n"):gmatch("([^\r\n]*)\r?\n") do
local stripped = line:match("^%s*(.-)%s*$")
local name = stripped:match("^%[%s*(.-)%s*%]$")
if stripped == "" or stripped:match("^[;#]") then
-- a blank line or a comment
elseif name then
section = name:lower()
else
local key, value = stripped:match("^([^=]-)%s*=%s*(.*)$")
if key and section == "package" then
m[key:lower()] = value
elseif key and section == "depends" then
m.depends[#m.depends + 1] = { id = key, min = value }
end
end
end
return m
end
-- The package id, or nil.
function packages.get(handle, id)
return dbQuery(handle, "SELECT * FROM package WHERE id = ?", id)[1]
end
-- A version of a package as the catalogue gives it: its fields, size, digest, signer, and needs.
local function entryOf(handle, row)
local entry = { id = row.packageId, version = row.version, size = row.size, sha256 = row.sha256,
signer = row.signerKey, abi = row.abi, signed = row.repoSig ~= nil, depends = {} }
for _, field in ipairs(FIELDS) do
entry[field] = row[field]
end
for _, d in ipairs(dbQuery(handle, "SELECT dependId, minVersion FROM packageDepend WHERE packageId = ? AND version = ? ORDER BY dependId",
row.packageId, row.version)) do
entry.depends[#entry.depends + 1] = { id = d.dependId, min = d.minVersion }
end
return entry
end
local VERSION_COLUMNS = "v.*, b.size, b.sha256"
local VERSION_JOIN = "packageVersion v JOIN blob b ON b.id = v.blobId JOIN package p ON p.id = v.packageId"
-- Every published package at its newest approved version, by id.
function packages.catalogue(handle)
local rows = dbQuery(handle,
"SELECT " .. VERSION_COLUMNS .. " FROM " .. VERSION_JOIN .. " " ..
"WHERE p.state = 'published' AND v.state = 'approved' AND v.rank = " ..
"(SELECT MAX(rank) FROM packageVersion w WHERE w.packageId = v.packageId AND w.state = 'approved') " ..
"ORDER BY v.packageId")
local list = {}
for _, row in ipairs(rows) do
list[#list + 1] = entryOf(handle, row)
end
return list
end
-- A published package's approved versions, newest first (nil when it is not listed).
function packages.versions(handle, id)
local pkg = packages.get(handle, id)
if not pkg or pkg.state ~= "published" then
return nil
end
local list = {}
for _, row in ipairs(dbQuery(handle, "SELECT " .. VERSION_COLUMNS .. " FROM " .. VERSION_JOIN .. " " ..
"WHERE v.packageId = ? AND v.state = 'approved' ORDER BY v.rank DESC", id)) do
list[#list + 1] = entryOf(handle, row)
end
return list
end
-- One version with its file, when it may be handed out: approved, of a published package (or,
-- with any true, whatever its state, for the operator's review).
function packages.servable(handle, id, version, any)
local row = dbQuery(handle, "SELECT " .. VERSION_COLUMNS .. ", p.state AS packageState FROM " .. VERSION_JOIN ..
" WHERE v.packageId = ? AND v.version = ?", id, version)[1]
if not row or (not any and (row.state ~= "approved" or row.packageState ~= "published")) then
return nil
end
return row
end
-- What is newer than a client's own list ({{id, version}}): the newest approved version of each
-- package it names that is newer than the one it has, and the ids the catalogue does not know.
function packages.updates(handle, installed)
local newer, unknown = {}, {}
local byId = {}
for _, entry in ipairs(packages.catalogue(handle)) do
byId[entry.id] = entry
end
for _, have in ipairs(installed or {}) do
local entry = byId[tostring(have.id or "")]
if not entry then
unknown[#unknown + 1] = tostring(have.id)
else
local _, haveRank = packages.versionOf(have.version)
local _, newRank = packages.versionOf(entry.version)
if haveRank and newRank > haveRank then
newer[#newer + 1] = entry
end
end
end
return newer, unknown
end
-- Take in a checked package: its manifest's text, the blob holding its bytes, the key its
-- publisher signed it with (or nil), and the account uploading it (nil: the operator). Returns the
-- version's id and version, or nil, an HTTP status, and why.
function packages.intake(handle, manifestText, blobId, signerKey, accountId)
local m = packages.readManifest(manifestText)
local id = tostring(m.id or "")
local version, rank = packages.versionOf(m.version)
local vendor = id:match("^([^.]+)%.")
if id == "" or not version then
return nil, 400, "the manifest has no id or version"
end
if vendor == packages.DVX_VENDOR and accountId then
return nil, 403, id .. " is one of DVX's own packages: only the operator uploads those"
end
local pkg = packages.get(handle, id)
if pkg and pkg.ownerAccountId ~= accountId then
return nil, 403, id .. " belongs to another account"
end
if dbQuery(handle, "SELECT 1 AS x FROM packageVersion WHERE packageId = ? AND version = ?", id, version)[1] then
return nil, 409, id .. " " .. version .. " has been uploaded already: give the new one a higher version"
end
local now = math.floor(timeNow())
dbExec(handle, "BEGIN")
if not pkg then
dbExec(handle, "INSERT INTO package (id, ownerAccountId, state, createdAt) VALUES (?, ?, 'published', ?)", id, accountId, now)
end
dbExec(handle,
"INSERT INTO packageVersion (packageId, version, rank, name, kind, summary, publisher, author, licence, homepage, " ..
"dvx, abi, manifest, signerKey, blobId, state, uploadedBy, uploadedAt) " ..
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)",
id, version, rank, m.name or id, m.kind or "", m.summary, m.publisher, m.author, m.licence, m.homepage,
m.dvx, tonumber(m.abi), manifestText, signerKey, blobId, accountId, now)
for _, d in ipairs(m.depends) do
dbExec(handle, "INSERT INTO packageDepend (packageId, version, dependId, minVersion) VALUES (?, ?, ?, ?)",
id, version, d.id, d.min)
end
dbExec(handle, "COMMIT")
return id, version
end
-- An account's own packages, every version with the review's word on it.
function packages.mine(handle, accountId)
local list = {}
for _, row in ipairs(dbQuery(handle, "SELECT v.packageId, v.version, v.name, v.state, v.note, v.uploadedAt, v.reviewedAt, p.state AS packageState " ..
"FROM packageVersion v JOIN package p ON p.id = v.packageId " ..
"WHERE p.ownerAccountId = ? ORDER BY v.packageId, v.rank DESC", accountId)) do
list[#list + 1] = row
end
return list
end
-- The versions waiting for the operator, oldest first, with who sent each.
function packages.pending(handle)
return dbQuery(handle,
"SELECT v.packageId, v.version, v.name, v.kind, v.summary, v.signerKey, v.uploadedAt, a.email AS uploader, b.size, b.sha256 " ..
"FROM packageVersion v JOIN blob b ON b.id = v.blobId LEFT JOIN account a ON a.id = v.uploadedBy " ..
"WHERE v.state = 'pending' ORDER BY v.uploadedAt")
end
-- The operator's word on a version the caller has found: approved (with the repository's
-- signature, already checked) or rejected (with a note).
function packages.review(handle, id, version, approved, signature, note)
dbExec(handle, "UPDATE packageVersion SET state = ?, repoSig = ?, note = ?, reviewedAt = ? WHERE packageId = ? AND version = ?",
approved and "approved" or "rejected", approved and signature or nil, note, math.floor(timeNow()), id, version)
end
-- Withdraw a package (no longer listed or handed out; the copies installed keep working) or
-- publish it again. The number of packages changed.
function packages.setState(handle, id, state)
dbExec(handle, "UPDATE package SET state = ? WHERE id = ?", state, id)
return dbQuery(handle, "SELECT changes() AS n")[1].n
end
return packages