Initial version.

Features:

- Upload PDF files let them analyze

- Manage meta data and items

- See processing in webapp
This commit is contained in:
Eike Kettner
2019-07-23 00:53:30 +02:00
parent 6154e6a387
commit 831cd8b655
341 changed files with 23634 additions and 484 deletions

View File

@ -1,3 +1,87 @@
docspell.restserver {
docspell.server {
# This is shown in the top right corner of the web application
app-name = "Docspell"
# This is the id of this node. If you run more than one server, you
# have to make sure to provide unique ids per node.
app-id = "rest1"
# This is the base URL this application is deployed to. This is used
# to create absolute URLs and to configure the cookie.
base-url = "http://localhost:7880"
# Where the server binds to.
bind {
address = "localhost"
port = 7880
}
# Authentication.
auth {
# The secret for this server that is used to sign the authenicator
# tokens. If multiple servers are running, all must share the same
# secret. You can use base64 or hex strings (prefix with b64: and
# hex:, respectively)
server-secret = "hex:caffee"
# How long an authentication token is valid. The web application
# will get a new one periodically.
session-valid = "5 minutes"
}
# Configuration for the backend.
backend {
# The database connection.
#
# By default a H2 file-based database is configured. You can
# provide a postgresql or mariadb connection here. When using H2
# use the PostgreSQL compatibility mode and AUTO_SERVER feature.
jdbc {
url = "jdbc:h2://"${java.io.tmpdir}"/docspell-demo.db;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;AUTO_SERVER=TRUE"
user = "sa"
password = ""
}
# Configuration for registering new users.
signup {
# The mode defines if new users can signup or not. It can have
# three values:
#
# - open: every new user can sign up
# - invite: new users can sign up only if they provide a correct
# invitation key. Invitation keys can be generated by the
# server.
# - closed: signing up is disabled.
mode = "open"
# If mode == 'invite', a password must be provided to generate
# invitation keys. It must not be empty.
new-invite-password = ""
# If mode == 'invite', this is the period an invitation token is
# considered valid.
invite-time = "3 days"
}
files {
# Defines the chunk size used to store bytes. This will affect
# the memory footprint when uploading and downloading files. At
# most this amount is loaded into RAM for down- and uploading.
#
# It also defines the chunk size used for the blobs inside the
# database.
chunk-size = 524288
# The file content types that are considered valid. Docspell
# will only pass these files to processing. The processing code
# itself has also checks for which files are supported and which
# not. This affects the uploading part and is a first check to
# avoid that 'bad' files get into the system.
valid-mime-types = [ "application/pdf" ]
}
}
}

View File

@ -1,19 +1,33 @@
package docspell.restserver
import docspell.backend.auth.Login
import docspell.backend.signup.{Config => SignupConfig}
import docspell.store.JdbcConfig
import docspell.backend.{Config => BackendConfig}
import docspell.common._
import scodec.bits.ByteVector
case class Config(appName: String
, baseUrl: String
, appId: Ident
, baseUrl: LenientUri
, bind: Config.Bind
, jdbc: JdbcConfig
, backend: BackendConfig
, auth: Login.Config
)
object Config {
val postgres = JdbcConfig(LenientUri.unsafe("jdbc:postgresql://localhost:5432/docspelldev"), "dev", "dev")
val h2 = JdbcConfig(LenientUri.unsafe("jdbc:h2:./target/docspelldev.db;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE"), "sa", "")
val default: Config =
Config("Docspell", "http://localhost:7880", Config.Bind("localhost", 7880), JdbcConfig("", "", ""))
Config("Docspell"
, Ident.unsafe("restserver1")
, LenientUri.unsafe("http://localhost:7880")
, Config.Bind("localhost", 7880)
, BackendConfig(postgres
, SignupConfig(SignupConfig.invite, Password("testpass"), Duration.hours(5 * 24))
, BackendConfig.Files(512 * 1024, List(MimeType.pdf)))
, Login.Config(ByteVector.fromValidHex("caffee"), Duration.minutes(2)))
case class Bind(address: String, port: Int)
}

View File

@ -0,0 +1,18 @@
package docspell.restserver
import docspell.common.pureconfig.Implicits._
import docspell.backend.signup.{Config => SignupConfig}
import _root_.pureconfig._
import _root_.pureconfig.generic.auto._
object ConfigFile {
import Implicits._
def loadConfig: Config =
ConfigSource.default.at("docspell.server").loadOrThrow[Config]
object Implicits {
implicit val signupModeReader: ConfigReader[SignupConfig.Mode] =
ConfigReader[String].emap(reason(SignupConfig.Mode.fromString))
}
}

View File

@ -2,17 +2,24 @@ package docspell.restserver
import cats.effect._
import cats.implicits._
import scala.concurrent.ExecutionContext
import java.util.concurrent.Executors
import java.nio.file.{Files, Paths}
import docspell.common.{Banner, ThreadFactories}
import org.log4s._
object Main extends IOApp {
private[this] val logger = getLogger
val blockingEc: ExecutionContext = ExecutionContext.fromExecutor(Executors.newCachedThreadPool)
val blockingEc: ExecutionContext = ExecutionContext.fromExecutor(Executors.newCachedThreadPool(
ThreadFactories.ofName("docspell-restserver-blocking")))
val blocker = Blocker.liftExecutionContext(blockingEc)
val connectEC: ExecutionContext = ExecutionContext.fromExecutorService(Executors.newFixedThreadPool(5,
ThreadFactories.ofName("docspell-dbconnect")))
def run(args: List[String]) = {
args match {
case file :: Nil =>
@ -33,7 +40,14 @@ object Main extends IOApp {
}
}
val cfg = Config.default
RestServer.stream[IO](cfg, blocker).compile.drain.as(ExitCode.Success)
val cfg = ConfigFile.loadConfig
val banner = Banner("REST Server"
, BuildInfo.version
, BuildInfo.gitHeadCommit
, cfg.backend.jdbc.url
, Option(System.getProperty("config.file"))
, cfg.appId, cfg.baseUrl)
logger.info(s"\n${banner.render("***>")}")
RestServer.stream[IO](cfg, connectEC, blockingEc, blocker).compile.drain.as(ExitCode.Success)
}
}

View File

@ -1,6 +1,12 @@
package docspell.restserver
import docspell.backend.BackendApp
trait RestApp[F[_]] {
def init: F[Unit]
def config: Config
def backend: BackendApp[F]
}

View File

@ -1,16 +1,28 @@
package docspell.restserver
import cats.implicits._
import cats.effect._
import docspell.backend.BackendApp
import docspell.common.NodeType
final class RestAppImpl[F[_]: Sync](cfg: Config) extends RestApp[F] {
import scala.concurrent.ExecutionContext
final class RestAppImpl[F[_]: Sync](val config: Config, val backend: BackendApp[F]) extends RestApp[F] {
def init: F[Unit] =
Sync[F].pure(())
backend.node.register(config.appId, NodeType.Restserver, config.baseUrl)
def shutdown: F[Unit] =
backend.node.unregister(config.appId)
}
object RestAppImpl {
def create[F[_]: Sync](cfg: Config): Resource[F, RestApp[F]] =
Resource.liftF(Sync[F].pure(new RestAppImpl(cfg)))
def create[F[_]: ConcurrentEffect: ContextShift](cfg: Config, connectEC: ExecutionContext, httpClientEc: ExecutionContext, blocker: Blocker): Resource[F, RestApp[F]] =
for {
backend <- BackendApp(cfg.backend, connectEC, httpClientEc, blocker)
app = new RestAppImpl[F](cfg, backend)
appR <- Resource.make(app.init.map(_ => app))(_.shutdown)
} yield appR
}

View File

@ -1,42 +1,69 @@
package docspell.restserver
import cats.effect._
import docspell.backend.auth.AuthToken
import org.http4s.server.blaze.BlazeServerBuilder
import org.http4s.implicits._
import fs2.Stream
import org.http4s.server.middleware.Logger
import org.http4s.server.Router
import docspell.restserver.webapp._
import docspell.restserver.routes._
import org.http4s.HttpRoutes
import scala.concurrent.ExecutionContext
object RestServer {
def stream[F[_]: ConcurrentEffect](cfg: Config, blocker: Blocker)
def stream[F[_]: ConcurrentEffect](cfg: Config, connectEC: ExecutionContext, httpClientEc: ExecutionContext, blocker: Blocker)
(implicit T: Timer[F], CS: ContextShift[F]): Stream[F, Nothing] = {
val app = for {
restApp <- RestAppImpl.create[F](cfg)
_ <- Resource.liftF(restApp.init)
restApp <- RestAppImpl.create[F](cfg, connectEC, httpClientEc, blocker)
httpApp = Router(
"/api/info" -> InfoRoutes(cfg),
"/api/info" -> routes.InfoRoutes(cfg),
"/api/v1/open/" -> openRoutes(cfg, restApp),
"/api/v1/sec/" -> Authenticate(restApp.backend.login, cfg.auth) {
token => securedRoutes(cfg, restApp, token)
},
"/app/assets" -> WebjarRoutes.appRoutes[F](blocker, cfg),
"/app" -> TemplateRoutes[F](blocker, cfg)
).orNotFound
// With Middlewares in place
finalHttpApp = Logger.httpApp(false, false)(httpApp)
finalHttpApp = Logger.httpApp(logHeaders = false, logBody = false)(httpApp)
} yield finalHttpApp
Stream.resource(app).flatMap(httpApp =>
BlazeServerBuilder[F]
.bindHttp(cfg.bind.port, cfg.bind.address)
.withHttpApp(httpApp)
.serve
BlazeServerBuilder[F].
bindHttp(cfg.bind.port, cfg.bind.address).
withHttpApp(httpApp).
withoutBanner.
serve)
}.drain
def securedRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F], token: AuthToken): HttpRoutes[F] =
Router(
"auth" -> LoginRoutes.session(restApp.backend.login, cfg),
"tag" -> TagRoutes(restApp.backend, cfg, token),
"equipment" -> EquipmentRoutes(restApp.backend, cfg, token),
"organization" -> OrganizationRoutes(restApp.backend, cfg, token),
"person" -> PersonRoutes(restApp.backend, cfg, token),
"source" -> SourceRoutes(restApp.backend, cfg, token),
"user" -> UserRoutes(restApp.backend, cfg, token),
"collective" -> CollectiveRoutes(restApp.backend, cfg, token),
"queue" -> JobQueueRoutes(restApp.backend, cfg, token),
"item" -> ItemRoutes(restApp.backend, cfg, token),
"attachment" -> AttachmentRoutes(restApp.backend, cfg, token),
"upload" -> UploadRoutes.secured(restApp.backend, cfg, token)
)
}.drain
def openRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] =
Router(
"auth" -> LoginRoutes.login(restApp.backend.login, cfg),
"signup" -> RegisterRoutes(restApp.backend, cfg),
"upload" -> UploadRoutes.open(restApp.backend, cfg)
)
}

