diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 9b417e6e..076ed2ba 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -4,7 +4,7 @@ on: push: branches: [ main ] pull_request: - branches: [ main ] + #branches: [ main ] jobs: @@ -23,4 +23,4 @@ jobs: - name: Test run: go test -v ./... --cover - + diff --git a/README.md b/README.md index d9d83f66..a517b2b7 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A personal documents server, Coreander indexes the documents (EPUBs and PDFs wit * Fast search engine powered by [Bleve](https://github.com/blevesearch/bleve), with support for documents in multiple languages. * Search by author, title and even document series ([Calibre's](https://calibre-ebook.com/) `series` meta supported) * Improved search for documents with metadata in english, spanish, french, italian, german and portuguese, including genre and singular/plural forms of words in the results among others. -* Estimated reading time calculation. +* Estimated reading time calculation. * High-performance web server powered by [Fiber](https://github.com/gofiber/fiber). * Lightweight, responsive web interface based on [Bootstrap](https://getbootstrap.com/). * Web interface available in english, spanish and french, more languages can be easily added. @@ -18,6 +18,7 @@ A personal documents server, Coreander indexes the documents (EPUBs and PDFs wit * Read indexed epubs and PDFs from Coreander's interface thanks to [foliate-js](https://github.com/johnfactotum/foliate-js). * Restrictable access only to registered users. * Upload documents through the web interface. +* Download as kepub (epub for Kobo devices) converted on the fly thanks to [Kepubify](https://github.com/pgaskin/kepubify). ## Installation @@ -56,7 +57,7 @@ Coreander requires a `LIB_PATH` environment variable to be set, which tells the On first run, Coreander will index the documents in your library, creating a database with those entries located at `$home/coreander/index`. Depending on your system's performance and the size of your library this may take a while. Also, the database can grow fairly big, so make sure you have enough free space on disk. Every time is run, the application check for new entries, reindexing the whole library. You can -avoid this behaviour by setting the environment variable `SKIP_INDEXING` to `true`. +avoid this behavior by setting the environment variable `SKIP_INDEXING` to `true`. Even if the application is still indexing entries, you can access its web interface right away. Just open a web browser and go to `localhost:3000` (replace `localhost` with the hostname / IP address of the machine where the server is running if you want to access it from another system). It is possible to change the listening port just executing the application with the `PORT` environment variable (e. g. `PORT=4000 coreander`) @@ -97,7 +98,7 @@ On first run, Coreander creates an admin user with the following credentials: * `PORT`: Port number in which the webserver listens for requests. Defaults to 3000. * `BATCH_SIZE`: Number of documents persisted by the indexer in one write operation. Defaults to 100. * `COVER_MAX_WIDTH`: Maximum horizontal size for documents cover thumbnails in pixels. Defaults to 600. -* `SKIP_INDEXING`: Whether to bypass the indexing process or not. +* `FORCE_INDEXING`: Whether to force indexing already indexed documents or not. Defaults to false. * `SMTP_SERVER`: Address of the send mail server. * `SMTP_PORT`: Port number of the send mail server. Defaults to 587. * `SMTP_USER`: User to authenticate against the SMTP server. @@ -109,5 +110,4 @@ On first run, Coreander creates an admin user with the following credentials: * `SESSION_TIMEOUT`: Specifies the maximum time a user session may last, in hours. Floating-point values are allowed. Defaults to 24 hours. * `RECOVERY_TIMEOUT`: Specifies the maximum time a user recovery link may last, in hours. Floating-point values are allowed. Defaults to 2 hours. * `UPLOAD_DOCUMENT_MAX_SIZE`: Maximum document size allowed to be uploaded to the library, in megabytes. Set this to 0 to unlimit upload size. Defaults to 20 megabytes. -* `HOSTNAME`: **Deprecated, use FQDN instead**. * `FQDN`: Domain name of the server. If Coreander is listening to a non-standard HTTP / HTTPS port, include it using a colon (e. g. example.com:3000). Defaults to `localhost`. diff --git a/config.go b/config.go index 4dfcc53b..b27a385f 100644 --- a/config.go +++ b/config.go @@ -4,8 +4,6 @@ package main type Config struct { // LibPath holds the absolute path to the folder containing the documents LibPath string `env:"LIB_PATH" env-required:"true"` - // Deprecated. Use FQDN instead - Hostname string `env:"HOSTNAME" env-default:"localhost"` // FQDN stores the domain name of the server. If the server is listening on a non-standard HTTP / HTTPS port, include it using a colon (e. g. :3000) FQDN string `env:"FQDN" env-default:"localhost"` // Port defines the port number in which the webserver listens for requests @@ -14,8 +12,8 @@ type Config struct { BatchSize int `env:"BATCH_SIZE" env-default:"100"` // CoverMaxWidth sets the maximum horizontal size for documents cover thumbnails in pixels CoverMaxWidth int `env:"COVER_MAX_WIDTH" env-default:"600"` - // SkipIndexing signals whether to bypass the indexing process or not - SkipIndexing bool `env:"SKIP_INDEXING" env-default:"false"` + // ForceIndexing signals whether to force indexing already indexed documents or not + ForceIndexing bool `env:"FORCE_INDEXING" env-default:"false"` // SmtpServer points to the address of the send mail server SmtpServer string `env:"SMTP_SERVER"` // SmtpPort defines the port in which the mail server listens for requests diff --git a/filewatcher.go b/filewatcher.go index ac7fd8cb..4ec690a7 100644 --- a/filewatcher.go +++ b/filewatcher.go @@ -3,7 +3,7 @@ package main import ( - "github.com/svera/coreander/v3/internal/index" + "github.com/svera/coreander/v4/internal/index" ) func fileWatcher(idx *index.BleveIndexer, libPath string) { diff --git a/filewatcher_linux.go b/filewatcher_linux.go index 2c6c531c..b4acae9c 100644 --- a/filewatcher_linux.go +++ b/filewatcher_linux.go @@ -4,7 +4,7 @@ import ( "log" "github.com/rjeczalik/notify" - "github.com/svera/coreander/v3/internal/index" + "github.com/svera/coreander/v4/internal/index" ) func fileWatcher(idx *index.BleveIndexer, libPath string) { @@ -20,7 +20,7 @@ func fileWatcher(idx *index.BleveIndexer, libPath string) { select { case ei := <-c: if ei.Event() == notify.InCloseWrite || ei.Event() == notify.InMovedFrom { - if err := idx.AddFile(ei.Path()); err != nil { + if _, err := idx.AddFile(ei.Path()); err != nil { log.Printf("Error indexing new file: %s\n", ei.Path()) } } diff --git a/go.mod b/go.mod index 2b282ce8..83621b6a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ -module github.com/svera/coreander/v3 +module github.com/svera/coreander/v4 -go 1.21 +go 1.21.0 + +toolchain go1.21.1 require ( github.com/blevesearch/bleve/v2 v2.4.0 @@ -27,9 +29,11 @@ require ( require ( github.com/andybalholm/cascadia v1.3.2 // indirect + github.com/beevik/etree v1.4.1 // indirect github.com/blevesearch/go-faiss v1.0.13 // indirect github.com/blevesearch/zapx/v16 v16.0.12 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/geek1011/kepubify v2.3.2+incompatible // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/gofiber/template v1.8.3 // indirect github.com/gofiber/utils v1.1.0 // indirect @@ -37,14 +41,21 @@ require ( github.com/hhrutter/lzw v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/kr/smartypants v0.1.0 // indirect github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-zglob v0.0.5 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect + github.com/pgaskin/kepubify v2.3.2+incompatible // indirect + github.com/pgaskin/kepubify/_/go116-zip.go117 v0.0.0-20210611152744-2d89b3182523 // indirect + github.com/pgaskin/kepubify/_/html v0.0.0-20211223234002-6ee2cc632cdc // indirect github.com/philhofer/fwd v1.1.2 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sirupsen/logrus v1.9.3 // indirect + github.com/spf13/pflag v1.0.5 // indirect github.com/tinylib/msgp v1.1.9 // indirect + golang.org/x/sync v0.7.0 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect modernc.org/libc v1.49.3 // indirect @@ -90,6 +101,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mschoch/smat v0.2.0 // indirect + github.com/pgaskin/kepubify/v4 v4.0.4 github.com/pirmd/epub v0.3.0 github.com/rivo/uniseg v0.4.7 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect diff --git a/go.sum b/go.sum index 2be4c03b..113c2ae5 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,10 @@ github.com/antzucaro/matchr v0.0.0-20210222213004-b04723ef80f0 h1:R/qAiUxFT3mNgQ github.com/antzucaro/matchr v0.0.0-20210222213004-b04723ef80f0/go.mod h1:v3ZDlfVAL1OrkKHbGSFFK60k0/7hruHPDq2XMs9Gu6U= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/bamiaux/rez v0.0.0-20170731184118-29f4463c688b/go.mod h1:obBQGGIFbbv9KWg92Qu9UHeD94JXmHD1jovY/z6I3O8= +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= +github.com/beevik/etree v1.4.1 h1:PmQJDDYahBGNKDcpdX8uPy1xRCwoCGVUiW669MEirVI= +github.com/beevik/etree v1.4.1/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs= github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE= github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= @@ -67,6 +71,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/flotzilla/pdf_parser v0.1.96 h1:SlgvO7NZqFzhBO+o6X1u7rUYjhv+81V3dYQF+LTfGOE= github.com/flotzilla/pdf_parser v0.1.96/go.mod h1:/CPB1OWEeFqRbtnFWXgArmOnA3u7smVHxr5dFy4U6Nk= +github.com/geek1011/kepubify v2.3.2+incompatible h1:G1dAwpTpSHN79/bOqQ64SjkllYPhpp25kUkJ95WNFnA= +github.com/geek1011/kepubify v2.3.2+incompatible/go.mod h1:xMWLgn5FQSh6oNcq//MwSwPf2WgyQq6T+fY9nSn9+Cs= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= @@ -104,6 +110,7 @@ github.com/gosimple/slug v1.14.0 h1:RtTL/71mJNDfpUbCOmnf/XFkzKRtD6wL6Uy+3akm4Es= github.com/gosimple/slug v1.14.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= github.com/hhrutter/tiff v1.0.1 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0= @@ -122,6 +129,8 @@ github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQs github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/smartypants v0.1.0 h1:Sn8hn5XrY+uXrxSWUdcr621Gfpk11mOGGVs4XX06kEw= +github.com/kr/smartypants v0.1.0/go.mod h1:EcTX9ge+SWNaGwbQvHwNICsMGavh98FLUqyOWFr+j9c= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= @@ -138,6 +147,9 @@ github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-zglob v0.0.5 h1:LKgpZXHg94zoBPDbb6aeiCs4SiQSS8otal4JnzkIvMc= +github.com/mattn/go-zglob v0.0.5/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY= github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58= github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -153,6 +165,15 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pdfcpu/pdfcpu v0.7.0 h1:cd7/z7hAyyDuzdciKfNZyQ3TYreJza2DsuPdIHYURcA= github.com/pdfcpu/pdfcpu v0.7.0/go.mod h1:kmpD0rk8YnZj0l3qSeGBlAB+XszHUgNv//ORH/E7EYo= +github.com/pgaskin/kepubify v2.3.2+incompatible h1:0wWWxop5T5O+p6tlZ59saC9hZYtkrXaKWLiTXmUNHHg= +github.com/pgaskin/kepubify v2.3.2+incompatible/go.mod h1:vQR3SJUwNyKStXpUPsVcCjBZtjZ1TgYtgKb8jHMyEDg= +github.com/pgaskin/kepubify/_/go116-zip.go117 v0.0.0-20210611152744-2d89b3182523 h1:pYGj3rKTy+TDs5Z707kT+ztjoIDCy76lc2UPkZocAFM= +github.com/pgaskin/kepubify/_/go116-zip.go117 v0.0.0-20210611152744-2d89b3182523/go.mod h1:FNMbV/TSSnhqyzjq8jsS+VD0o/gwpuCH0dh8G1uQ/fw= +github.com/pgaskin/kepubify/_/html v0.0.0-20211223234002-6ee2cc632cdc h1:mJk4TIXTO+JmxgHJ5iyil42PLQJWkyaKB/qNcjJU6h4= +github.com/pgaskin/kepubify/_/html v0.0.0-20211223234002-6ee2cc632cdc/go.mod h1:fxzoIpMFAReNKunZ+ttVbf3hNVrJGtrSZMI4olZizbs= +github.com/pgaskin/kepubify/v4 v4.0.4 h1:8ePyepo4eRNSmeDs5MdJLJtit+zxmK36wILmGcvpccU= +github.com/pgaskin/kepubify/v4 v4.0.4/go.mod h1:wzUdFNYW2uZh2xfHDuzNRRUO4WqV+y99UBxVd3rBTus= +github.com/pgaskin/koboutils/v2 v2.1.2-0.20220306004009-a07e72ebae42/go.mod h1:wTzkDIlsxmUyfwfspGcm0Ap+HOxSUYV0S8kMYrf+0gM= github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= @@ -181,6 +202,8 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -235,6 +258,7 @@ golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= diff --git a/internal/index/bleve.go b/internal/index/bleve.go index 81e4fa3a..48459f92 100644 --- a/internal/index/bleve.go +++ b/internal/index/bleve.go @@ -19,12 +19,12 @@ import ( "github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode" "github.com/blevesearch/bleve/v2/mapping" "github.com/spf13/afero" - "github.com/svera/coreander/v3/internal/metadata" + "github.com/svera/coreander/v4/internal/metadata" ) // Version identifies the mapping used for indexing. Any changes in the mapping requires an increase // of version, to signal that a new index needs to be created. -const Version = "v2" +const Version = "v3" // Metadata fields var ( @@ -33,11 +33,11 @@ var ( ) var noStopWordsFilters = map[string][]string{ - es.AnalyzerName: {es.NormalizeName, lowercase.Name, es.LightStemmerName}, - en.AnalyzerName: {en.PossessiveName, lowercase.Name, porter.Name}, - de.AnalyzerName: {de.NormalizeName, lowercase.Name, de.LightStemmerName}, - fr.AnalyzerName: {fr.ElisionName, lowercase.Name, fr.LightStemmerName}, - it.AnalyzerName: {it.ElisionName, lowercase.Name, it.LightStemmerName}, + es.AnalyzerName: {lowercase.Name, es.NormalizeName, es.LightStemmerName}, + en.AnalyzerName: {lowercase.Name, en.PossessiveName, porter.Name}, + de.AnalyzerName: {lowercase.Name, de.NormalizeName, de.LightStemmerName}, + fr.AnalyzerName: {lowercase.Name, fr.ElisionName, fr.LightStemmerName}, + it.AnalyzerName: {lowercase.Name, it.ElisionName, it.LightStemmerName}, pt.AnalyzerName: {lowercase.Name, pt.LightStemmerName}, } diff --git a/internal/index/bleve_read.go b/internal/index/bleve_read.go index 614dc1c9..01f9bed3 100644 --- a/internal/index/bleve_read.go +++ b/internal/index/bleve_read.go @@ -13,8 +13,8 @@ import ( "github.com/blevesearch/bleve/v2/search/query" "github.com/gosimple/slug" "github.com/spf13/afero" - "github.com/svera/coreander/v3/internal/metadata" - "github.com/svera/coreander/v3/internal/result" + "github.com/svera/coreander/v4/internal/metadata" + "github.com/svera/coreander/v4/internal/result" ) func (b *BleveIndexer) IndexingProgress() (Progress, error) { diff --git a/internal/index/bleve_test.go b/internal/index/bleve_test.go index f15fb9c4..eedc5318 100644 --- a/internal/index/bleve_test.go +++ b/internal/index/bleve_test.go @@ -6,10 +6,10 @@ import ( "github.com/blevesearch/bleve/v2" "github.com/spf13/afero" - "github.com/svera/coreander/v3/internal/index" - "github.com/svera/coreander/v3/internal/metadata" - "github.com/svera/coreander/v3/internal/result" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/index" + "github.com/svera/coreander/v4/internal/metadata" + "github.com/svera/coreander/v4/internal/result" + "github.com/svera/coreander/v4/internal/webserver/model" ) func TestIndexAndSearch(t *testing.T) { @@ -35,7 +35,7 @@ func TestIndexAndSearch(t *testing.T) { appFS.MkdirAll("lib", 0755) afero.WriteFile(appFS, tcase.filename, []byte(""), 0644) - err = idx.AddLibrary(1) + err = idx.AddLibrary(1, true) if err != nil { t.Errorf("Error indexing: %s", err.Error()) } @@ -290,5 +290,34 @@ func testCases() []testCase { }, ), }, + { + "Test spanish stemmer returning accented word while using unaccented word in search", + "lib/book9.epub", + metadata.Metadata{ + Title: "Últimos días en Colditz", + Authors: []string{"Patrick R. Reid"}, + Description: "Just test metadata", + Language: "es", + Subjects: []string{"History", "WWII"}, + }, + "ultimos", + result.NewPaginated[[]index.Document]( + model.ResultsPerPage, + 1, + 1, + []index.Document{ + { + ID: "book9.epub", + Slug: "patrick-r-reid-ultimos-dias-en-colditz", + Metadata: metadata.Metadata{ + Title: "Últimos días en Colditz", + Authors: []string{"Patrick R. Reid"}, + Description: "Just test metadata", + Subjects: []string{"History", "WWII"}, + }, + }, + }, + ), + }, } } diff --git a/internal/index/bleve_write.go b/internal/index/bleve_write.go index c8f666a6..fb848b99 100644 --- a/internal/index/bleve_write.go +++ b/internal/index/bleve_write.go @@ -5,33 +5,35 @@ import ( "log" "os" "path/filepath" + "regexp" "slices" "strings" "time" + index "github.com/blevesearch/bleve_index_api" "github.com/gosimple/slug" "github.com/spf13/afero" - "github.com/svera/coreander/v3/internal/metadata" + "github.com/svera/coreander/v4/internal/metadata" ) // AddFile adds a file to the index -func (b *BleveIndexer) AddFile(file string) error { +func (b *BleveIndexer) AddFile(file string) (string, error) { ext := strings.ToLower(filepath.Ext(file)) if _, ok := b.reader[ext]; !ok { - return fmt.Errorf("file extension %s not supported", ext) + return "", fmt.Errorf("file extension %s not supported", ext) } meta, err := b.reader[ext].Metadata(file) if err != nil { - return fmt.Errorf("error extracting metadata from file %s: %s", file, err) + return "", fmt.Errorf("error extracting metadata from file %s: %s", file, err) } document := b.createDocument(meta, file, nil) err = b.idx.Index(document.ID, document) if err != nil { - return fmt.Errorf("error indexing file %s: %s", file, err) + return "", fmt.Errorf("error indexing file %s: %s", file, err) } - return nil + return document.Slug, nil } // RemoveFile removes a file from the index @@ -44,13 +46,19 @@ func (b *BleveIndexer) RemoveFile(file string) error { return nil } -// AddLibrary scans for documents and adds them to the index in batches of -func (b *BleveIndexer) AddLibrary(batchSize int) error { +// AddLibrary scans for documents and adds them to the index in batches of if they +// haven't been previously indexed or if is true +func (b *BleveIndexer) AddLibrary(batchSize int, forceIndexing bool) error { batch := b.idx.NewBatch() batchSlugs := make(map[string]struct{}, batchSize) languages := []string{} b.indexStartTime = float64(time.Now().UnixNano()) e := afero.Walk(b.fs, b.libraryPath, func(fullPath string, f os.FileInfo, err error) error { + if indexed, lang := b.isAlreadyIndexed(fullPath); indexed && !forceIndexing { + b.indexedDocuments += 1 + languages = addLanguage(lang, languages) + return nil + } ext := strings.ToLower(filepath.Ext(fullPath)) if _, ok := b.reader[ext]; !ok { return nil @@ -79,13 +87,33 @@ func (b *BleveIndexer) AddLibrary(batchSize int) error { } return nil }) + if len(languages) > 0 { + batch.SetInternal(internalLanguages, []byte(strings.Join(languages, ","))) + } + b.idx.Batch(batch) b.indexStartTime = 0 b.indexedDocuments = 0 - batch.SetInternal(internalLanguages, []byte(strings.Join(languages, ","))) - b.idx.Batch(batch) return e } +func (b *BleveIndexer) isAlreadyIndexed(fullPath string) (bool, string) { + doc, err := b.idx.Document(b.id(fullPath)) + if err != nil { + log.Fatalln(err) + } + if doc == nil { + return false, "" + } + lang := "" + doc.VisitFields(func(f index.Field) { + if f.Name() == "Language" { + lang = string(f.Value()) + return + } + }) + return true, lang +} + func addLanguage(lang string, languages []string) []string { if !slices.Contains(languages, defaultAnalyzer) && lang == "" { return append(languages, defaultAnalyzer) @@ -116,7 +144,7 @@ func (b *BleveIndexer) createDocument(meta metadata.Metadata, fullPath string, b SubjectsEq: make([]string, len(meta.Subjects)), } - document.ID = b.ID(document, fullPath) + document.ID = b.id(fullPath) document.Slug = b.Slug(document, batchSlugs) copy(document.AuthorsEq, meta.Authors) for i := range document.AuthorsEq { @@ -134,6 +162,10 @@ func (b *BleveIndexer) createDocument(meta metadata.Metadata, fullPath string, b // processed in the current batch in memory to also compare the current doc slug against them. func (b *BleveIndexer) Slug(document DocumentWrite, batchSlugs map[string]struct{}) string { docSlug := makeSlug(document) + exp, err := regexp.Compile(`^[a-zA-Z0-9\-]+(--)[0-9]+$`) + if err != nil { + log.Fatal(err) + } i := 1 existsInBatch := false for { @@ -147,12 +179,16 @@ func (b *BleveIndexer) Slug(document DocumentWrite, batchSlugs map[string]struct if doc.Slug == "" && !existsInBatch { return docSlug } + if exp.MatchString(docSlug) { + pos := strings.LastIndex(docSlug, "--") + docSlug = docSlug[:pos] + } i++ - docSlug = fmt.Sprintf("%s-%d", docSlug, i) + docSlug = fmt.Sprintf("%s--%d", docSlug, i) } } -func (b *BleveIndexer) ID(meta DocumentWrite, file string) string { +func (b *BleveIndexer) id(file string) string { ID := strings.ReplaceAll(file, b.libraryPath, "") return strings.TrimPrefix(ID, string(filepath.Separator)) } diff --git a/internal/index/document.go b/internal/index/document.go index 9d2c5f13..d5e0af66 100644 --- a/internal/index/document.go +++ b/internal/index/document.go @@ -1,6 +1,6 @@ package index -import "github.com/svera/coreander/v3/internal/metadata" +import "github.com/svera/coreander/v4/internal/metadata" type Document struct { metadata.Metadata diff --git a/internal/metadata/epub.go b/internal/metadata/epub.go index 562b85cf..ace34881 100644 --- a/internal/metadata/epub.go +++ b/internal/metadata/epub.go @@ -22,26 +22,24 @@ type EpubReader struct{} func (e EpubReader) Metadata(file string) (Metadata, error) { bk := Metadata{} - opf, err := epub.GetPackageFromFile(file) + meta, err := epub.GetMetadataFromFile(file) if err != nil { return bk, err } title := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)) - if len(opf.Metadata.Title) > 0 && len(opf.Metadata.Title[0].Value) > 0 { - title = opf.Metadata.Title[0].Value + if len(meta.Title) > 0 && len(meta.Title[0]) > 0 { + title = meta.Title[0] } var authors []string - if len(opf.Metadata.Creator) > 0 { - for _, creator := range opf.Metadata.Creator { - if creator.Role == "aut" || creator.Role == "" { - // Some epub files mistakenly put all authors in a single field instead of using a field for each one. - // We want to identify those cases looking for specific separators and then indexing each author properly. - names := strings.Split(creator.Value, "&") - for i := range names { - names[i] = strings.TrimSpace(names[i]) - } - authors = append(authors, names...) + for _, creator := range meta.Creator { + if creator.Role == "aut" || creator.Role == "" { + // Some epub files mistakenly put all authors in a single field instead of using a field for each one. + // We want to identify those cases looking for specific separators and then indexing each author properly. + names := strings.Split(creator.FullName, "&") + for i := range names { + names[i] = strings.TrimSpace(names[i]) } + authors = append(authors, names...) } } @@ -50,72 +48,51 @@ func (e EpubReader) Metadata(file string) (Metadata, error) { } var subjects []string - if len(opf.Metadata.Subject) > 0 { - for _, subject := range opf.Metadata.Subject { - subject.Value = strings.TrimSpace(subject.Value) - if subject.Value == "" { - continue - } - // Some epub files mistakenly put all subjects in a single field instead of using a field for each one. - // We want to identify those cases looking for specific separators and then indexing each subject properly. - names := strings.Split(subject.Value, ",") - for i := range names { - names[i] = strings.TrimSpace(names[i]) - } - subjects = append(subjects, names...) + for _, subject := range meta.Subject { + subject = strings.TrimSpace(subject) + if subject == "" { + continue + } + // Some epub files mistakenly put all subjects in a single field instead of using a field for each one. + // We want to identify those cases looking for specific separators and then indexing each subject properly. + names := strings.Split(subject, ",") + for i := range names { + names[i] = strings.TrimSpace(names[i]) } + subjects = append(subjects, names...) } description := "" - if len(opf.Metadata.Description) > 0 { + if len(meta.Description) > 0 { strict := bluemonday.StrictPolicy() - noHTMLDescription := strict.Sanitize(opf.Metadata.Description[0].Value) - if noHTMLDescription == opf.Metadata.Description[0].Value { - paragraphs := strings.Split(opf.Metadata.Description[0].Value, "\n") + noHTMLDescription := strict.Sanitize(meta.Description[0]) + if noHTMLDescription == meta.Description[0] { + paragraphs := strings.Split(meta.Description[0], "\n") description = "

