-- 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