View File

@ -0,0 +1,36 @@
package docspell.restserver.auth
import org.http4s._
import org.http4s.util._
import docspell.backend.auth._
import docspell.common.AccountId
import docspell.restserver.Config
case class CookieData(auth: AuthToken) {
def accountId: AccountId = auth.account
def asString: String = auth.asString
def asCookie(cfg: Config): ResponseCookie = {
val domain = "" //cfg.baseUrl.hostAndPort
val sec = false //cfg.baseUrl.protocol.exists(_.endsWith("s"))
ResponseCookie(CookieData.cookieName, asString, domain = Some(domain), path = Some("/api/v1"), httpOnly = true, secure = sec)
}
}
object CookieData {
val cookieName = "docspell_auth"
val headerName = "X-Docspell-Auth"
def authenticator[F[_]](r: Request[F]): Either[String, String] =
fromCookie(r) orElse fromHeader(r)
def fromCookie[F[_]](req: Request[F]): Either[String, String] = {
for {
header <- headers.Cookie.from(req.headers).toRight("Cookie parsing error")
cookie <- header.values.toList.find(_.name == cookieName).toRight("Couldn't find the authcookie")
} yield cookie.content
}
def fromHeader[F[_]](req: Request[F]): Either[String, String] = {
req.headers.get(CaseInsensitiveString(headerName)).map(_.value).toRight("Couldn't find an authenticator")
}
}

View File

