Compare commits

...

No commits in common. "main" and "atom" have entirely different histories.
main ... atom

173 changed files with 5869 additions and 3410 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"]

16
.env.example Normal file
View file

@ -0,0 +1,16 @@
# -- PixivFE configuration
# See ./doc/Environment\ Variables.go for more details
# -- Required variables
# NOTE: PixivFE can be exposed on either a port or a Unix socket
PIXIVFE_PORT="8282"
# PIXIVFE_UNIXSOCKET="/srv/http/pages/pixivfe" # Ignored if PIXIVFE_PORT is set
# PIXIVFE_TOKEN=changethis # Only set here if not using a secret
# -- Optional variables
# PIXIVFE_DEV=
PIXIVFE_HOST="127.0.0.1"
# PIXIVFE_REQUESTLIMIT=
# PIXIVFE_IMAGEPROXY=
PIXIVFE_USERAGENT="Mozilla/5.0"
PIXIVFE_ACCEPTLANGUAGE="en-US,en;q=0.5"

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("")
}

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

@ -0,0 +1,123 @@
// Global (Server-Wide) Settings
package core
import (
"errors"
"log"
"math/rand"
"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 // if 0, request limit is disabled
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) {
if v == "" {
s.RequestLimit = 0
} else {
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)
}
func GetRandomDefaultToken() string {
defaultToken := GlobalServerConfig.Token[rand.Intn(len(GlobalServerConfig.Token))]
return defaultToken
}

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

@ -0,0 +1,106 @@
package core
import (
"context"
"errors"
"fmt"
"io"
"net/http"
config "codeberg.org/vnpower/pixivfe/v2/core/config"
"github.com/tidwall/gjson"
)
type HttpResponse struct {
Ok bool
StatusCode int
Body string
Message string
}
func WebAPIRequest(context context.Context, URL, token string) HttpResponse {
req, err := http.NewRequest("GET", URL, nil)
if err != nil {
return HttpResponse{
Ok: false,
StatusCode: 0,
Body: "",
Message: fmt.Sprintf("Failed to create a request to %s\n.", URL),
}
}
req = req.WithContext(context)
req.Header.Add("User-Agent", config.GlobalServerConfig.UserAgent)
req.Header.Add("Accept-Language", config.GlobalServerConfig.AcceptLanguage)
if token == "" {
req.AddCookie(&http.Cookie{
Name: "PHPSESSID",
Value: config.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 send 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."),
}
}
resp2 := HttpResponse{
Ok: true,
StatusCode: resp.StatusCode,
Body: string(body),
Message: "",
}
if !(300 > resp2.StatusCode && resp2.StatusCode >= 200) {
fmt.Println("non-2xx response from pixiv:", URL, resp2.StatusCode, resp2.Body)
}
return resp2
}
func UnwrapWebAPIRequest(context context.Context, URL, token string) (string, error) {
resp := WebAPIRequest(context, 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
}

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

@ -0,0 +1,129 @@
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, ratio, page string) string {
base := "https://www.pixiv.net/ajax/search/%s/%s?order=%s&mode=%s&ratio=%s&p=%s"
return fmt.Sprintf(base, artworkType, name, order, age_settings, ratio, 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)
}
func GetNovelRelatedURL(id string, limit int) string {
base := "https://www.pixiv.net/ajax/novel/%s/recommend/init?limit=%d"
return fmt.Sprintf(base, id, limit)
}

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

@ -0,0 +1,52 @@
package kmutex
import "sync"
// Map, Refencence-counted by ID (any).
type Kmutex struct {
Map sync.Map
}
// Create new Kmutex
func New() *Kmutex {
return &Kmutex{}
}
// decrement ID ref count
// Returns: ref count after
func (km *Kmutex) Unlock(key any) uint64 {
for {
actual, ok := km.Map.Load(key)
if !ok {
panic("impossible! memory corruption?")
}
if actual.(uint64) == 1 {
deleted := km.Map.CompareAndDelete(key, actual.(uint64))
if deleted {
return 0
}
} else {
after := actual.(uint64) - 1
swapped := km.Map.CompareAndSwap(key, actual.(uint64), after)
if swapped {
return after
}
}
}
}
// increment ID ref count
// Returns: ref count after
func (km *Kmutex) Lock(key any) uint64 {
for {
actual, loaded := km.Map.LoadOrStore(key, uint64(1))
if !loaded {
return 1
}
after := actual.(uint64) + 1
swapped := km.Map.CompareAndSwap(key, actual.(uint64), after)
if swapped {
return after
}
}
}

69
core/session/aux.go Normal file
View file

@ -0,0 +1,69 @@
package core
import (
"log"
"net/url"
"strings"
config "codeberg.org/vnpower/pixivfe/v2/core/config"
"github.com/gofiber/fiber/v2"
)
func GetPixivToken(c *fiber.Ctx) string {
return GetCookie(c, Cookie_Token)
}
func GetImageProxy(c *fiber.Ctx) url.URL {
value := GetCookie(c, Cookie_ImageProxy)
if value == "" {
// fall through to default case
} else {
proxyUrl, err := url.Parse(value)
if err != nil {
// fall through to default case
} else {
return *proxyUrl
}
}
return config.GlobalServerConfig.ProxyServer
}
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
}
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)
}
// note: still cannot believe Go doesn't have this function built-in
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
}

