35 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
8ca4e2105d v3.4.1: Fixed link on orphan page 2024-03-24 14:18:39 -05:00
f3b63d5cbd v3.4.0: list orphan packages
track and list orphan packages

respond to requests while syncing packages

fix crashes due to configuration

bump dependency versions
2024-03-23 04:09:38 -05:00
1f54eae84d 3.3.3: automatically clear lockfiles 2024-02-29 19:18:51 -05:00
ee29453824 update base docker image tag name 2024-02-19 04:32:22 -05:00
9ffcd0b5ee fix userbar base 2024-02-16 04:28:16 -05:00
7410c7ca0c v3.3.2: dynamically generated, old-school userbar
I can't believe I'm doing this. Closes #3
2024-02-16 04:21:31 -05:00
6db2c2d683 v3.3.1: configurable sync frequency 2024-02-15 03:47:05 -05:00
a9191e84a0 v3.3.0: IRC notifications 2024-02-14 03:22:09 -05:00
4a4e3d9614 update readme
closes #4
2024-02-13 17:19:35 -05:00
66f45cae07 package names link to Gitea
closes #1
2024-02-13 01:14:59 -05:00
91186f29ff create user cache dir 2024-02-11 18:38:09 -05:00
e148e79195 increase healthcheck timeout 2024-02-11 23:15:01 +00:00
482ef99320 Merge branch 'packy-notifier-v3' into 'master'
V3.0.0

See merge request sanin.dev/artix-packy-notifier!2
2024-02-11 23:01:45 +00:00
5ec8dec3e7 V3.0.0 2024-02-11 23:01:45 +00:00
43 changed files with 6599 additions and 1154 deletions

View File

@@ -1,2 +1,4 @@
volume/
config/
distribution/
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 }}

22
.gitignore vendored
View File

@@ -75,15 +75,19 @@ typings/
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
# Next.js build output
.next
# nuxt.js build output
# Nuxt.js build / generate output
.nuxt
dist
.vscode
# gatsby files
# Gatsby files
.cache/
public
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
@@ -97,6 +101,12 @@ public
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# custom .gitignore
volume
docker-cache/
assets/css/
assets/js/
assets/webp/
config/
distribution/

View File

@@ -1,29 +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" .
artix-packy-exporter:
stage: build
script:
- docker build --pull -t "$CI_REGISTRY_IMAGE:exporter" prometheus/
deploy:
stage: deploy
only:
- master
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker push "$CI_REGISTRY_IMAGE"
- docker push "$CI_REGISTRY_IMAGE:exporter"

View File