@ -0,0 +1,337 @@
package docspell.restserver.conv
import java.time.{LocalDate, ZoneId}
import fs2.Stream
import cats.implicits._
import cats.effect.{Effect, Sync}
import docspell.common._
import docspell.common.syntax.all._
import docspell.restapi.model._
import docspell.store.records._
import Conversions._
import bitpeace.FileMeta
import docspell.backend.ops.OCollective.{InsightData, PassChangeResult}
import docspell.backend.ops.OJob.JobCancelResult
import docspell.backend.ops.OUpload.{UploadData, UploadMeta, UploadResult}
import docspell.backend.ops.{OItem, OJob, OOrganization, OUpload}
import docspell.store.AddResult
import org.http4s.multipart.Multipart
import org.http4s.headers.`Content-Type`
import org.log4s.Logger
trait Conversions {
// insights
def mkItemInsights(d: InsightData): ItemInsights =
ItemInsights(d.incoming, d.outgoing, d.bytes, TagCloud(d.tags.toList.map(p => NameCount(p._1, p._2))))
// attachment meta
def mkAttachmentMeta(rm: RAttachmentMeta): AttachmentMeta =
AttachmentMeta(rm.content.getOrElse("")
, rm.nerlabels.map(nl => Label(nl.tag, nl.label, nl.startPosition, nl.endPosition))
, mkItemProposals(rm.proposals))
// item proposal
def mkItemProposals(ml: MetaProposalList): ItemProposals = {
def get(mpt: MetaProposalType) =
ml.find(mpt).
map(mp => mp.values.toList.map(_.ref).map(mkIdName)).
getOrElse(Nil)
def getDates(mpt: MetaProposalType): List[Timestamp] =
ml.find(mpt).
map(mp => mp.values.toList.
map(cand => cand.ref.id.id).
flatMap(str => Either.catchNonFatal(LocalDate.parse(str)).toOption).
map(_.atTime(12, 0).atZone(ZoneId.of("GMT"))).
map(zdt => Timestamp(zdt.toInstant))).
getOrElse(Nil).
distinct.
take(5)
ItemProposals(
corrOrg = get(MetaProposalType.CorrOrg),
corrPerson = get(MetaProposalType.CorrPerson),
concPerson = get(MetaProposalType.ConcPerson),
concEquipment = get(MetaProposalType.ConcEquip),
itemDate = getDates(MetaProposalType.DocDate),
dueDate = getDates(MetaProposalType.DueDate)
)
}
// item detail
def mkItemDetail(data: OItem.ItemData): ItemDetail =
ItemDetail(data.item.id
, data.item.direction
, data.item.name
, data.item.source
, data.item.state
, data.item.created
, data.item.updated
, data.item.itemDate
, data.corrOrg.map(o => IdName(o.oid, o.name))
, data.corrPerson.map(p => IdName(p.pid, p.name))
, data.concPerson.map(p => IdName(p.pid, p.name))
, data.concEquip.map(e => IdName(e.eid, e.name))
, data.inReplyTo.map(mkIdName)
, data.item.dueDate
, data.item.notes
, data.attachments.map((mkAttachment _).tupled).toList
, data.tags.map(mkTag).toList)
def mkAttachment(ra: RAttachment, m: FileMeta): Attachment =
Attachment(ra.id, ra.name, m.length, MimeType.unsafe(m.mimetype.asString))
// item list
def mkQuery(m: ItemSearch, coll: Ident): OItem.Query =
OItem.Query(coll
, m.name
, if (m.inbox) Seq(ItemState.Created) else Seq(ItemState.Created, ItemState.Confirmed)
, m.direction
, m.corrPerson
, m.corrOrg
, m.concPerson
, m.concEquip
, m.tagsInclude.map(Ident.unsafe)
, m.tagsExclude.map(Ident.unsafe)
, m.dateFrom
, m.dateUntil
, m.dueDateFrom
, m.dueDateUntil
)
def mkItemList(v: Vector[OItem.ListItem]): ItemLightList = {
val groups = v.groupBy(item => item.date.toDate.toString.substring(0, 7))
def mkGroup(g: (String, Vector[OItem.ListItem])): ItemLightGroup =
ItemLightGroup(g._1, g._2.map(mkItemLight).toList)
val gs = groups.map(mkGroup _).toList.sortWith((g1, g2) => g1.name.compareTo(g2.name) >= 0)
ItemLightList(gs)
}
def mkItemLight(i: OItem.ListItem): ItemLight =
ItemLight(i.id, i.name, i.state, i.date, i.dueDate, i.source, i.direction.name.some, i.corrOrg.map(mkIdName),
i.corrPerson.map(mkIdName), i.concPerson.map(mkIdName), i.concEquip.map(mkIdName), i.fileCount)
// job
def mkJobQueueState(state: OJob.CollectiveQueueState): JobQueueState = {
def desc(f: JobDetail => Option[Timestamp])(j1: JobDetail, j2: JobDetail): Boolean = {
val t1 = f(j1).getOrElse(Timestamp.Epoch)
val t2 = f(j2).getOrElse(Timestamp.Epoch)
t1.value.isAfter(t2.value)
}
def asc(f: JobDetail => Option[Timestamp])(j1: JobDetail, j2: JobDetail): Boolean = {
val t1 = f(j1).getOrElse(Timestamp.Epoch)
val t2 = f(j2).getOrElse(Timestamp.Epoch)
t1.value.isBefore(t2.value)
}
JobQueueState(state.running.map(mkJobDetail).toList.sortWith(asc(_.started))
, state.done.map(mkJobDetail).toList.sortWith(desc(_.finished))
, state.queued.map(mkJobDetail).toList.sortWith(asc(_.submitted.some)))
}
def mkJobDetail(jd: OJob.JobDetail): JobDetail =
JobDetail(jd.job.id
, jd.job.subject
, jd.job.submitted
, jd.job.priority
, jd.job.state
, jd.job.retries
, jd.logs.map(mkJobLog).toList
, jd.job.progress
, jd.job.worker
, jd.job.started
, jd.job.finished)
def mkJobLog(jl: RJobLog): JobLogEvent =
JobLogEvent(jl.created, jl.level, jl.message)
// upload
def readMultipart[F[_]: Effect](mp: Multipart[F], logger: Logger, prio: Priority, validFileTypes: Seq[MimeType]): F[UploadData[F]] = {
def parseMeta(body: Stream[F, Byte]): F[ItemUploadMeta] = {
body.through(fs2.text.utf8Decode).
parseJsonAs[ItemUploadMeta].
map(_.fold(ex => {
logger.error(ex)("Reading upload metadata failed.")
throw ex
}, identity))
}
val meta: F[(Boolean, UploadMeta)] = mp.parts.find(_.name.exists(_ equalsIgnoreCase "meta")).
map(p => parseMeta(p.body)).
map(fm => fm.map(m => (m.multiple, UploadMeta(m.direction, "webapp", validFileTypes)))).
getOrElse((true, UploadMeta(None, "webapp", validFileTypes)).pure[F])
val files = mp.parts.
filter(p => p.name.forall(s => !s.equalsIgnoreCase("meta"))).
map(p => OUpload.File(p.filename, p.headers.get(`Content-Type`).map(fromContentType), p.body))
for {
metaData <- meta
_ <- Effect[F].delay(logger.debug(s"Parsed upload meta data: $metaData"))
tracker <- Ident.randomId[F]
} yield UploadData(metaData._1, metaData._2, files, prio, Some(tracker))
}
// organization and person
def mkOrg(v: OOrganization.OrgAndContacts): Organization = {
val ro = v.org
Organization(ro.oid, ro.name, Address(ro.street, ro.zip, ro.city, ro.country),
v.contacts.map(mkContact).toList, ro.notes, ro.created)
}
def newOrg[F[_]: Sync](v: Organization, cid: Ident): F[OOrganization.OrgAndContacts] = {
def contacts(oid: Ident) =
v.contacts.traverse(c => newContact(c, oid.some, None))
for {
now <- Timestamp.current[F]
oid <- Ident.randomId[F]
cont <- contacts(oid)
org = ROrganization(oid, cid, v.name, v.address.street, v.address.zip, v.address.city, v.address.country, v.notes, now)
} yield OOrganization.OrgAndContacts(org, cont)
}
def changeOrg[F[_]: Sync](v: Organization, cid: Ident): F[OOrganization.OrgAndContacts] = {
def contacts(oid: Ident) =
v.contacts.traverse(c => newContact(c, oid.some, None))
for {
cont <- contacts(v.id)
org = ROrganization(v.id, cid, v.name, v.address.street, v.address.zip, v.address.city, v.address.country, v.notes, v.created)
} yield OOrganization.OrgAndContacts(org, cont)
}
def mkPerson(v: OOrganization.PersonAndContacts): Person = {
val ro = v.person
Person(ro.pid, ro.name, Address(ro.street, ro.zip, ro.city, ro.country),
v.contacts.map(mkContact).toList, ro.notes, ro.concerning, ro.created)
}
def newPerson[F[_]: Sync](v: Person, cid: Ident): F[OOrganization.PersonAndContacts] = {
def contacts(pid: Ident) =
v.contacts.traverse(c => newContact(c, None, pid.some))
for {
now <- Timestamp.current[F]
pid <- Ident.randomId[F]
cont <- contacts(pid)
org = RPerson(pid, cid, v.name, v.address.street, v.address.zip, v.address.city, v.address.country, v.notes, v.concerning, now)
} yield OOrganization.PersonAndContacts(org, cont)
}
def changePerson[F[_]: Sync](v: Person, cid: Ident): F[OOrganization.PersonAndContacts] = {
def contacts(pid: Ident) =
v.contacts.traverse(c => newContact(c, None, pid.some))
for {
cont <- contacts(v.id)
org = RPerson(v.id, cid, v.name, v.address.street, v.address.zip, v.address.city, v.address.country, v.notes, v.concerning, v.created)
} yield OOrganization.PersonAndContacts(org, cont)
}
// contact
def mkContact(rc: RContact): Contact =
Contact(rc.contactId, rc.value, rc.kind)
def newContact[F[_]: Sync](c: Contact, oid: Option[Ident], pid: Option[Ident]): F[RContact] =
timeId.map { case (id, now) =>
RContact(id, c.value, c.kind, pid, oid, now)
}
// users
def mkUser(ru: RUser): User =
User(ru.login, ru.state, None, ru.email, ru.lastLogin, ru.loginCount, ru.created)
def newUser[F[_]: Sync](u: User, cid: Ident): F[RUser] =
timeId.map { case (id, now) =>
RUser(id, u.login, cid, u.password.getOrElse(Password.empty), u.state, u.email, u.loginCount, u.lastLogin, u.created)
}
def changeUser(u: User, cid: Ident): RUser =
RUser(Ident.unsafe(""), u.login, cid, u.password.getOrElse(Password.empty), u.state, u.email, u.loginCount, u.lastLogin, u.created)
// tags
def mkTag(rt: RTag): Tag =
Tag(rt.tagId, rt.name, rt.category, rt.created)
def newTag[F[_]: Sync](t: Tag, cid: Ident): F[RTag] =
timeId.map { case (id, now) =>
RTag(id, cid, t.name, t.category, now)
}
def changeTag(t: Tag, cid: Ident): RTag =
RTag(t.id, cid, t.name, t.category, t.created)
// sources
def mkSource(s: RSource): Source =
Source(s.sid, s.abbrev, s.description, s.counter, s.enabled, s.priority, s.created)
def newSource[F[_]: Sync](s: Source, cid: Ident): F[RSource] =
timeId.map({ case (id, now) =>
RSource(id, cid, s.abbrev, s.description, 0, s.enabled, s.priority, now)
})
def changeSource[F[_]: Sync](s: Source, coll: Ident): RSource =
RSource(s.id, coll, s.abbrev, s.description, s.counter, s.enabled, s.priority, s.created)
// equipment
def mkEquipment(re: REquipment): Equipment =
Equipment(re.eid, re.name, re.created)
def newEquipment[F[_]: Sync](e: Equipment, cid: Ident): F[REquipment] =
timeId.map({ case (id, now) =>
REquipment(id, cid, e.name, now)
})
def changeEquipment(e: Equipment, cid: Ident): REquipment =
REquipment(e.id, cid, e.name, e.created)
// idref
def mkIdName(ref: IdRef): IdName =
IdName(ref.id, ref.name)
// basic result
def basicResult(cr: JobCancelResult): BasicResult =
cr match {
case JobCancelResult.JobNotFound => BasicResult(false, "Job not found")
case JobCancelResult.CancelRequested => BasicResult(true, "Cancel was requested at the job executor")
case JobCancelResult.Removed => BasicResult(true, "The job has been removed from the queue.")
}
def basicResult(ar: AddResult, successMsg: String): BasicResult = ar match {
case AddResult.Success => BasicResult(true, successMsg)
case AddResult.EntityExists(msg) => BasicResult(false, msg)
case AddResult.Failure(ex) => BasicResult(false, s"Internal error: ${ex.getMessage}")
}
def basicResult(ur: OUpload.UploadResult): BasicResult = ur match {
case UploadResult.Success => BasicResult(true, "Files submitted.")
case UploadResult.NoFiles => BasicResult(false, "There were no files to submit.")
case UploadResult.NoSource => BasicResult(false, "The source id is not valid.")
}
def basicResult(cr: PassChangeResult): BasicResult = cr match {
case PassChangeResult.Success => BasicResult(true, "Password changed.")
case PassChangeResult.UpdateFailed => BasicResult(false, "The database update failed.")
case PassChangeResult.PasswordMismatch => BasicResult(false, "The current password is incorrect.")
case PassChangeResult.UserNotFound => BasicResult(false, "User not found.")
}
// MIME Type
def fromContentType(header: `Content-Type`): MimeType =
MimeType(header.mediaType.mainType, header.mediaType.subType)
}
object Conversions extends Conversions {
private def timeId[F[_]: Sync]: F[(Ident, Timestamp)] =
for {
id <- Ident.randomId[F]
now <- Timestamp.current
} yield (id, now)
}