67
core/session/cookie.go Normal file
View file

@ -0,0 +1,67 @@
// User Settings (Using Browser Cookies)
package core
import (
"time"
"github.com/gofiber/fiber/v2"
)
type CookieName string
const ( // the __Host thing force it to be secure and same-origin (no subdomain) >> https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
Cookie_Token CookieName = "__Host-pixivfe-Token"
Cookie_CSRF CookieName = "__Host-pixivfe-CSRF"
Cookie_ImageProxy CookieName = "__Host-pixivfe-ImageProxy"
Cookie_ShowArtR18 CookieName = "__Host-pixivfe-ShowArtR18"
Cookie_ShowArtR18G CookieName = "__Host-pixivfe-ShowArtR18G"
Cookie_ShowArtAI CookieName = "__Host-pixivfe-ShowArtAI"
)
// Go can't make this a const...
var AllCookieNames []CookieName = []CookieName{
Cookie_Token,
Cookie_CSRF,
Cookie_ImageProxy,
}
func GetCookie(c *fiber.Ctx, name CookieName, defaultValue ...string) string {
return c.Cookies(string(name), defaultValue...)
}
func SetCookie(c *fiber.Ctx, name CookieName, value string) {
cookie := fiber.Cookie{
Name: string(name),
Value: value,
Path: "/",
// expires in 30 days from now
Expires: c.Context().Time().Add(30 * (24 * time.Hour)),
HTTPOnly: true,
Secure: true,
SameSite: fiber.CookieSameSiteStrictMode, // bye-bye cross site forgery
}
c.Cookie(&cookie)
}
var CookieExpireDelete = time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC)
func ClearCookie(c *fiber.Ctx, name CookieName) {
cookie := fiber.Cookie{
Name: string(name),
Value: "",
Path: "/",
// expires in 30 days from now
Expires: CookieExpireDelete,
HTTPOnly: true,
Secure: true,
SameSite: fiber.CookieSameSiteStrictMode,
}
c.Cookie(&cookie)
}
func ClearAllCookies(c *fiber.Ctx) {
for _, name := range AllCookieNames {
ClearCookie(c, name)
}
}

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

