guix_mirror_bot pushed a commit to branch master
in repository guix.

commit 054aae7bb201f6ba83390017a4cc644061a6abda
Author: Rodion Goritskov <rod...@goritskov.com>
AuthorDate: Sat Jul 19 10:52:11 2025 +0200

    services: Add miniflux-service-type.
    
    * doc/guix.texi: Document Miniflux service and configuration.
    * gnu/services/web.scm: New service.
    * gnu/services/web.scm: Define shepherd service and account roles.
    * gnu/tests/web.scm: (%miniflux-create-admin-credentials,
    miniflux-base-system, %test-miniflux-admin-string, 
%test-miniflux-admin-file,
    %test-miniflux-socket): Add system tests for Miniflux service.
    
    Change-Id: I4a336e677ec8b46aed632f0ded9cc11c2d38975f
    Signed-off-by: Ludovic Courtès <l...@gnu.org>
---
 doc/guix.texi        |  75 ++++++++++++++++++++
 gnu/services/web.scm | 194 ++++++++++++++++++++++++++++++++++++++++++++++++++-
 gnu/tests/web.scm    | 194 ++++++++++++++++++++++++++++++++++++++++++++++++++-
 3 files changed, 461 insertions(+), 2 deletions(-)

diff --git a/doc/guix.texi b/doc/guix.texi
index af91f02d1f..9aadad4c2e 100644
--- a/doc/guix.texi
+++ b/doc/guix.texi
@@ -144,6 +144,7 @@ Copyright @copyright{} 2024 Evgeny Pisemsky@*
 Copyright @copyright{} 2025 jgart@*
 Copyright @copyright{} 2025 Artur Wroblewski@*
 Copyright @copyright{} 2025 Edouard Klein@*
+Copyright @copyright{} 2025 Rodion Goritskov@*
 
 Permission is granted to copy, distribute and/or modify this document
 under the terms of the GNU Free Documentation License, Version 1.3 or
@@ -35700,6 +35701,80 @@ The file which should store the logging output of 
Agate.
 @end table
 @end deftp
 