@@ -1,22 +1,51 @@
FROM gitea.artixlinux.org/artixdocker/artixlinux:base-devel
FROM artixlinux/artixlinux:base-devel AS baseimg
VOLUME /usr/volume
WORKDIR /usr/files
RUN pacman -Syu --noconfirm
RUN pacman -Sy --noconfirm artools-pkg artix-checkupdates git nodejs npm cronie-openrc openssh icu glibc openssl openssl-1.1 &&\
mkdir -p /root/.config/artools/ /root/.cache/ && \
ln -sf /usr/files/.cron /etc/cron.d/.cron
FROM baseimg AS build-env
WORKDIR /usr/notifier
RUN pacman -Sy --noconfirm nodejs npm typescript
COPY package*.json ./
RUN npm install
COPY . .
RUN chmod 0644 ./* && \
chmod +x ./*.sh && \
npm install
RUN tsc && \
npm run-script build && \
npm ci --only=production
ENV CRON="*/30 * * * *"
ENV ARTIX_MIRROR="https://mirrors.qontinuum.space/artixlinux/%s/os/x86_64"
ENV ARCH_MIRROR="https://mirrors.qontinuum.space/archlinux/%s/os/x86_64"
FROM baseimg AS deploy
VOLUME /usr/notifier/config
WORKDIR /usr/notifier
HEALTHCHECK --timeout=15m \
CMD curl --fail http://localhost:8081/healthcheck || exit 1
EXPOSE 8080
RUN pacman -Sy --noconfirm curl artools-pkg artix-checkupdates git nodejs npm openssh icu glibc openssl openssl-1.1 &&\
mkdir -p /root/.config/artools/ /root/.cache/ && \
useradd -m artix
COPY --from=build-env /usr/notifier /usr/notifier
RUN mkdir -p ./config /home/artix/.config/artix-checkupdates \
/home/artix/.config/artools /home/artix/.cache/artix-checkupdates && \
ln -sf /usr/notifier/config/artools-pkg.conf /home/artix/.config/artools/artools-pkg.conf && \
ln -sf /usr/notifier/config/artix-checkupdates.conf /home/artix/.config/artix-checkupdates/config && \
chown -R artix:artix /home/artix/ && \
chown -R artix:artix .
USER artix
ENV ARTIX_MIRROR="https://mirror.sanin.dev/artix-linux/%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 ARCH_REPOS="core-staging,extra-staging,multilib-staging,core-testing,extra-testing,multilib-testing,core,extra,multilib"
CMD ./startup.sh
CMD [ "node", "distribution/index.mjs"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Cory Sanin
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.

View File

@@ -1,25 +1,59 @@
# artix-packy-notifier
# artix-checkupdates-web
Notify me when one of my packages needs maintaining
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
and publishes prometheus metrics.
mount a folder to `/usr/volume`.
## configuration
Inside the volume, create a `packages.json` with the following schema:
create `config/config.json`:
| Variable | Description |
|-----------------|-----------------------------------------------------------------------------------------------------------------------|
| PREVIOUS | The path to store the generated list of actionable packages. Defaults to `previous.json` in the mounted volume. |
| packages | An array of packages to look for pending operations for. |
| writeAllPending | Boolean. If all pending packages should be included in the PREVIOUS file. Provided as `allPackages` and `allMovable`. |
| apprise.api | The url of the Apprise server to use for sending notifications. For example, "http://192.168.1.123:8000" |
| apprise.urls | An array of Apprise destination URLs to deliver notifications to. For example, "tgram://bot-token/chat-id" |
| apprise | The URL of the Apprise instance for sending notifications |
| maintainers | Array of maintainer names as strings or objects containing the `name` of the maintainer and a list of `channels` to send notifications to |
| cron | The cron schedule for when the application should check for pending operations via [artix-checkupdates](https://gitea.artixlinux.org/artix/artix-checkupdates) |
| syncfreq | How often (in days) should the application sync package ownership from Gitea |
| port | What port to run the webserver on (defaults to 8080) |
| savePath | Location of auxiliary save data (defaults to `config/data.db`) |
| db | Location of the SQLite DB (defaults to `config/packages.db`) |
| irc-framework | The options to feed into [irc-framework](https://github.com/kiwiirc/irc-framework/blob/master/docs/clientapi.md) |
| ircClient | Auxilary config data for the IRC bot. For now, it takes `ircClient.channel` and optionally `ircClient.channel_key` |
The following environment variables should be supplied.
Note that the IRC bot needs to be exempt from excess flooding. The following command permanently voices a bot on Libera.chat:
```
/msg ChanServ FLAGS #example artix-update-bot +V
```
If the channel is intended only for the bot to broadcast, consider setting the channel mode to "moderated":
```
/mode +m #example
```
| Variable | Description |
|--------------|--------------------------------------------|
| CRON | The cron schedule for checking for updates |
| ARTIX_MIRROR | The Artix mirror to use |
| ARCH_MIRROR | The Arch mirror to use |
| ARTIX_REPOS | The Artix repos to check |
| ARCH_REPOS | The Arch repos to check |
## How to run
```
npm install
npm exec tsc
node distribution/index.mjs
```
## Docker Setup
Image : `ghcr.io/corysanin/artix-checkupdates-web:latest`
mount a folder to `/usr/notifier/config`.
Include a `config.json` as described above.
Include `artools-pkg.conf`:
```
GIT_TOKEN='YOUR-GITEA-TOKEN-HERE'
```
Include `artix-checkupdates.conf`:
```
ARTIX_MIRROR=https://example.com/%s/os/x86_64
ARCH_MIRROR=https://example.com/%s/os/x86_64
ARTIX_REPOS=system-goblins,world-goblins,galaxy-goblins,lib32-goblins,system-gremlins,world-gremlins,galaxy-gremlins,lib32-gremlins,system,world,galaxy,lib32
ARCH_REPOS=core-staging,extra-staging,multilib-staging,core-testing,extra-testing,multilib-testing,core,extra,multilib
```

26
assets/svg/artix_logo.svg Normal file
View File

@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 46.725 46.725"><script xmlns=""/>
<script/>
<defs>
<linearGradient xlink:href="#a" id="h" x1="70.513" x2="63.044" y1="62.847" y2="59.204" gradientTransform="matrix(-1 0 0 1 114.837 50.597)" gradientUnits="userSpaceOnUse"/>
<linearGradient id="a">
<stop offset="0" stop-color="#fff" stop-opacity=".365"/>
<stop offset="1" stop-color="#fff" stop-opacity="0"/>
</linearGradient>
<linearGradient xlink:href="#a" id="g" x1="70.513" x2="55.281" y1="62.847" y2="56.394" gradientTransform="translate(-21.061 67.885)" gradientUnits="userSpaceOnUse"/>
<linearGradient xlink:href="#b" id="f" x1="70.725" x2="81.158" y1="12.292" y2="19.324" gradientTransform="translate(-11.754 126.585)" gradientUnits="userSpaceOnUse"/>
<linearGradient id="b">
<stop offset="0" stop-opacity=".102"/>
<stop offset="1" stop-opacity=".304"/>
</linearGradient>
<linearGradient xlink:href="#b" id="e" x1="70.725" x2="87.092" y1="12.292" y2="26.895" gradientTransform="translate(-26.4 101.152)" gradientUnits="userSpaceOnUse"/>
<linearGradient xlink:href="#b" id="d" x1="105.834" x2="80.209" y1="15.354" y2="30.531" gradientTransform="translate(-56.383 115.378)" gradientUnits="userSpaceOnUse"/>
<linearGradient xlink:href="#a" id="c" x1="75.543" x2="81.2" y1="145.986" y2="143.227" gradientTransform="translate(-16.572 -7.109)" gradientUnits="userSpaceOnUse"/>
</defs>
<path class="artix-logo-base" fill="#10a0cc" stroke-width=".1" d="m23.362 0-8.034 16.474 22.112 12.39zM12.901 21.45.574 46.726l36.578-15.111zm26.955 12.368L28.32 40.441l17.832 6.284z"/>
<path fill="url(#c)" stroke-width=".265" d="m58.971 138.877 4.138-5.876 6.295 12.908z" transform="translate(-23.253 -99.183)"/>
<path fill="url(#d)" stroke-width=".265" d="m23.826 145.909 25.626-15.177 10.952.065z" transform="translate(-23.253 -99.183)"/>
<path fill="url(#e)" stroke-width=".265" d="m60.693 128.047-22.112-12.39 5.744-2.213z" transform="translate(-23.253 -99.183)"/>
<path fill="url(#f)" stroke-width=".265" d="m69.404 145.909-17.831-6.284 7.398-.748z" transform="translate(-23.253 -99.183)"/>
<path fill="url(#g)" stroke-width=".265" d="m23.826 145.909 25.626-15.177-13.3-10.098z" transform="translate(-23.253 -99.183)"/>
<path fill="url(#h)" stroke-width=".265" d="m60.693 128.047-16.368-14.603 2.29-14.26z" transform="translate(-23.253 -99.183)"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

171
build/build.ts Normal file
View File

@@ -0,0 +1,171 @@
import fs from 'fs';
import path from 'path';
import child_process from 'child_process';
import uglifyjs from "uglify-js";
import * as sass from 'sass';
import * as csso from 'csso';
const spawn = child_process.spawn;
const fsp = fs.promises;
const STYLESDIR = 'styles';
const SCRIPTSDIR = 'scripts';
const IMAGESDIR = path.join('assets', 'images');
const STYLEOUTDIR = process.env.STYLEOUTDIR || path.join('assets', 'css');
const SCRIPTSOUTDIR = process.env.SCRIPTSOUTDIR || path.join('assets', 'js');
const IMAGESOUTDIR = process.env.IMAGESOUTDIR || path.join('assets', 'webp');
const STYLEOUTFILE = process.env.STYLEOUTFILE || 'styles.css';
const SQUASH = new RegExp('^[0-9]+-');
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, {
recursive: true,
force: true
})));
}
async function mkdir(dir: string | string[]) {
if (typeof dir === 'string') {
await fsp.mkdir(dir, { recursive: true });
}
else {
await Promise.all(dir.map(mkdir));
}
}
// Process styles
async function styles() {
await mkdir([STYLEOUTDIR, STYLESDIR]);
await emptyDir(STYLEOUTDIR);
let styles: string[] = [];
let files = await fsp.readdir(STYLESDIR);
await Promise.all(files.map(f => new Promise(async (res, reject) => {
let p = path.join(STYLESDIR, f);
console.log(`Processing style ${p}`);
let style = sass.compile(p).css;
if (f.charAt(0) !== '_') {
if (SQUASH.test(f)) {
styles.push(style);
}
else {
let o = path.join(STYLEOUTDIR, f.substring(0, f.lastIndexOf('.')) + '.css');
await fsp.writeFile(o, csso.minify(style).css);
console.log(`Wrote ${o}`);
}
}
res(0);
})));
let out = csso.minify(styles.join('\n')).css;
let outpath = path.join(STYLEOUTDIR, STYLEOUTFILE);
await fsp.writeFile(outpath, out);
console.log(`Wrote ${outpath}`);
}
// Process scripts
async function scripts() {
await mkdir([SCRIPTSOUTDIR, SCRIPTSDIR]);
await emptyDir(SCRIPTSOUTDIR);
let files = await fsp.readdir(SCRIPTSDIR);
await Promise.all(files.map(f => new Promise(async (res, reject) => {
let p = path.join(SCRIPTSDIR, f);
let o = path.join(SCRIPTSOUTDIR, f);
console.log(`Processing script ${p}`);
try {
await fsp.writeFile(o, uglifyjs.minify((await fsp.readFile(p)).toString()).code);
console.log(`Wrote ${o}`);
}
catch (ex) {
console.log(`error writing ${o}: ${ex}`);
}
res(0);
})));
}
// Process images
async function images(dir = '') {
let p = path.join(IMAGESDIR, dir);
await mkdir(p);
if (dir.length === 0) {
await mkdir(IMAGESOUTDIR)
await emptyDir(IMAGESOUTDIR);
}
let files = await fsp.readdir(p, {
withFileTypes: true
});
if (files.length) {
await Promise.all(files.map(f => new Promise(async (res, reject) => {
if (f.isFile()) {
let outDir = path.join(IMAGESOUTDIR, dir);
let infile = path.join(p, f.name);
let outfile = path.join(outDir, f.name.substring(0, f.name.lastIndexOf('.')) + '.webp');
await mkdir(outDir);
console.log(`Processing image ${infile}`)
let process = spawn('cwebp', ['-mt', '-q', '50', infile, '-o', outfile]);
let timeout = setTimeout(() => {
reject('Timed out');
process.kill();
}, 30000);
process.on('exit', async (code) => {
clearTimeout(timeout);
if (code === 0) {
console.log(`Wrote ${outfile}`);
res(null);
}
else {
reject(code);
}
});
}
else if (f.isDirectory()) {
images(path.join(dir, f.name)).then(res).catch(reject);
}
})));
}
}
function isAbortError(err: unknown): boolean {
return typeof err === 'object' && err !== null && 'name' in err && err.name === 'AbortError';
}
(async function () {
await Promise.all([styles(), scripts(), images()]);
if (process.argv.indexOf('--watch') >= 0) {
console.log('watching for changes...');
(async () => {
try {
const watcher = fsp.watch(STYLESDIR);
for await (const _ of watcher)
await styles();
} catch (err) {
if (isAbortError(err))
return;
throw err;
}
})();
(async () => {
try {
const watcher = fsp.watch(SCRIPTSDIR);
for await (const _ of watcher)
await scripts();
} catch (err) {
if (isAbortError(err))
return;
throw err;
}
})();
(async () => {
try {
const watcher = fsp.watch(IMAGESDIR, {
recursive: true // no Linux ☹️
});
for await (const _ of watcher)
await images();
} catch (err) {
if (isAbortError(err))
return;
throw err;
}
})();
}
})();