@ -0,0 +1,392 @@
package core
import (
"errors"
"fmt"
"html/template"
"sort"
"strings"
"sync"
"time"
http "codeberg.org/vnpower/pixivfe/v2/core/http"
session "codeberg.org/vnpower/pixivfe/v2/core/session"
"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(c.Context(), 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(c.Context(), 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(c.Context(), 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(c.Context(), 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(c.Context(), 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 := recent[i].ID
right := recent[j].ID
return numberGreaterThan(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 {
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/session"
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.GetPixivToken(c)
URL := http.GetDiscoveryURL(mode, 100)
var artworks []ArtworkBrief
resp, err := http.UnwrapWebAPIRequest(c.Context(), 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.GetPixivToken(c)
URL := http.GetDiscoveryNovelURL(mode, 100)
var novels []NovelBrief
resp, err := http.UnwrapWebAPIRequest(c.Context(), 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/session"
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(c.Context(), 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/session"
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.GetPixivToken(c)
URL := http.GetNewestArtworksURL(worktype, r18, "0")
var body struct {
Artworks []ArtworkBrief `json:"illusts"`
// LastId string
}
resp, err := http.UnwrapWebAPIRequest(c.Context(), 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
}

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

@ -0,0 +1,125 @@
package core
import (
"time"
http "codeberg.org/vnpower/pixivfe/v2/core/http"
session "codeberg.org/vnpower/pixivfe/v2/core/session"
"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(c.Context(), 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
}
func GetNovelRelated(c *fiber.Ctx, id string) ([]NovelBrief, error) {
var novels struct {
List []NovelBrief `json:"novels"`
}
// hard-coded value, may change
URL := http.GetNovelRelatedURL(id, 50)
response, err := http.UnwrapWebAPIRequest(c.Context(), URL, "")
if err != nil {
return novels.List, err
}
response = session.ProxyImageUrl(c, response)
err = json.Unmarshal([]byte(response), &novels)
if err != nil {
return novels.List, err
}
return novels.List, nil
}

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

@ -0,0 +1,38 @@
package core
import (
session "codeberg.org/vnpower/pixivfe/v2/core/session"
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.GetPixivToken(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(c.Context(), 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/session"
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 int `json:"illust_page_count,string"`
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 int `json:"illust_type,string"`
} `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(c.Context(), 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,105 @@
package core
import (
"fmt"
"html/template"
"net/http"
"time"
session "codeberg.org/vnpower/pixivfe/v2/core/session"
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
}
// note(@iacore):
// so the funny thing about Pixiv is that they will return this month's data for a request of a future date
// is it a bug or a feature?
func GetRankingCalendar(c *fiber.Ctx, mode string, year, month int) (template.HTML, error) {
token := session.GetPixivToken(c)
URL := url.GetRankingCalendarURL(mode, year, month)
req, err := http.NewRequest("GET", URL, nil)
if err != nil {
return template.HTML(""), err
}
req = req.WithContext(c.Context())
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 := c.Context().Time()
// 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"
http "codeberg.org/vnpower/pixivfe/v2/core/http"
session "codeberg.org/vnpower/pixivfe/v2/core/session"
"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(c.Context(), 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, ratio, page string) (*SearchResult, error) {
URL := http.GetSearchArtworksURL(artworkType, name, order, age_settings, ratio, page)
response, err := http.UnwrapWebAPIRequest(c.Context(), 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
}

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

@ -0,0 +1,320 @@
package core
import (
"errors"
"fmt"
"html/template"
"math"
"sort"
http "codeberg.org/vnpower/pixivfe/v2/core/http"
session "codeberg.org/vnpower/pixivfe/v2/core/session"
"github.com/goccy/go-json"
"github.com/gofiber/fiber/v2"
)
// pixivfe internal data type. not used by pixiv.
type UserArtCategory string
const (
UserArt_Any UserArtCategory = ""
UserArt_Illustration UserArtCategory = "illustrations"
UserArt_Manga UserArtCategory = "manga"
UserArt_Bookmarked UserArtCategory = "bookmarks" // what this user has bookmarked; not art by this user
)
func (s UserArtCategory) Validate() error {
if s != UserArt_Any &&
s != UserArt_Illustration &&
s != UserArt_Manga &&
s != UserArt_Bookmarked {
return fmt.Errorf("Invalid work category: %#v. " + `only "%s", "%s", "%s" and "%s" are available`, s, UserArt_Any, UserArt_Illustration, UserArt_Manga, UserArt_Bookmarked)
} else {
return nil
}
}
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() error {
if string(s.SocialRaw[:]) == "[]" {
// Fuck Pixiv
return nil
}
err := json.Unmarshal(s.SocialRaw, &s.Social)
if err != nil {
return err
}
return nil
}
func GetFrequentTags(c *fiber.Ctx, ids string) ([]FrequentTag, error) {
var tags []FrequentTag
URL := http.GetFrequentTagsURL(ids)
response, err := http.UnwrapWebAPIRequest(c.Context(), 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(c.Context(), 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 string, category UserArtCategory, page int) (string, int, error) {
URL := http.GetUserArtworksURL(id)
resp, err := http.UnwrapWebAPIRequest(c.Context(), 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 == UserArt_Illustration || category == UserArt_Any {
for k := range illusts {
ids = append(ids, k)
count++
}
}
if category == UserArt_Manga || category == UserArt_Any {
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 string, category UserArtCategory, page int) (User, error) {
var user User
token := session.GetPixivToken(c)
URL := http.GetUserInformationURL(id)
resp, err := http.UnwrapWebAPIRequest(c.Context(), 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 := works[i].ID
right := works[j].ID
return numberGreaterThan(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
}
err = user.ParseSocial()
if err != nil {
return User{}, err
}
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(c.Context(), 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
}
func numberGreaterThan(l, r string) bool {
if len(l) > len(r) {
return true
}
if len(l) < len(r) {
return false
}
return l > r
}

View file

@ -0,0 +1,15 @@
package doc
const BuiltinProxyUrl = "/proxy/i.pximg.net" // built-in proxy route
// 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 are affiliated with.
"https://pixiv.ducks.party",
"https://pximg.cocomi.eu.org",
"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,137 @@
// 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.
//
// NOTE: See [How to get PIXIVFE_TOKEN](How-to-get-the-pixiv-token.md) for how to obtain your own token.
},
{
Name: "PIXIVFE_REQUESTLIMIT",
CommonName: "limit number of request per 30 seconds",
// **Required**: No
//
// Set this to a number to enable the built-in rate limiter. For example `PIXIVFE_REQUESTLIMIT=15`.
//
// It might be better to enable rate limiting in the reverse proxy in front of PixivFE rather than using this.
},
{
Name: "PIXIVFE_IMAGEPROXY",
CommonName: "image proxy server",
Value: BuiltinProxyUrl,
Announce: true,
// **Required**: No, defaults to using the built-in proxy
//
// NOTE: The protocol must be included in the URL, for example `https://piximg.example.com`, where `https://` is the protocol used.
//
// The URL of the image proxy server. Pixiv does not allow you to fetch their images directly, requiring `Referer: https://www.pixiv.net/` to be included in the HTTP request headers. For example, trying to directly access this [image](https://i.pximg.net/img-original/img/2023/06/06/20/30/01/108783513_p0.png) returns HTTP 403 Forbidden.
// This can be circumvented by using a reverse proxy that adds the required `Referer` HTTP request header to the HTTP request for the image. You can [host an image proxy server](Hosting-an-image-proxy-server-for-Pixiv.md), or see the [list of public image proxies](Built-in Proxy List.go). 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).

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

@ -0,0 +1,81 @@
# Roadmap
## To implement
/settings/
- [x] Merge login page with settings page
- [x] Persistence (http-only secure cookies)
- [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
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.2
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
)
replace github.com/gofiber/template/jet/v2 => github.com/iacore/template/jet/v2 v2.0.0-20240319184104-a6fac91c3493

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/fiber/v2 v2.52.2 h1:b0rYH6b06Df+4NyrbdptQL8ifuxw/Tf2DgfkZkDaxEo=
github.com/gofiber/fiber/v2 v2.52.2/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
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/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/template/jet/v2 v2.0.0-20240319184104-a6fac91c3493 h1:nR2rq9DataQ+2lf/wrqG1lS0qI0bIaL9GhMee4enHWk=
github.com/iacore/template/jet/v2 v2.0.0-20240319184104-a6fac91c3493/go.mod h1:VxznXztlv6HdUL3atN4zz+Qo7ynVkmQJU11Dr1a30p8=
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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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
}

294
main.go
View file

@ -1,28 +1,63 @@
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"
session "codeberg.org/vnpower/pixivfe/v2/core/session"
"codeberg.org/vnpower/pixivfe/v2/core/kmutex"
"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"
)
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.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??? https://github.com/gofiber/template/issues/341
err := engine.Load()
if err != nil {
panic(err)
}
server := fiber.New(fiber.Config{
AppName: "PixivFE",
@ -36,6 +71,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 +85,215 @@ 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,
server.Use(func(c *fiber.Ctx) error {
pageURL := c.BaseURL() + c.OriginalURL()
c.Bind(fiber.Map{
"BaseURL": c.BaseURL(),
"OriginalURL": c.OriginalURL(),
"PageURL": pageURL,
})
return c.Next()
})
KeyGenerator: func(c *fiber.Ctx) string {
return utils.CopyString(c.OriginalURL())
if config.GlobalServerConfig.RequestLimit > 0 {
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 (Hard)!", requestIP)
// close the connection immediately
_ = c.Context().Conn().Close()
return nil
}
// sleeping
// here, sleeping is not the best solution.
// todo: close this connection when this IP reaches hard limit
dur := time.Duration(retryAfter) * time.Second
log.Println("Limit Reached (Soft)! Sleeping for ", dur)
ctx, cancel := context.WithTimeout(c.Context(), dur)
defer cancel()
<-ctx.Done()
return c.Next()
},
}))
}
server.Use(logger.New(
logger.Config{
Format: "${time} +${latency} ${ip} ${method} ${path} ${status} ${error} \n",
Next: CanRequestSkipLogger,
CustomTags: map[string]logger.LogFunc{
// make latency always print in seconds
logger.TagLatency: 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("%.6f", 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; media-src 'self' %s; connect-src 'self'; form-action 'self'; frame-ancestors 'none';", session.GetImageProxyOrigin(c)))
// use this if need iframe: `frame-ancestors 'self'`
c.Set("Permissions-Policy", "accelerometer=(), ambient-light-sensor=(), battery=(), camera=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), navigation-override=(), payment=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()")
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})
return c.Next()
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")
server.Use(recover.New(recover.Config{EnableStackTrace: config.GlobalServerConfig.InDevelopment}))
// Routes
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.atom.xml", pages.UserAtomFeed)
server.Get("/users/:id/:category?.atom.xml", pages.UserAtomFeed)
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)
// Settings group
settings := server.Group("/settings")
settings.Get("/", pages.SettingsPage)
settings.Post("/:type", pages.SettingsPost)
// 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"))
})
// 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")
// 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)
// Routes/Views
views.SetupRoutes(server)
// Disable trusted proxies since we do not use any for now
// server.SetTrustedProxies(nil)
return server
}
func main() {
err := configs.ParseConfig()
configs.SetupStorage()
if err != nil {
panic(err)
// 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("Listening on domain socket %v\n", config.GlobalServerConfig.UnixSocket)
err = server.Listener(ln)
if err != nil {
panic(err)
}
} else {
addr := config.GlobalServerConfig.Host + ":" + config.GlobalServerConfig.Port
ln, err := net.Listen(server.Config().Network, addr)
if err != nil {
log.Panicf("failed to listen: %v", err)
}
addr = ln.Addr().String()
log.Printf("Listening on http://%v/\n", addr)
err = server.Listener(ln)
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)
}

128
pages/actions.go Normal file
View file

@ -0,0 +1,128 @@
package pages
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
session "codeberg.org/vnpower/pixivfe/v2/core/session"
"github.com/gofiber/fiber/v2"
"github.com/tidwall/gjson"
)
func pixivPostRequest(c *fiber.Ctx, url, payload, token, csrf string) error {
requestBody := []byte(payload)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody))
if err != nil {
return err
}
req = req.WithContext(c.Context())
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.GetPixivToken(c)
csrf := session.GetCookie(c, session.Cookie_CSRF)
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(c, URL, payload, token, csrf); err != nil {
return err
}
return c.SendString("Success")
}
func DeleteBookmarkRoute(c *fiber.Ctx) error {
token := session.GetPixivToken(c)
csrf := session.GetCookie(c, session.Cookie_CSRF)
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(c, URL, payload, token, csrf); err != nil {
return err
}
return c.SendString("Success")
}
func LikeRoute(c *fiber.Ctx) error {
token := session.GetPixivToken(c)
csrf := session.GetCookie(c, session.Cookie_CSRF)
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(c, URL, payload, token, csrf); err != nil {
return err
}
return c.SendString("Success")
}

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

