Compare commits

...

No commits in common. "main" and "log-latency" have entirely different histories.

169 changed files with 5560 additions and 3314 deletions

View file

@ -1,9 +0,0 @@
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/main ."
bin = "./tmp/main"
delay = 1000 # ms
exclude_dir = ["assets", "tmp", "vendor"]
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_regex = ["_test\\.go"]

12
.env.example Normal file
View file

@ -0,0 +1,12 @@
# -- PixivFE configuration
# See ./doc/Environment\ Variables.go for more details
# -- Required
# PIXIVFE_TOKEN=changethis # Only set here if not using a secret
PIXIVFE_PORT=8282
PIXIVFE_IMAGEPROXY=pixiv.ducks.party
# -- Optional
# PIXIVFE_USERAGENT=
# PIXIVFE_BASEURL=
# PIXIVFE_ACCEPTLANGUAGE=

View file

@ -0,0 +1,27 @@
name: Compress assets
on:
push:
branches:
- v2
pull_request:
branches:
- v2
jobs:
compress-assets:
runs-on: docker
container:
image: node
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Install Leanify
run: |
curl -L https://files.perennialte.ch/leanify -o leanify
chmod +x ./leanify
mv ./leanify /usr/local/bin
- name: Compress assets
run: leanify -p ./views/assets

15
.gitignore vendored
View file

@ -1,3 +1,14 @@
# dotenv
.env
# sass cache
.sass-cache/
# css sourcemaps
*.css.map
# executable got from `go build .`
/pixivfe
tmp
.dir-locals.el
# custom dev script
dev.sh
# not sure what this is for
/tmp
# exclude changes to pixivfe_token.txt
docker/pixivfe_token.txt

View file

@ -1,11 +0,0 @@
steps:
build:
image: golang
commands:
- go get
- go mod download
- CGO_ENABLED=0 GOOS=linux go build -mod=readonly -o pixivfe
- ./pixivfe &
- sleep 3
- go test -v -bench=. -count 5
secrets: [pixivfe_token]

View file