View File

@@ -1,24 +1,36 @@
version: '2'
services:
packy:
artix-notifier-daemon:
container_name: artix-notifier-daemon
build:
context: ./
volumes:
- ./volume:/usr/volume
- ./config:/usr/notifier/config
depends_on:
- artix-notifier-irc
- artix-notifier-web
environment:
ARTIX_MIRROR: "https://mirrors.qontinuum.space/artixlinux/%s/os/x86_64"
ARCH_MIRROR: "https://mirrors.qontinuum.space/archlinux/%s/os/x86_64"
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"
CRON: "*/30 * * * *"
packy-prom:
artix-notifier-irc:
container_name: artix-notifier-irc
build:
context: ./prometheus
context: ./
volumes:
- ./volume:/usr/volume
- ./config:/usr/notifier/config
environment:
COMPONENT: "ircbot"
artix-notifier-web:
container_name: artix-notifier-web
build:
context: ./
volumes:
- ./config:/usr/notifier/config
ports:
- 8080:8080
environment:
PORT: 8080
COMPONENT: "web"

147
index.js
View File

@@ -1,147 +0,0 @@
const fs = require('fs');
const fsp = fs.promises;
const spawn = require('child_process').spawn;
const phin = require('phin');
const TIMEOUT = 180000;
const PKGCONFIG = process.env.PKGCONFIG || '/usr/volume/packages.json';
const EXTRASPACE = new RegExp('\\s+', 'g');
async function notify(apprise, packarr, type) {
for (let i = 0; i < 25; i++) {
try {
return await phin({
url: `${apprise.api}/notify/`,
method: 'POST',
data: {
title: `Packages ready to ${type}`,
body: packarr.join('\n'),
urls: apprise.urls.join(',')
}
});
}
catch (ex) {
console.error('Failed to send 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;
}
function checkUpdates(flags) {
return new Promise((resolve, reject) => {
let process = spawn('artix-checkupdates', flags);
let timeout = setTimeout(() => {
reject('Timed out');
process.kill();
}, TIMEOUT);
let packagelist = [];
process.stdout.on('data', data => {
packagelist = packagelist.concat(parseCheckUpdatesOutput(data.toString()));
});
process.stderr.on('data', err => {
console.log(err.toString());
})
process.on('exit', async (code) => {
if (code === 0) {
clearTimeout(timeout);
resolve(packagelist);
}
else {
reject(code);
}
});
});
}
async function getPendingPackages() {
return {
movable: await checkUpdates(['-m']),
upgradable: await checkUpdates(['-u'])
};
}
fs.readFile(PKGCONFIG, async (err, data) => {
if (err) {
console.log(err);
}
else {
data = JSON.parse(data);
const PREVIOUS = data.PREVIOUS || process.env.PREVIOUS || '/usr/volume/previous.json';
const packages = data.packages;
const actionableFilter = p => packages.indexOf(p) >= 0;
let previousm = [], previousu = [], movable = [], upgradable = [], newpack = [];
try {
const p = JSON.parse(await fsp.readFile(PREVIOUS));
if ('packages' in p) {
previousu = p.packages;
}
if ('movable' in p) {
previousm = p.movable;
}
}
catch (ex) {
console.log(`Could not read ${PREVIOUS}: ${ex}`);
}
try {
let allPending = await getPendingPackages();
movable = allPending.movable.filter(actionableFilter);
upgradable = allPending.upgradable.filter(actionableFilter);
console.log('Movable:');
movable.forEach(pkg => console.log(pkg));
console.log('\nUpgradable:');
upgradable.forEach(pkg => console.log(pkg));
let output = {
packages: upgradable,
movable
};
if (data.writeAllPending) {
output['allPackages'] = allPending.upgradable;
output['allMovable'] = allPending.movable;
}
try {
await fsp.writeFile(PREVIOUS, JSON.stringify(output));
}
catch (ex) {
console.log(`Could not write ${PREVIOUS}: ${ex}`);
}
movable.forEach(package => {
if (previousm.indexOf(package) === -1) {
newpack.push(package);
}
});
if (newpack.length > 0) {
await notify(data.apprise, newpack, 'move');
}
newpack = [];
upgradable.forEach(package => {
if (previousu.indexOf(package) === -1) {
newpack.push(package);
}
});
if (newpack.length > 0) {
await notify(data.apprise, newpack, 'upgrade');
}
}
catch (ex) {
console.log('Task failed:', ex);
}
}
});

4655
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,56 @@
{
"name": "artix-packy-notifier",
"version": "2.2.1",
"name": "artix-checkupdates-web",
"version": "4.1.2",
"description": "Determine packages that need attention",
"main": "index.js",
"main": "./distribution/index.js",
"type": "module",
"scripts": {
"build": "npx build-shit",
"watch": "npx build-shit --watch"
},
"repository": {
"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": [
"artix",
"linux",
"packages"
],
"files": [
"distribution",
"assets",
"views",
"userbar"
],
"author": "Cory Sanin",
"license": "MIT",
"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": {
"phin": "^3.7.0"
"artix-checkupdates": "1.0.2",
"better-sqlite3": "11.9.1",
"dayjs": "1.11.13",
"ejs": "3.1.10",
"express": "4.21.2",
"express-useragent": "1.0.15",
"irc-framework": "4.14.0",
"json5": "2.2.3",
"ky": "1.8.1",
"node-cron": "3.0.3",
"prom-client": "15.1.3",
"sharp": "0.34.1"
},
"devDependencies": {
"@sindresorhus/tsconfig": "7.0.0",
"@types/better-sqlite3": "^7.6.12",
"@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"
}
}

View File

@@ -1,19 +0,0 @@
FROM oven/bun:alpine AS build-env
WORKDIR /build
COPY ./package*json ./
COPY ./bun.lockb ./
RUN bun install --production --no-progress
FROM oven/bun:alpine
EXPOSE ${PORT:-8080}
HEALTHCHECK --timeout=3s \
CMD curl --fail http://localhost:${PORT:-8080}/healthcheck || exit 1
WORKDIR /usr/src/artix-packy-exporter
RUN apk add --no-cache curl
COPY --from=build-env /build .
COPY . .
USER bun
CMD [ "bun", "run", "index.ts"]

View File

@@ -1,15 +0,0 @@
# artix-packy-exporter
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.0.11. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

Binary file not shown.

View File

@@ -1,59 +0,0 @@
import express from 'express';
import prom from 'prom-client';
import fs from 'fs';
const fsp = fs.promises;
const http = express();
const NAME = 'Artix Package Exporter';
const PORT = process.env.PORT || 8080;
const METRICPREFIX = process.env.METRICPREFIX || 'artixpackages_';
const register = prom.register;
const NICE_NAMES: any = {
packages: 'My upgradable',
movable: 'My movable',
allPackages: 'All upgradable',
allMovable: 'All movable'
};
new prom.Gauge({
name: `${METRICPREFIX}pending_packages`,
help: 'Number of packages that have pending updates or moves.',
labelNames: ['category'],
async collect() {
const prev = JSON.parse((await fsp.readFile(process.env.PREVIOUS || '/usr/volume/previous.json')).toString());
for (const category in prev) {
this.set({ category: ((category in NICE_NAMES) ? NICE_NAMES[category] : category) }, (prev[category] as string[]).length);
}
}
});
new prom.Gauge({
name: `${METRICPREFIX}watched_packages`,
help: 'Number of packages being monitored for updates.',
async collect() {
const config = JSON.parse((await fsp.readFile(process.env.PACKAGES || '/usr/volume/packages.json')).toString());
this.set('packages' in config ? config['packages'].length : 0);
}
});
http.get('/', async (_, res) => {
res.send(NAME);
});
http.get('/metrics', async (_, res) => {
try {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
}
catch (ex) {
res.status(500).send(ex);
}
});
http.get('/healthcheck', async (_, res) => {
res.send('Healthy');
});
process.on('SIGTERM', http.listen(PORT, () => {
console.log(`${NAME} running on port ${PORT}.`);
}).close);

View File

@@ -1,753 +0,0 @@
{
"name": "artix-packy-exporter",
"version": "2.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "artix-packy-exporter",
"version": "2.2.1",
"dependencies": {
"express": "4.18.2",
"prom-client": "15.1.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"bun-types": "latest"
},
"peerDependencies": {
"typescript": "^5.0.0"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.7.0",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
"integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==",
"dev": true,
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/express": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz",
"integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==",
"dev": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
"@types/qs": "*",
"@types/serve-static": "*"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "4.17.41",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.41.tgz",
"integrity": "sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA==",
"dev": true,
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==",
"dev": true
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true
},
"node_modules/@types/node": {
"version": "20.9.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.0.tgz",
"integrity": "sha512-nekiGu2NDb1BcVofVcEKMIwzlx4NjHlcjhoxxKBNLtz15Y1z7MYf549DFvkHSId02Ax6kGwWntIBPC3l/JZcmw==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@types/qs": {
"version": "6.9.10",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.10.tgz",
"integrity": "sha512-3Gnx08Ns1sEoCrWssEgTSJs/rsT2vhGP+Ja9cnnk9k4ALxinORlQneLXFeFKOTJMOeZUFD1s7w+w2AphTpvzZw==",
"dev": true
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true
},
"node_modules/@types/send": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz",
"integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==",
"dev": true,
"dependencies": {
"@types/mime": "^1",
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.5.tgz",
"integrity": "sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==",
"dev": true,
"dependencies": {
"@types/http-errors": "*",
"@types/mime": "*",
"@types/node": "*"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"license": "MIT"
},
"node_modules/bintrees": {
"version": "1.0.2",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.1",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.4",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.11.0",
"raw-body": "2.5.1",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bun-types": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.0.11.tgz",
"integrity": "sha512-XaDwjnBlkdTOtBEAcXhDnPSKFMlwFK/526z0iyairYIDhZJMzZM1QU4D7XRiEI2SpKQWexn0S/LN9Mwx5xSJNg==",
"dev": true
},
"node_modules/bytes": {
"version": "3.1.2",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind": {
"version": "1.0.5",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.1",
"set-function-length": "^1.1.1"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.5.0",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/define-data-property": {
"version": "1.1.1",
"license": "MIT",
"dependencies": {
"get-intrinsic": "^1.2.1",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/depd": {
"version": "2.0.0",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "1.0.2",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.18.2",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.1",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.5.0",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "1.2.0",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"merge-descriptors": "1.0.1",
"methods": "~1.1.2",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "0.1.7",
"proxy-addr": "~2.0.7",
"qs": "6.11.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "0.18.0",
"serve-static": "1.15.0",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
}
},
"node_modules/finalhandler": {
"version": "1.2.0",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"statuses": "2.0.1",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.2.2",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2",
"has-proto": "^1.0.1",
"has-symbols": "^1.0.3",
"hasown": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gopd": {
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"get-intrinsic": "^1.1.3"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"get-intrinsic": "^1.2.2"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-proto": {
"version": "1.0.1",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.0.3",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.0",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"license": "MIT",
"dependencies": {
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.1",
"license": "MIT"
},
"node_modules/methods": {
"version": "1.1.2",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.1",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.7",
"license": "MIT"
},
"node_modules/prom-client": {
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.0.tgz",
"integrity": "sha512-cCD7jLTqyPdjEPBo/Xk4Iu8jxjuZgZJ3e/oET3L+ZwOuap/7Cw3dH/TJSsZKs1TQLZ2IHpIlRAKw82ef06kmMw==",
"dependencies": {
"@opentelemetry/api": "^1.4.0",
"tdigest": "^0.1.1"
},
"engines": {
"node": "^16 || ^18 || >=20"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.11.0",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.0.4"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.1",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"license": "MIT"
},
"node_modules/send": {
"version": "0.18.0",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "2.4.1",
"range-parser": "~1.2.1",
"statuses": "2.0.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.15.0",
"license": "MIT",
"dependencies": {
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "0.18.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/set-function-length": {
"version": "1.1.1",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.1",
"get-intrinsic": "^1.2.1",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.0.4",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.0",
"get-intrinsic": "^1.0.2",
"object-inspect": "^1.9.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/tdigest": {
"version": "0.1.2",
"license": "MIT",
"dependencies": {
"bintrees": "1.0.2"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/typescript": {
"version": "5.2.2",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
},
"node_modules/unpipe": {
"version": "1.0.0",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}

View File

@@ -1,17 +0,0 @@
{
"name": "artix-packy-exporter",
"version": "2.2.1",
"module": "index.ts",
"type": "module",
"dependencies": {
"express": "4.18.2",
"prom-client": "15.1.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"bun-types": "latest"
},
"peerDependencies": {
"typescript": "^5.0.0"
}
}

View File

@@ -1,22 +0,0 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"module": "esnext",
"target": "esnext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"allowImportingTsExtensions": true,
"noEmit": true,
"composite": true,
"strict": true,
"downlevelIteration": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"types": [
"bun-types" // add Bun global
]
}
}

View File

@@ -1,5 +0,0 @@
#!/bin/bash
echo " ~cron job started~ "
cd /usr/files
node index.js
echo "job finished."

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

173
src/db.mts Normal file
View File

@@ -0,0 +1,173 @@
import Database from 'better-sqlite3';
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 {
private _db: Database.Database;
private _queries: DatabaseOperations;
constructor(file: string | Buffer) {
this._db = new Database(file);
const db = this._db;
db.pragma('journal_mode = WAL');
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();
}
this._queries = {
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);`),
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`),
GETMAINTAINERPACKAGECOUNT: db.prepare(`SELECT COUNT(package) as count FROM ${TABLE} WHERE maintainer = @maintainer;`),
REMOVEOLDPACKAGE: db.prepare(`DELETE FROM ${TABLE} WHERE lastseen < @lastseen;`),
move: {
GET: db.prepare(`SELECT * FROM ${TABLE} WHERE move = @bool;`),
GETNEWBYMAINTAINER: db.prepare(`SELECT * FROM ${TABLE} WHERE maintainer = @maintainer AND move = 4;`),
UPDATE: db.prepare(`UPDATE ${TABLE} SET move = @bool 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`),
FIXFLAG: db.prepare(`UPDATE ${TABLE} SET move = 1 WHERE move > 1`),
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;`)
},
udate: {
GET: db.prepare(`SELECT * FROM ${TABLE} WHERE udate = @bool;`),
GETNEWBYMAINTAINER: db.prepare(`SELECT * FROM ${TABLE} WHERE maintainer = @maintainer AND udate = 4;`),
UPDATE: db.prepare(`UPDATE ${TABLE} SET udate = @bool 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`),
FIXFLAG: db.prepare(`UPDATE ${TABLE} SET udate = 1 WHERE udate > 1`),
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;`)
}
};
}
restoreFlags(type: Category | null = null) {
if (type !== 'udate') {
this._queries.move.FIXFLAG.run();
}
if (type !== 'move') {
this._queries.udate.FIXFLAG.run();
}
}
getPackage(pack: string): PackageDBEntry {
return this._queries.GETPACKAGE.get({
package: pack
}) as PackageDBEntry;
}
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({
package: pack,
maintainer,
lastseen
});
}
incrementFlag(pack: string, type: Category): boolean {
this._queries[type].INCREMENT.run({
package: pack
});
return true;
}
decrementFlags(type: Category): boolean {
this._queries[type].DECREMENT.run();
return true;
}
updateFlag(pack: string, type: Category, bool: number): boolean {
this._queries[type].UPDATE.run({
package: pack,
bool
});
return true;
}
getFlag(type: Category, bool: boolean = true): PackageDBEntry[] {
return this._queries[type].GET.all({
bool
}) as PackageDBEntry[];
}
getNewByMaintainer(maintainer: string, type: Category): PackageDBEntry[] {
return this._queries[type].GETNEWBYMAINTAINER.all({
maintainer
}) as PackageDBEntry[];
}
getPackagesByMaintainer(maintainer: string, type: Category): PackageDBEntry[] {
return this._queries[type].GETPACKAGESBYMAINTAINER.all({
maintainer
}) as PackageDBEntry[];
}
getPackageCountByMaintainer(maintainer: string, type: Category): number {
return (this._queries[type].GETPACKAGECOUNTBYMAINTAINER.get({
maintainer
}) as CountResult).count;
}
cleanOldPackages(lastseen: number): Database.RunResult {
return this._queries.REMOVEOLDPACKAGE.run({
lastseen
});
}
getMaintainerPackageCount(maintainer: string): number {
return (this._queries.GETMAINTAINERPACKAGECOUNT.get({
maintainer
}) as CountResult).count;
}
}
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 };

398
src/web.mts Normal file
View File

@@ -0,0 +1,398 @@
import fs from 'fs';
import * as fsp from 'node:fs/promises';
import { DB } from './db.mjs';
import express from 'express';
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 = path.resolve(import.meta.dirname, '..');
const VIEWOPTIONS = {
outputFunctionName: 'echo'
};
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));
}
function parsePackage(p: ParseablePackage): string {
return typeof p === 'string' ? p : p.package;
}
function packageUrl(p: ParseablePackage) {
return `https://gitea.artixlinux.org/packages/${parsePackage(p)}`;
}
function prepPackages(arr: ParseablePackage[], action: Action): WebPackageObject[] {
return arr.map(m => {
return {
package: parsePackage(m),
action,
url: packageUrl(m)
}
});
}
async function createOutlinedText(string: string, meta: sharp.Metadata, gravity: sharp.Gravity = 'west') {
const txt = sharp({
create: {
width: meta.width || 0,
height: meta.height || 0,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 }
}
}).composite([
{
input: {
text: {
text: string,
font: 'Visitor TT2 BRK',
fontfile: path.join(PROJECT_ROOT, 'userbar', 'visitor', 'visitor2.ttf'),
width: meta.width || 0,
dpi: 109,
rgba: true
}
},
gravity
}
]);
const outline = await txt.clone().png().toBuffer();
const mult = gravity === 'east' ? -1 : 1;
const layers = [
{
input: (outline),
top: 1 * mult,
left: 0
},
{
input: (outline),
top: 0,
left: 1 * mult
},
{
input: (outline),
top: 1 * mult,
left: 2 * mult
},
{
input: (outline),
top: 2 * mult,
left: 1 * mult
},
{
input: (await txt.clone().linear(0, 255).png().toBuffer()),
top: 1 * mult,
left: 1 * mult
}
];
return txt.composite(layers);
}
class Web {
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 privateapp = express();
const port = process.env['PORT'] || options.port || 8080;
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('view engine', 'ejs');
app.set('view options', VIEWOPTIONS);
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']}"`);
if ((req.useragent as Details).browser === 'curl') {
res.send('404: not found\n');
return;
}
res.render('error',
{
inliner,
site: {
prefix: 'Artix Checkupdates',
suffix: 'Error'
},
status,
description
},
function (err, html) {
if (!err) {
res.status(status).send(html);
}
else {
console.error(err);
res.status(500).send(description);
}
}
);
}
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) => {
res.send('Healthy');
});
app.get('/', async (req, res) => {
let packages = prepPackages(saveData.move, 'Move');
packages = packages.concat(prepPackages(saveData.update, 'Update'));
if ((req.useragent as Details).browser === 'curl') {
res.send(renderForCurl(packages));
return;
}
res.render('index',
{
inliner,
site: {
prefix: 'Artix Checkupdates',
suffix: 'Web Edition'
},
packages,
maintainers: maintainers
},
function (err, html) {
if (!err) {
res.send(html);
}
else {
console.error(err);
sendError(req, res, 500, 'Something went wrong. Try again later.');
}
}
);
});
app.get('/maintainer/:maintainer', async (req, res) => {
const maintainer = req.params.maintainer;
const packagesOwned = db.getMaintainerPackageCount(maintainer);
let packages = prepPackages(db.getPackagesByMaintainer(maintainer, 'move'), 'Move');
packages = packages.concat(prepPackages(db.getPackagesByMaintainer(maintainer, 'udate'), 'Update'));
if (packagesOwned > 0) {
if ((req.useragent as Details).browser === 'curl') {
res.send(`${maintainer}'s pending actions\n\n${renderForCurl(packages)}`);
return;
}
res.render('maintainer',
{
inliner,
site: {
prefix: 'Artix Checkupdates',
suffix: `${maintainer}'s pending actions`
},
maintainer,
packagesOwned,
packages
},
function (err, html) {
if (!err) {
res.send(html);
}
else {
console.error(err);
res.status(500).send('Something went wrong. Try again later.');
}
}
);
}
else {
sendError(req, res, 404, 'File not found');
}
});
app.get('/userbar/:maintainer.png', async (req, res) => {
const maintainer = req.params.maintainer;
const packagesOwned = db.getMaintainerPackageCount(maintainer);
if (packagesOwned > 0) {
const img = sharp(path.join(PROJECT_ROOT, 'userbar', 'userbar.png'));
const meta: sharp.Metadata = await img.metadata();
const layers = [
{
input: (await (await createOutlinedText('Artix Maintainer', meta)).png().toBuffer()),
top: 1,
left: 55
},
{
input: (await (await createOutlinedText(`${packagesOwned} packages`, meta, 'east')).png().toBuffer()),
top: 3,
left: -12
}
];
res.set('Content-Type', 'image/png')
.set('Cache-Control', 'public, max-age=172800')
.send(await img.composite(layers).png({
quality: 90,
compressionLevel: 3
}).toBuffer());
}
else {
sendError(req, res, 404, 'File not found');
}
});
app.get('/robots.txt', (_, res) => {
res.set('content-type', 'text/plain').send('User-agent: *\nDisallow: /metrics\n');
});
app.get('/api/1.0/maintainers', (req, res) => {
const acceptHeader = req.headers.accept;
res.set('Cache-Control', 'public, max-age=360');
if (acceptHeader && acceptHeader.includes('application/json')) {
res.json({
maintainers
});
}
else {
res.send(maintainers.join(' '));
}
});
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;
new prom.Gauge({
name: `${METRICPREFIX}pending_packages`,
help: 'Number of packages that have pending moves and updates.',
labelNames: ['maintainer', 'action'],
collect() {
maintainers.forEach(m => {
this.set({
maintainer: `${m}`,
action: 'move'
}, db.getPackageCountByMaintainer(m, 'move'));
this.set({
maintainer: `${m}`,
action: 'update'
}, db.getPackageCountByMaintainer(m, 'udate'));
});
this.set({
maintainer: 'any',
action: 'move'
}, saveData.move.length);
this.set({
maintainer: 'any',
action: 'update'
}, saveData.update.length);
}
});
new prom.Gauge({
name: `${METRICPREFIX}watched_packages`,
help: 'Number of packages being monitored for updates.',
labelNames: ['maintainer'],
collect() {
maintainers.forEach(m => {
this.set({
maintainer: `${m}`
}, db.getMaintainerPackageCount(m));
});
}
});
app.get('/metrics', async (_, res) => {
try {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
}
catch (ex) {
console.error(ex);
res.status(500).send('something went wrong.');
}
});
app.use('/assets/', express.static('assets', {
maxAge: '30d'
}));
app.use((req, res) => sendError(req, res, 404, 'File not found'));
privateapp.use('/', app);
readSave();
this._webserver = app.listen(port, () => console.log(`artix-checkupdates-web running on port ${port}`));
this._privateserver = privateapp.listen(privateport);
}
close = () => {
this._webserver.close();
this._privateserver.close();
}
}
export default Web;
export { Web };