@ -0,0 +1,51 @@
package pages
import (
"fmt"
"strconv"
"strings"
"sync"
core "codeberg.org/vnpower/pixivfe/v2/core/webapi"
"github.com/gofiber/fiber/v2"
)
func ArtworkMultiPage(c *fiber.Ctx) error {
ids_ := c.Params("ids")
ids := strings.Split(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 fmt.Errorf("Invalid ID: %s", 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 (
"fmt"
"strconv"
core "codeberg.org/vnpower/pixivfe/v2/core/webapi"
"github.com/gofiber/fiber/v2"
)
func ArtworkPage(c *fiber.Ctx) error {
id := c.Params("id")
if _, err := strconv.Atoi(id); err != nil {
return fmt.Errorf("Invalid ID: %s", id)
}
illust, err := core.GetArtworkByID(c, 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 {
id := c.Params("id")
if _, err := strconv.Atoi(id); err != nil {
return fmt.Errorf("Invalid ID: %s", id)
}
illust, err := core.GetArtworkByID(c, 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/session"
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.GetPixivToken(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",
})
}

38
pages/novel.go Normal file
View file

@ -0,0 +1,38 @@
package pages
import (
"fmt"
"strconv"
core "codeberg.org/vnpower/pixivfe/v2/core/webapi"
"github.com/gofiber/fiber/v2"
)
func NovelPage(c *fiber.Ctx) error {
id := c.Params("id")
if _, err := strconv.Atoi(id); err != nil {
return fmt.Errorf("Invalid ID: %s", id)
}
novel, err := core.GetNovelByID(c, id)
if err != nil {
return err
}
related, err := core.GetNovelRelated(c, id)
if err != nil {
return err
}
user, err := core.GetUserBasicInformation(c, novel.UserID)
if err != nil {
return err
}
return c.Render("pages/novel", fiber.Map{
"Novel": novel,
"NovelRelated": related,
"User": user,
"Title": novel.Title,
})
}

64
pages/personal.go Normal file
View file

@ -0,0 +1,64 @@
package pages
import (
"strconv"
"strings"
session "codeberg.org/vnpower/pixivfe/v2/core/session"
core "codeberg.org/vnpower/pixivfe/v2/core/webapi"
"github.com/gofiber/fiber/v2"
)
func LoginUserPage(c *fiber.Ctx) error {
token := session.GetPixivToken(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.GetPixivToken(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.GetPixivToken(c); token == "" {
return c.Redirect("/settings")
}
mode := c.Query("mode", "all")
page := c.Query("page", "1")
pageInt, err := strconv.Atoi(page)
if err != nil {
return err
}
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,
})
}

85
pages/proxy.go Normal file
View file

@ -0,0 +1,85 @@
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
}
req = req.WithContext(c.Context())
// 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 = req.WithContext(c.Context())
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
}
req = req.WithContext(c.Context())
// 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 := c.Context().Time()
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),
})
}