@ -1,13 +1,37 @@
FROM docker.io/golang:1.21.0 as builder
# ------ Builder stage ------
FROM docker.io/golang:1.21 as builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . ./
RUN CGO_ENABLED=0 GOOS=linux go build -mod=readonly -v -o pixivfe
FROM docker.io/alpine:3
COPY --from=builder /app/pixivfe /pixivfe
COPY --from=builder /app/template /template
# Build the application binary with optimisations for a smaller, static binary
RUN CGO_ENABLED=0 GOOS=linux go build -mod=readonly -v -ldflags="-s -w" -o pixivfe
# ------ Final image ------
FROM docker.io/alpine:3.14
WORKDIR /app
# Create a non-root user `pixivfe` for security purposes and set ownership
RUN addgroup -g 1000 -S pixivfe && \
adduser -u 1000 -S pixivfe -G pixivfe && \
chown -R pixivfe:pixivfe /app
# Copy the compiled application and other necessary files from the builder stage
COPY --from=builder /app/pixivfe /app/pixivfe
COPY --from=builder /app/views /app/views
COPY ./docker/entrypoint.sh /entrypoint.sh
# Include entrypoint script and ensure it's executable
RUN chmod +x /entrypoint.sh && \
chown pixivfe:pixivfe /entrypoint.sh
# Use the non-root user to run the application
USER pixivfe
EXPOSE 8282
ENTRYPOINT ["/pixivfe"]
ENTRYPOINT ["/entrypoint.sh"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --start-interval=5s --retries=3 \
CMD wget --spider -q --tries=1 http://127.0.0.1:8282/about || exit 1

44
LICENSE
View file

@ -618,45 +618,5 @@ copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
PixivFE: a privacy respecting frontend for Pixiv
Copyright (C) 2023-2024 VnPower

View file

@ -1,9 +1,6 @@
### Note
A backend rewrite is ongoing. Check out branch [v2](https://codeberg.org/VnPower/pixivfe/src/branch/v2).
# PixivFE
A privacy-respecting alternative front-end for Pixiv that doesn't suck
A privacy-respecting alternative front-end for Pixiv that doesn't suck.
<p>
<a href="https://codeberg.org/vnpower/pixivfe">
@ -12,43 +9,67 @@ A privacy-respecting alternative front-end for Pixiv that doesn't suck
</p>
![CI badge](https://ci.codeberg.org/api/badges/12556/status.svg)
[![Go Report Card](https://goreportcard.com/badge/codeberg.org/vnpower/pixivfe)](https://goreportcard.com/report/codeberg.org/vnpower/pixivfe)
[![Go Report Card](https://goreportcard.com/badge/codeberg.org/vnpower/pixivfe/v2)](https://goreportcard.com/report/codeberg.org/vnpower/pixivfe)
Questions? Feedbacks? You can [PM me](https://matrix.to/#/@vnpower:exozy.me) on
Matrix!
Questions? Feedback? You can [PM me](https://matrix.to/#/@vnpower:eientei.org) on Matrix! You can also see the [Known quirks](doc/Quirks.md) page to check if your issue has a known solution.
You can keep track of this project's development
[here](https://codeberg.org/VnPower/pixivfe/projects/3481).
You can keep track of this project's development using the [roadmap](doc/dev/general.md).
## Features
- Lightweight - both the interface and the code
- Privacy-first - the server will do the work for you
- No bloat - we only serve HTML and CSS
- No bloat - we only serve HTML, CSS and minimal JS code
- Open source - you can trust me!
## Hosting
Check out [this page](https://codeberg.org/VnPower/pixivfe/wiki/Hosting). We
currently have guides for Docker and Caddy.
You can use PixivFE for personal use! Assuming that you use an operating system that can run POSIX shell scripts, install `go`, clone this repository, modify the `run.sh` file, and profit!
I recommend self-hosting your own instance for personal use, instead of relying entirely on official instances.
To deploy PixivFE using Docker or the compiled binary, see the [Hosting PixivFE](doc/Hosting.md) wiki page.
PixivFE can work with or without an external image proxy server. Here is [the built-in proxy list](doc/Built-in%20Proxy%20List.go).
See [hosting a Pixiv image proxy](doc/Hosting-an-image-proxy-server-for-Pixiv.md) if you want to host one yourself.
## Development
**Requirements:**
- [Go](https://go.dev/doc/install) (to build PixivFE from source)
- [Sass](https://github.com/sass/dart-sass/) (will be run by PixivFE in development mode)
To install Dart Sass, you can choose any of the following methods.
- use system package manager (usually called `dart-sass`)
- download executable from [the official release page](https://github.com/sass/dart-sass/releases)
- `pnpm i -g sass`
```bash
# Clone the PixivFE repository
git clone https://codeberg.org/VnPower/PixivFE.git && cd PixivFE
# Run in PixivFE in development mode (styles and templates reload automatically)
PIXIVFE_DEV=1 <other_environment_variables> go run .
```
## Instances
| Name | Cloudflare? | URL |
|--------------------|-------------|---------------------------------|
| exozyme (Official) | No | https://pixivfe.exozy.me |
| dragongoose | No | https://pixivfe.drgns.space |
| chaotic.ninja | No | https://pix.chaotic.ninja |
| WhateverItWorks | Yes | https://art.whateveritworks.org |
| ducks.party | No | https://pixivfe.ducks.party |
<!-- The current instance table is really wide; maybe there's a better way of formatting it without losing information?
The badges are also difficult to read on a small screen due to Codeberg shrinking the width of the columns -->
Hosted one yourself? Create a pull request to add it here!
| Name | URL | Country | Cloudflare? | [Observatory](https://observatory.mozilla.org/faq/) grade | Uptime |
| ------------------ | ---------------------------- | ------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| exozyme (Official) | https://pixivfe.exozy.me | US | No | [![Mozilla HTTP Observatory Grade](https://img.shields.io/mozilla-observatory/grade-score/pixivfe.exozy.me?label=)](https://observatory.mozilla.org/analyze/pixivfe.exozy.me) | ![Uptime Robot status](https://img.shields.io/uptimerobot/status/m796383741-c72f1ae6562dc943d032ba96?&cacheSeconds=3600) ![Uptime Robot ratio (30 days)](https://img.shields.io/uptimerobot/ratio/m796383741-c72f1ae6562dc943d032ba96?label=uptime%20%2Fmonth&cacheSeconds=3600) |
| dragongoose | https://pixivfe.drgns.space | US | No | [![Mozilla HTTP Observatory Grade](https://img.shields.io/mozilla-observatory/grade-score/pixivfe.drgns.space?label=)](https://observatory.mozilla.org/analyze/pixivfe.drgns.space) | ![Uptime Robot status](https://img.shields.io/uptimerobot/status/m796383743-c0cf0d6b5dbb09c8dbe7dc53?&cacheSeconds=3600) ![Uptime Robot ratio (30 days)](https://img.shields.io/uptimerobot/ratio/m796383743-c0cf0d6b5dbb09c8dbe7dc53?label=uptime%20%2Fmonth&cacheSeconds=3600) |
| ducks.party | https://pixivfe.ducks.party | NL | No | [![Mozilla HTTP Observatory Grade](https://img.shields.io/mozilla-observatory/grade-score/pixivfe.ducks.party?label=)](https://observatory.mozilla.org/analyze/pixivfe.ducks.party) | ![Uptime Robot status](https://img.shields.io/uptimerobot/status/m796383747-c92c281f520d52fe3fd894ed?&cacheSeconds=3600) ![Uptime Robot ratio (30 days)](https://img.shields.io/uptimerobot/ratio/m796383747-c92c281f520d52fe3fd894ed?label=uptime%20%2Fmonth&cacheSeconds=3600) |
| perennialte.ch | https://pixiv.perennialte.ch | AU | No | [![Mozilla HTTP Observatory Grade](https://img.shields.io/mozilla-observatory/grade-score/pixiv.perennialte.ch?label=)](https://observatory.mozilla.org/analyze/pixiv.perennialte.ch) | ![Uptime Robot status](https://img.shields.io/uptimerobot/status/m796383748-503799f65873a23dbc860a02?&cacheSeconds=3600) ![Uptime Robot ratio (30 days)](https://img.shields.io/uptimerobot/ratio/m796383748-503799f65873a23dbc860a02?label=uptime%20%2Fmonth&cacheSeconds=3600) |
## License & Attributions
If you are hosting your own instance, you can create a pull request to add it here!
For more information on instance uptime, see the [PixivFE instance status page](https://stats.uptimerobot.com/FbEGewWlbX).
## License
License: [AGPL3](https://www.gnu.org/licenses/agpl-3.0.txt)
Special thanks:
- [huggy](https://huggy.moe): author of [ugoira.com](https://ugoira.com) for the ugoira API
- [dragongoose](https://drgns.space): writing guides
- Contributors, stargazers and users like you, as well!

View file

@ -1,14 +0,0 @@
# This is required for the API. See https://github.com/Nandaka/PixivUtil2/wiki#pixiv-login-using-cookie
# Please keep this carefully, there is a risk of account theft if somebody got your cookie.
# It is better to use a decoy account, instead of using your main account's cookie.
PHPSESSID: 75921176_zsZa7sNLX8zj4N9UKf526ZV0wdWOwN79
Port: "8080"
UserAgent: Mozilla/5.0
# Number of items in each list page
PageItems: 30
# Default image proxy server. Recommended since Pixiv doesn't like you accessing images on their server.
ImageProxyServer: px2.rainchan.win

View file

@ -1,49 +0,0 @@
package configs
import (
"errors"
"os"
"time"
)
var Token, BaseURL, Port, UserAgent, ProxyServer, StartingTime, Version, AcceptLanguage string
func parseEnv(key string) (string, error) {
value, ok := os.LookupEnv(key)
if !ok {
return value, errors.New("Failed to get environment variable" + key)
}
return value, nil
}
func parseEnvWithDefault(key string, defaultValue string) string {
value, ok := os.LookupEnv(key)
if !ok {
return defaultValue
}
return value
}
func ParseConfig() error {
var err error
Token, err = parseEnv("PIXIVFE_TOKEN")
if err != nil {
return err
}
BaseURL = parseEnvWithDefault("PIXIVFE_BASEURL", "localhost")
Port = parseEnvWithDefault("PIXIVFE_PORT", "8282")
UserAgent = parseEnvWithDefault("PIXIVFE_USERAGENT", "Mozilla/5.0")
ProxyServer = parseEnvWithDefault("PIXIVFE_IMAGEPROXY", "pximg.cocomi.cf")
AcceptLanguage = parseEnvWithDefault("PIXIVFE_ACCEPTLANGUAGE", "en-US,en;q=0.5")
StartingTime = time.Now().UTC().Format("2006-01-02 15:04")
Version = "v1.0.5"
return nil
}

View file

@ -1,16 +0,0 @@
package configs
import (
"time"
"github.com/gofiber/fiber/v2/middleware/session"
)
var Store *session.Store
func SetupStorage() {
Store = session.New(session.Config{
Expiration: time.Hour * 24 * 30,
})
Store.RegisterType("")
}

110
core/config/config.go Normal file
View file

@ -0,0 +1,110 @@
package core
import (
"errors"
"log"
"net/url"
"strconv"
"strings"
"time"
"codeberg.org/vnpower/pixivfe/v2/doc"
)
var GlobalServerConfig ServerConfig
type ServerConfig struct {
// Required
Token []string
ProxyServer url.URL // proxy server, may contain prefix as well
// can be left empty
Host string
// One of two is required
Port string
UnixSocket string
UserAgent string
AcceptLanguage string
RequestLimit int
StartingTime string
Version string
InDevelopment bool
}
func (s *ServerConfig) InitializeConfig() error {
s.setVersion()
doc.CollectAllEnv()
token, hasToken := doc.LookupEnv("PIXIVFE_TOKEN")
if !hasToken {
log.Fatalln("PIXIVFE_TOKEN is required, but was not set.")
return errors.New("PIXIVFE_TOKEN is required, but was not set.\n")
}
// TODO Maybe add some testing?
s.Token = strings.Split(token, ",")
port, hasPort := doc.LookupEnv("PIXIVFE_PORT")
socket, hasSocket := doc.LookupEnv("PIXIVFE_UNIXSOCKET")
if !hasPort && !hasSocket {
log.Fatalln("Either PIXIVFE_PORT or PIXIVFE_UNIXSOCKET has to be set.")
return errors.New("Either PIXIVFE_PORT or PIXIVFE_UNIXSOCKET has to be set.")
}
s.Port = port
s.UnixSocket = socket
_, hasDev := doc.LookupEnv("PIXIVFE_DEV")
s.InDevelopment = hasDev
s.Host = doc.GetEnv("PIXIVFE_HOST")
s.UserAgent = doc.GetEnv("PIXIVFE_USERAGENT")
s.AcceptLanguage = doc.GetEnv("PIXIVFE_ACCEPTLANGUAGE")
s.SetRequestLimit(doc.GetEnv("PIXIVFE_REQUESTLIMIT"))
s.SetProxyServer(doc.GetEnv("PIXIVFE_IMAGEPROXY"))
doc.AnnounceAllEnv()
s.setStartingTime()
return nil
}
func (s *ServerConfig) SetProxyServer(v string) {
proxyUrl, err := url.Parse(v)
if err != nil {
panic(err)
}
s.ProxyServer = *proxyUrl
if (proxyUrl.Scheme == "") != (proxyUrl.Host == "") {
log.Panicf("proxy server url is weird: %s\nPlease specify e.g. https://example.com", proxyUrl.String())
}
if strings.HasSuffix(proxyUrl.Path, "/") {
log.Panicf("proxy server path (%s) has cannot end in /: %s\nPixivFE does not support this now, sorry", proxyUrl.Path, proxyUrl.String())
}
}
func (s *ServerConfig) SetRequestLimit(v string) {
t, err := strconv.Atoi(v)
if err != nil {
panic(err)
}
s.RequestLimit = t
}
func (s *ServerConfig) setStartingTime() {
s.StartingTime = time.Now().UTC().Format("2006-01-02 15:04")
log.Printf("Set starting time to: %s\n", s.StartingTime)
}
func (s *ServerConfig) setVersion() {
s.Version = "v2.4"
log.Printf("PixivFE %s\n", s.Version)
}

169
core/config/session.go Normal file
View file

@ -0,0 +1,169 @@
package core
import (
"log"
"math/rand"
"net/url"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/session"
)
var Store *session.Store
func SetupStorage() {
Store = session.New(session.Config{
Expiration: time.Hour * 24 * 30,
})
}
func saveSession(sess *session.Session) error {
if err := sess.Save(); err != nil {
return err
}
return nil
}
func ProxyImageUrl(c *fiber.Ctx, s string) string {
proxyOrigin := GetImageProxyPrefix(c)
s = strings.ReplaceAll(s, `https:\/\/i.pximg.net`, proxyOrigin)
// s = strings.ReplaceAll(s, `https:\/\/i.pximg.net`, "/proxy/i.pximg.net")
s = strings.ReplaceAll(s, `https:\/\/s.pximg.net`, "/proxy/s.pximg.net")
return s
}
func ProxyImageUrlNoEscape(c *fiber.Ctx, s string) string {
proxyOrigin := GetImageProxyPrefix(c)
s = strings.ReplaceAll(s, `https://i.pximg.net`, proxyOrigin)
// s = strings.ReplaceAll(s, `https:\/\/i.pximg.net`, "/proxy/i.pximg.net")
s = strings.ReplaceAll(s, `https://s.pximg.net`, "/proxy/s.pximg.net")
return s
}
// note: still cannot believe Go didn't have this function
func urlAuthority(url url.URL) string {
r := ""
if (url.Scheme != "" ) != (url.Host != "") {
log.Panicf("url must have both scheme and authority or neither: %s", url.String())
}
if url.Scheme != "" {
r += url.Scheme + "://"
}
r += url.Host
return r
}
func GetImageProxyOrigin(c *fiber.Ctx) string {
url := GetImageProxy(c)
return urlAuthority(url)
}
func GetImageProxyPrefix(c *fiber.Ctx) string {
url := GetImageProxy(c)
return urlAuthority(url) + url.Path
// note: not sure if url.EscapedPath() is useful here. go's standard library is trash at handling URL (:// should be part of the scheme)
}
func GetImageProxy(c *fiber.Ctx) url.URL {
sess, err := Store.Get(c)
if err != nil {
log.Println("Failed to get current session!")
// fall through to default case
} else {
value := sess.Get("ImageProxy")
if value_s, ok := value.(string); ok {
proxyUrl, err := url.Parse(value_s)
if err != nil {
// fall through to default case
} else {
return *proxyUrl
}
}
}
return GlobalServerConfig.ProxyServer
}
func GetRandomDefaultToken() string {
defaultToken := GlobalServerConfig.Token[rand.Intn(len(GlobalServerConfig.Token))]
return defaultToken
}
func GetToken(c *fiber.Ctx) string {
defaultToken := GlobalServerConfig.Token[rand.Intn(len(GlobalServerConfig.Token))]
sess, err := Store.Get(c)
if err != nil {
log.Fatalln("Failed to get current session and its values! Falling back to server default!")
return defaultToken
}
value := sess.Get("Token")
if value != nil {
return value.(string)
}
return defaultToken
}
func CheckToken(c *fiber.Ctx) string {
sess, err := Store.Get(c)
if err != nil {
log.Fatalln("Failed to get current session and its values!")
return ""
}
value := sess.Get("Token")
if value != nil {
return value.(string)
}
return ""
}
func GetCSRFToken(c *fiber.Ctx) string {
sess, err := Store.Get(c)
if err != nil {
log.Fatalln("Failed to get current session and its values!")
return ""
}
value := sess.Get("CSRF")
if value != nil {
return value.(string)
}
return ""
}
func SetSessionValue(c *fiber.Ctx, name, value string) error {
sess, err := Store.Get(c)
if err != nil {
return err
}
sess.Set(name, value)
if err = saveSession(sess); err != nil {
log.Fatalln("Failed to save session storage!")
return err
}
return nil
}
func RemoveSessionValue(c *fiber.Ctx, name string) error {
sess, err := Store.Get(c)
if err != nil {
return err
}
sess.Delete(name)
if err = saveSession(sess); err != nil {
log.Fatalln("Failed to save session storage!")
return err
}
return nil
}

90
core/http/request.go Normal file
View file

@ -0,0 +1,90 @@
package core
import (
"errors"
"fmt"
"io"
"net/http"
core "codeberg.org/vnpower/pixivfe/v2/core/config"
"github.com/tidwall/gjson"
)
type HttpResponse struct {
Ok bool
StatusCode int
Body string
Message string
}
func WebAPIRequest(URL, token string) HttpResponse {
req, _ := http.NewRequest("GET", URL, nil)
req.Header.Add("User-Agent", core.GlobalServerConfig.UserAgent)
req.Header.Add("Accept-Language", core.GlobalServerConfig.AcceptLanguage)
if token == "" {
req.AddCookie(&http.Cookie{
Name: "PHPSESSID",
Value: core.GetRandomDefaultToken(),
})
} else {
req.AddCookie(&http.Cookie{
Name: "PHPSESSID",
Value: token,
})
}
// Make the request
resp, err := http.DefaultClient.Do(req)
if err != nil {
return HttpResponse{
Ok: false,
StatusCode: 0,
Body: "",
Message: fmt.Sprintf("Failed to create a request to %s\n.", URL),
}
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return HttpResponse{
Ok: false,
StatusCode: 0,
Body: "",
Message: fmt.Sprintln("Failed to parse request data."),
}
}
return HttpResponse{
Ok: true,
StatusCode: resp.StatusCode,
Body: string(body),
Message: "",
}
}
func UnwrapWebAPIRequest(URL, token string) (string, error) {
resp := WebAPIRequest(URL, token)
if !resp.Ok {
return "", errors.New(resp.Message)
}
if !gjson.Valid(resp.Body) {
return "", fmt.Errorf("invalid json: %v", resp.Body)
}
err := gjson.Get(resp.Body, "error")
if !err.Exists() {
return "", errors.New("incompatible request body")
}
if err.Bool() {
return "", errors.New(gjson.Get(resp.Body, "message").String())
}
return gjson.Get(resp.Body, "body").String(), nil
}

123
core/http/url.go Normal file
View file

@ -0,0 +1,123 @@
package core
import (
"fmt"
"net/url"
)
func GetNewestArtworksURL(worktype, r18, lastID string) string {
base := "https://www.pixiv.net/ajax/illust/new?limit=30&type=%s&r18=%s&lastId=%s"
return fmt.Sprintf(base, worktype, r18, lastID)
}
func GetDiscoveryURL(mode string, limit int) string {
base := "https://www.pixiv.net/ajax/discovery/artworks?mode=%s&limit=%d"
return fmt.Sprintf(base, mode, limit)
}
func GetDiscoveryNovelURL(mode string, limit int) string {
base := "https://www.pixiv.net/ajax/discovery/novels?mode=%s&limit=%d"
return fmt.Sprintf(base, mode, limit)
}
func GetRankingURL(mode, content, date, page string) string {
base := "https://www.pixiv.net/ranking.php?format=json&mode=%s&content=%s&date=%s&p=%s"
baseNoDate := "https://www.pixiv.net/ranking.php?format=json&mode=%s&content=%s&p=%s"
if date != "" {
return fmt.Sprintf(base, mode, content, date, page)
}
return fmt.Sprintf(baseNoDate, mode, content, page)
}
func GetRankingCalendarURL(mode string, year, month int) string {
base := "https://www.pixiv.net/ranking_log.php?mode=%s&date=%d%02d"
return fmt.Sprintf(base, mode, year, month)
}
func GetUserInformationURL(id string) string {
base := "https://www.pixiv.net/ajax/user/%s?full=1"
return fmt.Sprintf(base, id)
}
func GetUserArtworksURL(id string) string {
base := "https://www.pixiv.net/ajax/user/%s/profile/all"
return fmt.Sprintf(base, id)
}
func GetUserFullArtworkURL(id, ids string) string {
base := "https://www.pixiv.net/ajax/user/%s/profile/illusts?work_category=illustManga&is_first_page=0&lang=en%s"
return fmt.Sprintf(base, id, ids)
}
func GetUserBookmarksURL(id, mode string, page int) string {
base := "https://www.pixiv.net/ajax/user/%s/illusts/bookmarks?tag=&offset=%d&limit=48&rest=%s"
return fmt.Sprintf(base, id, page*48, mode)
}
func GetFrequentTagsURL(ids string) string {
base := "https://www.pixiv.net/ajax/tags/frequent/illust?%s"
return fmt.Sprintf(base, ids)
}
func GetNewestFromFollowingURL(mode, page string) string {
base := "https://www.pixiv.net/ajax/follow_latest/%s?mode=%s&p=%s"
// TODO: Recheck this URL
return fmt.Sprintf(base, "illust", mode, page)
}
func GetArtworkInformationURL(id string) string {
base := "https://www.pixiv.net/ajax/illust/%s"
return fmt.Sprintf(base, id)
}
func GetArtworkImagesURL(id string) string {
base := "https://www.pixiv.net/ajax/illust/%s/pages"
return fmt.Sprintf(base, id)
}
func GetArtworkRelatedURL(id string, limit int) string {
base := "https://www.pixiv.net/ajax/illust/%s/recommend/init?limit=%d"
return fmt.Sprintf(base, id, limit)
}
func GetArtworkCommentsURL(id string) string {
base := "https://www.pixiv.net/ajax/illusts/comments/roots?illust_id=%s&limit=100"
return fmt.Sprintf(base, id)
}
func GetTagDetailURL(unescapedTag string) string {
base := "https://www.pixiv.net/ajax/search/tags/%s"
return fmt.Sprintf(base, url.PathEscape(unescapedTag))
}
func GetSearchArtworksURL(artworkType, name, order, age_settings, page string) string {
base := "https://www.pixiv.net/ajax/search/%s/%s?order=%s&mode=%s&p=%s"
return fmt.Sprintf(base, artworkType, name, order, age_settings, page)
}
func GetLandingURL(mode string) string {
base := "https://www.pixiv.net/ajax/top/illust?mode=%s"
return fmt.Sprintf(base, mode)
}
func GetNovelURL(id string) string {
base := "https://www.pixiv.net/ajax/novel/%s"
return fmt.Sprintf(base, id)
}

134
core/kmutex/kmutex.go Normal file
View file

@ -0,0 +1,134 @@
/* inlined from github.com/im7mortal/kmutex
MIT License
Copyright (c) 2023 Petr Lozhkin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
/*
Package kmutex is a synchronization primitive that allows locking individual
resources by unique ID.
Key + Mutex = Kmutex
*/
package kmutex
import "sync"
// Can be locked by unique ID
type Kmutex struct {
l sync.Locker
s map[interface{}]*klock
}
// klock is a per-key lock that conatins a sync.Cond to signal another
// goroutine that the lock is available, a reference count of the number of
// goroutines waiting for and using the lock, and a boolean to check if the
// lock is already unlocked.
//
// It is necessary to use a separate condition variable for each key to ensure
// that only a goroutine that is waiting for that key is awakened.
type klock struct {
cond *sync.Cond
locked bool
ref uint64
}
// Create new Kmutex
func New() *Kmutex {
l := sync.Mutex{}
return &Kmutex{
l: &l,
s: make(map[interface{}]*klock),
}
}
// Create new Kmutex with user provided lock
func WithLock(l sync.Locker) *Kmutex {
return &Kmutex{
l: l,
s: make(map[interface{}]*klock),
}
}
// Unlock Kmutex by unique ID
func (km *Kmutex) Unlock(key interface{}) {
km.l.Lock()
defer km.l.Unlock()
kl, ok := km.s[key]
if !ok || !kl.locked {
panic("unlock of unlocked kmutex")
}
kl.ref--
if kl.ref == 0 {
delete(km.s, key)
return
}
kl.locked = false
kl.cond.Signal()
}
// Lock Kmutex by unique ID
// Returns: current ref count
func (km *Kmutex) Lock(key interface{}) uint64 {
km.l.Lock()
defer km.l.Unlock()
kl, ok := km.s[key]
if !ok {
km.s[key] = &klock{
cond: sync.NewCond(km.l),
locked: true,
ref: 1,
}
return 1
}
kl.ref++
kl.cond.Wait()
// No need to check if locked, since signal only given on unlock and
// only delivered to one goroutine.
kl.locked = true
return kl.ref
}
// satisfy sync.Locker interface
type locker struct {
km *Kmutex
key interface{}
}
// Lock this locker. If already locked, Lock blocks until it is available.
func (l locker) Lock() {
l.km.Lock(l.key)
}
// Unlock this locker. It is a run-time error if already locked.
func (l locker) Unlock() {
l.km.Unlock(l.key)
}
// Return a object which implement sync.Locker interface
// A Locker represents an object that can be locked and unlocked.
func (km Kmutex) Locker(key interface{}) sync.Locker {
return locker{
key: key,
km: &km,
}
}

394
core/webapi/artwork.go Normal file
View file

@ -0,0 +1,394 @@
package core
import (
"errors"
"fmt"
"html/template"
"sort"
"strconv"
"strings"
"sync"
"time"
session "codeberg.org/vnpower/pixivfe/v2/core/config"
http "codeberg.org/vnpower/pixivfe/v2/core/http"
"github.com/goccy/go-json"
"github.com/gofiber/fiber/v2"
)
// Pixiv returns 0, 1, 2 to filter SFW and/or NSFW artworks.
// Those values are saved in `xRestrict`
// 0: Safe
// 1: R18
// 2: R18G
type xRestrict int
const (
Safe xRestrict = 0
R18 xRestrict = 1
R18G xRestrict = 2
)
var xRestrictModel = map[xRestrict]string{
Safe: "",
R18: "R18",
R18G: "R18G",
}
// Pixiv returns 0, 1, 2 to filter SFW and/or NSFW artworks.
// Those values are saved in `aiType`
// 0: Not rated / Unknown
// 1: Not AI-generated
// 2: AI-generated
type aiType int
const (
Unrated aiType = 0
NotAI aiType = 1
AI aiType = 2
)
var aiTypeModel = map[aiType]string{
Unrated: "Unrated",
NotAI: "Not AI",
AI: "AI",
}
type ImageResponse struct {
Width int `json:"width"`
Height int `json:"height"`
Urls map[string]string `json:"urls"`
}
type Image struct {
Width int
Height int
Small string
Medium string
Large string
Original string
}
type Tag struct {
Name string `json:"tag"`
TranslatedName string `json:"translation"`
}
type Comment struct {
AuthorID string `json:"userId"`
AuthorName string `json:"userName"`
Avatar string `json:"img"`
Context string `json:"comment"`
Stamp string `json:"stampId"`
Date string `json:"commentDate"`
}
type UserBrief struct {
ID string `json:"userId"`
Name string `json:"name"`
Avatar string `json:"imageBig"`
}
type ArtworkBrief struct {
ID string `json:"id"`
Title string `json:"title"`
ArtistID string `json:"userId"`
ArtistName string `json:"userName"`
ArtistAvatar string `json:"profileImageUrl"`
Thumbnail string `json:"url"`
Pages int `json:"pageCount"`
XRestrict int `json:"xRestrict"`
AiType int `json:"aiType"`
Bookmarked any `json:"bookmarkData"`
IllustType int `json:"illustType"`
}
type Illust struct {
ID string `json:"id"`
Title string `json:"title"`
Description template.HTML `json:"description"`
UserID string `json:"userId"`
UserName string `json:"userName"`
UserAccount string `json:"userAccount"`
Date time.Time `json:"uploadDate"`
Images []Image
Tags []Tag `json:"tags"`
Pages int `json:"pageCount"`
Bookmarks int `json:"bookmarkCount"`
Likes int `json:"likeCount"`
Comments int `json:"commentCount"`
Views int `json:"viewCount"`
CommentDisabled int `json:"commentOff"`
SanityLevel int `json:"sl"`
XRestrict xRestrict `json:"xRestrict"`
AiType aiType `json:"aiType"`
Bookmarked any `json:"bookmarkData"`
Liked any `json:"likeData"`
User UserBrief
RecentWorks []ArtworkBrief
RelatedWorks []ArtworkBrief
CommentsList []Comment
IsUgoira bool
}
func GetUserBasicInformation(c *fiber.Ctx, id string) (UserBrief, error) {
var user UserBrief
URL := http.GetUserInformationURL(id)
response, err := http.UnwrapWebAPIRequest(URL, "")
if err != nil {
return user, err
}
response = session.ProxyImageUrl(c, response)
err = json.Unmarshal([]byte(response), &user)
if err != nil {
return user, err
}
return user, nil
}
func GetArtworkImages(c *fiber.Ctx, id string) ([]Image, error) {
var resp []ImageResponse
var images []Image
URL := http.GetArtworkImagesURL(id)
response, err := http.UnwrapWebAPIRequest(URL, "")
if err != nil {
return nil, err
}
response = session.ProxyImageUrl(c, response)
err = json.Unmarshal([]byte(response), &resp)
if err != nil {
return images, err
}
// Extract and proxy every images
for _, imageRaw := range resp {
var image Image
// this is the original art dimention, not the "regular" art dimension
// the image ratio of "regular" is close to Width/Height
// maybe not useful
image.Width = imageRaw.Width
image.Height = imageRaw.Height
image.Small = imageRaw.Urls["thumb_mini"]
image.Medium = imageRaw.Urls["small"]
image.Large = imageRaw.Urls["regular"]
image.Original = imageRaw.Urls["original"]
images = append(images, image)
}
return images, nil
}
func GetArtworkComments(c *fiber.Ctx, id string) ([]Comment, error) {
var body struct {
Comments []Comment `json:"comments"`
}
URL := http.GetArtworkCommentsURL(id)
response, err := http.UnwrapWebAPIRequest(URL, "")
if err != nil {
return nil, err
}
response = session.ProxyImageUrl(c, response)
err = json.Unmarshal([]byte(response), &body)
if err != nil {
return nil, err
}
return body.Comments, nil
}
func GetRelatedArtworks(c *fiber.Ctx, id string) ([]ArtworkBrief, error) {
var body struct {
Illusts []ArtworkBrief `json:"illusts"`
}
// TODO: keep the hard-coded limit?
URL := http.GetArtworkRelatedURL(id, 96)
response, err := http.UnwrapWebAPIRequest(URL, "")
if err != nil {
return nil, err
}
response = session.ProxyImageUrl(c, response)
err = json.Unmarshal([]byte(response), &body)
if err != nil {
return nil, err
}
return body.Illusts, nil
}
func GetArtworkByID(c *fiber.Ctx, id string, full bool) (*Illust, error) {
URL := http.GetArtworkInformationURL(id)
response, err := http.UnwrapWebAPIRequest(URL, "")
if err != nil {
return nil, err
}
var illust struct {
*Illust
// recent illustrations by same user
Recent map[int]any `json:"userIllusts"`
RawTags json.RawMessage `json:"tags"`
}
// Parse basic illust information
err = json.Unmarshal([]byte(response), &illust)
if err != nil {
return nil, err
}
// Begin testing here
wg := sync.WaitGroup{}
cerr := make(chan error, 6)
wg.Add(3)
go func() {
// Get illust images
defer wg.Done()
images, err := GetArtworkImages(c, id)
if err != nil {
cerr <- err
return
}
illust.Images = images
}()
go func() {
// Get basic user information (the URL above does not contain avatars)
defer wg.Done()
var err error
userInfo, err := GetUserBasicInformation(c, illust.UserID)
if err != nil {
cerr <- err
return
}
illust.User = userInfo
}()
go func() {
defer wg.Done()
var err error
// Extract tags
var tags struct {
Tags []struct {
Tag string `json:"tag"`
Translation map[string]string `json:"translation"`
} `json:"tags"`
}
err = json.Unmarshal(illust.RawTags, &tags)
if err != nil {
cerr <- err
return
}
var tagsList []Tag
for _, tag := range tags.Tags {
var newTag Tag
newTag.Name = tag.Tag
newTag.TranslatedName = tag.Translation["en"]
tagsList = append(tagsList, newTag)
}
illust.Tags = tagsList
}()
if full {
wg.Add(3)
go func() {
defer wg.Done()
var err error
// Get recent artworks
ids := make([]int, 0)
for k := range illust.Recent {
ids = append(ids, k)
}
sort.Sort(sort.Reverse(sort.IntSlice(ids)))
idsString := ""
count := min(len(ids), 20)
for i := 0; i < count; i++ {
idsString += fmt.Sprintf("&ids[]=%d", ids[i])
}
recent, err := GetUserArtworks(c, illust.UserID, idsString)
if err != nil {
cerr <- err
return
}
sort.Slice(recent[:], func(i, j int) bool {
left, _ := strconv.Atoi(recent[i].ID)
right, _ := strconv.Atoi(recent[j].ID)
return left > right
})
illust.RecentWorks = recent
}()
go func() {
defer wg.Done()
var err error
related, err := GetRelatedArtworks(c, id)
if err != nil {
cerr <- err
return
}
illust.RelatedWorks = related
}()
go func() {
defer wg.Done()
if illust.CommentDisabled == 1 {
return
}
var err error
comments, err := GetArtworkComments(c, id)
if err != nil {
println("here")
cerr <- err
return
}
illust.CommentsList = comments
}()
}
wg.Wait()
close(cerr)
all_errors := []error{}
for suberr := range cerr {
all_errors = append(all_errors, suberr)
}
err_summary := errors.Join(all_errors...)
if err_summary != nil {
return nil, err_summary
}
// If this artwork is an ugoira
illust.IsUgoira = strings.Contains(illust.Images[0].Original, "ugoira")
return illust.Illust, nil
}

61
core/webapi/discovery.go Normal file
View file

@ -0,0 +1,61 @@
package core
import (
"fmt"
session "codeberg.org/vnpower/pixivfe/v2/core/config"
http "codeberg.org/vnpower/pixivfe/v2/core/http"
"github.com/goccy/go-json"
"github.com/gofiber/fiber/v2"
"github.com/tidwall/gjson"
)
func GetDiscoveryArtwork(c *fiber.Ctx, mode string) ([]ArtworkBrief, error) {
token := session.GetToken(c)
URL := http.GetDiscoveryURL(mode, 100)
var artworks []ArtworkBrief
resp, err := http.UnwrapWebAPIRequest(URL, token)
if err != nil {
return nil, err
}
resp = session.ProxyImageUrl(c, resp)
if !gjson.Valid(resp) {
return nil, fmt.Errorf("invalid json: %v", resp)
}
data := gjson.Get(resp, "thumbnails.illust").String()
err = json.Unmarshal([]byte(data), &artworks)
if err != nil {
return nil, err
}
return artworks, nil
}
func GetDiscoveryNovels(c *fiber.Ctx, mode string) ([]NovelBrief, error) {
token := session.GetToken(c)
URL := http.GetDiscoveryNovelURL(mode, 100)
var novels []NovelBrief
resp, err := http.UnwrapWebAPIRequest(URL, token)
if err != nil {
return nil, err
}
resp = session.ProxyImageUrl(c, resp)
if !gjson.Valid(resp) {
return nil, fmt.Errorf("invalid json: %v", resp)
}
data := gjson.Get(resp, "thumbnails.novel").String()
err = json.Unmarshal([]byte(data), &novels)
if err != nil {
return nil, err
}
return novels, nil
}

118
core/webapi/index.go Normal file
View file

@ -0,0 +1,118 @@
package core
import (
"fmt"
session "codeberg.org/vnpower/pixivfe/v2/core/config"
http "codeberg.org/vnpower/pixivfe/v2/core/http"
"github.com/goccy/go-json"
"github.com/gofiber/fiber/v2"
"github.com/tidwall/gjson"
)
type Pixivision struct {
ID string `json:"id"`
Title string `json:"title"`
Thumbnail string `json:"thumbnailUrl"`
URL string `json:"url"`
}
type RecommendedTags struct {
Name string `json:"tag"`
Artworks []ArtworkBrief
}
type LandingArtworks struct {
Commissions []ArtworkBrief
Following []ArtworkBrief
Recommended []ArtworkBrief
Newest []ArtworkBrief
Rankings []ArtworkBrief
Users []ArtworkBrief
Pixivision []Pixivision
RecommendByTags []RecommendedTags
}
func GetLanding(c *fiber.Ctx, mode string) (*LandingArtworks, error) {
var pages struct {
Pixivision []Pixivision `json:"pixivision"`
Follow []int `json:"follow"`
Recommended struct {
IDs []string `json:"ids"`
} `json:"recommend"`
// EditorRecommended []any `json:"editorRecommend"`
// UserRecommended []any `json:"recommendUser"`
// Commission []any `json:"completeRequestIds"`
RecommendedByTags []struct {
Name string `json:"tag"`
IDs []string `json:"ids"`
} `json:"recommendByTag"`
}
URL := http.GetLandingURL(mode)
var landing LandingArtworks
resp, err := http.UnwrapWebAPIRequest(URL, "")
if err != nil {
return &landing, err
}
resp = session.ProxyImageUrl(c, resp)
if !gjson.Valid(resp) {
return nil, fmt.Errorf("invalid json: %v", resp)
}
artworks := map[string]ArtworkBrief{}
// Get thumbnails and save it into a map, since they were kept
// separately and need to the index quickly.
//
// Since there are no duplicates in this object, we are unable
// to rely to ranges (ex. one artwork in two separate sections)
stuff := gjson.Get(resp, "thumbnails.illust")
stuff.ForEach(func(key, value gjson.Result) bool {
var artwork ArtworkBrief
err = json.Unmarshal([]byte(value.String()), &artwork)
if err != nil {
return false
}
if artwork.ID != "" {
artworks[artwork.ID] = artwork
}
return true // keep iterating
})
pagesStr := gjson.Get(resp, "page").String()
err = json.Unmarshal([]byte(pagesStr), &pages)
if err != nil {
return &landing, err
}
// Parse everything
landing.Pixivision = pages.Pixivision
landing.Following = make([]ArtworkBrief, len(pages.Follow))
for _, i := range pages.Follow {
landing.Following = append(landing.Following, artworks[fmt.Sprint(i)])
}
for _, i := range pages.RecommendedByTags {
temp := make([]ArtworkBrief, 0)
for _, j := range i.IDs {
temp = append(temp, artworks[j])
}
landing.RecommendByTags = append(landing.RecommendByTags, RecommendedTags{Name: i.Name, Artworks: temp})
}
landing.Recommended = make([]ArtworkBrief, 0)
for _, i := range pages.Recommended.IDs {
landing.Recommended = append(landing.Recommended, artworks[i])
}
return &landing, nil
}

31
core/webapi/newest.go Normal file
View file

@ -0,0 +1,31 @@
package core
import (
session "codeberg.org/vnpower/pixivfe/v2/core/config"
http "codeberg.org/vnpower/pixivfe/v2/core/http"
"github.com/goccy/go-json"
"github.com/gofiber/fiber/v2"
)
func GetNewestArtworks(c *fiber.Ctx, worktype string, r18 string) ([]ArtworkBrief, error) {
token := session.GetToken(c)
URL := http.GetNewestArtworksURL(worktype, r18, "0")
var body struct {
Artworks []ArtworkBrief `json:"illusts"`
// LastId string
}
resp, err := http.UnwrapWebAPIRequest(URL, token)
if err != nil {
return nil, err
}
resp = session.ProxyImageUrl(c, resp)
err = json.Unmarshal([]byte(resp), &body)
if err != nil {
return nil, err
}
return body.Artworks, nil
}

103
core/webapi/novel.go Normal file
View file

@ -0,0 +1,103 @@
package core
import (
"time"
session "codeberg.org/vnpower/pixivfe/v2/core/config"
http "codeberg.org/vnpower/pixivfe/v2/core/http"
"github.com/goccy/go-json"
"github.com/gofiber/fiber/v2"
)
type Novel struct {
Bookmarks int `json:"bookmarkCount"`
CommentCount int `json:"commentCount"`
MarkerCount int `json:"markerCount"`
CreateDate time.Time `json:"createDate"`
UploadDate time.Time `json:"uploadDate"`
Description string `json:"description"`
ID string `json:"id"`
Title string `json:"title"`
Likes int `json:"likeCount"`
Pages int `json:"pageCount"`
UserID string `json:"userId"`
UserName string `json:"userName"`
Views int `json:"viewCount"`
IsOriginal bool `json:"isOriginal"`
IsBungei bool `json:"isBungei"`
XRestrict int `json:"xRestrict"`
Restrict int `json:"restrict"`
Content string `json:"content"`
CoverURL string `json:"coverUrl"`
IsBookmarkable bool `json:"isBookmarkable"`
BookmarkData interface{} `json:"bookmarkData"`
LikeData bool `json:"likeData"`
PollData interface{} `json:"pollData"`
Marker interface{} `json:"marker"`
Tags struct {
AuthorID string `json:"authorId"`
IsLocked bool `json:"isLocked"`
Tags []struct {
Name string `json:"tag"`
} `json:"tags"`
Writable bool `json:"writable"`
} `json:"tags"`
SeriesNavData interface{} `json:"seriesNavData"`
HasGlossary bool `json:"hasGlossary"`
IsUnlisted bool `json:"isUnlisted"`
Language string `json:"language"`
CommentOff int `json:"commentOff"`
CharacterCount int `json:"characterCount"`
WordCount int `json:"wordCount"`
UseWordCount bool `json:"useWordCount"`
ReadingTime int `json:"readingTime"`
AiType int `json:"aiType"`
Genre string `json:"genre"`
}
type NovelBrief struct {
ID string `json:"id"`
Title string `json:"title"`
XRestrict int `json:"xRestrict"`
Restrict int `json:"restrict"`
CoverURL string `json:"url"`
Tags []string `json:"tags"`
UserID string `json:"userId"`
UserName string `json:"userName"`
UserAvatar string `json:"profileImageUrl"`
TextCount int `json:"textCount"`
WordCount int `json:"wordCount"`
ReadingTime int `json:"readingTime"`
Description string `json:"description"`
IsBookmarkable bool `json:"isBookmarkable"`
BookmarkData interface{} `json:"bookmarkData"`
Bookmarks int `json:"bookmarkCount"`
IsOriginal bool `json:"isOriginal"`
CreateDate time.Time `json:"createDate"`
UpdateDate time.Time `json:"updateDate"`
IsMasked bool `json:"isMasked"`
SeriesID string `json:"seriesId"`
SeriesTitle string `json:"seriesTitle"`
IsUnlisted bool `json:"isUnlisted"`
AiType int `json:"aiType"`
Genre string `json:"genre"`
}
func GetNovelByID(c *fiber.Ctx, id string) (Novel, error) {
var novel Novel
URL := http.GetNovelURL(id)
response, err := http.UnwrapWebAPIRequest(URL, "")
if err != nil {
return novel, err
}
response = session.ProxyImageUrl(c, response)
err = json.Unmarshal([]byte(response), &novel)
if err != nil {
return novel, err
}
return novel, nil
}

38
core/webapi/personal.go Normal file
View file

@ -0,0 +1,38 @@
package core
import (
session "codeberg.org/vnpower/pixivfe/v2/core/config"
http "codeberg.org/vnpower/pixivfe/v2/core/http"
"github.com/goccy/go-json"
"github.com/gofiber/fiber/v2"
)
func GetNewestFromFollowing(c *fiber.Ctx, mode, page string) ([]ArtworkBrief, error) {
token := session.GetToken(c)
URL := http.GetNewestFromFollowingURL(mode, page)
var body struct {
Thumbnails json.RawMessage `json:"thumbnails"`
}
var artworks struct {
Artworks []ArtworkBrief `json:"illust"`
}
resp, err := http.UnwrapWebAPIRequest(URL, token)
if err != nil {
return nil, err
}
resp = session.ProxyImageUrl(c, resp)
err = json.Unmarshal([]byte(resp), &body)
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(body.Thumbnails), &artworks)
if err != nil {
return nil, err
}
return artworks.Artworks, nil
}

57
core/webapi/ranking.go Normal file
View file

@ -0,0 +1,57 @@
package core
import (
"errors"
"strings"
session "codeberg.org/vnpower/pixivfe/v2/core/config"
http "codeberg.org/vnpower/pixivfe/v2/core/http"
"github.com/goccy/go-json"
"github.com/gofiber/fiber/v2"
)
type Ranking struct {
Contents []struct {
Title string `json:"title"`
Image string `json:"url"`
Pages string `json:"illust_page_count"`
ArtistName string `json:"user_name"`
ArtistAvatar string `json:"profile_img"`
ID int `json:"illust_id"`
ArtistID int `json:"user_id"`
Rank int `json:"rank"`
IllustType string `json:"illust_type"`
} `json:"contents"`
Mode string `json:"mode"`
Content string `json:"content"`
Page int `json:"page"`
RankTotal int `json:"rank_total"`
CurrentDate string `json:"date"`
PrevDateRaw json.RawMessage `json:"prev_date"`
NextDateRaw json.RawMessage `json:"next_date"`
PrevDate string
NextDate string
}
func GetRanking(c *fiber.Ctx, mode, content, date, page string) (Ranking, error) {
URL := http.GetRankingURL(mode, content, date, page)
var ranking Ranking
resp := http.WebAPIRequest(URL, "")
if !resp.Ok {
return ranking, errors.New(resp.Message)
}
proxiedResp := session.ProxyImageUrl(c, resp.Body)
err := json.Unmarshal([]byte(proxiedResp), &ranking)
if err != nil {
return ranking, err
}
ranking.PrevDate = strings.ReplaceAll(string(ranking.PrevDateRaw[:]), "\"", "")
ranking.NextDate = strings.ReplaceAll(string(ranking.NextDateRaw[:]), "\"", "")
return ranking, nil
}

View file

@ -0,0 +1,98 @@
package core
import (
"fmt"
"html/template"
"net/http"
"time"
session "codeberg.org/vnpower/pixivfe/v2/core/config"
url "codeberg.org/vnpower/pixivfe/v2/core/http"
"github.com/gofiber/fiber/v2"
"golang.org/x/net/html"
)
func get_weekday(n time.Weekday) int {
switch n {
case time.Sunday:
return 1
case time.Monday:
return 2
case time.Tuesday:
return 3
case time.Wednesday:
return 4
case time.Thursday:
return 5
case time.Friday:
return 6
case time.Saturday:
return 7
}
return 0
}
func GetRankingCalendar(c *fiber.Ctx, mode string, year, month int) (template.HTML, error) {
token := session.GetToken(c)
URL := url.GetRankingCalendarURL(mode, year, month)
req, _ := http.NewRequest("GET", URL, nil)
req.Header.Add("User-Agent", "Mozilla/5.0")
req.Header.Add("Cookie", "PHPSESSID="+token)
// req.AddCookie(&http.Cookie{
// Name: "PHPSESSID",
// Value: token,
// })
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
// Use the html package to parse the response body from the request
doc, err := html.Parse(resp.Body)
if err != nil {
return "", err
}
// Find and print all links on the web page
var links []string
var link func(*html.Node)
link = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "img" {
for _, a := range n.Attr {
if a.Key == "data-src" {
// adds a new link entry when the attribute matches
links = append(links, session.ProxyImageUrlNoEscape(c, a.Val))
}
}
}
// traverses the HTML of the webpage from the first child node
for c := n.FirstChild; c != nil; c = c.NextSibling {
link(c)
}
}
link(doc)
// now := time.Now()
// yearNow := now.Year()
// monthNow := now.Month()
lastMonth := time.Date(year, time.Month(month), 0, 0, 0, 0, 0, time.UTC)
thisMonth := time.Date(year, time.Month(month+1), 0, 0, 0, 0, 0, time.UTC)
renderString := ""
for i := 0; i < get_weekday(lastMonth.Weekday()); i++ {
renderString += "<div class=\"calendar-node calendar-node-empty\"></div>"
}
for i := 0; i < thisMonth.Day(); i++ {
date := fmt.Sprintf("%d%02d%02d", year, month, i+1)
if len(links) > i {
renderString += fmt.Sprintf(`<a href="/ranking?mode=%s&date=%s"><div class="calendar-node"><img src="%s" alt="Day %d" /><span>%d</span></div></a>`, mode, date, links[i], i+1, i+1)
} else {
renderString += fmt.Sprintf(`<div class="calendar-node"><span>%d</span></div>`, i+1)
}
}
return template.HTML(renderString), nil
}

94
core/webapi/tag.go Normal file
View file

@ -0,0 +1,94 @@
package core
import (
"strings"
session "codeberg.org/vnpower/pixivfe/v2/core/config"
http "codeberg.org/vnpower/pixivfe/v2/core/http"
"github.com/goccy/go-json"
"github.com/gofiber/fiber/v2"
)
type TagDetail struct {
Name string `json:"tag"`
AlternativeName string `json:"word"`
Metadata struct {
Detail string `json:"abstract"`
Image string `json:"image"`
Name string `json:"tag"`
ID json.Number `json:"id"`
} `json:"pixpedia"`
}
type SearchArtworks struct {
Artworks []ArtworkBrief `json:"data"`
Total int `json:"total"`
}
type SearchResult struct {
Artworks SearchArtworks
Popular struct {
Permanent []ArtworkBrief `json:"permanent"`
Recent []ArtworkBrief `json:"recent"`
} `json:"popular"`
RelatedTags []string `json:"relatedTags"`
}
func GetTagData(c *fiber.Ctx, name string) (TagDetail, error) {
var tag TagDetail
URL := http.GetTagDetailURL(name)
response, err := http.UnwrapWebAPIRequest(URL, "")
if err != nil {
return tag, err
}
response = session.ProxyImageUrl(c, response)
err = json.Unmarshal([]byte(response), &tag)
if err != nil {
return tag, err
}
return tag, nil
}
func GetSearch(c *fiber.Ctx, artworkType, name, order, age_settings, page string) (*SearchResult, error) {
URL := http.GetSearchArtworksURL(artworkType, name, order, age_settings, page)
response, err := http.UnwrapWebAPIRequest(URL, "")
if err != nil {
return nil, err
}
response = session.ProxyImageUrl(c, response)
// IDK how to do better than this lol
temp := strings.ReplaceAll(string(response), `"illust"`, `"works"`)
temp = strings.ReplaceAll(temp, `"manga"`, `"works"`)
temp = strings.ReplaceAll(temp, `"illustManga"`, `"works"`)
var resultRaw struct {
*SearchResult
ArtworksRaw json.RawMessage `json:"works"`
}
var artworks SearchArtworks
var result *SearchResult
err = json.Unmarshal([]byte(temp), &resultRaw)
if err != nil {
return nil, err
}
result = resultRaw.SearchResult
err = json.Unmarshal([]byte(resultRaw.ArtworksRaw), &artworks)
if err != nil {
return nil, err
}
result.Artworks = artworks
return result, nil
}

283
core/webapi/user.go Normal file
View file

@ -0,0 +1,283 @@
package core
import (
"errors"
"fmt"
"html/template"
"math"
"sort"
"strconv"
session "codeberg.org/vnpower/pixivfe/v2/core/config"
http "codeberg.org/vnpower/pixivfe/v2/core/http"
"github.com/goccy/go-json"
"github.com/gofiber/fiber/v2"
)
type FrequentTag struct {
Name string `json:"tag"`
TranslatedName string `json:"tag_translation"`
}
type User struct {
ID string `json:"userId"`
Name string `json:"name"`
Avatar string `json:"imageBig"`
Following int `json:"following"`
MyPixiv int `json:"mypixivCount"`
Comment template.HTML `json:"commentHtml"`
Webpage string `json:"webpage"`
SocialRaw json.RawMessage `json:"social"`
Artworks []ArtworkBrief `json:"artworks"`
Background map[string]interface{} `json:"background"`
ArtworksCount int
FrequentTags []FrequentTag
Social map[string]map[string]string
BackgroundImage string
}
func (s *User) ParseSocial() {
if string(s.SocialRaw[:]) == "[]" {
// Fuck Pixiv
return
}
_ = json.Unmarshal(s.SocialRaw, &s.Social)
}
func GetFrequentTags(c *fiber.Ctx, ids string) ([]FrequentTag, error) {
var tags []FrequentTag
URL := http.GetFrequentTagsURL(ids)
response, err := http.UnwrapWebAPIRequest(URL, "")
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(response), &tags)
if err != nil {
return nil, err
}
return tags, nil
}
func GetUserArtworks(c *fiber.Ctx, id, ids string) ([]ArtworkBrief, error) {
var works []ArtworkBrief
URL := http.GetUserFullArtworkURL(id, ids)
resp, err := http.UnwrapWebAPIRequest(URL, "")
if err != nil {
return nil, err
}
resp = session.ProxyImageUrl(c, resp)
var body struct {
Illusts map[int]json.RawMessage `json:"works"`
}
err = json.Unmarshal([]byte(resp), &body)
if err != nil {
return nil, err
}
for _, v := range body.Illusts {
var illust ArtworkBrief
err = json.Unmarshal(v, &illust)
if err != nil {
return nil, err
}
works = append(works, illust)
}
return works, nil
}
func GetUserArtworksID(c *fiber.Ctx, id, category string, page int) (string, int, error) {
URL := http.GetUserArtworksURL(id)
resp, err := http.UnwrapWebAPIRequest(URL, "")
if err != nil {
return "", -1, err
}
var body struct {
Illusts json.RawMessage `json:"illusts"`
Mangas json.RawMessage `json:"manga"`
}
err = json.Unmarshal([]byte(resp), &body)
if err != nil {
return "", -1, err
}
var ids []int
var idsString string
err = json.Unmarshal([]byte(resp), &body)
if err != nil {
return "", -1, err
}
var illusts map[int]string
var mangas map[int]string
count := 0
if err = json.Unmarshal(body.Illusts, &illusts); err != nil {
illusts = make(map[int]string)
}
if err = json.Unmarshal(body.Mangas, &mangas); err != nil {
mangas = make(map[int]string)
}
// Get the keys, because Pixiv only returns IDs (very evil)
if category == "illustrations" || category == "artworks" {
for k := range illusts {
ids = append(ids, k)
count++
}
}
if category == "manga" || category == "artworks" {
for k := range mangas {
ids = append(ids, k)
count++
}
}
// Reverse sort the ids
sort.Sort(sort.Reverse(sort.IntSlice(ids)))
worksNumber := float64(count)
worksPerPage := 30.0
if page < 1 || float64(page) > math.Ceil(worksNumber/worksPerPage)+1.0 {
return "", -1, errors.New("No page available.")
}
start := (page - 1) * int(worksPerPage)
end := int(min(float64(page)*worksPerPage, worksNumber)) // no overflow
for _, k := range ids[start:end] {
idsString += fmt.Sprintf("&ids[]=%d", k)
}
return idsString, count, nil
}
func GetUserArtwork(c *fiber.Ctx, id, category string, page int) (User, error) {
var user User
token := session.GetToken(c)
URL := http.GetUserInformationURL(id)
resp, err := http.UnwrapWebAPIRequest(URL, token)
if err != nil {
return user, err
}
resp = session.ProxyImageUrl(c, resp)
err = json.Unmarshal([]byte(resp), &user)
if err != nil {
return user, err
}
if category != "bookmarks" {
ids, count, err := GetUserArtworksID(c, id, category, page)
if err != nil {
return user, err
}
if count > 0 {
// Check if the user has artworks available or not
works, err := GetUserArtworks(c, id, ids)
if err != nil {
return user, err
}
// IDK but the order got shuffled even though Pixiv sorted the IDs in the response
sort.Slice(works[:], func(i, j int) bool {
left, _ := strconv.Atoi(works[i].ID)
right, _ := strconv.Atoi(works[j].ID)
return left > right
})
user.Artworks = works
user.FrequentTags, err = GetFrequentTags(c, ids)
if err != nil {
return user, err
}
}
// Artworks count
user.ArtworksCount = count
} else {
// Bookmarks
works, count, err := GetUserBookmarks(c, id, "show", page)
if err != nil {
return user, err
}
user.Artworks = works
// Public bookmarks count
user.ArtworksCount = count
}
user.ParseSocial()
if user.Background != nil {
user.BackgroundImage = user.Background["url"].(string)
}
return user, nil
}
func GetUserBookmarks(c *fiber.Ctx, id, mode string, page int) ([]ArtworkBrief, int, error) {
page--
URL := http.GetUserBookmarksURL(id, mode, page)
resp, err := http.UnwrapWebAPIRequest(URL, "")
if err != nil {
return nil, -1, err
}
resp = session.ProxyImageUrl(c, resp)
var body struct {
Artworks []json.RawMessage `json:"works"`
Total int `json:"total"`
}
err = json.Unmarshal([]byte(resp), &body)
if err != nil {
return nil, -1, err
}
artworks := make([]ArtworkBrief, len(body.Artworks))
for index, value := range body.Artworks {
var artwork ArtworkBrief
err = json.Unmarshal([]byte(value), &artwork)
if err != nil {
artworks[index] = ArtworkBrief{
ID: "#",
Title: "Deleted or Private",
Thumbnail: "https://s.pximg.net/common/images/limit_unknown_360.png",
}
continue
}
artworks[index] = artwork
}
return artworks, body.Total, nil
}

View file

@ -0,0 +1,16 @@
package doc
// the list of proxies on /settings
var BuiltinProxyList = []string{
// WE ARE NOT AFFILIATED WITH MOST OF THE PROXIES!!!!
// !!!!!!!
"https://pximg.exozy.me", // except this one. this one we affiliated.
"https://pixiv.ducks.party",
"https://pximg.cocomi.eu.org",
//"https://sex.nyan.xyz",
//"https://i.loli.best",
"https://mima.localghost.org/proxy/pximg",
"https://i.pixiv.re",
// "https://pixiv.tatakai.top", // dead due to us :(
// "https://pximg.chaotic.ninja", // incompatible
}

View file

@ -0,0 +1,133 @@
// Environment Variables
//
// PixivFE's behavior is governed by those Environment Variables.
package doc
import (
"log"
"os"
)
// An environment variable is a KEY=VALUE pair
type EnvVar = struct {
Name string
CommonName string
Value string // available at run-time
Announce bool
}
// All environment variables used by PixivFE
var EnvList []*EnvVar = []*EnvVar{
{
Name: "PIXIVFE_DEV",
CommonName: "development mode",
// **Required**: No
//
// Set this to anything to enable development mode, in which the server will live-reload HTML templates and disable caching. For example, `PIXIVFE_DEV=true`.
},
{
Name: "PIXIVFE_HOST",
CommonName: "TCP hostname",
// **Required**: No (ignored if PIXIVFE_UNIXSOCKET was set)
//
// Hostname/IP address to listen on. For example `PIXIVFE_HOST=localhost`.
},
{
Name: "PIXIVFE_PORT",
CommonName: "TCP port",
// **Required**: Yes (no if PIXIVFE_UNIXSOCKET was set)
//
// Port to listen on. For example `PIXIVFE_PORT=8745`.
},
{
Name: "PIXIVFE_UNIXSOCKET",
CommonName: "UNIX socket path",
// **Required**: Yes (ignored if PIXIVFE_PORT was set)
//
// UNIX socket to listen on. For example `PIXIVFE_UNIXSOCKET=/srv/http/pages/pixivfe`.
},
{
Name: "PIXIVFE_TOKEN",
CommonName: "Pixiv token",
// **Required**: Yes
//
// Authorization is required to fully access Pixiv's Ajax API. This variable will store your Pixiv's account cookie, which will be used by PixivFE for authorization.
//
// **Notice:** Please read [How to get PIXIVFE_TOKEN](How-to-get-the-pixiv-token.md) to see how can you get your own token and more.
},
{
Name: "PIXIVFE_REQUESTLIMIT",
CommonName: "request limit per 30 seconds",
Value: "15",
// **Required**: No
},
{
Name: "PIXIVFE_IMAGEPROXY",
CommonName: "image proxy server",
Value: "/proxy/i.pximg.net", // built-in proxy route
Announce: true,
// **Required**: No, default to using built-in proxy
//
// See the current [list of image proxies](Built-in Proxy List.go).
//
// The address to proxy images. Pixiv does not allow you to get their images normally. For example, this [image](https://i.pximg.net/img-original/img/2023/06/06/20/30/01/108783513_p0.png). We could bypass this anyway by using NGINX and reverse proxy. [You can host an image proxy server if you want](Hosting-an-image-proxy-server-for-Pixiv.md). If you wish not to, or unable to get images directly from Pixiv, set this variable.
},
{
Name: "PIXIVFE_USERAGENT",
CommonName: "user agent",
Value: "Mozilla/5.0",
// **Required**: No
//
// The value of the `User-Agent` header, used to make requests to Pixiv's API.
},
{
Name: "PIXIVFE_ACCEPTLANGUAGE",
CommonName: "Accept-Language header",
Value: "en-US,en;q=0.5",
// **Required**: No
//
// The value of the `Accept-Language` header, used to make requests to Pixiv's API. You can change the response's language with this one.
},
}
// ======================================================================
// what lies below is irrelevant to you if you just want to use PixivFE
// ======================================================================
func CollectAllEnv() {
for _, v := range EnvList {
value, hasValue := os.LookupEnv(v.Name)
if hasValue {
v.Value = value
v.Announce = true
}
}
}
func GetEnv(key string) string {
value, _ := LookupEnv(key)
return value
}
func LookupEnv(key string) (string, bool) {
for _, v := range EnvList {
if v.Name == key {
return v.Value, v.Value != ""
}
}
log.Panicf("Environment Variable Name not in `EnvironList`: %s", key)
panic("Go's type system has no Void/noreturn type...")
}
func AnnounceAllEnv() {
for _, v := range EnvList {
if v.Announce {
log.Printf("Set %s to: %s\n", v.CommonName, v.Value)
}
}
}

View file

@ -0,0 +1,40 @@
# Hosting an i.pximg.net proxy for PixivFE
If you preferred not to use third-party image proxy server, then you could one by yourself!
To get any images from Pixiv, you just have to change the referer to Pixiv.
```
proxy_cache_path /path/to/cache levels=1:2 keys_zone=pximg:10m max_size=10g inactive=7d use_temp_path=off;
server {
listen 443 ssl http2;
ssl_certificate /path/to/ssl_certificate.crt;
ssl_certificate_key /path/to/ssl_certificate.key;
server_name pximg.example.com;
access_log off;
location / {
proxy_cache pximg;
proxy_pass https://i.pximg.net;
proxy_cache_revalidate on;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_lock on;
add_header X-Cache-Status $upstream_cache_status;
proxy_set_header Host i.pximg.net;
proxy_set_header Referer "https://www.pixiv.net/";
proxy_cache_valid 200 7d;
proxy_cache_valid 404 5m;
}
}
```
Now, just replace `i.pximg.net` with yours, for example the image I mentioned in the environment variable page: `https://i.pximg.net/img-original/img/2023/06/06/20/30/01/108783513_p0.png` -> `https://pximg.example.com/img-original/img/2023/06/06/20/30/01/108783513_p0.png`.
You can visit this site to know more: https://pixiv.cat/reverseproxy.html. It is also an image proxy server! Try https://i.pixiv.cat/img-original/img/2023/06/06/20/30/01/108783513_p0.png.
You can also try out [this repo](https://gitler.moe/suwako/imgproxy) from TechnicalSuwako for references.

130
doc/Hosting.md Normal file
View file

@ -0,0 +1,130 @@
# Hosting PixivFE
This page covers multiple methods to install PixivFE. Using [Docker](#docker) is recommended for production use.
## Prerequisites
### Getting the token
PixivFE needs an account token to reach the API.
You can check out [this page](How-to-get-the-pixiv-token.md) for detailed information about how to get the token.
## Installation
### Docker
Docker images for PixivFE can be built with support for `amd64` and `arm64` platforms.
However, there is no Docker image for PixivFE, so you will have to build your own.
#### Docker Compose
Deploying PixivFE using Docker Compose requires the Compose plugin to be installed. Follow these [instructions on the Docker Docs](https://docs.docker.com/compose/install) on how to install it.
##### 1. Setting up the repository
Clone the repo and `cd` into the directory:
```bash
git clone https://codeberg.org/VnPower/PixivFE.git && cd PixivFE
```
##### 2. Set token
A [secret](https://docs.docker.com/compose/use-secrets/) is used to provide the token used by PixivFE to fetch content.
Copy the contents of the `PHPSESSID` cookie into `docker/pixivfe_token.txt`.
##### 3. Compose!
```bash
docker compose up -d
```
Your PixivFE instance is now up at `localhost:8282`!
To follow container logs:
```bash
docker logs -f pixivfe
```
#### Docker CLI
Deploying PixivFE using Docker CLI may be easier than Docker Compose, but requires a slightly different setup.
Furthermore, the `buildx` Docker plugin needs to be installed. Follow these [instructions on the Docker `buildx` repo](https://github.com/docker/buildx?tab=readme-ov-file#installing) on how to install it.
##### 1. Setting up the repository
```bash
git clone https://codeberg.org/VnPower/PixivFE.git && cd PixivFE
```
##### 2. Building the image
For `amd64` platforms:
```bash
docker buildx build --platform linux/amd64 -t vnpower/pixivfe:latest --load .
```
For `arm64` platforms:
```bash
docker buildx build --platform linux/arm64 -t vnpower/pixivfe:latest-arm64 --load .
```
##### 3. Deploying PixivFE
Deploy PixivFE:
```
docker run -d --name pixivfe -p 8282:8282 vnpower/pixivfe:latest
```
Deploy using a different port on the host (in this case, port 8080):
```
docker run -d --name pixivfe -p 8080:8282 vnpower/pixivfe:latest
```
> **Note**:
>
> If deploying on an `arm64` platform, use the `vnpower/pixivfe:latest-arm64` image instead.
If you're using a reverse proxy in front of PixivFE, prefix the port numbers with `127.0.0.1` so that PixivFE only listens on the host port **locally**. For example, if the host port for PixivFE is `8080`, specify `127.0.0.1:8080:8282`.
### Binary with Caddy reverse proxy
Clone the repository and install the dependencies.
```bash
git clone https://codeberg.org/VnPower/PixivFE.git && cd PixivFE
go install
```
You may wanted to check out some of the environment variables used by PixivFE before continuing.
After that, run `go run main.go`. And PixivFE should be running now!
[Caddy](https://caddyserver.com/) is a great alternative to NGINX, because it is written in Go but also easy to config.
Install Caddy using your package manager.
After installing Caddy, make sure that you are inside PixivFE's directory. Then, create a file named `Caddyfile`. You should see something like this:
```
example.com {
reverse_proxy localhost:8282
}
```
Change `example.com` to your domain, also change `8282` if you set the PixivFE's port to something else.
Finally, run `caddy run`.
## Acknowledgement
- [Keep Caddy Running](https://caddyserver.com/docs/running#keep-caddy-running)

View file

@ -0,0 +1,43 @@
# How to get the cookie (PIXIVFE_TOKEN)
This guide covers how to get your Pixiv account's cookie to authenticate.
> **Note**:
>
> You should create an entirely new account for this to avoid account theft. And also, PixivFE will get contents **from your account.** You might not want people to know what kind of illustrations you like :P. For now, the only page that may contain contents that is relevant to you is the discovery page. Be careful if you are using your main account.
## Firefox-based
1. Log in to your Pixiv account of choice. You should be greeted with the landing page with logging in. If you are already logged in, go to the landing page.
![The URL of the landing page](https://files.catbox.moe/7dbv3e.png)
2. Hit `F12` to open up the developer tools. Then, go to the `Storage` tab.
![Storage tab on Firefox](https://files.catbox.moe/mra6rs.png)
3. At the left side, open up the `Cookies` section. Then select `www.pixiv.net`, this is the place where you will get your cookie.
The page now should look like the screenshot below. Select the cookie with the key `PHPSESSID`, the value next to it is your account's token.
![Cookie on Firefox](https://files.catbox.moe/zb16o8.png)
4. Copy it and set the environment variable! If deploying using Docker Compose, copy it into `docker/pixivfe_token.txt` instead.
## Chrome-based
1. Log in to your Pixiv account of choice. You should be greeted with the landing page with logging in. If you are already logged in, go to the landing page.
2. Hit `F12` to open up the developer tools. Then, go to the `Applications` tab.
3. At the left side, you can see the `Storage` section. Inside of that section, there is an another section called `Cookies`, open up the `Cookies` section, then select `www.pixiv.net`. This is the place where you will get your cookie.
The page now should look like the screenshot below. Select the cookie with the key `PHPSESSID`, the value next to it is your account's token.
![PHPSESSID on Chrome-based browsers](https://files.catbox.moe/8wu9f0.png)
4. Copy it and set the environment variable! If deploying using Docker Compose, copy it into `docker/pixivfe_token.txt` instead.
## Note
- The token should look something like this: `123456_AaBbccDDeeFFggHHIiJjkkllmMnnooPP`. The part before the underline is your member ID, the part after the underline is just a random string.
- The token will reset when you logout. Please double-check that your token is still valid before reporting any issues.
- Chrome-based browsers and some content was taken from [this page by Nandaka.](https://github.com/Nandaka/PixivUtil2/wiki#pixiv-login-using-cookie)

7
doc/Quirks.md Normal file
View file

@ -0,0 +1,7 @@
# Known quirks
## Why aren't my userstyles working?
Origin: https://codeberg.org/VnPower/PixivFE/pulls/62#issuecomment-1568191
PixivFE implements a strong [Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy) that prevents inline styles from being loaded. If you're using Stylus, you need to enable **Advanced > Circumvent CSP 'style-src' via adoptedStyleSheets** in Stylus Options (see [issue #1685](https://github.com/openstyles/stylus/issues/1685) on the Stylus GitHub repository).

83
doc/dev/general.md Normal file
View file

@ -0,0 +1,83 @@
# Roadmap
## To implement
/settings/
- [x] Merge login page with settings page
- [ ] Persistence
A JSON file to store values.
This way, values set by users won't be lost after restarts.
- [User Settings](user-customization.md)
/novel/
- [Novel support](novels.md)
Might need some ideas for the reader's UI.
Allow options for font size and family?
Black and white backgrounds?
Theme support?
/series/
- [ ] Manga series
Serialized web comics. Example: https://www.pixiv.net/user/13651304/series/171013
- [ ] Novel series
Independent features
- [x] Multiple tokens support
Now you can do PIXIVFE_TOKEN=TOKEN_A,TOKEN_B
- [ ] Pixivision
https://www.pixivision.net/en/
Pretty good to discover new artworks n stuff.
Implement by parsing the webpage.
- [ ] RSS support for Pixivision
- [ ] Search page
A page to do more extensive searching.
Might require JavaScript for search recommendation, if wanted.
- [ ] Full landing page
There are a lot of sections for the landing page. https://www.pixiv.net/ajax/top/illust
The artwork parsing part has already been implemented flawlessly.
We only have to write the frontend code for those sections.
- [ ] Various interesting pages from Pixiv.net
- https://www.pixiv.net/idea/
- https://www.pixiv.net/request
- https://www.pixiv.net/contest/ (no AJAX endpoints)
## To consider
- App API support
May be painful to implement.
Required to fully replace Pixiv, if user actions won't work universally.
https://codeberg.org/VnPower/PixivFE/issues/7
- Testing
Do we really need testing? What to test?
- User discovery
For discovery page.
Pretty useless if user actions (following) doesn't work.
- "Popular" artworks
Check the README of this:
https://github.com/kokseen1/Mashiro
- i18n
The last thing to work on, probably.
## Misc
- [x] Ranking page
A lot of options weren't implemented.
- [x] Revisit ranking calendar
There should be a way to display R18 thumbnails now?

17
doc/dev/novels.md Normal file
View file

@ -0,0 +1,17 @@
## Functions
- [ ] Novel series
## UI
- [ ] Furigana support
- [ ] Reader settings panel
- [ ] Novel page with vertical text
If `body.suggestedSettings.viewMode == 1`
- [ ] Attributes
- [ ] Recent novels from writers
- [ ] Page support
- [ ] Recommended novels
- [ ] Other works panel?
## Misc
- [ ] Novel ranking
- [ ] Novel mode for any possible pages

19
doc/dev/pitfalls.md Normal file
View file

@ -0,0 +1,19 @@
## Strict CSP
Reference: search for "Content-Security-Policy" in **.go
Current CSP disallows inline styles and scripts and iframes.
## Low Quality Go Module: net/url
`url.Path` is stored decoded (no %XX). `url.Scheme` is stored without `://` (mandated by RFC). Not sure why Go does that. Felt like this is bound to cause some nasty bug on decoding and encoding.
Current proxied URLs don't have weird characters in them. Hopefully it stays this way.
Solution: Replace "net/url" with a better third-party module
## Jet Templating Engine Has No Error Reporting
Not sure why.
Solution: [templ](https://github.com/a-h/templ)

View file

@ -0,0 +1,19 @@
# Per-User Customization Options
Probably cookie-based.
## site-wide
- [ ] sidebar close on history change or not [#63](https://codeberg.org/VnPower/PixivFE/issues/63)
- [ ] navbar sticky or not
- [ ] Multiple tokens support
Let the host supply multiple tokens at once to avoid overuse.
## novel
- [ ] font size
- [ ] font family
## artwork
- [ ] native AI/R15/R18/R18-G... artwork filtering
We can filter them out using values supplied by Pixiv for each artworks.

View file

@ -4,8 +4,8 @@ services:
pixivfe:
container_name: pixivfe
hostname: pixivfe
restart: always
user: 65534:65534
restart: unless-stopped
user: 1000:1000
read_only: true
security_opt:
- no-new-privileges:true
@ -15,6 +15,19 @@ services:
context: .
dockerfile: Dockerfile
ports:
- "8282:8282"
environment:
- PIXIVFE_TOKEN=changethis
- "8282:8282" # Specify `127.0.0.1:8282:8282` instead if using a reverse proxy
env_file: .env
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "--tries=1", "http://127.0.0.1:8282/about"]
interval: 30s
timeout: 3s
start_period: 15s
retries: 3
secrets:
- pixivfe_token
secrets:
pixivfe_token:
# Copy the contents of the `PHPSESSID` cookie into `pixivfe_token.txt`
# See ./doc/How-to-get-the-pixiv-token.md for instructions
file: ./docker/pixivfe_token.txt

12
docker/entrypoint.sh Executable file
View file

@ -0,0 +1,12 @@
#!/bin/sh
# Check if the secret file exists at /run/secrets/pixivfe_token
if [ -f /run/secrets/pixivfe_token ]; then
export PIXIVFE_TOKEN=$(cat /run/secrets/pixivfe_token)
echo "Info: PIXIVFE_TOKEN loaded from secret."
else
echo "Info: PIXIVFE_TOKEN not loaded from secret. Loading the environment variable normally."
fi
# Execute the main application
exec /app/pixivfe "$@"

1
docker/pixivfe_token.txt Normal file
View file

@ -0,0 +1 @@
changethis

33
go.mod
View file

@ -1,32 +1,35 @@
module codeberg.org/vnpower/pixivfe
module codeberg.org/vnpower/pixivfe/v2
go 1.21
replace github.com/gofiber/fiber/v2 => github.com/iacore/fiber/v2 v2.0.0-20240221154744-f7bf0b188c39
require (
github.com/goccy/go-json v0.10.2
github.com/gofiber/fiber/v2 v2.47.0
github.com/gofiber/template/jet/v2 v2.1.3
github.com/gofiber/fiber/v2 v2.52.0
github.com/gofiber/template/jet/v2 v2.1.8
github.com/tidwall/gjson v1.17.0
golang.org/x/net v0.17.0
)
require (
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 // indirect
github.com/CloudyKit/jet/v6 v6.2.0 // indirect
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/gofiber/template v1.8.2 // indirect
github.com/andybalholm/brotli v1.0.6 // indirect
github.com/gofiber/template v1.8.3 // indirect
github.com/gofiber/utils v1.1.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/klauspost/compress v1.16.5 // indirect
github.com/google/uuid v1.5.0 // indirect
github.com/klauspost/compress v1.17.4 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/philhofer/fwd v1.1.2 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 // indirect
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tinylib/msgp v1.1.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.47.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/net v0.14.0 // indirect
golang.org/x/sys v0.11.0 // indirect
golang.org/x/sys v0.15.0 // indirect
)

87
go.sum
View file

@ -2,74 +2,70 @@ github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 h1:sR+/8Yb4s
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
github.com/CloudyKit/jet/v6 v6.2.0 h1:EpcZ6SR9n28BUGtNJSvlBqf90IpjeFr36Tizxhn/oME=
github.com/CloudyKit/jet/v6 v6.2.0/go.mod h1:d3ypHeIRNo2+XyqnGA8s+aphtcVpjP5hPwP/Lzo7Ro4=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/gofiber/fiber/v2 v2.46.0 h1:wkkWotblsGVlLjXj2dpgKQAYHtXumsK/HyFugQM68Ns=
github.com/gofiber/fiber/v2 v2.46.0/go.mod h1:DNl0/c37WLe0g92U6lx1VMQuxGUQY5V7EIaVoEsUffc=
github.com/gofiber/fiber/v2 v2.47.0 h1:EN5lHVCc+Pyqh5OEsk8fzRiifgwpbrP0rulQ4iNf3fs=
github.com/gofiber/fiber/v2 v2.47.0/go.mod h1:mbFMVN1lQuzziTkkakgtKKdjfsXSw9BKR5lmcNksUoU=
github.com/gofiber/template v1.8.2 h1:PIv9s/7Uq6m+Fm2MDNd20pAFFKt5wWs7ZBd8iV9pWwk=
github.com/gofiber/template v1.8.2/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
github.com/gofiber/template/jet/v2 v2.1.3 h1:l/mDuBrJAG1z2sPNQ8/Fn8PRX+6ywhhNCtEqUHEPpAE=
github.com/gofiber/template/jet/v2 v2.1.3/go.mod h1:eWS6P1s/VloKrzOIHLkYFOfjI7KAdFb9ZEaLU6Gtca8=
github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
github.com/gofiber/template/jet/v2 v2.1.8 h1:UCAu62v2RwJXsYVo+9zSEqA8WtNLul0KPsgTJAGowAs=
github.com/gofiber/template/jet/v2 v2.1.8/go.mod h1:K7LwxjGLkzK5TFw1xr0qLiJseo0mz4Cbw/NXEnA18vY=
github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI=
github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/iacore/fiber/v2 v2.0.0-20240221154744-f7bf0b188c39 h1:pw9lhXGyS1P1s1g/h7nBjPJKJBg5UVsrLocuJxv6SQc=
github.com/iacore/fiber/v2 v2.0.0-20240221154744-f7bf0b188c39/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 h1:rmMl4fXJhKMNWl+K+r/fq4FbbKI+Ia2m9hYBLm2h4G4=
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94/go.mod h1:90zrgN3D/WJsDd1iXHT96alCoN2KJo6/4x1DZC3wZs8=
github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM=
github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.47.0 h1:y7moDoxYzMooFpT5aHgNgVOQDrS3qlkfiP9mDtGGK9c=
github.com/valyala/fasthttp v1.47.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -77,12 +73,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
@ -92,9 +84,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,211 +0,0 @@
package handler
import (
"fmt"
"sort"
"strconv"
"strings"
"codeberg.org/vnpower/pixivfe/models"
"github.com/goccy/go-json"
)
func (p *PixivClient) GetArtworkImages(id string) ([]models.Image, error) {
var resp []models.ImageResponse
var images []models.Image
URL := fmt.Sprintf(ArtworkImagesURL, id)
response, err := p.PixivRequest(URL)
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(response), &resp)
if err != nil {
return images, err
}
// Extract and proxy every images
for _, imageRaw := range resp {
var image models.Image
image.Small = imageRaw.Urls["thumb_mini"]
image.Medium = imageRaw.Urls["small"]
image.Large = imageRaw.Urls["regular"]
image.Original = imageRaw.Urls["original"]
images = append(images, image)
}
return images, nil
}
func (p *PixivClient) GetArtworkByID(id string) (*models.Illust, error) {
var images []models.Image
URL := fmt.Sprintf(ArtworkInformationURL, id)
response, err := p.PixivRequest(URL)
if err != nil {
return nil, err
}
var illust struct {
*models.Illust
Recent map[int]any `json:"userIllusts"`
RawTags json.RawMessage `json:"tags"`
}
// Parse basic illust information
err = json.Unmarshal([]byte(response), &illust)
if err != nil {
return nil, err
}
// Begin testing here
c1 := make(chan []models.Image)
c2 := make(chan []models.IllustShort)
c3 := make(chan models.UserShort)
c4 := make(chan []models.Tag)
c5 := make(chan []models.IllustShort)
c6 := make(chan []models.Comment)
go func() {
// Get illust images
images, err = p.GetArtworkImages(id)
if err != nil {
c1 <- nil
}
c1 <- images
}()
go func() {
// Get recent artworks
ids := make([]int, 0)
for k := range illust.Recent {
ids = append(ids, k)
}
sort.Sort(sort.Reverse(sort.IntSlice(ids)))
idsString := ""
count := min(len(ids), 20)
for i := 0; i < count; i++ {
idsString += fmt.Sprintf("&ids[]=%d", ids[i])
}
recent, err := p.GetUserArtworks(illust.UserID, idsString)
if err != nil {
c2 <- nil
}
sort.Slice(recent[:], func(i, j int) bool {
left, _ := strconv.Atoi(recent[i].ID)
right, _ := strconv.Atoi(recent[j].ID)
return left > right
})
c2 <- recent
}()
go func() {
// Get basic user information (the URL above does not contain avatars)
userInfo, err := p.GetUserBasicInformation(illust.UserID)
if err != nil {
//
}
c3 <- userInfo
}()
go func() {
var tagsList []models.Tag
// Extract tags
var tags struct {
Tags []struct {
Tag string `json:"tag"`
Translation map[string]string `json:"translation"`
} `json:"tags"`
}
err = json.Unmarshal(illust.RawTags, &tags)
if err != nil {
c4 <- nil
}
for _, tag := range tags.Tags {
var newTag models.Tag
newTag.Name = tag.Tag
newTag.TranslatedName = tag.Translation["en"]
tagsList = append(tagsList, newTag)
}
c4 <- tagsList
}()
go func() {
related, _ := p.GetRelatedArtworks(id)
// Error handling...
c5 <- related
}()
go func() {
comments, _ := p.GetArtworkComments(id)
// Error handling...
c6 <- comments
}()
illust.Images = <-c1
illust.RecentWorks = <-c2
illust.User = <-c3
illust.Tags = <-c4
illust.RelatedWorks = <-c5
illust.CommentsList = <-c6
// If this artwork is an ugoira
illust.IsUgoira = strings.Contains(illust.Images[0].Original, "ugoira")
return illust.Illust, nil
}
func (p *PixivClient) GetArtworkComments(id string) ([]models.Comment, error) {
var body struct {
Comments []models.Comment `json:"comments"`
}
URL := fmt.Sprintf(ArtworkCommentsURL, id)
response, err := p.PixivRequest(URL)
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(response), &body)
if err != nil {
return nil, err
}
return body.Comments, nil
}
func (p *PixivClient) GetRelatedArtworks(id string) ([]models.IllustShort, error) {
var body struct {
Illusts []models.IllustShort `json:"illusts"`
}
URL := fmt.Sprintf(ArtworkRelatedURL, id, 96)
response, err := p.PixivRequest(URL)
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(response), &body)
if err != nil {
return nil, err
}
return body.Illusts, nil
}

View file

@ -1,125 +0,0 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"codeberg.org/vnpower/pixivfe/models"
)
type PixivClient struct {
Client *http.Client
Cookie map[string]string
Header map[string]string
Lang string
}
func (p *PixivClient) SetHeader(header map[string]string) {
p.Header = header
}
func (p *PixivClient) AddHeader(key, value string) {
p.Header[key] = value
}
func (p *PixivClient) SetUserAgent(value string) {
p.AddHeader("User-Agent", value)
}
func (p *PixivClient) SetCookie(cookie map[string]string) {
p.Cookie = cookie
}
func (p *PixivClient) AddCookie(key, value string) {
p.Cookie[key] = value
}
func (p *PixivClient) SetSessionID(value string) {
p.Cookie["PHPSESSID"] = value
}
func (p *PixivClient) SetLang(lang string) {
p.Lang = lang
}
func (p *PixivClient) Request(URL string, token ...string) (*http.Response, error) {
req, _ := http.NewRequest("GET", URL, nil)
// Add headers
for k, v := range p.Header {
req.Header.Add(k, v)
}
for k, v := range p.Cookie {
req.AddCookie(&http.Cookie{Name: k, Value: v})
}
if token != nil {
req.AddCookie(&http.Cookie{Name: "PHPSESSID", Value: token[0]})
}
// Make a request
resp, err := p.Client.Do(req)
if err != nil {
return resp, err
}
if resp.StatusCode != 200 {
return resp, errors.New(fmt.Sprintf("Pixiv returned code: %d for request %s", resp.StatusCode, URL))
}
return resp, nil
}
func (p *PixivClient) TextRequest(URL string, tokens ...string) (string, error) {
var token string
if len(token) > 0 {
token = tokens[0]
}
/// Make a request to a URL and return the response's string body
resp, err := p.Request(URL, token)
if err != nil {
return "", err
}
// Extract the bytes from server's response
body, err := io.ReadAll(resp.Body)
if err != nil {
return string(body), err
}
return string(body), nil
}
func (p *PixivClient) PixivRequest(URL string, tokens ...string) (json.RawMessage, error) {
/// Make a request to a Pixiv API URL with a standard response, handle errors and return the raw JSON response
var response models.PixivResponse
var token string
if len(token) > 0 {
token = tokens[0]
}
body, err := p.TextRequest(URL, token)
// body = strings.ReplaceAll(body, "i.pximg.net", configs.ProxyServer)
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(body), &response)
if err != nil {
return nil, err
}
if response.Error {
// Pixiv returned an error
return nil, errors.New("Pixiv responded: " + response.Message)
}
return response.Body, nil
}

View file

@ -1,22 +0,0 @@
package handler
const (
ArtworkInformationURL = "https://www.pixiv.net/ajax/illust/%s"
ArtworkImagesURL = "https://www.pixiv.net/ajax/illust/%s/pages"
ArtworkRelatedURL = "https://www.pixiv.net/ajax/illust/%s/recommend/init?limit=%d"
ArtworkCommentsURL = "https://www.pixiv.net/ajax/illusts/comments/roots?illust_id=%s&limit=100"
ArtworkNewestURL = "https://www.pixiv.net/ajax/illust/new?limit=30&type=%s&r18=%s&lastId=%s"
ArtworkRankingURL = "https://www.pixiv.net/ranking.php?format=json&mode=%s&content=%s%s&p=%s"
ArtworkDiscoveryURL = "https://www.pixiv.net/ajax/discovery/artworks?mode=%s&limit=%d"
SearchTagURL = "https://www.pixiv.net/ajax/search/tags/%s"
SearchArtworksURL = "https://www.pixiv.net/ajax/search/%s/%s?order=%s&mode=%s&p=%s"
SearchTopURL = "https://www.pixiv.net/ajax/search/top/%s"
UserInformationURL = "https://www.pixiv.net/ajax/user/%s?full=1"
UserBasicInformationURL = "https://www.pixiv.net/ajax/user/%s"
UserArtworksURL = "https://www.pixiv.net/ajax/user/%s/profile/all"
UserArtworksFullURL = "https://www.pixiv.net/ajax/user/%s/profile/illusts?work_category=illustManga&is_first_page=0&lang=en%s"
UserBookmarksURL = "https://www.pixiv.net/ajax/user/%s/illusts/bookmarks?tag=&offset=%d&limit=48&rest=%s"
FrequentTagsURL = "https://www.pixiv.net/ajax/tags/frequent/illust?%s"
LandingPageURL = "https://www.pixiv.net/ajax/top/illust?mode=%s"
NewestFromFollowURL = "https://www.pixiv.net/ajax/follow_latest/%s?mode=%s&p=%s"
)

View file

@ -1,219 +0,0 @@
package handler
import (
"fmt"
"html/template"
"net/http"
"strings"
"time"
"codeberg.org/vnpower/pixivfe/models"
"github.com/goccy/go-json"
"golang.org/x/net/html"
)
func get_weekday(n time.Weekday) int {
switch n {
case time.Sunday:
return 1
case time.Monday:
return 2
case time.Tuesday:
return 3
case time.Wednesday:
return 4
case time.Thursday:
return 5
case time.Friday:
return 6
case time.Saturday:
return 7
}
return 0
}
func (p *PixivClient) GetNewestArtworks(worktype string, r18 string) ([]models.IllustShort, error) {
var newWorks []models.IllustShort
lastID := "0"
for i := 0; i < 10; i++ {
URL := fmt.Sprintf(ArtworkNewestURL, worktype, r18, lastID)
response, err := p.PixivRequest(URL)
if err != nil {
return nil, err
}
var body struct {
Illusts []models.IllustShort `json:"illusts"`
LastID string `json:"lastId"`
}
err = json.Unmarshal([]byte(response), &body)
if err != nil {
return nil, err
}
newWorks = append(newWorks, body.Illusts...)
lastID = body.LastID
}
return newWorks, nil
}
func (p *PixivClient) GetRanking(mode string, content string, date string, page string) (models.RankingResponse, error) {
// Ranking data is formatted differently
var pr models.RankingResponse
if len(date) > 0 {
date = "&date=" + date
}
url := fmt.Sprintf(ArtworkRankingURL, mode, content, date, page)
s, err := p.TextRequest(url)
if err != nil {
return pr, err
}
err = json.Unmarshal([]byte(s), &pr)
if err != nil {
return pr, err
}
pr.PrevDate = strings.ReplaceAll(string(pr.PrevDateRaw[:]), "\"", "")
pr.NextDate = strings.ReplaceAll(string(pr.NextDateRaw[:]), "\"", "")
return pr, nil
}
func (p *PixivClient) GetSearch(artworkType string, name string, order string, age_settings string, page string) (*models.SearchResult, error) {
URL := fmt.Sprintf(SearchArtworksURL, artworkType, name, order, age_settings, page)
response, err := p.PixivRequest(URL)
if err != nil {
return nil, err
}
// IDK how to do better than this lol
temp := strings.ReplaceAll(string(response), `"illust"`, `"works"`)
temp = strings.ReplaceAll(temp, `"manga"`, `"works"`)
temp = strings.ReplaceAll(temp, `"illustManga"`, `"works"`)
var resultRaw struct {
*models.SearchResult
ArtworksRaw json.RawMessage `json:"works"`
}
var artworks models.SearchArtworks
var result *models.SearchResult
err = json.Unmarshal([]byte(temp), &resultRaw)
if err != nil {
return nil, err
}
result = resultRaw.SearchResult
err = json.Unmarshal([]byte(resultRaw.ArtworksRaw), &artworks)
if err != nil {
return nil, err
}
result.Artworks = artworks
return result, nil
}
func (p *PixivClient) GetDiscoveryArtwork(mode string, count int) ([]models.IllustShort, error) {
var artworks []models.IllustShort
for count > 0 {
itemsForRequest := min(100, count)
count -= itemsForRequest
URL := fmt.Sprintf(ArtworkDiscoveryURL, mode, itemsForRequest)
response, err := p.PixivRequest(URL)
if err != nil {
return nil, err
}
var thumbnail struct {
Data json.RawMessage `json:"thumbnails"`
}
err = json.Unmarshal([]byte(response), &thumbnail)
if err != nil {
return nil, err
}
var body struct {
Artworks []models.IllustShort `json:"illust"`
}
err = json.Unmarshal([]byte(thumbnail.Data), &body)
if err != nil {
return nil, err
}
artworks = append(artworks, body.Artworks...)
}
return artworks, nil
}
func (p *PixivClient) GetRankingLog(mode string, year, month int, image_proxy string) (template.HTML, error) {
url := fmt.Sprintf("https://www.pixiv.net/ranking_log.php?mode=%s&date=%d%02d", mode, year, month)
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
// Use the html package to parse the response body from the request
doc, err := html.Parse(resp.Body)
if err != nil {
return "", err
}
// Find and print all links on the web page
var links []string
var link func(*html.Node)
link = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "img" {
for _, a := range n.Attr {
if a.Key == "data-src" {
// adds a new link entry when the attribute matches
links = append(links, models.ProxyImage(a.Val, image_proxy))
}
}
}
// traverses the HTML of the webpage from the first child node
for c := n.FirstChild; c != nil; c = c.NextSibling {
link(c)
}
}
link(doc)
// now := time.Now()
// yearNow := now.Year()
// monthNow := now.Month()
lastMonth := time.Date(year, time.Month(month), 0, 0, 0, 0, 0, time.UTC)
thisMonth := time.Date(year, time.Month(month+1), 0, 0, 0, 0, 0, time.UTC)
renderString := ""
for i := 0; i < get_weekday(lastMonth.Weekday()); i++ {
renderString += "<div class=\"calendar-node calendar-node-empty\"></div>"
}
for i := 0; i < thisMonth.Day(); i++ {
date := fmt.Sprintf("%d%02d%02d", year, month, i+1)
if len(links) > i {
renderString += fmt.Sprintf(`<a href="/ranking?mode=%s&date=%s"><div class="calendar-node" style="background-image: url(%s)"><span>%d</span></div></a>`, mode, date, links[i], i+1)
} else {
renderString += fmt.Sprintf(`<div class="calendar-node"><span>%d</span></div>`, i+1)
}
}
return template.HTML(renderString), nil
}

View file

@ -1,64 +0,0 @@
package handler
import (
"fmt"
"codeberg.org/vnpower/pixivfe/models"
"github.com/goccy/go-json"
)
func (p *PixivClient) GetNewestFromFollowing(mode, page, token string) ([]models.IllustShort, error) {
URL := fmt.Sprintf(NewestFromFollowURL, "illust", mode, page)
var body struct {
Thumbnails json.RawMessage `json:"thumbnails"`
}
var artworks struct {
Artworks []models.IllustShort `json:"illust"`
}
response, err := p.PixivRequest(URL, token)
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(response), &body)
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(body.Thumbnails), &artworks)
if err != nil {
return nil, err
}
return artworks.Artworks, nil
}
// func (p *PixivClient) FollowUser(id string) error {
// formData := url.Values{}
// formData.Add("mode", "add")
// formData.Add("type", "user")
// formData.Add("user_id", id)
// formData.Add("tag", "")
// formData.Add("restrict", "0")
// formData.Add("format", "json")
// init, err := p.GetCSRF()
// println(init)
// if err != nil {
// return err
// }
// pattern := regexp.MustCompile(`.*pixiv.context.token = "([a-z0-9]{32})"?.*`)
// quotesPattern := regexp.MustCompile(`([a-z0-9]{32})`)
// token := quotesPattern.FindString(pattern.FindString(init))
// println(token)
// _, err = p.RequestWithFormData(FollowUserURL, formData, token)
// if err != nil {
// return err
// }
// return nil
// }

View file

@ -1,44 +0,0 @@
package handler
import (
"fmt"
"codeberg.org/vnpower/pixivfe/models"
"github.com/goccy/go-json"
)
func (p *PixivClient) GetTagData(name string) (models.TagDetail, error) {
var tag models.TagDetail
URL := fmt.Sprintf(SearchTagURL, name)
response, err := p.PixivRequest(URL)
if err != nil {
return tag, err
}
err = json.Unmarshal([]byte(response), &tag)
if err != nil {
return tag, err
}
return tag, nil
}
func (p *PixivClient) GetFrequentTags(ids string) ([]models.FrequentTag, error) {
var tags []models.FrequentTag
URL := fmt.Sprintf(FrequentTagsURL, ids)
response, err := p.PixivRequest(URL)
if err != nil {
return nil, err
}
err = json.Unmarshal([]byte(response), &tags)
if err != nil {
return nil, err
}
return tags, nil
}

View file

@ -1,201 +0,0 @@
package handler
import (
"fmt"
"html/template"
"math/rand"
"net/url"
"regexp"
"strconv"
"time"
)
func GetRandomColor() string {
// Some color shade I stole
colors := []string{
// Green
"#C8847E",
"#C8A87E",
"#C8B87E",
"#C8C67E",
"#C7C87E",
"#C2C87E",
"#BDC87E",
"#82C87E",
"#82C87E",
"#7EC8AF",
"#7EAEC8",
"#7EA6C8",
"#7E99C8",
"#7E87C8",
"#897EC8",
"#967EC8",
"#AE7EC8",
"#B57EC8",
"#C87EA5",
}
// Randomly choose one and return
return colors[rand.Intn(len(colors))]
}
func ParseEmojis(s string) template.HTML {
emojiList := map[string]string{
"normal": "101",
"surprise": "102",
"serious": "103",
"heaven": "104",
"happy": "105",
"excited": "106",
"sing": "107",
"cry": "108",
"normal2": "201",
"shame2": "202",
"love2": "203",
"interesting2": "204",
"blush2": "205",
"fire2": "206",
"angry2": "207",
"shine2": "208",
"panic2": "209",
"normal3": "301",
"satisfaction3": "302",
"surprise3": "303",
"smile3": "304",
"shock3": "305",
"gaze3": "306",
"wink3": "307",
"happy3": "308",
"excited3": "309",
"love3": "310",
"normal4": "401",
"surprise4": "402",
"serious4": "403",
"love4": "404",
"shine4": "405",
"sweat4": "406",
"shame4": "407",
"sleep4": "408",
"heart": "501",
"teardrop": "502",
"star": "503",
}
regex := regexp.MustCompile(`\(([^)]+)\)`)
parsedString := regex.ReplaceAllStringFunc(s, func(s string) string {
s = s[1 : len(s)-1] // Get the string inside
id := emojiList[s]
return fmt.Sprintf(`<img src="https://s.pximg.net/common/images/emoji/%s.png" alt="(%s)" class="emoji" />`, id, s)
})
return template.HTML(parsedString)
}
func ParsePixivRedirect(s string) template.HTML {
regex := regexp.MustCompile(`\/jump\.php\?(http[^"]+)`)
parsedString := regex.ReplaceAllStringFunc(s, func(s string) string {
s = s[10:]
return s
})
escaped, err := url.QueryUnescape(parsedString)
if err != nil {
return template.HTML(s)
}
return template.HTML(escaped)
}
func EscapeString(s string) string {
escaped := url.QueryEscape(s)
return escaped
}
func ParseTime(date time.Time) string {
return date.Format("2006-01-02 15:04")
}
func CreatePaginator(base, ending string, current_page, max_page int) template.HTML {
peek := 2
limit := peek*peek + 1
count := 0
pages := ""
pages += fmt.Sprintf(`<a href="%s1%s" class="pagination-button">&laquo;</a>`, base, ending)
pages += fmt.Sprintf(`<a href="%s%d%s" class="pagination-button">&lsaquo;</a>`, base, max(1, current_page-1), ending)
for i := current_page - peek; (i <= max_page || max_page == -1) && count < limit; i++ {
if i < 1 {
continue
}
if i == current_page {
pages += fmt.Sprintf(`<a href="%s%d%s" class="pagination-button" id="highlight">%d</a>`, base, i, ending, i)
} else {
pages += fmt.Sprintf(`<a href="%s%d%s" class="pagination-button">%d</a>`, base, i, ending, i)
}
count++
}
if max_page == -1 {
pages += fmt.Sprintf(`<a href="%s%d%s" class="pagination-button">&rsaquo;</a>`, base, current_page+1, ending)
pages += fmt.Sprintf(`<a href="%s%d%s" class="pagination-button" id="disabled">&raquo;</a>`, base, max_page, ending)
} else {
pages += fmt.Sprintf(`<a href="%s%d%s" class="pagination-button">&rsaquo;</a>`, base, min(max_page, current_page+1), ending)
pages += fmt.Sprintf(`<a href="%s%d%s" class="pagination-button">&raquo;</a>`, base, max_page, ending)
}
return template.HTML(pages)
}
func GetTemplateFunctions() template.FuncMap {
return template.FuncMap{
"toInt": func(s string) int {
n, _ := strconv.Atoi(s)
return n
},
"parseEmojis": func(s string) template.HTML {
return ParseEmojis(s)
},
"parsePixivRedirect": func(s string) template.HTML {
return ParsePixivRedirect(s)
},
"escapeString": func(s string) string {
return EscapeString(s)
},
"randomColor": func() string {
return GetRandomColor()
},
"isEmpty": func(s string) bool {
return len(s) < 1
},
"isEmphasize": func(s string) bool {
switch s {
case
"R-18",
"R-18G":
return true
}
return false
},
"reformatDate": func(s string) string {
if len(s) != 8 {
return s
}
return fmt.Sprintf("%s-%s-%s", s[4:], s[2:4], s[:2])
},
"parseTime": func(date time.Time) string {
return ParseTime(date)
},
"createPaginator": func(base, ending string, current_page, max_page int) template.HTML {
return CreatePaginator(base, ending, current_page, max_page)
},
}
}

View file

@ -1,95 +0,0 @@
package handler
import (
"fmt"
"codeberg.org/vnpower/pixivfe/models"
"github.com/goccy/go-json"
)
func (p *PixivClient) GetLandingPage(mode string) (models.LandingArtworks, error) {
var context models.LandingArtworks
URL := fmt.Sprintf(LandingPageURL, mode)
response, err := p.PixivRequest(URL)
if err != nil {
return context, err
}
type IDS struct {
Ids []any `json:"ids"`
}
var pages struct {
Pixivision []models.Pixivision `json:"pixivision"`
Follow []any `json:"follow"`
Commission []any `json:"completeRequestIds"`
Newest []any `json:"newPost"`
Recommended IDS `json:"recommend"`
EditorRecommended []any `json:"editorRecommend"`
UserRecommended []any `json:"recommendUser"`
RecommendedByTags []struct {
models.LandingRecommendByTags
Ids []any `json:"ids"`
} `json:"recommendByTag"`
}
var body struct {
Thumbnails json.RawMessage `json:"thumbnails"`
Page json.RawMessage `json:"page"`
}
var artworks struct {
Artworks []models.IllustShort `json:"illust"`
}
err = json.Unmarshal([]byte(response), &body)
if err != nil {
return context, err
}
err = json.Unmarshal([]byte(body.Thumbnails), &artworks)
if err != nil {
return context, err
}
err = json.Unmarshal([]byte(body.Page), &pages)
if err != nil {
return context, err
}
context.Pixivision = pages.Pixivision
// Keep track
count := len(pages.Commission)
context.Commissions = artworks.Artworks[:count]
context.Following = artworks.Artworks[count : count+len(pages.Follow)]
count += len(pages.Follow)
context.Recommended = artworks.Artworks[count : count+len(pages.Recommended.Ids)]
count += len(pages.Recommended.Ids)
context.Newest = artworks.Artworks[count : count+len(pages.Newest)]
count += len(pages.Newest)
// For rankings, we just take 100 anyway
context.Rankings = artworks.Artworks[count : count+100]
count += 100
// IDK what this is
count += len(pages.EditorRecommended)
context.Users = artworks.Artworks[count : count+len(pages.UserRecommended)*3]
count += len(pages.UserRecommended) * 3
for i := 0; i < len(pages.RecommendedByTags); i++ {
temp := pages.RecommendedByTags[i]
temp.Artworks = artworks.Artworks[count : count+min(len(temp.Ids), 18)]
context.RecommendByTags = append(context.RecommendByTags, temp.LandingRecommendByTags)
count += len(temp.Ids)
}
return context, nil
}

View file

@ -1,240 +0,0 @@
package handler
import (
"errors"
"fmt"
"math"
"sort"
"strconv"
"codeberg.org/vnpower/pixivfe/models"
"github.com/goccy/go-json"
)
func (p *PixivClient) GetUserArtworksID(id string, category string, page int) (string, int, error) {
URL := fmt.Sprintf(UserArtworksURL, id)
response, err := p.PixivRequest(URL)
if err != nil {
return "", -1, err
}
var ids []int
var idsString string
var body struct {
Illusts json.RawMessage `json:"illusts"`
Mangas json.RawMessage `json:"manga"`
}
err = json.Unmarshal(response, &body)
if err != nil {
return "", -1, err
}
var illusts map[int]string
var mangas map[int]string
count := 0
if err = json.Unmarshal(body.Illusts, &illusts); err != nil {
illusts = make(map[int]string)
}
if err = json.Unmarshal(body.Mangas, &mangas); err != nil {
mangas = make(map[int]string)
}
// Get the keys, because Pixiv only returns IDs (very evil)
if category == "illustrations" || category == "artworks" {
for k := range illusts {
ids = append(ids, k)
count++
}
}
if category == "manga" || category == "artworks" {
for k := range mangas {
ids = append(ids, k)
count++
}
}
// Reverse sort the ids
sort.Sort(sort.Reverse(sort.IntSlice(ids)))
worksNumber := float64(count)
worksPerPage := 30.0
if page < 1 || float64(page) > math.Ceil(worksNumber/worksPerPage)+1.0 {
return "", -1, errors.New("Page overflow")
}
start := (page - 1) * int(worksPerPage)
end := int(min(float64(page)*worksPerPage, worksNumber)) // no overflow
for _, k := range ids[start:end] {
idsString += fmt.Sprintf("&ids[]=%d", k)
}
return idsString, count, nil
}
func (p *PixivClient) GetUserArtworks(id string, ids string) ([]models.IllustShort, error) {
var works []models.IllustShort
URL := fmt.Sprintf(UserArtworksFullURL, id, ids)
response, err := p.PixivRequest(URL)
if err != nil {
return nil, err
}
var body struct {
Illusts map[int]json.RawMessage `json:"works"`
}
err = json.Unmarshal(response, &body)
if err != nil {
return nil, err
}
for _, v := range body.Illusts {
var illust models.IllustShort
err = json.Unmarshal(v, &illust)
if err != nil {
return nil, err
}
works = append(works, illust)
}
return works, nil
}
func (p *PixivClient) GetUserBasicInformation(id string) (models.UserShort, error) {
var user models.UserShort
URL := fmt.Sprintf(UserBasicInformationURL, id)
response, err := p.PixivRequest(URL)
if err != nil {
return user, err
}
err = json.Unmarshal([]byte(response), &user)
if err != nil {
return user, err
}
return user, nil
}
func (p *PixivClient) GetUserInformation(id string, category string, page int) (*models.User, error) {
var user *models.User
URL := fmt.Sprintf(UserInformationURL, id)
response, err := p.PixivRequest(URL)
if err != nil {
return user, err
}
var body struct {
*models.User
Background map[string]interface{} `json:"background"`
}
// Basic user information
err = json.Unmarshal([]byte(response), &body)
if err != nil {
return nil, err
}
user = body.User
if category != "bookmarks" {
// Artworks
ids, count, err := p.GetUserArtworksID(id, category, page)
if err != nil {
return nil, err
}
works, _ := p.GetUserArtworks(id, ids)
// IDK but the order got shuffled even though Pixiv sorted the IDs in the response
sort.Slice(works[:], func(i, j int) bool {
left, _ := strconv.Atoi(works[i].ID)
right, _ := strconv.Atoi(works[j].ID)
return left > right
})
user.Artworks = works
// Artworks count
user.ArtworksCount = count
// Frequent tags
user.FrequentTags, err = p.GetFrequentTags(ids)
if err != nil {
return nil, err
}
} else {
// Bookmarks
works, count, err := p.GetUserBookmarks(id, "show", page)
if err != nil {
return nil, err
}
user.Artworks = works
// Public bookmarks count
user.ArtworksCount = count
// Parse social medias
}
user.ParseSocial()
// Background image
if body.Background != nil {
user.BackgroundImage = body.Background["url"].(string)
}
return user, nil
}
func (p *PixivClient) GetUserBookmarks(id string, mode string, page int) ([]models.IllustShort, int, error) {
page--
URL := fmt.Sprintf(UserBookmarksURL, id, page*48, mode)
response, err := p.PixivRequest(URL)
if err != nil {
return nil, -1, err
}
var body struct {
Artworks []json.RawMessage `json:"works"`
Total int `json:"total"`
}
err = json.Unmarshal([]byte(response), &body)
if err != nil {
return nil, -1, err
}
artworks := make([]models.IllustShort, len(body.Artworks))
for index, value := range body.Artworks {
var artwork models.IllustShort
err = json.Unmarshal([]byte(value), &artwork)
if err != nil {
artworks[index] = models.IllustShort{
ID: "#",
Title: "Deleted or Private",
Thumbnail: "https://s.pximg.net/common/images/limit_unknown_360.png",
}
continue
}
artworks[index] = artwork
}
return artworks, body.Total, nil
}

261
main.go
View file

@ -1,28 +1,62 @@
package main
import (
"context"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"syscall"
"time"
"codeberg.org/vnpower/pixivfe/configs"
"codeberg.org/vnpower/pixivfe/handler"
"codeberg.org/vnpower/pixivfe/views"
config "codeberg.org/vnpower/pixivfe/v2/core/config"
"codeberg.org/vnpower/pixivfe/v2/pages"
"codeberg.org/vnpower/pixivfe/v2/serve"
"github.com/goccy/go-json"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cache"
"github.com/gofiber/fiber/v2/middleware/compress"
"github.com/gofiber/fiber/v2/middleware/limiter"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/gofiber/fiber/v2/utils"
"github.com/gofiber/template/jet/v2"
"codeberg.org/vnpower/pixivfe/v2/core/kmutex"
)
func setup_router() *fiber.App {
// HTML templates, automatically loaded
engine := jet.New("./template", ".jet.html")
func CanRequestSkipLimiter(c *fiber.Ctx) bool {
path := c.Path()
return strings.HasPrefix(path, "/assets/") ||
strings.HasPrefix(path, "/css/") ||
strings.HasPrefix(path, "/js/") ||
strings.HasPrefix(path, "/proxy/s.pximg.net/")
}
engine.AddFuncMap(handler.GetTemplateFunctions())
func CanRequestSkipLogger(c *fiber.Ctx) bool {
path := c.Path()
return CanRequestSkipLimiter(c) ||
strings.HasPrefix(path, "/proxy/i.pximg.net/")
}
func main() {
config.SetupStorage()
config.GlobalServerConfig.InitializeConfig()
engine := jet.New("./views", ".jet.html")
engine.AddFuncMap(serve.GetTemplateFunctions())
if config.GlobalServerConfig.InDevelopment {
engine.Reload(true)
}
// gofiber bug: no error even if the templates are invalid???
err := engine.Load()
if err != nil {
panic(err)
}
server := fiber.New(fiber.Config{
AppName: "PixivFE",
@ -36,6 +70,8 @@ func setup_router() *fiber.App {
TrustedProxies: []string{"0.0.0.0/0"},
ProxyHeader: fiber.HeaderXForwardedFor,
ErrorHandler: func(c *fiber.Ctx, err error) error {
log.Println(err)
// Status code defaults to 500
code := fiber.StatusInternalServerError
@ -48,98 +84,195 @@ func setup_router() *fiber.App {
// Send custom error page
err = c.Status(code).Render("pages/error", fiber.Map{"Title": "Error", "Error": err})
if err != nil {
// In case the SendFile fails
return c.Status(fiber.StatusInternalServerError).SendString("Internal Server Error")
return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("Internal Server Error: %s", err))
}
// Return from handler
return nil
},
})
server.Use(logger.New())
server.Use(cache.New(
cache.Config{
Next: func(c *fiber.Ctx) bool {
resp_code := c.Response().StatusCode()
if resp_code < 200 || resp_code >= 300 {
return true
}
if c.Path() == "/" {
return true
}
// Disable cache for settings page
if strings.Contains(c.Path(), "/settings") {
return true
}
return false
},
Expiration: 5 * time.Minute,
CacheControl: true,
keyedSleepingSpot := kmutex.New()
server.Use(limiter.New(limiter.Config{
Next: CanRequestSkipLimiter,
Expiration: 30 * time.Second,
Max: config.GlobalServerConfig.RequestLimit,
LimiterMiddleware: limiter.SlidingWindow{},
LimitReached: func(c *fiber.Ctx) error {
// limit response throughput by pacing, since not every bot reads X-RateLimit-*
// on limit reached, they just have to wait
// the design of this means that if they send multiple requests when reaching rate limit, they will wait even longer (since `retryAfter` is calculated before anything has slept)
retryAfter_s := c.GetRespHeader(fiber.HeaderRetryAfter)
retryAfter, err := strconv.ParseUint(retryAfter_s, 10, 64)
if err != nil {
log.Panicf("response header 'RetryAfter' should be a number: %v", err)
}
requestIP := c.IP()
refcount := keyedSleepingSpot.Lock(requestIP)
defer keyedSleepingSpot.Unlock(requestIP)
if refcount >= 4 { // on too much concurrent requests
// todo: maybe blackhole `requestIP` here
log.Println("Limit Reached!")
return fmt.Errorf("Woah! You are going too fast! I'll have to keep an eye on you.")
}
ctx, cancel := context.WithTimeout(c.Context(), time.Duration(retryAfter)*time.Second)
defer cancel()
<-ctx.Done()
return c.Next()
KeyGenerator: func(c *fiber.Ctx) string {
return utils.CopyString(c.OriginalURL())
},
}))
server.Use(logger.New(
logger.Config{
Format: "${time} ~${latencySI} ${ip} ${method} ${path} ${status} ${error} \n",
Next: CanRequestSkipLogger,
EnableLatency: true,
CustomTags: map[string]logger.LogFunc{
"latencySI": func(output logger.Buffer, c *fiber.Ctx, data *logger.Data, extraParam string) (int, error) {
latency := data.Stop.Sub(data.Start).Seconds()
return output.WriteString(fmt.Sprintf("%.13f", latency))
},
},
},
))
server.Use(recover.New())
server.Use(compress.New(compress.Config{
Level: compress.LevelBestSpeed, // 1
}))
// Global headers (from GotHub)
if !config.GlobalServerConfig.InDevelopment {
server.Use(cache.New(
cache.Config{
Next: func(c *fiber.Ctx) bool {
resp_code := c.Response().StatusCode()
if resp_code < 200 || resp_code >= 300 {
return true
}
// Disable cache for settings page
return strings.Contains(c.Path(), "/settings") || c.Path() == "/"
},
Expiration: 5 * time.Minute,
CacheControl: true,
KeyGenerator: func(c *fiber.Ctx) string {
return utils.CopyString(c.OriginalURL())
},
},
))
}
// Global HTTP headers
server.Use(func(c *fiber.Ctx) error {
c.Set("X-Frame-Options", "SAMEORIGIN")
c.Set("X-XSS-Protection", "1; mode=block")
c.Set("X-Frame-Options", "DENY")
// use this if need iframe: `X-Frame-Options: SAMEORIGIN`
c.Set("X-Content-Type-Options", "nosniff")
c.Set("Referrer-Policy", "no-referrer")
c.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
c.Set("Content-Security-Policy", fmt.Sprintf("base-uri 'self'; default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' %s; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; ", config.GetImageProxyOrigin(c)))
// use this if need iframe: `frame-ancestors 'self'`
return c.Next()
})
server.Use(func(c *fiber.Ctx) error {
var baseURL string
if configs.BaseURL != "localhost" {
baseURL = "https://" + configs.BaseURL
}
c.Bind(fiber.Map{"FullURL": baseURL + c.OriginalURL(), "BaseURL": baseURL})
baseURL := c.BaseURL() + c.OriginalURL()
c.Bind(fiber.Map{"BaseURL": baseURL})
return c.Next()
})
// Static files
server.Static("/favicon.ico", "./template/favicon.ico")
server.Static("css/", "./template/css")
server.Static("assets/", "./template/assets")
server.Static("/robots.txt", "./template/robots.txt")
server.Static("/favicon.ico", "./views/assets/favicon.ico")
server.Static("/robots.txt", "./views/assets/robots.txt")
server.Static("/assets/", "./views/assets")
server.Static("/css/", "./views/css")
server.Static("/js/", "./views/js")
// Routes/Views
views.SetupRoutes(server)
server.Use(recover.New(recover.Config{EnableStackTrace: config.GlobalServerConfig.InDevelopment}))
// Disable trusted proxies since we do not use any for now
// server.SetTrustedProxies(nil)
// Routes
return server
}
server.Get("/", pages.IndexPage)
server.Get("/about", pages.AboutPage)
server.Get("/newest", pages.NewestPage)
server.Get("/discovery", pages.DiscoveryPage)
server.Get("/discovery/novel", pages.NovelDiscoveryPage)
server.Get("/ranking", pages.RankingPage)
server.Get("/rankingCalendar", pages.RankingCalendarPage)
server.Post("/rankingCalendar", pages.RankingCalendarPicker)
server.Get("/users/:id/:category?", pages.UserPage)
server.Get("/artworks/:id/", pages.ArtworkPage).Name("artworks")
server.Get("/artworks/:id/embed", pages.ArtworkEmbedPage)
server.Get("/artworks-multi/:ids/", pages.ArtworkMultiPage)
server.Get("/novel/:id/", pages.NovelPage)
func main() {
err := configs.ParseConfig()
configs.SetupStorage()
// Settings group
settings := server.Group("/settings")
settings.Get("/", pages.SettingsPage)
settings.Post("/:type", pages.SettingsPost)
if err != nil {
panic(err)
// Personal group
self := server.Group("/self")
self.Get("/", pages.LoginUserPage)
self.Get("/followingWorks", pages.FollowingWorksPage)
self.Get("/bookmarks", pages.LoginBookmarkPage)
self.Post("/addBookmark/:id", pages.AddBookmarkRoute)
self.Post("/deleteBookmark/:id", pages.DeleteBookmarkRoute)
self.Post("/like/:id", pages.LikeRoute)
server.Get("/tags/:name", pages.TagPage)
server.Post("/tags/:name", pages.TagPage)
server.Post("/tags",
func(c *fiber.Ctx) error {
name := c.FormValue("name")
return c.Redirect("/tags/"+name, http.StatusFound)
})
// Legacy illust URL
server.Get("/member_illust.php", func(c *fiber.Ctx) error {
return c.Redirect("/artworks/" + c.Query("illust_id"))
})
// Proxy routes
proxy := server.Group("/proxy")
proxy.Get("/i.pximg.net/*", pages.IPximgProxy)
proxy.Get("/s.pximg.net/*", pages.SPximgProxy)
proxy.Get("/ugoira.com/*", pages.UgoiraProxy)
// run sass when in development mode
if config.GlobalServerConfig.InDevelopment {
go func() {
cmd := exec.Command("sass", "--watch", "views/css")
cmd.Stdout = os.Stderr // Sass quirk
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGHUP}
runtime.LockOSThread() // Go quirk https://github.com/golang/go/issues/27505
err := cmd.Run()
if err != nil {
log.Println(fmt.Errorf("when running sass: %w", err))
}
}()
}
r := setup_router()
if strings.Contains(configs.Port, "/") {
ln, err := net.Listen("unix", configs.Port)
// Listen
if config.GlobalServerConfig.UnixSocket != "" {
ln, err := net.Listen("unix", config.GlobalServerConfig.UnixSocket)
if err != nil {
panic("Failed to listen to " + configs.Port)
panic(err)
}
log.Printf("PixivFE is running on %v\n", config.GlobalServerConfig.UnixSocket)
err = server.Listener(ln)
if err != nil {
panic(err)
}
} else {
addr := config.GlobalServerConfig.Host + ":" + config.GlobalServerConfig.Port
log.Printf("PixivFE is running on http://%v/\n", addr)
// note: string concatenation is very flaky
err := server.Listen(addr)
if err != nil {
panic(err)
}
r.Listener(ln)
}
println("PixivFE is up and running on port " + configs.Port + "!")
r.Listen(":" + configs.Port)
}

View file

@ -1,56 +0,0 @@
package models
import (
"regexp"
"strings"
)
func ProxyImage(URL string, target string) string {
if strings.Contains(URL, "s.pximg.net") {
// This subdomain didn't get proxied
return URL
}
regex := regexp.MustCompile(`.*?pximg\.net`)
proxy := "https://" + target
return regex.ReplaceAllString(URL, proxy)
}
func ProxyShortArtworkSlice(artworks []IllustShort, proxy string) []IllustShort {
for i := range artworks {
artworks[i].Thumbnail = ProxyImage(artworks[i].Thumbnail, proxy)
artworks[i].ArtistAvatar = ProxyImage(artworks[i].ArtistAvatar, proxy)
}
return artworks
}
func ProxyRecommendedByTagsSlice(artworks []LandingRecommendByTags, proxy string) []LandingRecommendByTags {
for i := range artworks {
artworks[i].Artworks = ProxyShortArtworkSlice(artworks[i].Artworks, proxy)
}
return artworks
}
func ProxyRankedArtworkSlice(artworks []RankedArtwork, proxy string) []RankedArtwork {
for i := range artworks {
artworks[i].Image = ProxyImage(artworks[i].Image, proxy)
artworks[i].ArtistAvatar = ProxyImage(artworks[i].ArtistAvatar, proxy)
}
return artworks
}
func ProxyCommentsSlice(comments []Comment, proxy string) []Comment {
for i := range comments {
comments[i].Avatar = ProxyImage(comments[i].Avatar, proxy)
}
return comments
}
func ProxyPixivisionSlice(articles []Pixivision, proxy string) []Pixivision {
for i := range articles {
articles[i].Thumbnail = ProxyImage(articles[i].Thumbnail, proxy)
}
return articles
}

View file

@ -1,268 +0,0 @@
package models
import (
"html/template"
"time"
"encoding/json"
)
type PaginationData struct {
PreviousPage int
CurrentPage int
NextPage int
}
type PixivResponse struct {
Error bool
Message string
Body json.RawMessage
}
type RankingResponse struct {
Artworks []RankedArtwork `json:"contents"`
Mode string `json:"mode"`
Content string `json:"content"`
CurrentDate string `json:"date"`
PrevDateRaw json.RawMessage `json:"prev_date"`
NextDateRaw json.RawMessage `json:"next_date"`
PrevDate string
NextDate string
}
func (s *RankingResponse) ProxyImages(proxy string) {
s.Artworks = ProxyRankedArtworkSlice(s.Artworks, proxy)
}
type ImageResponse struct {
Urls map[string]string `json:"urls"`
}
type TagResponse struct {
AuthorID string `json:"authorId"`
RawTags json.RawMessage `json:"tags"`
}
// Pixiv returns 0, 1, 2 to filter SFW and/or NSFW artworks.
// Those values are saved in `xRestrict`
// 0: Safe
// 1: R18
// 2: R18G
type xRestrict int
const (
Safe xRestrict = 0
R18 xRestrict = 1
R18G xRestrict = 2
)
var xRestrictModel = map[xRestrict]string{
Safe: "",
R18: "R18",
R18G: "R18G",
}
// Pixiv returns 0, 1, 2 to filter SFW and/or NSFW artworks.
// Those values are saved in `aiType`
// 0: Not rated / Unknown
// 1: Not AI-generated
// 2: AI-generated
type aiType int
const (
Unrated aiType = 0
NotAI aiType = 1
AI aiType = 2
)
var aiTypeModel = map[aiType]string{
Unrated: "Unrated",
NotAI: "Not AI",
AI: "AI",
}
// Pixiv gives us 5 types of an image. I don't need the mini one tho.
// PS: Where tf is my 360x360 image, Pixiv?
type Image struct {
Small string `json:"thumb_mini"`
Medium string `json:"small"`
Large string `json:"regular"`
Original string `json:"original"`
}
type Tag struct {
Name string `json:"tag"`
TranslatedName string `json:"translation"`
}
type FrequentTag struct {
Name string `json:"tag"`
TranslatedName string `json:"tag_translation"`
}
type Illust struct {
ID string `json:"id"`
Title string `json:"title"`
Description template.HTML `json:"description"`
UserID string `json:"userId"`
UserName string `json:"userName"`
UserAccount string `json:"userAccount"`
Date time.Time `json:"uploadDate"`
Images []Image `json:"images"`
Tags []Tag `json:"tags"`
Pages int `json:"pageCount"`
Bookmarks int `json:"bookmarkCount"`
Likes int `json:"likeCount"`
Comments int `json:"commentCount"`
Views int `json:"viewCount"`
CommentDisabled int `json:"commentOff"`
SanityLevel int `json:"sl"`
XRestrict xRestrict `json:"xRestrict"`
AiType aiType `json:"aiType"`
User UserShort
RecentWorks []IllustShort
RelatedWorks []IllustShort
CommentsList []Comment
IsUgoira bool
}
func (s *Illust) ProxyImages(proxy string) {
for i := range s.Images {
s.Images[i].Small = ProxyImage(s.Images[i].Small, proxy)
s.Images[i].Medium = ProxyImage(s.Images[i].Medium, proxy)
s.Images[i].Large = ProxyImage(s.Images[i].Large, proxy)
s.Images[i].Original = ProxyImage(s.Images[i].Original, proxy)
}
for i := range s.RecentWorks {
s.RecentWorks[i].Thumbnail = ProxyImage(s.RecentWorks[i].Thumbnail, proxy)
}
s.RelatedWorks = ProxyShortArtworkSlice(s.RelatedWorks, proxy)
s.CommentsList = ProxyCommentsSlice(s.CommentsList, proxy)
s.User.Avatar = ProxyImage(s.User.Avatar, proxy)
}
type IllustShort struct {
ID string `json:"id"`
Title string `json:"title"`
Description template.HTML `json:"description"`
ArtistID string `json:"userId"`
ArtistName string `json:"userName"`
ArtistAvatar string `json:"profileImageUrl"`
Date time.Time `json:"uploadDate"`
Thumbnail string `json:"url"`
Pages int `json:"pageCount"`
XRestrict xRestrict `json:"xRestrict"`
AiType aiType `json:"aiType"`
}
type Comment struct {
AuthorID string `json:"userId"`
AuthorName string `json:"userName"`
Avatar string `json:"img"`
Context string `json:"comment"`
Stamp string `json:"stampId"`
Date string `json:"commentDate"`
}
type User struct {
ID string `json:"userId"`
Name string `json:"name"`
Avatar string `json:"imageBig"`
BackgroundImage string `json:"background"`
Following int `json:"following"`
MyPixiv int `json:"mypixivCount"`
Comment template.HTML `json:"commentHtml"`
Webpage string `json:"webpage"`
SocialRaw json.RawMessage `json:"social"`
Artworks []IllustShort `json:"artworks"`
ArtworksCount int
FrequentTags []FrequentTag
Social map[string]map[string]string
}
func (s *User) ProxyImages(proxy string) {
s.Avatar = ProxyImage(s.Avatar, proxy)
s.BackgroundImage = ProxyImage(s.BackgroundImage, proxy)
s.Artworks = ProxyShortArtworkSlice(s.Artworks, proxy)
}
func (s *User) ParseSocial() {
if string(s.SocialRaw[:]) == "[]" {
// Fuck Pixiv
return
}
_ = json.Unmarshal(s.SocialRaw, &s.Social)
}
type UserShort struct {
ID string `json:"userId"`
Name string `json:"name"`
Avatar string `json:"imageBig"`
}
type RankedArtwork struct {
ID int `json:"illust_id"`
Title string `json:"title"`
Rank int `json:"rank"`
Pages string `json:"illust_page_count"`
Image string `json:"url"`
ArtistID int `json:"user_id"`
ArtistName string `json:"user_name"`
ArtistAvatar string `json:"profile_img"`
}
type TagDetail struct {
Name string `json:"tag"`
AlternativeName string `json:"word"`
Metadata struct {
Detail string `json:"abstract"`
Image string `json:"image"`
Name string `json:"tag"`
ID json.Number `json:"id"`
} `json:"pixpedia"`
}
type SearchArtworks struct {
Artworks []IllustShort `json:"data"`
Total int `json:"total"`
}
type SearchResult struct {
Artworks SearchArtworks
Popular struct {
Permanent []IllustShort `json:"permanent"`
Recent []IllustShort `json:"recent"`
} `json:"popular"`
RelatedTags []string `json:"relatedTags"`
}
func (s *SearchResult) ProxyImages(proxy string) {
s.Artworks.Artworks = ProxyShortArtworkSlice(s.Artworks.Artworks, proxy)
s.Popular.Permanent = ProxyShortArtworkSlice(s.Popular.Permanent, proxy)
s.Popular.Recent = ProxyShortArtworkSlice(s.Popular.Recent, proxy)
}
type Pixivision struct {
ID string `json:"id"`
Title string `json:"title"`
Thumbnail string `json:"thumbnailUrl"`
URL string `json:"url"`
}
type LandingRecommendByTags struct {
Name string `json:"tag"`
Artworks []IllustShort
}
type LandingArtworks struct {
Commissions []IllustShort
Following []IllustShort
Recommended []IllustShort
Newest []IllustShort
Rankings []IllustShort
Users []IllustShort
Pixivision []Pixivision
RecommendByTags []LandingRecommendByTags
}

View file

@ -1,32 +0,0 @@
server {
server_name changethis;
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
ssl_certificate /etc/letsencrypt/live/changethis/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/changethis/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
resolver 1.1.1.1;
ssl_trusted_certificate /etc/letsencrypt/live/changethis/chain.pem;
ssl_stapling on;
ssl_stapling_verify on;
access_log /dev/null;
error_log /dev/null;
location / {
proxy_set_header X-Forwarded-For $remote_addr;
proxy_pass http://localhost:8282;
}
}
server {
listen 80;
listen [::]:80;
server_name changethis;
return 301 https://changethis$request_uri;
}

16
pages/about.go Normal file
View file

@ -0,0 +1,16 @@
package pages
import (
"codeberg.org/vnpower/pixivfe/v2/core/config"
"github.com/gofiber/fiber/v2"
)
func AboutPage(c *fiber.Ctx) error {
info := fiber.Map{
"Time": core.GlobalServerConfig.StartingTime,
"Version": core.GlobalServerConfig.Version,
"ImageProxy": core.GlobalServerConfig.ProxyServer.String(),
"AcceptLanguage": core.GlobalServerConfig.AcceptLanguage,
}
return c.Render("pages/about", info)
}

124
pages/actions.go Normal file
View file

@ -0,0 +1,124 @@
package pages
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
session "codeberg.org/vnpower/pixivfe/v2/core/config"
"github.com/gofiber/fiber/v2"
"github.com/tidwall/gjson"
)
func pixivPostRequest(url, payload, token, csrf string) error {
requestBody := []byte(payload)
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(requestBody))
req.Header.Add("User-Agent", "Mozilla/5.0")
req.Header.Add("Accept", "application/json")
req.Header.Add("Content-Type", "application/json; charset=utf-8")
req.Header.Add("Cookie", "PHPSESSID="+token)
req.Header.Add("x-csrf-token", csrf)
// req.AddCookie(&http.Cookie{
// Name: "PHPSESSID",
// Value: token,
// })
resp, err := http.DefaultClient.Do(req)
if err != nil {
return errors.New("Failed to do this action.")
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return errors.New("Cannot parse the response from Pixiv. Please report this issue.")
}
body_s := string(body)
if !gjson.Valid(body_s) {
return fmt.Errorf("invalid json: %v", body_s)
}
errr := gjson.Get(body_s, "error")
if !errr.Exists() {
return errors.New("Incompatible request body.")
}
if errr.Bool() {
return errors.New("Pixiv: Invalid request.")
}
return nil
}
func AddBookmarkRoute(c *fiber.Ctx) error {
token := session.CheckToken(c)
csrf := session.GetCSRFToken(c)
if token == "" || csrf == "" {
return c.Redirect("/login")
}
id := c.Params("id")
if id == "" {
return errors.New("No ID provided.")
}
URL := "https://www.pixiv.net/ajax/illusts/bookmarks/add"
payload := fmt.Sprintf(`{
"illust_id": "%s",
"restrict": 0,
"comment": "",
"tags": []
}`, id)
if err := pixivPostRequest(URL, payload, token, csrf); err != nil {
return err
}
return c.SendString("Success")
}
func DeleteBookmarkRoute(c *fiber.Ctx) error {
token := session.CheckToken(c)
csrf := session.GetCSRFToken(c)
if token == "" || csrf == "" {
return c.Redirect("/login")
}
id := c.Params("id")
if id == "" {
return errors.New("No ID provided.")
}
// You can't unlike
URL := "https://www.pixiv.net/ajax/illusts/bookmarks/delete"
payload := fmt.Sprintf(`bookmark_id=%s`, id)
if err := pixivPostRequest(URL, payload, token, csrf); err != nil {
return err
}
return c.SendString("Success")
}
func LikeRoute(c *fiber.Ctx) error {
token := session.CheckToken(c)
csrf := session.GetCSRFToken(c)
if token == "" || csrf == "" {
return c.Redirect("/login")
}
id := c.Params("id")
if id == "" {
return errors.New("No ID provided.")
}
URL := "https://www.pixiv.net/ajax/illusts/like"
payload := fmt.Sprintf(`{"illust_id": "%s"}`, id)
if err := pixivPostRequest(URL, payload, token, csrf); err != nil {
return err
}
return c.SendString("Success")
}

52
pages/artwork-multi.go Normal file
View file

@ -0,0 +1,52 @@
package pages
import (
"errors"
"fmt"
"strconv"
"strings"
"sync"
core "codeberg.org/vnpower/pixivfe/v2/core/webapi"
"github.com/gofiber/fiber/v2"
)
func ArtworkMultiPage(c *fiber.Ctx) error {
param_ids := c.Params("ids")
ids := strings.Split(param_ids, ",")
artworks := make([]*core.Illust, len(ids))
wg := sync.WaitGroup{}
wg.Add(len(ids))
for i, id := range ids {
if _, err := strconv.Atoi(id); err != nil {
return errors.New("invalid id")
}
go func(i int, id string) {
defer wg.Done()
illust, err := core.GetArtworkByID(c, id, false)
if err != nil {
artworks[i] = &core.Illust{
Title: err.Error(), // this might be flaky
}
return
}
metaDescription := ""
for _, i := range illust.Tags {
metaDescription += "#" + i.Name + ", "
}
artworks[i] = illust
}(i, id)
}
wg.Wait()
return c.Render("pages/artwork-multi", fiber.Map{
"Artworks": artworks,
"Title": fmt.Sprintf("(%d images)", len(artworks)),
})
}

59
pages/artwork.go Normal file
View file

@ -0,0 +1,59 @@
package pages
import (
"errors"
"strconv"
core "codeberg.org/vnpower/pixivfe/v2/core/webapi"
"github.com/gofiber/fiber/v2"
)
func ArtworkPage(c *fiber.Ctx) error {
param_id := c.Params("id")
if _, err := strconv.Atoi(param_id); err != nil {
return errors.New("invalid id")
}
illust, err := core.GetArtworkByID(c, param_id, true)
if err != nil {
return err
}
metaDescription := ""
for _, i := range illust.Tags {
metaDescription += "#" + i.Name + ", "
}
// todo: passing ArtWorkData{} here will not work. maybe lowercase?
return c.Render("pages/artwork", fiber.Map{
"Illust": illust,
"Title": illust.Title,
"MetaDescription": metaDescription,
"MetaImage": illust.Images[0].Original,
})
}
func ArtworkEmbedPage(c *fiber.Ctx) error {
param_id := c.Params("id")
if _, err := strconv.Atoi(param_id); err != nil {
return errors.New("invalid id")
}
illust, err := core.GetArtworkByID(c, param_id, false)
if err != nil {
return err
}
metaDescription := ""
for _, i := range illust.Tags {
metaDescription += "#" + i.Name + ", "
}
// todo: passing ArtWorkData{} here will not work. maybe lowercase?
return c.Render("embed", fiber.Map{
"Illust": illust,
"Title": illust.Title,
"MetaDescription": metaDescription,
"MetaImage": illust.Images[0].Original,
}, "embed")
}

34
pages/discovery.go Normal file
View file

@ -0,0 +1,34 @@
package pages
import (
core "codeberg.org/vnpower/pixivfe/v2/core/webapi"
"github.com/gofiber/fiber/v2"
)
func DiscoveryPage(c *fiber.Ctx) error {
mode := c.Query("mode", "safe")
works, err := core.GetDiscoveryArtwork(c, mode)
if err != nil {
return err
}
return c.Render("pages/discovery", fiber.Map{
"Artworks": works,
"Title": "Discovery",
})
}
func NovelDiscoveryPage(c *fiber.Ctx) error {
mode := c.Query("mode", "safe")
works, err := core.GetDiscoveryNovels(c, mode)
if err != nil {
return err
}
return c.Render("pages/novelDiscovery", fiber.Map{
"Novels": works,
"Title": "Discovery",
})
}

34
pages/index.go Normal file
View file

@ -0,0 +1,34 @@
package pages
import (
session "codeberg.org/vnpower/pixivfe/v2/core/config"
core "codeberg.org/vnpower/pixivfe/v2/core/webapi"
"github.com/gofiber/fiber/v2"
)
func IndexPage(c *fiber.Ctx) error {
// If token is set, do the landing request...
if token := session.CheckToken(c); token != "" {
mode := c.Query("mode", "all")
works, err := core.GetLanding(c, mode)
if err != nil {
return err
}
return c.Render("pages/index", fiber.Map{
"Title": "Landing", "Data": works,
})
}
// ...otherwise, default to today's illustration ranking
works, err := core.GetRanking(c, "daily", "illust", "", "1")
if err != nil {
return err
}
return c.Render("pages/index", fiber.Map{
"Title": "Landing", "NoTokenData": works,
})
}

22
pages/newest.go Normal file
View file

@ -0,0 +1,22 @@
package pages
import (
core "codeberg.org/vnpower/pixivfe/v2/core/webapi"
"github.com/gofiber/fiber/v2"
)
func NewestPage(c *fiber.Ctx) error {
worktype := c.Query("type", "illust")
r18 := c.Query("r18", "false")
works, err := core.GetNewestArtworks(c, worktype, r18)
if err != nil {
return err
}
return c.Render("pages/newest", fiber.Map{
"Items": works,
"Title": "Newest works",
})
}

27
pages/novel.go Normal file
View file

@ -0,0 +1,27 @@
package pages
import (
"errors"
"strconv"
core "codeberg.org/vnpower/pixivfe/v2/core/webapi"
"github.com/gofiber/fiber/v2"
)
func NovelPage(c *fiber.Ctx) error {
param_id := c.Params("id")
if _, err := strconv.Atoi(param_id); err != nil {
return errors.New("invalid id")
}
novel, err := core.GetNovelByID(c, param_id)
if err != nil {
return err
}
// todo: passing ArtWorkData{} here will not work. maybe lowercase?
return c.Render("pages/novel", fiber.Map{
"Novel": novel,
"Title": novel.Title,
})
}

61
pages/personal.go Normal file
View file

@ -0,0 +1,61 @@
package pages
import (
"strconv"
"strings"
session "codeberg.org/vnpower/pixivfe/v2/core/config"
core "codeberg.org/vnpower/pixivfe/v2/core/webapi"
"github.com/gofiber/fiber/v2"
)
func LoginUserPage(c *fiber.Ctx) error {
token := session.CheckToken(c)
if token == "" {
return c.Redirect("/settings")
}
// The left part of the token is the member ID
userId := strings.Split(token, "_")
c.Redirect("/users/" + userId[0])
return nil
}
func LoginBookmarkPage(c *fiber.Ctx) error {
token := session.CheckToken(c)
if token == "" {
return c.Redirect("/settings")
}
// The left part of the token is the member ID
userId := strings.Split(token, "_")
c.Redirect("/users/" + userId[0] + "/bookmarks#checkpoint")
return nil
}
func FollowingWorksPage(c *fiber.Ctx) error {
if token := session.CheckToken(c); token == "" {
return c.Redirect("/settings")
}
mode := c.Query("mode", "all")
page := c.Query("page", "1")
pageInt, _ := strconv.Atoi(page)
works, err := core.GetNewestFromFollowing(c, mode, page)
if err != nil {
return err
}
return c.Render("pages/following", fiber.Map{
"Title": "Following works",
"Mode": mode,
"Artworks": works,
"CurPage": page,
"Page": pageInt,
})
}

82
pages/proxy.go Normal file
View file

@ -0,0 +1,82 @@
package pages
import (
"fmt"
"io"
"net/http"
"github.com/gofiber/fiber/v2"
)
func SPximgProxy(c *fiber.Ctx) error {
URL := fmt.Sprintf("https://s.pximg.net/%s", c.Params("*"))
req, err := http.NewRequest("GET", URL, nil)
if err != nil {
return err
}
// Make the request
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
c.Set("Content-Type", resp.Header.Get("Content-Type"))
return c.Send([]byte(body))
}
func IPximgProxy(c *fiber.Ctx) error {
URL := fmt.Sprintf("https://i.pximg.net/%s", c.Params("*"))
req, err := http.NewRequest("GET", URL, nil)
if err != nil {
return err
}
req.Header.Add("Referer", "https://www.pixiv.net/")
// Make the request
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
c.Set("Content-Type", resp.Header.Get("Content-Type"))
return c.Send([]byte(body))
}
func UgoiraProxy(c *fiber.Ctx) error {
URL := fmt.Sprintf("https://ugoira.com/api/mp4/%s", c.Params("*"))
req, err := http.NewRequest("GET", URL, nil)
if err != nil {
return err
}
// Make the request
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
c.Set("Content-Type", resp.Header.Get("Content-Type"))
return c.Send([]byte(body))
}

35
pages/ranking.go Normal file
View file

@ -0,0 +1,35 @@
package pages
import (
"strconv"
core "codeberg.org/vnpower/pixivfe/v2/core/webapi"
"github.com/gofiber/fiber/v2"
)
func RankingPage(c *fiber.Ctx) error {
mode := c.Query("mode", "daily")
content := c.Query("content", "all")
date := c.Query("date", "")
page := c.Query("page", "1")
pageInt, err := strconv.Atoi(page)
if err != nil {
panic(err)
}
works, err := core.GetRanking(c, mode, content, date, page)
if err != nil {
return err
}
return c.Render("pages/rank", fiber.Map{
"Title": "Ranking",
"Page": pageInt,
"PageLimit": 10, // hard-coded by pixiv
"Mode": mode,
"Content": content,
"Date": date,
"Data": works,
})
}

90
pages/rankingCalendar.go Normal file
View file

@ -0,0 +1,90 @@
package pages
import (
"fmt"
"strconv"
"time"
core "codeberg.org/vnpower/pixivfe/v2/core/webapi"
"github.com/gofiber/fiber/v2"
)
type DateWrap struct {
Link string
Year int
Month int
MonthPadded string
MonthLiteral string
}
func parseDate(t time.Time) DateWrap {
var d DateWrap
year := t.Year()
month := t.Month()
monthPadded := fmt.Sprintf("%02d", month)
d.Link = fmt.Sprintf("%d-%s-01", year, monthPadded)
d.Year = year
d.Month = int(month)
d.MonthPadded = monthPadded
d.MonthLiteral = month.String()
return d
}
func RankingCalendarPicker(c *fiber.Ctx) error {
mode := c.FormValue("mode", "daily")
date := c.FormValue("date", "")
return c.RedirectToRoute("/rankingCalendar", fiber.Map{
"queries": map[string]string{
"mode": mode,
"date": date,
},
})
}
func RankingCalendarPage(c *fiber.Ctx) error {
mode := c.Query("mode", "daily")
date := c.Query("date", "")
var year int
var month int
// If the user supplied a date
if len(date) == 10 {
var err error
year, err = strconv.Atoi(date[:4])
if err != nil {
return err
}
month, err = strconv.Atoi(date[5:7])
if err != nil {
return err
}
} else {
now := time.Now()
year = now.Year()
month = int(now.Month())
}
realDate := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
monthBefore := realDate.AddDate(0, -1, 0)
monthAfter := realDate.AddDate(0, 1, 0)
render, err := core.GetRankingCalendar(c, mode, year, month)
if err != nil {
return err
}
return c.Render("pages/rankingCalendar", fiber.Map{
"Title": "Ranking calendar",
"Render": render,
"Mode": mode,
"Year": year,
"MonthBefore": parseDate(monthBefore),
"MonthAfter": parseDate(monthAfter),
"ThisMonth": parseDate(realDate),
})
}

34
pages/search.go Normal file
View file

@ -0,0 +1,34 @@
package pages
// import (
// session "codeberg.org/vnpower/pixivfe/v2/core/config"
// core "codeberg.org/vnpower/pixivfe/v2/core/webapi"
// "github.com/gofiber/fiber/v2"
// )
// func IndexPage(c *fiber.Ctx) error {
// // If token is set, do the landing request...
// if token := session.CheckToken(c); token != "" {
// mode := c.Query("mode", "all")
// works, err := core.GetLanding(c, mode)
// if err != nil {
// return err
// }
// return c.Render("pages/index", fiber.Map{
// "Title": "Landing", "Data": works,
// })
// }
// // ...otherwise, default to today's illustration ranking
// works, err := core.GetRanking(c, "daily", "illust", "", "1")
// if err != nil {
// return err
// }
// return c.Render("pages/index", fiber.Map{
// "Title": "Landing", "NoTokenData": works,
// })
// }

110
pages/settings.go Normal file
View file

@ -0,0 +1,110 @@
package pages
import (
"errors"
"io"
"net/http"
"regexp"
session "codeberg.org/vnpower/pixivfe/v2/core/config"
httpc "codeberg.org/vnpower/pixivfe/v2/core/http"
"codeberg.org/vnpower/pixivfe/v2/doc"
"github.com/gofiber/fiber/v2"
)
func setToken(c *fiber.Ctx) error {
// Parse the value from the form
token := c.FormValue("token")
if token != "" {
URL := httpc.GetNewestFromFollowingURL("all", "1")
_, err := httpc.UnwrapWebAPIRequest(URL, token)
if err != nil {
return errors.New("Cannot authorize with supplied token.")
}
// Make a test request to verify the token.
// THE TEST URL IS NSFW!
req, _ := http.NewRequest("GET", "https://www.pixiv.net/en/artworks/115365120", nil)
req.Header.Add("User-Agent", "Mozilla/5.0")
req.AddCookie(&http.Cookie{
Name: "PHPSESSID",
Value: token,
})
resp, err := http.DefaultClient.Do(req)
if err != nil {
return errors.New("Cannot authorize with supplied token.")
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return errors.New("Cannot parse the response from Pixiv. Please report this issue.")
}
// CSRF token
r := regexp.MustCompile(`"token":"([0-9a-f]+)"`)
csrf := r.FindStringSubmatch(string(body))[1]
if csrf == "" {
return errors.New("Cannot authorize with supplied token.")
}
// Set the token
if err := session.SetSessionValue(c, "Token", token); err != nil {
return err
}
if err := session.SetSessionValue(c, "CSRF", csrf); err != nil {
return err
}
return nil
}
return errors.New("You submitted an empty/invalid form.")
}
func setImageServer(c *fiber.Ctx) error {
// Parse the value from the form
token := c.FormValue("image-proxy")
if token != "" {
if err := session.SetSessionValue(c, "ImageProxy", token); err != nil {
return err
}
return nil
}
return errors.New("You submitted an empty/invalid form.")
}
func setLogout(c *fiber.Ctx) error {
session.RemoveSessionValue(c, "Token")
return nil
}
func SettingsPage(c *fiber.Ctx) error {
return c.Render("pages/settings", fiber.Map{
"ProxyList": doc.BuiltinProxyList,
})
}
func SettingsPost(c *fiber.Ctx) error {
t := c.Params("type")
var err error
switch t {
case "image_server":
err = setImageServer(c)
case "token":
err = setToken(c)
case "logout":
err = setLogout(c)
default:
err = errors.New("no such setting available")
}
if err != nil {
return err
}
c.Redirect("/")
return nil
}

35
pages/tag.go Normal file
View file

@ -0,0 +1,35 @@
package pages
import (
"net/url"
"strconv"
core "codeberg.org/vnpower/pixivfe/v2/core/webapi"
"github.com/gofiber/fiber/v2"
)
func TagPage(c *fiber.Ctx) error {
queries := make(map[string]string, 3)
queries["Mode"] = c.Query("mode", "safe")
queries["Category"] = c.Query("category", "artworks")
queries["Order"] = c.Query("order", "date_d")
name, err := url.PathUnescape(c.Params("name"))
if err != nil {
return err
}
page := c.Query("page", "1")
pageInt, _ := strconv.Atoi(page)
tag, err := core.GetTagData(c, name)
if err != nil {
return err
}
result, err := core.GetSearch(c, queries["Category"], name, queries["Order"], queries["Mode"], page)
if err != nil {
return err
}
return c.Render("pages/tag", fiber.Map{"Title": "Results for " + tag.Name, "Tag": tag, "Data": result, "Queries": queries, "Page": pageInt})
}

50
pages/user.go Normal file
View file

@ -0,0 +1,50 @@
package pages
import (
"errors"
"math"
"strconv"
core "codeberg.org/vnpower/pixivfe/v2/core/webapi"
"github.com/gofiber/fiber/v2"
)
func UserPage(c *fiber.Ctx) error {
id := c.Params("id")
if _, err := strconv.Atoi(id); err != nil {
return err
}
category := c.Params("category", "artworks")
if !(category == "artworks" || category == "illustrations" || category == "manga" || category == "bookmarks") {
return errors.New("Invalid work category: only illustrations, manga, artworks and bookmarks are available")
}
page := c.Query("page", "1")
pageInt, _ := strconv.Atoi(page)
user, err := core.GetUserArtwork(c, id, category, pageInt)
if err != nil {
return err
}
var worksCount int
var worksPerPage float64
if category == "bookmarks" {
worksPerPage = 48.0
} else {
worksPerPage = 30.0
}
worksCount = user.ArtworksCount
pageLimit := math.Ceil(float64(worksCount) / worksPerPage)
return c.Render("pages/user", fiber.Map{
"Title": user.Name,
"User": user,
"Category": category,
"PageLimit": int(pageLimit),
"Page": pageInt,
"MetaImage": user.BackgroundImage,
})
}

View file

@ -1,76 +0,0 @@
package main
import (
"net/http"
"testing"
)
func Benchmark_Main(t *testing.B) {
r, err := http.Get("http://localhost:8282/")
if r.StatusCode != 200 {
t.Errorf("Status code not 200: was %d", r.StatusCode)
}
if err != nil {
t.Error(err)
}
}
func Benchmark_Ranking(t *testing.B) {
r, err := http.Get("http://localhost:8282/ranking")
if r.StatusCode != 200 {
t.Errorf("Status code not 200: was %d", r.StatusCode)
}
if err != nil {
t.Error(err)
}
}
func Benchmark_Ranking_Complex(t *testing.B) {
r, err := http.Get("http://localhost:8282/ranking?content=all&mode=daily_r18&page=1&date=20230826")
if r.StatusCode != 200 {
t.Errorf("Status code not 200: was %d", r.StatusCode)
}
if err != nil {
t.Error(err)
}
}
func Benchmark_Artwork(t *testing.B) {
r, err := http.Get("http://localhost:8282/artworks/111157207")
if r.StatusCode != 200 {
t.Errorf("Status code not 200: was %d", r.StatusCode)
}
if err != nil {
t.Error(err)
}
}
func Benchmark_Artwork_R18(t *testing.B) {
r, err := http.Get("http://localhost:8282/artworks/111130033")
if r.StatusCode != 200 {
t.Errorf("Status code not 200: was %d", r.StatusCode)
}
if err != nil {
t.Error(err)
}
}
func Benchmark_User_NoSocial(t *testing.B) {
r, err := http.Get("http://localhost:8282/users/1035047")
if r.StatusCode != 200 {
t.Errorf("Status code not 200: was %d", r.StatusCode)
}
if err != nil {
t.Error(err)
}
}
func Benchmark_User_WithSocial(t *testing.B) {
r, err := http.Get("http://localhost:8282/users/59336265")
if r.StatusCode != 200 {
t.Errorf("Status code not 200: was %d", r.StatusCode)
}
if err != nil {
t.Error(err)
}
}

16
run.sh Normal file
View file

@ -0,0 +1,16 @@
#!/bin/sh
# Update the program every time you run?
# git pull
# Visit ./doc/Environment\ Variables.go for more details
export PIXIVFE_TOKEN=token_123456
export PIXIVFE_IMAGEPROXY=pximg.cocomi.cf
# export PIXIVFE_UNIXSOCKET=/srv/http/pages/pixivfe
export PIXIVFE_PORT=8282
go mod download
go get codeberg.org/vnpower/pixivfe/v2/...
CGO_ENABLED=0 GOOS=linux go build -mod=readonly -o pixivfe
./pixivfe

43
semgrep.yml Normal file
View file

@ -0,0 +1,43 @@
# Usage: semgrep scan -f semgrep.yml
rules:
- id: rule-0
message: "find http requests made not with *fiber.Ctx available"
languages: [go]
severity: WARNING
patterns:
- pattern-either:
- pattern: |
http.UnwrapWebAPIRequest(...)
- pattern: |
http.WebAPIRequest(...)
- pattern-not-inside: |
func $FUNC(c *fiber.Ctx, ...) $RET {
...
}
- id: rule-1
message: "find http requests made (limiter should be installed at all places)"
languages: [go]
# severity: INFO
severity: INVENTORY
patterns:
- pattern-either:
- pattern: |
http.UnwrapWebAPIRequest(...)
- pattern: |
http.WebAPIRequest(...)
- id: rule-2
message: "gjson.Get without gjson.Valid"
languages: [go]
severity: ERROR
patterns:
# - pattern-inside: |
# func $FUNC(...) $RET {
# ...
# }
- pattern: |
gjson.Get($X, ...)
- pattern-not-inside: |
if !gjson.Valid($X) {
$...DISCARD
}
...

312
serve/template.go Normal file
View file

@ -0,0 +1,312 @@
package serve
import (
"fmt"
"html/template"
"math"
"math/rand"
"net/url"
"regexp"
"strconv"
"strings"
"time"
core "codeberg.org/vnpower/pixivfe/v2/core/webapi"
)
func GetRandomColor() string {
// Some color shade I stole
colors := []string{
// Green
"#C8847E",
"#C8A87E",
"#C8B87E",
"#C8C67E",
"#C7C87E",
"#C2C87E",
"#BDC87E",
"#82C87E",
"#82C87E",
"#7EC8AF",
"#7EAEC8",
"#7EA6C8",
"#7E99C8",
"#7E87C8",
"#897EC8",
"#967EC8",
"#AE7EC8",
"#B57EC8",
"#C87EA5",
}
// Randomly choose one and return
return colors[rand.Intn(len(colors))]
}
func ParseEmojis(s string) template.HTML {
emojiList := map[string]string{
"normal": "101",
"surprise": "102",
"serious": "103",
"heaven": "104",
"happy": "105",
"excited": "106",
"sing": "107",
"cry": "108",
"normal2": "201",
"shame2": "202",
"love2": "203",
"interesting2": "204",
"blush2": "205",
"fire2": "206",
"angry2": "207",
"shine2": "208",
"panic2": "209",
"normal3": "301",
"satisfaction3": "302",
"surprise3": "303",
"smile3": "304",
"shock3": "305",
"gaze3": "306",
"wink3": "307",
"happy3": "308",
"excited3": "309",
"love3": "310",
"normal4": "401",
"surprise4": "402",
"serious4": "403",
"love4": "404",
"shine4": "405",
"sweat4": "406",
"shame4": "407",
"sleep4": "408",
"heart": "501",
"teardrop": "502",
"star": "503",
}
regex := regexp.MustCompile(`\(([^)]+)\)`)
parsedString := regex.ReplaceAllStringFunc(s, func(s string) string {
s = s[1 : len(s)-1] // Get the string inside
id := emojiList[s]
return fmt.Sprintf(`<img src="/proxy/s.pximg.net/common/images/emoji/%s.png" alt="(%s)" class="emoji" />`, id, s)
})
return template.HTML(parsedString)
}
func ParsePixivRedirect(s string) template.HTML {
regex := regexp.MustCompile(`\/jump\.php\?(http[^"]+)`)
parsedString := regex.ReplaceAllStringFunc(s, func(s string) string {
s = s[10:]
return s
})
escaped, err := url.QueryUnescape(parsedString)
if err != nil {
return template.HTML(s)
}
return template.HTML(escaped)
}
func EscapeString(s string) string {
escaped := url.QueryEscape(s)
return escaped
}
func ParseTime(date time.Time) string {
return date.Format("2006-01-02 15:04")
}
func CreatePaginator(base, ending string, current_page, max_page int) template.HTML {
pageUrl := func(page int) string {
return fmt.Sprintf(`%s%d%s`, base, page, ending)
}
const (
peek = 5 // this can be changed freely
limit = peek*2 + 1 // tied to the algorithm below, do not change
)
hasMaxPage := max_page != -1
count := 0
pages := ""
pages += `<div class="pagination-buttons">`
{ // "jump to page" <form>
hidden_section := ""
urlParsed, err := url.Parse(base)
if err != nil {
panic(err)
}
for k, vs := range urlParsed.Query() {
if k == "page" {
continue
}
for _, v := range vs {
hidden_section += fmt.Sprintf(`<input type="hidden" name="%s" value="%s"/>`, k, v)
}
}
max_section := ""
if hasMaxPage {
max_section = fmt.Sprintf(`max="%d"`, max_page)
}
pages += fmt.Sprintf(`<form action="%s">%s<input name="page" type="number" required value="%d" min="%d" %s placeholder="Page№" title="Jump To Page Number"/></form>`, pageUrl(current_page), hidden_section, current_page, 1, max_section)
pages += `<br />`
}
{
// previous,first (two buttons)
pages += `<span>`
{
pages += fmt.Sprintf(`<a href="%s" class="pagination-button">&laquo;</a>`, pageUrl(1))
pages += fmt.Sprintf(`<a href="%s" class="pagination-button">&lsaquo;</a>`, pageUrl(max(1, current_page-1)))
}
pages += `</span>`
// page number buttons
for i := current_page - peek; (i <= max_page || max_page == -1) && count < limit; i++ {
if i < 1 {
continue
}
if i == current_page {
pages += fmt.Sprintf(`<a href="%s" class="pagination-button" id="highlight">%d</a>`, pageUrl(i), i)
} else {
pages += fmt.Sprintf(`<a href="%s" class="pagination-button">%d</a>`, pageUrl(i), i)
}
count++
}
// next,last (two buttons)
pages += `<span>`
if hasMaxPage {
pages += fmt.Sprintf(`<a href="%s" class="pagination-button">&rsaquo;</a>`, pageUrl(min(max_page, current_page+1)))
pages += fmt.Sprintf(`<a href="%s" class="pagination-button">&raquo;</a>`, pageUrl(max_page))
} else {
pages += fmt.Sprintf(`<a href="%s" class="pagination-button">&rsaquo;</a>`, pageUrl(current_page+1))
pages += fmt.Sprintf(`<a href="%s" class="pagination-button" class="disabled">&raquo;</a>`, pageUrl(max_page))
}
pages += `</span>`
}
pages += `</div>`
return template.HTML(pages)
}
func GetNovelGenre(s string) string {
switch s {
case "1":
return "Romance"
case "2":
return "Isekai fantasy"
case "3":
return "Contemporary fantasy"
case "4":
return "Mystery"
case "5":
return "Horror"
case "6":
return "Sci-fi"
case "7":
return "Literature"
case "8":
return "Drama"
case "9":
return "Historical pieces"
case "10":
return "BL (yaoi)"
case "11":
return "Yuri"
case "12":
return "For kids"
case "13":
return "Poetry"
case "14":
return "Essays/non-fiction"
case "15":
return "Screenplays/scripts"
case "16":
return "Reviews/opinion pieces"
case "17":
return "Other"
}
return fmt.Sprintf("(Unknown Genre %s)", s)
}
func GetTemplateFunctions() template.FuncMap {
return template.FuncMap{
"toInt": func(s string) int {
n, _ := strconv.Atoi(s)
return n
},
"parseEmojis": func(s string) template.HTML {
return ParseEmojis(s)
},
"parsePixivRedirect": func(s string) template.HTML {
return ParsePixivRedirect(s)
},
"escapeString": func(s string) string {
return EscapeString(s)
},
"randomColor": func() string {
return GetRandomColor()
},
"isEmpty": func(s string) bool {
return len(s) < 1
},
"isEmphasize": func(s string) bool {
switch s {
case
"R-18",
"R-18G":
return true
}
return false
},
"reformatDate": func(s string) string {
if len(s) != 8 {
return s
}
return fmt.Sprintf("%s-%s-%s", s[4:], s[2:4], s[:2])
},
"parseTime": func(date time.Time) string {
return ParseTime(date)
},
"createPaginator": func(base, ending string, current_page, max_page int) template.HTML {
return CreatePaginator(base, ending, current_page, max_page)
},
"joinArtworkIds": func(artworks []core.ArtworkBrief) string {
ids := []string{}
for _, art := range artworks {
ids = append(ids, art.ID)
}
return strings.Join(ids, ",")
},
"stripEmbed": func(s string) string {
// this is stupid
return s[:len(s)-6]
},
"renderNovel": func(s string) template.HTML {
s = strings.ReplaceAll(s, "\n", "<br />")
s = strings.ReplaceAll(s, "[newpage]", "Insert page separator here.")
return template.HTML(s)
},
"novelGenre": GetNovelGenre,
"floor": func(i float64) int {
return int(math.Floor(i))
},
"filterWordsIDK": func(s string) string {
// Don't say anything... we don't own these...
s = strings.ReplaceAll(s, "loli", "****")
s = strings.ReplaceAll(s, "sex", "***")
return s
},
}
}

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="48px" height="48px"><g fill-opacity="0" fill="#dddddd" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><path d="M0,256v-256h256v256z" id="bgRectangle"></path></g><g fill="#ffffff" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(5.33333,5.33333)"><path d="M24,4c-11.02771,0 -20,8.97229 -20,20c0,3.27532 0.86271,6.33485 2.26172,9.06445l-2.16797,7.76367c-0.50495,1.8034 1.27818,3.58449 3.08203,3.08008l7.76758,-2.16797c2.72769,1.39712 5.7836,2.25977 9.05664,2.25977c11.02771,0 20,-8.97229 20,-20c0,-11.02771 -8.97229,-20 -20,-20zM24,7c9.40629,0 17,7.59371 17,17c0,9.40629 -7.59371,17 -17,17c-3.00297,0 -5.80774,-0.78172 -8.25586,-2.14648c-0.34566,-0.19287 -0.75354,-0.24131 -1.13477,-0.13477l-7.38672,2.0625l2.0625,-7.38281c0.10655,-0.38122 0.05811,-0.7891 -0.13477,-1.13477c-1.36674,-2.4502 -2.15039,-5.25915 -2.15039,-8.26367c0,-9.40629 7.59371,-17 17,-17zM23.97656,12.97852c-0.82766,0.01293 -1.48843,0.69381 -1.47656,1.52148v12c-0.00765,0.54095 0.27656,1.04412 0.74381,1.31683c0.46725,0.27271 1.04514,0.27271 1.51238,0c0.46725,-0.27271 0.75146,-0.77588 0.74381,-1.31683v-12c0.00582,-0.40562 -0.15288,-0.7963 -0.43991,-1.08296c-0.28703,-0.28666 -0.67792,-0.44486 -1.08353,-0.43852zM24,31c-1.10457,0 -2,0.89543 -2,2c0,1.10457 0.89543,2 2,2c1.10457,0 2,-0.89543 2,-2c0,-1.10457 -0.89543,-2 -2,-2z"></path></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 B

View file

@ -1,38 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 512 512"
version="1.1"
id="svg51"
sodipodi:docname="circlems.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs55" />
<sodipodi:namedview
id="namedview53"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
showgrid="false"
inkscape:zoom="0.82714845"
inkscape:cx="112.43447"
inkscape:cy="263.55608"
inkscape:window-width="836"
inkscape:window-height="996"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg51"
inkscape:showpageshadow="2"
inkscape:deskcolor="#505050" />
<path
d="M 256,55.31287 C 145.13655,55.31287 55.312871,145.13655 55.312871,256 55.312871,366.86345 145.13655,456.68713 256,456.68713 366.86345,456.68713 456.68713,366.86345 456.68713,256 456.68713,145.13655 366.86345,55.31287 256,55.31287 Z m 0,362.53159 C 166.58094,417.84446 94.155545,345.41906 94.155545,256 94.155545,166.58094 166.58094,94.15554 256,94.15554 c 89.41906,0 161.84446,72.4254 161.84446,161.84446 0,89.41906 -72.4254,161.84446 -161.84446,161.84446 z"
id="path49"
style="fill:#ffffff;stroke-width:0.809223" />
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="50px" height="50px"><g fill-opacity="0" fill="#dddddd" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><path d="M0,256v-256h256v256z" id="bgRectangle"></path></g><g fill="#ffffff" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(5.12,5.12)"><path d="M22.20508,2c-0.48953,0.00026 -0.90693,0.35484 -0.98633,0.83789l-0.97266,5.95508c-1.16958,0.34023 -2.28485,0.7993 -3.33594,1.37109l-4.91406,-3.50977c-0.39728,-0.28369 -0.94131,-0.23911 -1.28711,0.10547l-3.89062,3.88672c-0.3432,0.34344 -0.39015,0.88376 -0.11133,1.28125l3.45703,4.94531c-0.58061,1.05722 -1.04985,2.17878 -1.39844,3.35938l-5.92969,0.98633c-0.4815,0.0811 -0.83404,0.49805 -0.83398,0.98633v5.5c-0.00088,0.48518 0.3466,0.901 0.82422,0.98633l5.93359,1.05078c0.3467,1.17855 0.81296,2.30088 1.39453,3.35937l-3.5,4.89648c-0.28369,0.39728 -0.23911,0.94131 0.10547,1.28711l3.88867,3.89063c0.34265,0.34275 0.88175,0.39048 1.2793,0.11328l4.95508,-3.46875c1.05419,0.57517 2.17218,1.03762 3.3457,1.38086l0.99023,5.96289c0.08025,0.48228 0.49742,0.83584 0.98633,0.83594h5.5c0.4858,0.00071 0.90184,-0.34778 0.98633,-0.82617l1.06055,-5.98633c1.16868,-0.3485 2.28142,-0.8178 3.33008,-1.39648l4.98828,3.5c0.39749,0.27882 0.93781,0.23187 1.28125,-0.11133l3.88867,-3.89258c0.34612,-0.34687 0.38995,-0.89343 0.10352,-1.29102l-3.55664,-4.9375c0.56867,-1.04364 1.02681,-2.14972 1.36719,-3.31055l6.01758,-1.05469c0.47839,-0.08448 0.82689,-0.50053 0.82617,-0.98633v-5.5c-0.00026,-0.48953 -0.35484,-0.90693 -0.83789,-0.98633l-6.00781,-0.98242c-0.34266,-1.15945 -0.80206,-2.26356 -1.37109,-3.30664l3.50781,-4.99805c0.27882,-0.39749 0.23187,-0.93781 -0.11133,-1.28125l-3.89062,-3.88867c-0.34687,-0.34612 -0.89343,-0.38995 -1.29102,-0.10352l-4.92383,3.54102c-1.04908,-0.57636 -2.16255,-1.04318 -3.33398,-1.38867l-1.04687,-5.98437c-0.08364,-0.47917 -0.49991,-0.82867 -0.98633,-0.82812zM23.05664,4h3.80859l0.99609,5.68555c0.06772,0.38959 0.35862,0.70269 0.74219,0.79883c1.46251,0.36446 2.83609,0.94217 4.08984,1.70117c0.34265,0.20761 0.77613,0.1907 1.10156,-0.04297l4.67969,-3.36328l2.69336,2.69336l-3.33203,4.74805c-0.22737,0.3236 -0.24268,0.75079 -0.03906,1.08984c0.75149,1.25092 1.32146,2.61583 1.68555,4.07031c0.0969,0.38717 0.41473,0.67966 0.80859,0.74414l5.70703,0.93359v3.80859l-5.71875,1.00391c-0.3899,0.06902 -0.70237,0.36157 -0.79687,0.74609c-0.35988,1.45263 -0.93019,2.8175 -1.68164,4.06836c-0.20617,0.34256 -0.18851,0.775 0.04492,1.09961l3.37891,4.68945l-2.69336,2.69531l-4.74023,-3.32617c-0.32527,-0.22783 -0.75452,-0.24163 -1.09375,-0.03516c-1.24752,0.75899 -2.62251,1.33943 -4.08008,1.70898c-0.38168,0.09622 -0.67142,0.40737 -0.74023,0.79492l-1.00977,5.6875h-3.81445l-0.94141,-5.66211c-0.06549,-0.39365 -0.35874,-0.7107 -0.74609,-0.80664c-1.46338,-0.36069 -2.84314,-0.93754 -4.10547,-1.69531c-0.33857,-0.20276 -0.76473,-0.18746 -1.08789,0.03906l-4.70312,3.29492l-2.69531,-2.69922l3.32422,-4.64648c0.23221,-0.3254 0.24834,-0.75782 0.04102,-1.09961c-0.76602,-1.26575 -1.34535,-2.6454 -1.71094,-4.11523c-0.09555,-0.38244 -0.40684,-0.67307 -0.79492,-0.74219l-5.63086,-1v-3.81445l5.62695,-0.93555c0.39312,-0.06519 0.71002,-0.35754 0.80664,-0.74414c0.36873,-1.4749 0.94778,-2.85432 1.71094,-4.11719c0.20562,-0.33876 0.19183,-0.76697 -0.03516,-1.0918l-3.28516,-4.69531l2.69727,-2.69531l4.66211,3.33203c0.32413,0.23112 0.75447,0.248 1.0957,0.04297c1.25566,-0.75415 2.63862,-1.32636 4.10352,-1.68555c0.38927,-0.09584 0.68369,-0.41486 0.74805,-0.81055zM25,17c-4.40643,0 -8,3.59357 -8,8c0,4.40643 3.59357,8 8,8c4.40643,0 8,-3.59357 8,-8c0,-4.40643 -3.59357,-8 -8,-8zM25,19c3.32555,0 6,2.67445 6,6c0,3.32555 -2.67445,6 -6,6c-3.32555,0 -6,-2.67445 -6,-6c0,-3.32555 2.67445,-6 6,-6z"></path></g></g></svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 383 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 830 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 732 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="60px" height="60px"><g fill-opacity="0" fill="#dddddd" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><path d="M0,256v-256h256v256z" id="bgRectangle"></path></g><g fill="#ffffff" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(8.53333,8.53333)"><path d="M15,3c-6.627,0 -12,5.373 -12,12c0,6.016 4.432,10.984 10.206,11.852v-8.672h-2.969v-3.154h2.969v-2.099c0,-3.475 1.693,-5 4.581,-5c1.383,0 2.115,0.103 2.461,0.149v2.753h-1.97c-1.226,0 -1.654,1.163 -1.654,2.473v1.724h3.593l-0.487,3.154h-3.106v8.697c5.857,-0.794 10.376,-5.802 10.376,-11.877c0,-6.627 -5.373,-12 -12,-12z"></path></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 577 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 569 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="48px" height="48px"><g fill-opacity="0" fill="#dddddd" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><path d="M0,256v-256h256v256z" id="bgRectangle"></path></g><g fill="#ffffff" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(10.66667,10.66667)"><path d="M12,2.09961l-11,9.90039h3v9h7v-6h2v6h7v-9h3zM12,4.79102l6,5.40039v0.80859v8h-3v-6h-6v6h-3v-8.80859z"></path></g></g></svg>

Before

Width:  |  Height:  |  Size: 949 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="48px" height="48px"><g fill-opacity="0" fill="#dddddd" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><path d="M0,256v-256h256v256z" id="bgRectangle"></path></g><g fill="#ffffff" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(10.66667,10.66667)"><path d="M8,3c-2.757,0 -5,2.243 -5,5v8c0,2.757 2.243,5 5,5h8c2.757,0 5,-2.243 5,-5v-8c0,-2.757 -2.243,-5 -5,-5zM8,5h8c1.654,0 3,1.346 3,3v8c0,1.654 -1.346,3 -3,3h-8c-1.654,0 -3,-1.346 -3,-3v-8c0,-1.654 1.346,-3 3,-3zM17,6c-0.55228,0 -1,0.44772 -1,1c0,0.55228 0.44772,1 1,1c0.55228,0 1,-0.44772 1,-1c0,-0.55228 -0.44772,-1 -1,-1zM12,7c-2.757,0 -5,2.243 -5,5c0,2.757 2.243,5 5,5c2.757,0 5,-2.243 5,-5c0,-2.757 -2.243,-5 -5,-5zM12,9c1.654,0 3,1.346 3,3c0,1.654 -1.346,3 -3,3c-1.654,0 -3,-1.346 -3,-3c0,-1.654 1.346,-3 3,-3z"></path></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 B

View file

@ -1,38 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 1072 1024"
role="img"
version="1.1"
id="svg4"
sodipodi:docname="pawoo.svg"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs8" />
<sodipodi:namedview
id="namedview6"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="0.23046875"
inkscape:cx="538.0339"
inkscape:cy="512"
inkscape:window-width="1676"
inkscape:window-height="996"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg4" />
<path
d="M558.652 601.142c0 71.962-46.057 130.285-102.853 130.285a82.797 82.797 0 01-29.8-5.553 43.423 43.423 0 009.223.982c30.298 0 54.865-31.114 54.865-69.487 0-38.373-24.567-69.488-54.865-69.488s-54.854 31.115-54.854 69.488c0 21.323 7.578 40.397 19.5 53.137-28.238-23.23-46.933-63.532-46.933-109.364 0-71.95 46.058-130.285 102.864-130.285 56.796 0 102.853 58.334 102.853 130.285zM654.66 128c-288.003 0-425.154 231.525-425.154 384v148.412c0 32.145-22.283 44.222-62.48 40.314-39.414-3.836-63.259-32.452-89.188-51.384a45.585 45.585 0 00-61.898 65.9C102.82 821.658 300.84 896 531.23 896c78.7 0 146.578-9.555 204.06-26.545a221.548 221.548 0 01-24.568-21.56c-25.728-25.941-42.138-61.579-50.13-108.926-6.642-39.226-6.31-80.108-6.038-112.964.047-6.322.106-86.572.106-86.572a13.71 13.71 0 1127.422 0s-.048 80.415-.107 86.797c-.52 64.361-1.231 152.485 48.224 202.354 9.815 9.898 22.27 24.272 33.02 31.766 24.403 13.77 56.062 21.94 97.146 21.94 13.722 0 205.717 13.71 205.717-274.293 0-27.421 27.432-479.997-411.422-479.997z"
id="path2"
style="fill:#ffffff" />
</svg>

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 776 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="50px" height="50px"><g fill-opacity="0" fill="#dddddd" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><path d="M0,256v-256h256v256z" id="bgRectangle"></path></g><g fill="#ffffff" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(5.12,5.12)"><path d="M21,3c-9.37891,0 -17,7.62109 -17,17c0,9.37891 7.62109,17 17,17c3.71094,0 7.14063,-1.19531 9.9375,-3.21875l13.15625,13.125l2.8125,-2.8125l-13,-13.03125c2.55469,-2.97656 4.09375,-6.83984 4.09375,-11.0625c0,-9.37891 -7.62109,-17 -17,-17zM21,5c8.29688,0 15,6.70313 15,15c0,8.29688 -6.70312,15 -15,15c-8.29687,0 -15,-6.70312 -15,-15c0,-8.29687 6.70313,-15 15,-15z"></path></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="48px" height="48px"><g fill-opacity="0" fill="#dddddd" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><path d="M0,256v-256h256v256z" id="bgRectangle"></path></g><g fill="#ffffff" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(10.66667,10.66667)"><path d="M22,3.999c-0.78,0.463 -2.345,1.094 -3.265,1.276c-0.027,0.007 -0.049,0.016 -0.075,0.023c-0.813,-0.802 -1.927,-1.299 -3.16,-1.299c-2.485,0 -4.5,2.015 -4.5,4.5c0,0.131 -0.011,0.372 0,0.5c-3.353,0 -5.905,-1.756 -7.735,-4c-0.199,0.5 -0.286,1.29 -0.286,2.032c0,1.401 1.095,2.777 2.8,3.63c-0.314,0.081 -0.66,0.139 -1.02,0.139c-0.581,0 -1.196,-0.153 -1.759,-0.617c0,0.017 0,0.033 0,0.051c0,1.958 2.078,3.291 3.926,3.662c-0.375,0.221 -1.131,0.243 -1.5,0.243c-0.26,0 -1.18,-0.119 -1.426,-0.165c0.514,1.605 2.368,2.507 4.135,2.539c-1.382,1.084 -2.341,1.486 -5.171,1.486h-0.964c1.788,1.146 4.065,2.001 6.347,2.001c7.43,0 11.653,-5.663 11.653,-11.001c0,-0.086 -0.002,-0.266 -0.005,-0.447c0,-0.018 0.005,-0.035 0.005,-0.053c0,-0.027 -0.008,-0.053 -0.008,-0.08c-0.003,-0.136 -0.006,-0.263 -0.009,-0.329c0.79,-0.57 1.475,-1.281 2.017,-2.091c-0.725,0.322 -1.503,0.538 -2.32,0.636c0.834,-0.5 2.019,-1.692 2.32,-2.636zM18,8.999c0,4.08 -2.957,8.399 -8.466,8.943c0.746,-0.529 1.466,-1.28 1.466,-1.28c0,0 -3,-2.662 -3.225,-6.14c1.035,0.316 2.113,0.477 3.225,0.477h2v-2.5c0,-0.001 0,-0.001 0,-0.001c0.002,-1.38 1.12,-2.498 2.5,-2.498c1.381,0 2.5,1.119 2.5,2.5c0,0 0,0.42 0,0.499z"></path></g></g></svg>

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 897 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1 +0,0 @@
{"version":3,"sourceRoot":"","sources":["style.scss"],"names":[],"mappings":"AAgBA;EACE;EACA;;;AAGF;EACE;EACA;EAEA,kBAzBG;EA0BH,OAxBG;EA0BH;EACA,aAjBY;EAmBZ;EACA;EACA;;;AAGF;EACE,OAjCK;EAkCL;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;AAAA;AAAA;AAAA;EAIE;EACA;EACA;;;AAGF;AAAA;EAEE;;;AAGF;AAAA;EAEE;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;AAAA;AAAA;EAGE;EACA,eA9Ec;EA+Ed;EACA;;;AAGF;AAAA;EAEE;EACA,kBA3Fa;EA4Fb,OA1Fa;;;AA6Ff;AAAA;EAEE,kBAjGa;;;AAoGf;EACE;EACA;;;AAGF;EACE;EACA,eAtGc;EAuGd;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;;;AAIJ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;EAQE,eA3Hc;EA4Hd;EACA;EACA;EACA,aA9HY;EA+HZ;EACA;;;AAGF;AAAA;AAAA;AAAA;EAIE,aAlIY;;;AAqId;AAAA;AAAA;AAAA;EAIE;;;AAGF;AAAA;AAAA;AAAA;EAIE;EACA;;;AAGF;AAAA;AAAA;AAAA;AAAA;AAAA;EAME,kBAlKK;EAmKL;EACA,OAtKG;;;AAyKL;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;AAEA;EACE;;AAGF;EACE;;AAGF;EACE;EACA,kBAhMW;EAiMX;;;AAIJ;EACE;EACA;EACA;EACA;;AACA;EACE;EACA;EACA;EACA;;AAEA;EACE;EACA;;AAEA;EACE;EACA;EACA,OAtNH;;AA2NC;EACE;EACA;;AAKF;EACE;;AAIJ;EACE;;AAEA;EACE;;AAIJ;EACE;EACA;;AAGF;EACE,kBAvPD;EAwPC;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;;AAGF;EACE;EAEA;EACA;;AAEA;EACE;EACA;EACA;EACA,OA7QL;EA8QK;EACA;EACA;;AAEA;EACE,kBApRG;;AAuRL;EACE;EACA;EACA;EACA;;;AAoBZ;EAEI;IACE;;;AAKN;EACE;EACA;EACA;;AAEA;EACE;EACA;;;AAIJ;EACE;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EAIA;EACA;;;AAGF;EACE;EACA;EACA;;;AAKA;AAAA;EACE;EACA,OA7VC;;AAiWD;AAAA;EACE,OAtVgB;EAuVhB,QAvVgB;;AA0VlB;AAAA;EACE,WA3VgB;;AA8VlB;AAAA;EACE,WA/VgB;;AAgWhB;AAAA;EACE,WAlWc;;AAwWlB;AAAA;EACE,OAzWgB;EA0WhB,QA1WgB;;AA6WlB;AAAA;EACE,WA9WgB;;AAiXlB;AAAA;EACE,WAlXgB;;AAoXhB;AAAA;EACE,WArXc;;AA0XpB;AAAA;EACE;EACA;EACA;;AAEA;AAAA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;AAAA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEF;AAAA;EACE;EACA;EACA;EACA;EACA;EACA;EACA,kBAraI;EAsaJ,OAzaH;;AA4aC;AAAA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA,OApbH;EAqbG;EACA;EACA;EACA;EACA;EACA;;AAEA;AAAA;EACE;;AAKN;AAAA;EACE;EACA;EACA,eAjcU;;AAocZ;AAAA;EACE;EACA;;AAEA;AAAA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAIJ;AAAA;EACE;EACA;;AAEA;AAAA;EACE;EACA;;AAEA;AAAA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;AAAA;EACE;EACA;EACA;EACA;EACA;;;AAOV;EACE,kBAvfa;EAwfb,eAnfc;EAofd;;AAEA;EACE;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;;AAIJ;EACE;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;;AAGF;EACE;;AAEA;EACE;;AAEF;EACE;;AACA;EACE,OA5hBK;;AAkiBb;EACE;;AAGF;EACE;;AAEA;EACE;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;;AAIJ;EACE;;AAEA;EACE,OAxjBI;;AA2jBN;EACE,OA7jBD;EA8jBC;EACA;;AAKN;EACE;EACA;;AAEA;EACE;EACA;;AAGF;EACE;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;;AAKF;EACE;EACA;EACA;EACA,OAzmBD;;AA2mBC;EACE;EACA;EACA;EACA;EACA;;;AAMR;EACE;EAOA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;;;AAIJ;EACE;EACA;EACA;;;AAGF;EACE;;AAEA;EACE;EACA;EACA;EACA;;AAEA;EACE;;AAIJ;EACE;;AAEA;EACE;;;AAKN;EACE;EACA;;;AAGF;AAAA;EAEE;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA,kBAlsBa;EAmsBb;;AAEA;EACE;EACA,OAtsBC;EAusBD,kBAxsBW;EAysBX;EACA;EACA;EACA;EACA;EACA;EACA;;;AAIJ;EACE;IACE;;EAGF;AAAA;IAEE;;;AAIJ;EACE;;AAEA;EACE;EACA;EACA;EACA;EAEA;EACA;;AAIA;EACE;;;AAKN;EACE,kBAnvBa;EAovBb;EACA;EACA;EACA;EACA;EACA;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;;AAEF;EACE;EACA","file":"style.css"}

Some files were not shown because too many files have changed in this diff Show more