275 lines
12 KiB
Lua
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
|