1
pages/search.go Normal file
View file

@ -0,0 +1 @@
package pages

127
pages/settings.go Normal file
View file

@ -0,0 +1,127 @@
package pages
import (
"errors"
"io"
"net/http"
"regexp"
session "codeberg.org/vnpower/pixivfe/v2/core/session"
httpc "codeberg.org/vnpower/pixivfe/v2/core/http"
"codeberg.org/vnpower/pixivfe/v2/doc"
"github.com/gofiber/fiber/v2"
)
// todo: allow clear proxy
// todo: allow clear all settings
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(c.Context(), 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, err := http.NewRequest("GET", "https://www.pixiv.net/en/artworks/115365120", nil)
if err != nil {
return err
}
req = req.WithContext(c.Context())
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
session.SetCookie(c, session.Cookie_Token, token)
session.SetCookie(c, session.Cookie_CSRF, csrf)
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 != "" {
session.SetCookie(c, session.Cookie_ImageProxy, token)
} else {
session.ClearCookie(c, session.Cookie_ImageProxy)
}
return nil
}
func setLogout(c *fiber.Ctx) error {
session.ClearCookie(c, session.Cookie_Token)
return nil
}
func resetAll(c *fiber.Ctx) error {
session.ClearAllCookies(c)
return nil
}
func SettingsPage(c *fiber.Ctx) error {
cookies := []fiber.Map{}
for _, name := range session.AllCookieNames {
value := session.GetCookie(c, name)
cookies = append(cookies, fiber.Map{
"Key": name,
"Value": value,
})
}
return c.Render("pages/settings", fiber.Map{
"CookieList": cookies,
"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)
case "reset-all":
err = resetAll(c)
default:
err = errors.New("No such setting is available.")
}
if err != nil {
return err
}
c.Redirect("/")
return nil
}

39
pages/tag.go Normal file
View file

@ -0,0 +1,39 @@
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")
queries["Ratio"] = c.Query("ratio", "")
name, err := url.PathUnescape(c.Params("name"))
if err != nil {
return err
}
page := c.Query("page", "1")
pageInt, err := strconv.Atoi(page)
if err != nil {
return err
}
tag, err := core.GetTagData(c, name)
if err != nil {
return err
}
result, err := core.GetSearch(c, queries["Category"], name, queries["Order"], queries["Mode"], queries["Ratio"], 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})
}

