21 Commits

Author SHA1 Message Date
1db899b5c8 4.1.2: dependencies 2025-05-04 12:55:07 -05:00
fc71bc08db Merge pull request #2 from CorySanin/dependabot/npm_and_yarn/tar-fs-2.1.2
Bump tar-fs from 2.1.1 to 2.1.2
2025-05-04 12:46:38 -05:00
dependabot[bot]
c560100a8b Bump tar-fs from 2.1.1 to 2.1.2
Bumps [tar-fs](https://github.com/mafintosh/tar-fs) from 2.1.1 to 2.1.2.
- [Commits](https://github.com/mafintosh/tar-fs/compare/v2.1.1...v2.1.2)

---
updated-dependencies:
- dependency-name: tar-fs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-28 23:57:01 +00:00
3c34a98e67 4.1.1: use build script package 2025-03-18 21:28:13 -05:00
cb28872652 Merge pull request #1 from CorySanin/ts-modules
TS + modules
2025-01-28 17:54:53 -05:00
a8c057245a github action 2025-01-27 23:22:17 -05:00
d328414186 ts + modules 2025-01-27 20:11:53 -05:00
5b44841cbf 4.0.8: fun with curl 2025-01-07 01:28:35 -05:00
d07f02f4a2 4.0.7: bump dependencies, minor changes 2025-01-05 22:11:59 -05:00
fb44cf636f 4.0.6: shut down irc bot if health check is unhealthy 2024-10-30 13:33:37 -05:00
768b69ce08 4.0.5: update dependencies 2024-09-20 20:02:36 -05:00
0a63914127 Apply name compliance to artix-checkupdates output 2024-08-12 21:23:30 -05:00
568e9d186e Add package count, bump dependencies 2024-07-24 00:58:07 -05:00
fb4b291c81 only allocate for required objects 2024-07-23 00:46:47 -05:00
56c38bdefd 4.0.2: remove delay, sort maintainers' packages 2024-07-19 00:40:22 -05:00
228c462782 minor bugfixes
set up private api correctly and only send irc messages if enabled in config
2024-07-17 11:40:34 -05:00
ad14eff70b set WAL pragma 2024-07-17 00:23:45 -05:00
25e7b4b211 v4.0.0: break out components 2024-07-17 00:13:07 -05:00
65663c02dd always update base image 2024-06-18 20:37:03 -05:00
c4935a0129 update deps, add cache control to maintainers API 2024-06-18 20:30:10 -05:00
1030873894 3.4.2: bump dependencies, add package api 2024-05-12 21:48:48 -05:00
26 changed files with 2333 additions and 1252 deletions

View File

@@ -1,3 +1,4 @@
volume/ volume/
config/ config/
distribution/
node_modules node_modules

90
.github/workflows/build-app-image.yml vendored Normal file
View File

@@ -0,0 +1,90 @@
name: App Image CI
on:
push:
branches:
- master
tags:
- 'v*'
pull_request:
branches:
- master
jobs:
build_app_image:
name: Build app image
runs-on: ubuntu-latest
env:
GH_REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
REPOSITORY: ${{ github.event.repository.name }}
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
with:
install: true
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.GH_REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for release Docker image
if: startsWith(github.ref, 'refs/tags/v')
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.GH_REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Extract metadata for develop Docker image
if: "!startsWith(github.ref, 'refs/tags/v')"
id: meta-develop
uses: docker/metadata-action@v5
with:
images: |
${{ env.GH_REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
- name: Build and push release Docker image
if: startsWith(github.ref, 'refs/tags/v')
uses: docker/build-push-action@v6
with:
target: deploy
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
cache-from: type=gha,scope=${{ github.workflow }}
cache-to: type=gha,mode=max,scope=${{ github.workflow }}
- name: Build and push develop Docker image
if: "!startsWith(github.ref, 'refs/tags/v')"
uses: docker/build-push-action@v6
with:
target: deploy
push: true
tags: ${{ steps.meta-develop.outputs.tags }}
labels: ${{ steps.meta-develop.outputs.labels }}
platforms: linux/amd64
cache-from: type=gha,scope=${{ github.workflow }}
cache-to: type=gha,mode=max,scope=${{ github.workflow }}

3
.gitignore vendored
View File

@@ -108,4 +108,5 @@ docker-cache/
assets/css/ assets/css/
assets/js/ assets/js/
assets/webp/ assets/webp/
config/ config/
distribution/

View File

@@ -1,23 +0,0 @@
image: docker:latest
services:
- docker:dind
variables:
DOCKER_DRIVER: overlay
stages:
- build
- deploy
artix-packy-pusher:
stage: build
script:
- docker build --pull --no-cache -t "$CI_REGISTRY_IMAGE" .
deploy:
stage: deploy
only:
- master
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker push "$CI_REGISTRY_IMAGE"

View File

@@ -1,8 +1,12 @@
FROM artixlinux/artixlinux:base-devel as build-env FROM artixlinux/artixlinux:base-devel AS baseimg
RUN pacman -Syu --noconfirm
FROM baseimg AS build-env
WORKDIR /usr/notifier WORKDIR /usr/notifier
RUN pacman -Sy --noconfirm nodejs npm RUN pacman -Sy --noconfirm nodejs npm typescript
COPY package*.json ./ COPY package*.json ./
@@ -10,16 +14,17 @@ RUN npm install
COPY . . COPY . .
RUN npm run-script build && \ RUN tsc && \
npm run-script build && \
npm ci --only=production npm ci --only=production
FROM artixlinux/artixlinux:base-devel as deploy FROM baseimg AS deploy
VOLUME /usr/notifier/config VOLUME /usr/notifier/config
WORKDIR /usr/notifier WORKDIR /usr/notifier
HEALTHCHECK --timeout=15m \ HEALTHCHECK --timeout=15m \
CMD curl --fail http://localhost:8080/healthcheck || exit 1 CMD curl --fail http://localhost:8081/healthcheck || exit 1
EXPOSE 8080 EXPOSE 8080
@@ -38,10 +43,9 @@ RUN mkdir -p ./config /home/artix/.config/artix-checkupdates \
USER artix USER artix
ENV ARTIX_MIRROR="https://mirrors.qontinuum.space/artixlinux/%s/os/x86_64" ENV ARTIX_MIRROR="https://mirror.sanin.dev/artix-linux/%s/os/x86_64/"
ENV ARCH_MIRROR="https://mirrors.qontinuum.space/archlinux/%s/os/x86_64" ENV ARCH_MIRROR="https://mirror.sanin.dev/arch-linux/%s/os/x86_64/"
ENV ARTIX_REPOS="system-goblins,world-goblins,galaxy-goblins,lib32-goblins,system-gremlins,world-gremlins,galaxy-gremlins,lib32-gremlins,system,world,galaxy,lib32" ENV ARTIX_REPOS="system-goblins,world-goblins,galaxy-goblins,lib32-goblins,system-gremlins,world-gremlins,galaxy-gremlins,lib32-gremlins,system,world,galaxy,lib32"
ENV ARCH_REPOS="core-staging,extra-staging,multilib-staging,core-testing,extra-testing,multilib-testing,core,extra,multilib" ENV ARCH_REPOS="core-staging,extra-staging,multilib-staging,core-testing,extra-testing,multilib-testing,core,extra,multilib"
ENV GITEA_TOKEN="CHANGEME"
CMD [ "node", "index.js"] CMD [ "node", "distribution/index.mjs"]

View File

@@ -1,4 +1,4 @@
# artix-packy-notifier # artix-checkupdates-web
Notification system and web frontend for Artix packages with pending operations. Notifications can be sent via Notification system and web frontend for Artix packages with pending operations. Notifications can be sent via
[Apprise](https://github.com/caronc/apprise/wiki#notification-services) or IRC. Web interface shows all packages with pending operations [Apprise](https://github.com/caronc/apprise/wiki#notification-services) or IRC. Web interface shows all packages with pending operations
@@ -33,12 +33,13 @@ If the channel is intended only for the bot to broadcast, consider setting the c
``` ```
npm install npm install
node index.js npm exec tsc
node distribution/index.mjs
``` ```
## Docker Setup ## Docker Setup
Image : `registry.gitlab.com/sanin.dev/artix-packy-notifier` Image : `ghcr.io/corysanin/artix-checkupdates-web:latest`
mount a folder to `/usr/notifier/config`. mount a folder to `/usr/notifier/config`.

View File

@@ -1,31 +1,29 @@
/** import fs from 'fs';
* Writing this myself because gulp has a billion garbage dependencies import path from 'path';
* and webpack sucks poop through a straw. Everyone is stupid. import child_process from 'child_process';
*/ import uglifyjs from "uglify-js";
const fsp = require('fs').promises; import * as sass from 'sass';
const path = require('path'); import * as csso from 'csso';
const spawn = require('child_process').spawn;
const sass = require('sass');
const csso = require('csso');
const uglifyjs = require("uglify-js");
const spawn = child_process.spawn;
const fsp = fs.promises;
const STYLESDIR = 'styles'; const STYLESDIR = 'styles';
const SCRIPTSDIR = 'scripts'; const SCRIPTSDIR = 'scripts';
const IMAGESDIR = path.join('assets', 'images'); const IMAGESDIR = path.join('assets', 'images');
const STYLEOUTDIR = process.env.STYLEOUTDIR || path.join(__dirname, 'assets', 'css'); const STYLEOUTDIR = process.env.STYLEOUTDIR || path.join('assets', 'css');
const SCRIPTSOUTDIR = process.env.SCRIPTSOUTDIR || path.join(__dirname, 'assets', 'js'); const SCRIPTSOUTDIR = process.env.SCRIPTSOUTDIR || path.join('assets', 'js');
const IMAGESOUTDIR = process.env.IMAGESOUTDIR || path.join(__dirname, 'assets', 'webp'); const IMAGESOUTDIR = process.env.IMAGESOUTDIR || path.join('assets', 'webp');
const STYLEOUTFILE = process.env.STYLEOUTFILE || 'styles.css'; const STYLEOUTFILE = process.env.STYLEOUTFILE || 'styles.css';
const SQUASH = new RegExp('^[0-9]+-'); const SQUASH = new RegExp('^[0-9]+-');
async function emptyDir(dir) { async function emptyDir(dir: string) {
await Promise.all((await fsp.readdir(dir, { withFileTypes: true })).map(f => path.join(dir, f.name)).map(p => fsp.rm(p, { await Promise.all((await fsp.readdir(dir, { withFileTypes: true })).map(f => path.join(dir, f.name)).map(p => fsp.rm(p, {
recursive: true, recursive: true,
force: true force: true
}))); })));
} }
async function mkdir(dir) { async function mkdir(dir: string | string[]) {
if (typeof dir === 'string') { if (typeof dir === 'string') {
await fsp.mkdir(dir, { recursive: true }); await fsp.mkdir(dir, { recursive: true });
} }
@@ -37,8 +35,8 @@ async function mkdir(dir) {
// Process styles // Process styles
async function styles() { async function styles() {
await mkdir([STYLEOUTDIR, STYLESDIR]); await mkdir([STYLEOUTDIR, STYLESDIR]);
await await emptyDir(STYLEOUTDIR); await emptyDir(STYLEOUTDIR);
let styles = []; let styles: string[] = [];
let files = await fsp.readdir(STYLESDIR); let files = await fsp.readdir(STYLESDIR);
await Promise.all(files.map(f => new Promise(async (res, reject) => { await Promise.all(files.map(f => new Promise(async (res, reject) => {
let p = path.join(STYLESDIR, f); let p = path.join(STYLESDIR, f);
@@ -110,7 +108,7 @@ async function images(dir = '') {
clearTimeout(timeout); clearTimeout(timeout);
if (code === 0) { if (code === 0) {
console.log(`Wrote ${outfile}`); console.log(`Wrote ${outfile}`);
res(); res(null);
} }
else { else {
reject(code); reject(code);
@@ -124,6 +122,10 @@ async function images(dir = '') {
} }
} }
function isAbortError(err: unknown): boolean {
return typeof err === 'object' && err !== null && 'name' in err && err.name === 'AbortError';
}
(async function () { (async function () {
await Promise.all([styles(), scripts(), images()]); await Promise.all([styles(), scripts(), images()]);
if (process.argv.indexOf('--watch') >= 0) { if (process.argv.indexOf('--watch') >= 0) {
@@ -134,7 +136,7 @@ async function images(dir = '') {
for await (const _ of watcher) for await (const _ of watcher)
await styles(); await styles();
} catch (err) { } catch (err) {
if (err.name === 'AbortError') if (isAbortError(err))
return; return;
throw err; throw err;
} }
@@ -146,7 +148,7 @@ async function images(dir = '') {
for await (const _ of watcher) for await (const _ of watcher)
await scripts(); await scripts();
} catch (err) { } catch (err) {
if (err.name === 'AbortError') if (isAbortError(err))
return; return;
throw err; throw err;
} }
@@ -160,7 +162,7 @@ async function images(dir = '') {
for await (const _ of watcher) for await (const _ of watcher)
await images(); await images();
} catch (err) { } catch (err) {
if (err.name === 'AbortError') if (isAbortError(err))
return; return;
throw err; throw err;
} }

View File

@@ -1,7 +1,31 @@
version: '2' version: '2'
services: services:
packy: artix-notifier-daemon:
container_name: artix-notifier-daemon
build:
context: ./
volumes:
- ./config:/usr/notifier/config
depends_on:
- artix-notifier-irc
- artix-notifier-web
environment:
COMPONENT: "daemon"
ARTIX_REPOS: "system-goblins,world-goblins,system-gremlins,world-gremlins,system,world"
ARCH_REPOS: "core-staging,extra-staging,core-testing,extra-testing,core,extra"
artix-notifier-irc:
container_name: artix-notifier-irc
build:
context: ./
volumes:
- ./config:/usr/notifier/config
environment:
COMPONENT: "ircbot"
artix-notifier-web:
container_name: artix-notifier-web
build: build:
context: ./ context: ./
volumes: volumes:
@@ -9,7 +33,4 @@ services:
ports: ports:
- 8080:8080 - 8080:8080
environment: environment:
ARTIX_MIRROR: "https://mirrors.qontinuum.space/artixlinux/%s/os/x86_64" COMPONENT: "web"
ARCH_MIRROR: "https://mirrors.qontinuum.space/archlinux/%s/os/x86_64"
ARTIX_REPOS: "system-goblins,world-goblins,system-gremlins,world-gremlins,system,world"
ARCH_REPOS: "core-staging,extra-staging,core-testing,extra-testing,core,extra"

288
index.js
View File

@@ -1,288 +0,0 @@
const path = require('path');
const fs = require('fs');
const os = require('os');
const spawn = require('child_process').spawn;
const cron = require('node-cron');
const dayjs = require('dayjs');
const json5 = require('json5');
const phin = require('phin');
const DB = require('./db');
const IRCBot = require('./ircbot');
const Web = require('./web');
const fsp = fs.promises;
const TIMEOUT = 180000;
const ORPHAN = {
"name": "orphan",
"ircName": "orphaned"
};
const EXTRASPACE = new RegExp('\\s+', 'g');
const CHECKUPDATESCACHE = path.join(os.homedir(), '.cache', 'artix-checkupdates');
const NICETYPES = {
move: 'move',
udate: 'update'
}
let saveData = {
'last-sync': null,
move: [],
update: []
}
let ircBot;
let locked = false;
let savePath = process.env.SAVEPATH || path.join(__dirname, 'config', 'data.json');
fs.readFile(process.env.CONFIGPATH || path.join(__dirname, 'config', 'config.json'), async (err, data) => {
if (err) {
console.error(err);
process.exit(1);
}
else {
console.log('Written by Cory Sanin for Artix Linux');
const config = json5.parse(data);
savePath = config.savePath || savePath;
const db = new DB(process.env.DBPATH || config.db || path.join(__dirname, 'config', 'packages.db'));
try {
saveData = JSON.parse(await fsp.readFile(savePath));
}
catch {
console.error(`Failed to read existing save data at ${savePath}`);
}
// resetting flags in case of improper shutdown
db.restoreFlags();
let cronjob = cron.schedule(process.env.CRON || config.cron || '*/30 * * * *', () => {
main(config, db);
});
const web = new Web(db, config, saveData);
ircBot = new IRCBot(config);
ircBot.connect();
process.on('SIGTERM', () => {
cronjob.stop();
web.close();
ircBot.close();
});
}
});
async function main(config, db) {
if (locked) {
return
}
locked = true;
console.log('Starting scheduled task');
let now = dayjs();
if (!('last-sync' in saveData) || !saveData['last-sync'] || dayjs(saveData['last-sync']).isBefore(now.subtract(process.env.SYNCFREQ || config.syncfreq || 2, 'days'))) {
ircBot.close();
await updateMaintainers(config, db);
saveData['last-sync'] = now.toJSON();
await writeSaveData();
await ircBot.connect();
}
await checkupdates(config, db);
await writeSaveData();
locked = false;
console.log('Task complete.');
}
async function writeSaveData() {
try {
await fsp.writeFile(savePath, JSON.stringify(saveData));
}
catch {
console.error(`Failed to write save data to ${savePath}`);
}
}
async function checkupdates(config, db) {
try {
await handleUpdates(config, db, saveData.move = await execCheckUpdates(['-m']), 'move');
await handleUpdates(config, db, saveData.update = await execCheckUpdates(['-u']), 'udate');
}
catch (ex) {
console.error('Failed to check for updates:', ex);
}
}
async function handleUpdates(config, db, packs, type) {
packs.forEach(v => {
let p = db.getPackage(v);
p && db.updateFlag(v, type, p[type] > 0 ? 2 : 4);
});
const maintainers = [...config.maintainers, ORPHAN];
for (let i = 0; i < maintainers.length; i++) {
const m = maintainers[i];
const mname = typeof m === 'object' ? m.name : m;
const ircName = typeof m === 'object' ? (m.ircName || mname) : m;
const packages = db.getNewByMaintainer(mname, type);
if (typeof m === 'object' && m.channels) {
notify({
api: config.apprise,
urls: m.channels
}, packages, NICETYPES[type])
}
ircNotify(packages, ircName, NICETYPES[type]);
}
db.decrementFlags(type);
db.restoreFlags(type);
}
async function updateMaintainers(config, db) {
console.log('Syncing packages...');
const lastseen = (new Date()).getTime();
const maintainers = [...(config.maintainers || []), ORPHAN];
for (let i = 0; i < maintainers.length; i++) {
let maintainer = maintainers[i];
if (typeof maintainer === 'object') {
maintainer = maintainer.name;
}
console.log(`Syncing ${maintainer}...`);
try {
const packages = await getMaintainersPackages(maintainer);
for (let j = 0; j < packages.length; j++) {
const package = packages[j];
db.updatePackage(package, maintainer, lastseen);
await asyncSleep(50);
}
}
catch (err) {
console.error(`Failed to get packages for ${maintainer}`);
console.error(err);
}
}
console.log(`removing unused packages...`);
db.cleanOldPackages(lastseen);
console.log(`Package sync complete`);
}
function getMaintainersPackages(maintainer) {
return new Promise((resolve, reject) => {
let process = spawn('artixpkg', ['admin', 'query', maintainer === ORPHAN.name ? '-t' : '-m', maintainer]);
let timeout = setTimeout(() => {
reject('Timed out');
process.kill();
}, TIMEOUT);
let packagelist = [];
process.stdout.on('data', data => {
packagelist = packagelist.concat(data.toString().trim().split('\n'));
});
process.stderr.on('data', err => {
console.error(err.toString());
})
process.on('exit', async (code) => {
if (code === 0) {
clearTimeout(timeout);
resolve(packagelist);
}
else {
reject(code);
}
});
});
}
async function notify(apprise, packarr, type) {
if (!(packarr && packarr.length && apprise && apprise.api && apprise.urls)) {
return;
}
const packagesStr = packarr.map(p => p.package).join('\n');
for (let i = 0; i < 25; i++) {
try {
return await phin({
url: `${apprise.api}/notify/`,
method: 'POST',
data: {
title: `${packarr[0].maintainer}: packages ready to ${type}`,
body: packagesStr,
urls: apprise.urls.join(',')
}
});
}
catch (ex) {
console.error('Failed to send notification, attempt #%d', i + 1);
console.error(ex);
}
}
return null;
}
function ircNotify(packarr, maintainer, type) {
if (!(packarr && packarr.length)) {
return;
}
const packagesStr = packarr.map(p => p.package).join('\n');
for (let i = 0; i < 25; i++) {
try {
return ircBot.sendMessage(`${maintainer}: packages ready to ${type}\n${packagesStr}\n-------- EOF --------`);
}
catch (ex) {
console.error('Failed to send IRC notification, attempt #%d', i + 1);
console.error(ex);
}
}
return null;
}
function parseCheckUpdatesOutput(output) {
let packages = [];
let lines = output.split('\n');
lines.forEach(l => {
let package = l.trim().replace(EXTRASPACE, ' ');
if (package.length > 0 && package.indexOf('Package basename') < 0) {
packages.push(package.split(' ', 2)[0]);
}
});
return packages;
}
async function cleanUpLockfiles() {
try {
await fsp.rm(CHECKUPDATESCACHE, { recursive: true, force: true });
}
catch (ex) {
console.error('Failed to remove the artix-checkupdates cache directory:', ex);
}
}
function execCheckUpdates(flags, errCallback) {
return new Promise((resolve, reject) => {
let process = spawn('artix-checkupdates', flags);
let timeout = setTimeout(async () => {
process.kill() && await cleanUpLockfiles();
reject('Timed out');
}, TIMEOUT);
let outputstr = '';
let errorOutput = '';
process.stdout.on('data', data => {
outputstr += data.toString();
});
process.stderr.on('data', err => {
const errstr = err.toString();
errorOutput += `${errstr}, `;
console.error(errstr);
})
process.on('exit', async (code) => {
if (code === 0 && errorOutput.length === 0) {
clearTimeout(timeout);
resolve(parseCheckUpdatesOutput(outputstr));
}
else {
errorOutput.includes('unable to lock database') && cleanUpLockfiles();
reject((code && `exited with ${code}`) || errorOutput);
}
});
});
}
function asyncSleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

View File

@@ -1,70 +0,0 @@
const IRC = require('irc-framework');
class IRCBot {
constructor(config) {
const options = config['irc-framework'];
const aux = this._aux = config.ircClient || {};
this._channel = aux.channel;
this._messageQueue = [];
this._enabled = !!options;
if (options) {
const bot = this._bot = new IRC.Client(options);
bot.on('sasl failed', d => console.error(d));
bot.on('notice', d => console.log(`irc:notice: ${d.message}`));
bot.on('action', d => console.log(`irc:action: ${d.message}`));
setInterval(() => this.processMessageQueue(), 2000);
}
else {
console.log('"ircClient" not provided in config. IRC notifications will not be delivered.');
}
}
connect() {
return new Promise((resolve, reject) => {
if (this._enabled) {
const bot = this._bot;
bot.connect();
const callback = () => {
clearTimeout(timeout);
console.log(`IRC bot ${bot.user.nick} connected.`);
bot.join(this._aux.channel, this._aux.channel_key);
bot.removeListener('registered', callback);
resolve();
};
const timeout = setTimeout(() => {
bot.removeListener('registered', callback);
reject('timeout exceeded');
}, 60000);
bot.on('registered', callback);
}
else {
resolve();
}
});
}
sendMessage(str) {
(this._enabled ? str.split('\n') : []).forEach(line => {
this._messageQueue.push(line);
});
}
processMessageQueue() {
const bot = this._bot
let message = bot.connected && this._messageQueue.shift();
message && bot.say(this._channel, message);
}
close() {
if (this._enabled && this._bot.connected) {
this._bot.quit('Shutting down');
}
}
}
module.exports = IRCBot;

2190
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,42 +1,56 @@
{ {
"name": "artix-packy-notifier", "name": "artix-checkupdates-web",
"version": "3.4.1", "version": "4.1.2",
"description": "Determine packages that need attention", "description": "Determine packages that need attention",
"main": "index.js", "main": "./distribution/index.js",
"type": "module",
"scripts": { "scripts": {
"build": "node build.js", "build": "npx build-shit",
"watch": "node build.js --watch" "watch": "npx build-shit --watch"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+ssh://git@gitlab.com/sanin.dev/artix-packy-notifier.git" "url": "git+ssh://git@github.com:CorySanin/artix-checkupdates-web.git"
}, },
"keywords": [ "keywords": [
"artix", "artix",
"linux", "linux",
"packages" "packages"
], ],
"files": [
"distribution",
"assets",
"views",
"userbar"
],
"author": "Cory Sanin", "author": "Cory Sanin",
"license": "MIT", "license": "MIT",
"bugs": { "bugs": {
"url": "https://gitlab.com/sanin.dev/artix-packy-notifier/issues" "url": "https://github.com/CorySanin/artix-checkupdates-web/issues"
}, },
"homepage": "https://gitlab.com/sanin.dev/artix-packy-notifier#readme", "homepage": "https://github.com/CorySanin/artix-checkupdates-web#readme",
"dependencies": { "dependencies": {
"better-sqlite3": "9.4.3", "artix-checkupdates": "1.0.2",
"dayjs": "1.11.10", "better-sqlite3": "11.9.1",
"ejs": "3.1.9", "dayjs": "1.11.13",
"express": "4.19.1", "ejs": "3.1.10",
"irc-framework": "4.13.1", "express": "4.21.2",
"express-useragent": "1.0.15",
"irc-framework": "4.14.0",
"json5": "2.2.3", "json5": "2.2.3",
"ky": "1.8.1",
"node-cron": "3.0.3", "node-cron": "3.0.3",
"phin": "^3.7.0", "prom-client": "15.1.3",
"prom-client": "15.1.0", "sharp": "0.34.1"
"sharp": "0.33.2"
}, },
"devDependencies": { "devDependencies": {
"csso": "5.0.5", "@sindresorhus/tsconfig": "7.0.0",
"sass": "1.72.0", "@types/better-sqlite3": "^7.6.12",
"uglify-js": "3.17.4" "@types/express": "^5.0.0",
"@types/express-useragent": "1.0.5",
"@types/node": "22.10.7",
"@types/node-cron": "3.0.11",
"forking-build-shit": "0.0.2",
"typescript": "5.7.3"
} }
} }

35
src/config.d.ts vendored Normal file
View File

@@ -0,0 +1,35 @@
import { } from "node:os";
export type IRCFrameworkConfig = {
host: string;
port: number;
nick: string;
}
export type AuxiliaryIRCConfig = {
channel?: string;
channel_key: string;
}
export type Maintainer = {
name: string;
channels?: string[];
ircName?: string;
}
export type MaintainerArrayElement = (string | Maintainer);
export type Config = {
webhostname: string;
apprise: string;
maintainers: MaintainerArrayElement[];
cron?: string;
syncfreq: number;
db?: string;
port?: number;
privateport?: number;
savePath?: string;
irchostname?: string;
'irc-framework'?: IRCFrameworkConfig;
ircClient?: AuxiliaryIRCConfig;
}

268
src/daemon.mts Normal file
View File

@@ -0,0 +1,268 @@
import { Checkupdates } from 'artix-checkupdates';
import { DB } from './db.mjs';
import { spawn } from 'child_process';
import ky from 'ky';
import * as path from 'path';
import * as fsp from 'node:fs/promises';
import * as cron from 'node-cron';
import dayjs from 'dayjs';
import express from 'express';
import type http from "http";
import type { Express } from "express";
import type { Config, MaintainerArrayElement } from './config.js';
import type { Category, PackageDBEntry } from './db.mjs';
const TIMEOUT = 180000;
const ORPHAN = {
"name": "orphan",
"ircName": "orphaned"
};
const NICETYPES = {
move: 'move',
udate: 'update'
};
type SaveData = {
'last-sync': string | null;
move: string[];
update: string[];
}
type AppriseConf = {
api: string;
urls: string[];
}
function notStupidParseInt(v: string | undefined): number {
return v === undefined ? NaN : parseInt(v);
}
class Daemon {
private _config: Config;
private _savePath: string;
private _locked: boolean;
private _saveData: SaveData;
private _db: DB;
private _cronjob: cron.ScheduledTask;
private _webserver: http.Server;
constructor(config: Config) {
const PROJECT_ROOT = path.resolve(import.meta.dirname, '..');
const app: Express = express();
const port = process.env['PRIVATEPORT'] || config.privateport || 8081;
this._config = config;
this._savePath = process.env['SAVEPATH'] || config.savePath || path.join(PROJECT_ROOT, 'config', 'data.json');
console.log('Written by Cory Sanin for Artix Linux');
this._locked = false;
const db = this._db = new DB(process.env['DBPATH'] || config.db || path.join(PROJECT_ROOT, 'config', 'packages.db'));
this._saveData = {
'last-sync': null,
move: [],
update: []
};
app.set('trust proxy', 1);
app.get('/healthcheck', (_, res) => {
res.send('Healthy');
});
this.readSaveData();
// resetting flags in case of improper shutdown
db.restoreFlags();
this._cronjob = cron.schedule(process.env['CRON'] || config.cron || '*/30 * * * *', () => {
this.main(this._config);
});
this._webserver = app.listen(port);
}
main = async (config: Config) => {
if (this._locked) {
return
}
this._locked = true;
console.log('Starting scheduled task');
let now = dayjs();
if (!('last-sync' in this._saveData) || !this._saveData['last-sync'] || dayjs(this._saveData['last-sync']).isBefore(now.subtract(notStupidParseInt(process.env['SYNCFREQ']) || config.syncfreq || 2, 'days'))) {
await this.updateMaintainers(config);
this._saveData['last-sync'] = now.toJSON();
await this.writeSaveData();
}
await this.checkupdates(config);
await this.writeSaveData();
this._locked = false;
console.log('Task complete.');
}
updateMaintainers = async (config: Config) => {
const db = this._db;
console.log('Syncing packages...');
const lastseen = (new Date()).getTime();
const maintainers = [...(config.maintainers || []), ORPHAN];
for (let i = 0; i < maintainers.length; i++) {
let maintainer = maintainers[i] as MaintainerArrayElement;
if (typeof maintainer === 'object') {
maintainer = maintainer.name;
}
console.log(`Syncing ${maintainer}...`);
try {
const packages: string[] = await this.getMaintainersPackages(maintainer);
for (let j = 0; j < packages.length; j++) {
db.updatePackage(packages[j] as string, maintainer, lastseen);
}
}
catch (err) {
console.error(`Failed to get packages for ${maintainer}`);
console.error(err);
}
}
console.log(`removing unused packages...`);
db.cleanOldPackages(lastseen);
console.log(`Package sync complete`);
}
checkupdates = async (config: Config) => {
const check = new Checkupdates();
try {
await this.handleUpdates(config, this._saveData.move = (await check.fetchMovable()).map(p => p.basename), 'move');
await this.handleUpdates(config, this._saveData.update = (await check.fetchUpgradable()).map(p => p.basename), 'udate');
}
catch (ex) {
console.error('Failed to check for updates:', ex);
}
}
getMaintainersPackages = (maintainer: string): Promise<string[]> => {
return new Promise((resolve, reject) => {
let process = spawn('artixpkg', ['admin', 'query', maintainer === ORPHAN.name ? '-t' : '-m', maintainer]);
let timeout = setTimeout(() => {
reject('Timed out');
process.kill();
}, TIMEOUT);
let packagelist: string[] = [];
process.stdout.on('data', data => {
packagelist = packagelist.concat(data.toString().trim().split('\n'));
});
process.stderr.on('data', err => {
console.error(err.toString());
})
process.on('exit', async (code) => {
if (code === 0) {
clearTimeout(timeout);
resolve(packagelist);
}
else {
reject(code);
}
});
});
}
handleUpdates = async (config: Config, packs: string[], type: Category) => {
const db = this._db;
packs.forEach(v => {
let p = db.getPackage(v);
p && db.updateFlag(v, type, p[type] > 0 ? 2 : 4);
});
const maintainers: MaintainerArrayElement[] = [...config.maintainers, ORPHAN];
for (let i = 0; i < maintainers.length; i++) {
const m = maintainers[i] as MaintainerArrayElement;
const mname: string = typeof m === 'object' ? m.name : m;
const ircName = typeof m === 'object' ? (m.ircName || mname) : m;
const packages = db.getNewByMaintainer(mname, type);
if (typeof m === 'object' && m.channels) {
this.notify({
api: config.apprise,
urls: m.channels
}, packages, NICETYPES[type]);
}
this.ircNotify(packages, ircName, NICETYPES[type]);
}
db.decrementFlags(type);
db.restoreFlags(type);
}
notify = async (apprise: AppriseConf, packarr: PackageDBEntry[], type: string) => {
if (!(packarr && packarr.length && apprise && apprise.api && apprise.urls)) {
return;
}
const packagesStr = packarr.map(p => p.package).join('\n');
for (let i = 0; i < 25; i++) {
try {
return await ky.post(`${apprise.api}/notify/`, {
json: {
title: `${packarr[0]?.maintainer}: packages ready to ${type}`,
body: packagesStr,
urls: apprise.urls.join(',')
}
});
}
catch (ex) {
console.error('Failed to send notification, attempt #%d', i + 1);
console.error(ex);
}
}
return null;
}
ircNotify = async (packarr: PackageDBEntry[], maintainer: string, type: string) => {
const config = this._config;
if (!(packarr && packarr.length && config['irc-framework'])) {
return;
}
const hostname = process.env['IRCHOSTNAME'] || config.irchostname || 'http://artix-notifier-irc:8081';
const packagesStr = packarr.map(p => p.package).join('\n');
for (let i = 0; i < 25; i++) {
try {
return await ky.post(`${hostname}/api/1.0/notifications`, {
json: {
message: `${maintainer}: packages ready to ${type}\n${packagesStr}\n-------- EOF --------`
}
});
}
catch (ex) {
console.error('Failed to send IRC notification, attempt #%d', i + 1);
console.error(ex);
}
}
return null;
}
readSaveData = async () => {
try {
this._saveData = JSON.parse((await fsp.readFile(this._savePath)).toString());
}
catch {
console.error(`Failed to read existing save data at ${this._savePath}`);
}
}
writeSaveData = async () => {
const config = this._config;
const hostname = process.env['WEBHOSTNAME'] || config.webhostname || 'http://artix-notifier-web:8081';
try {
await fsp.writeFile(this._savePath, JSON.stringify(this._saveData));
ky.put(`${hostname}/api/1.0/data`);
}
catch {
console.error(`Failed to write save data to ${this._savePath}`);
}
}
close = () => {
if (this._webserver) {
this._webserver.close();
}
if (this._cronjob) {
this._cronjob.stop();
}
}
}
export default Daemon;
export { Daemon };
export type { SaveData };

View File

@@ -1,10 +1,52 @@
const sqlite = require('better-sqlite3'); import Database from 'better-sqlite3';
const TABLE = 'packages'; const TABLE = 'packages';
type Category = 'move' | 'udate';
interface CommonOperations {
GET: Database.Statement;
GETNEWBYMAINTAINER: Database.Statement;
UPDATE: Database.Statement;
INCREMENT: Database.Statement;
DECREMENT: Database.Statement;
FIXFLAG: Database.Statement;
GETPACKAGESBYMAINTAINER: Database.Statement;
GETPACKAGECOUNTBYMAINTAINER: Database.Statement;
}
interface DatabaseOperations {
GETPACKAGE: Database.Statement;
ADDPACKAGE: Database.Statement;
GETPACKAGES: Database.Statement;
GETPACKAGESSTARTWITH: Database.Statement;
UPDATEMAINTAINER: Database.Statement;
GETMAINTAINERPACKAGECOUNT: Database.Statement;
REMOVEOLDPACKAGE: Database.Statement;
move: CommonOperations;
udate: CommonOperations;
}
interface PackageDBEntry {
package: string;
maintainer: string;
move: number;
udate: number;
lastseen: Date;
}
interface CountResult {
count: number;
}
class DB { class DB {
constructor(file) { private _db: Database.Database;
this._db = new sqlite(file); private _queries: DatabaseOperations;
constructor(file: string | Buffer) {
this._db = new Database(file);
const db = this._db; const db = this._db;
db.pragma('journal_mode = WAL');
if (!db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='${TABLE}';`).get()) { if (!db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='${TABLE}';`).get()) {
db.prepare(`CREATE TABLE ${TABLE} (package VARCHAR(128) PRIMARY KEY, maintainer VARCHAR(128), move INTEGER, udate INTEGER, lastseen DATETIME);`).run(); db.prepare(`CREATE TABLE ${TABLE} (package VARCHAR(128) PRIMARY KEY, maintainer VARCHAR(128), move INTEGER, udate INTEGER, lastseen DATETIME);`).run();
@@ -13,6 +55,8 @@ class DB {
this._queries = { this._queries = {
GETPACKAGE: db.prepare(`SELECT * FROM ${TABLE} WHERE package = @package;`), GETPACKAGE: db.prepare(`SELECT * FROM ${TABLE} WHERE package = @package;`),
ADDPACKAGE: db.prepare(`INSERT INTO ${TABLE} (package,maintainer,move,udate,lastseen) VALUES (@package, @maintainer, 0, 0, @lastseen);`), ADDPACKAGE: db.prepare(`INSERT INTO ${TABLE} (package,maintainer,move,udate,lastseen) VALUES (@package, @maintainer, 0, 0, @lastseen);`),
GETPACKAGES: db.prepare(`SELECT package FROM ${TABLE}`),
GETPACKAGESSTARTWITH: db.prepare(`SELECT package FROM ${TABLE} WHERE package LIKE @startsWith || '%'`),
UPDATEMAINTAINER: db.prepare(`UPDATE ${TABLE} SET maintainer = @maintainer, lastseen= @lastseen WHERE package = @package`), UPDATEMAINTAINER: db.prepare(`UPDATE ${TABLE} SET maintainer = @maintainer, lastseen= @lastseen WHERE package = @package`),
GETMAINTAINERPACKAGECOUNT: db.prepare(`SELECT COUNT(package) as count FROM ${TABLE} WHERE maintainer = @maintainer;`), GETMAINTAINERPACKAGECOUNT: db.prepare(`SELECT COUNT(package) as count FROM ${TABLE} WHERE maintainer = @maintainer;`),
REMOVEOLDPACKAGE: db.prepare(`DELETE FROM ${TABLE} WHERE lastseen < @lastseen;`), REMOVEOLDPACKAGE: db.prepare(`DELETE FROM ${TABLE} WHERE lastseen < @lastseen;`),
@@ -23,7 +67,7 @@ class DB {
INCREMENT: db.prepare(`UPDATE ${TABLE} SET move = move + 1 WHERE package = @package`), INCREMENT: db.prepare(`UPDATE ${TABLE} SET move = move + 1 WHERE package = @package`),
DECREMENT: db.prepare(`UPDATE ${TABLE} SET move = 0 WHERE move = 1`), DECREMENT: db.prepare(`UPDATE ${TABLE} SET move = 0 WHERE move = 1`),
FIXFLAG: db.prepare(`UPDATE ${TABLE} SET move = 1 WHERE move > 1`), FIXFLAG: db.prepare(`UPDATE ${TABLE} SET move = 1 WHERE move > 1`),
GETPACKAGESBYMAINTAINER: db.prepare(`SELECT * FROM ${TABLE} WHERE maintainer = @maintainer AND move > 0;`), GETPACKAGESBYMAINTAINER: db.prepare(`SELECT * FROM ${TABLE} WHERE maintainer = @maintainer AND move > 0 ORDER BY package ASC;`),
GETPACKAGECOUNTBYMAINTAINER: db.prepare(`SELECT COUNT(package) as count FROM ${TABLE} WHERE maintainer = @maintainer AND move > 0;`) GETPACKAGECOUNTBYMAINTAINER: db.prepare(`SELECT COUNT(package) as count FROM ${TABLE} WHERE maintainer = @maintainer AND move > 0;`)
}, },
udate: { udate: {
@@ -33,24 +77,33 @@ class DB {
INCREMENT: db.prepare(`UPDATE ${TABLE} SET udate = udate + 1 WHERE package = @package`), INCREMENT: db.prepare(`UPDATE ${TABLE} SET udate = udate + 1 WHERE package = @package`),
DECREMENT: db.prepare(`UPDATE ${TABLE} SET udate = 0 WHERE udate = 1`), DECREMENT: db.prepare(`UPDATE ${TABLE} SET udate = 0 WHERE udate = 1`),
FIXFLAG: db.prepare(`UPDATE ${TABLE} SET udate = 1 WHERE udate > 1`), FIXFLAG: db.prepare(`UPDATE ${TABLE} SET udate = 1 WHERE udate > 1`),
GETPACKAGESBYMAINTAINER: db.prepare(`SELECT * FROM ${TABLE} WHERE maintainer = @maintainer AND udate > 0;`), GETPACKAGESBYMAINTAINER: db.prepare(`SELECT * FROM ${TABLE} WHERE maintainer = @maintainer AND udate > 0 ORDER BY package ASC;`),
GETPACKAGECOUNTBYMAINTAINER: db.prepare(`SELECT COUNT(package) as count FROM ${TABLE} WHERE maintainer = @maintainer AND udate > 0;`) GETPACKAGECOUNTBYMAINTAINER: db.prepare(`SELECT COUNT(package) as count FROM ${TABLE} WHERE maintainer = @maintainer AND udate > 0;`)
} }
}; };
} }
restoreFlags() { restoreFlags(type: Category | null = null) {
this._queries.move.FIXFLAG.run(); if (type !== 'udate') {
this._queries.udate.FIXFLAG.run(); this._queries.move.FIXFLAG.run();
}
if (type !== 'move') {
this._queries.udate.FIXFLAG.run();
}
} }
getPackage(pack) { getPackage(pack: string): PackageDBEntry {
return this._queries.GETPACKAGE.get({ return this._queries.GETPACKAGE.get({
package: pack package: pack
}); }) as PackageDBEntry;
} }
updatePackage(pack, maintainer, lastseen) { getPackages(startsWith: string | null = null): string[] {
return ((!!startsWith) ? this._queries.GETPACKAGESSTARTWITH.all({ startsWith }) :
this._queries.GETPACKAGES.all()).map(p => (p as PackageDBEntry).package);
}
updatePackage(pack: string, maintainer: string, lastseen: number): Database.RunResult {
return this._queries[this.getPackage(pack) ? 'UPDATEMAINTAINER' : 'ADDPACKAGE'].run({ return this._queries[this.getPackage(pack) ? 'UPDATEMAINTAINER' : 'ADDPACKAGE'].run({
package: pack, package: pack,
maintainer, maintainer,
@@ -58,19 +111,19 @@ class DB {
}); });
} }
incrementFlag(pack, type) { incrementFlag(pack: string, type: Category): boolean {
this._queries[type].INCREMENT.run({ this._queries[type].INCREMENT.run({
package: pack package: pack
}); });
return true; return true;
} }
decrementFlags(type) { decrementFlags(type: Category): boolean {
this._queries[type].DECREMENT.run(); this._queries[type].DECREMENT.run();
return true; return true;
} }
updateFlag(pack, type, bool) { updateFlag(pack: string, type: Category, bool: number): boolean {
this._queries[type].UPDATE.run({ this._queries[type].UPDATE.run({
package: pack, package: pack,
bool bool
@@ -78,41 +131,43 @@ class DB {
return true; return true;
} }
getFlag(type, bool = true) { getFlag(type: Category, bool: boolean = true): PackageDBEntry[] {
return this._queries[type].GET.all({ return this._queries[type].GET.all({
bool bool
}); }) as PackageDBEntry[];
} }
getNewByMaintainer(maintainer, type) { getNewByMaintainer(maintainer: string, type: Category): PackageDBEntry[] {
return this._queries[type].GETNEWBYMAINTAINER.all({ return this._queries[type].GETNEWBYMAINTAINER.all({
maintainer maintainer
}); }) as PackageDBEntry[];
} }
getPackagesByMaintainer(maintainer, type) { getPackagesByMaintainer(maintainer: string, type: Category): PackageDBEntry[] {
return this._queries[type].GETPACKAGESBYMAINTAINER.all({ return this._queries[type].GETPACKAGESBYMAINTAINER.all({
maintainer maintainer
}); }) as PackageDBEntry[];
} }
getPackageCountByMaintainer(maintainer, type) { getPackageCountByMaintainer(maintainer: string, type: Category): number {
return this._queries[type].GETPACKAGECOUNTBYMAINTAINER.get({ return (this._queries[type].GETPACKAGECOUNTBYMAINTAINER.get({
maintainer maintainer
}).count; }) as CountResult).count;
} }
cleanOldPackages(lastseen) { cleanOldPackages(lastseen: number): Database.RunResult {
return this._queries.REMOVEOLDPACKAGE.run({ return this._queries.REMOVEOLDPACKAGE.run({
lastseen lastseen
}); });
} }
getMaintainerPackageCount(maintainer) { getMaintainerPackageCount(maintainer: string): number {
return this._queries.GETMAINTAINERPACKAGECOUNT.get({ return (this._queries.GETMAINTAINERPACKAGECOUNT.get({
maintainer maintainer
}).count; }) as CountResult).count;
} }
} }
module.exports = DB; export default DB;
export { DB };
export type { PackageDBEntry, Category };

29
src/index.mts Normal file
View File

@@ -0,0 +1,29 @@
import * as path from 'path';
import * as fsp from 'node:fs/promises';
import JSON5 from 'json5';
import { IRCBot } from './ircBot.mjs';
import { Daemon } from './daemon.mjs';
import { Web } from './web.mjs';
const PROJECT_ROOT = path.resolve(import.meta.dirname, '..');
const data = await fsp.readFile(process.env['CONFIG'] || path.join(PROJECT_ROOT, 'config', 'config.json'));
let config = JSON5.parse(data.toString());
let arg = process.env['COMPONENT'] || (process.execArgv && process.execArgv[0]);
if (arg === 'daemon') {
const daemon = new Daemon(config);
process.on('SIGTERM', daemon.close);
}
else if (arg === 'ircbot') {
const bot = new IRCBot(config);
bot.connect();
process.on('SIGTERM', bot.close);
}
else if (arg === 'web') {
const web = new Web(config);
process.on('SIGTERM', web.close);
}
else {
console.error('Please pass the component you wish to run.');
}

119
src/ircBot.mts Normal file
View File

@@ -0,0 +1,119 @@
import express from 'express';
import type { Config, AuxiliaryIRCConfig } from './config.js';
import type http from "http";
// @ts-ignore
import IRC from 'irc-framework';
class IRCBot {
private _aux: AuxiliaryIRCConfig | undefined;
private _channel: string | undefined;
private _bot: any;
private _enabled: boolean;
private _messageQueue: string[];
private _webserver: http.Server | undefined;
constructor(config: Config) {
const options = config['irc-framework'];
const aux = this._aux = config.ircClient || undefined;
const app = express();
const port = process.env['PRIVATEPORT'] || config.privateport || 8081;
this._channel = aux?.channel;
this._messageQueue = [];
this._enabled = !!options;
app.set('trust proxy', 1);
app.use(express.json());
app.get('/healthcheck', (_, res) => {
if (this._bot && this._bot.connected) {
res.send('healthy');
}
else {
res.status(500).send('offline');
process.exit(1);
}
});
app.post('/api/1.0/notifications', (req, res) => {
const body = req.body;
if (body && body.message) {
this.sendMessage(body.message);
res.json({
success: true
});
}
else {
res.status(400).json({
success: false,
error: 'must include `message` property in request body'
});
}
});
if (options) {
const bot = this._bot = new IRC.Client(options);
bot.on('sasl failed', (d: Error) => console.error(d));
bot.on('notice', (d: Error) => console.log(`irc:notice: ${d.message}`));
bot.on('action', (d: Error) => console.log(`irc:action: ${d.message}`));
setInterval(() => this.processMessageQueue(), 2000);
this._webserver = app.listen(port, () => console.log(`artix-checkupdates-notifier-irc running on port ${port}`));
}
else {
console.log('"ircClient" not provided in config. IRC notifications will not be delivered.');
}
}
connect(): Promise<void> {
return new Promise((resolve, reject) => {
if (this._enabled) {
const bot = this._bot;
bot.connect();
const callback = () => {
clearTimeout(timeout);
console.log(`IRC bot ${bot.user.nick} connected.`);
bot.join(this._aux?.channel, this._aux?.channel_key);
bot.removeListener('registered', callback);
resolve();
};
const timeout = setTimeout(() => {
bot.removeListener('registered', callback);
reject('timeout exceeded');
}, 60000);
bot.on('registered', callback);
}
else {
resolve();
}
});
}
sendMessage(str: string) {
(this._enabled ? str.split('\n') : []).forEach(line => {
this._messageQueue.push(line);
});
}
processMessageQueue() {
const bot = this._bot
let message = bot.connected && this._messageQueue.shift();
message && bot.say(this._channel, message);
}
close() {
if (this._webserver) {
this._webserver.close();
}
if (this._enabled && this._bot.connected) {
this._bot.quit('Shutting down');
}
}
}
export default IRCBot;
export { IRCBot };

View File

@@ -1,44 +1,60 @@
const express = require('express'); import fs from 'fs';
const prom = require('prom-client'); import * as fsp from 'node:fs/promises';
const sharp = require('sharp'); import { DB } from './db.mjs';
const fs = require('fs'); import express from 'express';
const path = require('path'); import exuseragent from 'express-useragent';
import prom from 'prom-client';
import sharp from 'sharp';
import * as path from 'path';
import type http from "http";
import type { Request, Response } from "express";
import type { Details } from "express-useragent";
import type { Config } from './config.js';
import type { PackageDBEntry } from './db.mjs';
import type { SaveData } from './daemon.mjs';
const PROJECT_ROOT = __dirname; const PROJECT_ROOT = path.resolve(import.meta.dirname, '..');
const VIEWOPTIONS = { const VIEWOPTIONS = {
outputFunctionName: 'echo' outputFunctionName: 'echo'
}; };
const NAMECOMPLIANCE = [
p => p.replace(/([a-zA-Z0-9]+)\+([a-zA-Z]+)/g, '$1-$2'),
p => p.replace(/\+/g, "plus"),
p => p.replace(/[^a-zA-Z0-9_\-\.]/g, "-"),
p => p.replace(/[_\-]{2,}/g, "-")
]
function inliner(file) { type Action = "Move" | "Update";
type WebPackageObject = {
package: string;
action: Action;
url: string;
}
type ParseablePackage = string | PackageDBEntry;
function inliner(file: string) {
return fs.readFileSync(path.join(PROJECT_ROOT, file)); return fs.readFileSync(path.join(PROJECT_ROOT, file));
} }
function packageUrl(p) { function parsePackage(p: ParseablePackage): string {
let packagename = typeof p === 'string' ? p : p.package; return typeof p === 'string' ? p : p.package;
return `https://gitea.artixlinux.org/packages/${NAMECOMPLIANCE.reduce((s, fn) => fn(s), packagename)}`;
} }
function prepPackages(arr, action) { function packageUrl(p: ParseablePackage) {
return `https://gitea.artixlinux.org/packages/${parsePackage(p)}`;
}
function prepPackages(arr: ParseablePackage[], action: Action): WebPackageObject[] {
return arr.map(m => { return arr.map(m => {
return { return {
package: m, package: parsePackage(m),
action, action,
url: packageUrl(m) url: packageUrl(m)
} }
}); });
} }
async function createOutlinedText(string, meta, gravity = 'west') { async function createOutlinedText(string: string, meta: sharp.Metadata, gravity: sharp.Gravity = 'west') {
const txt = sharp({ const txt = sharp({
create: { create: {
width: meta.width, width: meta.width || 0,
height: meta.height, height: meta.height || 0,
channels: 4, channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 } background: { r: 0, g: 0, b: 0, alpha: 0 }
} }
@@ -49,7 +65,7 @@ async function createOutlinedText(string, meta, gravity = 'west') {
text: string, text: string,
font: 'Visitor TT2 BRK', font: 'Visitor TT2 BRK',
fontfile: path.join(PROJECT_ROOT, 'userbar', 'visitor', 'visitor2.ttf'), fontfile: path.join(PROJECT_ROOT, 'userbar', 'visitor', 'visitor2.ttf'),
width: meta.width, width: meta.width || 0,
dpi: 109, dpi: 109,
rgba: true rgba: true
} }
@@ -94,18 +110,36 @@ async function createOutlinedText(string, meta, gravity = 'west') {
} }
class Web { class Web {
constructor(db, options, savedata) { private _webserver: http.Server;
private _privateserver: http.Server;
constructor(options: Config) {
const db = new DB(process.env['DBPATH'] || options.db || path.join(PROJECT_ROOT, 'config', 'packages.db'));
const app = express(); const app = express();
const port = process.env.PORT || options.port || 8080; const privateapp = express();
const METRICPREFIX = process.env.METRICPREFIX || 'artixpackages_'; const port = process.env['PORT'] || options.port || 8080;
const maintainers = this._maintainers = (options.maintainers || []).map(m => typeof m === 'object' ? m.name : m).sort(); const privateport = process.env['PRIVATEPORT'] || options.privateport || 8081;
const METRICPREFIX = process.env['METRICPREFIX'] || 'artixpackages_';
const maintainers = (options.maintainers || []).map(m => typeof m === 'object' ? m.name : m).sort();
const savePath = process.env['SAVEPATH'] || options.savePath || path.join(PROJECT_ROOT, 'config', 'data.json');
let saveData: SaveData = {
'last-sync': null,
move: [],
update: []
};
app.set('trust proxy', 1); app.set('trust proxy', 1);
app.set('view engine', 'ejs'); app.set('view engine', 'ejs');
app.set('view options', VIEWOPTIONS); app.set('view options', VIEWOPTIONS);
function sendError(req, res, status, description) { app.use(exuseragent.express());
function sendError(req: Request, res: Response, status: number, description: string) {
console.log(`${status} (${description}): ${req.url} requested by ${req.ip} "${req.headers['user-agent']}"`); console.log(`${status} (${description}): ${req.url} requested by ${req.ip} "${req.headers['user-agent']}"`);
if ((req.useragent as Details).browser === 'curl') {
res.send('404: not found\n');
return;
}
res.render('error', res.render('error',
{ {
inliner, inliner,
@@ -128,13 +162,27 @@ class Web {
); );
} }
async function readSave() {
saveData = JSON.parse((await fsp.readFile(savePath)).toString());
}
function renderForCurl(packages: WebPackageObject[]) {
const colHeader = 'Package basename';
const tabSize = packages.reduce((acc, cur) => Math.max(acc, cur.package?.length || 0), colHeader.length) + 4;
return `${colHeader.padEnd(tabSize, ' ')}Action\n${packages.map(p => `${p.package?.padEnd(tabSize, ' ')}${p.action}`).join('\n')}\n`;
}
app.get('/healthcheck', async (_, res) => { app.get('/healthcheck', async (_, res) => {
res.send('Healthy'); res.send('Healthy');
}); });
app.get('/', async (_, res) => { app.get('/', async (req, res) => {
let packages = prepPackages(savedata.move, 'Move'); let packages = prepPackages(saveData.move, 'Move');
packages = packages.concat(prepPackages(savedata.update, 'Update')); packages = packages.concat(prepPackages(saveData.update, 'Update'));
if ((req.useragent as Details).browser === 'curl') {
res.send(renderForCurl(packages));
return;
}
res.render('index', res.render('index',
{ {
inliner, inliner,
@@ -163,6 +211,10 @@ class Web {
let packages = prepPackages(db.getPackagesByMaintainer(maintainer, 'move'), 'Move'); let packages = prepPackages(db.getPackagesByMaintainer(maintainer, 'move'), 'Move');
packages = packages.concat(prepPackages(db.getPackagesByMaintainer(maintainer, 'udate'), 'Update')); packages = packages.concat(prepPackages(db.getPackagesByMaintainer(maintainer, 'udate'), 'Update'));
if (packagesOwned > 0) { if (packagesOwned > 0) {
if ((req.useragent as Details).browser === 'curl') {
res.send(`${maintainer}'s pending actions\n\n${renderForCurl(packages)}`);
return;
}
res.render('maintainer', res.render('maintainer',
{ {
inliner, inliner,
@@ -195,7 +247,7 @@ class Web {
const packagesOwned = db.getMaintainerPackageCount(maintainer); const packagesOwned = db.getMaintainerPackageCount(maintainer);
if (packagesOwned > 0) { if (packagesOwned > 0) {
const img = sharp(path.join(PROJECT_ROOT, 'userbar', 'userbar.png')); const img = sharp(path.join(PROJECT_ROOT, 'userbar', 'userbar.png'));
const meta = await img.metadata(); const meta: sharp.Metadata = await img.metadata();
const layers = [ const layers = [
{ {
@@ -228,6 +280,7 @@ class Web {
app.get('/api/1.0/maintainers', (req, res) => { app.get('/api/1.0/maintainers', (req, res) => {
const acceptHeader = req.headers.accept; const acceptHeader = req.headers.accept;
res.set('Cache-Control', 'public, max-age=360');
if (acceptHeader && acceptHeader.includes('application/json')) { if (acceptHeader && acceptHeader.includes('application/json')) {
res.json({ res.json({
maintainers maintainers
@@ -238,6 +291,37 @@ class Web {
} }
}); });
app.get('/api/1.0/packages', (req, res) => {
const acceptHeader = req.headers.accept;
const startsWith = req.query['startswith'] as string;
const packages = db.getPackages(startsWith);
res.set('Cache-Control', 'public, max-age=360');
if (acceptHeader && acceptHeader.includes('application/json')) {
res.json({
packages
});
}
else {
res.send(packages.join(' '));
}
});
privateapp.put('/api/1.0/data', (_, res) => {
try {
readSave();
res.json({
success: true
});
}
catch (ex) {
console.error(ex);
res.status(500).json({
success: false,
error: 'failed to read save data'
});
}
});
const register = prom.register; const register = prom.register;
new prom.Gauge({ new prom.Gauge({
@@ -258,11 +342,11 @@ class Web {
this.set({ this.set({
maintainer: 'any', maintainer: 'any',
action: 'move' action: 'move'
}, savedata.move.length); }, saveData.move.length);
this.set({ this.set({
maintainer: 'any', maintainer: 'any',
action: 'update' action: 'update'
}, savedata.update.length); }, saveData.update.length);
} }
}); });
@@ -285,7 +369,7 @@ class Web {
res.end(await register.metrics()); res.end(await register.metrics());
} }
catch (ex) { catch (ex) {
console.error(err); console.error(ex);
res.status(500).send('something went wrong.'); res.status(500).send('something went wrong.');
} }
}); });
@@ -296,12 +380,19 @@ class Web {
app.use((req, res) => sendError(req, res, 404, 'File not found')); app.use((req, res) => sendError(req, res, 404, 'File not found'));
this._webserver = app.listen(port, () => console.log(`artix-packy-notifier-web running on port ${port}`)); privateapp.use('/', app);
readSave();
this._webserver = app.listen(port, () => console.log(`artix-checkupdates-web running on port ${port}`));
this._privateserver = privateapp.listen(privateport);
} }
close = () => { close = () => {
this._webserver.close(); this._webserver.close();
this._privateserver.close();
} }
} }
module.exports = Web; export default Web;
export { Web };

View File

@@ -164,14 +164,14 @@ table tr td:last-child {
} }
} }
@media screen and (max-width:790px) { @media screen and (max-width:910px) {
.container { .container {
width: initial; width: initial;
margin: .5em .5em; margin: .5em .5em;
} }
} }
@media screen and (max-width:660px) { @media screen and (max-width:740px) {
header { header {
nav { nav {
float: initial; float: initial;

12
tsconfig.dist.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "./tsconfig",
"compilerOptions": {
"sourceMap": true,
"inlineSources": true,
"rootDir": "./src",
"outDir": "./distribution"
},
"include": [
"src"
]
}

10
tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "@sindresorhus/tsconfig",
"compilerOptions": {
"exactOptionalPropertyTypes": true,
"esModuleInterop": true
},
"include": [
"src"
]
}

View File

@@ -3,7 +3,7 @@
<ul> <ul>
<li>© <%= (new Date()).getFullYear() %> Artix Linux</li> <li>© <%= (new Date()).getFullYear() %> Artix Linux</li>
<li>Developed by Cory Sanin</li> <li>Developed by Cory Sanin</li>
<li><a href="https://gitea.artixlinux.org/corysanin/artix-packy-notifier">Source</a></li> <li><a href="https://github.com/CorySanin/artix-checkupdates-web">Source</a></li>
</ul> </ul>
</footer> </footer>
</div> </div>

View File

@@ -9,6 +9,6 @@
<meta name="apple-mobile-web-app-title" content="Artix Checkupdates"> <meta name="apple-mobile-web-app-title" content="Artix Checkupdates">
<meta name="application-name" content="Artix Checkupdates"> <meta name="application-name" content="Artix Checkupdates">
<meta name="theme-color" content="#212121"> <meta name="theme-color" content="#212121">
<link rel="stylesheet" href="/assets/css/styles.css?v=2"> <link rel="stylesheet" href="/assets/css/styles.css?v=3">
<link rel="shortcut icon" href="/assets/svg/artix_logo.svg"> <link rel="shortcut icon" href="/assets/svg/artix_logo.svg">
</head> </head>

View File

@@ -10,6 +10,7 @@
<li><a href="https://forum.artixlinux.org/">Forum</a></li> <li><a href="https://forum.artixlinux.org/">Forum</a></li>
<li><a href="https://wiki.artixlinux.org/">Wiki</a></li> <li><a href="https://wiki.artixlinux.org/">Wiki</a></li>
<li><a href="https://gitea.artixlinux.org/explore/repos">Sources</a></li> <li><a href="https://gitea.artixlinux.org/explore/repos">Sources</a></li>
<li><a href="https://packages.artixlinux.org/">Packages</a></li>
</ul> </ul>
</nav> </nav>
</header> </header>

View File

@@ -11,7 +11,7 @@
<% }); %> <% }); %>
<li><a href="/maintainer/orphan">Orphan Packages</a></li> <li><a href="/maintainer/orphan">Orphan Packages</a></li>
</ul> </ul>
<h2>All Pending</h2> <h2>All Pending (<%= (packages && packages.length) || 0 %> packages)</h2>
<table> <table>
<tr> <tr>
<th>Package</th> <th>Package</th>

View File

@@ -14,6 +14,9 @@
<%= maintainer %> owns <a href="https://gitea.artixlinux.org/explore/repos?q=maintainer-<%= maintainer %>&topic=1"><%= packagesOwned %> packages</a>. <%= maintainer %> owns <a href="https://gitea.artixlinux.org/explore/repos?q=maintainer-<%= maintainer %>&topic=1"><%= packagesOwned %> packages</a>.
</p> </p>
<% } %> <% } %>
<p>
<%= (packages && packages.length) || 0 %> of which require attention.
</p>
<table> <table>
<tr> <tr>
<th>Package</th> <th>Package</th>
@@ -21,10 +24,11 @@
</tr> </tr>
<% packages && packages.forEach(p => { %> <% packages && packages.forEach(p => { %>
<tr> <tr>
<td><a href="<%= p.url %>"><%= p.package.package %></a></td> <td><a href="<%= p.url %>"><%= p.package %></a></td>
<td><%= p.action %></td> <td><%= p.action %></td>
</tr> </tr>
<% }); %> <% }); %>
</table> </table>
</div> </div>
<%- include("footer", locals) %>
</body> </body>