Add a task to check for updates periodically

It must be enabled and configured by the admin.

Refs: #990
This commit is contained in:
eikek 2021-08-19 22:24:35 +02:00
parent 90421599ea
commit 5d33b3841a
10 changed files with 459 additions and 4 deletions

View File

@ -21,6 +21,9 @@ import scodec.bits.ByteVector
object Implicits {
implicit val accountIdReader: ConfigReader[AccountId] =
ConfigReader[String].emap(reason(AccountId.parse))
implicit val pathReader: ConfigReader[Path] =
ConfigReader[JPath].map(Path.fromNioPath)

View File

@ -180,6 +180,62 @@ docspell.joex {
}
}
# A periodic task to check for new releases of docspell. It can
# inform about a new release via e-mail. You need to specify an
# account that has SMTP settings to use for sending.
update-check {
# Whether to enable this task
enabled = false
# Sends the mail without checking the latest release. Can be used
# if you want to see if mail sending works, but don't want to wait
# until a new release is published.
test-run = false
# When the update check should execute. Default is to run every
# week.
schedule = "Sun *-*-* 00:00:00"
# An account id in form of `collective/user` (or just `user` if
# collective and user name are the same). This user account must
# have at least one valid SMTP settings which are used to send the
# mail.
sender-account = ""
# The SMTP connection id that should be used for sending the mail.
smtp-id = ""
# A list of recipient e-mail addresses.
# Example: `[ "john.doe@gmail.com" ]`
recipients = []
# The subject of the mail. It supports the same variables as the
# body.
subject = "Docspell {{ latestVersion }} is available"
# The body of the mail. Subject and body can contain these
# variables which are replaced:
#
# - `latestVersion` the latest available version of Docspell
# - `currentVersion` the currently running (old) version of Docspell
# - `releasedAt` a date when the release was published
#
# The body is processed as markdown after the variables have been
# replaced.
body = """
Hello,
You are currently running Docspell {{ currentVersion }}. Version *{{ latestVersion }}*
is now available, which was released on {{ releasedAt }}. Check the release page at:
<https://github.com/eikek/docspell/releases/latest>
Have a nice day!
Docpell Update Check
"""
}
# Configuration of text extraction
extraction {
# For PDF files it is first tried to read the text parts of the

View File

@ -19,6 +19,7 @@ import docspell.ftssolr.SolrConfig
import docspell.joex.analysis.RegexNerFile
import docspell.joex.hk.HouseKeepingConfig
import docspell.joex.scheduler.{PeriodicSchedulerConfig, SchedulerConfig}
import docspell.joex.updatecheck.UpdateCheckConfig
import docspell.store.JdbcConfig
case class Config(
@ -36,7 +37,8 @@ case class Config(
sendMail: MailSendConfig,
files: Files,
mailDebug: Boolean,
fullTextSearch: Config.FullTextSearch
fullTextSearch: Config.FullTextSearch,
updateCheck: UpdateCheckConfig
)
object Config {

View File

@ -6,21 +6,56 @@
package docspell.joex
import cats.data.Validated
import cats.data.ValidatedNec
import cats.implicits._
import docspell.common.config.Implicits._
import docspell.joex.scheduler.CountingScheme
import emil.MailAddress
import emil.javamail.syntax._
import pureconfig._
import pureconfig.generic.auto._
import yamusca.imports._
object ConfigFile {
import Implicits._
def loadConfig: Config =
ConfigSource.default.at("docspell.joex").loadOrThrow[Config]
validOrThrow(ConfigSource.default.at("docspell.joex").loadOrThrow[Config])
private def validOrThrow(cfg: Config): Config =
validate(cfg).fold(err => sys.error(err.toList.mkString("- ", "\n", "")), identity)
object Implicits {
implicit val countingSchemeReader: ConfigReader[CountingScheme] =
ConfigReader[String].emap(reason(CountingScheme.readString))
implicit val templateReader: ConfigReader[Template] =
ConfigReader[String].emap(reason(str => mustache.parse(str.trim).left.map(_._2)))
implicit val mailAddressReader: ConfigReader[MailAddress] =
ConfigReader[String].emap(reason(MailAddress.parse))
}
def validate(cfg: Config): ValidatedNec[String, Config] =
List(
failWhen(
cfg.updateCheck.enabled && cfg.updateCheck.recipients.isEmpty,
"No recipients given for enabled update check!"
),
failWhen(
cfg.updateCheck.enabled && cfg.updateCheck.smtpId.isEmpty,
"No recipients given for enabled update check!"
),
failWhen(
cfg.updateCheck.enabled && cfg.updateCheck.subject.els.isEmpty,
"No subject given for enabled update check!"
)
).reduce(_ |+| _).map(_ => cfg)
def failWhen(cond: Boolean, msg: => String): ValidatedNec[String, Unit] =
Validated.condNec(!cond, (), msg)
}

View File

@ -32,16 +32,17 @@ import docspell.joex.process.ItemHandler
import docspell.joex.process.ReProcessItem
import docspell.joex.scanmailbox._
import docspell.joex.scheduler._
import docspell.joex.updatecheck._
import docspell.joexapi.client.JoexClient
import docspell.store.Store
import docspell.store.queue._
import docspell.store.records.{REmptyTrashSetting, RJobLog}
import docspell.store.usertask.UserTaskScope
import docspell.store.usertask.UserTaskStore
import emil.javamail._
import org.http4s.blaze.client.BlazeClientBuilder
import org.http4s.client.Client
import docspell.store.usertask.UserTaskStore
import docspell.store.usertask.UserTaskScope
final class JoexAppImpl[F[_]: Async](
cfg: Config,
@ -81,6 +82,9 @@ final class JoexAppImpl[F[_]: Async](
.periodicTask[F](cfg.houseKeeping.schedule)
.flatMap(pstore.insert) *>
scheduleEmptyTrashTasks *>
UpdateCheckTask
.periodicTask(cfg.updateCheck)
.flatMap(pstore.insert) *>
MigrationTask.job.flatMap(queue.insertIfNew) *>
AllPreviewsTask
.job(MakePreviewArgs.StoreMode.WhenMissing, None)
@ -130,6 +134,7 @@ object JoexAppImpl {
itemSearchOps <- OItemSearch(store)
analyser <- TextAnalyser.create[F](cfg.textAnalysis.textAnalysisConfig)
regexNer <- RegexNerFile(cfg.textAnalysis.regexNerFileConfig, store)
updateCheck <- UpdateCheck.resource(httpClient)
javaEmil =
JavaMailEmil(Settings.defaultSettings.copy(debug = cfg.mailDebug))
sch <- SchedulerBuilder(cfg.scheduler, store)
@ -239,6 +244,13 @@ object JoexAppImpl {
EmptyTrashTask.onCancel[F]
)
)
.withTask(
JobTask.json(
UpdateCheckTask.taskName,
UpdateCheckTask[F](cfg.updateCheck, cfg.sendMail, javaEmil, updateCheck),
UpdateCheckTask.onCancel[F]
)
)
.resource
psch <- PeriodicScheduler.create(
cfg.periodicScheduler,

View File

@ -0,0 +1,58 @@
/*
* Copyright 2020 Docspell Contributors
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package docspell.joex.updatecheck
import cats.effect._
import docspell.common.MailSendConfig
import docspell.joex.mail.EmilHeader
import docspell.store.records.RUserEmail
import emil._
import emil.builder._
import emil.markdown._
import yamusca.implicits._
import yamusca.imports._
object MakeMail {
def apply[F[_]: Sync](
sendCfg: MailSendConfig,
cfg: UpdateCheckConfig,
smtpCfg: RUserEmail,
latestRelease: UpdateCheck.Release
): Mail[F] = {
val templateCtx = TemplateCtx(latestRelease)
val md = templateCtx.render(cfg.body)
val subj = templateCtx.render(cfg.subject)
MailBuilder.build(
From(smtpCfg.mailFrom),
Tos(cfg.recipients),
XMailer.emil,
Subject(subj),
EmilHeader.listId(sendCfg.listId),
MarkdownBody[F](md).withConfig(
MarkdownConfig("body { font-size: 10pt; font-family: sans-serif; }")
)
)
}
final case class TemplateCtx(
currentVersion: String,
latestVersion: String,
releasedAt: String
)
object TemplateCtx {
def apply(release: UpdateCheck.Release): TemplateCtx =
TemplateCtx(UpdateCheck.currentVersion, release.version, release.published_at)
implicit val yamuscaConverter: ValueConverter[TemplateCtx] =
ValueConverter.deriveConverter[TemplateCtx]
}
}

View File

@ -0,0 +1,64 @@
/*
* Copyright 2020 Docspell Contributors
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package docspell.joex.updatecheck
import cats.effect._
import docspell.joex.BuildInfo
import io.circe.Decoder
import io.circe.generic.semiauto._
import org.http4s.circe.CirceEntityDecoder._
import org.http4s.client.Client
import org.http4s.implicits._
trait UpdateCheck[F[_]] {
def latestRelease: F[UpdateCheck.Release]
}
object UpdateCheck {
val currentVersion: String =
BuildInfo.version
final case class Release(
html_url: String,
id: Int,
tag_name: String,
name: String,
created_at: String,
published_at: String
) {
def version: String = tag_name
def isCurrent: Boolean = {
val version = BuildInfo.version
version.endsWith("SNAPSHOT") || version == tag_name
}
}
object Release {
implicit val jsonDecoder: Decoder[Release] =
deriveDecoder[Release]
}
def apply[F[_]: Async](client: Client[F]): UpdateCheck[F] =
new UpdateCheck[F] {
def latestRelease: F[UpdateCheck.Release] =
client.expect[Release](latestReleaseUrl)
}
def resource[F[_]: Async](client: Client[F]): Resource[F, UpdateCheck[F]] =
Resource.pure(UpdateCheck[F](client))
private[this] val latestReleaseUrl =
uri"https://api.github.com/repos/eikek/docspell/releases/latest"
}

View File

@ -0,0 +1,24 @@
/*
* Copyright 2020 Docspell Contributors
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package docspell.joex.updatecheck
import docspell.common._
import com.github.eikek.calev.CalEvent
import emil.MailAddress
import yamusca.data.Template
final case class UpdateCheckConfig(
enabled: Boolean,
testRun: Boolean,
schedule: CalEvent,
senderAccount: AccountId,
smtpId: Ident,
recipients: List[MailAddress],
subject: Template,
body: Template
)

View File

@ -0,0 +1,98 @@
/*
* Copyright 2020 Docspell Contributors
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package docspell.joex.updatecheck
import cats.data.OptionT
import cats.effect._
import cats.implicits._
import docspell.common._
import docspell.joex.scheduler.Context
import docspell.joex.scheduler.Task
import docspell.store.records.RPeriodicTask
import docspell.store.records.RUserEmail
import docspell.store.usertask.UserTask
import docspell.store.usertask.UserTaskScope
import emil._
object UpdateCheckTask {
val taskName: Ident = Ident.unsafe("new-release-check")
type Args = Unit
def onCancel[F[_]]: Task[F, Args, Unit] =
Task.log(_.warn("Cancelling update-check task"))
def periodicTask[F[_]: Sync](cfg: UpdateCheckConfig): F[RPeriodicTask] =
UserTask(
Ident.unsafe("docspell-update-check"),
taskName,
cfg.enabled,
cfg.schedule,
None,
()
).encode.toPeriodicTask(
UserTaskScope(cfg.senderAccount.collective),
"Docspell Update Check".some
)
def apply[F[_]: Async](
cfg: UpdateCheckConfig,
sendCfg: MailSendConfig,
emil: Emil[F],
updateCheck: UpdateCheck[F]
): Task[F, Args, Unit] =
if (cfg.enabled)
Task { ctx =>
for {
_ <- ctx.logger.info(
s"Check for updates. Current version is: ${UpdateCheck.currentVersion}"
)
_ <- ctx.logger.debug(
s"Get SMTP connection for ${cfg.senderAccount.asString} and ${cfg.smtpId}"
)
smtpCfg <- findConnection(ctx, cfg)
_ <- ctx.logger.debug("Checking for latest release at GitHub")
latest <- updateCheck.latestRelease
_ <- ctx.logger.debug(s"Got latest release: $latest.")
_ <-
if (cfg.testRun)
ctx.logger.info(
s"This is a test-run as configured. A mail will always be sent!"
)
else ().pure[F]
_ <-
if (latest.isCurrent && !cfg.testRun)
ctx.logger.info(
s"Latest release is ${latest.version}, which is the current one. Everything uptodate."
)
else
ctx.logger.info(
s"Sending mail about new release: ${latest.tag_name}"
) *> emil(smtpCfg.toMailConfig).send(
MakeMail(sendCfg, cfg, smtpCfg, latest)
)
} yield ()
}
else
Task.pure(())
def findConnection[F[_]: Sync](
ctx: Context[F, _],
cfg: UpdateCheckConfig
): F[RUserEmail] =
OptionT(ctx.store.transact(RUserEmail.getByName(cfg.senderAccount, cfg.smtpId)))
.getOrElseF(
Sync[F].raiseError(
new Exception(
s"No smtp connection found for user ${cfg.senderAccount.asString} and connection '${cfg.smtpId.id}'!"
)
)
)
}

View File

@ -63,6 +63,27 @@ let
min-not-found = 2;
};
};
update-check = {
enabled = false;
test-run = false;
schedule = "Sun *-*-* 00:00:00";
sender-account = "";
smtp-id = "";
recipients = [];
subject = "Docspell {{ latestVersion }} is available";
body = ''
Hello,
You are currently running Docspell {{ currentVersion }}. Version *{{ latestVersion }}*
is now available, which was released on {{ releasedAt }}. Check the release page at:
<https://github.com/eikek/docspell/releases/latest>
Have a nice day!
Docpell Update Check
'';
};
extraction = {
pdf = {
min-text-len = 500;
@ -571,6 +592,88 @@ in {
'';
};
update-check = mkOption {
type = types.submodule({
options = {
enabled = mkOption {
type = types.bool;
default = defaults.update-check.enabled;
description = "Whether this task is enabled.";
};
test-run = mkOption {
type = types.bool;
default = defaults.update-check.test-run;
description = ''
Sends the mail without checking the latest release. Can be used
if you want to see if mail sending works, but don't want to wait
until a new release is published.
'';
};
schedule = mkOption {
type = types.str;
default = defaults.update-check.schedule;
description = ''
When the check-update task should execute. Default is to run every
week.
'';
};
sender-account = mkOption {
type = types.str;
default = defaults.update-check.sender-account;
description = ''
An account id in form of `collective/user` (or just `user` if
collective and user name are the same). This user account must
have at least one valid SMTP settings which are used to send the
mail.
'';
};
smtp-id = mkOption {
type = types.str;
default = defaults.update-check.smtp-id;
description = ''
The SMTP connection id that should be used for sending the mail.
'';
};
recipients = mkOption {
type = types.listOf types.str;
default = defaults.update-check.recipients;
example = [ "josh.doe@gmail.com" ];
description = ''
A list of recipient e-mail addresses.
'';
};
subject = mkOption {
type = types.str;
default = defaults.update-check.subject;
description = ''
The subject of the mail. It supports the same variables as the body.
'';
};
body = mkOption {
type = types.str;
default = defaults.update-check.body;
description = ''
The body of the mail. Subject and body can contain these
variables which are replaced:
- `latestVersion` the latest available version of Docspell
- `currentVersion` the currently running (old) version of Docspell
- `releasedAt` a date when the release was published
The body is processed as markdown after the variables have been
replaced.
'';
};
};
});
default = defaults.update-check;
description = ''
A periodic task to check for new releases of docspell. It can
inform about a new release via e-mail. You need to specify an
account that has SMTP settings to use for sending.
'';
};
extraction = mkOption {
type = types.submodule({
options = {