" + strings.Join(paragraphs, "

") + "

" } else { p := bluemonday.UGCPolicy() - description = p.Sanitize(opf.Metadata.Description[0].Value) + description = p.Sanitize(meta.Description[0]) } } lang := "" - if len(opf.Metadata.Language) > 0 { - lang = opf.Metadata.Language[0].Value + if len(meta.Language) > 0 { + lang = meta.Language[0] } year := "" - if len(opf.Metadata.Date) > 0 { - for _, date := range opf.Metadata.Date { - if date.Event == "publication" || date.Event == "" { - t, err := time.Parse("2006-01-02", date.Value) - if err == nil { - year = strings.TrimLeft(t.Format("2006"), "0") - break - } + for _, date := range meta.Date { + if date.Event == "publication" || date.Event == "" { + t, err := time.Parse("2006-01-02", date.Stamp) + if err == nil { + year = strings.TrimLeft(t.Format("2006"), "0") + break } } } - cover := "" - series := "" var seriesIndex float64 = 0 - for _, val := range opf.Metadata.Meta { - if val.Name == "cover" { - id := val.Content - for _, item := range opf.Manifest.Items { - if item.ID == id { - cover = item.Href - break - } - } - } - if val.Name == "calibre:series" { - series = val.Content - } - if val.Name == "calibre:series_index" { - seriesIndex, _ = strconv.ParseFloat(val.Content, 64) - } - } + + seriesIndex, _ = strconv.ParseFloat(meta.SeriesIndex, 64) bk = Metadata{ Title: title, @@ -123,8 +100,7 @@ func (e EpubReader) Metadata(file string) (Metadata, error) { Description: template.HTML(description), Language: lang, Year: year, - Cover: cover, - Series: series, + Series: meta.Series, SeriesIndex: seriesIndex, Type: "EPUB", Subjects: subjects, @@ -141,12 +117,26 @@ func (e EpubReader) Metadata(file string) (Metadata, error) { func (e EpubReader) Cover(documentFullPath string, coverMaxWidth int) ([]byte, error) { var cover []byte - reader := EpubReader{} - meta, err := reader.Metadata(documentFullPath) + coverFileName := "" + + opf, err := epub.GetPackageFromFile(documentFullPath) if err != nil { return nil, err } - if meta.Cover == "" { + + for _, val := range opf.Metadata.Meta { + if val.Name != "cover" { + continue + } + for _, item := range opf.Manifest.Items { + if item.ID == val.Content { + coverFileName = item.Href + break + } + } + } + + if coverFileName == "" { return nil, fmt.Errorf("no cover image set in %s", documentFullPath) } @@ -155,7 +145,7 @@ func (e EpubReader) Cover(documentFullPath string, coverMaxWidth int) ([]byte, e return nil, err } defer r.Close() - cover, err = extractCover(r, meta.Cover, coverMaxWidth) + cover, err = extractCover(r, coverFileName, coverMaxWidth) if err != nil { return nil, err } diff --git a/internal/metadata/metadata.go b/internal/metadata/metadata.go index 55a4f865..aac25193 100644 --- a/internal/metadata/metadata.go +++ b/internal/metadata/metadata.go @@ -13,7 +13,6 @@ type Metadata struct { Language string Year string Words float64 - Cover string Series string SeriesIndex float64 Pages int diff --git a/internal/webserver/authentication_test.go b/internal/webserver/authentication_test.go index fcfc2840..b9f24e78 100644 --- a/internal/webserver/authentication_test.go +++ b/internal/webserver/authentication_test.go @@ -12,14 +12,14 @@ import ( "github.com/PuerkitoBio/goquery" "github.com/gofiber/fiber/v2" "github.com/spf13/afero" - "github.com/svera/coreander/v3/internal/webserver" - "github.com/svera/coreander/v3/internal/webserver/infrastructure" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver" + "github.com/svera/coreander/v4/internal/webserver/infrastructure" + "github.com/svera/coreander/v4/internal/webserver/model" "gorm.io/gorm" ) func TestAuthentication(t *testing.T) { - db := infrastructure.Connect("file::memory:", 250) + db := infrastructure.Connect(":memory:", 250) app := bootstrapApp(db, &infrastructure.SMTP{}, afero.NewMemMapFs(), webserver.Config{}) data := url.Values{ @@ -29,7 +29,7 @@ func TestAuthentication(t *testing.T) { t.Run("Try to log in with good and bad credentials", func(t *testing.T) { // Check that login page is accessible - req, err := http.NewRequest(http.MethodGet, "/en/login", nil) + req, err := http.NewRequest(http.MethodGet, "/sessions/new", nil) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -42,7 +42,7 @@ func TestAuthentication(t *testing.T) { } // Use no credentials to log in - req, err = http.NewRequest(http.MethodPost, "/en/login", nil) + req, err = http.NewRequest(http.MethodPost, "/sessions", nil) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) @@ -56,7 +56,7 @@ func TestAuthentication(t *testing.T) { } // Use good credentials to log in - req, err = http.NewRequest(http.MethodPost, "/en/login", strings.NewReader(data.Encode())) + req, err = http.NewRequest(http.MethodPost, "/sessions", strings.NewReader(data.Encode())) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) @@ -75,17 +75,17 @@ func TestAuthentication(t *testing.T) { t.Error("No location header present") return } - if url.Path != "/en" { - t.Errorf("Expected location %s, received %s", "/en", url.Path) + if url.Path != "/" { + t.Errorf("Expected location %s, received %s", "/", url.Path) } }) } func TestRecoverNoEmailService(t *testing.T) { - db := infrastructure.Connect("file::memory:?cache=shared", 250) + db := infrastructure.Connect(":memory:?cache=shared", 250) app := bootstrapApp(db, &infrastructure.NoEmail{}, afero.NewMemMapFs(), webserver.Config{}) - req, err := http.NewRequest(http.MethodGet, "/en/recover", nil) + req, err := http.NewRequest(http.MethodGet, "/recover", nil) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -115,7 +115,7 @@ func TestRecover(t *testing.T) { LibraryPath: "fixtures/library", UploadDocumentMaxSize: 1, } - db = infrastructure.Connect("file::memory:?cache=shared", 250) + db = infrastructure.Connect(":memory:?cache=shared", 250) smtpMock = &infrastructure.SMTPMock{} app = bootstrapApp(db, smtpMock, afero.NewMemMapFs(), webserverConfig) @@ -127,7 +127,7 @@ func TestRecover(t *testing.T) { t.Run("Check that recover page is accessible", func(t *testing.T) { reset(2 * time.Hour) - req, err := http.NewRequest(http.MethodGet, "/en/recover", nil) + req, err := http.NewRequest(http.MethodGet, "/recover", nil) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -143,7 +143,7 @@ func TestRecover(t *testing.T) { t.Run("Check that not posting an email returns an error", func(t *testing.T) { reset(2 * time.Hour) - req, err := http.NewRequest(http.MethodPost, "/en/recover", nil) + req, err := http.NewRequest(http.MethodPost, "/recover", nil) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) @@ -180,7 +180,7 @@ func TestRecover(t *testing.T) { "email": {"unknown@example.com"}, } - req, err := http.NewRequest(http.MethodPost, "/en/recover", strings.NewReader(data.Encode())) + req, err := http.NewRequest(http.MethodPost, "/recover", strings.NewReader(data.Encode())) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) @@ -202,7 +202,7 @@ func TestRecover(t *testing.T) { t.Run("Try to access the update password without the recovery ID", func(t *testing.T) { reset(2 * time.Hour) - req, err := http.NewRequest(http.MethodGet, "/en/reset-password", nil) + req, err := http.NewRequest(http.MethodGet, "/reset-password", nil) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -218,7 +218,7 @@ func TestRecover(t *testing.T) { t.Run("Check that posting an existing email sends a recovery email and resetting the password successfully redirects to the login page", func(t *testing.T) { reset(2 * time.Hour) - req, err := http.NewRequest(http.MethodPost, "/en/recover", strings.NewReader(data.Encode())) + req, err := http.NewRequest(http.MethodPost, "/recover", strings.NewReader(data.Encode())) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) @@ -248,7 +248,7 @@ func TestRecover(t *testing.T) { "id": {adminUser.RecoveryUUID}, } - req, err = http.NewRequest(http.MethodPost, "/en/reset-password", strings.NewReader(data.Encode())) + req, err = http.NewRequest(http.MethodPost, "/reset-password", strings.NewReader(data.Encode())) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) @@ -267,14 +267,14 @@ func TestRecover(t *testing.T) { t.Error("No location header present") return } - if expectedURL := "/en/login"; url.Path != expectedURL { + if expectedURL := "/sessions"; url.Path != expectedURL { t.Errorf("Expected location %s, received %s", expectedURL, url.Path) } // Try to access again to the reset password page with the same recovery ID leads to an error db.Where("email = ?", "admin@example.com").First(&adminUser) - req, err = http.NewRequest(http.MethodGet, "/en/reset-password", nil) + req, err = http.NewRequest(http.MethodGet, "/reset-password", nil) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -295,7 +295,7 @@ func TestRecover(t *testing.T) { t.Run("Check that using a timed out link returns an error", func(t *testing.T) { reset(0 * time.Hour) - req, err := http.NewRequest(http.MethodPost, "/en/recover", strings.NewReader(data.Encode())) + req, err := http.NewRequest(http.MethodPost, "/recover", strings.NewReader(data.Encode())) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) @@ -315,7 +315,7 @@ func TestRecover(t *testing.T) { adminUser := model.User{} db.Where("email = ?", "admin@example.com").First(&adminUser) - req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/en/reset-password?id=%s", adminUser.RecoveryUUID), nil) + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/reset-password?id=%s", adminUser.RecoveryUUID), nil) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -336,7 +336,7 @@ func TestRecover(t *testing.T) { "id": {adminUser.RecoveryUUID}, } - req, err = http.NewRequest(http.MethodPost, "/en/reset-password", strings.NewReader(data.Encode())) + req, err = http.NewRequest(http.MethodPost, "/reset-password", strings.NewReader(data.Encode())) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) diff --git a/internal/webserver/controller.go b/internal/webserver/controller.go index 1f309ef1..0e6a0dd6 100644 --- a/internal/webserver/controller.go +++ b/internal/webserver/controller.go @@ -2,13 +2,13 @@ package webserver import ( "github.com/spf13/afero" - "github.com/svera/coreander/v3/internal/index" - "github.com/svera/coreander/v3/internal/metadata" - "github.com/svera/coreander/v3/internal/webserver/controller/auth" - "github.com/svera/coreander/v3/internal/webserver/controller/document" - "github.com/svera/coreander/v3/internal/webserver/controller/highlight" - "github.com/svera/coreander/v3/internal/webserver/controller/user" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/index" + "github.com/svera/coreander/v4/internal/metadata" + "github.com/svera/coreander/v4/internal/webserver/controller/auth" + "github.com/svera/coreander/v4/internal/webserver/controller/document" + "github.com/svera/coreander/v4/internal/webserver/controller/highlight" + "github.com/svera/coreander/v4/internal/webserver/controller/user" + "github.com/svera/coreander/v4/internal/webserver/model" "gorm.io/gorm" ) diff --git a/internal/webserver/controller/auth/controller.go b/internal/webserver/controller/auth/controller.go index 56a04ec2..0d046034 100644 --- a/internal/webserver/controller/auth/controller.go +++ b/internal/webserver/controller/auth/controller.go @@ -3,7 +3,7 @@ package auth import ( "time" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver/model" "golang.org/x/text/message" ) diff --git a/internal/webserver/controller/auth/login.go b/internal/webserver/controller/auth/login.go index 1eb1ad66..2dc9a6c0 100644 --- a/internal/webserver/controller/auth/login.go +++ b/internal/webserver/controller/auth/login.go @@ -5,14 +5,13 @@ import ( "strings" "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/webserver/infrastructure" + "github.com/svera/coreander/v4/internal/webserver/infrastructure" ) func (a *Controller) Login(c *fiber.Ctx) error { resetPassword := fmt.Sprintf( - "%s/%s/reset-password", + "%s/reset-password", c.Locals("fqdn").(string), - c.Params("lang"), ) msg := "" @@ -29,5 +28,6 @@ func (a *Controller) Login(c *fiber.Ctx) error { "Title": "Login", "Message": msg, "EmailSendingConfigured": emailSendingConfigured, + "DisableLoginLink": true, }, "layout") } diff --git a/internal/webserver/controller/auth/recover.go b/internal/webserver/controller/auth/recover.go index 9f17c798..b2c1a126 100644 --- a/internal/webserver/controller/auth/recover.go +++ b/internal/webserver/controller/auth/recover.go @@ -2,7 +2,7 @@ package auth import ( "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/webserver/infrastructure" + "github.com/svera/coreander/v4/internal/webserver/infrastructure" ) func (a *Controller) Recover(c *fiber.Ctx) error { diff --git a/internal/webserver/controller/auth/request.go b/internal/webserver/controller/auth/request.go index c30d9e1d..1d2116ad 100644 --- a/internal/webserver/controller/auth/request.go +++ b/internal/webserver/controller/auth/request.go @@ -8,7 +8,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/google/uuid" - "github.com/svera/coreander/v3/internal/webserver/infrastructure" + "github.com/svera/coreander/v4/internal/webserver/infrastructure" ) func (a *Controller) Request(c *fiber.Ctx) error { @@ -36,20 +36,18 @@ func (a *Controller) Request(c *fiber.Ctx) error { } recoveryLink := fmt.Sprintf( - "%s/%s/reset-password?id=%s", + "%s/reset-password?id=%s", c.Locals("fqdn"), - c.Params("lang"), user.RecoveryUUID, ) c.Render("auth/email", fiber.Map{ - "Lang": c.Params("lang"), "RecoveryLink": recoveryLink, "RecoveryTimeout": strconv.FormatFloat(a.config.RecoveryTimeout.Hours(), 'f', -1, 64), }) - return a.sender.Send( + a.sender.Send( c.FormValue("email"), - a.printers[c.Params("lang")].Sprintf("Password recovery request"), + a.printers[c.Locals("Lang").(string)].Sprintf("Password recovery request"), string(c.Response().Body()), ) } diff --git a/internal/webserver/controller/auth/reset-password.go b/internal/webserver/controller/auth/reset-password.go index 2884eeaf..33a32005 100644 --- a/internal/webserver/controller/auth/reset-password.go +++ b/internal/webserver/controller/auth/reset-password.go @@ -1,12 +1,11 @@ package auth import ( - "fmt" "time" "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/webserver/infrastructure" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver/infrastructure" + "github.com/svera/coreander/v4/internal/webserver/model" ) func (a *Controller) EditPassword(c *fiber.Ctx) error { @@ -45,7 +44,7 @@ func (a *Controller) UpdatePassword(c *fiber.Ctx) error { return fiber.ErrInternalServerError } - return c.Redirect(fmt.Sprintf("/%s/login", c.Params("lang"))) + return c.Redirect("/sessions") } func (a *Controller) validateRecoveryAccess(recoveryUuid string) (*model.User, error) { diff --git a/internal/webserver/controller/auth/signin.go b/internal/webserver/controller/auth/signin.go index ef4ae10c..b43a5a42 100644 --- a/internal/webserver/controller/auth/signin.go +++ b/internal/webserver/controller/auth/signin.go @@ -1,13 +1,12 @@ package auth import ( - "fmt" "strings" "time" "github.com/gofiber/fiber/v2" "github.com/golang-jwt/jwt/v4" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver/model" ) // Signs in a user and gives them a JWT. @@ -41,7 +40,7 @@ func (a *Controller) SignIn(c *fiber.Ctx) error { Name: "session", Value: signedToken, Path: "/", - MaxAge: int(a.config.SessionTimeout.Seconds()), + MaxAge: 34560000, // 400 days which is the life limit imposed by Chrome Secure: false, HTTPOnly: true, }) @@ -51,7 +50,7 @@ func (a *Controller) SignIn(c *fiber.Ctx) error { return c.Redirect(referer) } - return c.Redirect(fmt.Sprintf("/%s", c.Params("lang"))) + return c.Redirect("/") } func GenerateToken(c *fiber.Ctx, user *model.User, expiration time.Time, secret []byte) (string, error) { diff --git a/internal/webserver/controller/auth/signout.go b/internal/webserver/controller/auth/signout.go index e90f6dae..5378fb2d 100644 --- a/internal/webserver/controller/auth/signout.go +++ b/internal/webserver/controller/auth/signout.go @@ -1,8 +1,6 @@ package auth import ( - "fmt" - "github.com/gofiber/fiber/v2" ) @@ -16,6 +14,6 @@ func (a *Controller) SignOut(c *fiber.Ctx) error { Secure: false, HTTPOnly: true, }) - - return c.Redirect(fmt.Sprintf("/%s", c.Params("lang"))) + c.Set("HX-Refresh", "true") + return c.SendStatus(fiber.StatusNoContent) } diff --git a/internal/webserver/controller/document/controller.go b/internal/webserver/controller/document/controller.go index 44f61be4..82bcf617 100644 --- a/internal/webserver/controller/document/controller.go +++ b/internal/webserver/controller/document/controller.go @@ -2,9 +2,9 @@ package document import ( "github.com/spf13/afero" - "github.com/svera/coreander/v3/internal/index" - "github.com/svera/coreander/v3/internal/metadata" - "github.com/svera/coreander/v3/internal/result" + "github.com/svera/coreander/v4/internal/index" + "github.com/svera/coreander/v4/internal/metadata" + "github.com/svera/coreander/v4/internal/result" ) const relatedDocuments = 4 @@ -23,7 +23,7 @@ type IdxReaderWriter interface { SameSubjects(slug string, quantity int) ([]index.Document, error) SameAuthors(slug string, quantity int) ([]index.Document, error) SameSeries(slug string, quantity int) ([]index.Document, error) - AddFile(file string) error + AddFile(file string) (string, error) RemoveFile(file string) error Documents(IDs []string) (map[string]index.Document, error) } diff --git a/internal/webserver/controller/document/delete.go b/internal/webserver/controller/document/delete.go index f5f88760..eee18d7d 100644 --- a/internal/webserver/controller/document/delete.go +++ b/internal/webserver/controller/document/delete.go @@ -1,7 +1,6 @@ package document import ( - "fmt" "log" "path/filepath" @@ -9,13 +8,8 @@ import ( ) func (d *Controller) Delete(c *fiber.Ctx) error { - if c.FormValue("id") == "" { - return fiber.ErrBadRequest - } - - document, err := d.idx.Document(c.FormValue("id")) + document, err := d.idx.Document(c.Params("slug")) if err != nil { - fmt.Println(err) return fiber.ErrBadRequest } diff --git a/internal/webserver/controller/document/detail.go b/internal/webserver/controller/document/detail.go index e468912b..7ab8e037 100644 --- a/internal/webserver/controller/document/detail.go +++ b/internal/webserver/controller/document/detail.go @@ -7,8 +7,8 @@ import ( "strings" "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/webserver/infrastructure" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver/infrastructure" + "github.com/svera/coreander/v4/internal/webserver/model" ) func (d *Controller) Detail(c *fiber.Ctx) error { @@ -60,6 +60,11 @@ func (d *Controller) Detail(c *fiber.Ctx) error { document = d.hlRepository.Highlighted(int(session.ID), document) } + msg := "" + if c.Query("success") != "" { + msg = "Document uploaded successfully." + } + return c.Render("document", fiber.Map{ "Title": title, "Document": document, @@ -69,5 +74,6 @@ func (d *Controller) Detail(c *fiber.Ctx) error { "SameAuthors": sameAuthors, "SameSubjects": sameSubjects, "WordsPerMinute": d.config.WordsPerMinute, + "Message": msg, }, "layout") } diff --git a/internal/webserver/controller/document/download.go b/internal/webserver/controller/document/download.go index 7c997900..abc4b305 100644 --- a/internal/webserver/controller/document/download.go +++ b/internal/webserver/controller/document/download.go @@ -1,16 +1,27 @@ package document import ( + "archive/zip" + "bytes" + "context" "fmt" "io" + "log" "os" "path/filepath" "strings" "github.com/gofiber/fiber/v2" + "github.com/pgaskin/kepubify/v4/kepub" ) func (d *Controller) Download(c *fiber.Ctx) error { + var ( + output []byte + err error + fileName string + ) + document, err := d.idx.Document(c.Params("slug")) if err != nil { return fiber.ErrBadRequest @@ -22,13 +33,25 @@ func (d *Controller) Download(c *fiber.Ctx) error { return fiber.ErrNotFound } - file, err := os.Open(fullPath) - if err != nil { - return fiber.ErrInternalServerError - } - contents, err := io.ReadAll(file) - if err != nil { - return fiber.ErrInternalServerError + if c.Query("format") == "kepub" { + output, err = kepubify(fullPath) + if err != nil { + log.Println(err) + return fiber.ErrInternalServerError + } + fileName = strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)) + fileName = fileName + ".kepub.epub" + } else { + file, err := os.Open(fullPath) + if err != nil { + log.Println(err) + return fiber.ErrInternalServerError + } + if output, err = io.ReadAll(file); err != nil { + log.Println(err) + return fiber.ErrInternalServerError + } + fileName = filepath.Base(document.ID) } ext := strings.ToLower(filepath.Ext(document.ID)) @@ -39,7 +62,22 @@ func (d *Controller) Download(c *fiber.Ctx) error { c.Response().Header.Set(fiber.HeaderContentType, "application/pdf") } - c.Response().Header.Set(fiber.HeaderContentDisposition, fmt.Sprintf("inline; filename=\"%s\"", filepath.Base(document.ID))) - c.Response().BodyWriter().Write(contents) + c.Response().Header.Set(fiber.HeaderContentDisposition, fmt.Sprintf("inline; filename=\"%s\"", fileName)) + c.Response().BodyWriter().Write(output) return nil } + +func kepubify(fullPath string) ([]byte, error) { + output := bytes.NewBuffer(nil) + r, err := zip.OpenReader(fullPath) + if err != nil { + return nil, fiber.ErrInternalServerError + } + defer r.Close() + + if err = kepub.NewConverter().Convert(context.Background(), output, r); err != nil { + return nil, fiber.ErrInternalServerError + } + + return output.Bytes(), nil +} diff --git a/internal/webserver/controller/document/search.go b/internal/webserver/controller/document/search.go index 64744f79..a21b8f41 100644 --- a/internal/webserver/controller/document/search.go +++ b/internal/webserver/controller/document/search.go @@ -1,14 +1,15 @@ package document import ( + "log" "strconv" "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/index" - "github.com/svera/coreander/v3/internal/result" - "github.com/svera/coreander/v3/internal/webserver/infrastructure" - "github.com/svera/coreander/v3/internal/webserver/model" - "github.com/svera/coreander/v3/internal/webserver/view" + "github.com/svera/coreander/v4/internal/index" + "github.com/svera/coreander/v4/internal/result" + "github.com/svera/coreander/v4/internal/webserver/infrastructure" + "github.com/svera/coreander/v4/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver/view" ) func (d *Controller) Search(c *fiber.Ctx) error { @@ -35,6 +36,7 @@ func (d *Controller) Search(c *fiber.Ctx) error { if keywords := c.Query("search"); keywords != "" { if searchResults, err = d.idx.Search(keywords, page, model.ResultsPerPage); err != nil { + log.Println(err) return fiber.ErrInternalServerError } diff --git a/internal/webserver/controller/document/send.go b/internal/webserver/controller/document/send.go index f8898882..ba6b6c14 100644 --- a/internal/webserver/controller/document/send.go +++ b/internal/webserver/controller/document/send.go @@ -1,6 +1,7 @@ package document import ( + "log" "net/mail" "os" "path/filepath" @@ -10,7 +11,8 @@ import ( ) func (d *Controller) Send(c *fiber.Ctx) error { - if strings.Trim(c.FormValue("slug"), " ") == "" { + slug := "" + if slug = strings.Trim(c.Params("slug"), " "); slug == "" { return fiber.ErrBadRequest } @@ -18,13 +20,14 @@ func (d *Controller) Send(c *fiber.Ctx) error { return fiber.ErrBadRequest } - document, err := d.idx.Document(c.FormValue("slug")) + document, err := d.idx.Document(slug) if err != nil { return fiber.ErrBadRequest } if _, err := os.Stat(filepath.Join(d.config.LibraryPath, document.ID)); err != nil { - return fiber.ErrBadRequest + log.Println(err) + return fiber.ErrInternalServerError } return d.sender.SendDocument(c.FormValue("email"), d.config.LibraryPath, document.ID) diff --git a/internal/webserver/controller/document/upload.go b/internal/webserver/controller/document/upload.go index 53f1fc62..5bda91bd 100644 --- a/internal/webserver/controller/document/upload.go +++ b/internal/webserver/controller/document/upload.go @@ -11,18 +11,13 @@ import ( "slices" "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/log" "github.com/valyala/fasthttp" ) func (d *Controller) UploadForm(c *fiber.Ctx) error { - msg := "" - if c.Query("success") != "" { - msg = "Document uploaded successfully." - } - return c.Render("upload", fiber.Map{ "Title": "Coreander", - "Message": msg, "MaxSize": d.config.UploadDocumentMaxSize, }, "layout") } @@ -61,11 +56,13 @@ func (d *Controller) Upload(c *fiber.Ctx) error { bytes, err := fileToBytes(file) if err != nil { + log.Error() return internalServerErrorStatus } destFile, err := d.appFs.Create(destination) if err != nil { + log.Error(err) return internalServerErrorStatus } @@ -74,12 +71,14 @@ func (d *Controller) Upload(c *fiber.Ctx) error { } destFile.Close() - if err := d.idx.AddFile(destination); err != nil { + slug, err := d.idx.AddFile(destination) + if err != nil { + log.Error(err) os.Remove(destination) return internalServerErrorStatus } - return c.Redirect(fmt.Sprintf("/%s/upload?success=1", c.Params("lang"))) + return c.Redirect(fmt.Sprintf("/documents/%s?success=1", slug)) } func fileToBytes(fileHeader *multipart.FileHeader) ([]byte, error) { diff --git a/internal/webserver/controller/highlight/controller.go b/internal/webserver/controller/highlight/controller.go index 695954f9..0a75306d 100644 --- a/internal/webserver/controller/highlight/controller.go +++ b/internal/webserver/controller/highlight/controller.go @@ -1,9 +1,9 @@ package highlight import ( - "github.com/svera/coreander/v3/internal/index" - "github.com/svera/coreander/v3/internal/result" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/index" + "github.com/svera/coreander/v4/internal/result" + "github.com/svera/coreander/v4/internal/webserver/model" ) type highlightsRepository interface { diff --git a/internal/webserver/controller/highlight/highlight.go b/internal/webserver/controller/highlight/create.go similarity index 57% rename from internal/webserver/controller/highlight/highlight.go rename to internal/webserver/controller/highlight/create.go index 3f16c85a..f201d4ad 100644 --- a/internal/webserver/controller/highlight/highlight.go +++ b/internal/webserver/controller/highlight/create.go @@ -2,13 +2,13 @@ package highlight import ( "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver/model" ) -func (h *Controller) Highlight(c *fiber.Ctx) error { +func (h *Controller) Create(c *fiber.Ctx) error { user := c.Locals("Session").(model.Session) - document, err := h.idx.Document(c.FormValue("slug")) + document, err := h.idx.Document(c.Params("slug")) if err != nil { return fiber.ErrBadRequest } diff --git a/internal/webserver/controller/highlight/remove.go b/internal/webserver/controller/highlight/delete.go similarity index 57% rename from internal/webserver/controller/highlight/remove.go rename to internal/webserver/controller/highlight/delete.go index 37341f54..3c4f419a 100644 --- a/internal/webserver/controller/highlight/remove.go +++ b/internal/webserver/controller/highlight/delete.go @@ -2,13 +2,13 @@ package highlight import ( "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver/model" ) -func (h *Controller) Remove(c *fiber.Ctx) error { +func (h *Controller) Delete(c *fiber.Ctx) error { user := c.Locals("Session").(model.Session) - document, err := h.idx.Document(c.FormValue("slug")) + document, err := h.idx.Document(c.Params("slug")) if err != nil { return fiber.ErrBadRequest } diff --git a/internal/webserver/controller/highlight/highlights.go b/internal/webserver/controller/highlight/list.go similarity index 80% rename from internal/webserver/controller/highlight/highlights.go rename to internal/webserver/controller/highlight/list.go index 98fcc534..d3a8d162 100644 --- a/internal/webserver/controller/highlight/highlights.go +++ b/internal/webserver/controller/highlight/list.go @@ -5,14 +5,14 @@ import ( "strconv" "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/index" - "github.com/svera/coreander/v3/internal/result" - "github.com/svera/coreander/v3/internal/webserver/infrastructure" - "github.com/svera/coreander/v3/internal/webserver/model" - "github.com/svera/coreander/v3/internal/webserver/view" + "github.com/svera/coreander/v4/internal/index" + "github.com/svera/coreander/v4/internal/result" + "github.com/svera/coreander/v4/internal/webserver/infrastructure" + "github.com/svera/coreander/v4/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver/view" ) -func (h *Controller) Highlights(c *fiber.Ctx) error { +func (h *Controller) List(c *fiber.Ctx) error { emailSendingConfigured := true if _, ok := h.sender.(*infrastructure.NoEmail); ok { emailSendingConfigured = false @@ -32,7 +32,7 @@ func (h *Controller) Highlights(c *fiber.Ctx) error { h.wordsPerMinute = session.WordsPerMinute } - user, err := h.usrRepository.FindByUsername(c.Params("username")) + user, err := h.usrRepository.FindByUsername(session.Username) if err != nil { log.Println(err.Error()) return fiber.ErrInternalServerError diff --git a/internal/webserver/controller/user/controller.go b/internal/webserver/controller/user/controller.go index c64a3372..4167836d 100644 --- a/internal/webserver/controller/user/controller.go +++ b/internal/webserver/controller/user/controller.go @@ -1,8 +1,8 @@ package user import ( - "github.com/svera/coreander/v3/internal/result" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/result" + "github.com/svera/coreander/v4/internal/webserver/model" ) type usersRepository interface { diff --git a/internal/webserver/controller/user/create.go b/internal/webserver/controller/user/create.go index c31eb68f..b639567b 100644 --- a/internal/webserver/controller/user/create.go +++ b/internal/webserver/controller/user/create.go @@ -1,13 +1,12 @@ package user import ( - "fmt" "strconv" "strings" "github.com/gofiber/fiber/v2" "github.com/google/uuid" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver/model" ) // Create gathers information coming from the new user form and creates a new user @@ -47,5 +46,5 @@ func (u *Controller) Create(c *fiber.Ctx) error { return fiber.ErrInternalServerError } - return c.Redirect(fmt.Sprintf("/%s/users", c.Params("lang"))) + return c.Redirect("/users") } diff --git a/internal/webserver/controller/user/delete.go b/internal/webserver/controller/user/delete.go index fe4f5279..6ac9d520 100644 --- a/internal/webserver/controller/user/delete.go +++ b/internal/webserver/controller/user/delete.go @@ -2,12 +2,12 @@ package user import ( "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver/model" ) // Delete removes a user from the database func (u *Controller) Delete(c *fiber.Ctx) error { - user, err := u.repository.FindByUuid(c.FormValue("id")) + user, err := u.repository.FindByUsername(c.Params("username")) if err != nil { return fiber.ErrInternalServerError } @@ -20,7 +20,7 @@ func (u *Controller) Delete(c *fiber.Ctx) error { return fiber.ErrForbidden } - if err = u.repository.Delete(c.FormValue("id")); err != nil { + if err = u.repository.Delete(user.Uuid); err != nil { return fiber.ErrInternalServerError } diff --git a/internal/webserver/controller/user/edit.go b/internal/webserver/controller/user/edit.go index 1770a157..6bbe855c 100644 --- a/internal/webserver/controller/user/edit.go +++ b/internal/webserver/controller/user/edit.go @@ -4,7 +4,7 @@ import ( "log" "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver/model" ) // Edit renders the edit user form diff --git a/internal/webserver/controller/user/list.go b/internal/webserver/controller/user/list.go index 703c4f38..f4f65137 100644 --- a/internal/webserver/controller/user/list.go +++ b/internal/webserver/controller/user/list.go @@ -4,8 +4,8 @@ import ( "strconv" "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/webserver/model" - "github.com/svera/coreander/v3/internal/webserver/view" + "github.com/svera/coreander/v4/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver/view" ) // List list all users registered in the database diff --git a/internal/webserver/controller/user/new.go b/internal/webserver/controller/user/new.go index d1940e11..ffcb8f33 100644 --- a/internal/webserver/controller/user/new.go +++ b/internal/webserver/controller/user/new.go @@ -2,7 +2,7 @@ package user import ( "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver/model" ) // New renders the new user form diff --git a/internal/webserver/controller/user/update.go b/internal/webserver/controller/user/update.go index 7c7ef33e..ba72dfa4 100644 --- a/internal/webserver/controller/user/update.go +++ b/internal/webserver/controller/user/update.go @@ -7,13 +7,13 @@ import ( "time" "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/webserver/controller/auth" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver/controller/auth" + "github.com/svera/coreander/v4/internal/webserver/model" ) // Update gathers information from the edit user form and updates user data func (u *Controller) Update(c *fiber.Ctx) error { - user, err := u.repository.FindByUuid(c.FormValue("id")) + user, err := u.repository.FindByUsername(c.Params("username")) if err != nil { log.Println(err.Error()) return fiber.ErrInternalServerError @@ -27,7 +27,7 @@ func (u *Controller) Update(c *fiber.Ctx) error { session = val } - if session.Role != model.RoleAdmin && user.Uuid != session.Uuid { + if session.Role != model.RoleAdmin && user.Username != session.Username { return fiber.ErrForbidden } @@ -51,13 +51,13 @@ func (u *Controller) updateUserData(c *fiber.Ctx, user *model.User, session mode } if len(validationErrs) > 0 { - return c.Render("users/edit", fiber.Map{ + return c.Status(fiber.StatusBadRequest).Render("users/edit", fiber.Map{ "Title": "Edit user", "User": user, "MinPasswordLength": u.config.MinPasswordLength, "UsernamePattern": model.UsernamePattern, "Errors": validationErrs, - }, "layout") + }, "partials/main") } if err := u.repository.Update(user); err != nil { @@ -89,7 +89,7 @@ func (u *Controller) updateUserData(c *fiber.Ctx, user *model.User, session mode "UsernamePattern": model.UsernamePattern, "Errors": validationErrs, "Message": "Profile updated", - }, "layout") + }, "partials/main") } func (u *Controller) validate(c *fiber.Ctx, user *model.User, session model.Session) (map[string]string, error) { diff --git a/internal/webserver/document_detail_test.go b/internal/webserver/document_detail_test.go index 02d0734f..fbfbf417 100644 --- a/internal/webserver/document_detail_test.go +++ b/internal/webserver/document_detail_test.go @@ -5,12 +5,12 @@ import ( "testing" "github.com/spf13/afero" - "github.com/svera/coreander/v3/internal/webserver" - "github.com/svera/coreander/v3/internal/webserver/infrastructure" + "github.com/svera/coreander/v4/internal/webserver" + "github.com/svera/coreander/v4/internal/webserver/infrastructure" ) func TestDocumentAndRead(t *testing.T) { - db := infrastructure.Connect("file::memory:", 250) + db := infrastructure.Connect(":memory:", 250) smtpMock := &infrastructure.SMTPMock{} app := bootstrapApp(db, smtpMock, afero.NewOsFs(), webserver.Config{}) @@ -18,9 +18,10 @@ func TestDocumentAndRead(t *testing.T) { url string expectedStatus int }{ - {"/en/read/miguel-de-cervantes-y-saavedra-don-quijote-de-la-mancha", http.StatusOK}, - {"/en/read/miguel-de-cervantes-y-saavedra-don-quijote-de-la-mancha-2", http.StatusOK}, - {"/en/document/miguel-de-cervantes-y-saavedra-don-quijote-de-la-mancha", http.StatusOK}, + {"/documents/miguel-de-cervantes-y-saavedra-don-quijote-de-la-mancha/read", http.StatusOK}, + {"/documents/miguel-de-cervantes-y-saavedra-don-quijote-de-la-mancha--2/read", http.StatusOK}, + {"/documents/miguel-de-cervantes-y-saavedra-don-quijote-de-la-mancha--3/read", http.StatusOK}, + {"/documents/miguel-de-cervantes-y-saavedra-don-quijote-de-la-mancha", http.StatusOK}, } for _, tcase := range cases { diff --git a/internal/webserver/embedded/css/reader.css b/internal/webserver/embedded/css/reader.css index ebabbcf4..c828cb06 100644 --- a/internal/webserver/embedded/css/reader.css +++ b/internal/webserver/embedded/css/reader.css @@ -17,7 +17,7 @@ body { font: menu; font-family: system-ui, sans-serif; } -#spinner-container { +#spinner-container, #error-icon-container { height: 100vh; display: flex; align-items: center; diff --git a/internal/webserver/embedded/js/delete.js b/internal/webserver/embedded/js/delete.js index 788eb769..78a94bad 100644 --- a/internal/webserver/embedded/js/delete.js +++ b/internal/webserver/embedded/js/delete.js @@ -9,13 +9,11 @@ const deleteModal = document.getElementById('delete-modal'); const deleteForm = document.getElementById('delete-form'); +let id deleteModal.addEventListener('show.bs.modal', event => { const link = event.relatedTarget - const id = link.getAttribute('data-id') - const modalInput = deleteModal.querySelector('.id') - - modalInput.value = id; + id = link.getAttribute('data-id') }) deleteModal.addEventListener('hidden.bs.modal', event => { @@ -25,14 +23,8 @@ deleteModal.addEventListener('hidden.bs.modal', event => { deleteForm.addEventListener('submit', event => { event.preventDefault(); - fetch(deleteForm.getAttribute("action"), { - method: "DELETE", - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: new URLSearchParams({ - 'id': deleteForm.elements['id'].value, - }) + fetch(deleteForm.getAttribute("action") + '/' + id, { + method: "DELETE" }) .then((response) => { if (response.ok || response.status == "403") { diff --git a/internal/webserver/embedded/js/highlight.js b/internal/webserver/embedded/js/highlight.js index 5393c151..bc89fa84 100644 --- a/internal/webserver/embedded/js/highlight.js +++ b/internal/webserver/embedded/js/highlight.js @@ -16,9 +16,6 @@ Array.from(links).forEach((link) => { "Content-Type": "application/x-www-form-urlencoded", }, credentials: "same-origin", - body: new URLSearchParams({ - slug: link.getAttribute("data-slug"), - }), }) .then((response) => { if (response.ok) { diff --git a/internal/webserver/embedded/js/htmx.min.js b/internal/webserver/embedded/js/htmx.min.js new file mode 100644 index 00000000..2d49c568 --- /dev/null +++ b/internal/webserver/embedded/js/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.0"};Q.onLoad=$;Q.process=Dt;Q.on=be;Q.off=we;Q.trigger=he;Q.ajax=Hn;Q.find=r;Q.findAll=p;Q.closest=g;Q.remove=K;Q.addClass=W;Q.removeClass=o;Q.toggleClass=Y;Q.takeClass=ge;Q.swap=ze;Q.defineExtension=Un;Q.removeExtension=Bn;Q.logAll=z;Q.logNone=J;Q.parseInterval=d;Q._=_;const n={addTriggerHandler:Et,bodyContains:le,canAccessLocalStorage:j,findThisElement:Ee,filterValues:dn,swap:ze,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:T,getExpressionVars:Cn,getHeaders:hn,getInputValues:cn,getInternalData:ie,getSwapSpecification:pn,getTriggerSpecs:lt,getTarget:Ce,makeFragment:D,mergeObjects:ue,makeSettleInfo:xn,oobSwap:Te,querySelectorExt:fe,settleImmediately:Gt,shouldCancel:dt,triggerEvent:he,triggerErrorEvent:ae,withExtensions:Ut};const v=["get","post","put","delete","patch"];const R=v.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");const O=e("head");function e(e,t=false){return new RegExp(`<${e}(\\s[^>]*>|>)([\\s\\S]*?)<\\/${e}>`,t?"gim":"im")}function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function H(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function T(e,t){while(e&&!t(e)){e=u(e)}return e||null}function q(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;T(t,function(e){return!!(r=q(t,ce(e),n))});if(r!=="unset"){return r}}function a(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function L(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function N(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function A(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function I(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function P(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function k(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(P(e)){const t=I(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){w(e)}finally{e.remove()}}})}function D(e){const t=e.replace(O,"");const n=L(t);let r;if(n==="html"){r=new DocumentFragment;const i=N(e);A(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=N(t);A(r,i.body);r.title=i.title}else{const i=N('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){k(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function M(e){return typeof e==="function"}function X(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function F(t){const n=[];if(t){for(let e=0;e=0}function le(e){const t=e.getRootNode&&e.getRootNode();if(t&&t instanceof window.ShadowRoot){return ne().body.contains(t.host)}else{return ne().body.contains(e)}}function B(e){return e.trim().split(/\s+/)}function ue(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){w(e);return null}}function j(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function V(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function _(e){return vn(ne().body,function(){return eval(e)})}function $(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function z(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function J(){Q.logger=null}function r(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return r(ne(),e)}}function p(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return p(ne(),e)}}function E(){return window}function K(e,t){e=y(e);if(t){E().setTimeout(function(){K(e);e=null},t)}else{u(e).removeChild(e)}}function ce(e){return e instanceof Element?e:null}function G(e){return e instanceof HTMLElement?e:null}function Z(e){return typeof e==="string"?e:null}function h(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function W(e,t,n){e=ce(y(e));if(!e){return}if(n){E().setTimeout(function(){W(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function o(e,t,n){let r=ce(y(e));if(!r){return}if(n){E().setTimeout(function(){o(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function Y(e,t){e=y(e);e.classList.toggle(t)}function ge(e,t){e=y(e);se(e.parentElement.children,function(e){o(e,t)});W(ce(e),t)}function g(e,t){e=ce(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||a(e,t)){return e}}while(e=e&&ce(u(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function pe(e,t){return e.substring(e.length-t.length)===t}function i(e){const t=e.trim();if(l(t,"<")&&pe(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(e,t,n){e=y(e);if(t.indexOf("closest ")===0){return[g(ce(e),i(t.substr(8)))]}else if(t.indexOf("find ")===0){return[r(h(e),i(t.substr(5)))]}else if(t==="next"){return[ce(e).nextElementSibling]}else if(t.indexOf("next ")===0){return[me(e,i(t.substr(5)),!!n)]}else if(t==="previous"){return[ce(e).previousElementSibling]}else if(t.indexOf("previous ")===0){return[ye(e,i(t.substr(9)),!!n)]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else if(t==="root"){return[H(e,!!n)]}else if(t.indexOf("global ")===0){return m(e,t.slice(7),true)}else{return F(h(H(e,!!n)).querySelectorAll(i(t)))}}var me=function(t,e,n){const r=h(H(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function fe(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return r(h(t)||document,e)}else{return e}}function xe(e,t,n){if(M(t)){return{target:ne().body,event:Z(e),listener:t}}else{return{target:y(e),event:Z(t),listener:n}}}function be(t,n,r){_n(function(){const e=xe(t,n,r);e.target.addEventListener(e.event,e.listener)});const e=M(n);return e?n:r}function we(t,n,r){_n(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return M(n)?n:r}const ve=ne().createElement("output");function Se(e,t){const n=re(e,t);if(n){if(n==="this"){return[Ee(e,t)]}else{const r=m(e,n);if(r.length===0){w('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Ee(e,t){return ce(T(e,function(e){return te(ce(e),t)!=null}))}function Ce(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Ee(e,"hx-target")}else{return fe(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Re(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{s=e}const n=ne().querySelectorAll(t);if(n){se(n,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!He(s,e)){t=h(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){_e(s,e,e,t,i)}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);ae(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function qe(e){se(p(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){e.parentNode.replaceChild(n,e)}})}function Le(l,e,u){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=h(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);u.tasks.push(function(){Oe(t,s)})}}})}function Ne(e){return function(){o(e,Q.config.addedClass);Dt(ce(e));Ae(h(e));he(e,"htmx:load")}}function Ae(e){const t="[autofocus]";const n=G(a(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function c(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;W(ce(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ne(o))}}}function Ie(e,t){let n=0;while(n0){E().setTimeout(l,r.settleDelay)}else{l()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(!X(e)){e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){ae(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(nt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function b(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function ot(e){let t;if(e.length>0&&Qe.test(e[0])){e.shift();t=b(e,et).trim();e.shift()}else{t=b(e,x)}return t}const it="input, textarea, select";function st(e,t,n){const r=[];const o=tt(t);do{b(o,Ye);const l=o.length;const u=b(o,/[,\[\s]/);if(u!==""){if(u==="every"){const c={trigger:"every"};b(o,Ye);c.pollInterval=d(b(o,/[,\[\s]/));b(o,Ye);var i=rt(e,o,"event");if(i){c.eventFilter=i}r.push(c)}else{const f={trigger:u};var i=rt(e,o,"event");if(i){f.eventFilter=i}while(o.length>0&&o[0]!==","){b(o,Ye);const a=o.shift();if(a==="changed"){f.changed=true}else if(a==="once"){f.once=true}else if(a==="consume"){f.consume=true}else if(a==="delay"&&o[0]===":"){o.shift();f.delay=d(b(o,x))}else if(a==="from"&&o[0]===":"){o.shift();if(Qe.test(o[0])){var s=ot(o)}else{var s=b(o,x);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=ot(o);if(h.length>0){s+=" "+h}}}f.from=s}else if(a==="target"&&o[0]===":"){o.shift();f.target=ot(o)}else if(a==="throttle"&&o[0]===":"){o.shift();f.throttle=d(b(o,x))}else if(a==="queue"&&o[0]===":"){o.shift();f.queue=b(o,x)}else if(a==="root"&&o[0]===":"){o.shift();f[a]=ot(o)}else if(a==="threshold"&&o[0]===":"){o.shift();f[a]=b(o,x)}else{ae(e,"htmx:syntax:error",{token:o.shift()})}}r.push(f)}}if(o.length===l){ae(e,"htmx:syntax:error",{token:o.shift()})}b(o,Ye)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function lt(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||st(e,t,r)}if(n.length>0){return n}else if(a(e,"form")){return[{trigger:"submit"}]}else if(a(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(a(e,it)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function ut(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!pt(n,e,Xt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ft(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ht(t,n,e){if(t instanceof HTMLAnchorElement&&ft(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";if(r==="get"){}o=ee(t,"action")}e.forEach(function(e){mt(t,function(e,t){const n=ce(e);if(at(n)){f(n);return}de(r,o,n,t)},n,e,true)})}}function dt(e,t){const n=ce(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(a(n,'input[type="submit"], button')&&g(n,"form")!==null){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function gt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function pt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;ae(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function mt(s,l,e,u,c){const f=ie(s);let t;if(u.from){t=m(s,u.from)}else{t=[s]}if(u.changed){t.forEach(function(e){const t=ie(e);t.lastValue=e.value})}se(t,function(o){const i=function(e){if(!le(s)){o.removeEventListener(u.trigger,i);return}if(gt(s,e)){return}if(c||dt(e,s)){e.preventDefault()}if(pt(u,s,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(s)<0){t.handledFor.push(s);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!a(ce(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=ie(o);const r=o.value;if(n.lastValue===r){return}n.lastValue=r}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){l(s,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){l(s,e)},u.delay)}else{he(s,"htmx:trigger");l(s,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:i,on:o});o.addEventListener(u.trigger,i)})}let yt=false;let xt=null;function bt(){if(!xt){xt=function(){yt=true};window.addEventListener("scroll",xt);setInterval(function(){if(yt){yt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){wt(e)})}},200)}}function wt(e){if(!s(e,"data-hx-revealed")&&U(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function St(t,n,e){let i=false;se(v,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){Et(t,e,n,function(e,t){const n=ce(e);if(g(n,Q.config.disableSelector)){f(n);return}de(r,o,n,t)})})}});return i}function Et(r,e,t,n){if(e.trigger==="revealed"){bt();mt(r,n,t,e);wt(ce(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=fe(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ce(r),n,e)}else{mt(r,n,t,e)}}function Ct(e){const t=ce(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function qt(e){const t=g(ce(e.target),"button, input[type='submit']");const n=Nt(e);if(n){n.lastButtonClicked=t}}function Lt(e){const t=Nt(e);if(t){t.lastButtonClicked=null}}function Nt(e){const t=g(ce(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",qt);e.addEventListener("focusin",qt);e.addEventListener("focusout",Lt)}function It(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Pt(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){ae(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function _t(t){if(!j()){return null}t=V(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=D(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=jt();const r=xn(n);Dn(e.title);Ve(n,t,r);Gt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{ae(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=_t(e);if(t){const n=D(t.content);const r=jt();const o=xn(r);Dn(n.title);Ve(r,n,o);Gt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Zt(e)}}}function Yt(e){let t=Se(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Qt(e){let t=Se(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","")});return t}function en(e,t){se(e,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled")}})}function tn(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function sn(t,n,r,o,i){if(o==null||tn(t,o)){return}else{t.push(o)}if(nn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=F(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=F(o.files)}rn(s,e,n);if(i){ln(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){on(e.name,e.value,n)}else{t.push(e)}if(i){ln(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}rn(t,e,n)})}}function ln(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function un(t,e){for(const n of e.keys()){t.delete(n);e.getAll(n).forEach(function(e){t.append(n,e)})}return t}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){sn(n,o,i,g(e,"form"),l)}sn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const c=s.lastButtonClicked||e;const f=ee(c,"name");rn(f,c.value,o)}const u=Se(e,"hx-include");se(u,function(e){sn(n,r,i,ce(e),l);if(!a(e,"form")){se(h(e).querySelectorAll(it),function(e){sn(n,r,i,e,l)})}});un(r,o);return{errors:i,formData:r,values:An(r)}}function fn(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=Ln(e);let n="";e.forEach(function(e,t){n=fn(n,t,e)});return n}function hn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};wn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function dn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.substr(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function gn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function pn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!gn(e)){r.show="top"}if(n){const s=B(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=c;r.scrollTarget=i}else if(l.indexOf("show:")===0){const f=l.substr(5);var o=f.split(":");const a=o.pop();var i=o.length>0?o.join(":"):null;r.show=a;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.substr("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{w("Unknown modifier in hx-swap: "+l)}}}}return r}function mn(e){return re(e,"hx-encoding")==="multipart/form-data"||a(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function yn(t,n,r){let o=null;Ut(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(mn(n)){return un(new FormData,Ln(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function bn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ce(fe(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ce(fe(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function wn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.substr(11);t=true}else if(e.indexOf("js:")===0){e=e.substr(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return wn(ce(u(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{ae(e,"htmx:evalDisallowedError");return n}}function Sn(e,t){return wn(e,"hx-vars",true,t)}function En(e,t){return wn(e,"hx-vals",false,t)}function Cn(e){return ue(Sn(e),En(e))}function Rn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){ae(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function C(e,t){return t.test(e.getAllResponseHeaders())}function Hn(e,t,n){e=e.toLowerCase();if(n){if(n instanceof Element||typeof n==="string"){return de(e,t,null,null,{targetOverride:y(n),returnPromise:true})}else{return de(e,t,y(n.source),n.event,{handler:n.handler,headers:n.headers,values:n.values,targetOverride:y(n.target),swapOverride:n.swap,select:n.select,returnPromise:true})}}else{return de(e,t,null,null,{returnPromise:true})}}function Tn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function qn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ue({url:o,sameHost:r},n))}function Ln(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Nn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(r){return new Proxy(r,{get:function(e,t){if(typeof t==="symbol"){return Reflect.get(e,t)}if(t==="toJSON"){return()=>Object.fromEntries(r)}if(t in e){if(typeof e[t]==="function"){return function(){return r[t].apply(r,arguments)}}else{return e[t]}}const n=r.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Nn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Mn;const X=i.select||null;if(!le(r)){oe(s);return e}const u=i.targetOverride||ce(Ce(r));if(u==null||u==ve){ae(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let c=ie(r);const f=c.lastButtonClicked;if(f){const L=ee(f,"formaction");if(L!=null){n=L}const N=ee(f,"formmethod");if(N!=null){if(N.toLowerCase()!=="dialog"){t=N}}}const a=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:u,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:a};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const A=d.split(":");const I=A[0].trim();if(I==="this"){h=Ee(r,"hx-sync")}else{h=ce(fe(r,I))}d=(A[1]||"drop").trim();c=ie(h);if(d==="drop"&&c.xhr&&c.abortable!==true){oe(s);return e}else if(d==="abort"){if(c.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const Z=d.split(" ");g=(Z[1]||"last").trim()}}if(c.xhr){if(c.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(c.queuedRequests==null){c.queuedRequests=[]}if(g==="first"&&c.queuedRequests.length===0){c.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){c.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){c.queuedRequests=[];c.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;c.xhr=p;c.abortable=F;const m=function(){c.xhr=null;c.abortable=false;if(c.queuedRequests!=null&&c.queuedRequests.length>0){const e=c.queuedRequests.shift();e()}};const U=re(r,"hx-prompt");if(U){var y=prompt(U);if(y===null||!he(r,"htmx:prompt",{prompt:y,target:u})){oe(s);m();return e}}if(a&&!D){if(!confirm(a)){oe(s);m();return e}}let x=hn(r,u,y);if(t!=="get"&&!mn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=ue(x,i.headers)}const B=cn(r,t);let b=B.errors;const j=B.formData;if(i.values){un(j,Ln(i.values))}const V=Ln(Cn(r));const w=un(j,V);let v=dn(w,r);if(Q.config.getCacheBusterParam&&t==="get"){v.set("org.htmx.cache-buster",ee(u,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=wn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:v,parameters:An(v),unfilteredFormData:w,unfilteredParameters:An(w),headers:x,target:u,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;x=C.headers;v=Ln(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const $=n.split("#");const z=$[0];const R=$[1];let O=n;if(E){O=z;const W=!v.keys().next().done;if(W){if(O.indexOf("?")<0){O+="?"}else{O+="&"}O+=an(v);if(R){O+="#"+R}}}if(!qn(r,O,C)){ae(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),O,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in x){if(x.hasOwnProperty(k)){const Y=x[k];Rn(p,k,Y)}}}const H={xhr:p,target:u,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:O,responsePath:null,anchor:R}};p.onload=function(){try{const t=Tn(r);H.pathInfo.responsePath=On(p);M(r,H);en(T,q);he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){ae(r,"htmx:onLoadError",ue({error:e},H));throw e}};p.onerror=function(){en(T,q);ae(r,"htmx:afterRequest",H);ae(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){en(T,q);ae(r,"htmx:afterRequest",H);ae(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){en(T,q);ae(r,"htmx:afterRequest",H);ae(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Yt(r);var q=Qt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:yn(p,r,v);p.send(J);return e}function In(e,t){const n=t.xhr;let r=null;let o=null;if(C(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(C(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(C(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const u=re(e,"hx-replace-url");const c=ie(e).boosted;let f=null;let a=null;if(l){f="push";a=l}else if(u){f="replace";a=u}else if(c){f="push";a=s||i}if(a){if(a==="false"){return{}}if(a==="true"){a=s||i}if(t.pathInfo.anchor&&a.indexOf("#")===-1){a=a+"#"+t.pathInfo.anchor}return{type:f,path:a}}else{return{}}}function Pn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function kn(e){for(var t=0;t0){E().setTimeout(e,y.swapDelay)}else{e()}}if(a){ae(o,"htmx:responseError",ue({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Xn={};function Fn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Un(e,t){if(t.init){t.init(n)}Xn[e]=ue(Fn(),t)}function Bn(e){delete Xn[e]}function jn(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Xn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return jn(ce(u(e)),n,r)}var Vn=false;ne().addEventListener("DOMContentLoaded",function(){Vn=true});function _n(e){if(Vn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function $n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function Jn(){const e=zn();if(e){Q.config=ue(Q.config,e)}}_n(function(){Jn();$n();let e=ne().body;Dt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/internal/webserver/embedded/js/foliate-js/reader.js b/internal/webserver/embedded/js/reader.js similarity index 88% rename from internal/webserver/embedded/js/foliate-js/reader.js rename to internal/webserver/embedded/js/reader.js index 273ebe85..d292dad1 100644 --- a/internal/webserver/embedded/js/foliate-js/reader.js +++ b/internal/webserver/embedded/js/reader.js @@ -1,7 +1,7 @@ -import './view.js' -import { createTOCView } from './ui/tree.js' -import { createMenu } from './ui/menu.js' -import { Overlayer } from './overlayer.js' +import './foliate-js/view.js' +import { createTOCView } from './foliate-js/ui/tree.js' +import { createMenu } from './foliate-js/ui/menu.js' +import { Overlayer } from './foliate-js/overlayer.js' const isZip = async file => { const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer()) @@ -17,7 +17,7 @@ const isPDF = async file => { const makeZipLoader = async file => { const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } = - await import('./vendor/zip.js') + await import('./foliate-js/vendor/zip.js') configure({ useWebWorkers: false }) const reader = new ZipReader(new BlobReader(file)) const entries = await reader.getEntries() @@ -35,17 +35,21 @@ const getView = async file => { if (!file.size) throw new Error('File not found') else if (await isZip(file)) { const loader = await makeZipLoader(file) - const { EPUB } = await import('./epub.js') + const { EPUB } = await import('./foliate-js/epub.js') book = await new EPUB(loader).init() } else if (await isPDF(file)) { - const { makePDF } = await import('./pdf.js') + const { makePDF } = await import('./foliate-js/pdf.js') book = await makePDF(file) } if (!book) throw new Error('File type not supported') const view = document.createElement('foliate-view') + const storage = window.localStorage + const slug = document.getElementById('slug').value + document.body.append(view) await view.open(book) + await view.init({lastLocation: storage.getItem(slug)}) return view } @@ -136,6 +140,7 @@ class Reader { this.view.addEventListener('relocate', this.#onRelocate.bind(this)) document.body.removeChild($('#spinner-container')) + document.body.removeChild($('#error-icon-container')) const { book } = this.view this.view.renderer.setStyles?.(getCSS(this.style)) @@ -182,7 +187,7 @@ class Reader { // load and show highlights embedded in the file by Calibre const bookmarks = await book.getCalibreBookmarks?.() if (bookmarks) { - const { fromCalibreHighlight } = await import('./epubcfi.js') + const { fromCalibreHighlight } = await import('./foliate-js/epubcfi.js') for (const obj of bookmarks) { if (obj.type === 'highlight') { const value = fromCalibreHighlight(obj) @@ -221,6 +226,10 @@ class Reader { doc.addEventListener('keydown', this.#handleKeydown.bind(this)) } #onRelocate({ detail }) { + const storage = window.localStorage + const slug = document.getElementById('slug').value + + storage.setItem(slug, detail.cfi) const { fraction, location, tocItem, pageItem } = detail const percent = percentFormat.format(fraction) const loc = pageItem @@ -242,7 +251,16 @@ const open = async file => { const url = document.getElementById('url').value if (url) fetch(url) - .then(res => res.blob()) + .then(res => { + if (res.status == 403) { + return location.reload() + } + return res.blob() + }) .then(blob => open(new File([blob], new URL(url).pathname))) - .catch(e => console.error(e)) + .catch(e => { + document.body.removeChild($('#spinner-container')); + $('#error-icon-container').classList.remove('d-none'); + console.error(e); + }) else dropTarget.style.visibility = 'visible' diff --git a/internal/webserver/embedded/js/response-targets.js b/internal/webserver/embedded/js/response-targets.js new file mode 100644 index 00000000..951a7093 --- /dev/null +++ b/internal/webserver/embedded/js/response-targets.js @@ -0,0 +1,129 @@ +(function() { + /** @type {import("../htmx").HtmxInternalApi} */ + var api + + var attrPrefix = 'hx-target-' + + // IE11 doesn't support string.startsWith + function startsWith(str, prefix) { + return str.substring(0, prefix.length) === prefix + } + + /** + * @param {HTMLElement} elt + * @param {number} respCode + * @returns {HTMLElement | null} + */ + function getRespCodeTarget(elt, respCodeNumber) { + if (!elt || !respCodeNumber) return null + + var respCode = respCodeNumber.toString() + + // '*' is the original syntax, as the obvious character for a wildcard. + // The 'x' alternative was added for maximum compatibility with HTML + // templating engines, due to ambiguity around which characters are + // supported in HTML attributes. + // + // Start with the most specific possible attribute and generalize from + // there. + var attrPossibilities = [ + respCode, + + respCode.substr(0, 2) + '*', + respCode.substr(0, 2) + 'x', + + respCode.substr(0, 1) + '*', + respCode.substr(0, 1) + 'x', + respCode.substr(0, 1) + '**', + respCode.substr(0, 1) + 'xx', + + '*', + 'x', + '***', + 'xxx' + ] + if (startsWith(respCode, '4') || startsWith(respCode, '5')) { + attrPossibilities.push('error') + } + + for (var i = 0; i < attrPossibilities.length; i++) { + var attr = attrPrefix + attrPossibilities[i] + var attrValue = api.getClosestAttributeValue(elt, attr) + if (attrValue) { + if (attrValue === 'this') { + return api.findThisElement(elt, attr) + } else { + return api.querySelectorExt(elt, attrValue) + } + } + } + + return null + } + + /** @param {Event} evt */ + function handleErrorFlag(evt) { + if (evt.detail.isError) { + if (htmx.config.responseTargetUnsetsError) { + evt.detail.isError = false + } + } else if (htmx.config.responseTargetSetsError) { + evt.detail.isError = true + } + } + + htmx.defineExtension('response-targets', { + + /** @param {import("../htmx").HtmxInternalApi} apiRef */ + init: function(apiRef) { + api = apiRef + + if (htmx.config.responseTargetUnsetsError === undefined) { + htmx.config.responseTargetUnsetsError = true + } + if (htmx.config.responseTargetSetsError === undefined) { + htmx.config.responseTargetSetsError = false + } + if (htmx.config.responseTargetPrefersExisting === undefined) { + htmx.config.responseTargetPrefersExisting = false + } + if (htmx.config.responseTargetPrefersRetargetHeader === undefined) { + htmx.config.responseTargetPrefersRetargetHeader = true + } + }, + + /** + * @param {string} name + * @param {Event} evt + */ + onEvent: function(name, evt) { + if (name === 'htmx:beforeSwap' && + evt.detail.xhr && + evt.detail.xhr.status !== 200) { + if (evt.detail.target) { + if (htmx.config.responseTargetPrefersExisting) { + evt.detail.shouldSwap = true + handleErrorFlag(evt) + return true + } + if (htmx.config.responseTargetPrefersRetargetHeader && + evt.detail.xhr.getAllResponseHeaders().match(/HX-Retarget:/i)) { + evt.detail.shouldSwap = true + handleErrorFlag(evt) + return true + } + } + if (!evt.detail.requestConfig) { + return true + } + var target = getRespCodeTarget(evt.detail.requestConfig.elt, evt.detail.xhr.status) + if (target) { + handleErrorFlag(evt) + evt.detail.shouldSwap = true + evt.detail.target = target + } + return true + } + } + }) + })() \ No newline at end of file diff --git a/internal/webserver/embedded/js/send-email.js b/internal/webserver/embedded/js/send-email.js index 73dd487e..60860bb0 100644 --- a/internal/webserver/embedded/js/send-email.js +++ b/internal/webserver/embedded/js/send-email.js @@ -13,14 +13,13 @@ Array.from(forms).forEach(form => { submit.setAttribute("disabled", true); spinner.classList.remove("visually-hidden"); sendIcon.classList.add("visually-hidden"); - fetch('/send', { + fetch(form.getAttribute("action"), { method: "POST", headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ - 'email': form.elements[0].value, - 'slug': form.elements[1].value, + 'email': form.elements[0].value }) }) .then((response) => { diff --git a/internal/webserver/embedded/js/xh-error-check.js b/internal/webserver/embedded/js/xh-error-check.js new file mode 100644 index 00000000..391e1e91 --- /dev/null +++ b/internal/webserver/embedded/js/xh-error-check.js @@ -0,0 +1,25 @@ +document.body.addEventListener('htmx:afterRequest', function (evt) { + const errorTarget = document.getElementById("box-error") + const unexpectedServerError = errorTarget.getAttribute("data-unexpected-server-error") + const unexpectedError = errorTarget.getAttribute("data-unexpected-error") + if (evt.detail.successful) { + // Successful request, clear out alert + errorTarget.setAttribute("hidden", "true") + errorTarget.innerText = ""; + } else if (evt.detail.failed && evt.detail.xhr) { + // Server error with response contents, equivalent to htmx:responseError + const xhr = evt.detail.xhr; + if (xhr.status == "403") { + return location.reload() + } + + console.warn("Server error", evt.detail) + errorTarget.innerText = unexpectedServerError + `${xhr.status} - ${xhr.statusText}` + errorTarget.removeAttribute("hidden") + } else { + // Unspecified failure, usually caused by network error + console.error("Unexpected htmx error", evt.detail) + errorTarget.innerText = unexpectedError + errorTarget.removeAttribute("hidden") + } +}); diff --git a/internal/webserver/embedded/translations/es.yml b/internal/webserver/embedded/translations/es.yml index 02caf839..5fcc1b1c 100644 --- a/internal/webserver/embedded/translations/es.yml +++ b/internal/webserver/embedded/translations/es.yml @@ -120,7 +120,7 @@ "Indexing in progress, search results may not be accurate.": "Indexando documentos, los resultados de búsqueda pueden no ser precisos." "Remaining time: %s minutes": "Tiempo restante: %s minutos" "There was an error deleting the user, please try again later": "Hubo un error al borrar el usuario, por favor, vuelva a intentarlo más tarde" -"A user with this username already exists": "Ya exista un usuario con ese nombre de usuario" +"A user with this username already exists": "Ya existe un usuario con ese nombre de usuario" "A user with this email address already exists": "Ya existe un usuario con esa dirección de correo electrónico" "Username can only have letters, numbers, _, - and .": "El nombre de usuario solo puede contener letras, números. _, - y ." "Only letters, numbers, _, - and . allowed": "Solo se permiten letras, números, _, - y ." @@ -133,3 +133,5 @@ "Go left": "Ir a la izquierda" "Go right": "Ir a la derecha" "Session expired, please log in again.": "Sesión expirada, por favor identifícate de nuevo." +"Unexpected error, check your connection and try to refresh the page.": "Error inesperado, comprueba tu conexión y recarga la página." +"Unexpected server error": "Error inesperado en el servidor" diff --git a/internal/webserver/embedded/translations/fr.yml b/internal/webserver/embedded/translations/fr.yml index 7a71638e..da1d205f 100644 --- a/internal/webserver/embedded/translations/fr.yml +++ b/internal/webserver/embedded/translations/fr.yml @@ -133,3 +133,5 @@ "Go left": "Aller à gauche" "Go right": "Aller à droite" "Session expired, please log in again.": "Session expirée, veuillez vous reconnecter." +"Unexpected error, check your connection and try to refresh the page.": "Erreur inattendue, vérifiez votre connexion et essayez d'actualiser la page." +"Unexpected server error": "Erreur de serveur inattendue" diff --git a/internal/webserver/embedded/views/auth/edit-password.html b/internal/webserver/embedded/views/auth/edit-password.html index e46db62f..2788fb46 100644 --- a/internal/webserver/embedded/views/auth/edit-password.html +++ b/internal/webserver/embedded/views/auth/edit-password.html @@ -1,5 +1,5 @@

{{t .Lang "Set new password"}}

-
+
+

{{t .Lang "Please sign in"}}

@@ -9,7 +9,7 @@

{{t .Lang "Please sign in"}}

{{if .EmailSendingConfigured}} - + {{end}}
diff --git a/internal/webserver/embedded/views/auth/recover.html b/internal/webserver/embedded/views/auth/recover.html index 072fe42d..b6aad61a 100644 --- a/internal/webserver/embedded/views/auth/recover.html +++ b/internal/webserver/embedded/views/auth/recover.html @@ -1,6 +1,6 @@

{{t .Lang "Recover password"}}

- +
diff --git a/internal/webserver/embedded/views/document.html b/internal/webserver/embedded/views/document.html index b1df3649..aaed4398 100644 --- a/internal/webserver/embedded/views/document.html +++ b/internal/webserver/embedded/views/document.html @@ -7,7 +7,7 @@
- {{t $lang "\"%s\" cover" .Document.Title}} @@ -23,7 +23,7 @@

- + @@ -31,7 +31,7 @@

  {{t .Lang "Read"}} - + @@ -40,15 +40,24 @@

{{.Document.Type}} + + + + + +   {{t .Lang "Download"}} + KEPUB + + {{if and (.Session) (ne .Session.Name "")}} - +   {{t .Lang "Highlight"}} - + @@ -60,7 +69,7 @@

{{t .Lang "Send to email unavailable, no email service configured"}}

{{else}}
- + @@ -143,11 +152,11 @@

{{t $lang "Unknown author"}}

{{t $lang "Other documents in collection \"%s\"" .Document.Series}}

-
+
{{range $i, $doc := .SameSeries}}
{{template "partials/related" dict "Lang" $lang "Document" $doc}} @@ -166,11 +175,11 @@

-
+
{{range $i, $doc := .SameAuthors}}
{{template "partials/related" dict "Lang" $lang "Document" $doc}} @@ -186,11 +195,11 @@

{{t $lang "Other documents with similar subjects"}}

-
+
{{range $i, $doc := .SameSubjects}}
{{template "partials/related" dict "Lang" $lang "Document" $doc}} diff --git a/internal/webserver/embedded/views/index.html b/internal/webserver/embedded/views/index.html index c7127675..c0c2522c 100644 --- a/internal/webserver/embedded/views/index.html +++ b/internal/webserver/embedded/views/index.html @@ -9,12 +9,12 @@

{{t .Lang "Your highlights" }}

{{if gt (len .Highlights) 0}} - -
+ +
{{$lang := .Lang}} {{$emailSendingConfigured := .EmailSendingConfigured}} {{$session := .Session}} @@ -31,12 +31,12 @@

{{t .Lang "Your highlights" }}

{{ template "partials/actions" dict "Lang" $lang "Document" $doc "EmailSendingConfigured" $emailSendingConfigured "Session" $session "EmailFrom" $emailFrom "OnDehighlight" "remove"}} -
+
{{end}}
- {{template "partials/delete-modal" dict "Lang" .Lang "Action" "/document" "ModalHeader" "Delete document" "ModalBody" "Are you sure you want to delete this document?" "ModalErrorMessage" "There was an error deleting the document"}} + {{template "partials/delete-modal" dict "Lang" .Lang "Action" "/documents" "ModalHeader" "Delete document" "ModalBody" "Are you sure you want to delete this document?" "ModalErrorMessage" "There was an error deleting the document"}} diff --git a/internal/webserver/embedded/views/layout.html b/internal/webserver/embedded/views/layout.html index d4d6aaba..fe26dbc0 100644 --- a/internal/webserver/embedded/views/layout.html +++ b/internal/webserver/embedded/views/layout.html @@ -22,12 +22,12 @@ - +

{{t .Lang "Users"}} {{else}} - + Coreander

{{end}}
  • - + @@ -82,7 +82,7 @@
    Coreander
  • {{if eq .Session.Role 2}}
  • - + @@ -94,7 +94,7 @@
    Coreander
  • - + Coreander
  • {{else if not .DisableLoginLink}}
  • - {{t .Lang "Login"}} + {{t .Lang "Login"}}
  • {{end}}
    {{$lang := .Lang}} - {{$pathMinusLang := .PathMinusLang}} + {{$URLPath := .URLPath}} + {{$queryString := .QueryString}} {{range $i, $currentLang := .SupportedLanguages}} {{if eq $lang $currentLang}}
  • {{uppercase $currentLang}}
  • + {{else if eq $queryString ""}} +
  • {{uppercase $currentLang}}
  • {{else}} -
  • {{uppercase $currentLang}}
  • +
  • {{uppercase $currentLang}}
  • {{end}} {{end}}
    @@ -130,49 +133,16 @@
    Coreander
    -
    -
    - {{if .RemainingIndexingTime}} -
    - -
    - {{end}} - - {{if .Error}} -
    - -
    - {{end}} - - {{if .Warning}} -
    - -
    - {{end}} - - {{if .Message}} -
    - -
    - {{end}} - - {{embed}} -
    + {{template "partials/main" + dict "Lang" .Lang + "RemainingIndexingTime" .RemainingIndexingTime + "IndexingProgressPercentage" .IndexingProgressPercentage + "Error" .Error + "Warning" .Warning + "Message" .Message + "Embed" .Embed}}
    -
    {{t .Lang "Made with words by Sergio Vera"}} - Coreander
    + + diff --git a/internal/webserver/embedded/views/partials/actions.html b/internal/webserver/embedded/views/partials/actions.html index 0ee847e5..cf8b5f8d 100644 --- a/internal/webserver/embedded/views/partials/actions.html +++ b/internal/webserver/embedded/views/partials/actions.html @@ -1,5 +1,5 @@
    diff --git a/internal/webserver/embedded/views/partials/docs-list.html b/internal/webserver/embedded/views/partials/docs-list.html index f0b0ac79..77668c9a 100644 --- a/internal/webserver/embedded/views/partials/docs-list.html +++ b/internal/webserver/embedded/views/partials/docs-list.html @@ -9,7 +9,7 @@
    - {{t $lang "\"%s\" cover" $document.Title}} + {{t $lang "\"%s\" cover" $document.Title}}
    @@ -51,27 +51,27 @@

    {{range $i, $author := $document.Authors}} {{$authorTitle := t $lang "Search for more titles by %s" $author}} - {{$author}}{{if notLast $document.Authors $i}}, {{end}} + {{$author}}{{if notLast $document.Authors $i}}, {{end}} {{end}}
    {{else}}
    {{t $lang "Unknown author"}}
    {{end}} - + {{ if gt $document.Words 0.0 }}

    {{t $lang "Estimated reading time"}}: {{$document.ReadingTime $wordsPerMinute}}

    {{ end }} - + {{ if $document.Pages }}

    {{t $lang "%d pages" $document.Pages}}

    {{ end }} - + {{ if $document.Subjects }}
    {{range $i, $subject := $document.Subjects}} {{$subjectTitle := t $lang "Search for more titles in %s" $subject}} - {{$subject}} + {{$subject}} {{end}}
    {{ end }} @@ -81,7 +81,7 @@
    {{t $lang "Unknown author"}}
    {{else}}
    {{t $lang "No description available"}}
    {{end}} - +
    @@ -91,7 +91,7 @@
    {{t $lang "Unknown author"}}
    {{end}} {{end}}
    -{{template "partials/delete-modal" dict "Lang" $lang "Action" "/document" "ModalHeader" "Delete document" "ModalBody" "Are you sure you want to delete this document?" "ModalErrorMessage" "There was an error deleting the document"}} +{{template "partials/delete-modal" dict "Lang" $lang "Action" "documents" "ModalHeader" "Delete document" "ModalBody" "Are you sure you want to delete this document?" "ModalErrorMessage" "There was an error deleting the document"}} diff --git a/internal/webserver/embedded/views/partials/main.html b/internal/webserver/embedded/views/partials/main.html new file mode 100644 index 00000000..32dcb17b --- /dev/null +++ b/internal/webserver/embedded/views/partials/main.html @@ -0,0 +1,44 @@ +
    + {{if .RemainingIndexingTime}} +
    + +
    + {{end}} + +
    + +
    + + {{if .Error}} +
    + +
    + {{end}} + + {{if .Warning}} +
    + +
    + {{end}} + + {{if .Message}} +
    + +
    + {{end}} + + {{embed}} +
    diff --git a/internal/webserver/embedded/views/partials/related.html b/internal/webserver/embedded/views/partials/related.html index 7f05c3be..9a89c709 100644 --- a/internal/webserver/embedded/views/partials/related.html +++ b/internal/webserver/embedded/views/partials/related.html @@ -1,7 +1,7 @@
    - {{t .Lang "\"%s\" cover" .Document.Title}} + {{t .Lang "\"%s\" cover" .Document.Title}}
    -
    {{.Document.Title}}
    +
    {{.Document.Title}}
    {{if .Document.Authors}}

    {{join .Document.Authors ", "}} diff --git a/internal/webserver/embedded/views/partials/searchbox.html b/internal/webserver/embedded/views/partials/searchbox.html index db5a5ff0..f054ad9a 100644 --- a/internal/webserver/embedded/views/partials/searchbox.html +++ b/internal/webserver/embedded/views/partials/searchbox.html @@ -1,6 +1,6 @@

    -
    +
    diff --git a/internal/webserver/embedded/views/reader.html b/internal/webserver/embedded/views/reader.html index 4d1f8b4a..aae49671 100644 --- a/internal/webserver/embedded/views/reader.html +++ b/internal/webserver/embedded/views/reader.html @@ -13,7 +13,8 @@ - + +
    @@ -21,12 +22,22 @@
    +
    + + + + + + + +
    +
    - +
    -
    +
    diff --git a/internal/webserver/embedded/views/users/edit.html b/internal/webserver/embedded/views/users/edit.html index 13cec3fa..1d0924b7 100644 --- a/internal/webserver/embedded/views/users/edit.html +++ b/internal/webserver/embedded/views/users/edit.html @@ -8,10 +8,10 @@ type="button" role="tab" aria-controls="profile-tab-pane" aria-selected="false">{{t .Lang "Change password"}} -
    +
    -
    +
    @@ -38,7 +38,7 @@ {{t .Lang .Errors.email}}
    {{end}} -
    +
    @@ -56,14 +56,14 @@ {{t .Lang .Errors.wordsperminute}}
    {{end}} -
    +
    -
    + {{if eq .Session.Uuid .User.Uuid}}
    @@ -73,7 +73,7 @@
    {{t .Lang .Errors.oldpassword}}
    - {{end}} + {{end}}
    {{end}}
    @@ -84,7 +84,7 @@
    {{t .Lang .Errors.password .MinPasswordLength}}
    - {{end}} + {{end}}
    @@ -94,12 +94,13 @@
    {{t .Lang .Errors.confirmpassword}}
    - {{end}} + {{end}}
    -
    + + diff --git a/internal/webserver/embedded/views/users/index.html b/internal/webserver/embedded/views/users/index.html index 72df9af3..0b2d602a 100644 --- a/internal/webserver/embedded/views/users/index.html +++ b/internal/webserver/embedded/views/users/index.html @@ -5,7 +5,7 @@

    {{t $lang "Users"}}

    @@ -52,7 +52,7 @@
    {{t .Lang .Errors.password .MinPasswordLength}}
    - {{end}} + {{end}}
    @@ -62,7 +62,7 @@
    {{t .Lang .Errors.confirmpassword}}
    - {{end}} + {{end}}
    @@ -74,7 +74,7 @@
    {{t .Lang .Errors.role}}
    - {{end}} + {{end}}
    diff --git a/internal/webserver/fixtures/library/quijote_third_edition.epub b/internal/webserver/fixtures/library/quijote_third_edition.epub new file mode 100644 index 00000000..915238c4 Binary files /dev/null and b/internal/webserver/fixtures/library/quijote_third_edition.epub differ diff --git a/internal/webserver/highlights_test.go b/internal/webserver/highlights_test.go index 0a11d435..5c532cd6 100644 --- a/internal/webserver/highlights_test.go +++ b/internal/webserver/highlights_test.go @@ -4,14 +4,13 @@ import ( "fmt" "net/http" "net/url" - "strings" "testing" "github.com/PuerkitoBio/goquery" "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/webserver" - "github.com/svera/coreander/v3/internal/webserver/infrastructure" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver" + "github.com/svera/coreander/v4/internal/webserver/infrastructure" + "github.com/svera/coreander/v4/internal/webserver/model" "gorm.io/gorm" ) @@ -20,14 +19,13 @@ func TestHighlights(t *testing.T) { db *gorm.DB app *fiber.App adminCookie *http.Cookie - data url.Values adminUser model.User ) reset := func() { var err error - db = infrastructure.Connect("file::memory:", 250) + db = infrastructure.Connect(":memory:", 250) appFS := loadFilesInMemoryFs([]string{"fixtures/library/metadata.epub"}) app = bootstrapApp(db, &infrastructure.NoEmail{}, appFS, webserver.Config{}) adminCookie, err = login(app, "admin@example.com", "admin", t) @@ -35,9 +33,6 @@ func TestHighlights(t *testing.T) { t.Fatalf("Unexpected error: %v", err.Error()) } - data = url.Values{ - "slug": {"john-doe-test-epub"}, - } adminUser = model.User{} db.Where("email = ?", "admin@example.com").First(&adminUser) @@ -51,7 +46,7 @@ func TestHighlights(t *testing.T) { "words-per-minute": {"250"}, } - response, err := postRequest(regularUserData, adminCookie, app, "/en/users/new", t) + response, err := postRequest(regularUserData, adminCookie, app, "/users", t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -62,7 +57,7 @@ func TestHighlights(t *testing.T) { t.Run("Try to highlight a document without an active session", func(t *testing.T) { t.Cleanup(reset) - response, err := highlight(&http.Cookie{}, app, strings.NewReader(data.Encode()), fiber.MethodPost, t) + response, err := highlight(&http.Cookie{}, app, "john-doe-test-epub", fiber.MethodPost, t) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -73,23 +68,23 @@ func TestHighlights(t *testing.T) { t.Run("Try to highlight and dehighlight a document with an active session", func(t *testing.T) { t.Cleanup(reset) - response, err := highlight(adminCookie, app, strings.NewReader(data.Encode()), fiber.MethodPost, t) + response, err := highlight(adminCookie, app, "john-doe-test-epub", fiber.MethodPost, t) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } mustReturnStatus(response, fiber.StatusOK, t) - assertHighlights(app, t, adminCookie, adminUser.Username, 1) + assertHighlights(app, t, adminCookie, 1) - response, err = highlight(adminCookie, app, strings.NewReader(data.Encode()), fiber.MethodDelete, t) + response, err = highlight(adminCookie, app, "john-doe-test-epub", fiber.MethodDelete, t) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } mustReturnStatus(response, fiber.StatusOK, t) - assertHighlights(app, t, adminCookie, adminUser.Username, 0) + assertHighlights(app, t, adminCookie, 0) }) t.Run("Deleting a document also removes it from the highlights of all users", func(t *testing.T) { @@ -103,25 +98,21 @@ func TestHighlights(t *testing.T) { t.Fatalf("Unexpected error: %v", err.Error()) } - response, err := highlight(regularUserCookie, app, strings.NewReader(data.Encode()), fiber.MethodPost, t) + response, err := highlight(regularUserCookie, app, "john-doe-test-epub", fiber.MethodPost, t) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } mustReturnStatus(response, fiber.StatusOK, t) - assertHighlights(app, t, regularUserCookie, regularUser.Username, 1) + assertHighlights(app, t, regularUserCookie, 1) adminCookie, err = login(app, "admin@example.com", "admin", t) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } - data = url.Values{ - "id": {"john-doe-test-epub"}, - } - - _, err = deleteRequest(data, adminCookie, app, "/document", t) + _, err = deleteRequest(url.Values{}, adminCookie, app, "/documents/john-doe-test-epub", t) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -131,7 +122,7 @@ func TestHighlights(t *testing.T) { if total != 0 { t.Errorf("Expected no highlights in DB for user, got %d", total) } - assertHighlights(app, t, adminCookie, regularUser.Username, 0) + assertHighlights(app, t, adminCookie, 0) }) t.Run("Deleting a user also remove his/her highlights", func(t *testing.T) { @@ -145,25 +136,21 @@ func TestHighlights(t *testing.T) { t.Fatalf("Unexpected error: %v", err.Error()) } - response, err := highlight(regularUserCookie, app, strings.NewReader(data.Encode()), fiber.MethodPost, t) + response, err := highlight(regularUserCookie, app, "john-doe-test-epub", fiber.MethodPost, t) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } mustReturnStatus(response, fiber.StatusOK, t) - assertHighlights(app, t, regularUserCookie, regularUser.Username, 1) + assertHighlights(app, t, regularUserCookie, 1) adminCookie, err = login(app, "admin@example.com", "admin", t) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } - data := url.Values{ - "id": {regularUser.Uuid}, - } - - _, err = deleteRequest(data, adminCookie, app, "/users", t) + _, err = deleteRequest(url.Values{}, adminCookie, app, fmt.Sprintf("/users/%s", regularUser.Username), t) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -173,13 +160,12 @@ func TestHighlights(t *testing.T) { if total != 0 { t.Errorf("Expected no highlights in DB for deleted user, got %d", total) } - assertNoHighlights(app, t, adminCookie, regularUser.Username) }) } -func highlight(cookie *http.Cookie, app *fiber.App, reader *strings.Reader, method string, t *testing.T) (*http.Response, error) { +func highlight(cookie *http.Cookie, app *fiber.App, slug string, method string, t *testing.T) (*http.Response, error) { t.Helper() - req, err := http.NewRequest(method, "/highlights", reader) + req, err := http.NewRequest(method, fmt.Sprintf("/highlights/%s", slug), nil) if err != nil { return nil, err } @@ -189,10 +175,10 @@ func highlight(cookie *http.Cookie, app *fiber.App, reader *strings.Reader, meth return app.Test(req) } -func assertHighlights(app *fiber.App, t *testing.T, cookie *http.Cookie, username string, expectedResults int) { +func assertHighlights(app *fiber.App, t *testing.T, cookie *http.Cookie, expectedResults int) { t.Helper() - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/en/highlights/%s", username), nil) + req, err := http.NewRequest(http.MethodGet, "/highlights", nil) req.AddCookie(cookie) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) @@ -215,10 +201,10 @@ func assertHighlights(app *fiber.App, t *testing.T, cookie *http.Cookie, usernam } } -func assertNoHighlights(app *fiber.App, t *testing.T, cookie *http.Cookie, username string) { +func assertNoHighlights(app *fiber.App, t *testing.T, cookie *http.Cookie) { t.Helper() - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/en/highlights/%s", username), nil) + req, err := http.NewRequest(http.MethodGet, "/highlights", nil) req.AddCookie(cookie) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) diff --git a/internal/webserver/infrastructure/sqlite.go b/internal/webserver/infrastructure/sqlite.go index 3e15b81c..2d17ba5e 100644 --- a/internal/webserver/infrastructure/sqlite.go +++ b/internal/webserver/infrastructure/sqlite.go @@ -3,35 +3,24 @@ package infrastructure import ( "fmt" "log" - "math/rand" "os" "strings" - "time" "github.com/glebarez/sqlite" "github.com/google/uuid" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver/model" "gorm.io/gorm" ) -var ( - adjectives = []string{"red", "yellow", "white", "blue", "black", "brown", "green", "orange", "purple"} - animals = []string{"panda", "tiger", "lion", "lynx", "bear", "cat", "dog", "koala", "parrot", "dolphin", "shark", "whale", "hawk", "monkey", "vulture", "eagle"} -) - func Connect(path string, wordsPerMinute float64) *gorm.DB { - if _, err := os.Stat(path); os.IsNotExist(err) && !strings.Contains(path, "file::memory") { + if _, err := os.Stat(path); os.IsNotExist(err) && !strings.Contains(path, ":memory:") { if _, err = os.Create(path); err != nil { log.Fatal(err) } log.Printf("Created database at %s\n", path) } - // Use the following line to connect when the temporary code block below is removed - //db, err := gorm.Open(sqlite.Open(fmt.Sprintf("%s?_pragma=foreign_keys(1)", path)), &gorm.Config{}) - db, err := gorm.Open(sqlite.Open(path), &gorm.Config{ - DisableForeignKeyConstraintWhenMigrating: true, - }) + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("%s?_pragma=foreign_keys(1)", path)), &gorm.Config{}) if err != nil { log.Fatal(err) } @@ -39,43 +28,10 @@ func Connect(path string, wordsPerMinute float64) *gorm.DB { if err := db.AutoMigrate(&model.User{}, &model.Highlight{}); err != nil { log.Fatal(err) } - // The next block is temporary, used to add constraints to an en existing highlights table - // Remove when the new format is established - if !db.Migrator().HasConstraint(&model.User{}, "Highlights") { - err := db.Migrator().CreateConstraint(&model.User{}, "Highlights") - if err != nil { - log.Fatal(err) - } - err = db.Migrator().CreateConstraint(&model.User{}, "fk_users_highlights") - if err != nil { - log.Fatal(err) - } - } addDefaultAdmin(db, wordsPerMinute) - addUsernames(db) - if res := db.Exec("PRAGMA foreign_keys(1)", nil); res.Error != nil { - log.Fatal(err) - } return db } -// addUsernames is a temporary function to fill the newly created username field -// with a random username -func addUsernames(db *gorm.DB) { - var users []model.User - db.Find(&users, "username = ?", "") - s := rand.NewSource(time.Now().Unix()) - r := rand.New(s) - for _, user := range users { - if user.ID == 1 { - user.Username = "admin" - } else { - user.Username = adjectives[r.Intn(len(adjectives))] + animals[r.Intn(len(animals))] + fmt.Sprintf("%d", rand.Intn(1000)) - } - db.Save(&user) - } -} - func addDefaultAdmin(db *gorm.DB, wordsPerMinute float64) { var result int64 db.Table("users").Count(&result) diff --git a/internal/webserver/jwtclaimsreader.go b/internal/webserver/jwtclaimsreader.go index ad3a1f9e..f523d48d 100644 --- a/internal/webserver/jwtclaimsreader.go +++ b/internal/webserver/jwtclaimsreader.go @@ -3,7 +3,7 @@ package webserver import ( "github.com/gofiber/fiber/v2" "github.com/golang-jwt/jwt/v4" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver/model" ) func sessionData(c *fiber.Ctx) model.Session { diff --git a/internal/webserver/middleware.go b/internal/webserver/middleware.go index 235793dd..1cdda932 100644 --- a/internal/webserver/middleware.go +++ b/internal/webserver/middleware.go @@ -6,8 +6,8 @@ import ( "github.com/gofiber/fiber/v2" jwtware "github.com/gofiber/jwt/v3" - "github.com/svera/coreander/v3/internal/webserver/infrastructure" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver/infrastructure" + "github.com/svera/coreander/v4/internal/webserver/model" ) // RequireAdmin returns HTTP forbidden if the user requesting access diff --git a/internal/webserver/model/highlight_repository.go b/internal/webserver/model/highlight_repository.go index 00730ffa..1ad09734 100644 --- a/internal/webserver/model/highlight_repository.go +++ b/internal/webserver/model/highlight_repository.go @@ -3,8 +3,8 @@ package model import ( "log" - "github.com/svera/coreander/v3/internal/index" - "github.com/svera/coreander/v3/internal/result" + "github.com/svera/coreander/v4/internal/index" + "github.com/svera/coreander/v4/internal/result" "golang.org/x/exp/slices" "gorm.io/gorm" "gorm.io/gorm/clause" diff --git a/internal/webserver/model/user.go b/internal/webserver/model/user.go index df28cbc5..7692c4dc 100644 --- a/internal/webserver/model/user.go +++ b/internal/webserver/model/user.go @@ -20,7 +20,7 @@ type User struct { UpdatedAt time.Time Uuid string `gorm:"uniqueIndex; not null"` Name string `gorm:"not null"` - Username string `gorm:"type:text collate nocase; not null; default:''; unique"` + Username string `gorm:"type:text collate nocase; not null; unique"` Email string `gorm:"uniqueIndex; not null"` SendToEmail string Password string diff --git a/internal/webserver/model/user_repository.go b/internal/webserver/model/user_repository.go index ffc3b63c..6bd01439 100644 --- a/internal/webserver/model/user_repository.go +++ b/internal/webserver/model/user_repository.go @@ -6,7 +6,7 @@ import ( "fmt" "log" - "github.com/svera/coreander/v3/internal/result" + "github.com/svera/coreander/v4/internal/result" "gorm.io/gorm" ) diff --git a/internal/webserver/remove_document_test.go b/internal/webserver/remove_document_test.go index 36f220fb..7e29b8e8 100644 --- a/internal/webserver/remove_document_test.go +++ b/internal/webserver/remove_document_test.go @@ -1,21 +1,21 @@ package webserver_test import ( + "fmt" "log" "net/http" "net/url" "os" - "strings" "testing" "github.com/google/uuid" - "github.com/svera/coreander/v3/internal/webserver" - "github.com/svera/coreander/v3/internal/webserver/infrastructure" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver" + "github.com/svera/coreander/v4/internal/webserver/infrastructure" + "github.com/svera/coreander/v4/internal/webserver/model" ) func TestRemoveDocument(t *testing.T) { - db := infrastructure.Connect("file::memory:", 250) + db := infrastructure.Connect(":memory:", 250) smtpMock := &infrastructure.SMTPMock{} appFS := loadDirInMemoryFs("fixtures/library") app := bootstrapApp(db, smtpMock, appFS, webserver.Config{}) @@ -43,8 +43,7 @@ func TestRemoveDocument(t *testing.T) { slug string expectedHTTPStatus int }{ - {"Remove no document slug", "admin@example.com", "admin", "", "", http.StatusBadRequest}, - {"Remove non existing document slug", "admin@example.com", "admin", "wrong.epub", "", http.StatusBadRequest}, + {"Remove non existing document slug", "admin@example.com", "admin", "wrong.epub", "wrong-epub", http.StatusBadRequest}, {"Remove document with a regular user", "regular@example.com", "regular", "metadata.epub", "john-doe-test-epub", http.StatusForbidden}, {"Remove document with an admin user", "admin@example.com", "admin", "metadata.epub", "john-doe-test-epub", http.StatusOK}, } @@ -56,23 +55,15 @@ func TestRemoveDocument(t *testing.T) { err error ) - data := url.Values{ - "id": {tcase.slug}, - } - cookie, err := login(app, tcase.email, tcase.password, t) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } - req, err := http.NewRequest(http.MethodDelete, "/document", strings.NewReader(data.Encode())) + response, err = deleteRequest(url.Values{}, cookie, app, fmt.Sprintf("/documents/%s", tcase.slug), t) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.AddCookie(cookie) - - response, err = app.Test(req) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) diff --git a/internal/webserver/routes.go b/internal/webserver/routes.go index e9a8f65f..0a485c6c 100644 --- a/internal/webserver/routes.go +++ b/internal/webserver/routes.go @@ -1,19 +1,20 @@ package webserver import ( - "fmt" "net/http" - "strings" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/filesystem" - "github.com/svera/coreander/v3/internal/webserver/controller" + "github.com/svera/coreander/v4/internal/webserver/view" ) func routes(app *fiber.App, controllers Controllers, jwtSecret []byte, sender Sender, requireAuth bool) { - var allowIfNotLoggedIn = AllowIfNotLoggedIn(jwtSecret) - var alwaysRequireAuthentication = AlwaysRequireAuthentication(jwtSecret, sender) - var configurableAuthentication = ConfigurableAuthentication(jwtSecret, sender, requireAuth) + // Middlewares + var ( + allowIfNotLoggedIn = AllowIfNotLoggedIn(jwtSecret) + alwaysRequireAuthentication = AlwaysRequireAuthentication(jwtSecret, sender) + configurableAuthentication = ConfigurableAuthentication(jwtSecret, sender, requireAuth) + ) app.Use("/css", filesystem.New(filesystem.Config{ Root: http.FS(cssFS), @@ -30,64 +31,51 @@ func routes(app *fiber.App, controllers Controllers, jwtSecret []byte, sender Se app.Use(func(c *fiber.Ctx) error { c.Locals("Version", c.App().Config().AppName) c.Locals("SupportedLanguages", supportedLanguages) + c.Locals("Lang", chooseBestLanguage(c)) + q := c.Queries() + delete(q, "l") + c.Locals("URLPath", c.Path()) + c.Locals("QueryString", view.ToQueryString(q)) return c.Next() }) - langGroup := app.Group(fmt.Sprintf("/:lang", strings.Join(supportedLanguages, "|")), func(c *fiber.Ctx) error { - pathMinusLang := c.Path()[3:] - query := string(c.Request().URI().QueryString()) - if query != "" { - pathMinusLang = pathMinusLang + "?" + query - } - c.Locals("Lang", c.Params("lang")) - c.Locals("PathMinusLang", pathMinusLang) - return c.Next() - }) - - langGroup.Get("/login", allowIfNotLoggedIn, controllers.Auth.Login) - langGroup.Post("login", allowIfNotLoggedIn, controllers.Auth.SignIn) - langGroup.Get("/recover", allowIfNotLoggedIn, controllers.Auth.Recover) - langGroup.Post("/recover", allowIfNotLoggedIn, controllers.Auth.Request) - langGroup.Get("/reset-password", allowIfNotLoggedIn, controllers.Auth.EditPassword) - langGroup.Post("/reset-password", allowIfNotLoggedIn, controllers.Auth.UpdatePassword) - - usersGroup := langGroup.Group("/users", alwaysRequireAuthentication) - - usersGroup.Get("/", alwaysRequireAuthentication, RequireAdmin, controllers.Users.List) - usersGroup.Get("/new", alwaysRequireAuthentication, RequireAdmin, controllers.Users.New) - usersGroup.Post("/new", alwaysRequireAuthentication, RequireAdmin, controllers.Users.Create) - usersGroup.Get("/:username/edit", alwaysRequireAuthentication, controllers.Users.Edit) - usersGroup.Post("/:username/edit", alwaysRequireAuthentication, controllers.Users.Update) - app.Delete("/users", alwaysRequireAuthentication, RequireAdmin, controllers.Users.Delete) - - langGroup.Get("/highlights/:username", alwaysRequireAuthentication, controllers.Highlights.Highlights) - app.Post("/highlights", alwaysRequireAuthentication, controllers.Highlights.Highlight) - app.Delete("/highlights", alwaysRequireAuthentication, controllers.Highlights.Remove) - - app.Delete("/document", alwaysRequireAuthentication, RequireAdmin, controllers.Documents.Delete) - - langGroup.Get("/upload", alwaysRequireAuthentication, RequireAdmin, controllers.Documents.UploadForm) - langGroup.Post("/upload", alwaysRequireAuthentication, RequireAdmin, controllers.Documents.Upload) - - langGroup.Get("/logout", alwaysRequireAuthentication, controllers.Auth.SignOut) + app.Get("/sessions/new", allowIfNotLoggedIn, controllers.Auth.Login) + app.Post("/sessions", allowIfNotLoggedIn, controllers.Auth.SignIn) + app.Get("/recover", allowIfNotLoggedIn, controllers.Auth.Recover) + app.Post("/recover", allowIfNotLoggedIn, controllers.Auth.Request) + app.Get("/reset-password", allowIfNotLoggedIn, controllers.Auth.EditPassword) + app.Post("/reset-password", allowIfNotLoggedIn, controllers.Auth.UpdatePassword) + app.Delete("/sessions", alwaysRequireAuthentication, controllers.Auth.SignOut) + + usersGroup := app.Group("/users", alwaysRequireAuthentication) + + usersGroup.Get("/", RequireAdmin, controllers.Users.List) + usersGroup.Get("/new", RequireAdmin, controllers.Users.New) + usersGroup.Post("/", RequireAdmin, controllers.Users.Create) + usersGroup.Get("/:username", controllers.Users.Edit) + usersGroup.Put("/:username", controllers.Users.Update) + usersGroup.Delete("/:username", RequireAdmin, controllers.Users.Delete) + + highlightsGroup := app.Group("/highlights", alwaysRequireAuthentication) + highlightsGroup.Get("/", controllers.Highlights.List) + highlightsGroup.Post("/:slug", controllers.Highlights.Create) + highlightsGroup.Delete("/:slug", controllers.Highlights.Delete) + + docsGroup := app.Group("/documents") + app.Get("/upload", alwaysRequireAuthentication, RequireAdmin, controllers.Documents.UploadForm) + docsGroup.Post("/", alwaysRequireAuthentication, RequireAdmin, controllers.Documents.Upload) + docsGroup.Delete("/:slug", alwaysRequireAuthentication, RequireAdmin, controllers.Documents.Delete) // Authentication requirement is configurable for all routes below this middleware - langGroup.Use(configurableAuthentication) + app.Use(configurableAuthentication) app.Use(configurableAuthentication) - app.Get("/cover/:slug", controllers.Documents.Cover) - - langGroup.Get("/document/:slug", controllers.Documents.Detail) - - app.Post("/send", controllers.Documents.Send) - - app.Get("/download/:slug", controllers.Documents.Download) - - langGroup.Get("/", controllers.Documents.Search) - - langGroup.Get("/read/:slug", controllers.Documents.Reader) + docsGroup.Get("/:slug/cover", controllers.Documents.Cover) + docsGroup.Get("/:slug/read", controllers.Documents.Reader) + docsGroup.Get("/:slug/download", controllers.Documents.Download) + docsGroup.Post("/:slug/send", controllers.Documents.Send) + docsGroup.Get("/:slug", controllers.Documents.Detail) + docsGroup.Get("/", controllers.Documents.Search) - app.Get("/", func(c *fiber.Ctx) error { - return controller.Root(c, supportedLanguages) - }) + app.Get("/", controllers.Documents.Search) } diff --git a/internal/webserver/search_test.go b/internal/webserver/search_test.go index bfea3a00..064edb17 100644 --- a/internal/webserver/search_test.go +++ b/internal/webserver/search_test.go @@ -6,12 +6,12 @@ import ( "github.com/PuerkitoBio/goquery" "github.com/gofiber/fiber/v2" - "github.com/svera/coreander/v3/internal/webserver" - "github.com/svera/coreander/v3/internal/webserver/infrastructure" + "github.com/svera/coreander/v4/internal/webserver" + "github.com/svera/coreander/v4/internal/webserver/infrastructure" ) func TestSearch(t *testing.T) { - db := infrastructure.Connect("file::memory:", 250) + db := infrastructure.Connect(":memory:", 250) smtpMock := &infrastructure.SMTPMock{} appFS := loadDirInMemoryFs("fixtures/library") @@ -22,8 +22,8 @@ func TestSearch(t *testing.T) { url string expectedResults int }{ - {"Search for documents with no metadata", "/en?search=empty", 2}, - {"Search for documents with metadata", "/en?search=john+doe", 4}, + {"Search for documents with no metadata", "/documents?search=empty", 2}, + {"Search for documents with metadata", "/documents?search=john+doe", 4}, } for _, tcase := range cases { @@ -55,7 +55,7 @@ func TestSearch(t *testing.T) { func assertSearchResults(app *fiber.App, t *testing.T, search string, expectedResults int) { t.Helper() - req, err := http.NewRequest(http.MethodGet, "/en?search="+search, nil) + req, err := http.NewRequest(http.MethodGet, "/documents?search="+search, nil) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } diff --git a/internal/webserver/send_document_test.go b/internal/webserver/send_document_test.go index 9b28ac02..b4131232 100644 --- a/internal/webserver/send_document_test.go +++ b/internal/webserver/send_document_test.go @@ -7,12 +7,12 @@ import ( "testing" "github.com/spf13/afero" - "github.com/svera/coreander/v3/internal/webserver" - "github.com/svera/coreander/v3/internal/webserver/infrastructure" + "github.com/svera/coreander/v4/internal/webserver" + "github.com/svera/coreander/v4/internal/webserver/infrastructure" ) func TestSendDocument(t *testing.T) { - db := infrastructure.Connect("file::memory:", 250) + db := infrastructure.Connect(":memory:", 250) smtpMock := &infrastructure.SMTPMock{} app := bootstrapApp(db, smtpMock, afero.NewOsFs(), webserver.Config{}) @@ -22,7 +22,6 @@ func TestSendDocument(t *testing.T) { slug string expectedHTTPStatus int }{ - {"Send no document slug", "admin@example.com", "", http.StatusBadRequest}, {"Send no email address", "", "empty", http.StatusBadRequest}, {"Send non existing document slug", "admin@example.com", "wrong", http.StatusBadRequest}, {"Send document slug and email address", "admin@example.com", "john-doe-test-epub", http.StatusOK}, @@ -37,10 +36,9 @@ func TestSendDocument(t *testing.T) { data := url.Values{ "email": {tcase.email}, - "slug": {tcase.slug}, } - req, err := http.NewRequest(http.MethodPost, "/send", strings.NewReader(data.Encode())) + req, err := http.NewRequest(http.MethodPost, "/documents/"+tcase.slug+"/send", strings.NewReader(data.Encode())) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) diff --git a/internal/webserver/upload_test.go b/internal/webserver/upload_test.go index 11b71325..478fdcfd 100644 --- a/internal/webserver/upload_test.go +++ b/internal/webserver/upload_test.go @@ -13,13 +13,13 @@ import ( "github.com/gofiber/fiber/v2" "github.com/spf13/afero" - "github.com/svera/coreander/v3/internal/webserver" - "github.com/svera/coreander/v3/internal/webserver/infrastructure" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver" + "github.com/svera/coreander/v4/internal/webserver/infrastructure" + "github.com/svera/coreander/v4/internal/webserver/model" ) func TestUpload(t *testing.T) { - db := infrastructure.Connect("file::memory:", 250) + db := infrastructure.Connect(":memory:", 250) appFS := loadDirInMemoryFs("fixtures/library") app := bootstrapApp(db, &infrastructure.NoEmail{}, appFS, webserver.Config{}) @@ -38,7 +38,7 @@ func TestUpload(t *testing.T) { t.Fatalf("Unexpected error: %v", err.Error()) } - response, err := postRequest(data, adminCookie, app, "/en/users/new", t) + response, err := postRequest(data, adminCookie, app, "/users", t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -49,7 +49,7 @@ func TestUpload(t *testing.T) { } t.Run("Try to access upload page without an active session", func(t *testing.T) { - response, err := getRequest(&http.Cookie{}, app, "/en/upload", t) + response, err := getRequest(&http.Cookie{}, app, "/upload", t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -58,7 +58,7 @@ func TestUpload(t *testing.T) { }) t.Run("Try to access upload page with a regular user session", func(t *testing.T) { - response, err = getRequest(regularUserCookie, app, "/en/upload", t) + response, err = getRequest(regularUserCookie, app, "/upload", t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -71,7 +71,7 @@ func TestUpload(t *testing.T) { multipartWriter := multipart.NewWriter(&buf) multipartWriter.Close() - req, err := http.NewRequest(http.MethodPost, "/en/upload", &buf) + req, err := http.NewRequest(http.MethodPost, "/documents", &buf) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -87,7 +87,7 @@ func TestUpload(t *testing.T) { }) t.Run("Try to access upload page with an admin active session", func(t *testing.T) { - response, err := getRequest(adminCookie, app, "/en/upload", t) + response, err := getRequest(adminCookie, app, "/upload", t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -105,7 +105,7 @@ func TestUpload(t *testing.T) { filePart.Write([]byte("Hello, World!")) multipartWriter.Close() - req, err := http.NewRequest(http.MethodPost, "/en/upload", &buf) + req, err := http.NewRequest(http.MethodPost, "/documents", &buf) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -133,7 +133,7 @@ func TestUpload(t *testing.T) { part.Write([]byte(`sample`)) multipartWriter.Close() - req, err := http.NewRequest(http.MethodPost, "/en/upload", &buf) + req, err := http.NewRequest(http.MethodPost, "/documents", &buf) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -155,7 +155,7 @@ func TestUpload(t *testing.T) { multipartWriter := multipart.NewWriter(&buf) multipartWriter.Close() - req, err := http.NewRequest(http.MethodPost, "/en/upload", &buf) + req, err := http.NewRequest(http.MethodPost, "/documents", &buf) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -189,7 +189,7 @@ func TestUpload(t *testing.T) { multipartWriter.Close() - req, err := http.NewRequest(http.MethodPost, "/en/upload", &buf) + req, err := http.NewRequest(http.MethodPost, "/documents", &buf) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -232,7 +232,7 @@ func TestUpload(t *testing.T) { multipartWriter.Close() - req, err := http.NewRequest(http.MethodPost, "/en/upload", &buf) + req, err := http.NewRequest(http.MethodPost, "/documents", &buf) if err != nil { t.Fatalf("Unexpected error: %v", err.Error()) } diff --git a/internal/webserver/user_management_test.go b/internal/webserver/user_management_test.go index 598337a4..b58c26f1 100644 --- a/internal/webserver/user_management_test.go +++ b/internal/webserver/user_management_test.go @@ -11,9 +11,9 @@ import ( "github.com/PuerkitoBio/goquery" "github.com/gofiber/fiber/v2" "github.com/spf13/afero" - "github.com/svera/coreander/v3/internal/webserver" - "github.com/svera/coreander/v3/internal/webserver/infrastructure" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/webserver" + "github.com/svera/coreander/v4/internal/webserver/infrastructure" + "github.com/svera/coreander/v4/internal/webserver/model" "gorm.io/gorm" ) @@ -32,7 +32,7 @@ func TestUserManagement(t *testing.T) { t.Helper() var err error - db = infrastructure.Connect("file::memory:", 250) + db = infrastructure.Connect(":memory:", 250) app = bootstrapApp(db, &infrastructure.NoEmail{}, afero.NewMemMapFs(), webserver.Config{}) adminUser = model.User{} @@ -53,7 +53,7 @@ func TestUserManagement(t *testing.T) { "words-per-minute": {"250"}, } - response, err := postRequest(regularUserData, adminCookie, app, "/en/users/new", t) + response, err := postRequest(regularUserData, adminCookie, app, "/users", t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -70,7 +70,7 @@ func TestUserManagement(t *testing.T) { t.Run("Try to add a user without an active session", func(t *testing.T) { reset() - response, err := getRequest(&http.Cookie{}, app, "/en/users/new", t) + response, err := getRequest(&http.Cookie{}, app, "/users/new", t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -87,7 +87,7 @@ func TestUserManagement(t *testing.T) { "words-per-minute": {"250"}, } - response, err = postRequest(newUserData, &http.Cookie{}, app, "/en/users/new", t) + response, err = postRequest(newUserData, &http.Cookie{}, app, "/users", t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -98,7 +98,7 @@ func TestUserManagement(t *testing.T) { t.Run("Try to add a user with an admin active session", func(t *testing.T) { reset() - response, err := getRequest(adminCookie, app, "/en/users/new", t) + response, err := getRequest(adminCookie, app, "/users/new", t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -116,7 +116,7 @@ func TestUserManagement(t *testing.T) { "words-per-minute": {"250"}, } - response, err = postRequest(newUserData, adminCookie, app, "/en/users/new", t) + response, err = postRequest(newUserData, adminCookie, app, "/users", t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -133,14 +133,14 @@ func TestUserManagement(t *testing.T) { t.Run("Try to add a user with a regular user active session", func(t *testing.T) { reset() - response, err := getRequest(regularUserCookie, app, "/en/users/new", t) + response, err := getRequest(regularUserCookie, app, "/users/new", t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } mustReturnStatus(response, fiber.StatusForbidden, t) - response, err = postRequest(url.Values{}, regularUserCookie, app, "/en/users/new", t) + response, err = postRequest(url.Values{}, regularUserCookie, app, "/users", t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -151,7 +151,7 @@ func TestUserManagement(t *testing.T) { t.Run("Try to add a user with errors in form", func(t *testing.T) { reset() - response, err := postRequest(url.Values{}, adminCookie, app, "/en/users/new", t) + response, err := postRequest(url.Values{}, adminCookie, app, "/users", t) expectedErrorMessages := []string{ "Name cannot be empty", "Username cannot be empty", @@ -180,7 +180,7 @@ func TestUserManagement(t *testing.T) { "words-per-minute": {"250"}, } - response, err := postRequest(newUserData, adminCookie, app, "/en/users/new", t) + response, err := postRequest(newUserData, adminCookie, app, "/users", t) expectedErrorMessages := []string{ "A user with this username already exists", "A user with this email address already exists", @@ -195,14 +195,14 @@ func TestUserManagement(t *testing.T) { t.Run("Try to update a user without an active session", func(t *testing.T) { reset() - response, err := getRequest(&http.Cookie{}, app, fmt.Sprintf("/en/users/%s/edit", regularUser.Username), t) + response, err := getRequest(&http.Cookie{}, app, fmt.Sprintf("/users/%s", regularUser.Username), t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } mustReturnForbiddenAndShowLogin(response, t) - response, err = postRequest(regularUserData, &http.Cookie{}, app, fmt.Sprintf("/en/users/%s/edit", regularUser.Username), t) + response, err = putRequest(regularUserData, &http.Cookie{}, app, fmt.Sprintf("/users/%s", regularUser.Username), t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -216,14 +216,14 @@ func TestUserManagement(t *testing.T) { adminUserData := regularUserData adminUserData.Set("id", adminUser.Uuid) - response, err := getRequest(regularUserCookie, app, fmt.Sprintf("/en/users/%s/edit", adminUser.Username), t) + response, err := getRequest(regularUserCookie, app, fmt.Sprintf("/users/%s", adminUser.Username), t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } mustReturnStatus(response, fiber.StatusForbidden, t) - response, err = postRequest(adminUserData, regularUserCookie, app, fmt.Sprintf("/en/users/%s/edit", adminUser.Username), t) + response, err = putRequest(adminUserData, regularUserCookie, app, fmt.Sprintf("/users/%s", adminUser.Username), t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -237,14 +237,14 @@ func TestUserManagement(t *testing.T) { regularUserData.Set("name", "Updated regular user") regularUserData.Set("id", regularUser.Uuid) - response, err := getRequest(regularUserCookie, app, fmt.Sprintf("/en/users/%s/edit", regularUser.Username), t) + response, err := getRequest(regularUserCookie, app, fmt.Sprintf("/users/%s", regularUser.Username), t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } mustReturnStatus(response, fiber.StatusOK, t) - response, err = postRequest(regularUserData, regularUserCookie, app, fmt.Sprintf("/en/users/%s/edit", regularUser.Username), t) + response, err = putRequest(regularUserData, regularUserCookie, app, fmt.Sprintf("/users/%s", regularUser.Username), t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -261,14 +261,14 @@ func TestUserManagement(t *testing.T) { regularUserData.Set("name", "Updated regular user by an admin") regularUserData.Set("id", regularUser.Uuid) - response, err := postRequest(regularUserData, adminCookie, app, fmt.Sprintf("/en/users/%s/edit", regularUser.Username), t) + response, err := putRequest(regularUserData, adminCookie, app, fmt.Sprintf("/users/%s", regularUser.Username), t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } mustReturnStatus(response, fiber.StatusOK, t) - response, err = postRequest(regularUserData, adminCookie, app, fmt.Sprintf("/en/users/%s/edit", regularUser.Username), t) + response, err = putRequest(regularUserData, adminCookie, app, fmt.Sprintf("/users/%s", regularUser.Username), t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -282,7 +282,7 @@ func TestUserManagement(t *testing.T) { t.Run("Try to edit a non existing user with an admin session", func(t *testing.T) { reset() - response, err := getRequest(adminCookie, app, fmt.Sprintf("/en/users/%s/edit", "abcde"), t) + response, err := getRequest(adminCookie, app, fmt.Sprintf("/users/%s", "abcde"), t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -294,7 +294,7 @@ func TestUserManagement(t *testing.T) { regularUserData.Set("name", "Updated test user by an admin") - response, err := postRequest(regularUserData, adminCookie, app, fmt.Sprintf("/en/users/%s/edit", "abcde"), t) + response, err := putRequest(regularUserData, adminCookie, app, fmt.Sprintf("/users/%s", "abcde"), t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -304,11 +304,7 @@ func TestUserManagement(t *testing.T) { t.Run("Try to delete a user without an active session", func(t *testing.T) { reset() - regularUserData = url.Values{ - "id": {regularUser.Uuid}, - } - - response, err := deleteRequest(regularUserData, &http.Cookie{}, app, "/users", t) + response, err := deleteRequest(url.Values{}, &http.Cookie{}, app, fmt.Sprintf("/users/%s", regularUser.Username), t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -319,13 +315,7 @@ func TestUserManagement(t *testing.T) { t.Run("Try to delete a user with a regular user's session", func(t *testing.T) { reset() - regularUserData = url.Values{ - "id": {regularUser.Uuid}, - } - - regularUserData.Set("name", "Updated test user") - - response, err := deleteRequest(regularUserData, regularUserCookie, app, "/users", t) + response, err := deleteRequest(url.Values{}, regularUserCookie, app, fmt.Sprintf("/users/%s", regularUser.Username), t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -336,11 +326,7 @@ func TestUserManagement(t *testing.T) { t.Run("Try to delete a user with an admin session", func(t *testing.T) { reset() - regularUserData = url.Values{ - "id": {regularUser.Uuid}, - } - - response, err := deleteRequest(regularUserData, adminCookie, app, "/users", t) + response, err := deleteRequest(url.Values{}, adminCookie, app, fmt.Sprintf("/users/%s", regularUser.Username), t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -355,10 +341,7 @@ func TestUserManagement(t *testing.T) { t.Run("Try to delete the only existing admin user", func(t *testing.T) { reset() - regularUserData = url.Values{ - "id": {adminUser.Uuid}, - } - response, err := deleteRequest(regularUserData, adminCookie, app, "/users", t) + response, err := deleteRequest(url.Values{}, adminCookie, app, fmt.Sprintf("/users/%s", adminUser.Username), t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -369,11 +352,7 @@ func TestUserManagement(t *testing.T) { t.Run("Try to delete a non existing user with an admin session", func(t *testing.T) { reset() - regularUserData = url.Values{ - "id": {"abcde"}, - } - - response, err := deleteRequest(regularUserData, adminCookie, app, "/users", t) + response, err := deleteRequest(url.Values{}, adminCookie, app, "/users/wrong", t) if response == nil { t.Fatalf("Unexpected error: %v", err.Error()) } @@ -407,8 +386,8 @@ func mustRedirectToUsersList(response *http.Response, t *testing.T) { if err != nil { t.Fatal("No location header present") } - if url.Path != "/en/users" { - t.Errorf("Expected location %s, received %s", "/en/users", url.Path) + if url.Path != "/users" { + t.Errorf("Expected location %s, received %s", "/users", url.Path) } } @@ -428,7 +407,7 @@ func login(app *fiber.App, email, password string, t *testing.T) (*http.Cookie, "password": {password}, } - req, err := http.NewRequest(http.MethodPost, "/en/login", strings.NewReader(data.Encode())) + req, err := http.NewRequest(http.MethodPost, "/sessions", strings.NewReader(data.Encode())) if err != nil { return nil, err } diff --git a/internal/webserver/view/paginator.go b/internal/webserver/view/paginator.go index 321480b5..60baeabd 100644 --- a/internal/webserver/view/paginator.go +++ b/internal/webserver/view/paginator.go @@ -2,10 +2,8 @@ package view import ( "fmt" - "net/url" - "strings" - "github.com/svera/coreander/v3/internal/result" + "github.com/svera/coreander/v4/internal/result" ) // Page holds the URL of a results page, and if that page is the current one being shown @@ -45,29 +43,18 @@ func Pagination[T any](size int, results result.Paginated[T], params map[string] } for i := start; i <= end; i++ { p := Page{ - Link: fmt.Sprintf("?%spage=%d", toQueryString(params), i), + Link: fmt.Sprintf("?%s&page=%d", ToQueryString(params), i), } if i == results.Page() { p.IsCurrent = true if i > 1 { - nav.PreviousLink = fmt.Sprintf("?%spage=%d", toQueryString(params), i-1) + nav.PreviousLink = fmt.Sprintf("?%s&page=%d", ToQueryString(params), i-1) } if i < results.TotalPages() { - nav.NextLink = fmt.Sprintf("?%spage=%d", toQueryString(params), i+1) + nav.NextLink = fmt.Sprintf("?%s&page=%d", ToQueryString(params), i+1) } } nav.Pages[i] = p } return nav } - -func toQueryString(m map[string]string) string { - if len(m) == 0 { - return "" - } - parts := make([]string, 0, len(m)) - for k, v := range m { - parts = append(parts, fmt.Sprintf("%s=%s", k, url.QueryEscape(v))) - } - return strings.Join(parts, "&") + "&" -} diff --git a/internal/webserver/view/to_query_string.go b/internal/webserver/view/to_query_string.go new file mode 100644 index 00000000..b532ea3c --- /dev/null +++ b/internal/webserver/view/to_query_string.go @@ -0,0 +1,19 @@ +package view + +import ( + "fmt" + "html/template" + "net/url" + "strings" +) + +func ToQueryString(m map[string]string) template.URL { + if len(m) == 0 { + return "" + } + parts := make([]string, 0, len(m)) + for k, v := range m { + parts = append(parts, fmt.Sprintf("%s=%s", k, url.QueryEscape(v))) + } + return template.URL(strings.Join(parts, "&")) +} diff --git a/internal/webserver/webserver.go b/internal/webserver/webserver.go index 5a1433f7..7683d6bd 100644 --- a/internal/webserver/webserver.go +++ b/internal/webserver/webserver.go @@ -13,10 +13,10 @@ import ( "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cache" "github.com/gofiber/fiber/v2/middleware/favicon" - "github.com/svera/coreander/v3/internal/i18n" - "github.com/svera/coreander/v3/internal/index" - "github.com/svera/coreander/v3/internal/webserver/infrastructure" - "github.com/svera/coreander/v3/internal/webserver/model" + "github.com/svera/coreander/v4/internal/i18n" + "github.com/svera/coreander/v4/internal/index" + "github.com/svera/coreander/v4/internal/webserver/infrastructure" + "github.com/svera/coreander/v4/internal/webserver/model" "golang.org/x/exp/slices" "golang.org/x/text/message" ) @@ -144,7 +144,19 @@ func getSupportedLanguages() []string { } func chooseBestLanguage(c *fiber.Ctx) string { - lang := c.Params("lang") + lang := c.Query("l") + if lang != "" { + c.Cookie(&fiber.Cookie{ + Name: "locale", + Value: lang, + Path: "/", + MaxAge: 34560000, // 400 days which is the life limit imposed by Chrome + Secure: false, + HTTPOnly: true, + }) + return lang + } + lang = c.Cookies("locale") if !slices.Contains(supportedLanguages, lang) { lang = c.AcceptsLanguages(supportedLanguages...) if lang == "" { diff --git a/internal/webserver/webserver_test.go b/internal/webserver/webserver_test.go index 13ab8155..0f7f3c52 100644 --- a/internal/webserver/webserver_test.go +++ b/internal/webserver/webserver_test.go @@ -16,10 +16,10 @@ import ( "github.com/blevesearch/bleve/v2" "github.com/gofiber/fiber/v2" "github.com/spf13/afero" - "github.com/svera/coreander/v3/internal/index" - "github.com/svera/coreander/v3/internal/metadata" - "github.com/svera/coreander/v3/internal/webserver" - "github.com/svera/coreander/v3/internal/webserver/infrastructure" + "github.com/svera/coreander/v4/internal/index" + "github.com/svera/coreander/v4/internal/metadata" + "github.com/svera/coreander/v4/internal/webserver" + "github.com/svera/coreander/v4/internal/webserver/infrastructure" "gorm.io/gorm" ) @@ -29,13 +29,13 @@ func TestGET(t *testing.T) { url string expectedStatus int }{ - {"Redirect if the user tries to access to the root URL", "/", http.StatusFound}, - {"Page loads successfully if the user tries to access the spanish version", "/es", http.StatusOK}, - {"Page loads successfully if the user tries to access the english version", "/en", http.StatusOK}, + {"Redirect if the user tries to access to the root URL", "/", http.StatusOK}, + {"Page loads successfully if the user tries to access the spanish version", "/?l=es", http.StatusOK}, + {"Page loads successfully if the user tries to access the english version", "/?l=en", http.StatusOK}, {"Server returns not found if the user tries to access a non-existent URL", "/xx", http.StatusNotFound}, } - db := infrastructure.Connect("file::memory:", 250) + db := infrastructure.Connect(":memory:", 250) app := bootstrapApp(db, &infrastructure.NoEmail{}, afero.NewMemMapFs(), webserver.Config{}) for _, tcase := range cases { @@ -77,7 +77,7 @@ func bootstrapApp(db *gorm.DB, sender webserver.Sender, appFs afero.Fs, webserve idx = index.NewBleve(indexFile, appFs, webserverConfig.LibraryPath, metadataReaders) } - err = idx.AddLibrary(100) + err = idx.AddLibrary(100, true) if err != nil { log.Fatal(err) } @@ -124,6 +124,12 @@ func postRequest(data url.Values, cookie *http.Cookie, app *fiber.App, URL strin return formRequest(http.MethodPost, data, cookie, app, URL) } +func putRequest(data url.Values, cookie *http.Cookie, app *fiber.App, URL string, t *testing.T) (*http.Response, error) { + t.Helper() + + return formRequest(http.MethodPut, data, cookie, app, URL) +} + func deleteRequest(data url.Values, cookie *http.Cookie, app *fiber.App, URL string, t *testing.T) (*http.Response, error) { t.Helper() diff --git a/main.go b/main.go index 80f5026c..18abdc10 100644 --- a/main.go +++ b/main.go @@ -13,10 +13,10 @@ import ( "gorm.io/gorm" "github.com/spf13/afero" - "github.com/svera/coreander/v3/internal/index" - "github.com/svera/coreander/v3/internal/metadata" - "github.com/svera/coreander/v3/internal/webserver" - "github.com/svera/coreander/v3/internal/webserver/infrastructure" + "github.com/svera/coreander/v4/internal/index" + "github.com/svera/coreander/v4/internal/metadata" + "github.com/svera/coreander/v4/internal/webserver" + "github.com/svera/coreander/v4/internal/webserver/infrastructure" ) var version string = "unknown" @@ -36,6 +36,7 @@ var ( ) func init() { + log.Printf("Coreander version %s starting\n", version) homeDir, err = os.UserHomeDir() if err != nil { log.Fatal("Error retrieving user home dir") @@ -80,11 +81,7 @@ func migrateDir() { func main() { defer idx.Close() - if !cfg.SkipIndexing { - go startIndex(idx, appFs, cfg.BatchSize, cfg.LibPath) - } else { - go fileWatcher(idx, cfg.LibPath) - } + go startIndex(idx, appFs, cfg.BatchSize, cfg.LibPath) sender = &infrastructure.NoEmail{} if cfg.SmtpServer != "" && cfg.SmtpUser != "" && cfg.SmtpPassword != "" { @@ -101,7 +98,6 @@ func main() { MinPasswordLength: cfg.MinPasswordLength, WordsPerMinute: cfg.WordsPerMinute, JwtSecret: cfg.JwtSecret, - Hostname: cfg.Hostname, FQDN: cfg.FQDN, Port: cfg.Port, HomeDir: homeDir, @@ -111,12 +107,6 @@ func main() { UploadDocumentMaxSize: cfg.UploadDocumentMaxSize, } - // Hack for keeping backward compatibility, remove when complete - if webserverConfig.FQDN == "localhost" && webserverConfig.Hostname != "localhost" { - fmt.Println("Warning: using deprecated environment variable 'HOSTNAME`, use 'FQDN' instead.") - webserverConfig.FQDN = webserverConfig.Hostname - } - webserverConfig.SessionTimeout, err = time.ParseDuration(fmt.Sprintf("%fh", cfg.SessionTimeout)) if err != nil { log.Fatal(fmt.Errorf("wrong value for session timeout")) @@ -130,16 +120,16 @@ func main() { controllers := webserver.SetupControllers(webserverConfig, db, metadataReaders, idx, sender, appFs) app := webserver.New(webserverConfig, controllers, sender, idx) if strings.ToLower(cfg.FQDN) == "localhost" { - fmt.Printf("Warning: using \"localhost\" as FQDN. Links using this FQDN won't be accesible outside this system.\n") + fmt.Printf("Warning: using \"localhost\" as FQDN. Links using this FQDN won't be accessible outside this system.\n") } - fmt.Printf("Coreander version %s started listening on port %d\n\n", version, cfg.Port) + log.Printf("Started listening on port %d\n", cfg.Port) log.Fatal(app.Listen(fmt.Sprintf(":%d", cfg.Port))) } func startIndex(idx *index.BleveIndexer, appFs afero.Fs, batchSize int, libPath string) { start := time.Now().Unix() log.Printf("Indexing documents at %s, this can take a while depending on the size of your library.", libPath) - err := idx.AddLibrary(batchSize) + err := idx.AddLibrary(batchSize, cfg.ForceIndexing) if err != nil { log.Fatal(err) } @@ -153,7 +143,6 @@ func getIndexFile(fs afero.Fs) bleve.Index { indexFile, err := bleve.Open(homeDir + indexPath) if err == bleve.ErrorIndexPathDoesNotExist { log.Println("No index found, creating a new one.") - cfg.SkipIndexing = false indexFile = index.Create(homeDir + indexPath) } version, err := indexFile.GetInternal([]byte("version")) @@ -165,7 +154,6 @@ func getIndexFile(fs afero.Fs) bleve.Index { if err = fs.RemoveAll(homeDir + indexPath); err != nil { log.Fatal(err) } - cfg.SkipIndexing = false indexFile = index.Create(homeDir + indexPath) } return indexFile