View File

@ -0,0 +1,34 @@
package docspell.restserver.http4s
import cats.Applicative
import org.http4s.{EntityEncoder, Header, Response}
import org.http4s.dsl.Http4sDsl
trait ResponseGenerator[F[_]] {
self: Http4sDsl[F] =>
implicit final class EitherResponses[A,B](e: Either[A, B]) {
def toResponse(headers: Header*)
(implicit F: Applicative[F]
, w0: EntityEncoder[F, A]
, w1: EntityEncoder[F, B]): F[Response[F]] =
e.fold(
a => UnprocessableEntity(a),
b => Ok(b)
)
}
implicit final class OptionResponse[A](o: Option[A]) {
def toResponse(headers: Header*)
(implicit F: Applicative[F]
, w0: EntityEncoder[F, A]): F[Response[F]] =
o.map(a => Ok(a)).getOrElse(NotFound())
}
}
object ResponseGenerator {
}

View File

@ -0,0 +1,63 @@
package docspell.restserver.routes
import cats.data.NonEmptyList
import cats.effect._
import cats.implicits._
import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken
import docspell.backend.ops.OItem
import docspell.common.Ident
import org.http4s.{Header, HttpRoutes, MediaType, Response}
import org.http4s.dsl.Http4sDsl
import org.http4s.headers._
import org.http4s.circe.CirceEntityEncoder._
import docspell.restapi.model._
import docspell.restserver.Config
import docspell.restserver.conv.Conversions
import org.http4s.headers.ETag.EntityTag
object AttachmentRoutes {
def apply[F[_]: Effect](backend: BackendApp[F], cfg: Config, user: AuthToken): HttpRoutes[F] = {
val dsl = new Http4sDsl[F]{}
import dsl._
def makeByteResp(data: OItem.AttachmentData[F]): F[Response[F]] = {
val mt = MediaType.unsafeParse(data.meta.mimetype.asString)
val cntLen: Header = `Content-Length`.unsafeFromLong(data.meta.length)
val eTag: Header = ETag(data.meta.checksum)
val disp: Header = `Content-Disposition`("inline", Map("filename" -> data.ra.name.getOrElse("")))
Ok(data.data.take(data.meta.length)).
map(r => r.withContentType(`Content-Type`(mt)).
withHeaders(cntLen, eTag, disp))
}
HttpRoutes.of {
case req @ GET -> Root / Ident(id) =>
for {
fileData <- backend.item.findAttachment(id, user.account.collective)
inm = req.headers.get(`If-None-Match`).flatMap(_.tags)
matches = matchETag(fileData, inm)
resp <- if (matches) NotModified()
else fileData.map(makeByteResp).getOrElse(NotFound(BasicResult(false, "Not found")))
} yield resp
case GET -> Root / Ident(id) / "meta" =>
for {
rm <- backend.item.findAttachmentMeta(id, user.account.collective)
md = rm.map(Conversions.mkAttachmentMeta)
resp <- md.map(Ok(_)).getOrElse(NotFound(BasicResult(false, "Not found.")))
} yield resp
}
}
private def matchETag[F[_]]( fileData: Option[OItem.AttachmentData[F]]
, noneMatch: Option[NonEmptyList[EntityTag]]): Boolean =
(fileData, noneMatch) match {
case (Some(fd), Some(nm)) =>
fd.meta.checksum == nm.head.tag
case _ =>
false
}
}

View File

@ -0,0 +1,54 @@
package docspell.restserver.routes
import cats.data._
import cats.effect._
import cats.implicits._
import docspell.backend.auth._
import docspell.restserver.auth._
import org.http4s._
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.dsl.Http4sDsl
import org.http4s.server._
object Authenticate {
def authenticateRequest[F[_]: Effect](auth: String => F[Login.Result])(req: Request[F]): F[Login.Result] =
CookieData.authenticator(req) match {
case Right(str) => auth(str)
case Left(err) => Login.Result.invalidAuth.pure[F]
}
def of[F[_]: Effect](S: Login[F], cfg: Login.Config)(pf: PartialFunction[AuthedRequest[F, AuthToken], F[Response[F]]]): HttpRoutes[F] = {
val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
import dsl._
val authUser = getUser[F](S.loginSession(cfg))
val onFailure: AuthedRoutes[String, F] =
Kleisli(req => OptionT.liftF(Forbidden(req.authInfo)))
val middleware: AuthMiddleware[F, AuthToken] =
AuthMiddleware(authUser, onFailure)
middleware(AuthedRoutes.of(pf))
}
def apply[F[_]: Effect](S: Login[F], cfg: Login.Config)(f: AuthToken => HttpRoutes[F]): HttpRoutes[F] = {
val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
import dsl._
val authUser = getUser[F](S.loginSession(cfg))
val onFailure: AuthedRoutes[String, F] =
Kleisli(req => OptionT.liftF(Forbidden(req.authInfo)))
val middleware: AuthMiddleware[F, AuthToken] =
AuthMiddleware(authUser, onFailure)
middleware(AuthedRoutes(authReq => f(authReq.authInfo).run(authReq.req)))
}
private def getUser[F[_]: Effect](auth: String => F[Login.Result]): Kleisli[F, Request[F], Either[String, AuthToken]] =
Kleisli(r => authenticateRequest(auth)(r).map(_.toEither))
}