View File

@@ -1,9 +0,0 @@
#!/bin/bash
echo "Artix Packy Notifier"
echo "cron schedule: $CRON"
mkdir -p /usr/volume/packages ~/.config/artix-checkupdates
printf "ARTIX_MIRROR=$ARTIX_MIRROR\nARCH_MIRROR=$ARCH_MIRROR\nARTIX_REPOS=$ARTIX_REPOS\nARCH_REPOS=$ARCH_REPOS" "%s" "%s" > ~/.config/artix-checkupdates/config
R=$(echo "$CRON" | sed "s/\\//\\\\\\//g")
sed "s/%CRON%/$R/" cron > .cron
crontab /etc/cron.d/.cron
crond -f

48
styles/00-reset.css Normal file
View File

@@ -0,0 +1,48 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}

200
styles/01-main.scss Normal file
View File

@@ -0,0 +1,200 @@
html,
body {
font-family: sans-serif;
box-sizing: border-box;
background-color: #1a1a1a;
color: #d9d9d9;
font-size: 1.1em;
line-height: 1.2em;
}
*,
*::before,
*:after {
box-sizing: inherit;
}
h1 {
font-size: 1.8em;
line-height: 1.5em;
text-transform: uppercase;
}
h2 {
font-size: 1.6em;
line-height: 1.4em;
}
h3 {
font-size: 1.5em;
line-height: 1.3em;
}
h4 {
font-size: 1.4em;
line-height: 1.2em;
}
h5 {
font-size: 1.3em;
line-height: 1.1em;
}
h6 {
font-size: 1.2em;
line-height: 1.1em;
}
p {
margin-bottom: .5em;
}
a {
text-decoration: none;
color: #53bffc;
}
a:hover,
a:focus {
color: #92D7FC;
text-decoration: underline;
}
ul {
li {
line-height: 1.75em;
}
}
table {
background-color: #1a1a1a;
border-collapse: collapse;
width: 100%;
}
td,
th {
border: 1px solid #858585;
text-align: left;
overflow-x: auto;
overflow-wrap: anywhere;
padding: 8px;
}
tr:nth-child(even) {
background-color: #111;
}
th {
color: #fff;
background-color: #0f3147;
border: 1px solid #0A6682;
white-space: nowrap;
}
header {
a:hover,
a:focus {
text-decoration: none;
&.logo svg .artix-logo-base {
fill: #53bffc;
}
}
a.logo {
display: inline-block;
height: 3em;
svg {
width: 3em;
height: 3em;
}
}
svg+span {
display: none;
}
nav {
float: right;
ul li {
display: inline-block;
a {
display: inline-block;
line-height: 3em;
height: 3em;
padding: 0 10px 0 10px;
&:hover,
&:focus {
border-bottom: 2px solid #92D7FC;
}
}
}
}
}
footer {
font-size: 9pt;
ul li {
display: inline-block;
padding: 0 1em 0 0;
}
}
table tr td:last-child {
min-width: 6em;
}
.container {
padding: .5em .75em;
width: 80%;
margin: .5em auto;
border: 1px solid rgba(0, 0, 0, 0);
&.bg {
background-color: #2a2a2a;
border: 1px solid #858585;
}
}
@media screen and (max-width:910px) {
.container {
width: initial;
margin: .5em .5em;
}
}
@media screen and (max-width:740px) {
header {
nav {
float: initial;
}
}
}
@media screen and (max-width:270px) {
footer ul li {
display: block;
}
header nav ul li {
display: block;
a {
display: block;
}
}
html,
body {
font-size: 1em;
line-height: 1.1em;
}
}

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"
]
}

