mirror of
https://github.com/sbondCo/Watcharr.git
synced 2026-08-07 07:14:44 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61382de4d2 | |||
| e65ba9d1d9 | |||
| 6654d85448 | |||
| eabdb47cfa | |||
| 643ca0fd38 | |||
| a08b19675a | |||
| 38fba0ac10 | |||
| f23552c170 | |||
| 448fdd9df3 | |||
| ca428327e2 | |||
| f7cca74a41 | |||
| 86286b967f | |||
| 8243838bfb | |||
| 5bebb754ed | |||
| c8920c911d | |||
| dd0bae137f | |||
| d5baee0df7 | |||
| 764a25be6c | |||
| c019162994 | |||
| 05f9813be8 | |||
| f285d1a8f4 | |||
| 4cc9bd3edb | |||
| 34f7f54e72 | |||
| 3fe2d2b155 |
@@ -11,13 +11,20 @@ on:
|
||||
- "server/**"
|
||||
|
||||
jobs:
|
||||
format_check_go:
|
||||
test_and_format_check_go:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
|
||||
- name: go test all packages
|
||||
run: go test ./...
|
||||
|
||||
- name: gofmt test
|
||||
run: |
|
||||
outp=$(gofmt -l .)
|
||||
|
||||
+46
-4
@@ -2,9 +2,37 @@
|
||||
|
||||
These changes are awaiting release:
|
||||
|
||||
## Changed
|
||||
|
||||
- Move notifications to its own component.
|
||||
- Use an `svg` for the favicon and change its `fill` to `white` for dark themed browsers.
|
||||
|
||||
## Fixed
|
||||
|
||||
- SpinnerTiny: Use svg instead of the css border trick to create the spinner fixing it looking more like a horseshoe than a circle on different browser scales (also added animation on the svg circle).
|
||||
- SpinnerTiny: Fix color being wrong on dark theme.
|
||||
- Watched: Also log the `error` on the "failed to restore existing watched entry" log.
|
||||
|
||||
## Maintenance
|
||||
|
||||
- workflows/test-pr-server: Add test step
|
||||
|
||||
# [4.0.0] - 2026-07-11T02:34:00Z
|
||||
|
||||
> [!CAUTION]
|
||||
> It is always highly recommended that you perform a backup before updating (https://watcharr.app/docs/server_config/backup)!
|
||||
|
||||
> [!NOTE]
|
||||
> v4 is finally here! Don't worry about the major version bump, all migrations will be handled automatically when you start the upgraded version.
|
||||
>
|
||||
> Have a read of the Data Migrations section below to get an understanding of the migrations we are applying in v4. You'll also notice that on the first start there will be a delay while migrations are applied.
|
||||
|
||||
> [!NOTE]
|
||||
> This release contains security fixes. If your server is exposed to the public and you have signing up enabled, you should update sooner rather than later!
|
||||
|
||||
## Data migrations
|
||||
|
||||
This is the first time migrations are being taken place when updating Watcharr, please be mindful of that and **ensure you have your existing database backed up** incase of any errors.
|
||||
This is the first time migrations are taking place when updating Watcharr, please be mindful of that and **ensure you have your existing database backed up** incase of any errors.
|
||||
|
||||
### Backfilling `plays` data from users Activity.
|
||||
|
||||
@@ -39,6 +67,7 @@ If you backup your database by copying the .db file (while your server is stoppe
|
||||
- Moved db to WAL journal_mode.
|
||||
- WatchedUpdateRequest: Manually validate instead of using complex struct tags.
|
||||
- Now properly validating WatchedStatus.
|
||||
- ViewTrailerButton: Use youtube-nocookie.com (thanks [@GreatGatsby102])
|
||||
|
||||
## Fixed
|
||||
|
||||
@@ -49,6 +78,8 @@ If you backup your database by copying the .db file (while your server is stoppe
|
||||
- import: myanimelist: Don't import start/finish dates when they are empty.
|
||||
- Star and Play icons color.
|
||||
- Activity: Fixed automation tooltip going out of bounds by moving it to top.
|
||||
- Make `image` package a lot more robust (thanks [@4qu4r1um]).
|
||||
- GHSA-5q73-v9hv-4cf3
|
||||
|
||||
## Removed
|
||||
|
||||
@@ -59,6 +90,14 @@ If you backup your database by copying the .db file (while your server is stoppe
|
||||
|
||||
- Backup: Also note that `watcharr.db-wal` should be backed up along with the .db file since v3.
|
||||
|
||||
## New Contributors
|
||||
|
||||
- [@GreatGatsby102] made their first contribution in https://github.com/sbondCo/Watcharr/pull/1040
|
||||
|
||||
## Etc
|
||||
|
||||
- **Package**: https://github.com/sbondCo/Watcharr/pkgs/container/watcharr/1020169511?tag=v4.0.0 or on [docker hub](https://hub.docker.com/layers/sbondco/watcharr/v4.0.0/images/sha256-cd39b4fc0578ca374b2c7d65b410e29dc2859918a322e35e6b01f8e55d250f10).
|
||||
|
||||
# [3.0.1] - 2026-03-09
|
||||
|
||||
Hi All, delivered straight to your inbox today; you have bug fixes, some even improving general quality of life!!
|
||||
@@ -189,7 +228,7 @@ Many thanks to everyone who has worked on this release!
|
||||
## New
|
||||
|
||||
- About modal (accessible via face menu) by [@Clusters] in https://github.com/sbondCo/Watcharr/pull/811
|
||||
- System app theme (automatically swaps between light/dark themes depending on system config) by @antoniosarro in https://github.com/sbondCo/Watcharr/pull/822
|
||||
- System app theme (automatically swaps between light/dark themes depending on system config) by [@antoniosarro] in https://github.com/sbondCo/Watcharr/pull/822
|
||||
- Search shortcut (`Ctrl+S`) by [@IRHM] in https://github.com/sbondCo/Watcharr/pull/886
|
||||
|
||||
## Fixed
|
||||
@@ -1631,7 +1670,8 @@ Welcome to Watcharr :popcorn:, hope it is enjoyed and improves anyone's experien
|
||||
|
||||
<!-- Version Changelog References (newest first) -->
|
||||
|
||||
[Unreleased]: https://github.com/sbondCo/Watcharr/compare/v3.0.1...HEAD
|
||||
[Unreleased]: https://github.com/sbondCo/Watcharr/compare/v4.0.0...HEAD
|
||||
[4.0.0]: https://github.com/sbondCo/Watcharr/compare/v3.0.1...v4.0.0
|
||||
[3.0.1]: https://github.com/sbondCo/Watcharr/compare/v3.0.0...v3.0.1
|
||||
[3.0.0]: https://github.com/sbondCo/Watcharr/compare/v2.1.1...v3.0.0
|
||||
[2.1.1]: https://github.com/sbondCo/Watcharr/compare/v2.1.0...v2.1.1
|
||||
@@ -1729,4 +1769,6 @@ Welcome to Watcharr :popcorn:, hope it is enjoyed and improves anyone's experien
|
||||
[@jigglycrumb]: https://github.com/jigglycrumb
|
||||
[@IvanBeke]: https://github.com/IvanBeke
|
||||
[@ParksideParade]: https://github.com/ParksideParade
|
||||
[Dredsen]: https://github.com/Dredsen
|
||||
[@Dredsen]: https://github.com/Dredsen
|
||||
[@GreatGatsby102]: https://github.com/GreatGatsby102
|
||||
[@4qu4r1um]: https://github.com/4qu4r1um
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "3.0.2-dev1",
|
||||
"version": "4.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "watcharr",
|
||||
"version": "3.0.2-dev1",
|
||||
"version": "4.0.1",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"license": "GPL-3.0-only",
|
||||
"version": "3.0.2-dev1",
|
||||
"version": "4.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.0.2-dev1
|
||||
4.0.1
|
||||
+30
-12
@@ -16,6 +16,10 @@ import (
|
||||
// Also runs migrations, etc, before returning connection.
|
||||
// Any error returned from this func should always make our app Exit (caller
|
||||
// handled).
|
||||
//
|
||||
// NOTE: Our mock db used in tests mimics this function, so if this func is
|
||||
// changed, you should look at the mock db to make it match if it makes
|
||||
// sense, so our tests stay accurate to prod.
|
||||
func New() (*gorm.DB, error) {
|
||||
slog.Info("New: Opening new database connection")
|
||||
// Open the database.
|
||||
@@ -27,13 +31,27 @@ func New() (*gorm.DB, error) {
|
||||
slog.Error("New: Opening database failed.")
|
||||
return nil, err
|
||||
}
|
||||
if err := configure(db); err != nil {
|
||||
slog.Error("New: Configuring connection failed!", "error", err)
|
||||
// Setup the db (migrations, etc)
|
||||
if err := Setup(db); err != nil {
|
||||
slog.Error("New: Setting up connection failed!", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Setup configures our db connection and applies migrations.
|
||||
//
|
||||
// NOTE: This exists as a separate function so it can be reused by our testutil
|
||||
// package that we want to have configured in the same way as the main db so
|
||||
// that tests reflect real life.
|
||||
func Setup(db *gorm.DB) error {
|
||||
if err := configure(db); err != nil {
|
||||
slog.Error("Setup: Configuring connection failed!", "error", err)
|
||||
return err
|
||||
}
|
||||
// Perform auto migration.
|
||||
slog.Info("New: AutoMigrating")
|
||||
err = db.AutoMigrate(
|
||||
slog.Info("Setup: AutoMigrating")
|
||||
err := db.AutoMigrate(
|
||||
&migrate.MigrationRecord{},
|
||||
&entity.User{},
|
||||
&entity.UserServices{},
|
||||
@@ -50,21 +68,21 @@ func New() (*gorm.DB, error) {
|
||||
&entity.Tag{},
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("New: Auto migration failed.")
|
||||
return nil, err
|
||||
slog.Error("Setup: Auto migration failed.")
|
||||
return err
|
||||
}
|
||||
slog.Info("New: AutoMigrated")
|
||||
slog.Info("Setup: AutoMigrated")
|
||||
// Perform our manual migrations.
|
||||
if err := migrate.Now(db); err != nil {
|
||||
slog.Error("New: Manual migrations failed.", "error", err)
|
||||
return nil, err
|
||||
slog.Error("Setup: Manual migrations failed.", "error", err)
|
||||
return err
|
||||
}
|
||||
// Optimize database.
|
||||
if err := optimize(db); err != nil {
|
||||
slog.Error("New: Optimizing database failed.", "error", err)
|
||||
return nil, err
|
||||
slog.Error("Setup: Optimizing database failed.", "error", err)
|
||||
return err
|
||||
}
|
||||
return db, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Configure our SQLite database connection.
|
||||
|
||||
@@ -54,6 +54,9 @@ func Now(db *gorm.DB) error {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("Migration has NOT been applied before.. applying.",
|
||||
"id", mig.ID)
|
||||
|
||||
// Timing the migration.
|
||||
timeBeforeMig := time.Now()
|
||||
|
||||
@@ -89,7 +92,7 @@ func Now(db *gorm.DB) error {
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("Migration applied successfully.",
|
||||
slog.Info("Migration applied successfully.",
|
||||
"id", mig.ID,
|
||||
"duration", time.Since(timeBeforeMig))
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@ var migrations = []Migration{
|
||||
Model(&entity.Activity{}).
|
||||
Where("type IN ?", []entity.ActivityType{
|
||||
entity.IMPORTED_ADDED_WATCHED,
|
||||
// TODO: Should these be here?:
|
||||
entity.IMPORTED_ADDED_WATCHED_JF,
|
||||
entity.IMPORTED_ADDED_WATCHED_PLEX,
|
||||
}).
|
||||
|
||||
@@ -35,7 +35,19 @@ func (s *Service) saveGame(c *entity.Game, onlyUpdate bool) error {
|
||||
return errors.New("game missing id or title")
|
||||
}
|
||||
if c.CoverID != "" {
|
||||
p, err := image.DownloadAndInsertImage(s.db, "https://images.igdb.com/igdb/image/upload/t_cover_big/"+c.CoverID+".png", "games")
|
||||
p, err := image.
|
||||
NewSaver(
|
||||
s.db,
|
||||
"games",
|
||||
image.ValidateOptions{
|
||||
// To avoid losing quality, we want to keep png format
|
||||
// for our game posters.
|
||||
ToFormat: image.ValidateAllowedFormatPNG,
|
||||
},
|
||||
).
|
||||
DownloadAndInsertFromUrl(
|
||||
"https://images.igdb.com/igdb/image/upload/t_cover_big/" +
|
||||
c.CoverID + ".png")
|
||||
if err != nil {
|
||||
slog.Error("saveGame: Failed to cache game cover.", "error", err)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// This router simply serves the images stored in the server data folder
|
||||
// under the `img` folder.
|
||||
// Note: The `img` folder contains user uploaded content (eg profile pictures).
|
||||
|
||||
package img
|
||||
|
||||
import (
|
||||
"path"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
}
|
||||
|
||||
func NewRouter(br *router.BaseRouter) *Router {
|
||||
return &Router{
|
||||
br,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) AddRoutes() {
|
||||
img := r.br.Router.Group("/img").
|
||||
Use(func(c *gin.Context) {
|
||||
// The two following headers are preventative since this group
|
||||
// (the static route below) hosts user uploaded content, which can
|
||||
// potentially include malicious data. We are trying to protect
|
||||
// against XSS attacks here by telling the browser to:
|
||||
// - Not sniff content; and
|
||||
// - Not execute JS; and
|
||||
// - treat the content as if it was a separate domain (so if eg
|
||||
// somehow js runs, it won't be in same context as our tokens).
|
||||
// RESOURCE: web.dev/articles/securely-hosting-user-data
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Content-Security-Policy", "default-src 'none'; sandbox")
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// Serve up img folder.
|
||||
img.Static("/", path.Join(config.DataPath, "img"))
|
||||
}
|
||||
@@ -136,7 +136,9 @@ func (r *Router) UpdateAvatar(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := r.service.UploadUserAvatar(c, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, router.ErrorResponse{Error: err.Error()})
|
||||
c.JSON(
|
||||
http.StatusInternalServerError,
|
||||
router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
|
||||
+25
-46
@@ -1,16 +1,10 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/image"
|
||||
"gorm.io/gorm"
|
||||
@@ -133,58 +127,43 @@ func (s *Service) UserUpdateBio(userId uint, newBio string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) UploadUserAvatar(c *gin.Context, userId uint) (entity.Image, error) {
|
||||
func (s *Service) UploadUserAvatar(
|
||||
c *gin.Context,
|
||||
userId uint,
|
||||
) (entity.Image, error) {
|
||||
file, err := c.FormFile("avatar")
|
||||
if err != nil {
|
||||
slog.Error("failed to get file", "error", err)
|
||||
return entity.Image{}, errors.New("no file found")
|
||||
}
|
||||
|
||||
slog.Debug("an avatar is being uploaded", "name", file.Filename)
|
||||
slog.Debug("UploadUserAvatar: An avatar is being uploaded",
|
||||
"name", file.Filename)
|
||||
|
||||
f, _ := file.Open()
|
||||
if err := image.IsValidImageType(f); err != nil {
|
||||
return entity.Image{}, errors.New("invalid image type")
|
||||
}
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
slog.Error("uploadUserAvatar: Copy failed!", "error", err)
|
||||
return entity.Image{}, errors.New("copy failed")
|
||||
}
|
||||
hs := hex.EncodeToString(h.Sum(nil))
|
||||
defer f.Close()
|
||||
|
||||
slog.Debug("image hash calculated", "hash", hs, "first_letter", hs[0:1])
|
||||
|
||||
// Upload the file to specific dst.
|
||||
outp := path.Join("img/up/", hs[0:1], hs+filepath.Ext(file.Filename))
|
||||
c.SaveUploadedFile(file, path.Join(config.DataPath, outp))
|
||||
|
||||
_, err = f.Seek(0, 0)
|
||||
img, err := image.
|
||||
NewSaver(s.db, "up", image.ValidateOptions{}).
|
||||
DownloadAndInsert(f)
|
||||
if err != nil {
|
||||
slog.Error("uploadUserAvatar seeking back to start of image failed", "error", err)
|
||||
slog.Error("UploadUserAvatar: DownloadAndInsert failed!",
|
||||
"error", err)
|
||||
return entity.Image{}, errors.New("processing image failed")
|
||||
}
|
||||
|
||||
// No need to remove old image, the daily cleanup task will handle removing unused ones.
|
||||
var img entity.Image
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// Insert avatar into db
|
||||
img, err = image.InsertImage(s.db, hs, outp, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if img.ID == 0 {
|
||||
return errors.New("image has no id")
|
||||
}
|
||||
// Update users avatar to newly inserted
|
||||
if err := tx.Where("id = ?", userId).Updates(&entity.User{AvatarID: img.ID}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// commit transaction if no errors
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("uploadUserAvatar failed!", "error", err)
|
||||
return entity.Image{}, errors.New("uploadUserAvatar transaction failed")
|
||||
// No need to remove old image, the daily cleanup task will handle removing
|
||||
// unused ones.
|
||||
|
||||
// Update users avatar to newly inserted
|
||||
res := s.db.
|
||||
Where("id = ?", userId).
|
||||
Updates(&entity.User{AvatarID: img.ID})
|
||||
if res.Error != nil {
|
||||
slog.Error("UploadUserAvatar: Updating the users avatar in db failed!",
|
||||
"error", err)
|
||||
return entity.Image{}, errors.New("updating user failed")
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
||||
@@ -443,7 +443,8 @@ func (s *Service) AddWatched(
|
||||
&watched,
|
||||
); err != nil {
|
||||
// Try to restore the entry if unique contraint hit.
|
||||
slog.Error("AddWatched: Failed to restore existing watched entry.")
|
||||
slog.Error("AddWatched: Failed to restore existing watched entry.",
|
||||
"error", err)
|
||||
// Returns watched too because handlers of certain errors
|
||||
// may need it (and it's ID since we could have fetched it here)
|
||||
return watched, err
|
||||
|
||||
+14
-108
@@ -2,33 +2,35 @@ package image
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/buckket/go-blurhash"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/util"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TODO now that this file is in the image package it no longer needs to have "image(s)"
|
||||
// in the name of all the functions..
|
||||
const (
|
||||
defMaxSize int64 = 10 * util.Mebibyte
|
||||
defMaxWidthHeight int64 = 7680
|
||||
defMaxPixels int64 = 10_000_000
|
||||
)
|
||||
|
||||
// Insert an image into database
|
||||
func InsertImage(db *gorm.DB, hash string, path string, f io.Reader) (entity.Image, error) {
|
||||
bh, _ := GetBlurHash(f)
|
||||
func Insert(
|
||||
db *gorm.DB,
|
||||
hash string,
|
||||
path string,
|
||||
b []byte,
|
||||
) (entity.Image, error) {
|
||||
br := bytes.NewReader(b)
|
||||
bh, _ := GetBlurHash(br)
|
||||
img := entity.Image{
|
||||
Hash: hash,
|
||||
Path: path,
|
||||
@@ -95,99 +97,3 @@ WHERE NOT EXISTS (
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func IsValidImageType(f multipart.File) error {
|
||||
// Read first 512 bytes, since that is all `DetectContentType` will evaluate on.
|
||||
// Reading whole file is a waste.
|
||||
buff := make([]byte, 512)
|
||||
if _, err := f.Read(buff); err != nil {
|
||||
slog.Error("isValidImageType: failed to read file into buffer", "error", err)
|
||||
return errors.New("failed to verify if image is valid")
|
||||
}
|
||||
t := http.DetectContentType(buff)
|
||||
slog.Debug("isValidImageType", "type", t)
|
||||
if t != "image/png" && t != "image/jpeg" && t != "image/webp" && t != "image/gif" {
|
||||
slog.Debug("isValidImageType: rejecting file as not valid (supported) image type")
|
||||
return errors.New("invalid file type")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DownloadAndInsertImage(db *gorm.DB, url string, imgSubPath string) (entity.Image, error) {
|
||||
slog.Debug("Attempting to download image", "url", url)
|
||||
|
||||
// Get the data
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return entity.Image{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check server response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return entity.Image{}, fmt.Errorf("bad status: %s", resp.Status)
|
||||
}
|
||||
|
||||
// Read body into byte array, then create new reader
|
||||
// So we have the ability to seek.
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
slog.Error("downloadAndInsertImage failed to read response into byte array", "error", err)
|
||||
return entity.Image{}, err
|
||||
}
|
||||
br := bytes.NewReader(b)
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, br); err != nil {
|
||||
slog.Error("DownloadAndInsertImage: Copy failed!", "error", err)
|
||||
return entity.Image{}, errors.New("copy failed")
|
||||
}
|
||||
hs := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
// Seek back for file
|
||||
_, err = br.Seek(0, 0)
|
||||
if err != nil {
|
||||
slog.Error("downloadAndInsertImage seeking back to start of br failed", "error", err)
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
outp := path.Join("img/", imgSubPath, hs[0:1], hs+filepath.Ext(resp.Request.URL.Path))
|
||||
dataOutP := path.Join(config.DataPath, outp)
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(dataOutP)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
err = os.MkdirAll(path.Dir(dataOutP), 0764)
|
||||
if err != nil {
|
||||
return entity.Image{}, err
|
||||
}
|
||||
// If dirs made, try making file again
|
||||
out, err = os.Create(dataOutP)
|
||||
if err != nil {
|
||||
return entity.Image{}, err
|
||||
}
|
||||
} else {
|
||||
return entity.Image{}, err
|
||||
}
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, br)
|
||||
if err != nil {
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
// Seek back for insertImage
|
||||
_, err = br.Seek(0, 0)
|
||||
if err != nil {
|
||||
slog.Error("downloadAndInsertImage seeking back to start of br failed", "error", err)
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
img, err := InsertImage(db, hs, outp, br)
|
||||
if err != nil {
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/internal/testutil"
|
||||
)
|
||||
|
||||
func TestDownloadAndInsertFromUrl(t *testing.T) {
|
||||
testutil.SetupLogging()
|
||||
db := testutil.SetupDB(t)
|
||||
|
||||
i, err := NewSaver(db, "test", ValidateOptions{}).
|
||||
DownloadAndInsertFromUrl(
|
||||
"https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage.png")
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadAndInsert call failed: %v", err)
|
||||
}
|
||||
|
||||
if i.ID == 0 || i.Path == "" || i.BlurHash == "" {
|
||||
t.Fatal("returned entity.Image doesn't have certain fields!",
|
||||
"id", i.ID, "path", i.Path, "blurhash", i.BlurHash)
|
||||
}
|
||||
|
||||
fullImgDataPath := path.Join(config.DataPath, i.Path)
|
||||
|
||||
// Verify file exists and looks right.
|
||||
if fi, err := os.Stat(fullImgDataPath); err != nil {
|
||||
t.Fatalf("os.stat failed %v", err)
|
||||
} else if fi.Size() <= 1 {
|
||||
t.Fatalf("image file size doesn't seem right: %v", fi.Size())
|
||||
} else if filepath.Ext(fi.Name()) != ".jpg" {
|
||||
t.Fatalf("image file name doesn't have .jpg ext: %s", fi.Name())
|
||||
}
|
||||
|
||||
if err := os.Remove(fullImgDataPath); err != nil {
|
||||
// Not a fatal error because this is extra logic only for testing,
|
||||
// but failing test because something in the real logic might possibly
|
||||
// have something to do with it failing and we should probably know.
|
||||
t.Errorf("removing the image file errored: %v", err)
|
||||
}
|
||||
|
||||
// Verify image is in db
|
||||
var c int64
|
||||
if res := db.
|
||||
Model(&entity.Image{}).
|
||||
Where(&entity.Image{ID: i.ID}).
|
||||
Count(&c); res.Error != nil {
|
||||
t.Fatalf("verification query failed: %v", res.Error)
|
||||
}
|
||||
if c != 1 {
|
||||
t.Fatalf("count of images in db doesn't look right: %v", c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/util"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func NewSaver(db *gorm.DB, saveSubPath string, vo ValidateOptions) *Saver {
|
||||
return &Saver{
|
||||
db: db,
|
||||
SaveSubPath: saveSubPath,
|
||||
ValidateOptions: vo,
|
||||
}
|
||||
}
|
||||
|
||||
type Saver struct {
|
||||
// Database
|
||||
db *gorm.DB
|
||||
// Image save sub path.
|
||||
// Eg: `img/<subPath>/`
|
||||
SaveSubPath string
|
||||
// Validate options.
|
||||
ValidateOptions ValidateOptions
|
||||
}
|
||||
|
||||
// Download an image from `url` and insert it.
|
||||
func (s *Saver) DownloadAndInsertFromUrl(url string) (entity.Image, error) {
|
||||
slog.Debug("DownloadAndInsertFromUrl: Running.", "url", url)
|
||||
|
||||
// Get the data
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return entity.Image{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check server response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return entity.Image{}, fmt.Errorf("bad status: %s", resp.Status)
|
||||
}
|
||||
|
||||
return s.DownloadAndInsert(resp.Body)
|
||||
}
|
||||
|
||||
// Download an image from a provided Reader and insert it.
|
||||
func (s *Saver) DownloadAndInsert(r io.Reader) (entity.Image, error) {
|
||||
slog.Debug("DownloadAndInsert: Running.")
|
||||
|
||||
// Read all into memory.
|
||||
b, err := util.LimitedReadAll(r, defMaxSize)
|
||||
if err != nil {
|
||||
slog.Error("DownloadAndInsert: Failed to read response!", "error", err)
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
// Save the file.
|
||||
imgHash, imgPath, err := s.save(b)
|
||||
if err != nil {
|
||||
slog.Error("DownloadAndInsert: Failed to save file!", "error", err)
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
// Insert image into db.
|
||||
imge, err := Insert(s.db, imgHash, imgPath, b)
|
||||
if err != nil {
|
||||
slog.Error("DownloadAndInsert: Insert into db failed!", "error", err)
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
return imge, nil
|
||||
}
|
||||
|
||||
// Creates the image file on disk.
|
||||
// Returns image hash, image path and error.
|
||||
func (s *Saver) save(b []byte) (string, string, error) {
|
||||
br := bytes.NewReader(b)
|
||||
// First we get a hash of the files contents.
|
||||
// Using the hash for filename has the benefit of us not storing duplicate
|
||||
// files just because their filename provided to us is different.
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, br); err != nil {
|
||||
slog.Error("save: Copy failed!", "error", err)
|
||||
return "", "", errors.New("copy failed")
|
||||
}
|
||||
hs := hex.EncodeToString(h.Sum(nil))
|
||||
slog.Debug("save: image hash calculated",
|
||||
"hash", hs,
|
||||
"first_letter", hs[0:1])
|
||||
|
||||
// Validate the file.
|
||||
// We always validate, even from "trusted sources!"
|
||||
b, ext, err := s.validate(b)
|
||||
if err != nil {
|
||||
slog.Error("save: Validate failed!", "error", err)
|
||||
return "", "", err
|
||||
}
|
||||
br = bytes.NewReader(b)
|
||||
|
||||
// Create paths for file.
|
||||
imgPath := path.Join(
|
||||
// Always outputs to `img/` dir.
|
||||
"img/",
|
||||
// Any sub path for separating images.
|
||||
s.SaveSubPath,
|
||||
// Sub-separate images by the starting character of their hash.
|
||||
hs[0:1],
|
||||
// File name is whole hash then the file extension.
|
||||
hs+ext)
|
||||
fullOutPath := path.Join(config.DataPath, imgPath)
|
||||
slog.Debug("save: Built path", "path", imgPath)
|
||||
|
||||
// Save file
|
||||
err = os.MkdirAll(path.Dir(fullOutPath), 0764)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
out, err := os.Create(fullOutPath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, br)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return hs, imgPath, nil
|
||||
}
|
||||
|
||||
type ValidateAllowedFormat string
|
||||
|
||||
const (
|
||||
ValidateAllowedFormatJPEG ValidateAllowedFormat = "jpeg"
|
||||
ValidateAllowedFormatPNG ValidateAllowedFormat = "png"
|
||||
)
|
||||
|
||||
type ValidateOptions struct {
|
||||
ToFormat ValidateAllowedFormat
|
||||
}
|
||||
|
||||
// Validate the image safely.
|
||||
// Re-encodes the image in our own desired format, which helps verify the file
|
||||
// is a valid image and any undesirable data (eg think xss; appended html,
|
||||
// exit, etc) is not kept in the final image file we store.
|
||||
// We currently prefer jpg for the format we re-encode to, which also helps us
|
||||
// save storage space (adds compression, which the image we are validating
|
||||
// could lack or have a higher quality setting, etc).
|
||||
// Returns the new re-encoded image data, file extension, and error.
|
||||
// NOTE: Never use a user-set file extension (always validate we allow it).
|
||||
func (s *Saver) validate(b []byte) ([]byte, string, error) {
|
||||
br := bytes.NewReader(b)
|
||||
|
||||
// Check image header for config/format.
|
||||
cfg, format, err := image.DecodeConfig(br)
|
||||
if err != nil {
|
||||
slog.Error("Validate: Failed to DecodeConfig", "error", err)
|
||||
return []byte{}, "", errors.New("invalid or bad image")
|
||||
}
|
||||
slog.Debug("Validate",
|
||||
"cfg.Width", cfg.Width,
|
||||
"cfg.Height", cfg.Height,
|
||||
"format", format)
|
||||
if int64(cfg.Width) > defMaxWidthHeight ||
|
||||
int64(cfg.Height) > defMaxWidthHeight {
|
||||
return []byte{}, "", errors.New("dimensions too large")
|
||||
}
|
||||
// Protect against images that max out the allowed width/height.
|
||||
// If we assume each pixel is 4 bytes, someone maxing out 8000x8000 would
|
||||
// mean ~235mb of data we need to decode into memory (i think!), but instead
|
||||
// of limiting the max width/height values too much, we can limit the max
|
||||
// amount of pixels to restrict the max size of the img pixels we'd allow.
|
||||
// Then weird aspect ratios are still allowed.
|
||||
// I'm definitely over-engineering this feature for a self-hosted movie list app lol.
|
||||
if int64(cfg.Width)*int64(cfg.Height) > defMaxPixels {
|
||||
return []byte{}, "", errors.New("i can't handle all those pixels")
|
||||
}
|
||||
|
||||
// Seek back, we are reading again below for decode.
|
||||
if _, err = br.Seek(0, 0); err != nil {
|
||||
slog.Error("Validate: Seeking reader to start failed", "error", err)
|
||||
return []byte{}, "", err
|
||||
}
|
||||
|
||||
// Decode image.
|
||||
// This should catch any malformed image files.
|
||||
var img image.Image
|
||||
switch format {
|
||||
case "png":
|
||||
img, err = png.Decode(br)
|
||||
case "jpeg":
|
||||
img, err = jpeg.Decode(br)
|
||||
case "gif":
|
||||
img, err = gif.Decode(br)
|
||||
default:
|
||||
return []byte{}, "", errors.New("unsupported image type")
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("full image decode failed", "error", err, "format", format)
|
||||
return []byte{}, "", errors.New("invalid or corrupt image")
|
||||
}
|
||||
|
||||
// Re-encode the image from our decoded data (any extra included, possibly
|
||||
// malicious data not part of the image should be gone now).
|
||||
// exif data, etc should also be gone now too which is good.
|
||||
// We just re-encode as jpeg right now, but if we wanted to, in the future
|
||||
// it's possible to encode different formats based on if we want to perserve
|
||||
// transparency from png, etc. OR maybe using webp will be easier and we
|
||||
// can just use that format since it supports transparency and animations.
|
||||
outfmt := ValidateAllowedFormatJPEG
|
||||
if s.ValidateOptions.ToFormat != "" {
|
||||
outfmt = s.ValidateOptions.ToFormat
|
||||
}
|
||||
|
||||
switch outfmt {
|
||||
case ValidateAllowedFormatJPEG:
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 75}); err != nil {
|
||||
slog.Error("Validate: Failed to encode jpeg", "error", err)
|
||||
return []byte{}, "", errors.New("failed to encode image")
|
||||
}
|
||||
return buf.Bytes(), ".jpg", nil
|
||||
case ValidateAllowedFormatPNG:
|
||||
// NOTE: PNG->PNG re-encode can still result in output file being a bit
|
||||
// bigger, I think this is acceptable. I haven't seen any case where the
|
||||
// difference is big enough to care (sometimes can be smaller output too).
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
slog.Error("Validate: Failed to encode png", "error", err)
|
||||
return []byte{}, "", errors.New("failed to encode image")
|
||||
}
|
||||
return buf.Bytes(), ".png", nil
|
||||
default:
|
||||
return []byte{}, "", errors.New("invalid outfmt described")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
We don't really use the `internal` folder because none of our code is anything we'd ever expect anyone to import and rely on in their own codebase.
|
||||
|
||||
However, for some packages, it might make sense to put it here just as a signal to us (while developing) that this package is not code that goes into a prod build, etc (eg: `testutil`). This feels nicer, avoiding a scenario where our root folder has a bunch of real packages and ones that should never see prod mixed together (which is probably confusing).
|
||||
@@ -0,0 +1,55 @@
|
||||
// testutil is for testing code that we want to reuse for tests.
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SetupLogging will configure the default slog logger.
|
||||
// Since our main app uses `slog`, this is useful for getting
|
||||
// debug logs to show, or hiding all, etc.
|
||||
// Controlled by env var `WTEST_LOG_LEVEL` (accepts: `debug` or `error`), if not
|
||||
// set, default Info log level is used.
|
||||
func SetupLogging() {
|
||||
level := slog.LevelInfo
|
||||
switch os.Getenv("WTEST_LOG_LEVEL") {
|
||||
case "debug":
|
||||
level = slog.LevelDebug
|
||||
case "error":
|
||||
level = slog.LevelError
|
||||
}
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(
|
||||
os.Stdout, &slog.HandlerOptions{Level: level})))
|
||||
}
|
||||
|
||||
// Setup a fresh database for testing.
|
||||
// Exits test by using t.Fatalf if something fails.
|
||||
func SetupDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
// Open our test db.
|
||||
// Note: Could have used inmemory db, but it breaks our WAL migration and
|
||||
// errors out, and I don't wanna mess with prod code simply so I can use
|
||||
// an inmem db for testing, so we make a temporary file db.
|
||||
db, err := gorm.Open(
|
||||
sqlite.Open(filepath.Join(t.TempDir(), "test-watcharr.db")),
|
||||
&gorm.Config{TranslateError: true},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open test db: %v", err)
|
||||
}
|
||||
|
||||
// Setup the db same as we do for prod.
|
||||
if err := database.Setup(db); err != nil {
|
||||
t.Fatalf("failed to migrate test db: %v", err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
func LimitedReadAll(r io.Reader, maxSize int64) ([]byte, error) {
|
||||
// LimitReader: Allow reading one extra byte so our maxSize check later works.
|
||||
b, err := io.ReadAll(io.LimitReader(r, maxSize+1))
|
||||
if err != nil {
|
||||
return b, err
|
||||
}
|
||||
if int64(len(b)) > maxSize {
|
||||
return b, errors.New("file is too big")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package util
|
||||
|
||||
const (
|
||||
Byte int64 = 1
|
||||
Kibibyte int64 = 1024
|
||||
Mebibyte int64 = 1024 * Kibibyte
|
||||
)
|
||||
+2
-2
@@ -33,6 +33,7 @@ import (
|
||||
"github.com/sbondCo/Watcharr/feature/feature"
|
||||
"github.com/sbondCo/Watcharr/feature/follow"
|
||||
"github.com/sbondCo/Watcharr/feature/game"
|
||||
"github.com/sbondCo/Watcharr/feature/img"
|
||||
"github.com/sbondCo/Watcharr/feature/imprt"
|
||||
"github.com/sbondCo/Watcharr/feature/jellyfin"
|
||||
"github.com/sbondCo/Watcharr/feature/job"
|
||||
@@ -266,6 +267,7 @@ func main() {
|
||||
game.NewRouter(br, gameService, watchedService).AddRoutes()
|
||||
search.NewRouter(br, searchService, watchedService).AddRoutes()
|
||||
discover.NewRouter(br, discoverService, watchedService).AddRoutes()
|
||||
img.NewRouter(br).AddRoutes()
|
||||
|
||||
// Only add setup routes if there are no users found in db.
|
||||
var userCount int64
|
||||
@@ -281,8 +283,6 @@ func main() {
|
||||
"error", uresp.Error)
|
||||
}
|
||||
|
||||
api.Static("/img", path.Join(config.DataPath, "img"))
|
||||
|
||||
go taskl.SetupTasks(cfg, db)
|
||||
|
||||
gine.Run("0.0.0.0:3080")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/logo-col.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
|
||||
+40
-16
@@ -1,23 +1,32 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
style?: string;
|
||||
}
|
||||
|
||||
let { style = "" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="loader" {style}></div>
|
||||
<span class="loader">
|
||||
<svg viewBox="0 0 50 50">
|
||||
<circle
|
||||
cx="25"
|
||||
cy="25"
|
||||
r="20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<style lang="scss">
|
||||
.loader {
|
||||
$animDur: 1.4s;
|
||||
display: flex;
|
||||
width: 16px;
|
||||
height: 12px;
|
||||
border: 2px solid $text-color;
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
animation: rotation 1s linear infinite;
|
||||
height: 16px;
|
||||
animation: rotation $animDur linear infinite;
|
||||
|
||||
svg {
|
||||
circle {
|
||||
stroke-dasharray: 80px 200px;
|
||||
stroke-dashoffset: 0px;
|
||||
animation: circle-size $animDur ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotation {
|
||||
@@ -28,4 +37,19 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes circle-size {
|
||||
0% {
|
||||
stroke-dasharray: 1px 200px;
|
||||
stroke-dashoffset: 0px;
|
||||
}
|
||||
50% {
|
||||
stroke-dasharray: 100px 200px;
|
||||
stroke-dashoffset: -15px;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 1px 200px;
|
||||
stroke-dashoffset: -124px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
return a.best ? -1 : 1;
|
||||
});
|
||||
if (t[0]?.id) {
|
||||
return `https://www.youtube.com/embed/${t[0]?.id}`;
|
||||
return `https://www.youtube-nocookie.com/embed/${t[0]?.id}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
bind:this={avatarInput}
|
||||
type="file"
|
||||
title=""
|
||||
accept=".jpg,.png,.gif,.webp"
|
||||
accept=".jpg,.png,.gif"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { store } from "@/store.svelte";
|
||||
import SpinnerTiny from "./SpinnerTiny.svelte";
|
||||
import { unNotify } from "./util/notify";
|
||||
import Icon from "./Icon.svelte";
|
||||
</script>
|
||||
|
||||
<div id="notifications">
|
||||
{#each store.notifications as n}
|
||||
<div class={`${n.type} notif`}>
|
||||
{#if n.type === "loading"}
|
||||
<SpinnerTiny />
|
||||
{/if}
|
||||
<!-- only comes from our strings (which may have html) -->
|
||||
<!-- eslint-disable-next-line -->
|
||||
<span>{@html n.text}</span>
|
||||
<button
|
||||
class="plain"
|
||||
onclick={() => {
|
||||
unNotify(n.id);
|
||||
}}
|
||||
>
|
||||
<Icon i="close" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
#notifications {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 10px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 8px;
|
||||
z-index: 99999;
|
||||
|
||||
.notif {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
min-width: 200px;
|
||||
color: black;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba($color: #000000, $alpha: 0.2);
|
||||
box-shadow: 0 4px 10px rgba($color: #000000, $alpha: 0.2);
|
||||
animation: comein 250ms ease forwards;
|
||||
position: relative;
|
||||
|
||||
&.loading {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
@keyframes comein {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: white;
|
||||
background-color: $error;
|
||||
border: 1px solid $error;
|
||||
|
||||
span {
|
||||
border-color: rgba($color: white, $alpha: 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: white;
|
||||
background-color: $success;
|
||||
border: 1px solid $success;
|
||||
|
||||
span {
|
||||
border-color: rgba($color: white, $alpha: 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-right: 12px;
|
||||
border-right: 1px solid rgba($color: black, $alpha: 0.2);
|
||||
padding: 10px 12px;
|
||||
padding-left: 9px;
|
||||
|
||||
a {
|
||||
color: white;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 8px;
|
||||
width: 22px;
|
||||
height: 100%;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -615,7 +615,7 @@
|
||||
<td class="icon-cell">
|
||||
<div>
|
||||
{#if !l.state}
|
||||
<SpinnerTiny style="width: 13px;" />
|
||||
<SpinnerTiny />
|
||||
{:else if l.state === ImportResponseType.IMPORT_SUCCESS}
|
||||
<Icon i="check" wh={22} />
|
||||
{:else if l.state === ImportResponseType.IMPORT_NOTFOUND}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import SpinnerTiny from "@/lib/SpinnerTiny.svelte";
|
||||
import { unNotify } from "@/lib/util/notify";
|
||||
import { store } from "@/store.svelte";
|
||||
import Notifications from "@/lib/notifications.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { pwaInfo } from "virtual:pwa-info";
|
||||
|
||||
@@ -37,26 +34,8 @@
|
||||
</svelte:head>
|
||||
|
||||
<div id="tooltip"></div>
|
||||
<div id="notifications">
|
||||
{#each store.notifications as n}
|
||||
<div class={`${n.type} notif`}>
|
||||
{#if n.type === "loading"}
|
||||
<SpinnerTiny />
|
||||
{/if}
|
||||
<!-- only comes from our strings (which may have html) -->
|
||||
<!-- eslint-disable-next-line -->
|
||||
<span>{@html n.text}</span>
|
||||
<button
|
||||
class="plain"
|
||||
onclick={() => {
|
||||
unNotify(n.id);
|
||||
}}
|
||||
>
|
||||
<Icon i="close" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Notifications />
|
||||
|
||||
{@render children?.()}
|
||||
|
||||
|
||||
@@ -235,89 +235,6 @@
|
||||
}
|
||||
|
||||
:global {
|
||||
#notifications {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 10px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 8px;
|
||||
z-index: 99999;
|
||||
|
||||
.notif {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
min-width: 200px;
|
||||
color: black;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba($color: #000000, $alpha: 0.2);
|
||||
box-shadow: 0 4px 10px rgba($color: #000000, $alpha: 0.2);
|
||||
animation: comein 250ms ease forwards;
|
||||
position: relative;
|
||||
|
||||
&.loading {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
@keyframes comein {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: white;
|
||||
background-color: $error;
|
||||
border: 1px solid $error;
|
||||
|
||||
span {
|
||||
border-color: rgba($color: white, $alpha: 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: white;
|
||||
background-color: $success;
|
||||
border: 1px solid $success;
|
||||
|
||||
span {
|
||||
border-color: rgba($color: white, $alpha: 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-right: 12px;
|
||||
border-right: 1px solid rgba($color: black, $alpha: 0.2);
|
||||
padding: 10px 12px;
|
||||
padding-left: 9px;
|
||||
|
||||
a {
|
||||
color: white;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 8px;
|
||||
width: 22px;
|
||||
height: 100%;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.small-scrollbar {
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
&::-webkit-scrollbar {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" fill="none" viewBox="0 0 512 512">
|
||||
<style>
|
||||
/* For dark themed browsers, we make the favicon a more visible color. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.fav-fill {
|
||||
fill: #fff !important;
|
||||
}
|
||||
|
||||
.fav-outline {
|
||||
stroke: #fff !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path class="fav-fill"
|
||||
d="M272.375 449.62 257.1 374.883l-21.23 99.733L147.329 482l-53.075-26.552L10.5 59.107l95.922-7.901 53.074 26.552 11.65 62.56 19.159-91.703 84.27-6.994 50.874 26.552 16.44 80.175 20.712-109.836L448.426 31 501.5 57.552 419.818 468.79l-94.369 7.9zm94.498-405.797-41.424 224.852q-.517 4.793-1.294 12.305-.777 7.383-1.424 13.47l-.518 6.088h-1.553l-.518-5.829q-.518-5.827-1.553-13.47-1.035-7.771-2.201-12.046L269.786 47.45l-75.339 6.347-41.294 207.496q-1.036 6.347-2.719 16.45-1.553 10.103-2.33 18.392l-.776 8.16h-1.683l-.518-7.642q-.518-7.77-1.812-18.133t-2.848-18.263L101.762 57.034l-84.919 7.383 81.682 385.202 79.482-6.346 45.695-212.16q1.036-5.31 2.33-16.19a549 549 0 0 0 2.072-20.465l.906-9.584 1.553-.518.777 9.325q.777 9.197 2.071 20.076 1.425 10.88 2.46 16.19l40.776 214.88 85.436-7.383 79.482-399.968z"
|
||||
style="fill:#000;fill-opacity:1" />
|
||||
<path class="fav-outline"
|
||||
d="M272.375 449.62 257.1 374.883l-21.23 99.733L147.329 482l-53.075-26.552L10.5 59.107l95.922-7.901 53.074 26.552 11.65 62.56 19.159-91.703 84.27-6.994 50.874 26.552 16.44 80.175 20.712-109.836L448.426 31 501.5 57.552 419.818 468.79l-94.369 7.9zm94.498-405.797-41.424 224.852q-.517 4.793-1.294 12.305-.777 7.383-1.424 13.47l-.518 6.088h-1.553l-.518-5.829q-.518-5.827-1.553-13.47-1.035-7.771-2.201-12.046L269.786 47.45l-75.339 6.347-41.294 207.496q-1.036 6.347-2.719 16.45-1.553 10.103-2.33 18.392l-.776 8.16h-1.683l-.518-7.642q-.518-7.77-1.812-18.133t-2.848-18.263L101.762 57.034l-84.919 7.383 81.682 385.202 79.482-6.346 45.695-212.16q1.036-5.31 2.33-16.19a549 549 0 0 0 2.072-20.465l.906-9.584 1.553-.518.777 9.325q.777 9.197 2.071 20.076 1.425 10.88 2.46 16.19l40.776 214.88 85.436-7.383 79.482-399.968z"
|
||||
style="fill:none;stroke-width:10;stroke:#000;stroke-opacity:1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
Reference in New Issue
Block a user