View File

@ -0,0 +1,52 @@
package docspell.restserver.routes
import cats.effect._
import cats.implicits._
import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken
import docspell.restapi.model._
import docspell.restserver.Config
import docspell.restserver.conv.Conversions
import docspell.restserver.http4s.ResponseGenerator
import org.http4s.HttpRoutes
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.circe.CirceEntityDecoder._
import org.http4s.dsl.Http4sDsl
object CollectiveRoutes {
def apply[F[_]: Effect](backend: BackendApp[F], cfg: Config, user: AuthToken): HttpRoutes[F] = {
val dsl = new Http4sDsl[F] with ResponseGenerator[F] {}
import dsl._
HttpRoutes.of {
case GET -> Root / "insights" =>
for {
ins <- backend.collective.insights(user.account.collective)
resp <- Ok(Conversions.mkItemInsights(ins))
} yield resp
case req@POST -> Root / "settings" =>
for {
settings <- req.as[CollectiveSettings]
res <- backend.collective.updateLanguage(user.account.collective, settings.language)
resp <- Ok(Conversions.basicResult(res, "Language updated."))
} yield resp
case GET -> Root / "settings" =>
for {
collDb <- backend.collective.find(user.account.collective)
sett = collDb.map(c => CollectiveSettings(c.language))
resp <- sett.toResponse()
} yield resp
case GET -> Root =>
for {
collDb <- backend.collective.find(user.account.collective)
coll = collDb.map(c => Collective(c.id, c.state, c.created))
resp <- coll.toResponse()
} yield resp
}
}
}

View File

@ -0,0 +1,52 @@
package docspell.restserver.routes
import cats.effect._
import cats.implicits._
import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken
import docspell.common.Ident
import docspell.restapi.model._
import docspell.restserver.Config
import docspell.restserver.conv.Conversions._
import org.http4s.HttpRoutes
import org.http4s.circe.CirceEntityDecoder._
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.dsl.Http4sDsl
object EquipmentRoutes {
def apply[F[_]: Effect](backend: BackendApp[F], cfg: Config, user: AuthToken): HttpRoutes[F] = {
val dsl = new Http4sDsl[F]{}
import dsl._
HttpRoutes.of {
case GET -> Root =>
for {
data <- backend.equipment.findAll(user.account)
resp <- Ok(EquipmentList(data.map(mkEquipment).toList))
} yield resp
case req @ POST -> Root =>
for {
data <- req.as[Equipment]
equip <- newEquipment(data, user.account.collective)
res <- backend.equipment.add(equip)
resp <- Ok(basicResult(res, "Equipment created"))
} yield resp
case req @ PUT -> Root =>
for {
data <- req.as[Equipment]
equip = changeEquipment(data, user.account.collective)
res <- backend.equipment.update(equip)
resp <- Ok(basicResult(res, "Equipment updated."))
} yield resp
case DELETE -> Root / Ident(id) =>
for {
del <- backend.equipment.delete(id, user.account.collective)
resp <- Ok(basicResult(del, "Equipment deleted."))
} yield resp
}
}
}

View File