95
pages/user.go Normal file
View file

@ -0,0 +1,95 @@
package pages
import (
"math"
"strconv"
"time"
core "codeberg.org/vnpower/pixivfe/v2/core/webapi"
"github.com/gofiber/fiber/v2"
)
type userPageData struct {
user core.User
category core.UserArtCategory
pageLimit int
page int
}
func process(c *fiber.Ctx) (userPageData, error) {
id := c.Params("id")
if _, err := strconv.Atoi(id); err != nil {
return userPageData{}, err
}
category := core.UserArtCategory(c.Params("category", string(core.UserArt_Any)))
err := category.Validate()
if err != nil {
return userPageData{}, err
}
page_param := c.Query("page", "1")
page, err := strconv.Atoi(page_param)
if err != nil {
return userPageData{}, err
}
user, err := core.GetUserArtwork(c, id, category, page)
if err != nil {
return userPageData{}, err
}
var worksCount int
var worksPerPage float64
if category == core.UserArt_Bookmarked {
worksPerPage = 48.0
} else {
worksPerPage = 30.0
}
worksCount = user.ArtworksCount
pageLimit := int(math.Ceil(float64(worksCount) / worksPerPage))
return userPageData{user, category, pageLimit, page}, nil
}
func UserPage(c *fiber.Ctx) error {
data, err := process(c)
if err != nil {
return err
}
return c.Render("pages/user", fiber.Map{
"Title": data.user.Name,
"User": data.user,
"Category": data.category,
"PageLimit": data.pageLimit,
"Page": data.page,
"MetaImage": data.user.BackgroundImage,
})
}
func UserAtomFeed(c *fiber.Ctx) error {
data, err := process(c)
if err != nil {
return err
}
err = c.Render("pages/user.atom", fiber.Map{
"URL": string(c.Request().RequestURI()),
"Title": data.user.Name,
"User": data.user,
"Category": data.category,
"Updated": time.Now().Format(time.RFC3339),
"PageLimit": data.pageLimit,
"Page": data.page,
// "MetaImage": data.user.BackgroundImage,
}, "")
if err != nil {
return err
}
c.Context().SetContentType("application/atom+xml")
return nil
}

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