+@subsubheading Miniflux
+
+@cindex miniflux
+The @uref{https://miniflux.app/, Miniflux} is a minimalist RSS feed reader
+with a web interface.
+
+Depending on the configuration, an initial administrator user can be 
pre-created
+on startup. To enable this, @code{create-admin?} should be set to @code{#t}, 
and
+both @code{admin-username-file} and @code{admin-password-file} should point to
+files containing the username and password, respectively. However, it is
+recommended to manually change the password to a secure one via the web
+UI after the initial service startup.
+
+@defvar miniflux-service-type
+This is the type of the Miniflux service. Its value
+must be a @code{miniflux-configuration} record as in this example:
+
+@lisp
+(service miniflux-service-type
+         (miniflux-configuration
+          (listen-address "0.0.0.0:8080")
+          (base-url "http://my-news-source.test";)
+          (create-administrator-account? #t)
+          (administrator-account-name "/var/miniflux/initial-admin-username")
+          (administrator-account-password 
"/var/miniflux/initial-admin-password")))
+@end lisp
+
+The details of the @code{miniflux-configuration} record type are given below.
+
+@end defvar
+
+@deftp {Data Type} miniflux-configuration
+Available @code{miniflux-configuration} fields are:
+
+@table @asis
+@item @code{listen-address} (default: @code{"127.0.0.1:8080"}) (type: string)
+Address to listen on.
+Use absolute path like @code{"/var/run/miniflux/miniflux.sock"} for a Unix 
socket.
+
+@item @code{base-url} (default: @code{"http://127.0.0.1/"}) (type: string)
+Base URL to generate HTML links and base path for cookies.
+
+@item @code{create-administrator-account?} (default: @code{#f}) (type: boolean)
+Create an initial administrator account.
+
+@item @code{administrator-account-name} (type: maybe-string-or-file-path)
+Initial administrator account name as a string or an absolute path
+to a file with an account name inside.
+
+@item @code{administrator-account-password} (type: maybe-string-or-file-path)
+Initial administrator account password as a string or an absolute path
+to a file with a password inside.
+
+@item @code{run-migrations?} (default: @code{#t}) (type: boolean)
+Run database migrations during application startup.
+
+@item @code{database-url} (default: @code{"host=/var/run/postgresql"}) (type: 
string)
+PostgreSQL connection string.
+
+@item @code{user} (default: @code{"miniflux"}) (type: string)
+User name for Postgresql and system account.
+
+@item @code{group} (default: @code{"miniflux"}) (type: string)
+Group for the system account.
+
+@item @code{log-file} (default: @code{"/var/log/miniflux.log"}) (type: string)
+Path to the log file.
+
+@item @code{extra-settings} (type: maybe-list)
+Extra configuration parameters as a list of strings.
+
+@end table
+@end deftp
+
 @node High Availability Services
 @subsection High Availability Services
 
diff --git a/gnu/services/web.scm b/gnu/services/web.scm
index 3989607646..ae33a25394 100644
--- a/gnu/services/web.scm
+++ b/gnu/services/web.scm
@@ -19,6 +19,7 @@
 ;;; Copyright © 2023 Miguel Ángel Moreno <m...@migalmoreno.com>
 ;;; Copyright © 2024 Leo Nikkilä <he...@lnikki.la>
 ;;; Copyright © 2025 Maxim Cournoyer <maxim.courno...@gmail.com>
+;;; Copyright © 2025 Rodion Goritskov <rod...@goritskov.com>
 ;;;
 ;;; This file is part of GNU Guix.
 ;;;
@@ -40,8 +41,10 @@
   #:use-module (gnu services shepherd)
   #:use-module (gnu services admin)
   #:use-module (gnu services configuration)
+  #:use-module (gnu services databases)
   #:use-module (gnu services getmail)
   #:use-module (gnu services mail)
+  #:use-module (gnu system file-systems)
   #:use-module (gnu system pam)
   #:use-module (gnu system shadow)
   #:use-module (gnu packages admin)
@@ -59,7 +62,9 @@
   #:use-module (gnu packages mail)
   #:use-module (gnu packages rust-apps)
   #:autoload   (guix i18n) (G_)
+  #:autoload   (gnu build linux-container) (%namespaces)
   #:use-module (guix diagnostics)
+  #:use-module (guix least-authority)
   #:use-module (guix packages)
   #:use-module (guix records)
   #:use-module (guix modules)
@@ -74,6 +79,7 @@
   #:use-module (srfi srfi-34)
   #:use-module (ice-9 match)
   #:use-module (ice-9 format)
+  #:use-module (ice-9 regex)
   #:export (httpd-configuration
             httpd-configuration?
             httpd-configuration-package
@@ -328,7 +334,23 @@
             agate-configuration-group
             agate-configuration-log-file
 
-            agate-service-type))
+            agate-service-type
+
+            miniflux-configuration
+            miniflux-configuration?
+            miniflux-configuration-listen-address
+            miniflux-configuration-base-url
+            miniflux-configuration-create-administrator-account?
+            miniflux-configuration-administrator-account-name
+            miniflux-configuration-administrator-account-password
+            miniflux-configuration-run-migrations?
+            miniflux-configuration-database-url
+            miniflux-configuration-user
+            miniflux-configuration-group
+            miniflux-configuration-log-file
+            miniflux-configuration-extra-settings
+
+            miniflux-service-type))
 
 ;;; Commentary:
 ;;;
@@ -2279,3 +2301,173 @@ root=/srv/gemini
    (default-value (agate-configuration))
    (description "Run Agate, a simple Gemini protocol server written in
 Rust.")))
+
+(define (serialize-string field-name val)
+  (format #f "~a=~a\n" field-name val))
+
+(define (string-or-file-path? val)
+  (string? val))
+(define (serialize-string-or-file-path field-name val)
+  (serialize-string (if (absolute-file-name? val)
+                        (format #f "~a_FILE" field-name) field-name) val))
+(define-maybe string-or-file-path)
+
+(define (serialize-list field-name val)
+  (string-append (string-join val "\n") "\n"))
+(define-maybe list)
+
+(define (serialize-boolean field-name val)
+  (if val (serialize-string field-name "1") (serialize-string field-name "0")))
+
+(define-configuration/no-serialization miniflux-configuration
+  (listen-address
+   (string "127.0.0.1:8080")
+   "Address to listen on.
+Use absolute path like @code{\"/var/run/miniflux/miniflux.sock\"} for a Unix 
socket.")
+  (base-url
+   (string "http://127.0.0.1/";)
+   "Base URL to generate HTML links and base path for cookies.")
+  (create-administrator-account?
+   (boolean #f)
+   "Create an initial administrator account.")
+  (administrator-account-name
+   maybe-string-or-file-path
+   "Initial administrator account name as a string or an absolute path to a 
file with a account name inside.")
+  (administrator-account-password
+   maybe-string-or-file-path
+   "Initial administrator account password as a string or an absolute path to 
a file with a password inside.")
+  (run-migrations?
+   (boolean #t)
+   "Run database migrations during application startup.")
+  (database-url
+   (string "host=/var/run/postgresql")
+   "PostgreSQL connection string.")
+  (user
+   (string "miniflux")
+   "User name for Postgresql and system account.")
+  (group
+   (string "miniflux")
+   "Group for the system account.")
+  (log-file
+   (string "/var/log/miniflux.log")
+   "Path to the log file.")
+  (extra-settings
+   maybe-list
+   "Extra configuration parameters as a list of strings."))
+
+(define (miniflux-serialize-configuration config)
+  (match-record config <miniflux-configuration>
+                (listen-address base-url create-administrator-account?
+                                administrator-account-name 
administrator-account-password
+                                run-migrations? database-url extra-settings)
+    (string-append (serialize-string "LISTEN_ADDR" listen-address)
+                   (serialize-string "BASE_URL" base-url)
+                   (serialize-boolean "CREATE_ADMIN" 
create-administrator-account?)
+                   (serialize-maybe-string-or-file-path "ADMIN_USERNAME" 
administrator-account-name)
+                   (serialize-maybe-string-or-file-path "ADMIN_PASSWORD" 
administrator-account-password)
+                   (serialize-boolean "RUN_MIGRATIONS" run-migrations?)
+                   (serialize-string "DATABASE_URL" database-url)
+                   (serialize-maybe-list #f extra-settings))))
+
+(define (miniflux-configuration-file config)
+  (mixed-text-file "miniflux.conf" (miniflux-serialize-configuration config)))
+
+(define (pair->file-system-mapping pair previous)
+  (if (pair? pair)
+      (let ((path (car pair))
+            (writable (cdr pair)))
+        (if (or (and (string? path)
+                     (absolute-file-name? path))
+                (computed-file? path))
+            (append previous (list (file-system-mapping
+                                     (source path)
+                                     (target source)
+                                     (writable? writable))))
+            previous))
+      previous))
+
+(define (miniflux-shepherd-service config)
+  (match-record config <miniflux-configuration>
+                (user group log-file database-url listen-address
+                      administrator-account-name 
administrator-account-password)
+    (let ((config-file (miniflux-configuration-file config)))
+      (list (shepherd-service
+             (documentation "Run Miniflux server")
+             (provision '(miniflux))
+             (requirement '(postgres networking))
+             (start #~(make-forkexec-constructor
+                       (list #$(least-authority-wrapper
+                                 (file-append miniflux "/bin/miniflux")
+                                 #:name "miniflux"
+                                 #:user user
+                                 #:group group
+                                 #:preserved-environment-variables
+                                 (append 
%default-preserved-environment-variables
+                                         '("SSL_CERT_FILE"))
+                                 #:mappings
+                                 (fold pair->file-system-mapping
+                                       '()
+                                       `((,log-file . #t)
+                                         (,config-file . #f)
+                                         ("/etc/ssl/certs/ca-certificates.crt" 
. #f)
+                                         (,administrator-account-name . #f)
+                                         (,administrator-account-password . #f)
+                                         (,(dirname listen-address)  . #t)
+                                         ,(let* ((db-socket-match 
(string-match ".*host=(/[^ ]*).*" database-url))
+                                                 (db-socket (if 
db-socket-match (match:substring db-socket-match 1) #f)))
+                                            (if db-socket
+                                                `(,db-socket . #t)))))
+                                 #:namespaces
+                                 (fold delq %namespaces '(net user)))
+                              "-config-file"
+                              #$config-file)
+                       #:log-file #$log-file))
+              (stop #~(make-kill-destructor)))))))
+
+(define (miniflux-accounts config)
+  (match-record config <miniflux-configuration>
+                (user group)
+    `(,(user-group
+         (name group)
+         (system? #t))
+      ,(user-account
+         (name user)
+         (group group)
+         (system? #t)
+         (comment "miniflux server user")
+         (home-directory "/var/empty")
+         (shell (file-append shadow "/sbin/nologin"))))))
+
+(define (miniflux-postgresql-role config)
+  (list (postgresql-role
+          (name (miniflux-configuration-user config))
+          (create-database? #t))))
+
+(define (miniflux-log-files config)
+  (list (miniflux-configuration-log-file config)))
+
+(define (miniflux-activation-service-type config)
+  (match-record config <miniflux-configuration>
+                (user listen-address)
+    #~(begin
+        (use-modules (gnu build activation))
+        (let ((user (getpwnam #$user)))
+          (if (absolute-file-name? #$listen-address)
+              (mkdir-p/perms (dirname #$listen-address) user #o755))))))
+
+(define miniflux-service-type
+  (service-type
+    (name 'miniflux)
+    (default-value (miniflux-configuration))
+    (extensions
+     (list (service-extension account-service-type
+                             miniflux-accounts)
+          (service-extension postgresql-role-service-type
+                             miniflux-postgresql-role)
+          (service-extension shepherd-root-service-type
+                             miniflux-shepherd-service)
+           (service-extension log-rotation-service-type
+                              miniflux-log-files)
+           (service-extension activation-service-type
+                              miniflux-activation-service-type)))
+    (description "Run Miniflux, minimalist feed reader")))
diff --git a/gnu/tests/web.scm b/gnu/tests/web.scm
index 431996ede4..419b5f0b5b 100644
--- a/gnu/tests/web.scm
+++ b/gnu/tests/web.scm
@@ -5,6 +5,7 @@
 ;;; Copyright © 2018 Pierre-Antoine Rouby <pierre-antoine.ro...@inria.fr>
 ;;; Copyright © 2018 Marius Bakke <mba...@fastmail.com>
 ;;; Copyright © 2024 Maxim Cournoyer <ma...@guixotic.coop>
+;;; Copyright © 2025 Rodion Goritskov <rod...@goritskov.com>
 ;;;
 ;;; This file is part of GNU Guix.
 ;;;
@@ -37,6 +38,7 @@
   #:use-module (gnu packages base)
   #:use-module (gnu packages databases)
   #:use-module (gnu packages guile-xyz)
+  #:use-module (gnu packages gnupg)
   #:use-module (gnu packages patchutils)
   #:use-module (gnu packages python)
   #:use-module (gnu packages tls)
@@ -56,7 +58,10 @@
             %test-hpcguix-web
             %test-anonip
             %test-patchwork
-            %test-agate))
+            %test-agate
+            %test-miniflux-admin-string
+            %test-miniflux-admin-file
+            %test-miniflux-socket))
 
 (define %index.html-contents
   ;; Contents of the /index.html file.
@@ -848,3 +853,190 @@ HTTP-PORT."
    (name "agate")
    (description "Connect to a running Agate service.")
    (value (run-agate-test name %agate-os %index.gmi-contents))))
+
+
+;;;
+;;; Miniflux
+;;;
+
+(define %miniflux-create-admin-credentials
+  #~(begin
+      (mkdir "/var/miniflux")
+      (call-with-output-file "/var/miniflux/admin-username"
+        (lambda (port)
+          (display "test" port)))
+      (call-with-output-file "/var/miniflux/admin-password"
+        (lambda (port)
+          (display "testpassword" port)))))
+
+(define miniflux-base-system
+  (lambda (miniflux-config)
+    (simple-operating-system
+     (simple-service 'create-admin-credentials
+                     activation-service-type
+                     %miniflux-create-admin-credentials)
+     (service dhcpcd-service-type)
+     (service postgresql-service-type
+             (postgresql-configuration
+               (postgresql postgresql-13)))
+     (service miniflux-service-type
+              miniflux-config))))
+
+(define %miniflux-with-admin-as-string
+  (miniflux-base-system
+   (miniflux-configuration
+    (listen-address "0.0.0.0:8080")
+    (create-administrator-account? #t)
+    (administrator-account-name "test")
+    (administrator-account-password "testpassword"))))
+
+(define %miniflux-with-admin-as-file
+  (miniflux-base-system
+   (miniflux-configuration
+    (listen-address "0.0.0.0:8080")
+    (create-administrator-account? #t)
+    (administrator-account-name "/var/miniflux/admin-username")
+    (administrator-account-password "/var/miniflux/admin-password"))))
+
+(define %miniflux-with-socket
+  (miniflux-base-system
+   (miniflux-configuration
+    (listen-address "/var/run/miniflux/miniflux.sock"))))
+
+(define* (run-miniflux-test name test-os)
+  (define os
+    (marionette-operating-system
+     test-os
+     #:imported-modules '((gnu services herd)
+                          (guix combinators))))
+
+  (define forwarded-port 8080)
+
+  (define vm
+    (virtual-machine
+      (operating-system os)
+      (memory-size 512)
+      (port-forwardings `((8080 . ,forwarded-port)))))
+
+  (define test
+    (with-extensions (list guile-gcrypt)
+      (with-imported-modules '((gnu build marionette))
+        #~(begin
+            (use-modules (srfi srfi-64)
+                        (srfi srfi-11)
+                         (gnu build marionette)
+                        (web client)
+                        (web uri)
+                         (web response)
+                        (ice-9 match)
+                         (ice-9 iconv)
+                         (gcrypt base64))
+
+            (define marionette
+              (make-marionette (list #$vm)))
+
+            (test-runner-current (system-test-runner #$output))
+            (test-begin #$name)
+
+           (test-assert "Check Miniflux service is running"
+             (begin
+               (#$retry-on-error
+                (lambda ()
+                  (marionette-eval
+                   '(begin
+                      (use-modules (gnu services herd))
+                      (match (start-service '#$(string->symbol "miniflux"))
+                        (#f #f)
+                        (('service response-parts ...)
+                         (match (assq-ref response-parts 'running)
+                           (#f #f)
+                           ((running) #t)))))
+                   marionette))
+                #:delay 1
+                #:times 10)))
+
+            (test-assert "Miniflux TCP port ready, IPv4"
+              (wait-for-tcp-port #$forwarded-port marionette))
+
+           (test-assert "Miniflux login page is opened"
+              (begin
+                (wait-for-tcp-port #$forwarded-port marionette)
+                (#$retry-on-error
+                 (lambda ()
+                   (let-values (((_ text)
+                                 (http-get
+                                 #$(format #f "http://localhost:~A/"; 
forwarded-port)
+                                 #:decode-body? #t)))
+                     (string-contains text "<title>Sign In - 
Miniflux</title>")))
+                 #:times 10
+                 #:delay 2)))
+
+            (define authorization-header
+              (let ((encoded (base64-encode (string->bytevector 
"test:testpassword" "utf-8"))))
+                `(authorization . (basic . ,encoded))))
+
+            (test-equal "Miniflux initial admin API call is successful"
+              200
+              (begin
+                (wait-for-tcp-port #$forwarded-port marionette)
+                (#$retry-on-error
+                 (lambda ()
+                   (let-values (((response _)
+                                 (http-get #$(format #f 
"http://localhost:~A/v1/me"; forwarded-port)
+                                           #:headers (list 
authorization-header)
+                                           #:decode-body? #t)))
+
+                     (response-code response)))
+                 #:times 10
+                 #:delay 2)))
+
+            (test-end)))))
+  (gexp->derivation (string-append name "-test") test))
+
+(define* (run-miniflux-socket-test name test-os)
+  (define os
+    (marionette-operating-system
+     test-os
+     #:imported-modules '((gnu services herd)
+                          (guix combinators))))
+
+  (define vm
+    (virtual-machine
+      (operating-system os)
+      (memory-size 512)))
+
+  (define test
+    (with-imported-modules '((gnu build marionette))
+      #~(begin
+          (use-modules (srfi srfi-64)
+                       (gnu build marionette))
+
+          (define marionette
+            (make-marionette (list #$vm)))
+
+          (test-runner-current (system-test-runner #$output))
+          (test-begin #$name)
+
+          (test-assert "Check socket file is created"
+            (wait-for-unix-socket "/var/run/miniflux/miniflux.sock" 
marionette))
+
+          (test-end))))
+  (gexp->derivation (string-append name "-test") test))
+
+(define %test-miniflux-admin-string
+  (system-test
+   (name "miniflux-admin-string")
+   (description "Run Miniflux with initial admin credentials as string.")
+   (value (run-miniflux-test name %miniflux-with-admin-as-string))))
+
+(define %test-miniflux-admin-file
+  (system-test
+   (name "miniflux-admin-file")
+   (description "Run Miniflux with initial admin credentials as file.")
+   (value (run-miniflux-test name %miniflux-with-admin-as-file))))
+
+(define %test-miniflux-socket
+  (system-test
+   (name "miniflux-socket")
+   (description "Run Miniflux on unix socket.")
+   (value (run-miniflux-socket-test name %miniflux-with-socket))))

Reply via email to