@ -1,13 +1,11 @@
package docspell.restserver
package docspell.restserver.routes
import cats.effect._
import org.http4s._
import cats.effect.Sync
import docspell.restapi.model.VersionInfo
import docspell.restserver.{BuildInfo, Config}
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.HttpRoutes
import org.http4s.dsl.Http4sDsl
import org.http4s.circe.CirceEntityEncoder._
import docspell.restapi.model._
import docspell.restserver.BuildInfo
object InfoRoutes {

View File

@ -0,0 +1,142 @@
package docspell.restserver.routes
import cats.effect._
import cats.implicits._
import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken
import docspell.common.{Ident, ItemState}
import org.http4s.HttpRoutes
import org.http4s.dsl.Http4sDsl
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.circe.CirceEntityDecoder._
import docspell.restapi.model._
import docspell.restserver.Config
import docspell.common.syntax.all._
import docspell.restserver.conv.Conversions
import org.log4s._
object ItemRoutes {
private[this] val logger = getLogger
def apply[F[_]: Effect](backend: BackendApp[F], cfg: Config, user: AuthToken): HttpRoutes[F] = {
val dsl = new Http4sDsl[F]{}
import dsl._
HttpRoutes.of {
case req @ POST -> Root / "search" =>
for {
mask <- req.as[ItemSearch]
_ <- logger.ftrace(s"Got search mask: $mask")
query = Conversions.mkQuery(mask, user.account.collective)
_ <- logger.ftrace(s"Running query: $query")
items <- backend.item.findItems(query, 100)
resp <- Ok(Conversions.mkItemList(items))
} yield resp
case GET -> Root / Ident(id) =>
for {
item <- backend.item.findItem(id, user.account.collective)
result = item.map(Conversions.mkItemDetail)
resp <- result.map(r => Ok(r)).getOrElse(NotFound(BasicResult(false, "Not found.")))
} yield resp
case req@POST -> Root / Ident(id) / "confirm" =>
for {
res <- backend.item.setState(id, ItemState.Confirmed, user.account.collective)
resp <- Ok(Conversions.basicResult(res, "Item data confirmed"))
} yield resp
case req@POST -> Root / Ident(id) / "unconfirm" =>
for {
res <- backend.item.setState(id, ItemState.Created, user.account.collective)
resp <- Ok(Conversions.basicResult(res, "Item back to created."))
} yield resp
case req@POST -> Root / Ident(id) / "tags" =>
for {
tags <- req.as[ReferenceList].map(_.items)
res <- backend.item.setTags(id, tags.map(_.id), user.account.collective)
resp <- Ok(Conversions.basicResult(res, "Tags updated"))
} yield resp
case req@POST -> Root / Ident(id) / "direction" =>
for {
dir <- req.as[DirectionValue]
res <- backend.item.setDirection(id, dir.direction, user.account.collective)
resp <- Ok(Conversions.basicResult(res, "Direction updated"))
} yield resp
case req@POST -> Root / Ident(id) / "corrOrg" =>
for {
idref <- req.as[OptionalId]
res <- backend.item.setCorrOrg(id, idref.id, user.account.collective)
resp <- Ok(Conversions.basicResult(res, "Correspondent organization updated"))
} yield resp
case req@POST -> Root / Ident(id) / "corrPerson" =>
for {
idref <- req.as[OptionalId]
res <- backend.item.setCorrPerson(id, idref.id, user.account.collective)
resp <- Ok(Conversions.basicResult(res, "Correspondent person updated"))
} yield resp
case req@POST -> Root / Ident(id) / "concPerson" =>
for {
idref <- req.as[OptionalId]
res <- backend.item.setConcPerson(id, idref.id, user.account.collective)
resp <- Ok(Conversions.basicResult(res, "Concerned person updated"))
} yield resp
case req@POST -> Root / Ident(id) / "concEquipment" =>
for {
idref <- req.as[OptionalId]
res <- backend.item.setConcEquip(id, idref.id, user.account.collective)
resp <- Ok(Conversions.basicResult(res, "Concerned equipment updated"))
} yield resp
case req@POST -> Root / Ident(id) / "notes" =>
for {
text <- req.as[OptionalText]
res <- backend.item.setNotes(id, text.text, user.account.collective)
resp <- Ok(Conversions.basicResult(res, "Concerned equipment updated"))
} yield resp
case req@POST -> Root / Ident(id) / "name" =>
for {
text <- req.as[OptionalText]
res <- backend.item.setName(id, text.text.getOrElse(""), user.account.collective)
resp <- Ok(Conversions.basicResult(res, "Concerned equipment updated"))
} yield resp
case req@POST -> Root / Ident(id) / "duedate" =>
for {
date <- req.as[OptionalDate]
_ <- logger.fdebug(s"Setting item due date to ${date.date}")
res <- backend.item.setItemDueDate(id, date.date, user.account.collective)
resp <- Ok(Conversions.basicResult(res, "Item due date updated"))
} yield resp
case req@POST -> Root / Ident(id) / "date" =>
for {
date <- req.as[OptionalDate]
_ <- logger.fdebug(s"Setting item date to ${date.date}")
res <- backend.item.setItemDate(id, date.date, user.account.collective)
resp <- Ok(Conversions.basicResult(res, "Item date updated"))
} yield resp
case GET -> Root / Ident(id) / "proposals" =>
for {
ml <- backend.item.getProposals(id, user.account.collective)
ip = Conversions.mkItemProposals(ml)
resp <- Ok(ip)
} yield resp
case DELETE -> Root / Ident(id) =>
for {
n <- backend.item.delete(id, user.account.collective)
res = BasicResult(n > 0, if (n > 0) "Item deleted" else "Item deletion failed.")
resp <- Ok(res)
} yield resp
}
}
}

View File

@ -0,0 +1,36 @@
package docspell.restserver.routes
import cats.effect._
import cats.implicits._
import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken
import docspell.common.Ident
import docspell.restserver.Config
import docspell.restserver.conv.Conversions
import org.http4s.HttpRoutes
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.dsl.Http4sDsl
object JobQueueRoutes {
def apply[F[_]: Effect](backend: BackendApp[F], cfg: Config, user: AuthToken): HttpRoutes[F] = {
val dsl = new Http4sDsl[F] {}
import dsl._
HttpRoutes.of {
case GET -> Root / "state" =>
for {
js <- backend.job.queueState(user.account.collective, 200)
res = Conversions.mkJobQueueState(js)
resp <- Ok(res)
} yield resp
case POST -> Root / Ident(id) / "cancel" =>
for {
result <- backend.job.cancelJob(id, user.account.collective)
resp <- Ok(Conversions.basicResult(result))
} yield resp
}
}
}

View File

@ -0,0 +1,58 @@
package docspell.restserver.routes
import cats.effect._
import cats.implicits._
import docspell.backend.auth._
import docspell.restapi.model._
import docspell.restserver._
import docspell.restserver.auth._
import org.http4s._
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.circe.CirceEntityDecoder._
import org.http4s.dsl.Http4sDsl
object LoginRoutes {
def login[F[_]: Effect](S: Login[F], cfg: Config): HttpRoutes[F] = {
val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
import dsl._
HttpRoutes.of[F] {
case req@POST -> Root / "login" =>
for {
up <- req.as[UserPass]
res <- S.loginUserPass(cfg.auth)(Login.UserPass(up.account, up.password))
resp <- makeResponse(dsl, cfg, res, up.account)
} yield resp
}
}
def session[F[_]: Effect](S: Login[F], cfg: Config): HttpRoutes[F] = {
val dsl: Http4sDsl[F] = new Http4sDsl[F] {}
import dsl._
HttpRoutes.of[F] {
case req @ POST -> Root / "session" =>
Authenticate.authenticateRequest(S.loginSession(cfg.auth))(req).
flatMap(res => makeResponse(dsl, cfg, res, ""))
case POST -> Root / "logout" =>
Ok().map(_.addCookie(ResponseCookie(CookieData.cookieName, "", maxAge = Some(-1))))
}
}
def makeResponse[F[_]: Effect](dsl: Http4sDsl[F], cfg: Config, res: Login.Result, account: String): F[Response[F]] = {
import dsl._
res match {
case Login.Result.Ok(token) =>
for {
cd <- AuthToken.user(token.account, cfg.auth.serverSecret).map(CookieData.apply)
resp <- Ok(AuthResult(token.account.collective.id, token.account.user.id, true, "Login successful", Some(cd.asString), cfg.auth.sessionValid.millis)).
map(_.addCookie(cd.asCookie(cfg)))
} yield resp
case _ =>
Ok(AuthResult("", account, false, "Login failed.", None, 0L))
}
}
}

View File

@ -0,0 +1,61 @@
package docspell.restserver.routes
import cats.effect._
import cats.implicits._
import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken
import org.http4s.HttpRoutes
import org.http4s.dsl.Http4sDsl
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.circe.CirceEntityDecoder._
import docspell.restapi.model._
import docspell.restserver.Config
import docspell.restserver.conv.Conversions._
import ParamDecoder._
import docspell.common.Ident
object OrganizationRoutes {
def apply[F[_]: Effect](backend: BackendApp[F], cfg: Config, user: AuthToken): HttpRoutes[F] = {
val dsl = new Http4sDsl[F]{}
import dsl._
HttpRoutes.of {
case GET -> Root :? FullQueryParamMatcher(full) =>
if (full.getOrElse(false)) {
for {
data <- backend.organization.findAllOrg(user.account)
resp <- Ok(OrganizationList(data.map(mkOrg).toList))
} yield resp
} else {
for {
data <- backend.organization.findAllOrgRefs(user.account)
resp <- Ok(ReferenceList(data.map(mkIdName).toList))
} yield resp
}
case req @ POST -> Root =>
for {
data <- req.as[Organization]
newOrg <- newOrg(data, user.account.collective)
added <- backend.organization.addOrg(newOrg)
resp <- Ok(basicResult(added, "New organization saved."))
} yield resp
case req @ PUT -> Root =>
for {
data <- req.as[Organization]
upOrg <- changeOrg(data, user.account.collective)
update <- backend.organization.updateOrg(upOrg)
resp <- Ok(basicResult(update, "Organization updated."))
} yield resp
case DELETE -> Root / Ident(id) =>
for {
delOrg <- backend.organization.deleteOrg(id, user.account.collective)
resp <- Ok(basicResult(delOrg, "Organization deleted."))
} yield resp
}
}
}

View File

@ -0,0 +1,14 @@
package docspell.restserver.routes
import org.http4s.QueryParamDecoder
import org.http4s.dsl.impl.OptionalQueryParamDecoderMatcher
object ParamDecoder {
implicit val booleanDecoder: QueryParamDecoder[Boolean] =
QueryParamDecoder.fromUnsafeCast(qp => Option(qp.value).exists(_ equalsIgnoreCase "true"))("Boolean")
object FullQueryParamMatcher extends OptionalQueryParamDecoderMatcher[Boolean]("full")
}

View File

@ -0,0 +1,65 @@
package docspell.restserver.routes
import cats.effect._
import cats.implicits._
import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken
import org.http4s.HttpRoutes
import org.http4s.dsl.Http4sDsl
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.circe.CirceEntityDecoder._
import docspell.restapi.model._
import docspell.restserver.Config
import docspell.restserver.conv.Conversions._
import docspell.common.Ident
import docspell.common.syntax.all._
import ParamDecoder._
import org.log4s._
object PersonRoutes {
private[this] val logger = getLogger
def apply[F[_]: Effect](backend: BackendApp[F], cfg: Config, user: AuthToken): HttpRoutes[F] = {
val dsl = new Http4sDsl[F]{}
import dsl._
HttpRoutes.of {
case GET -> Root :? FullQueryParamMatcher(full) =>
if (full.getOrElse(false)) {
for {
data <- backend.organization.findAllPerson(user.account)
resp <- Ok(PersonList(data.map(mkPerson).toList))
} yield resp
} else {
for {
data <- backend.organization.findAllPersonRefs(user.account)
resp <- Ok(ReferenceList(data.map(mkIdName).toList))
} yield resp
}
case req @ POST -> Root =>
for {
data <- req.as[Person]
newPer <- newPerson(data, user.account.collective)
added <- backend.organization.addPerson(newPer)
resp <- Ok(basicResult(added, "New person saved."))
} yield resp
case req @ PUT -> Root =>
for {
data <- req.as[Person]
upPer <- changePerson(data, user.account.collective)
update <- backend.organization.updatePerson(upPer)
resp <- Ok(basicResult(update, "Person updated."))
} yield resp
case DELETE -> Root / Ident(id) =>
for {
_ <- logger.fdebug(s"Deleting person ${id.id}")
delOrg <- backend.organization.deletePerson(id, user.account.collective)
resp <- Ok(basicResult(delOrg, "Person deleted."))
} yield resp
}
}
}

View File

@ -0,0 +1,68 @@
package docspell.restserver.routes
import cats.effect._
import cats.implicits._
import docspell.backend.BackendApp
import docspell.backend.ops.OCollective.RegisterData
import docspell.backend.signup.{NewInviteResult, SignupResult}
import docspell.restapi.model._
import docspell.restserver.Config
import docspell.restserver.http4s.ResponseGenerator
import org.http4s.HttpRoutes
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.circe.CirceEntityDecoder._
import org.http4s.dsl.Http4sDsl
import org.log4s._
object RegisterRoutes {
private[this] val logger = getLogger
def apply[F[_]: Effect](backend: BackendApp[F], cfg: Config): HttpRoutes[F] = {
val dsl = new Http4sDsl[F] with ResponseGenerator[F] {}
import dsl._
HttpRoutes.of {
case req @ POST -> Root / "register" =>
for {
data <- req.as[Registration]
res <- backend.signup.register(cfg.backend.signup)(convert(data))
resp <- Ok(convert(res))
} yield resp
case req@ POST -> Root / "newinvite" =>
for {
data <- req.as[GenInvite]
res <- backend.signup.newInvite(cfg.backend.signup)(data.password)
resp <- Ok(convert(res))
} yield resp
}
}
def convert(r: NewInviteResult): InviteResult = r match {
case NewInviteResult.Success(id) =>
InviteResult(true, "New invitation created.", Some(id))
case NewInviteResult.InvitationDisabled =>
InviteResult(false, "Signing up is not enabled for invitations.", None)
case NewInviteResult.PasswordMismatch =>
InviteResult(false, "Password is invalid.", None)
}
def convert(r: SignupResult): BasicResult = r match {
case SignupResult.CollectiveExists =>
BasicResult(false, "A collective with this name already exists.")
case SignupResult.InvalidInvitationKey =>
BasicResult(false, "Invalid invitation key.")
case SignupResult.SignupClosed =>
BasicResult(false, "Sorry, registration is closed.")
case SignupResult.Failure(ex) =>
logger.error(ex)("Error signing up")
BasicResult(false, s"Internal error: ${ex.getMessage}")
case SignupResult.Success =>
BasicResult(true, "Signup successful")
}
def convert(r: Registration): RegisterData =
RegisterData(r.collectiveName, r.login, r.password, r.invite)
}

View File

@ -0,0 +1,54 @@
package docspell.restserver.routes
import cats.effect._
import cats.implicits._
import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken
import docspell.common.Ident
import docspell.restapi.model._
import docspell.restserver.Config
import docspell.restserver.conv.Conversions._
import docspell.restserver.http4s.ResponseGenerator
import org.http4s.HttpRoutes
import org.http4s.circe.CirceEntityDecoder._
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.dsl.Http4sDsl
object SourceRoutes {
def apply[F[_]: Effect](backend: BackendApp[F], cfg: Config, user: AuthToken): HttpRoutes[F] = {
val dsl = new Http4sDsl[F] with ResponseGenerator[F] {}
import dsl._
HttpRoutes.of {
case GET -> Root =>
for {
all <- backend.source.findAll(user.account)
res <- Ok(SourceList(all.map(mkSource).toList))
} yield res
case req @ POST -> Root =>
for {
data <- req.as[Source]
src <- newSource(data, user.account.collective)
added <- backend.source.add(src)
resp <- Ok(basicResult(added, "Source added."))
} yield resp
case req @ PUT -> Root =>
for {
data <- req.as[Source]
src = changeSource(data, user.account.collective)
updated <- backend.source.update(src)
resp <- Ok(basicResult(updated, "Source updated."))
} yield resp
case DELETE -> Root / Ident(id) =>
for {
del <- backend.source.delete(id, user.account.collective)
resp <- Ok(basicResult(del, "Source deleted."))
} yield resp
}
}
}

View File

@ -0,0 +1,54 @@
package docspell.restserver.routes
import cats.effect._
import cats.implicits._
import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken
import docspell.common.Ident
import docspell.restapi.model._
import docspell.restserver.Config
import docspell.restserver.conv.Conversions._
import docspell.restserver.http4s.ResponseGenerator
import org.http4s.HttpRoutes
import org.http4s.circe.CirceEntityDecoder._
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.dsl.Http4sDsl
object TagRoutes {
def apply[F[_]: Effect](backend: BackendApp[F], cfg: Config, user: AuthToken): HttpRoutes[F] = {
val dsl = new Http4sDsl[F] with ResponseGenerator[F] {}
import dsl._
HttpRoutes.of {
case GET -> Root =>
for {
all <- backend.tag.findAll(user.account)
resp <- Ok(TagList(all.size, all.map(mkTag).toList))
} yield resp
case req @ POST -> Root =>
for {
data <- req.as[Tag]
tag <- newTag(data, user.account.collective)
res <- backend.tag.add(tag)
resp <- Ok(basicResult(res, "Tag successfully created."))
} yield resp
case req @ PUT -> Root =>
for {
data <- req.as[Tag]
tag = changeTag(data, user.account.collective)
res <- backend.tag.update(tag)
resp <- Ok(basicResult(res, "Tag successfully updated."))
} yield resp
case DELETE -> Root / Ident(id) =>
for {
del <- backend.tag.delete(id, user.account.collective)
resp <- Ok(basicResult(del, "Tag successfully deleted."))
} yield resp
}
}
}

View File

@ -0,0 +1,51 @@
package docspell.restserver.routes
import cats.effect._
import cats.implicits._
import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken
import docspell.common.{Ident, Priority}
import docspell.restserver.Config
import docspell.restserver.conv.Conversions._
import docspell.restserver.http4s.ResponseGenerator
import org.http4s.HttpRoutes
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.EntityDecoder._
import org.http4s.dsl.Http4sDsl
import org.http4s.multipart.Multipart
import org.log4s._
object UploadRoutes {
private[this] val logger = getLogger
def secured[F[_]: Effect](backend: BackendApp[F], cfg: Config, user: AuthToken): HttpRoutes[F] = {
val dsl = new Http4sDsl[F] with ResponseGenerator[F] {}
import dsl._
HttpRoutes.of {
case req @ POST -> Root / "item" =>
for {
multipart <- req.as[Multipart[F]]
updata <- readMultipart(multipart, logger, Priority.High, cfg.backend.files.validMimeTypes)
result <- backend.upload.submit(updata, user.account)
res <- Ok(basicResult(result))
} yield res
}
}
def open[F[_]: Effect](backend: BackendApp[F], cfg: Config): HttpRoutes[F] = {
val dsl = new Http4sDsl[F] with ResponseGenerator[F] {}
import dsl._
HttpRoutes.of {
case req @ POST -> Root / "item" / Ident(id)=>
for {
multipart <- req.as[Multipart[F]]
updata <- readMultipart(multipart, logger, Priority.Low, cfg.backend.files.validMimeTypes)
result <- backend.upload.submit(updata, id)
res <- Ok(basicResult(result))
} yield res
}
}
}

View File

@ -0,0 +1,61 @@
package docspell.restserver.routes
import cats.effect._
import cats.implicits._
import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken
import docspell.common.Ident
import docspell.restapi.model._
import docspell.restserver.Config
import docspell.restserver.conv.Conversions._
import docspell.restserver.http4s.ResponseGenerator
import org.http4s.HttpRoutes
import org.http4s.circe.CirceEntityDecoder._
import org.http4s.circe.CirceEntityEncoder._
import org.http4s.dsl.Http4sDsl
object UserRoutes {
def apply[F[_]: Effect](backend: BackendApp[F], cfg: Config, user: AuthToken): HttpRoutes[F] = {
val dsl = new Http4sDsl[F] with ResponseGenerator[F] {}
import dsl._
HttpRoutes.of {
case req @ POST -> Root / "changePassword" =>
for {
data <- req.as[PasswordChange]
res <- backend.collective.changePassword(user.account, data.currentPassword, data.newPassword)
resp <- Ok(basicResult(res))
} yield resp
case GET -> Root =>
for {
all <- backend.collective.listUser(user.account.collective)
res <- Ok(UserList(all.map(mkUser).toList))
} yield res
case req @ POST -> Root =>
for {
data <- req.as[User]
nuser <- newUser(data, user.account.collective)
added <- backend.collective.add(nuser)
resp <- Ok(basicResult(added, "User created."))
} yield resp
case req @ PUT -> Root =>
for {
data <- req.as[User]
nuser = changeUser(data, user.account.collective)
update <- backend.collective.update(nuser)
resp <- Ok(basicResult(update, "User updated."))
} yield resp
case DELETE -> Root / Ident(id) =>
for {
ar <- backend.collective.deleteUser(id, user.account.collective)
resp <- Ok(basicResult(ar, "User deleted."))
} yield resp
}
}
}

View File

@ -0,0 +1,26 @@
package docspell.restserver.webapp
import _root_.io.circe._
import _root_.io.circe.generic.semiauto._
import docspell.common.LenientUri
import docspell.restserver.Config
import docspell.backend.signup.{Config => SignupConfig}
import yamusca.imports._
import yamusca.implicits._
case class Flags(appName: String, baseUrl: LenientUri, signupMode: SignupConfig.Mode)
object Flags {
def apply(cfg: Config): Flags =
Flags(cfg.appName, cfg.baseUrl, cfg.backend.signup.mode)
implicit val jsonEncoder: Encoder[Flags] =
deriveEncoder[Flags]
implicit def yamuscaSignupModeConverter: ValueConverter[SignupConfig.Mode] =
ValueConverter.of(m => Value.fromString(m.name))
implicit def yamuscaUriConverter: ValueConverter[LenientUri] =
ValueConverter.of(uri => Value.fromString(uri.asString))
implicit def yamuscaValueConverter: ValueConverter[Flags] =
ValueConverter.deriveConverter[Flags]
}

View File

@ -7,9 +7,7 @@ import org.http4s._
import org.http4s.headers._
import org.http4s.HttpRoutes
import org.http4s.dsl.Http4sDsl
import org.slf4j._
import _root_.io.circe._
import _root_.io.circe.generic.semiauto._
import org.log4s._
import _root_.io.circe.syntax._
import yamusca.imports._
import yamusca.implicits._
@ -19,7 +17,7 @@ import java.util.concurrent.atomic.AtomicReference
import docspell.restserver.{BuildInfo, Config}
object TemplateRoutes {
private[this] val logger = LoggerFactory.getLogger(getClass)
private[this] val logger = getLogger
val `text/html` = new MediaType("text", "html")
@ -78,27 +76,16 @@ object TemplateRoutes {
object DocData {
def apply(cfg: Config): DocData =
DocData("/app/assets" + Webjars.swaggerui, s"/app/assets/${BuildInfo.name}/${BuildInfo.version}/openapi.yml")
DocData("/app/assets" + Webjars.swaggerui, s"/app/assets/${BuildInfo.name}/${BuildInfo.version}/docspell-openapi.yml")
implicit def yamuscaValueConverter: ValueConverter[DocData] =
ValueConverter.deriveConverter[DocData]
}
case class Flags(appName: String, baseUrl: String)
object Flags {
def apply(cfg: Config): Flags =
Flags(cfg.appName, cfg.baseUrl)
implicit val jsonEncoder: Encoder[Flags] =
deriveEncoder[Flags]
implicit def yamuscaValueConverter: ValueConverter[Flags] =
ValueConverter.deriveConverter[Flags]
}
case class IndexData(flags: Flags
, cssUrls: Seq[String]
, jsUrls: Seq[String]
, faviconBase: String
, appExtraJs: String
, flagsJson: String)
@ -115,9 +102,9 @@ object TemplateRoutes {
"/app/assets" + Webjars.semanticui + "/semantic.min.js",
s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell-app.js"
)
,
s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell.js"
, Flags(cfg).asJson.spaces2 )
, s"/app/assets/docspell-webapp/${BuildInfo.version}/favicon"
, s"/app/assets/docspell-webapp/${BuildInfo.version}/docspell.js"
, Flags(cfg).asJson.spaces2 )
implicit def yamuscaValueConverter: ValueConverter[IndexData] =
ValueConverter.deriveConverter[IndexData]

View File

@ -22,7 +22,7 @@ object WebjarRoutes {
}
def assetFilter(asset: WebjarAsset): Boolean =
List(".js", ".css", ".html", ".jpg", ".png", ".eot", ".woff", ".woff2", ".svg", ".otf", ".ttf", ".yml").
List(".js", ".css", ".html", ".json", ".jpg", ".png", ".eot", ".woff", ".woff2", ".svg", ".otf", ".ttf", ".yml", ".xml").
exists(e => asset.asset.endsWith(e))
}