BIN
userbar/userbar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
userbar/userbar.psd Normal file

Binary file not shown.

View File

@@ -0,0 +1,86 @@
______________________________
Visitor Created by Brian Kent
<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
Thanks for Downloading Visitor.
-Visitor TT1 [.ttf]
-Visitor TT2 [.ttf]
-Visitor [7pt] [.fon]
'Visitor.fon' is a Windows Bitmap Font (.fon). This font is best
used at 7pt. To use it at larger point sizes (for images), try using
a graphics program like Photo Shop, Paint Shop Pro, or the Paint
program that comes with Windows. Type out your text at the recommended
point size [7pt], then resize the image. Set the color mode to 256
or 2 colors so the edges don't get blured when resizing, then after you
have the text to the size that you want, then change back to a higher
color mode and edit the image.
For programs that don't show Bitmap Fonts in the Font Selector, you
may be able to get the font to work by typing in:
visitor -brk-
The TTF versions were created different ways. TT1 was created from
within FontLab and TT2 was created in Illustrator then imported into
FontLab. I didn't know which one to include so I just included both.
If you have any questions or comments, you can e-mail me at
kentpw@norwich.net
You can visit my Homepage <<3C>NIGMA GAMES & FONTS> at
http://www.aenigmafonts.com/
____________
!!! NOTE !!!
<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
This font has been updated! I've edited the (BRK) in the font name
to just BRK. It seems that Adobe Illustrator and web pages with CSS
don't like fonts with ( and ) in their name.
________________
INSTALLING FONTS
<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
There's a couple of ways to install Fonts. The 'easy' way to
install fonts is to just Unzip/place the font file(s) into your
Windows\Fonts directory (I always use this method). If you're unable
to do it the 'easy' way, then try to do it this way (for Windows
95/98/NT):
1] Unzip the Font(s) to a folder (or somewhere, just remember where
you unzipped it) on your Computer.
2] Next, click on the START button, then select SETTINGS then
CONTROL PANEL.
3] When the Control Panel Window pops up, Double Click on FONTS.
4] When the FONTS window pops up, select File then Install New Font...
5] A Add Fonts window will pop up, just go to the folder that you
unzipped the Font(s) to, select the Font(s) and then click on OK.
Now the Font(s) are installed.
Now you can use the Font(s) in programs the utilize Fonts. Make
sure that you install the font(s) first, then open up your apps
(so the app will recognize the font). Sometimes you'll have to
wait until you computer 'auto-refreshes' for programs to recognize
fonts (Windows is sometimes slow to do that). You can refresh your
computer quicker by going into Windows Explorer -or- My Computer and
press F5 (or in the menubar select VIEW then REFRESH).
__________
DISCLAIMER
<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
-The font(s) in this zip file were created by me (Brian Kent). All
of my Fonts are Freeware, you can use them any way you want to
(Personal use, Commercial use, or whatever).
-If you have a Font related site and would like to offer my fonts on
your site, go right ahead. All I ask is that you keep this text file
intact with the Font.
-You may not Sell or Distribute my Fonts for profit or alter them in
any way without asking me first. [e-mail - kentpw@norwich.net]