92
semgrep.yml Normal file
View file

@ -0,0 +1,92 @@
# Usage: semgrep scan -f semgrep.yml
rules:
- id: rule-0
message: "http requests made without *fiber.Ctx"
languages: [go]
severity: WARNING
patterns:
- pattern-either:
- pattern: |
http.UnwrapWebAPIRequest(...)
- pattern: |
http.WebAPIRequest(...)
- pattern-not-inside: |
func $FUNC(c *fiber.Ctx, ...) $RET {
...
}
# note: the below two rules autofix have slight problems. where `http` is sometimes "net/http". need minor manual tweaking after --autofix.
- id: rule-1-0
message: "find http requests made to Pixiv"
languages: [go]
severity: INFO
patterns:
- pattern: |
http.UnwrapWebAPIRequest($A, $B)
fix: |
http.UnwrapWebAPIRequest(c.Context(), $A, $B)
- id: rule-1-1
message: "find http requests made to Pixiv"
languages: [go]
severity: INFO
patterns:
- pattern: |
http.WebAPIRequest($A, $B)
fix: |
http.WebAPIRequest(c.Context(), $A, $B)
- 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
}
...
- id: rule-3
message: "http request without context"
languages: [go]
severity: WARNING
# severity: INVENTORY
patterns:
- pattern-inside: |
$REQ, $ERR := http.NewRequest($...ARGV)
...
- pattern-not: |
$REQ, $ERR := http.NewRequest($...ARGV)
if $ERR != nil {
...
}
$REQ = $REQ.WithContext($CTX)
...
fix: |
$REQ, err := http.NewRequest($...ARGV)
if err != nil {
return err
}
$REQ = $REQ.WithContext(c.Context())
- id: rule-4
message: "fmt.Sprint on string"
languages: [go]
severity: WARNING
pattern: |
fmt.Sprint(($S : string))
- id: rule-5
message: "unhandled error"
languages: [go]
severity: WARNING
pattern: |
(_ : error) = ...
- id: rule-6
message: "raw UserArtCategory string"
languages: [go]
severity: WARNING
pattern-either:
- pattern: |
($A : UserArtCategory) == "$B"

300
serve/template.go Normal file
View file

@ -0,0 +1,300 @@
package serve
import (
"fmt"
"html/template"
"math"
"math/rand"
"net/url"
"regexp"
"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{
"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))
},
}
}

1
staticcheck.conf Normal file
View file

@ -0,0 +1 @@
checks = ["inherit", "-ST1005"] # no "error strings should not be capitalized"

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

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