View File

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Swagger UI</title>
<title>Docspell Swagger UI</title>
<link rel="stylesheet" type="text/css" href="{{swaggerRoot}}/swagger-ui.css" >
<link rel="icon" type="image/png" href="{{swaggerRoot}}/favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="{{swaggerRoot}}/favicon-16x16.png" sizes="16x16" />

View File

@ -1,8 +1,27 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="mobile-web-app-capable" content="yes">
<link rel="apple-touch-icon" sizes="57x57" href="{{{faviconBase}}}/apple-icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="{{{faviconBase}}}/apple-icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="{{{faviconBase}}}/apple-icon-72x72.png">
<link rel="apple-touch-icon" sizes="76x76" href="{{{faviconBase}}}/apple-icon-76x76.png">
<link rel="apple-touch-icon" sizes="114x114" href="{{{faviconBase}}}/apple-icon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="{{{faviconBase}}}/apple-icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="{{{faviconBase}}}/apple-icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="{{{faviconBase}}}/apple-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="{{{faviconBase}}}/apple-icon-180x180.png">
<link rel="icon" type="image/png" sizes="192x192" href="{{{faviconBase}}}/android-icon-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="{{{faviconBase}}}/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="{{{faviconBase}}}/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="16x16" href="{{{faviconBase}}}/favicon-16x16.png">
<link rel="manifest" href="{{{faviconBase}}}/manifest.json">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="{{{faviconBase}}}/ms-icon-144x144.png">
<meta name="theme-color" content="#ffffff">
<title>{{ flags.appName }}</title>
{{# cssUrls }}
<link rel="stylesheet" href="{{.}}"/>