Binary file not shown.

Binary file not shown.

9
views/error.ejs Normal file
View File

@@ -0,0 +1,9 @@
<%- include("head", locals) %>
<body>
<%- include("header", locals) %>
<div class="container bg">
<h1><%= status %></h1>
<p><%= description %></p>
</div>
</body>

9
views/footer.ejs Normal file
View File

@@ -0,0 +1,9 @@
<div class="container bg">
<footer>
<ul>
<li>© <%= (new Date()).getFullYear() %> Artix Linux</li>
<li>Developed by Cory Sanin</li>
<li><a href="https://github.com/CorySanin/artix-checkupdates-web">Source</a></li>
</ul>
</footer>
</div>

14
views/head.ejs Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title><%= site.prefix %> - <%= site.suffix %></title>
<meta charset="utf-8" />
<meta name="author" content="Cory Sanin" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="apple-mobile-web-app-title" content="Artix Checkupdates">
<meta name="application-name" content="Artix Checkupdates">
<meta name="theme-color" content="#212121">
<link rel="stylesheet" href="/assets/css/styles.css?v=3">
<link rel="shortcut icon" href="/assets/svg/artix_logo.svg">
</head>

17
views/header.ejs Normal file
View File

@@ -0,0 +1,17 @@
<div class="container">
<header>
<a href="/" class="logo"><%- inliner('/assets/svg/artix_logo.svg') %>
<span>Artix Checkupdates</span>
</a>
<nav>
<ul>
<li><a href="/">Checkupdates</a></li>
<li><a href="https://artixlinux.org/">Artix Linux</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://gitea.artixlinux.org/explore/repos">Sources</a></li>
<li><a href="https://packages.artixlinux.org/">Packages</a></li>
</ul>
</nav>
</header>
</div>

29
views/index.ejs Normal file
View File

@@ -0,0 +1,29 @@
<%- include("head", locals) %>
<body>
<%- include("header", locals) %>
<div class="container bg">
<h1>Pending Packages</h1>
<h2>By Maintainer</h2>
<ul>
<% maintainers && maintainers.forEach(m => { %>
<li><a href="/maintainer/<%= m %>"><%= m %></a></li>
<% }); %>
<li><a href="/maintainer/orphan">Orphan Packages</a></li>
</ul>
<h2>All Pending (<%= (packages && packages.length) || 0 %> packages)</h2>
<table>
<tr>
<th>Package</th>
<th>Action</th>
</tr>
<% packages && packages.forEach(p => { %>
<tr>
<td><a href="<%= p.url %>"><%= p.package %></a></td>
<td><%= p.action %></td>
</tr>
<% }); %>
</table>
</div>
<%- include("footer", locals) %>
</body>

34
views/maintainer.ejs Normal file
View File

@@ -0,0 +1,34 @@
<%- include("head", locals) %>
<body>
<%- include("header", locals) %>
<div class="container bg">
<% if (maintainer === 'orphan') { %>
<h1>Orphaned Packages with Pending Operations</h1>
<p>
There are <a href="https://gitea.artixlinux.org/explore/repos?q=<%= maintainer %>&topic=1"><%= packagesOwned %> packages</a> without a maintainer.
</p>
<% } else { %>
<h1><%= maintainer %>'s Operations</h1>
<p>
<%= maintainer %> owns <a href="https://gitea.artixlinux.org/explore/repos?q=maintainer-<%= maintainer %>&topic=1"><%= packagesOwned %> packages</a>.
</p>
<% } %>
<p>
<%= (packages && packages.length) || 0 %> of which require attention.
</p>
<table>
<tr>
<th>Package</th>
<th>Action</th>
</tr>
<% packages && packages.forEach(p => { %>
<tr>
<td><a href="<%= p.url %>"><%= p.package %></a></td>
<td><%= p.action %></td>
</tr>
<% }); %>
</table>
</div>
<%- include("footer", locals) %>
</body>