├── .babelrc.json
├── .eslintrc.js
├── .github
└── workflows
│ └── build.yml
├── .gitignore
├── .prettierrc
├── .vscode
└── tasks.json
├── LICENSE
├── Makefile
├── README.md
├── bin
├── alias.txt
├── android-chrome-192x192.png
├── android-chrome-512x512.png
├── apple-touch-icon.png
├── assets
│ ├── css
│ │ ├── main.323131fa4389df74d20b.css
│ │ └── main.323131fa4389df74d20b.css.map
│ ├── fonts
│ │ ├── noto-sans-display-v13-latin-500.eot
│ │ ├── noto-sans-display-v13-latin-500.svg
│ │ ├── noto-sans-display-v13-latin-500.ttf
│ │ ├── noto-sans-display-v13-latin-500.woff
│ │ ├── noto-sans-display-v13-latin-500.woff2
│ │ ├── noto-sans-display-v13-latin-600.eot
│ │ ├── noto-sans-display-v13-latin-600.svg
│ │ ├── noto-sans-display-v13-latin-600.ttf
│ │ ├── noto-sans-display-v13-latin-600.woff
│ │ └── noto-sans-display-v13-latin-600.woff2
│ └── js
│ │ └── main.206da974d305760970e5.js
├── blacklist.txt
├── cover.jpg
├── favicon-16x16.png
├── favicon-32x32.png
├── favicon.ico
├── metadata.json
├── robots.txt
└── templates
│ ├── about.html
│ ├── archive.html
│ ├── error.html
│ ├── feed.html
│ ├── footer.html
│ ├── head.html
│ ├── header.html
│ ├── index.html
│ ├── list.html
│ ├── pagination.html
│ ├── reader.html
│ ├── reader_pagination.html
│ ├── search.html
│ ├── sitemap.xml
│ ├── sort.html
│ ├── stats.html
│ ├── submissions.html
│ ├── submit.html
│ └── taxonomy.html
├── cache
├── cache.go
└── lru.go
├── cmd
├── dataServer
│ └── main.go
├── util
│ ├── api.go
│ ├── archive.go
│ ├── main.go
│ └── metadata.go
└── webServer
│ ├── api.go
│ ├── archive.go
│ ├── index.go
│ ├── list.go
│ ├── main.go
│ ├── submission.go
│ └── taxonomy.go
├── config
├── config.go
└── config.ini
├── contrib
├── nginx
│ ├── koushoku-cdn.conf.example
│ └── koushoku.conf.example
└── systemd
│ ├── koushoku-cdn.service.example
│ └── koushoku.service.example
├── database
├── database.go
└── schema.sql
├── errs
└── errs.go
├── go.mod
├── go.sum
├── models
├── archive.go
├── artist.go
├── boil_queries.go
├── boil_table_names.go
├── boil_types.go
├── boil_view_names.go
├── circle.go
├── magazine.go
├── parody.go
├── psql_upsert.go
├── submission.go
├── tag.go
└── users.go
├── modext
├── archive.go
├── artist.go
├── circle.go
├── magazine.go
├── parody.go
├── submission.go
├── tag.go
└── user.go
├── package.json
├── postcss.config.js
├── server
├── context.go
├── middlewares.go
├── server.go
├── template.go
└── template_helper.go
├── services
├── alias.go
├── archive.go
├── archive_favorite.go
├── archive_rels.go
├── archive_sql.go
├── artist.go
├── blacklist.go
├── circle.go
├── magazine.go
├── metadata.go
├── parody.go
├── stats.go
├── submission.go
├── tag.go
├── user.go
└── util.go
├── sqlboiler.toml
├── tsconfig.eslint.json
├── tsconfig.json
├── web
├── fonts
│ ├── noto-sans-display-v13-latin-500.eot
│ ├── noto-sans-display-v13-latin-500.svg
│ ├── noto-sans-display-v13-latin-500.ttf
│ ├── noto-sans-display-v13-latin-500.woff
│ ├── noto-sans-display-v13-latin-500.woff2
│ ├── noto-sans-display-v13-latin-600.eot
│ ├── noto-sans-display-v13-latin-600.svg
│ ├── noto-sans-display-v13-latin-600.ttf
│ ├── noto-sans-display-v13-latin-600.woff
│ └── noto-sans-display-v13-latin-600.woff2
├── head.html
├── main.ts
├── serviceWorker.ts
├── settings.ts
└── styles
│ ├── main.less
│ ├── main.v0.less
│ ├── normalize.css
│ └── variables.less
├── webpack.base.js
├── webpack.dev.js
├── webpack.prod.js
└── yarn.lock
/.babelrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@babel/preset-env", "@babel/preset-typescript"]
3 | }
4 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es6: true
5 | },
6 | extends: ["eslint:recommended", "prettier"],
7 | plugins: ["prettier"],
8 | globals: {
9 | Atomics: "readonly",
10 | SharedArrayBuffer: "readonly"
11 | },
12 | overrides: [
13 | {
14 | files: ["**/*.ts", "**/*.tsx"],
15 | extends: [
16 | "airbnb-base",
17 | "airbnb-typescript",
18 | "prettier",
19 | "plugin:@typescript-eslint/recommended",
20 | "plugin:import/typescript"
21 | ],
22 | parser: "@typescript-eslint/parser",
23 | parserOptions: {
24 | ecmaFeatures: { jsx: true },
25 | project: "./tsconfig.eslint.json",
26 | tsconfigRootDir: __dirname
27 | },
28 | plugins: ["@typescript-eslint", "prettier"],
29 | rules: {
30 | "no-continue": "off",
31 | "no-multi-assign": "off",
32 | "no-param-reassign": "off",
33 | "no-plusplus": "off",
34 | "no-return-assign": "off"
35 | }
36 | }
37 | ]
38 | };
39 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on:
4 | push:
5 | tags:
6 | - "v*.*.*"
7 |
8 | jobs:
9 | check:
10 | if: "github.repository == 'rs1703/koushoku'"
11 | name: Cancel previous actions
12 | runs-on: ubuntu-latest
13 |
14 | steps:
15 | - uses: styfle/cancel-workflow-action@0.9.1
16 | with:
17 | access_token: ${{ github.token }}
18 | all_but_latest: true
19 |
20 | build:
21 | if: "github.repository == 'rs1703/koushoku'"
22 | name: Build
23 | runs-on: ubuntu-latest
24 | needs: check
25 |
26 | steps:
27 | - uses: actions/checkout@v2
28 | with:
29 | submodules: recursive
30 |
31 | - uses: actions/setup-go@v3
32 | with:
33 | go-version: "^1.18.0"
34 |
35 | - run: make build
36 |
37 | - uses: borales/actions-yarn@v2.3.0
38 | with:
39 | cmd: install
40 |
41 | - uses: borales/actions-yarn@v2.3.0
42 | with:
43 | cmd: prod
44 |
45 | - uses: actions/upload-artifact@v3
46 | with:
47 | name: linux-386
48 | path: bin/386
49 | retention-days: 1
50 |
51 | - uses: actions/upload-artifact@v3
52 | with:
53 | name: linux-amd64
54 | path: bin/amd64
55 | retention-days: 1
56 |
57 | - uses: actions/upload-artifact@v3
58 | with:
59 | name: assets
60 | path: bin/assets
61 | retention-days: 1
62 |
63 | - uses: actions/upload-artifact@v3
64 | with:
65 | name: templates
66 | path: bin/templates
67 | retention-days: 1
68 |
69 | - uses: actions/upload-artifact@v3
70 | with:
71 | name: static
72 | path: |
73 | bin/android-chrome-192x192.png
74 | bin/android-chrome-512x512.png
75 | bin/apple-touch-icon.png
76 | bin/cover.jpg
77 | bin/favicon-16x16.png
78 | bin/favicon-32x32.png
79 | bin/favicon.ico
80 | bin/robots.txt
81 | retention-days: 1
82 |
83 | publish:
84 | if: "github.repository == 'rs1703/koushoku'"
85 | name: Publish
86 | runs-on: ubuntu-latest
87 | needs: [build]
88 |
89 | steps:
90 | - uses: actions/download-artifact@v3
91 | with:
92 | path: .
93 |
94 | - run: |
95 | cp -r {assets,templates} linux-386
96 | cp static/* linux-386
97 | zip -r linux-386.zip linux-386
98 |
99 | cp -r {assets,templates} linux-amd64
100 | cp static/* linux-amd64
101 | zip -r linux-amd64.zip linux-amd64
102 |
103 | - uses: softprops/action-gh-release@v1
104 | if: startsWith(github.ref, 'refs/tags/')
105 | with:
106 | files: linux-*.zip
107 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | config.ini
2 | !config/config.ini
3 | koushoku.ini
4 | koushoku.yaml
5 |
6 | build
7 | dist
8 | data
9 |
10 | *_generator.go
11 | *_dev.go
12 | *_test.go
13 | secret.go
14 |
15 | *.tar.*
16 | bash.sh
17 | contrib/nginx/*.conf
18 |
19 | *.bak
20 | *.service
21 |
22 | bin/*
23 | bin/app.webmanifest
24 | bin/assets/**/*.development.*
25 | bin/assets/favicon.ico
26 | !bin/templates
27 | !bin/assets
28 | !bin/alias.txt
29 | !bin/blacklist.txt
30 | !bin/robots.txt
31 | !bin/metadata.json
32 |
33 | !bin/cover.jpg
34 | !bin/app.webmanifest
35 | !bin/favicon.ico
36 | !bin/favicon-16x16.png
37 | !bin/favicon-32x32.png
38 | !bin/android-chrome-192x192.png
39 | !bin/android-chrome-512x512.png
40 | !bin/apple-touch-icon.png
41 |
42 | # If you prefer the allow list template instead of the deny list, see community template:
43 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
44 | #
45 | # Binaries for programs and plugins
46 | *.exe
47 | *.exe~
48 | *.dll
49 | *.so
50 | *.dylib
51 |
52 | # Test binary, built with `go test -c`
53 | *.test
54 |
55 | # Output of the go coverage tool, specifically when used with LiteIDE
56 | *.out
57 |
58 | # Dependency directories (remove the comment below to include it)
59 | # vendor/
60 |
61 | # Go workspace file
62 | go.work
63 |
64 | # Logs
65 | logs
66 | *.log
67 | npm-debug.log*
68 | yarn-debug.log*
69 | yarn-error.log*
70 | lerna-debug.log*
71 | .pnpm-debug.log*
72 |
73 | # Diagnostic reports (https://nodejs.org/api/report.html)
74 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
75 |
76 | # Runtime data
77 | pids
78 | *.pid
79 | *.seed
80 | *.pid.lock
81 |
82 | # Directory for instrumented libs generated by jscoverage/JSCover
83 | lib-cov
84 |
85 | # Coverage directory used by tools like istanbul
86 | coverage
87 | *.lcov
88 |
89 | # nyc test coverage
90 | .nyc_output
91 |
92 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
93 | .grunt
94 |
95 | # Bower dependency directory (https://bower.io/)
96 | bower_components
97 |
98 | # node-waf configuration
99 | .lock-wscript
100 |
101 | # Compiled binary addons (https://nodejs.org/api/addons.html)
102 | build/Release
103 |
104 | # Dependency directories
105 | node_modules/
106 | jspm_packages/
107 |
108 | # Snowpack dependency directory (https://snowpack.dev/)
109 | web_modules/
110 |
111 | # TypeScript cache
112 | *.tsbuildinfo
113 |
114 | # Optional npm cache directory
115 | .npm
116 |
117 | # Optional eslint cache
118 | .eslintcache
119 |
120 | # Optional stylelint cache
121 | .stylelintcache
122 |
123 | # Microbundle cache
124 | .rpt2_cache/
125 | .rts2_cache_cjs/
126 | .rts2_cache_es/
127 | .rts2_cache_umd/
128 |
129 | # Optional REPL history
130 | .node_repl_history
131 |
132 | # Output of 'npm pack'
133 | *.tgz
134 |
135 | # Yarn Integrity file
136 | .yarn-integrity
137 |
138 | # dotenv environment variable files
139 | .env
140 | .env.development.local
141 | .env.test.local
142 | .env.production.local
143 | .env.local
144 |
145 | # parcel-bundler cache (https://parceljs.org/)
146 | .cache
147 | .parcel-cache
148 |
149 | # Next.js build output
150 | .next
151 | out
152 |
153 | # Nuxt.js build / generate output
154 | .nuxt
155 | dist
156 |
157 | # Gatsby files
158 | .cache/
159 | # Comment in the public line in if your project uses Gatsby and not Next.js
160 | # https://nextjs.org/blog/next-9-1#public-directory-support
161 | # public
162 |
163 | # vuepress build output
164 | .vuepress/dist
165 |
166 | # vuepress v2.x temp and cache directory
167 | .temp
168 | .cache
169 |
170 | # Serverless directories
171 | .serverless/
172 |
173 | # FuseBox cache
174 | .fusebox/
175 |
176 | # DynamoDB Local files
177 | .dynamodb/
178 |
179 | # TernJS port file
180 | .tern-port
181 |
182 | # Stores VSCode versions used for testing VSCode extensions
183 | .vscode-test
184 |
185 | # yarn v2
186 | .yarn/cache
187 | .yarn/unplugged
188 | .yarn/build-state.yml
189 | .yarn/install-state.gz
190 | .pnp.*
191 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "avoid",
3 | "printWidth": 120,
4 | "semi": true,
5 | "singleQuote": false,
6 | "tabWidth": 2,
7 | "trailingComma": "none",
8 | "overrides": [
9 | {
10 | "files": ["*.less"],
11 | "options": {
12 | "parser": "less"
13 | }
14 | },
15 | {
16 | "files": ["*.html"],
17 | "options": {
18 | "parser": "go-template"
19 | }
20 | }
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | // See https://go.microsoft.com/fwlink/?LinkId=733558
3 | // for the documentation about the tasks.json format
4 | "version": "2.0.0",
5 | "tasks": [
6 | {
7 | "label": "Build Backend",
8 | "type": "shell",
9 | "command": "make build",
10 | "group": {
11 | "kind": "build"
12 | }
13 | }
14 | ]
15 | }
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | ARCHITECTURES=386 amd64
2 | LDFLAGS=-ldflags="-s -w"
3 |
4 | default: build
5 |
6 | all: vet test build build-view
7 |
8 | vet:
9 | go vet
10 |
11 | test:
12 | go test ./... -v -timeout 10m
13 |
14 | build:
15 | $(foreach GOARCH,$(ARCHITECTURES),\
16 | $(shell export GOARCH=$(GOARCH))\
17 | $(shell go build $(LDFLAGS) -o ./bin/$(GOARCH)/webServer ./cmd/webServer/...)\
18 | $(shell go build $(LDFLAGS) -o ./bin/$(GOARCH)/dataServer ./cmd/dataServer/...)\
19 | $(shell go build $(LDFLAGS) -o ./bin/$(GOARCH)/util ./cmd/util/...)\
20 | )\
21 |
22 | build-web:
23 | yarn install && yarn prod
24 |
25 | run:
26 | cd bin && ./webServer
27 |
28 | dev:
29 | cd bin && ./webServer -m development
30 |
31 | dev-web:
32 | yarn install && yarn dev
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # koushoku
2 |
3 | Source code of site [redacted] for those who are willing to run their own instance.
4 |
5 | ### How it serve and index the archives
6 |
7 | Archives and its files are served directly, without writing the files inside the archives into the disk (except for thumbnails). Archives inside the specified data directory will be indexed as long as it follows one of the following naming formats:
8 |
9 | - [Artist] Title (Magazine) [Foo] [Bar] [Crap] {tags kebab-case optional}
10 | - [Circle (Artist)] Title (Magazine) [Foo] [Bar] [Crap] {tags kebab-case optional}
11 |
12 | Archives will be indexed concurrently, and usually takes several minutes (~1m10s for around ~8k archives). You can decrease the maximum concurrent numbers if your server is overloaded.
13 |
14 | ## Prerequisites
15 |
16 | - Git
17 | - Go 1.18+
18 | - ImageMagick
19 | - Redis
20 |
21 | ## Setup
22 |
23 | ### Install the prerequisites
24 |
25 | ```sh
26 | # Arch-based distributions
27 | sudo pacman -Syu
28 | sudo pacman -S git go imagemagick postgresql redis
29 |
30 | # Debian-based distributions
31 | sudo apt-get install -y software-properties-common
32 | sudo add-apt-repository -y ppa:longsleep/golang-backports
33 |
34 | sudo apt-get update -y
35 | sudo apt-get install -y build-essential git golang-go postgresql imagemagick redis-server
36 | ```
37 |
38 | ### Initialize database cluster
39 |
40 | **Only for Arch-based distributions** - Before PostgreSQL can function correctly, the database cluster must be initialized - [wiki.archlinux.org](https://wiki.archlinux.org/title/PostgreSQL#Installation).
41 |
42 | ```sh
43 | echo initdb -D /var/lib/postgres/data | sudo su - postgres
44 | ```
45 |
46 | ### Enable and start PostgreSQL and Redis
47 |
48 | ```sh
49 | # Arch-based distributions
50 | systemctl --now enable postgresql redis
51 |
52 | # Debian-based distributions
53 | systemctl --now enable postgresql redis-server
54 | ```
55 |
56 | ### Create a new database and user/role
57 |
58 | ```sh
59 | sudo -u postgres psql --command "CREATE USER koushoku LOGIN SUPERUSER PASSWORD 'koushoku';"
60 | sudo -u postgres psql --command "CREATE DATABASE koushoku OWNER koushoku;"
61 | ```
62 |
63 | ### Build the back-end
64 |
65 | ```sh
66 | git clone https://github.com/rs1703/koushoku
67 | cd koushoku
68 | make build
69 | ```
70 |
71 | ## License
72 |
73 | **koushoku** is licensed under the [GNU General Public License v3.0](https://www.gnu.org/licenses/gpl-3.0.en.html).
74 |
--------------------------------------------------------------------------------
/bin/alias.txt:
--------------------------------------------------------------------------------
1 | artist:Blmanian:Blman
2 | artist:Eisuke:Esuke
3 | artist:Ichiko:Kameyoshi Ichiko
4 | artist:Ichinoseland:Ichinose Land
5 | artist:Ikuhana Niiro:Ikuhana Niro
6 | artist:IND Kary:Indo Curry
7 | circle:IND Kary:Inbou no Teikoku
8 | artist:Itouei:Itou Ei
9 | artist:Karma Tatsurou:Karma Tatsuro
10 | artist:Kisaragi Megu:Kisaragi Meg
11 | artist:Lob☆star:Lobstar
12 | artist:Masu:Ikematsu
13 | artist:Momoco:Momoko
14 | artist:Midori No Ruupe:Midori No Rupe
15 | artist:Mitiking:Michiking
16 | artist:Sanjuuro:Sanjuurou
17 | artist:Shibananasei:Shiba Nanasei
18 | artist:Ushino Kanadume:Ushino Kandume
19 | artist:Yoshima Nikki:Yokoshima Nikki
20 | artist:Yuzunoki Ichi:Yuzuno Kiichi
21 | artist:Kurosugatari:Kurosu Gatari
22 | artist:Satosaki:Satozaki
23 | tag:Heart Pupil:Heart Pupils
24 | tag:Ojousama:Ojou-sama
25 | tag:X ray:X-Ray
26 | tag:Genderbend:YWNBAW
27 | tag:Trans:YWNBAW
28 | tag:Loli:Uohhhhhhhhh 😭😭😭
29 | title:Elder Daughter's Circumstances:Eldest Daughter's Circumstances
30 | title:Our First:Our First...
31 | title:23_30 Bliss:23:30 Bliss
32 | title:Hypnosis Heaven - Challenge 01:Hypnosis Heaven - Challenge 1
33 | title:Sachi's Part-time Jo:Sachi's Part-time Job
34 | title:Sachi-chan no Arbeit Sachi's Part-time Job:Sachi's Part-time Job
35 | title:Sachi-chan no Arbeit 2 Sachi's Part-time Job 2:Sachi's Part-time Job 2
36 | title:Sachi-chan no Arbeit 3 Sachi's Part-time Job 3:Sachi's Part-time Job 3
37 | title:Sachi-chan no Arbeit 4 Sachi's Part-time Job 4:Sachi's Part-time Job 4
--------------------------------------------------------------------------------
/bin/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/bin/android-chrome-192x192.png
--------------------------------------------------------------------------------
/bin/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/bin/android-chrome-512x512.png
--------------------------------------------------------------------------------
/bin/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/bin/apple-touch-icon.png
--------------------------------------------------------------------------------
/bin/assets/fonts/noto-sans-display-v13-latin-500.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/bin/assets/fonts/noto-sans-display-v13-latin-500.eot
--------------------------------------------------------------------------------
/bin/assets/fonts/noto-sans-display-v13-latin-500.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/bin/assets/fonts/noto-sans-display-v13-latin-500.ttf
--------------------------------------------------------------------------------
/bin/assets/fonts/noto-sans-display-v13-latin-500.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/bin/assets/fonts/noto-sans-display-v13-latin-500.woff
--------------------------------------------------------------------------------
/bin/assets/fonts/noto-sans-display-v13-latin-500.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/bin/assets/fonts/noto-sans-display-v13-latin-500.woff2
--------------------------------------------------------------------------------
/bin/assets/fonts/noto-sans-display-v13-latin-600.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/bin/assets/fonts/noto-sans-display-v13-latin-600.eot
--------------------------------------------------------------------------------
/bin/assets/fonts/noto-sans-display-v13-latin-600.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/bin/assets/fonts/noto-sans-display-v13-latin-600.ttf
--------------------------------------------------------------------------------
/bin/assets/fonts/noto-sans-display-v13-latin-600.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/bin/assets/fonts/noto-sans-display-v13-latin-600.woff
--------------------------------------------------------------------------------
/bin/assets/fonts/noto-sans-display-v13-latin-600.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/bin/assets/fonts/noto-sans-display-v13-latin-600.woff2
--------------------------------------------------------------------------------
/bin/blacklist.txt:
--------------------------------------------------------------------------------
1 | artist:AI
2 | artist:Aduma Ren
3 | artist:amakuchi
4 | artist:amano ameno
5 | artist:Amano Miki
6 | artist:ban kazuyasu
7 | artist:gorgeous takarada
8 | artist:hachiya makoto
9 | artist:hyji
10 | artist:hitori
11 | artist:ichiei
12 | artist:IRON Y
13 | artist:Koutarou
14 | artist:mg joe
15 | artist:oto-love
16 | artist:Shiden Akira
17 | artist:The Amanoja9
18 | artist:Tonarino Esuichi
19 | artist:tsukino jyogi
20 | artist:type.90
21 | artist:yamamoto
22 | tag:ecchi
23 | tag:cg set
24 | tag:interview
25 | tag:non-h
26 | tag:yaoi
27 | tag:spread
28 | tag:spreads
29 | title:Bitch Stream Spread
30 | title:Elder Daughter's Circumstances
31 | title:Kairakuten BEAST 2020-09 Illustration Pyon-Kti
32 | title:Laid-Back Deserted Island Life With a Level 1 Princess Knight - Status Note: 01
33 | title:Let Your Smile Bloom Finale
34 | title*:Aoha Pin-Up
35 | title*:Bavel Pin-Up Girls
36 | title*:BEAST Cover
37 | title*:Cover Girl's Comic
38 | title*:Fairy Tale Worlds Gone Awry
39 | title*:Hand Refresh Milk House
40 | title*:Kairakuten Cover
41 | title*:Kairakuten Heroines
42 | title*:Kairakuten Pin-up
43 | title*:Karma 4-Panel Theater
44 | title*:Key-Visual Collection
45 | title*:Let Your Smile Bloom Chapter
46 | title*:School Regulation Violation
47 | title*:Shitty Lab -
48 | title*:Today's Addendum
49 | title*:Mt. Youth Libido
50 | title*:X-Eros Pinup
51 | title*:X-Eros Girls Channel
52 | title*:48 Sex Positions
--------------------------------------------------------------------------------
/bin/cover.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/bin/cover.jpg
--------------------------------------------------------------------------------
/bin/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/bin/favicon-16x16.png
--------------------------------------------------------------------------------
/bin/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/bin/favicon-32x32.png
--------------------------------------------------------------------------------
/bin/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/bin/favicon.ico
--------------------------------------------------------------------------------
/bin/templates/about.html:
--------------------------------------------------------------------------------
1 | {{- define "about.html" }}
2 |
3 |
4 | {{- template "head" . }}
5 |
6 | {{- template "header" . }}
7 |
8 | About
9 | Lorem ipsum dolor sit amet.
10 |
11 | {{- template "footer" . }}
12 |
13 |
14 | {{- end }}
15 |
--------------------------------------------------------------------------------
/bin/templates/error.html:
--------------------------------------------------------------------------------
1 | {{- define "error.html" -}}
2 |
3 |
4 | {{- template "head" . }}
5 |
6 | {{- template "header" . }}
7 |
8 | {{ .status }} {{ .statusText }}
9 | {{- if .errors }}
10 | {{- range .errors }}
11 | {{ . }}
12 | {{- end }}
13 | {{- else }}
14 | {{ .error }}
15 | {{- end }}
16 |
17 | {{- template "footer" . }}
18 |
19 |
20 | {{- end }}
21 |
--------------------------------------------------------------------------------
/bin/templates/feed.html:
--------------------------------------------------------------------------------
1 | {{- define "feed" }}
2 | {{- $dataBaseURL := .dataBaseURL }}
3 |
53 | {{- end }}
54 |
--------------------------------------------------------------------------------
/bin/templates/footer.html:
--------------------------------------------------------------------------------
1 | {{- define "footer" }}
2 |
11 | {{- end }}
12 |
--------------------------------------------------------------------------------
/bin/templates/head.html:
--------------------------------------------------------------------------------
1 | {{- define "head" -}} {{- $title := .title -}} {{- if .archive -}} {{- $title = printf "%s - %s" .archive.Title .title -}} {{- else -}} {{- $title = printf "%s - %s" .name .title -}} {{- end -}}{{ $title }} {{- if .archive -}} {{- $title = .archive.Title -}} {{- if and .archive.Artists (eq (len .archive.Artists) 1) -}} {{- $artist := (index .archive.Artists 0) -}} {{- $title = printf "%s by %s" .archive.Title $artist.Name -}} {{- end -}} {{- end -}} {{- $description := "" -}} {{- if .archive -}} {{- $artists := "" -}} {{- range $i, $v := .archive.Artists -}} {{- if $i -}} {{- $artists = printf "%s," $artists -}} {{- end -}} {{- $artists = printf "%s %s" $artists .Name -}} {{- end -}} {{- $tags := "" -}} {{- range $i, $v := .archive.Tags -}} {{- if $i -}} {{- $tags = printf "%s," $tags -}} {{- end -}} {{- $tags = printf "%s %s" $tags .Name -}} {{- end -}} {{- if $tags -}} {{- $description = printf "Read or download %s by %s. %s." .archive.Title $artists $tags -}} {{- else -}} {{- $description = printf "Read or download %s by %s." .archive.Title $artists -}} {{- end -}} {{- end -}} {{- if $description -}} {{- end -}} {{- if .archive -}} {{- else -}} {{- end -}} {{- $img := printf "%s/cover.jpg" .baseURL -}} {{- if .archive -}} {{- $img = printf "%s/data/%d/1/896.webp" .dataBaseURL .archive.ID -}} {{- else }} {{- end -}} {{- if $img -}} {{- if .archive -}} {{- else -}} {{- end -}} {{- end -}} {{- end }}
--------------------------------------------------------------------------------
/bin/templates/header.html:
--------------------------------------------------------------------------------
1 | {{- define "header" }}
2 |
53 | {{- end }}
54 |
--------------------------------------------------------------------------------
/bin/templates/index.html:
--------------------------------------------------------------------------------
1 | {{- define "index.html" }}
2 |
3 |
4 | {{- template "head" . }}
5 |
6 | {{- template "header" . }}
7 |
8 |
9 | {{- if .archives }}
10 |
11 | {{- template "sort". }}
12 | {{- template "pagination" . }}
13 |
14 | {{- template "feed" . }}
15 |
16 | {{- template "pagination" . }}
17 |
18 | {{- end }}
19 |
20 |
21 | {{- template "footer" . }}
22 |
23 |
24 | {{- end }}
25 |
--------------------------------------------------------------------------------
/bin/templates/list.html:
--------------------------------------------------------------------------------
1 | {{- define "list.html" }}
2 |
3 |
4 | {{- template "head" . }}
5 |
6 | {{- template "header" . }}
7 |
8 |
9 |
10 | {{ .taxonomyTitle }} ({{ .total }})
11 | {{- if .data }}
12 | {{- template "pagination" . }}
13 | {{- end }}
14 |
15 | {{- if .data }}
16 |
17 | {{- $taxonomy := .taxonomy }}
18 | {{- range .data }}
19 |
25 | {{- end }}
26 |
27 |
28 | {{- template "pagination" . }}
29 |
30 | {{- end }}
31 |
32 |
33 | {{- template "footer" . }}
34 |
35 |
36 | {{- end }}
37 |
--------------------------------------------------------------------------------
/bin/templates/pagination.html:
--------------------------------------------------------------------------------
1 | {{- define "pagination" }}
2 | {{- if .pagination }}
3 | {{- if gt .pagination.TotalPages 1 }}
4 | {{- $query := .query }}
5 | {{- $currentPage := .pagination.CurrentPage }}
6 |
61 | {{- end }}
62 | {{- end }}
63 | {{- end }}
64 |
--------------------------------------------------------------------------------
/bin/templates/reader.html:
--------------------------------------------------------------------------------
1 | {{- define "reader.html" }}
2 |
3 |
4 | {{- template "head" . }}
5 |
11 |
12 | {{- template "reader_pagination". }}
13 |
14 |
15 | {{- $next := .pageNum }}
16 | {{- if lt $next .archive.Pages }}
17 | {{- $next = (inc $next) }}
18 | {{- end }}
19 |
20 |
21 |
22 |
23 |
24 | {{- template "reader_pagination". }}
25 |
26 |
27 |
28 | {{- end }}
29 |
--------------------------------------------------------------------------------
/bin/templates/reader_pagination.html:
--------------------------------------------------------------------------------
1 | {{- define "reader_pagination" }}
2 |
3 |
4 |
15 |
16 |
17 |
18 |
19 | {{- $prev := .pageNum }}
20 | {{- if gt $prev 1 }}
21 | {{- $prev = (dec $prev) }}
22 | {{- end }}
23 |
24 |
35 |
36 |
37 |
38 |
39 |
40 |
51 |
52 |
53 |
54 | {{ .pageNum }} of {{ .archive.Pages }}
55 | {{- $next := .pageNum }}
56 | {{- if lt $next .archive.Pages }}
57 | {{- $next = (inc $next) }}
58 | {{- end }}
59 |
60 |
71 |
72 |
73 |
74 |
75 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
103 |
104 |
105 |
106 |
107 |
108 |
?
109 |
110 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
139 |
140 |
143 |
144 |
145 |
146 |
147 | {{- end }}
148 |
--------------------------------------------------------------------------------
/bin/templates/search.html:
--------------------------------------------------------------------------------
1 | {{- define "search.html" }}
2 |
3 |
4 | {{- template "head" . }}
5 |
6 | {{- template "header" . }}
7 |
8 |
9 |
10 | Search Results ({{ .total }})
11 | {{- if .archives }}
12 | {{- template "sort". }}
13 | {{- template "pagination" . }}
14 | {{- end }}
15 |
16 | {{- if .archives }}
17 | {{- template "feed" . }}
18 |
19 | {{- template "pagination" . }}
20 |
21 | {{- else }}
22 |
23 | {{- if .hasQueries }}
24 |
No results found
25 |
There are no results that match your search criteria
26 | {{- else }}
27 |
Not yet available
28 | {{- end }}
29 |
30 | {{- end }}
31 |
32 |
33 | {{- template "footer" . }}
34 |
35 |
36 | {{- end }}
37 |
--------------------------------------------------------------------------------
/bin/templates/sitemap.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ baseURL }}
5 | hourly
6 | 1.0
7 |
8 |
9 | {{ baseURL }}/artists
10 | daily
11 | 1.0
12 |
13 |
14 | {{ baseURL }}/magazines
15 | daily
16 | 1.0
17 |
18 |
19 | {{ baseURL }}/tags
20 | daily
21 | 1.0
22 |
23 | {{- range .artists }}
24 |
25 | {{ baseURL }}/artists/{{ .Slug }}
26 | daily
27 |
28 | {{- end }}
29 | {{- range .magazines }}
30 |
31 | {{ baseURL }}/magazines/{{ .Slug }}
32 | daily
33 |
34 | {{- end }}
35 | {{- range .tags }}
36 |
37 | {{ baseURL }}/tags/{{ .Slug }}
38 | daily
39 |
40 | {{- end }}
41 | {{- range .archives }}
42 |
43 | {{ baseURL }}/archive/{{ .ID }}/{{ .Slug }}
44 | {{ formatUnix .UpdatedAt "2006-01-02" }}
45 | weekly
46 |
47 | {{- end }}
48 |
--------------------------------------------------------------------------------
/bin/templates/sort.html:
--------------------------------------------------------------------------------
1 | {{- define "sort" }}
2 |
59 | {{- end }}
60 |
--------------------------------------------------------------------------------
/bin/templates/stats.html:
--------------------------------------------------------------------------------
1 | {{- define "stats.html" }}
2 |
3 |
4 | {{- template "head" . }}
5 |
6 | {{- template "header" . }}
7 |
8 | Stats
9 |
10 |
11 | Number of archives
12 | {{ .stats.ArchiveCount }}
13 |
14 |
15 | Total number of pages
16 | {{ .stats.PageCount }}
17 |
18 |
19 | Average number of pages
20 | {{ .stats.AveragePageCount }}
21 |
22 |
23 | Total archive filesize
24 | {{ .stats.Size }} bytes ({{ formatBytes .stats.Size }})
25 |
26 |
27 | Average archive filesize
28 | {{ .stats.AverageSize }} bytes ({{ formatBytes .stats.AverageSize }})
29 |
30 |
31 | Number of artists
32 | {{ .stats.ArtistCount }}
33 |
34 |
35 | Number of circles
36 | {{ .stats.CircleCount }}
37 |
38 |
39 | Number of magazines
40 | {{ .stats.MagazineCount }}
41 |
42 |
43 | Number of parodies
44 | {{ .stats.ParodyCount }}
45 |
46 |
47 | Number of tags
48 | {{ .stats.TagCount }}
49 |
50 |
51 |
52 | *CG sets, illustrations, non-h, spreads, westerns, FURRY and 1-page manga/doujins are not indexed.
55 |
56 | Analytics
57 |
58 |
59 | Data Served
60 | {{ formatBytes .stats.Analytics.Bytes }}
61 |
62 |
63 | Cached Data
64 | {{ formatBytes .stats.Analytics.CachedBytes }}
65 |
66 |
67 | Requests
68 | {{ formatNumber .stats.Analytics.Requests }}
69 |
70 |
71 | Cached Requests
72 | {{ formatNumber .stats.Analytics.CachedRequests }}
73 |
74 |
75 |
76 |
77 |
78 | Last Updated: {{ formatTime .stats.Analytics.LastUpdated "Mon, 02 Jan 2006 15:04:05 MST" }}
79 |
80 | *Resets yearly
81 |
82 | {{- template "footer" . }}
83 |
84 |
85 |
90 |
208 |
209 |
210 | {{- end }}
211 |
--------------------------------------------------------------------------------
/bin/templates/submissions.html:
--------------------------------------------------------------------------------
1 | {{- define "submissions.html" }}
2 |
3 |
4 | {{- template "head" . }}
5 |
6 | {{- template "header" . }}
7 |
8 |
9 | Submissions ({{ .total }})
10 | {{- if .data }}
11 | {{- template "pagination" . }}
12 | {{- end }}
13 |
14 | {{- if .data }}
15 |
16 |
17 |
18 |
19 | ID
20 | Name
21 | Submitter
22 | Submitted
23 |
24 |
25 |
26 | {{- $format := "Mon, 02 Jan 2006 15:04:05 MST" }}
27 | {{- range .data }}
28 |
29 |
30 | {{ .ID }}
31 |
32 |
33 | {{ .Name }}
34 |
35 |
36 | {{- if .Submitter }}
37 | {{ .Submitter }}
38 | {{- else }}
39 | anonymous
40 | {{- end }}
41 |
42 |
43 | {{ formatUnix .CreatedAt $format }}
44 |
45 |
46 |
47 |
48 |
49 | {{- if .Accepted }}
50 | Accepted {{ formatUnix .AcceptedAt $format }}
51 | {{- else }}
52 | Rejected {{ formatUnix .RejectedAt $format }}
53 | {{- end }}
54 |
55 |
56 | {{- if or .Archives .Notes }}
57 |
58 | {{- if .Notes }}
59 |
60 | Note:
61 | {{ .Notes }}
62 |
63 | {{- end }}
64 | {{- if .Archives }}
65 |
70 | {{- end }}
71 |
72 | {{- end }}
73 |
74 | {{- end }}
75 |
76 |
77 |
78 | {{- end }}
79 |
80 | {{- template "footer" . }}
81 |
82 |
83 | {{- end }}
84 |
--------------------------------------------------------------------------------
/bin/templates/submit.html:
--------------------------------------------------------------------------------
1 | {{- define "submit.html" }}
2 |
3 |
4 | {{- template "head" . }}
5 |
6 | {{- template "header" . }}
7 |
8 | {{- if .error }}
9 | {{ .error }}
10 | {{- end }}
11 | {{- if .message }}
12 | {{ .message }}
13 | {{- end }}
14 | Submit
15 |
16 | Submission has a limit of 10.240 characters and URLs must be separated by new lines. You can only submit 10
17 | submissions per day and they will be reviewed manually within less than 7 days.
18 |
19 | Rules for submission
20 |
21 | No non-h and 1-page manga/doujins
22 | No watermarks and compressions
23 | No downscaling or force upscaling
24 |
25 |
26 | URL must be either a site which allow downloading, a direct download link or a torrent. To replace an existing
27 | archive with a better version, put [Replace] or [Revision] prefix in the title.
28 |
29 |
52 |
53 | {{- template "footer" . }}
54 |
55 |
56 | {{- end }}
57 |
--------------------------------------------------------------------------------
/bin/templates/taxonomy.html:
--------------------------------------------------------------------------------
1 | {{- define "taxonomy.html" }}
2 |
3 |
4 | {{- template "head" . }}
5 |
6 | {{- template "header" . }}
7 |
8 |
9 |
10 | {{ .taxonomy }} ({{ .total }})
11 | {{- if .archives }}
12 | {{- template "sort". }}
13 | {{- template "pagination" . }}
14 | {{- end }}
15 |
16 | {{- if .archives }}
17 | {{- template "feed" . }}
18 |
19 | {{- template "pagination" . }}
20 |
21 | {{- else }}
22 |
23 | {{- if .hasQueries }}
24 |
No results found
25 |
There are no results that match your search criteria
26 | {{- else }}
27 |
Not yet available
28 | {{- end }}
29 |
30 | {{- end }}
31 |
32 |
33 | {{- template "footer" . }}
34 |
35 |
36 | {{- end }}
37 |
--------------------------------------------------------------------------------
/cache/cache.go:
--------------------------------------------------------------------------------
1 | package cache
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log"
7 | "time"
8 |
9 | . "koushoku/config"
10 |
11 | "github.com/go-redis/redis/v8"
12 | )
13 |
14 | var Redis *redis.Client
15 | var Archives *LRU
16 | var Taxonomies *LRU
17 | var Templates *LRU
18 | var Submissions *LRU
19 |
20 | var Users *LRU
21 | var Favorites *LRU
22 | var Cache *LRU
23 |
24 | const defaultExpr = time.Hour
25 | const templateExpr = 5 * time.Minute
26 |
27 | func Init() {
28 | Redis = redis.NewClient(&redis.Options{
29 | Addr: fmt.Sprintf("%s:%d", Config.Redis.Host, Config.Redis.Port),
30 | DB: Config.Redis.DB,
31 | Password: Config.Redis.Passwd,
32 | })
33 | if result := Redis.Ping(context.Background()); result.Err() != nil {
34 | log.Fatalln(result.Err())
35 | }
36 |
37 | Archives = New(4096, defaultExpr)
38 | Taxonomies = New(4096, defaultExpr)
39 | Templates = New(4096, templateExpr)
40 | Submissions = New(4096, defaultExpr)
41 | Users = New(2048, defaultExpr)
42 | Favorites = New(2048, defaultExpr)
43 | Cache = New(512, defaultExpr)
44 | }
45 |
--------------------------------------------------------------------------------
/cache/lru.go:
--------------------------------------------------------------------------------
1 | package cache
2 |
3 | import (
4 | "fmt"
5 | "strconv"
6 | "strings"
7 | "time"
8 |
9 | "github.com/bluele/gcache"
10 | )
11 |
12 | // LRU is a wrapper for gcache.Cache.
13 | // It implements type-safe methods that
14 | // only accept string or int64 keys.
15 | type LRU struct {
16 | instance gcache.Cache
17 | }
18 |
19 | // New creates a new LRU cache.
20 | func New(size int, expr time.Duration) *LRU {
21 | return &LRU{gcache.New(size).LRU().Expiration(expr).Build()}
22 | }
23 |
24 | // Get gets a value from the cache by using string key.
25 | func (c *LRU) Get(key string) (any, error) {
26 | return c.instance.Get(key)
27 | }
28 |
29 | // Set sets a value to the cache by using string key.
30 | func (c *LRU) Set(key string, value any, ttl time.Duration) error {
31 | if ttl > 0 {
32 | return c.instance.SetWithExpire(key, value, ttl)
33 | }
34 | return c.instance.Set(key, value)
35 | }
36 |
37 | // Remove removes a value from the cache by using string key.
38 | func (c *LRU) Remove(key string) bool {
39 | return c.instance.Remove(key)
40 | }
41 |
42 | // Has checks if the cache has the key.
43 | func (c *LRU) Has(key string) bool {
44 | return c.instance.Has(key)
45 | }
46 |
47 | // Keys gets all the keys in the cache.
48 | func (c *LRU) Keys() []any {
49 | return c.instance.Keys(false)
50 | }
51 |
52 | // Purge purges all the cache.
53 | func (c *LRU) Purge() {
54 | c.instance.Purge()
55 | }
56 |
57 | // GetWIthInt64 gets a value from the cache by using int64 key.
58 | func (c *LRU) GetWithInt64(cid int64) (any, error) {
59 | return c.instance.Get(strconv.Itoa(int(cid)))
60 | }
61 |
62 | // SetWithInt64 sets a value to the cache by using int64 key.
63 | func (c *LRU) SetWithInt64(cid int64, value any, ttl time.Duration) error {
64 | if ttl > 0 {
65 | return c.instance.SetWithExpire(strconv.Itoa(int(cid)), value, ttl)
66 | }
67 | return c.instance.Set(strconv.Itoa(int(cid)), value)
68 | }
69 |
70 | // RemoveWithInt64 removes a value from the cache by using int64 key.
71 | func (c *LRU) RemoveWithInt64(cid int64) bool {
72 | return c.instance.Remove(strconv.Itoa(int(cid)))
73 | }
74 |
75 | // HasWithInt64 checks if the cache has the key.
76 | func (c *LRU) HasWithInt64(cid int64) bool {
77 | return c.instance.Has(strconv.Itoa(int(cid)))
78 | }
79 |
80 | // GetWithPrefix gets a value from the cache by using prefix and key.
81 | // Type of prefix and key can be string or int64.
82 | func (c *LRU) GetWithPrefix(prefix, key any) (any, error) {
83 | return c.instance.Get(fmt.Sprintf("/%v/%v", prefix, key))
84 | }
85 |
86 | // SetWithPrefix sets a value to the cache by using prefix and key.
87 | // Type of prefix and key can be string or int64.
88 | func (c *LRU) SetWithPrefix(prefix, key, value any, ttl time.Duration) error {
89 | if ttl > 0 {
90 | return c.instance.SetWithExpire(fmt.Sprintf("/%v/%v", prefix, key), value, ttl)
91 | }
92 | return c.instance.Set(fmt.Sprintf("/%v/%v", prefix, key), value)
93 | }
94 |
95 | // RemoveWithPrefix removes a value from the cache by using prefix and key.
96 | // Type of prefix and key can be string or int64.
97 | func (c *LRU) RemoveWithPrefix(prefix, key any) bool {
98 | return c.instance.Remove(fmt.Sprintf("/%v/%v", prefix, key))
99 | }
100 |
101 | // HasWithPrefix checks if the cache has the key.
102 | func (c *LRU) HasWithPrefix(prefix, key any) bool {
103 | return c.instance.Has(fmt.Sprintf("/%v/%v", prefix, key))
104 | }
105 |
106 | // PurgeWithPrefix purges all the cache with the given prefix.
107 | // Type of prefix can be string or int64.
108 | func (c *LRU) PurgeWithPrefix(prefix any) {
109 | for _, k := range c.instance.Keys(false) {
110 | if strings.HasPrefix(fmt.Sprintf("%v", k), fmt.Sprintf("/%v/", prefix)) {
111 | c.instance.Remove(k)
112 | }
113 | }
114 | }
115 |
116 | // KeysWithPrefix gets all the keys with the given prefix.
117 | // Type of prefix can be string or int64.
118 | func (c *LRU) KeysWithPrefix(prefix any) []string {
119 | var keys []string
120 | for _, k := range c.instance.Keys(false) {
121 | if strings.HasPrefix(fmt.Sprintf("%v", k), fmt.Sprintf("/%v/", prefix)) {
122 | keys = append(keys, strings.TrimPrefix(fmt.Sprintf("%v", k), fmt.Sprintf("/%v/", prefix)))
123 | }
124 | }
125 | return keys
126 | }
127 |
128 | // CacheStats represents the stats of the LRU cache.
129 | type CacheStats struct {
130 | Size int `json:"size"`
131 | HitCount uint64 `json:"hitCount"`
132 | HitRate float64 `json:"hitRate"`
133 | MissCount uint64 `json:"missCount"`
134 | LookupCount uint64 `json:"lookupCount"`
135 | }
136 |
137 | // GetStats returns the stats of the LRU cache.
138 | func (c *LRU) GetStats() *CacheStats {
139 | return &CacheStats{
140 | Size: c.instance.Len(true),
141 | HitCount: c.instance.HitCount(),
142 | HitRate: c.instance.HitRate(),
143 | MissCount: c.instance.MissCount(),
144 | LookupCount: c.instance.LookupCount(),
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/cmd/dataServer/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "archive/zip"
5 | "bytes"
6 | "fmt"
7 | "io"
8 | "mime"
9 | "net/http"
10 | "os"
11 | "path/filepath"
12 | "sort"
13 | "strconv"
14 | "strings"
15 |
16 | . "koushoku/config"
17 | "koushoku/server"
18 | "koushoku/services"
19 | )
20 |
21 | func main() {
22 | server.Init()
23 |
24 | server.GET("/archive/:id/:slug/download", download)
25 | server.HEAD("/archive/:id/:slug/download", download)
26 | server.GET("/data/:id/:pageNum", serve)
27 | server.GET("/data/:id/:pageNum/*width", serve)
28 |
29 | server.NoRoute(func(c *server.Context) {
30 | c.Redirect(http.StatusFound, Config.Meta.BaseURL)
31 | })
32 |
33 | server.Start(Config.Server.DataPort)
34 | }
35 |
36 | func download(c *server.Context) {
37 | id, err := c.ParamInt64("id")
38 | if err != nil {
39 | c.Status(http.StatusBadRequest)
40 | return
41 | }
42 |
43 | fp, err := services.GetArchiveSymlink(int(id))
44 | if err != nil {
45 | c.Status(http.StatusInternalServerError)
46 | return
47 | } else if len(fp) == 0 {
48 | c.Status(http.StatusNotFound)
49 | return
50 | }
51 |
52 | stat, err := os.Stat(fp)
53 | if err != nil {
54 | c.Status(http.StatusInternalServerError)
55 | return
56 | }
57 |
58 | c.Header("Accept-Ranges", "bytes")
59 | c.Header("Connection", "keep-alive")
60 | c.Header("Last-Modified", stat.ModTime().UTC().Format(http.TimeFormat))
61 |
62 | c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", stat.Name()))
63 | c.Header("Content-Length", strconv.FormatInt(stat.Size(), 10))
64 | c.Header("Content-Type", mime.TypeByExtension(filepath.Ext(fp)))
65 | c.Header("Content-Range", fmt.Sprintf("bytes 0-%d/%d", stat.Size()-1, stat.Size()))
66 |
67 | if c.Request.Method == http.MethodHead {
68 | return
69 | }
70 |
71 | http.ServeFile(c.Writer, c.Request, fp)
72 | }
73 |
74 | func createThumbnail(c *server.Context, f io.Reader, fp string, w int) (ok bool) {
75 | tmp, err := os.CreateTemp("", "tmp-")
76 | if err != nil {
77 | c.Status(http.StatusInternalServerError)
78 | return
79 | }
80 | defer func() {
81 | tmp.Close()
82 | os.Remove(tmp.Name())
83 | }()
84 |
85 | if _, err := io.Copy(tmp, f); err != nil {
86 | c.Status(http.StatusInternalServerError)
87 | return
88 | }
89 |
90 | opts := services.ResizeOptions{Width: w, Height: w * 3 / 2}
91 | if err := services.ResizeImage(tmp.Name(), fp, opts); err != nil {
92 | c.Status(http.StatusInternalServerError)
93 | return
94 | }
95 | return true
96 | }
97 |
98 | func serve(c *server.Context) {
99 | id, err := c.ParamInt("id")
100 | if err != nil {
101 | c.Status(http.StatusInternalServerError)
102 | return
103 | }
104 |
105 | pageNum := services.GetPageNum(c.Param("pageNum"))
106 | if pageNum <= 0 {
107 | c.Status(http.StatusBadRequest)
108 | return
109 | }
110 |
111 | str := strings.TrimPrefix(c.Param("width"), "/")
112 | width, _ := strconv.Atoi(strings.TrimSuffix(str, filepath.Ext(str)))
113 |
114 | path, err := services.GetArchiveSymlink(id)
115 | if err != nil {
116 | c.Status(http.StatusInternalServerError)
117 | return
118 | } else if len(path) == 0 {
119 | c.Status(http.StatusNotFound)
120 | return
121 | }
122 |
123 | var fp string
124 | if (pageNum == 1 && (width == 288 || width == 896)) || width == 320 {
125 | fp = filepath.Join(Config.Directories.Thumbnails, fmt.Sprintf("%d-%d.%d.webp", id, pageNum, width))
126 | if _, err := os.Stat(fp); err == nil {
127 | c.ServeFile(fp)
128 | return
129 | }
130 | }
131 |
132 | zf, err := zip.OpenReader(path)
133 | if err != nil {
134 | c.Status(http.StatusInternalServerError)
135 | return
136 | }
137 | defer zf.Close()
138 |
139 | var files []*zip.File
140 | for _, f := range zf.File {
141 | stat := f.FileInfo()
142 | name := stat.Name()
143 |
144 | if stat.IsDir() || !services.IsImage(name) {
145 | continue
146 | }
147 | files = append(files, f)
148 | }
149 |
150 | index := pageNum - 1
151 | if index > len(files) {
152 | c.Status(http.StatusNotFound)
153 | return
154 | }
155 |
156 | sort.SliceStable(files, func(i, j int) bool {
157 | return services.GetPageNum(filepath.Base(files[i].Name)) < services.GetPageNum(filepath.Base(files[j].Name))
158 | })
159 |
160 | file := files[index]
161 | stat := file.FileInfo()
162 |
163 | f, err := file.Open()
164 | if err != nil {
165 | c.Status(http.StatusInternalServerError)
166 | return
167 | }
168 | defer f.Close()
169 |
170 | if len(fp) > 0 {
171 | if createThumbnail(c, f, fp, width) {
172 | c.ServeFile(fp)
173 | }
174 | } else {
175 | buf, err := io.ReadAll(f)
176 | if err != nil {
177 | c.Status(http.StatusInternalServerError)
178 | return
179 | }
180 | c.ServeData(stat, bytes.NewReader(buf))
181 | }
182 | }
183 |
--------------------------------------------------------------------------------
/cmd/util/api.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "log"
8 | "net"
9 | "net/http"
10 | "strconv"
11 |
12 | . "koushoku/config"
13 | )
14 |
15 | type ApiOptions struct {
16 | ApiKey string `json:"key"`
17 | }
18 |
19 | type PurgeCacheOptions struct {
20 | ApiOptions
21 | Archives bool `json:"archives,omitempty"`
22 | Taxonomies bool `json:"taxonomies,omitempty"`
23 | Templates bool `json:"templates,omitempty"`
24 | Submissions bool `json:"submissions,omitempty"`
25 | }
26 |
27 | var ports []int
28 |
29 | func scanPorts(startPort, endPort int) {
30 | if len(ports) > 0 {
31 | return
32 | }
33 |
34 | if startPort == 0 {
35 | startPort = 42073
36 | }
37 |
38 | if endPort == 0 {
39 | endPort = 42074
40 | }
41 |
42 | for port := startPort; port <= endPort && len(ports) < 4; port++ {
43 | conn, err := net.Dial("tcp", net.JoinHostPort("localhost", strconv.Itoa(port)))
44 | if err != nil {
45 | continue
46 | }
47 | conn.Close()
48 | ports = append(ports, port)
49 | }
50 | }
51 |
52 | func purgeCaches(startPort, endPort int, opts PurgeCacheOptions) {
53 | scanPorts(startPort, endPort)
54 | opts.ApiKey = Config.HTTP.ApiKey
55 |
56 | buf, err := json.Marshal(opts)
57 | if err != nil {
58 | log.Fatalln(err)
59 | }
60 |
61 | for _, port := range ports {
62 | req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/purge-cache", port), bytes.NewBuffer(buf))
63 | if err != nil {
64 | log.Fatalln(err)
65 | }
66 |
67 | req.Header.Set("Content-Type", "application/json")
68 | client := &http.Client{}
69 | res, err := client.Do(req)
70 | if err != nil {
71 | log.Fatal(err)
72 | }
73 | defer res.Body.Close()
74 |
75 | if res.StatusCode == 200 {
76 | log.Printf("Purged caches on port %d\n", port)
77 | } else {
78 | log.Fatalf("Failed to purge archives cache: %s", res.Status)
79 | }
80 | }
81 | }
82 |
83 | func reloadTemplates(startPort, endPort int) {
84 | scanPorts(startPort, endPort)
85 | opts := ApiOptions{ApiKey: Config.HTTP.ApiKey}
86 |
87 | buf, err := json.Marshal(opts)
88 | if err != nil {
89 | log.Fatalln(err)
90 | }
91 |
92 | for _, port := range ports {
93 | req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/reload-templates", port), bytes.NewBuffer(buf))
94 | if err != nil {
95 | log.Fatalln(err)
96 | }
97 |
98 | req.Header.Set("Content-Type", "application/json")
99 | client := &http.Client{}
100 | res, err := client.Do(req)
101 | if err != nil {
102 | log.Fatal(err)
103 | }
104 | defer res.Body.Close()
105 |
106 | if res.StatusCode == 200 {
107 | log.Printf("Reloaded templates on port %d\n", port)
108 | } else {
109 | log.Fatalf("Failed to reload templates: %s", res.Status)
110 | }
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/cmd/webServer/api.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "log"
5 | "net/http"
6 |
7 | "koushoku/cache"
8 | . "koushoku/config"
9 | "koushoku/server"
10 | )
11 |
12 | type ApiPayload struct {
13 | ApiKey string `json:"key"`
14 | }
15 |
16 | type PurgeCachePayload struct {
17 | ApiPayload
18 | Archives bool `json:"archives"`
19 | Taxonomies bool `json:"taxonomies"`
20 | Templates bool `json:"templates"`
21 | Submissions bool `json:"submissions"`
22 | }
23 |
24 | func purgeCache(c *server.Context) {
25 | payload := &PurgeCachePayload{}
26 | if err := c.BindJSON(payload); err != nil {
27 | c.AbortWithStatus(http.StatusBadRequest)
28 | return
29 | }
30 |
31 | if payload.ApiKey != Config.HTTP.ApiKey {
32 | c.AbortWithStatus(http.StatusUnauthorized)
33 | return
34 | }
35 |
36 | if payload.Archives {
37 | log.Println("Purging archives cache...")
38 | cache.Archives.Purge()
39 | }
40 |
41 | if payload.Taxonomies {
42 | log.Println("Purging taxonomies cache...")
43 | cache.Taxonomies.Purge()
44 | }
45 |
46 | if payload.Templates {
47 | log.Println("Purging templates cache...")
48 | cache.Templates.Purge()
49 | }
50 |
51 | if payload.Submissions {
52 | log.Println("Purging submissions cache...")
53 | cache.Submissions.Purge()
54 | }
55 | }
56 |
57 | func reloadTemplates(c *server.Context) {
58 | payload := &ApiPayload{}
59 | if err := c.BindJSON(payload); err != nil {
60 | c.AbortWithStatus(http.StatusBadRequest)
61 | return
62 | }
63 |
64 | if payload.ApiKey != Config.HTTP.ApiKey {
65 | c.AbortWithStatus(http.StatusUnauthorized)
66 | return
67 | }
68 |
69 | log.Println("Reloading templates...")
70 | server.LoadTemplates()
71 | cache.Templates.Purge()
72 | }
73 |
--------------------------------------------------------------------------------
/cmd/webServer/archive.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "strings"
7 |
8 | "koushoku/server"
9 | "koushoku/services"
10 | )
11 |
12 | const (
13 | archiveTmplName = "archive.html"
14 | readerTmplName = "reader.html"
15 | )
16 |
17 | func archive(c *server.Context) {
18 | if c.TryCache(archiveTmplName) {
19 | return
20 | }
21 |
22 | id, err := c.ParamInt64("id")
23 | if err != nil {
24 | c.HTML(http.StatusInternalServerError, "error.html")
25 | return
26 | }
27 |
28 | result := services.GetArchive(id, services.GetArchiveOptions{
29 | Preloads: []string{
30 | services.ArchiveRels.Artists,
31 | services.ArchiveRels.Circles,
32 | services.ArchiveRels.Magazines,
33 | services.ArchiveRels.Parodies,
34 | services.ArchiveRels.Tags,
35 | services.ArchiveRels.Submission,
36 | },
37 | })
38 | if result.Err != nil {
39 | c.SetData("error", result.Err)
40 | c.HTML(http.StatusInternalServerError, "error.html")
41 | return
42 | }
43 |
44 | if (result.Archive.RedirectId > 0) && (result.Archive.RedirectId != id) {
45 | c.Redirect(http.StatusFound, fmt.Sprintf("/archive/%d", result.Archive.RedirectId))
46 | return
47 | }
48 |
49 | slug := c.Param("slug")
50 | isJson := strings.HasSuffix(slug, ".json")
51 | if isJson {
52 | slug = strings.TrimSuffix(slug, ".json")
53 | }
54 |
55 | if !strings.EqualFold(slug, result.Archive.Slug) {
56 | slug = result.Archive.Slug
57 | if isJson {
58 | slug += ".json"
59 | }
60 | c.Redirect(http.StatusFound, fmt.Sprintf("/archive/%d/%s", result.Archive.ID, slug))
61 | return
62 | }
63 |
64 | if isJson {
65 | c.JSON(http.StatusOK, result.Archive)
66 | } else {
67 | c.SetData("archive", result.Archive)
68 | c.Cache(http.StatusOK, archiveTmplName)
69 | }
70 | }
71 |
72 | func read(c *server.Context) {
73 | if c.TryCache(readerTmplName) {
74 | return
75 | }
76 |
77 | id, err := c.ParamInt64("id")
78 | if err != nil {
79 | c.HTML(http.StatusInternalServerError, "error.html")
80 | return
81 | }
82 |
83 | pageNum, err := c.ParamInt("pageNum")
84 | if err != nil {
85 | c.HTML(http.StatusInternalServerError, "error.html")
86 | return
87 | }
88 |
89 | result := services.GetArchive(id, services.GetArchiveOptions{})
90 | if result.Err != nil {
91 | c.SetData("error", result.Err)
92 | c.HTML(http.StatusInternalServerError, "error.html")
93 | return
94 | }
95 |
96 | slug := c.Param("slug")
97 | if !strings.EqualFold(slug, result.Archive.Slug) {
98 | if pageNum <= 0 || int16(pageNum) > result.Archive.Pages {
99 | c.Redirect(http.StatusFound, fmt.Sprintf("/archive/%d/%s/1", result.Archive.ID, result.Archive.Slug))
100 | } else {
101 | c.Redirect(http.StatusFound, fmt.Sprintf("/archive/%d/%s/%d", result.Archive.ID, result.Archive.Slug, pageNum))
102 | }
103 | return
104 | }
105 |
106 | if pageNum <= 0 || int16(pageNum) > result.Archive.Pages {
107 | c.Redirect(http.StatusFound, fmt.Sprintf("/archive/%d/%s/1", id, result.Archive.Slug))
108 | return
109 | }
110 |
111 | c.SetData("archive", result.Archive)
112 | c.SetData("pageNum", pageNum)
113 | c.Cache(http.StatusOK, readerTmplName)
114 | }
115 |
--------------------------------------------------------------------------------
/cmd/webServer/list.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "math"
6 | "net/http"
7 | "strconv"
8 |
9 | "koushoku/server"
10 | "koushoku/services"
11 | )
12 |
13 | const (
14 | listingLimit = 200
15 | listingTmplName = "list.html"
16 | )
17 |
18 | func artists(c *server.Context) {
19 | if c.TryCache(listingTmplName) {
20 | return
21 | }
22 |
23 | page, _ := strconv.Atoi(c.Query("page"))
24 | result := services.GetArtists(services.GetArtistsOptions{
25 | Limit: listingLimit,
26 | Offset: listingLimit * (page - 1),
27 | })
28 | if result.Err != nil {
29 | c.SetData("error", result.Err)
30 | c.HTML(http.StatusInternalServerError, "error.html")
31 | return
32 | }
33 |
34 | c.SetData("page", page)
35 | if page > 0 {
36 | c.SetData("name", fmt.Sprintf("Artists: Page %d", page))
37 | } else {
38 | c.SetData("name", "Artists")
39 | }
40 |
41 | c.SetData("taxonomy", "artists")
42 | c.SetData("taxonomyTitle", "Artists")
43 |
44 | c.SetData("data", result.Artists)
45 | c.SetData("total", result.Total)
46 |
47 | totalPages := int(math.Ceil(float64(result.Total) / float64(listingLimit)))
48 | c.SetData("pagination", services.CreatePagination(page, totalPages))
49 |
50 | c.Cache(http.StatusOK, listingTmplName)
51 | }
52 |
53 | func circles(c *server.Context) {
54 | if c.TryCache(listingTmplName) {
55 | return
56 | }
57 |
58 | page, _ := strconv.Atoi(c.Query("page"))
59 | result := services.GetCircles(services.GetCirclesOptions{
60 | Limit: listingLimit,
61 | Offset: listingLimit * (page - 1),
62 | })
63 | if result.Err != nil {
64 | c.SetData("error", result.Err)
65 | c.HTML(http.StatusInternalServerError, "error.html")
66 | return
67 | }
68 |
69 | c.SetData("page", page)
70 | if page > 0 {
71 | c.SetData("name", fmt.Sprintf("Circles: Page %d", page))
72 | } else {
73 | c.SetData("name", "Circles")
74 | }
75 |
76 | totalPages := int(math.Ceil(float64(result.Total) / float64(listingLimit)))
77 | c.SetData("taxonomy", "circles")
78 | c.SetData("taxonomyTitle", "Circles")
79 | c.SetData("data", result.Circles)
80 | c.SetData("total", result.Total)
81 | c.SetData("pagination", services.CreatePagination(page, totalPages))
82 |
83 | c.Cache(http.StatusOK, listingTmplName)
84 | }
85 |
86 | func magazines(c *server.Context) {
87 | if c.TryCache(listingTmplName) {
88 | return
89 | }
90 |
91 | page, _ := strconv.Atoi(c.Query("page"))
92 | opts := services.GetMagazinesOptions{
93 | Limit: listingLimit,
94 | Offset: listingLimit * (page - 1),
95 | }
96 |
97 | result := services.GetMagazines(opts)
98 | if result.Err != nil {
99 | c.SetData("error", result.Err)
100 | c.HTML(http.StatusInternalServerError, "error.html")
101 | return
102 | }
103 |
104 | c.SetData("page", page)
105 | if page > 0 {
106 | c.SetData("name", fmt.Sprintf("Magazines: Page %d", page))
107 | } else {
108 | c.SetData("name", "Magazines")
109 | }
110 |
111 | totalPages := int(math.Ceil(float64(result.Total) / float64(listingLimit)))
112 | c.SetData("taxonomy", "magazines")
113 | c.SetData("taxonomyTitle", "Magazines")
114 | c.SetData("data", result.Magazines)
115 | c.SetData("total", result.Total)
116 | c.SetData("pagination", services.CreatePagination(page, totalPages))
117 |
118 | c.Cache(http.StatusOK, listingTmplName)
119 | }
120 |
121 | func parodies(c *server.Context) {
122 | if c.TryCache(listingTmplName) {
123 | return
124 | }
125 |
126 | page, _ := strconv.Atoi(c.Query("page"))
127 | opts := services.GetParodiesOptions{
128 | Limit: listingLimit,
129 | Offset: listingLimit * (page - 1),
130 | }
131 |
132 | result := services.GetParodies(opts)
133 | if result.Err != nil {
134 | c.SetData("error", result.Err)
135 | c.HTML(http.StatusInternalServerError, "error.html")
136 | return
137 | }
138 |
139 | c.SetData("page", page)
140 | if page > 0 {
141 | c.SetData("name", fmt.Sprintf("Parodies: Page %d", page))
142 | } else {
143 | c.SetData("name", "Parodies")
144 | }
145 |
146 | totalPages := int(math.Ceil(float64(result.Total) / float64(listingLimit)))
147 | c.SetData("taxonomy", "parodies")
148 | c.SetData("taxonomyTitle", "Parodies")
149 | c.SetData("data", result.Parodies)
150 | c.SetData("total", result.Total)
151 | c.SetData("pagination", services.CreatePagination(page, totalPages))
152 |
153 | c.Cache(http.StatusOK, listingTmplName)
154 | }
155 |
156 | func tags(c *server.Context) {
157 | if c.TryCache(listingTmplName) {
158 | return
159 | }
160 |
161 | result := services.GetTags(services.GetTagsOptions{})
162 | if result.Err != nil {
163 | c.SetData("error", result.Err)
164 | c.HTML(http.StatusInternalServerError, "error.html")
165 | return
166 | }
167 |
168 | c.SetData("name", "Tags")
169 | c.SetData("taxonomy", "tags")
170 | c.SetData("taxonomyTitle", "Tags")
171 | c.SetData("data", result.Tags)
172 | c.SetData("total", result.Total)
173 |
174 | c.Cache(http.StatusOK, listingTmplName)
175 | }
176 |
--------------------------------------------------------------------------------
/cmd/webServer/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "net/http"
5 | "path/filepath"
6 |
7 | . "koushoku/config"
8 |
9 | "koushoku/cache"
10 | "koushoku/database"
11 | "koushoku/server"
12 | "koushoku/services"
13 |
14 | "github.com/gin-gonic/gin"
15 | )
16 |
17 | func main() {
18 | database.Init()
19 | cache.Init()
20 |
21 | if err := services.AnalyzeStats(); err != nil {
22 | return
23 | }
24 | server.Init()
25 |
26 | assets := server.Group("/")
27 | assets.Use(func(c *gin.Context) {
28 | c.Header("Cache-Control", "public, max-age=300")
29 | })
30 |
31 | assets.Static("/js", filepath.Join(Config.Directories.Root, "assets/js"))
32 | assets.Static("/css", filepath.Join(Config.Directories.Root, "assets/css"))
33 | assets.Static("/fonts", filepath.Join(Config.Directories.Root, "assets/fonts"))
34 |
35 | assets.StaticFile("/cover.jpg", filepath.Join(Config.Directories.Root, "cover.jpg"))
36 | assets.StaticFile("/robots.txt", filepath.Join(Config.Directories.Root, "robots.txt"))
37 | assets.StaticFile("/updates.txt", filepath.Join(Config.Directories.Root, "updates.txt"))
38 |
39 | assets.StaticFile("/favicon.ico", filepath.Join(Config.Directories.Root, "favicon.ico"))
40 | assets.StaticFile("/favicon-16x16.png", filepath.Join(Config.Directories.Root, "favicon-16x16.png"))
41 | assets.StaticFile("/favicon-32x32.png", filepath.Join(Config.Directories.Root, "favicon-32x32.png"))
42 | assets.StaticFile("/apple-touch-icon.png", filepath.Join(Config.Directories.Root, "apple-touch-icon.png"))
43 | assets.StaticFile("/android-chrome-192x192.png", filepath.Join(Config.Directories.Root, "android-chrome-192x192.png"))
44 | assets.StaticFile("/android-chrome-512x512.png", filepath.Join(Config.Directories.Root, "android-chrome-512x512.png"))
45 |
46 | server.GET("/", index)
47 | server.GET("/about", server.WithName("About"), about)
48 | server.GET("/search", search)
49 | server.GET("/stats", server.WithName("Stats"), stats)
50 | server.GET("/sitemap.xml", sitemap)
51 |
52 | server.GET("/archive/:id", archive)
53 | server.GET("/archive/:id/:slug", archive)
54 | server.GET("/archive/:id/:slug/:pageNum", read)
55 | server.GET("/artists", artists)
56 | server.GET("/artists/:slug", artist)
57 | server.GET("/circles", circles)
58 | server.GET("/circles/:slug", circle)
59 | server.GET("/magazines", magazines)
60 | server.GET("/magazines/:slug", magazine)
61 | server.GET("/parodies", parodies)
62 | server.GET("/parodies/:slug", parody)
63 | server.GET("/tags", tags)
64 | server.GET("/tags/:slug", tag)
65 |
66 | server.GET("/submit", server.WithName("Submit"), submit)
67 | server.POST("/submit", server.WithName("Submit"), server.WithRateLimit("Submit?", "10-D"), submitPost)
68 | server.GET("/submissions", submisisions)
69 |
70 | server.POST("/api/purge-cache", purgeCache)
71 | server.POST("/api/reload-templates", reloadTemplates)
72 |
73 | server.NoRoute(func(c *server.Context) {
74 | c.HTML(http.StatusNotFound, "error.html")
75 | })
76 |
77 | server.Start(Config.Server.WebPort)
78 | }
79 |
--------------------------------------------------------------------------------
/cmd/webServer/submission.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "koushoku/server"
6 | "koushoku/services"
7 | "math"
8 | "net/http"
9 | "strconv"
10 | )
11 |
12 | const (
13 | submitTmplName = "submit.html"
14 | submissionsTmplname = "submissions.html"
15 | )
16 |
17 | func submit(c *server.Context) {
18 | if !c.TryCache(submitTmplName) {
19 | c.Cache(http.StatusOK, submitTmplName)
20 | }
21 | }
22 |
23 | type SubmitPayload struct {
24 | Name string `form:"name"`
25 | Submitter string `form:"submitter"`
26 | Content string `form:"content"`
27 | }
28 |
29 | func submitPost(c *server.Context) {
30 | payload := &SubmitPayload{}
31 | c.Bind(payload)
32 |
33 | _, err := services.CreateSubmission(payload.Name, payload.Submitter, payload.Content)
34 | if err != nil {
35 | c.SetData("lastSubmissionName", payload.Name)
36 | c.SetData("lastSubmissionSubmitter", payload.Submitter)
37 | c.SetData("lastSubmissionContent", payload.Content)
38 | c.SetData("error", err)
39 | c.HTML(http.StatusBadRequest, submitTmplName)
40 | return
41 | }
42 | c.SetData("message", "Your submission has been submitted.")
43 | c.HTML(http.StatusOK, submitTmplName)
44 | }
45 |
46 | func submisisions(c *server.Context) {
47 | if c.TryCache(submissionsTmplname) {
48 | return
49 | }
50 |
51 | q := &SearchQueries{}
52 | c.BindQuery(q)
53 |
54 | page, _ := strconv.Atoi(c.Query("page"))
55 | result := services.GetSubmissions(services.GetSubmissionsOptions{
56 | Limit: listingLimit,
57 | Offset: listingLimit * (page - 1),
58 | })
59 | if result.Err != nil {
60 | c.SetData("error", result.Err)
61 | c.HTML(http.StatusInternalServerError, "error.html")
62 | return
63 | }
64 |
65 | c.SetData("page", page)
66 | if page > 0 {
67 | c.SetData("name", fmt.Sprintf("Submissions: Page %d", page))
68 | } else {
69 | c.SetData("name", "Submissions")
70 | }
71 |
72 | totalPages := int(math.Ceil(float64(result.Total) / float64(listingLimit)))
73 | c.SetData("data", result.Submissions)
74 | c.SetData("total", result.Total)
75 | c.SetData("pagination", services.CreatePagination(page, totalPages))
76 |
77 | c.Cache(http.StatusOK, submissionsTmplname)
78 | }
79 |
--------------------------------------------------------------------------------
/cmd/webServer/taxonomy.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "math"
6 | "net/http"
7 |
8 | "koushoku/server"
9 | "koushoku/services"
10 | )
11 |
12 | const taxonomyTmplName = "taxonomy.html"
13 |
14 | func artist(c *server.Context) {
15 | if c.TryCache(taxonomyTmplName) {
16 | return
17 | }
18 |
19 | artist, err := services.GetArtist(c.Param("slug"))
20 | if err != nil {
21 | c.SetData("error", err)
22 | c.HTML(http.StatusInternalServerError, "error.html")
23 | return
24 | }
25 |
26 | q := createNewSearchQueries(c)
27 | result := services.GetArchives(&services.GetArchivesOptions{
28 | ArtistsMatch: []string{artist.Name},
29 | Limit: indexLimit,
30 | Offset: indexLimit * (q.Page - 1),
31 | Preloads: []string{
32 | services.ArchiveRels.Artists,
33 | services.ArchiveRels.Circles,
34 | services.ArchiveRels.Magazines,
35 | services.ArchiveRels.Tags,
36 | },
37 |
38 | Sort: q.Sort,
39 | Order: q.Order,
40 | })
41 | if result.Err != nil {
42 | c.SetData("error", result.Err)
43 | c.HTML(http.StatusInternalServerError, "error.html")
44 | return
45 | }
46 |
47 | c.SetData("queries", q)
48 | if q.Page > 0 {
49 | c.SetData("name", fmt.Sprintf("%s: Page %d", artist.Name, q.Page))
50 | } else {
51 | c.SetData("name", artist.Name)
52 | }
53 |
54 | totalPages := int(math.Ceil(float64(result.Total) / float64(indexLimit)))
55 | c.SetData("taxonomy", artist.Name)
56 | c.SetData("archives", result.Archives)
57 | c.SetData("total", result.Total)
58 | c.SetData("pagination", services.CreatePagination(q.Page, totalPages))
59 |
60 | c.Cache(http.StatusOK, taxonomyTmplName)
61 | }
62 |
63 | func circle(c *server.Context) {
64 | if c.TryCache(taxonomyTmplName) {
65 | return
66 | }
67 |
68 | circle, err := services.GetCircle(c.Param("slug"))
69 | if err != nil {
70 | c.SetData("error", err)
71 | c.HTML(http.StatusInternalServerError, "error.html")
72 | return
73 | }
74 |
75 | q := createNewSearchQueries(c)
76 | result := services.GetArchives(&services.GetArchivesOptions{
77 | CirclesMatch: []string{circle.Name},
78 | Limit: indexLimit,
79 | Offset: indexLimit * (q.Page - 1),
80 | Preloads: []string{
81 | services.ArchiveRels.Artists,
82 | services.ArchiveRels.Circles,
83 | services.ArchiveRels.Magazines,
84 | services.ArchiveRels.Tags,
85 | },
86 |
87 | Sort: q.Sort,
88 | Order: q.Order,
89 | })
90 | if result.Err != nil {
91 | c.SetData("error", result.Err)
92 | c.HTML(http.StatusInternalServerError, "error.html")
93 | return
94 | }
95 |
96 | c.SetData("queries", q)
97 | if q.Page > 0 {
98 | c.SetData("name", fmt.Sprintf("%s: Page %d", circle.Name, q.Page))
99 | } else {
100 | c.SetData("name", circle.Name)
101 | }
102 |
103 | totalPages := int(math.Ceil(float64(result.Total) / float64(indexLimit)))
104 | c.SetData("taxonomy", circle.Name)
105 | c.SetData("archives", result.Archives)
106 | c.SetData("total", result.Total)
107 | c.SetData("pagination", services.CreatePagination(q.Page, totalPages))
108 |
109 | c.Cache(http.StatusOK, taxonomyTmplName)
110 | }
111 |
112 | func magazine(c *server.Context) {
113 | if c.TryCache(taxonomyTmplName) {
114 | return
115 | }
116 |
117 | magazine, err := services.GetMagazine(c.Param("slug"))
118 | if err != nil {
119 | c.SetData("error", err)
120 | c.HTML(http.StatusInternalServerError, "error.html")
121 | return
122 | }
123 |
124 | q := createNewSearchQueries(c)
125 | result := services.GetArchives(&services.GetArchivesOptions{
126 | MagazinesMatch: []string{magazine.Name},
127 | Limit: indexLimit,
128 | Offset: indexLimit * (q.Page - 1),
129 | Preloads: []string{
130 | services.ArchiveRels.Artists,
131 | services.ArchiveRels.Circles,
132 | services.ArchiveRels.Magazines,
133 | services.ArchiveRels.Tags,
134 | },
135 |
136 | Sort: q.Sort,
137 | Order: q.Order,
138 | })
139 | if result.Err != nil {
140 | c.SetData("error", result.Err)
141 | c.HTML(http.StatusInternalServerError, "error.html")
142 | return
143 | }
144 |
145 | c.SetData("queries", q)
146 | if q.Page > 0 {
147 | c.SetData("name", fmt.Sprintf("%s: Page %d", magazine.Name, q.Page))
148 | } else {
149 | c.SetData("name", magazine.Name)
150 | }
151 |
152 | totalPages := int(math.Ceil(float64(result.Total) / float64(indexLimit)))
153 | c.SetData("taxonomy", magazine.Name)
154 | c.SetData("archives", result.Archives)
155 | c.SetData("total", result.Total)
156 | c.SetData("pagination", services.CreatePagination(q.Page, totalPages))
157 |
158 | c.Cache(http.StatusOK, taxonomyTmplName)
159 | }
160 |
161 | func parody(c *server.Context) {
162 | if c.TryCache(taxonomyTmplName) {
163 | return
164 | }
165 |
166 | parody, err := services.GetParody(c.Param("slug"))
167 | if err != nil {
168 | c.SetData("error", err)
169 | c.HTML(http.StatusInternalServerError, "error.html")
170 | return
171 | }
172 |
173 | q := createNewSearchQueries(c)
174 | result := services.GetArchives(&services.GetArchivesOptions{
175 | ParodiesMatch: []string{parody.Name},
176 | Limit: indexLimit,
177 | Offset: indexLimit * (q.Page - 1),
178 | Preloads: []string{
179 | services.ArchiveRels.Artists,
180 | services.ArchiveRels.Circles,
181 | services.ArchiveRels.Magazines,
182 | services.ArchiveRels.Tags,
183 | },
184 | Sort: q.Sort,
185 | Order: q.Order,
186 | })
187 | if result.Err != nil {
188 | c.SetData("error", result.Err)
189 | c.HTML(http.StatusInternalServerError, "error.html")
190 | return
191 | }
192 |
193 | c.SetData("queries", q)
194 | if q.Page > 0 {
195 | c.SetData("name", fmt.Sprintf("%s: Page %d", parody.Name, q.Page))
196 | } else {
197 | c.SetData("name", parody.Name)
198 | }
199 |
200 | totalPages := int(math.Ceil(float64(result.Total) / float64(indexLimit)))
201 | c.SetData("taxonomy", parody.Name)
202 | c.SetData("archives", result.Archives)
203 | c.SetData("total", result.Total)
204 | c.SetData("pagination", services.CreatePagination(q.Page, totalPages))
205 |
206 | c.Cache(http.StatusOK, taxonomyTmplName)
207 | }
208 |
209 | func tag(c *server.Context) {
210 | if c.TryCache(taxonomyTmplName) {
211 | return
212 | }
213 |
214 | tag, err := services.GetTag(c.Param("slug"))
215 | if err != nil {
216 | c.SetData("error", err)
217 | c.HTML(http.StatusInternalServerError, "error.html")
218 | return
219 | }
220 |
221 | q := createNewSearchQueries(c)
222 | result := services.GetArchives(&services.GetArchivesOptions{
223 | TagsMatch: []string{tag.Name},
224 | Limit: indexLimit,
225 | Offset: indexLimit * (q.Page - 1),
226 | Preloads: []string{
227 | services.ArchiveRels.Artists,
228 | services.ArchiveRels.Circles,
229 | services.ArchiveRels.Magazines,
230 | services.ArchiveRels.Tags,
231 | },
232 | Sort: q.Sort,
233 | Order: q.Order,
234 | })
235 | if result.Err != nil {
236 | c.SetData("error", result.Err)
237 | c.HTML(http.StatusInternalServerError, "error.html")
238 | return
239 | }
240 |
241 | c.SetData("queries", q)
242 | if q.Page > 0 {
243 | c.SetData("name", fmt.Sprintf("%s: Page %d", tag.Name, q.Page))
244 | } else {
245 | c.SetData("name", tag.Name)
246 | }
247 |
248 | totalPages := int(math.Ceil(float64(result.Total) / float64(indexLimit)))
249 | c.SetData("taxonomy", tag.Name)
250 | c.SetData("archives", result.Archives)
251 | c.SetData("total", result.Total)
252 | c.SetData("pagination", services.CreatePagination(q.Page, totalPages))
253 |
254 | c.Cache(http.StatusOK, taxonomyTmplName)
255 | }
256 |
--------------------------------------------------------------------------------
/config/config.ini:
--------------------------------------------------------------------------------
1 | mode = production
2 |
3 | [meta]
4 | base_url = http://localhost:42073
5 | data_base_url = http://localhost:42075
6 | title = Koushoku
7 | description =
8 | language = en-US
9 |
10 | [database]
11 | host = localhost
12 | port = 5432
13 | name = koushoku
14 | user = koushoku
15 | passwd = koushoku
16 | ssl_mode = disable
17 |
18 | [redis]
19 | host = localhost
20 | port = 6379
21 | db =
22 | passwd =
23 |
24 | [http]
25 | cookie =
26 | # auto-generated
27 | api_key =
28 |
29 | [cloudflare]
30 | email =
31 | api_key =
32 | zone_tag =
33 |
34 | [directories]
35 | data =
--------------------------------------------------------------------------------
/contrib/nginx/koushoku-cdn.conf.example:
--------------------------------------------------------------------------------
1 | upstream koushokucdn {
2 | server 127.0.0.1:42075;
3 | server 127.0.0.1:42076 backup;
4 | }
5 |
6 | server {
7 | listen 80;
8 | listen [::]:80;
9 |
10 | server_name cdn.domain.com;
11 | return 301 https://$host$request_uri;
12 | }
13 |
14 | server {
15 | listen 443 ssl http2;
16 | listen [::]:443 ssl http2;
17 |
18 | server_name cdn.domain.com;
19 | add_header Strict-Transport-Security 'max-age=31536000; includeSubDomains; preload';
20 | add_header X-Frame-Options SAMEORIGIN;
21 | add_header X-Content-Type-Options "nosniff";
22 | add_header X-XSS-Protection "1; mode=block";
23 | add_header Referrer-Policy "strict-origin";
24 | add_header Permissions-Policy "fullscreen=(self)";
25 |
26 | location / {
27 | proxy_pass http://koushokucdn;
28 | }
29 | }
30 |
31 |
--------------------------------------------------------------------------------
/contrib/nginx/koushoku.conf.example:
--------------------------------------------------------------------------------
1 | upstream koushoku {
2 | server 127.0.0.1:42073;
3 | server 127.0.0.1:42074;
4 | }
5 |
6 | server {
7 | listen 80;
8 | listen [::]:80;
9 |
10 | server_name domain.com www.domain.com;
11 | return 301 https://$host$request_uri;
12 | }
13 |
14 | server {
15 | listen 443 ssl http2;
16 | listen [::]:443 ssl http2;
17 |
18 | server_name domain.com www.domain.com;
19 | add_header Strict-Transport-Security 'max-age=31536000; includeSubDomains; preload';
20 | add_header X-Frame-Options SAMEORIGIN;
21 | add_header X-Content-Type-Options "nosniff";
22 | add_header X-XSS-Protection "1; mode=block";
23 | add_header Referrer-Policy "strict-origin";
24 | add_header Permissions-Policy "fullscreen=(self)";
25 |
26 | location / {
27 | proxy_pass http://koushoku;
28 | }
29 | }
30 |
31 |
--------------------------------------------------------------------------------
/contrib/systemd/koushoku-cdn.service.example:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Koushoku cdn server
3 | After=syslog.target
4 | After=network.target
5 | Wants=postgresql.service
6 | After=postgresql.service
7 |
8 | [Service]
9 | RestartSec=2s
10 | Type=simple
11 | WorkingDirectory=/root/koushoku/bin
12 | ExecStart=/root/koushoku/bin/dataServer
13 | Restart=always
14 |
15 | [Install]
16 | WantedBy=multi-user.target
--------------------------------------------------------------------------------
/contrib/systemd/koushoku.service.example:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Koushoku web server
3 | After=syslog.target
4 | After=network.target
5 | Wants=postgresql.service
6 | After=postgresql.service
7 |
8 | [Service]
9 | RestartSec=2s
10 | Type=simple
11 | WorkingDirectory=/root/koushoku/bin
12 | ExecStart=/root/koushoku/bin/webServer
13 | Restart=always
14 |
15 | [Install]
16 | WantedBy=multi-user.target
--------------------------------------------------------------------------------
/database/database.go:
--------------------------------------------------------------------------------
1 | package database
2 |
3 | import (
4 | _ "embed"
5 | "sync"
6 |
7 | "database/sql"
8 | "fmt"
9 | "log"
10 |
11 | . "koushoku/config"
12 |
13 | _ "github.com/jackc/pgx/v4/stdlib"
14 | "github.com/volatiletech/sqlboiler/v4/boil"
15 | )
16 |
17 | var Conn *sql.DB
18 |
19 | //go:embed schema.sql
20 | var schema []byte
21 | var once sync.Once
22 |
23 | func Init() {
24 | once.Do(func() {
25 | cfg := Config.Database
26 | dsn := fmt.Sprintf("host=%s port=%d dbname=%s user=%s password=%s sslmode=%s",
27 | cfg.Host, cfg.Port, cfg.Name, cfg.User, cfg.Passwd, cfg.SSLMode)
28 |
29 | conn, err := sql.Open("pgx", dsn)
30 | if err != nil {
31 | log.Fatalln(err)
32 | }
33 |
34 | if err := conn.Ping(); err != nil {
35 | log.Fatalln(err)
36 | }
37 |
38 | if _, err = conn.Exec(string(schema)); err != nil && err != sql.ErrNoRows {
39 | log.Fatalln(err)
40 | }
41 |
42 | Conn = conn
43 | boil.SetDB(conn)
44 | })
45 | }
46 |
--------------------------------------------------------------------------------
/errs/errs.go:
--------------------------------------------------------------------------------
1 | package errs
2 |
3 | import "errors"
4 |
5 | var Unknown = errors.New("Unknown error")
6 |
7 | var (
8 | ArchiveNotFound = errors.New("Archive does not exist")
9 | ArtistNotFound = errors.New("Artist does not exist")
10 | CircleNotFound = errors.New("Circle does not exist")
11 | MagazineNotFound = errors.New("Magazine does not exist")
12 | TagNotFound = errors.New("Tag does not exist")
13 | ParodyNotFound = errors.New("Parody does not exist")
14 | UserNotFound = errors.New("User does not exist")
15 | SubmissionNotFound = errors.New("Submission does not exist")
16 | )
17 |
18 | var (
19 | ArchivePathRequired = errors.New("Archive path is required")
20 | ArtistNameRequired = errors.New("Artist name is required")
21 | ArtistNameTooLong = errors.New("Artist name must be at most 128 characters")
22 | CircleNameRequired = errors.New("Circle name is required")
23 | CircleNameTooLong = errors.New("CIrcle name must be at most 128 characters")
24 | MagazineNameRequired = errors.New("Magazine name is required")
25 | MagazineNameTooLong = errors.New("Magazine name must be at most 128 characters")
26 | ParodyNameRequired = errors.New("Parody name is required")
27 | ParodyNameTooLong = errors.New("Parody name must be at most 128 characters")
28 | TagNameRequired = errors.New("Tag name is required")
29 | TagNameTooLong = errors.New("Tag name must be at most 128 characters")
30 | SubmissionNameRequired = errors.New("Submission name is required")
31 | SubmissionNameTooLong = errors.New("Submission name must be at most 1024 characters")
32 | SubmissionSubmitterTooLong = errors.New("Submission submitter must be at most 128 characters")
33 | SubmissionContentRequired = errors.New("Submission content is required")
34 | SubmissionContentTooLong = errors.New("Submission content must be at most 10240 characters")
35 | )
36 |
37 | var (
38 | UserNameTooShort = errors.New("User name must be at least 3 characters")
39 | UserNameTooLong = errors.New("User name must be at most 32 characters")
40 | EmailRequired = errors.New("Email is required")
41 | EmailTooLong = errors.New("Email must be at most 255 characters")
42 | EmailInvalid = errors.New("Email is invalid")
43 | PasswordTooShort = errors.New("Password must be at least 6 characters")
44 | InvalidCredentials = errors.New("Invalid credentials")
45 | )
46 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module koushoku
2 |
3 | go 1.18
4 |
5 | require (
6 | github.com/PuerkitoBio/goquery v1.8.0
7 | github.com/bluele/gcache v0.0.2
8 | github.com/friendsofgo/errors v0.9.2
9 | github.com/gin-contrib/gzip v0.0.5
10 | github.com/gin-gonic/gin v1.7.7
11 | github.com/go-redis/redis/v8 v8.11.4
12 | github.com/google/uuid v1.3.0
13 | github.com/gosimple/slug v1.12.0
14 | github.com/jackc/pgx/v4 v4.15.0
15 | github.com/jessevdk/go-flags v1.5.0
16 | github.com/pkg/errors v0.9.1
17 | github.com/ulule/limiter/v3 v3.10.0
18 | github.com/volatiletech/null/v8 v8.1.2
19 | github.com/volatiletech/sqlboiler/v4 v4.8.6
20 | github.com/volatiletech/strmangle v0.0.1
21 | golang.org/x/crypto v0.0.0-20220214200702-86341886e292
22 | golang.org/x/text v0.3.6
23 | gopkg.in/ini.v1 v1.63.2
24 | )
25 |
26 | require (
27 | github.com/andybalholm/cascadia v1.3.1 // indirect
28 | github.com/cespare/xxhash/v2 v2.1.2 // indirect
29 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
30 | github.com/gin-contrib/sse v0.1.0 // indirect
31 | github.com/go-playground/locales v0.13.0 // indirect
32 | github.com/go-playground/universal-translator v0.17.0 // indirect
33 | github.com/go-playground/validator/v10 v10.4.1 // indirect
34 | github.com/gofrs/uuid v4.0.0+incompatible // indirect
35 | github.com/golang/protobuf v1.5.2 // indirect
36 | github.com/gosimple/unidecode v1.0.1 // indirect
37 | github.com/jackc/chunkreader/v2 v2.0.1 // indirect
38 | github.com/jackc/pgconn v1.11.0 // indirect
39 | github.com/jackc/pgio v1.0.0 // indirect
40 | github.com/jackc/pgpassfile v1.0.0 // indirect
41 | github.com/jackc/pgproto3/v2 v2.2.0 // indirect
42 | github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
43 | github.com/jackc/pgtype v1.10.0 // indirect
44 | github.com/json-iterator/go v1.1.11 // indirect
45 | github.com/kr/pretty v0.3.0 // indirect
46 | github.com/leodido/go-urn v1.2.0 // indirect
47 | github.com/mattn/go-isatty v0.0.12 // indirect
48 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
49 | github.com/modern-go/reflect2 v1.0.1 // indirect
50 | github.com/rogpeppe/go-internal v1.8.0 // indirect
51 | github.com/spf13/cast v1.4.1 // indirect
52 | github.com/ugorji/go/codec v1.1.7 // indirect
53 | github.com/volatiletech/inflect v0.0.1 // indirect
54 | github.com/volatiletech/randomize v0.0.1 // indirect
55 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect
56 | golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 // indirect
57 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
58 | google.golang.org/protobuf v1.27.1 // indirect
59 | gopkg.in/yaml.v2 v2.4.0 // indirect
60 | )
61 |
--------------------------------------------------------------------------------
/models/boil_queries.go:
--------------------------------------------------------------------------------
1 | // Code generated by SQLBoiler 4.11.0 (https://github.com/volatiletech/sqlboiler). DO NOT EDIT.
2 | // This file is meant to be re-generated in place and/or deleted at any time.
3 |
4 | package models
5 |
6 | import (
7 | "github.com/volatiletech/sqlboiler/v4/drivers"
8 | "github.com/volatiletech/sqlboiler/v4/queries"
9 | "github.com/volatiletech/sqlboiler/v4/queries/qm"
10 | )
11 |
12 | var dialect = drivers.Dialect{
13 | LQ: 0x22,
14 | RQ: 0x22,
15 |
16 | UseIndexPlaceholders: true,
17 | UseLastInsertID: false,
18 | UseSchema: false,
19 | UseDefaultKeyword: true,
20 | UseAutoColumns: false,
21 | UseTopClause: false,
22 | UseOutputClause: false,
23 | UseCaseWhenExistsClause: false,
24 | }
25 |
26 | // NewQuery initializes a new Query using the passed in QueryMods
27 | func NewQuery(mods ...qm.QueryMod) *queries.Query {
28 | q := &queries.Query{}
29 | queries.SetDialect(q, &dialect)
30 | qm.Apply(q, mods...)
31 |
32 | return q
33 | }
34 |
--------------------------------------------------------------------------------
/models/boil_table_names.go:
--------------------------------------------------------------------------------
1 | // Code generated by SQLBoiler 4.11.0 (https://github.com/volatiletech/sqlboiler). DO NOT EDIT.
2 | // This file is meant to be re-generated in place and/or deleted at any time.
3 |
4 | package models
5 |
6 | var TableNames = struct {
7 | Archive string
8 | ArchiveArtists string
9 | ArchiveCircles string
10 | ArchiveMagazines string
11 | ArchiveParodies string
12 | ArchiveTags string
13 | Artist string
14 | Circle string
15 | Magazine string
16 | Parody string
17 | Submission string
18 | Tag string
19 | UserFavorites string
20 | Users string
21 | }{
22 | Archive: "archive",
23 | ArchiveArtists: "archive_artists",
24 | ArchiveCircles: "archive_circles",
25 | ArchiveMagazines: "archive_magazines",
26 | ArchiveParodies: "archive_parodies",
27 | ArchiveTags: "archive_tags",
28 | Artist: "artist",
29 | Circle: "circle",
30 | Magazine: "magazine",
31 | Parody: "parody",
32 | Submission: "submission",
33 | Tag: "tag",
34 | UserFavorites: "user_favorites",
35 | Users: "users",
36 | }
37 |
--------------------------------------------------------------------------------
/models/boil_types.go:
--------------------------------------------------------------------------------
1 | // Code generated by SQLBoiler 4.11.0 (https://github.com/volatiletech/sqlboiler). DO NOT EDIT.
2 | // This file is meant to be re-generated in place and/or deleted at any time.
3 |
4 | package models
5 |
6 | import (
7 | "strconv"
8 |
9 | "github.com/friendsofgo/errors"
10 | "github.com/volatiletech/sqlboiler/v4/boil"
11 | "github.com/volatiletech/strmangle"
12 | )
13 |
14 | // M type is for providing columns and column values to UpdateAll.
15 | type M map[string]interface{}
16 |
17 | // ErrSyncFail occurs during insert when the record could not be retrieved in
18 | // order to populate default value information. This usually happens when LastInsertId
19 | // fails or there was a primary key configuration that was not resolvable.
20 | var ErrSyncFail = errors.New("models: failed to synchronize data after insert")
21 |
22 | type insertCache struct {
23 | query string
24 | retQuery string
25 | valueMapping []uint64
26 | retMapping []uint64
27 | }
28 |
29 | type updateCache struct {
30 | query string
31 | valueMapping []uint64
32 | }
33 |
34 | func makeCacheKey(cols boil.Columns, nzDefaults []string) string {
35 | buf := strmangle.GetBuffer()
36 |
37 | buf.WriteString(strconv.Itoa(cols.Kind))
38 | for _, w := range cols.Cols {
39 | buf.WriteString(w)
40 | }
41 |
42 | if len(nzDefaults) != 0 {
43 | buf.WriteByte('.')
44 | }
45 | for _, nz := range nzDefaults {
46 | buf.WriteString(nz)
47 | }
48 |
49 | str := buf.String()
50 | strmangle.PutBuffer(buf)
51 | return str
52 | }
53 |
--------------------------------------------------------------------------------
/models/boil_view_names.go:
--------------------------------------------------------------------------------
1 | // Code generated by SQLBoiler 4.11.0 (https://github.com/volatiletech/sqlboiler). DO NOT EDIT.
2 | // This file is meant to be re-generated in place and/or deleted at any time.
3 |
4 | package models
5 |
6 | var ViewNames = struct {
7 | }{}
8 |
--------------------------------------------------------------------------------
/models/psql_upsert.go:
--------------------------------------------------------------------------------
1 | // Code generated by SQLBoiler 4.11.0 (https://github.com/volatiletech/sqlboiler). DO NOT EDIT.
2 | // This file is meant to be re-generated in place and/or deleted at any time.
3 |
4 | package models
5 |
6 | import (
7 | "fmt"
8 | "strings"
9 |
10 | "github.com/volatiletech/sqlboiler/v4/drivers"
11 | "github.com/volatiletech/strmangle"
12 | )
13 |
14 | // buildUpsertQueryPostgres builds a SQL statement string using the upsertData provided.
15 | func buildUpsertQueryPostgres(dia drivers.Dialect, tableName string, updateOnConflict bool, ret, update, conflict, whitelist []string) string {
16 | conflict = strmangle.IdentQuoteSlice(dia.LQ, dia.RQ, conflict)
17 | whitelist = strmangle.IdentQuoteSlice(dia.LQ, dia.RQ, whitelist)
18 | ret = strmangle.IdentQuoteSlice(dia.LQ, dia.RQ, ret)
19 |
20 | buf := strmangle.GetBuffer()
21 | defer strmangle.PutBuffer(buf)
22 |
23 | columns := "DEFAULT VALUES"
24 | if len(whitelist) != 0 {
25 | columns = fmt.Sprintf("(%s) VALUES (%s)",
26 | strings.Join(whitelist, ", "),
27 | strmangle.Placeholders(dia.UseIndexPlaceholders, len(whitelist), 1, 1))
28 | }
29 |
30 | fmt.Fprintf(
31 | buf,
32 | "INSERT INTO %s %s ON CONFLICT ",
33 | tableName,
34 | columns,
35 | )
36 |
37 | if !updateOnConflict || len(update) == 0 {
38 | buf.WriteString("DO NOTHING")
39 | } else {
40 | buf.WriteByte('(')
41 | buf.WriteString(strings.Join(conflict, ", "))
42 | buf.WriteString(") DO UPDATE SET ")
43 |
44 | for i, v := range update {
45 | if i != 0 {
46 | buf.WriteByte(',')
47 | }
48 | quoted := strmangle.IdentQuote(dia.LQ, dia.RQ, v)
49 | buf.WriteString(quoted)
50 | buf.WriteString(" = EXCLUDED.")
51 | buf.WriteString(quoted)
52 | }
53 | }
54 |
55 | if len(ret) != 0 {
56 | buf.WriteString(" RETURNING ")
57 | buf.WriteString(strings.Join(ret, ", "))
58 | }
59 |
60 | return buf.String()
61 | }
62 |
--------------------------------------------------------------------------------
/modext/archive.go:
--------------------------------------------------------------------------------
1 | package modext
2 |
3 | import "koushoku/models"
4 |
5 | type Archive struct {
6 | ID int64 `json:"id"`
7 | Path string `json:"-"`
8 |
9 | Expunged bool `json:"expunged,omitempty"`
10 | RedirectId int64 `json:"redirectId,omitempty"`
11 |
12 | CreatedAt int64 `json:"createdAt"`
13 | UpdatedAt int64 `json:"updatedAt"`
14 | PublishedAt int64 `json:"publishedAt,omitempty"`
15 |
16 | Title string `json:"title"`
17 | Slug string `json:"slug"`
18 | Pages int16 `json:"pages,omitempty"`
19 | Size int64 `json:"size,omitempty"`
20 | Source string `json:"source,omitempty"`
21 |
22 | Artists []*Artist `json:"artists,omitempty"`
23 | Circles []*Circle `json:"circles,omitempty"`
24 | Magazines []*Magazine `json:"magazines,omitempty"`
25 | Parodies []*Parody `json:"parodies,omitempty"`
26 | Tags []*Tag `json:"tags,omitempty"`
27 | Submission *Submission `json:"submission,omitempty"`
28 | }
29 |
30 | func NewArchive(model *models.Archive) *Archive {
31 | if model == nil {
32 | return nil
33 | }
34 |
35 | archive := &Archive{
36 | ID: model.ID,
37 | Path: model.Path,
38 | Expunged: model.Expunged,
39 |
40 | CreatedAt: model.CreatedAt.Unix(),
41 | UpdatedAt: model.UpdatedAt.Unix(),
42 |
43 | Title: model.Title,
44 | Slug: model.Slug,
45 | Pages: model.Pages,
46 | Size: model.Size,
47 | Source: model.Source.String,
48 | }
49 |
50 | if model.RedirectID.Valid {
51 | archive.RedirectId = model.RedirectID.Int64
52 | }
53 |
54 | if model.PublishedAt.Valid {
55 | archive.PublishedAt = model.PublishedAt.Time.Unix()
56 | }
57 |
58 | return archive
59 | }
60 |
61 | func (archive *Archive) LoadRels(model *models.Archive) *Archive {
62 | if model == nil || model.R == nil {
63 | return archive
64 | }
65 |
66 | archive.LoadArtists(model)
67 | archive.LoadCircle(model)
68 | archive.LoadMagazine(model)
69 | archive.LoadParody(model)
70 | archive.LoadTags(model)
71 | archive.LoadSubmission(model)
72 |
73 | return archive
74 | }
75 |
76 | func (archive *Archive) LoadArtists(model *models.Archive) *Archive {
77 | if model == nil || model.R == nil || len(model.R.Artists) == 0 {
78 | return archive
79 | }
80 |
81 | archive.Artists = make([]*Artist, len(model.R.Artists))
82 | for i, artist := range model.R.Artists {
83 | archive.Artists[i] = NewArtist(artist)
84 | }
85 |
86 | return archive
87 | }
88 |
89 | func (archive *Archive) LoadCircle(model *models.Archive) *Archive {
90 | if model == nil || model.R == nil || len(model.R.Circles) == 0 {
91 | return archive
92 | }
93 |
94 | archive.Circles = make([]*Circle, len(model.R.Circles))
95 | for i, circle := range model.R.Circles {
96 | archive.Circles[i] = NewCircle(circle)
97 | }
98 |
99 | return archive
100 | }
101 |
102 | func (archive *Archive) LoadMagazine(model *models.Archive) *Archive {
103 | if model == nil || model.R == nil || len(model.R.Magazines) == 0 {
104 | return archive
105 | }
106 |
107 | archive.Magazines = make([]*Magazine, len(model.R.Magazines))
108 | for i, magazine := range model.R.Magazines {
109 | archive.Magazines[i] = NewMagazine(magazine)
110 | }
111 |
112 | return archive
113 | }
114 |
115 | func (archive *Archive) LoadParody(model *models.Archive) *Archive {
116 | if model == nil || model.R == nil || len(model.R.Parodies) == 0 {
117 | return archive
118 | }
119 |
120 | archive.Parodies = make([]*Parody, len(model.R.Parodies))
121 | for i, parody := range model.R.Parodies {
122 | archive.Parodies[i] = NewParody(parody)
123 | }
124 |
125 | return archive
126 | }
127 |
128 | func (archive *Archive) LoadTags(model *models.Archive) *Archive {
129 | if model == nil || model.R == nil || len(model.R.Tags) == 0 {
130 | return archive
131 | }
132 |
133 | archive.Tags = make([]*Tag, len(model.R.Tags))
134 | for i, tag := range model.R.Tags {
135 | archive.Tags[i] = NewTag(tag)
136 | }
137 |
138 | return archive
139 | }
140 |
141 | func (archive *Archive) LoadSubmission(model *models.Archive) *Archive {
142 | if model == nil || model.R == nil || model.R.Submission == nil {
143 | return archive
144 | }
145 |
146 | archive.Submission = NewSubmission(model.R.Submission)
147 | return archive
148 | }
149 |
--------------------------------------------------------------------------------
/modext/artist.go:
--------------------------------------------------------------------------------
1 | package modext
2 |
3 | import "koushoku/models"
4 |
5 | type Artist struct {
6 | ID int64 `json:"id" boil:"id"`
7 | Slug string `json:"slug" boil:"slug"`
8 | Name string `json:"name" boil:"name"`
9 | Count int64 `json:"count,omitempty" boil:"archive_count"`
10 | }
11 |
12 | func NewArtist(model *models.Artist) *Artist {
13 | if model == nil {
14 | return nil
15 | }
16 | return &Artist{ID: model.ID, Slug: model.Slug, Name: model.Name}
17 | }
18 |
--------------------------------------------------------------------------------
/modext/circle.go:
--------------------------------------------------------------------------------
1 | package modext
2 |
3 | import "koushoku/models"
4 |
5 | type Circle struct {
6 | ID int64 `json:"id" boil:"id"`
7 | Slug string `json:"slug" boil:"slug"`
8 | Name string `json:"name" boil:"name"`
9 | Count int64 `json:"count,omitempty" boil:"archive_count"`
10 | }
11 |
12 | func NewCircle(model *models.Circle) *Circle {
13 | if model == nil {
14 | return nil
15 | }
16 | return &Circle{ID: model.ID, Slug: model.Slug, Name: model.Name}
17 | }
18 |
--------------------------------------------------------------------------------
/modext/magazine.go:
--------------------------------------------------------------------------------
1 | package modext
2 |
3 | import "koushoku/models"
4 |
5 | type Magazine struct {
6 | ID int64 `json:"id" boil:"id"`
7 | Slug string `json:"slug" boil:"slug"`
8 | Name string `json:"name" boil:"name"`
9 | Count int64 `json:"count,omitempty" boil:"archive_count"`
10 | }
11 |
12 | func NewMagazine(model *models.Magazine) *Magazine {
13 | if model == nil {
14 | return nil
15 | }
16 | return &Magazine{ID: model.ID, Slug: model.Slug, Name: model.Name}
17 | }
18 |
--------------------------------------------------------------------------------
/modext/parody.go:
--------------------------------------------------------------------------------
1 | package modext
2 |
3 | import "koushoku/models"
4 |
5 | type Parody struct {
6 | ID int64 `json:"id" boil:"id"`
7 | Slug string `json:"slug" boil:"slug"`
8 | Name string `json:"name" boil:"name"`
9 | Count int64 `json:"count,omitempty" boil:"archive_count"`
10 | }
11 |
12 | func NewParody(model *models.Parody) *Parody {
13 | if model == nil {
14 | return nil
15 | }
16 | return &Parody{ID: model.ID, Slug: model.Slug, Name: model.Name}
17 | }
18 |
--------------------------------------------------------------------------------
/modext/submission.go:
--------------------------------------------------------------------------------
1 | package modext
2 |
3 | import "koushoku/models"
4 |
5 | type Submission struct {
6 | ID int64 `json:"id"`
7 |
8 | CreatedAt int64 `json:"createdAt"`
9 | UpdatedAt int64 `json:"updatedAt"`
10 |
11 | Name string `json:"-"`
12 | Submitter string `json:"submitter,omitempty"`
13 | Content string `json:"-"`
14 | Notes string `json:"-"`
15 |
16 | AcceptedAt int64 `json:"acceptedAt,omitempty"`
17 | RejectedAt int64 `json:"rejectedAt,omitempty"`
18 |
19 | Accepted bool `json:"accepted,omitempty"`
20 | Rejected bool `json:"rejected,omitempty"`
21 |
22 | Archives []*Archive `json:"archives,omitempty"`
23 | }
24 |
25 | func NewSubmission(model *models.Submission) *Submission {
26 | if model == nil {
27 | return nil
28 | }
29 |
30 | submission := &Submission{
31 | ID: model.ID,
32 | CreatedAt: model.CreatedAt.Unix(),
33 | UpdatedAt: model.UpdatedAt.Unix(),
34 |
35 | Name: model.Name,
36 | Submitter: model.Submitter.String,
37 | Content: model.Content,
38 | Notes: model.Notes.String,
39 |
40 | Accepted: model.Accepted,
41 | Rejected: model.Rejected,
42 | }
43 |
44 | if model.AcceptedAt.Valid {
45 | submission.AcceptedAt = model.AcceptedAt.Time.Unix()
46 | }
47 |
48 | if model.RejectedAt.Valid {
49 | submission.RejectedAt = model.RejectedAt.Time.Unix()
50 | }
51 |
52 | return submission
53 | }
54 |
55 | func (submission *Submission) LoadRels(model *models.Submission) *Submission {
56 | if model == nil || model.R == nil {
57 | return submission
58 | }
59 |
60 | submission.LoadArchives(model)
61 |
62 | return submission
63 | }
64 |
65 | func (submission *Submission) LoadArchives(model *models.Submission) *Submission {
66 | if model == nil || model.R == nil || len(model.R.Archives) == 0 {
67 | return submission
68 | }
69 |
70 | submission.Archives = make([]*Archive, len(model.R.Archives))
71 | for i, archive := range model.R.Archives {
72 | submission.Archives[i] = NewArchive(archive)
73 | }
74 |
75 | return submission
76 | }
77 |
--------------------------------------------------------------------------------
/modext/tag.go:
--------------------------------------------------------------------------------
1 | package modext
2 |
3 | import "koushoku/models"
4 |
5 | type Tag struct {
6 | ID int64 `json:"id" boil:"id"`
7 | Slug string `json:"slug" boil:"slug"`
8 | Name string `json:"name" boil:"name"`
9 | Count int64 `json:"count,omitempty" boil:"archive_count"`
10 | }
11 |
12 | func NewTag(model *models.Tag) *Tag {
13 | if model == nil {
14 | return nil
15 | }
16 | return &Tag{ID: model.ID, Slug: model.Slug, Name: model.Name}
17 | }
18 |
--------------------------------------------------------------------------------
/modext/user.go:
--------------------------------------------------------------------------------
1 | package modext
2 |
3 | import "koushoku/models"
4 |
5 | type User struct {
6 | ID int64 `json:"id"`
7 |
8 | CreatedAt int64 `json:"createdAt,omitempty"`
9 | UpdatedAt int64 `json:"updatedAt,omitempty"`
10 |
11 | Email string `json:"email"`
12 | Password string `json:"password"`
13 | Name string `json:"name"`
14 |
15 | IsBanned bool `json:"isBanned,omitempty"`
16 | IsAdmin bool `json:"isAdmin,omitempty"`
17 |
18 | Favorites []*Archive `json:"favorites,omitempty"`
19 | }
20 |
21 | func NewUser(model *models.User) *User {
22 | if model == nil {
23 | return nil
24 | }
25 | return &User{
26 | ID: model.ID,
27 |
28 | CreatedAt: model.CreatedAt.Unix(),
29 | UpdatedAt: model.UpdatedAt.Unix(),
30 |
31 | Email: model.Email,
32 | Password: model.Password,
33 | Name: model.Name,
34 |
35 | IsBanned: model.IsBanned,
36 | IsAdmin: model.IsAdmin,
37 | }
38 | }
39 |
40 | func (user *User) LoadFavorites(model *models.User) *User {
41 | if model.R == nil || len(model.R.Archives) == 0 {
42 | return user
43 | }
44 |
45 | user.Favorites = make([]*Archive, len(model.R.Archives))
46 | for i, archive := range model.R.Archives {
47 | user.Favorites[i] = NewArchive(archive)
48 | }
49 | return user
50 | }
51 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "koushoku-web",
3 | "license": "GPL-3.0",
4 | "private": true,
5 | "scripts": {
6 | "prod": "webpack --config webpack.prod.js",
7 | "dev": "webpack --config webpack.dev.js --watch"
8 | },
9 | "dependencies": {
10 | "workbox-cacheable-response": "^6.4.2",
11 | "workbox-expiration": "^6.4.2",
12 | "workbox-routing": "^6.4.2",
13 | "workbox-strategies": "^6.4.2"
14 | },
15 | "devDependencies": {
16 | "@babel/core": "^7.16.0",
17 | "@babel/preset-env": "^7.16.4",
18 | "@babel/preset-typescript": "^7.16.0",
19 | "@typescript-eslint/eslint-plugin": "^5.4.0",
20 | "@typescript-eslint/parser": "^5.4.0",
21 | "autoprefixer": "^10.4.0",
22 | "babel-loader": "^8.2.3",
23 | "copy-webpack-plugin": "^10.2.4",
24 | "css-loader": "^6.5.1",
25 | "css-minimizer-webpack-plugin": "^3.4.1",
26 | "eslint": "^8.3.0",
27 | "eslint-config-airbnb-base": "^15.0.0",
28 | "eslint-config-airbnb-typescript": "^16.0.0",
29 | "eslint-config-prettier": "^8.3.0",
30 | "eslint-plugin-import": "^2.25.3",
31 | "eslint-plugin-prettier": "^4.0.0",
32 | "html-webpack-plugin": "^5.5.0",
33 | "less": "^4.1.2",
34 | "less-loader": "^10.2.0",
35 | "mini-css-extract-plugin": "^2.5.2",
36 | "path": "^0.12.7",
37 | "postcss": "^8.4.3",
38 | "postcss-less": "^5.0.0",
39 | "postcss-loader": "^6.2.1",
40 | "prettier": "^2.5.0",
41 | "prettier-plugin-go-template": "^0.0.11",
42 | "style-loader": "^3.3.1",
43 | "ts-loader": "^9.2.6",
44 | "typescript": "^4.5.2",
45 | "uglifyjs-webpack-plugin": "^2.2.0",
46 | "webpack": "^5.64.4",
47 | "webpack-cli": "^4.9.1"
48 | },
49 | "browserslist": [
50 | "> 1%",
51 | "last 2 versions"
52 | ]
53 | }
54 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | const autoprefixer = require("autoprefixer");
2 |
3 | module.exports = {
4 | plugins: [autoprefixer]
5 | };
6 |
--------------------------------------------------------------------------------
/server/context.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "net/http"
7 | "net/url"
8 | "os"
9 | "strconv"
10 | "strings"
11 | "sync"
12 | "time"
13 |
14 | . "koushoku/config"
15 |
16 | "github.com/gin-gonic/gin"
17 | )
18 |
19 | type Context struct {
20 | *gin.Context
21 |
22 | sync.RWMutex
23 | MapData map[string]any
24 | }
25 |
26 | func (c *Context) GetURL() string {
27 | u, _ := url.Parse(Config.Meta.BaseURL)
28 | u.Path = c.Request.URL.Path
29 | u.RawQuery = c.Request.URL.RawQuery
30 | return u.String()
31 | }
32 |
33 | func (c *Context) preHTML(code *int) {
34 | if err, ok := c.GetData("error"); ok {
35 | err := strings.ToLower(err.(error).Error())
36 | if strings.Contains(err, "does not exist") || strings.Contains(err, "not found") {
37 | *code = http.StatusNotFound
38 | }
39 | }
40 |
41 | c.SetData("status", *code)
42 | c.SetData("statusText", http.StatusText(*code))
43 |
44 | if v, ok := c.MapData["name"]; !ok || len(v.(string)) == 0 {
45 | c.SetData("name", http.StatusText(*code))
46 | }
47 |
48 | c.SetData("title", Config.Meta.Title)
49 | c.SetData("description", Config.Meta.Description)
50 | c.SetData("baseURL", Config.Meta.BaseURL)
51 | c.SetData("dataBaseURL", Config.Meta.DataBaseURL)
52 | c.SetData("language", Config.Meta.Language)
53 | c.SetData("url", c.GetURL())
54 | c.SetData("query", c.Request.URL.Query())
55 | }
56 |
57 | func (c *Context) HTML(code int, name string) {
58 | c.preHTML(&code)
59 | renderTemplate(c, &RenderOptions{
60 | Data: c.MapData,
61 | Name: name,
62 | Status: code,
63 | })
64 | }
65 |
66 | func (c *Context) Cache(code int, name string) {
67 | if gin.Mode() == gin.DebugMode {
68 | c.HTML(code, name)
69 | } else {
70 | c.preHTML(&code)
71 | renderTemplate(c, &RenderOptions{
72 | Cache: true,
73 | Data: c.MapData,
74 | Name: name,
75 | Status: code,
76 | })
77 | }
78 | }
79 |
80 | func (c *Context) cacheKey() string {
81 | return c.GetURL()
82 | }
83 |
84 | func (c *Context) IsCached(name string) bool {
85 | _, ok := getTemplate(name, c.cacheKey())
86 | return ok
87 | }
88 |
89 | func (c *Context) TryCache(name string) bool {
90 | if c.IsCached(name) {
91 | c.Cache(http.StatusOK, name)
92 | return true
93 | }
94 | return false
95 | }
96 |
97 | func (c *Context) ErrorJSON(code int, message string, err error) {
98 | c.JSON(code, gin.H{
99 | "error": gin.H{
100 | "message": message,
101 | "cause": err.Error(),
102 | },
103 | })
104 | }
105 |
106 | func (c *Context) GetData(key string) (any, bool) {
107 | c.RLock()
108 | defer c.RUnlock()
109 |
110 | v, exists := c.MapData[key]
111 | return v, exists
112 | }
113 |
114 | func (c *Context) SetData(key string, value any) {
115 | c.Lock()
116 | defer c.Unlock()
117 |
118 | if c.MapData == nil {
119 | c.MapData = make(map[string]any)
120 | }
121 | c.MapData[key] = value
122 | }
123 |
124 | func (c *Context) SetCookie(name, value string, expires *time.Time) {
125 | cookie := &http.Cookie{
126 | Name: name,
127 | Value: url.QueryEscape(value),
128 | Path: "/",
129 | SameSite: http.SameSiteLaxMode,
130 | Secure: c.Request.TLS != nil || strings.HasPrefix(Config.Meta.BaseURL, "https"),
131 | HttpOnly: true,
132 | }
133 |
134 | if expires == nil {
135 | cookie.MaxAge = -1
136 | } else {
137 | cookie.Expires = *expires
138 | }
139 | http.SetCookie(c.Writer, cookie)
140 | }
141 |
142 | func (c *Context) ParamInt(name string) (int, error) {
143 | return strconv.Atoi(c.Param(name))
144 | }
145 |
146 | func (c *Context) ParamInt64(name string) (int64, error) {
147 | return strconv.ParseInt(c.Param(name), 10, 64)
148 | }
149 |
150 | type readLimiter struct {
151 | io.ReadSeeker
152 | r io.Reader
153 | }
154 |
155 | func (r readLimiter) Read(p []byte) (int, error) {
156 | return r.r.Read(p)
157 | }
158 |
159 | const (
160 | rate = 1 << 20
161 | capacity = 1 << 20
162 | )
163 |
164 | func (c *Context) serveContent(stream bool, stat os.FileInfo, content io.ReadSeeker) {
165 | if stream {
166 | c.Writer.Header().Set("Content-Type", "application/octet-stream")
167 | c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", stat.Name()))
168 | }
169 |
170 | //bucket := ratelimit.NewBucketWithRate(rate, capacity)
171 | //limiter := readLimiter{content, ratelimit.Reader(content, bucket)}
172 | //http.ServeContent(c.Writer, c.Request, stat.Name(), stat.ModTime(), limiter)
173 | http.ServeContent(c.Writer, c.Request, stat.Name(), stat.ModTime(), content)
174 | }
175 |
176 | func (c *Context) serveFile(stream bool, filepath string) {
177 | stat, err := os.Stat(filepath)
178 | if err != nil {
179 | if os.IsNotExist(err) {
180 | c.Status(http.StatusNotFound)
181 | } else {
182 | c.Status(http.StatusInternalServerError)
183 | }
184 | return
185 | }
186 |
187 | f, err := os.Open(filepath)
188 | if err != nil {
189 | c.Status(http.StatusInternalServerError)
190 | return
191 | }
192 | defer f.Close()
193 | c.serveContent(stream, stat, f)
194 | }
195 |
196 | func (c *Context) ServeFile(filepath string) {
197 | c.serveFile(false, filepath)
198 | }
199 |
200 | func (c *Context) StreamFile(filepath string) {
201 | c.serveFile(true, filepath)
202 | }
203 |
204 | func (c *Context) ServeData(stat os.FileInfo, data io.ReadSeeker) {
205 | c.serveContent(false, stat, data)
206 | }
207 |
208 | func (c *Context) StreamData(stat os.FileInfo, data io.ReadSeeker) {
209 | c.serveContent(true, stat, data)
210 | }
211 |
--------------------------------------------------------------------------------
/server/middlewares.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "log"
5 | "net/http"
6 |
7 | "koushoku/cache"
8 |
9 | "github.com/ulule/limiter/v3"
10 | mgin "github.com/ulule/limiter/v3/drivers/middleware/gin"
11 | "github.com/ulule/limiter/v3/drivers/store/redis"
12 | )
13 |
14 | func WithName(name string) Handler {
15 | return func(c *Context) {
16 | c.SetData("name", name)
17 | c.Next()
18 | }
19 | }
20 |
21 | func WithRedirect(relativePath string) Handler {
22 | return func(c *Context) {
23 | c.Redirect(http.StatusFound, relativePath)
24 | }
25 | }
26 |
27 | var limiters = make(map[string]Handler)
28 |
29 | func WithRateLimit(prefix, formatted string) Handler {
30 | handler, ok := limiters[prefix]
31 | if !ok {
32 | rate, err := limiter.NewRateFromFormatted(formatted)
33 | if err != nil {
34 | log.Fatalln(err)
35 | }
36 |
37 | store, err := redis.NewStoreWithOptions(cache.Redis, limiter.StoreOptions{
38 | Prefix: prefix,
39 | })
40 | if err != nil {
41 | log.Fatalln(err)
42 | }
43 |
44 | instance := limiter.New(store, rate, limiter.WithTrustForwardHeader(true))
45 | m := mgin.NewMiddleware(instance)
46 |
47 | handler = func(c *Context) { m(c.Context) }
48 | limiters[prefix] = handler
49 | }
50 | return handler
51 | }
52 |
--------------------------------------------------------------------------------
/server/server.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "net/http"
7 | "strings"
8 |
9 | . "koushoku/config"
10 |
11 | "github.com/gin-contrib/gzip"
12 | "github.com/gin-gonic/gin"
13 | )
14 |
15 | type Handler func(c *Context)
16 | type Handlers []Handler
17 |
18 | var server *gin.Engine
19 | var secretHandler func()
20 |
21 | func Init() {
22 | if strings.EqualFold(Config.Mode, "production") {
23 | gin.SetMode(gin.ReleaseMode)
24 | } else {
25 | gin.SetMode(gin.DebugMode)
26 | }
27 |
28 | server = gin.Default()
29 | LoadTemplates()
30 |
31 | server.ForwardedByClientIP = true
32 | server.RedirectTrailingSlash = true
33 | server.RemoveExtraSlash = true
34 |
35 | server.Use(gzip.Gzip(gzip.DefaultCompression))
36 | if secretHandler != nil {
37 | secretHandler()
38 | }
39 | }
40 |
41 | func Start(port int) {
42 | if gin.Mode() != gin.DebugMode {
43 | log.Println("Listening and serving HTTP on :", port)
44 | }
45 |
46 | srv := &http.Server{Addr: fmt.Sprintf(":%d", port), Handler: server}
47 | if err := srv.ListenAndServe(); err != nil {
48 | log.Fatalln(err)
49 | }
50 | }
51 |
52 | func (h Handler) wrap() gin.HandlerFunc {
53 | return func(c *gin.Context) {
54 | var context *Context
55 | if v, exists := c.Get("context"); exists {
56 | context = v.(*Context)
57 | } else {
58 | context = &Context{Context: c}
59 | c.Set("context", context)
60 | }
61 | h(context)
62 | }
63 | }
64 |
65 | func (h Handlers) wrap() []gin.HandlerFunc {
66 | ginHandlers := make([]gin.HandlerFunc, len(h))
67 | for i := range h {
68 | ginHandlers[i] = h[i].wrap()
69 | }
70 | return ginHandlers
71 | }
72 |
73 | func Group(relativePath string, handlers ...Handler) *gin.RouterGroup {
74 | return server.Group(relativePath, Handlers(handlers).wrap()...)
75 | }
76 |
77 | func Handle(method string, relativePath string, handlers ...Handler) {
78 | server.Handle(method, relativePath, Handlers(handlers).wrap()...)
79 | }
80 |
81 | func NoRoute(handlers ...Handler) {
82 | server.NoRoute(Handlers(handlers).wrap()...)
83 | }
84 |
85 | func GET(relativePath string, handlers ...Handler) {
86 | server.GET(relativePath, Handlers(handlers).wrap()...)
87 | }
88 |
89 | func POST(relativePath string, handlers ...Handler) {
90 | server.POST(relativePath, Handlers(handlers).wrap()...)
91 | }
92 |
93 | func PATCH(relativePath string, handlers ...Handler) {
94 | server.PATCH(relativePath, Handlers(handlers).wrap()...)
95 | }
96 |
97 | func PUT(relativePath string, handlers ...Handler) {
98 | server.PUT(relativePath, Handlers(handlers).wrap()...)
99 | }
100 |
101 | func DELETE(relativePath string, handlers ...Handler) {
102 | server.DELETE(relativePath, Handlers(handlers).wrap()...)
103 | }
104 |
105 | func HEAD(relativePath string, handlers ...Handler) {
106 | server.HEAD(relativePath, Handlers(handlers).wrap()...)
107 | }
108 |
--------------------------------------------------------------------------------
/server/template.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | html "html/template"
5 | text "text/template"
6 |
7 | "bytes"
8 | "fmt"
9 | "io/fs"
10 | "log"
11 | "net/http"
12 | "path/filepath"
13 | "strings"
14 | "sync"
15 |
16 | "koushoku/cache"
17 | . "koushoku/config"
18 |
19 | "github.com/gin-gonic/gin"
20 | "github.com/pkg/errors"
21 | )
22 |
23 | type RenderOptions struct {
24 | Cache bool
25 | Data map[string]any
26 | Name string
27 | Status int
28 | }
29 |
30 | const (
31 | htmlContentType = "text/html; charset=utf-8"
32 | xmlContentType = "application/xml; charset=utf-8"
33 | )
34 |
35 | var (
36 | htmlTemplates *html.Template
37 | xmlTemplates *text.Template
38 | mu sync.Mutex
39 | ErrTemplateNotFound = errors.New("Template not found")
40 | )
41 |
42 | func LoadTemplates() {
43 | mu.Lock()
44 | defer mu.Unlock()
45 |
46 | var htmlFiles, xmlFiles []string
47 | err := filepath.Walk(filepath.Join(Config.Directories.Templates),
48 | func(path string, stat fs.FileInfo, err error) error {
49 | if err != nil || stat.IsDir() {
50 | return err
51 | }
52 |
53 | if strings.HasSuffix(path, ".html") {
54 | htmlFiles = append(htmlFiles, path)
55 | } else {
56 | xmlFiles = append(xmlFiles, path)
57 | }
58 |
59 | return nil
60 | })
61 | if err != nil {
62 | log.Fatalln(err)
63 | }
64 |
65 | if len(htmlFiles) > 0 {
66 | htmlTemplates, err = html.New("").Funcs(helper).ParseFiles(htmlFiles...)
67 | if err != nil {
68 | log.Fatalln(err)
69 | }
70 | }
71 |
72 | if len(xmlFiles) > 0 {
73 | xmlTemplates, err = text.New("").Funcs(helper).ParseFiles(xmlFiles...)
74 | if err != nil {
75 | log.Fatalln(err)
76 | }
77 | }
78 | }
79 |
80 | func parseTemplate(name string, data any) ([]byte, error) {
81 | if strings.HasSuffix(name, ".html") {
82 | return parseHtmlTemplate(name, data)
83 | }
84 | return parseXmlTemplate(name, data)
85 | }
86 |
87 | func parseHtmlTemplate(name string, data any) ([]byte, error) {
88 | if gin.Mode() == gin.DebugMode {
89 | LoadTemplates()
90 | }
91 |
92 | t := htmlTemplates.Lookup(name)
93 | if t == nil {
94 | return nil, ErrTemplateNotFound
95 |
96 | }
97 |
98 | var buf bytes.Buffer
99 | if err := t.Execute(&buf, data); err != nil {
100 | log.Println(err)
101 | return nil, err
102 | }
103 | return buf.Bytes(), nil
104 | }
105 |
106 | func parseXmlTemplate(name string, data any) ([]byte, error) {
107 | if gin.Mode() == gin.DebugMode {
108 | LoadTemplates()
109 | }
110 |
111 | t := xmlTemplates.Lookup(name)
112 | if t == nil {
113 | return nil, ErrTemplateNotFound
114 |
115 | }
116 |
117 | var buf bytes.Buffer
118 | if err := t.Execute(&buf, data); err != nil {
119 | log.Println(err)
120 | return nil, err
121 | }
122 | return buf.Bytes(), nil
123 | }
124 |
125 | func getTemplate(name, key string) ([]byte, bool) {
126 | var v any
127 | var err error
128 |
129 | if len(key) > 0 {
130 | v, err = cache.Templates.Get(fmt.Sprintf("%s:%s", name, key))
131 | } else {
132 | v, err = cache.Templates.Get(name)
133 | }
134 |
135 | if err != nil {
136 | return nil, false
137 | }
138 | return v.([]byte), true
139 | }
140 |
141 | func setTemplate(name, key string, data any) ([]byte, error) {
142 | buf, err := parseTemplate(name, data)
143 | if err != nil {
144 | return nil, err
145 | }
146 |
147 | if len(key) > 0 {
148 | cache.Templates.Set(fmt.Sprintf("%s:%s", name, key), buf, 0)
149 | } else {
150 | cache.Templates.Set(name, buf, 0)
151 | }
152 | return buf, nil
153 | }
154 |
155 | func renderTemplate(c *Context, opts *RenderOptions) {
156 | var buf []byte
157 | if opts.Cache {
158 | var ok bool
159 | if buf, ok = getTemplate(opts.Name, c.cacheKey()); !ok {
160 | var err error
161 | buf, err = setTemplate(opts.Name, c.cacheKey(), opts.Data)
162 | if err != nil {
163 | c.Status(http.StatusInternalServerError)
164 | return
165 | }
166 | }
167 | } else {
168 | buf, _ = parseTemplate(opts.Name, opts.Data)
169 | }
170 |
171 | contentType := htmlContentType
172 | if strings.HasSuffix(opts.Name, ".xml") {
173 | contentType = xmlContentType
174 | }
175 | c.Data(opts.Status, contentType, buf)
176 | }
177 |
--------------------------------------------------------------------------------
/server/template_helper.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "fmt"
5 | "math"
6 | "net/url"
7 | "strings"
8 | "time"
9 |
10 | . "koushoku/config"
11 | "koushoku/services"
12 | )
13 |
14 | var helper = map[string]any{
15 | "baseURL": func() string {
16 | return Config.Meta.BaseURL
17 | },
18 |
19 | "dataBaseURL": func() string {
20 | return Config.Meta.DataBaseURL
21 | },
22 |
23 | "language": func() string {
24 | return Config.Meta.Language
25 | },
26 |
27 | "formatBytes": func(v any) string {
28 | var b int64
29 | switch v := v.(type) {
30 | case int:
31 | b = int64(v)
32 | case int64:
33 | b = v
34 | case uint:
35 | b = int64(v)
36 | case uint64:
37 | b = int64(v)
38 | }
39 | return services.FormatBytes(b)
40 | },
41 |
42 | "formatNumber": func(v any) string {
43 | var n int64
44 | switch v := v.(type) {
45 | case int:
46 | n = int64(v)
47 | case int16:
48 | n = int64(v)
49 | case int32:
50 | n = int64(v)
51 | case int64:
52 | n = v
53 | case uint:
54 | n = int64(v)
55 | case uint16:
56 | n = int64(v)
57 | case uint32:
58 | n = int64(v)
59 | case uint64:
60 | n = int64(v)
61 | }
62 | return services.FormatNumber(n)
63 | },
64 |
65 | "joinURL": func(base string, s ...string) string {
66 | return services.JoinURL(base, s...)
67 | },
68 |
69 | "setQuery": func(query url.Values, key string, value any) string {
70 | query.Set(key, fmt.Sprintf("%v", value))
71 | return fmt.Sprintf("?%s", query.Encode())
72 | },
73 |
74 | "makeSlice": func(v any) []int {
75 | var n int
76 | switch v := v.(type) {
77 | case int:
78 | n = v
79 | case int16:
80 | n = int(v)
81 | case int32:
82 | n = int(v)
83 | case int64:
84 | n = int(v)
85 | case uint:
86 | n = int(v)
87 | case uint32:
88 | n = int(v)
89 | case uint64:
90 | n = int(v)
91 | }
92 | return make([]int, n)
93 | },
94 |
95 | "createQuery": func(query url.Values, key string, value any) string {
96 | clone := make(url.Values)
97 | for k, v := range query {
98 | clone[k] = v
99 | }
100 | clone.Set(key, fmt.Sprintf("%v", value))
101 | return fmt.Sprintf("?%s", clone.Encode())
102 | },
103 |
104 | "includes": func(slice []string, s string) bool {
105 | for _, v := range slice {
106 | if strings.EqualFold(v, s) {
107 | return true
108 | }
109 | }
110 | return false
111 | },
112 |
113 | "add": func(a, b int) int {
114 | return a + b
115 | },
116 |
117 | "sub": func(a, b int) int {
118 | return a - b
119 | },
120 |
121 | "mul": func(a, b int) int {
122 | return a * b
123 | },
124 |
125 | "div": func(a, b int) int {
126 | return a / b
127 | },
128 |
129 | "mod": func(a, b int) int {
130 | return a % b
131 | },
132 |
133 | "inc": func(a int) int {
134 | return a + 1
135 | },
136 |
137 | "dec": func(a int) int {
138 | return a - 1
139 | },
140 |
141 | "abs": math.Abs,
142 | "floor": math.Floor,
143 | "ceil": math.Ceil,
144 | "min": math.Min,
145 | "max": math.Max,
146 |
147 | "lowerCase": strings.ToLower,
148 | "upperCase": strings.ToUpper,
149 | "titleCase": strings.Title,
150 | "trim": strings.Trim,
151 | "trimLeft": strings.TrimLeft,
152 | "trimRight": strings.TrimRight,
153 | "trimSpace": strings.TrimSpace,
154 | "trimPrefix": strings.TrimPrefix,
155 | "trimSuffix": strings.TrimSuffix,
156 | "hasPrefix": strings.HasPrefix,
157 | "hasSuffix": strings.HasSuffix,
158 | "contains": strings.Contains,
159 | "replace": strings.Replace,
160 |
161 | "formatTime": func(t time.Time, format string) string {
162 | return t.UTC().Format(format)
163 | },
164 |
165 | "formatUnix": func(n int64, format string) string {
166 | return time.Unix(n, 0).UTC().Format(format)
167 | },
168 |
169 | "currentTime": func() time.Time {
170 | return time.Now().UTC()
171 | },
172 |
173 | "currentUnix": func() int64 {
174 | return time.Now().UTC().Unix()
175 | },
176 |
177 | "currentYear": func() int {
178 | return time.Now().UTC().Year()
179 | },
180 |
181 | "currentMonth": func() int {
182 | return int(time.Now().UTC().Month())
183 | },
184 |
185 | "currentMonthString": func() string {
186 | return time.Now().UTC().Month().String()
187 | },
188 |
189 | "currentDay": func() int {
190 | return time.Now().UTC().Day()
191 | },
192 |
193 | "currentDayString": func() string {
194 | return time.Now().UTC().Weekday().String()
195 | },
196 |
197 | "currentHour": func() int {
198 | return time.Now().UTC().Hour()
199 | },
200 |
201 | "currentMinute": func() int {
202 | return time.Now().UTC().Minute()
203 | },
204 |
205 | "currentSecond": func() int {
206 | return time.Now().UTC().Second()
207 | },
208 | }
209 |
--------------------------------------------------------------------------------
/services/alias.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "bufio"
5 | "log"
6 | "os"
7 | "strings"
8 | "sync"
9 |
10 | . "koushoku/config"
11 | )
12 |
13 | var Aliases struct {
14 | ArchiveMatches map[string]string
15 | ArtistMatches map[string]string
16 | CircleMatches map[string]string
17 | MagazineMatches map[string]string
18 | ParodyMatches map[string]string
19 | TagMatches map[string]string
20 |
21 | once sync.Once
22 | }
23 |
24 | func InitAliases() {
25 | Aliases.once.Do(func() {
26 | Aliases.ArchiveMatches = make(map[string]string)
27 | Aliases.ArtistMatches = make(map[string]string)
28 | Aliases.CircleMatches = make(map[string]string)
29 | Aliases.MagazineMatches = make(map[string]string)
30 | Aliases.ParodyMatches = make(map[string]string)
31 | Aliases.TagMatches = make(map[string]string)
32 |
33 | stat, err := os.Stat(Config.Paths.Alias)
34 | if os.IsNotExist(err) || stat.IsDir() {
35 | return
36 | }
37 |
38 | f, err := os.Open(Config.Paths.Alias)
39 | if err != nil {
40 | log.Println(err)
41 | return
42 | }
43 | defer f.Close()
44 |
45 | scanner := bufio.NewScanner(f)
46 | scanner.Split(bufio.ScanLines)
47 |
48 | for scanner.Scan() {
49 | line := strings.TrimSpace(scanner.Text())
50 | if len(line) == 0 {
51 | continue
52 | }
53 |
54 | strs := strings.Split(strings.ToLower(line), ":")
55 | if len(strs) < 3 {
56 | continue
57 | }
58 |
59 | k := Slugify(strs[1])
60 | v := strings.TrimSpace(strings.Join(strs[2:], ":"))
61 |
62 | switch strings.TrimSpace(strs[0]) {
63 | case "title":
64 | Aliases.ArchiveMatches[k] = v
65 | case "artist":
66 | Aliases.ArtistMatches[k] = v
67 | case "circle":
68 | Aliases.CircleMatches[k] = v
69 | case "magazine":
70 | Aliases.MagazineMatches[k] = v
71 | case "parody":
72 | Aliases.ParodyMatches[k] = v
73 | case "tag":
74 | Aliases.TagMatches[k] = v
75 | }
76 | }
77 |
78 | if err := scanner.Err(); err != nil {
79 | log.Println(err)
80 | }
81 | })
82 | }
83 |
--------------------------------------------------------------------------------
/services/archive_favorite.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "database/sql"
5 | "log"
6 |
7 | "koushoku/cache"
8 | "koushoku/errs"
9 | "koushoku/models"
10 | "koushoku/modext"
11 |
12 | . "github.com/volatiletech/sqlboiler/v4/queries/qm"
13 | )
14 |
15 | func AddFavorite(id int64, user *modext.User) error {
16 | archive, err := models.FindArchiveG(id)
17 | if err != nil {
18 | if err == sql.ErrNoRows {
19 | return errs.ArchiveNotFound
20 | }
21 | log.Println(err)
22 | return errs.Unknown
23 | }
24 |
25 | if exists, _ := archive.Users(Where("id = ?", user.ID)).ExistsG(); exists {
26 | return nil
27 | }
28 |
29 | if err := archive.AddUsersG(false, &models.User{ID: user.ID}); err != nil {
30 | log.Println(err)
31 | return errs.Unknown
32 | }
33 | return nil
34 | }
35 |
36 | type GetFavoritesOptions = GetArchivesOptions
37 | type GetFavoritesResult = GetArchivesResult
38 |
39 | func GetFavorites(user *modext.User, opts GetFavoritesOptions) (result *GetFavoritesResult) {
40 | opts.Validate()
41 |
42 | cacheKey := makeCacheKey(opts)
43 | if c, err := cache.Favorites.GetWithPrefix(user.ID, cacheKey); err == nil {
44 | return c.(*GetFavoritesResult)
45 | }
46 |
47 | result = &GetFavoritesResult{Archives: []*modext.Archive{}}
48 | defer func() {
49 | if len(result.Archives) > 0 || result.Total > 0 || result.Err != nil {
50 | cache.Favorites.RemoveWithPrefix(user.ID, cacheKey)
51 | cache.Favorites.SetWithPrefix(user.ID, cacheKey, result, 0)
52 | }
53 | }()
54 |
55 | selectMods, countMods := opts.ToQueries()
56 | model := &models.User{ID: user.ID}
57 | archives, err := model.Archives(selectMods...).AllG()
58 | if err != nil {
59 | log.Println(err)
60 | result.Err = errs.Unknown
61 | return
62 | }
63 |
64 | count, err := model.Archives(countMods...).CountG()
65 | if err != nil {
66 | log.Println(err)
67 | result.Err = errs.Unknown
68 | return
69 | }
70 |
71 | result.Archives = make([]*modext.Archive, len(archives))
72 | result.Total = int(count)
73 |
74 | for i, archive := range archives {
75 | result.Archives[i] = modext.NewArchive(archive).LoadRels(archive)
76 | }
77 | return
78 | }
79 |
80 | func DeleteFavorite(id int64, user *modext.User) error {
81 | archive, err := models.FindArchiveG(id)
82 | if err != nil {
83 | if err == sql.ErrNoRows {
84 | return errs.ArchiveNotFound
85 | }
86 | log.Println(err)
87 | return errs.Unknown
88 | }
89 |
90 | if err := archive.RemoveUsersG(&models.User{ID: user.ID}); err != nil {
91 | log.Println(err)
92 | return errs.Unknown
93 | }
94 | // TODO: purge cache
95 | return nil
96 | }
97 |
--------------------------------------------------------------------------------
/services/archive_rels.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "sort"
5 | "strings"
6 | "sync"
7 |
8 | "koushoku/models"
9 | "koushoku/modext"
10 |
11 | "github.com/volatiletech/sqlboiler/v4/boil"
12 | )
13 |
14 | var relsCache struct {
15 | Artists map[string]*models.Artist
16 | Circles map[string]*models.Circle
17 | Magazines map[string]*models.Magazine
18 | Parodies map[string]*models.Parody
19 | Tags map[string]*models.Tag
20 |
21 | sync.RWMutex
22 | sync.Once
23 | }
24 |
25 | func PopulateArchiveRels(e boil.Executor, model *models.Archive, archive *modext.Archive) error {
26 | relsCache.Do(func() {
27 | relsCache.Lock()
28 | defer relsCache.Unlock()
29 |
30 | relsCache.Artists = make(map[string]*models.Artist)
31 | relsCache.Circles = make(map[string]*models.Circle)
32 | relsCache.Magazines = make(map[string]*models.Magazine)
33 | relsCache.Parodies = make(map[string]*models.Parody)
34 | relsCache.Tags = make(map[string]*models.Tag)
35 | })
36 |
37 | var err error
38 | if len(archive.Artists) > 0 {
39 | var artists []*models.Artist
40 | for _, artist := range archive.Artists {
41 | relsCache.RLock()
42 | artistModel, ok := relsCache.Artists[artist.Name]
43 | relsCache.RUnlock()
44 |
45 | if ok {
46 | artists = append(artists, artistModel)
47 | continue
48 | }
49 |
50 | relsCache.Lock()
51 | artist, err = CreateArtist(artist.Name)
52 | if err != nil {
53 | relsCache.Unlock()
54 | return err
55 | }
56 |
57 | artistModel = &models.Artist{ID: artist.ID, Slug: artist.Slug, Name: artist.Name}
58 | relsCache.Artists[artist.Name] = artistModel
59 | relsCache.Unlock()
60 |
61 | artists = append(artists, artistModel)
62 | }
63 | if err := model.SetArtists(e, false, artists...); err != nil {
64 | return err
65 | }
66 | }
67 |
68 | if len(archive.Circles) > 0 {
69 | var circles []*models.Circle
70 | for _, circle := range archive.Circles {
71 | relsCache.RLock()
72 | circleModel, ok := relsCache.Circles[circle.Name]
73 | relsCache.RUnlock()
74 |
75 | if ok {
76 | circles = append(circles, circleModel)
77 | continue
78 | }
79 |
80 | relsCache.Lock()
81 | circle, err := CreateCircle(circle.Name)
82 | if err != nil {
83 | relsCache.Unlock()
84 | return err
85 | }
86 |
87 | circleModel = &models.Circle{ID: circle.ID, Slug: circle.Slug, Name: circle.Name}
88 | relsCache.Circles[circle.Name] = circleModel
89 | relsCache.Unlock()
90 |
91 | circles = append(circles, circleModel)
92 | }
93 | if err := model.SetCircles(e, false, circles...); err != nil {
94 | return err
95 | }
96 | }
97 |
98 | if len(archive.Magazines) > 0 {
99 | var magazines []*models.Magazine
100 | for _, magazine := range archive.Magazines {
101 | relsCache.RLock()
102 | magazineModel, ok := relsCache.Magazines[magazine.Name]
103 | relsCache.RUnlock()
104 |
105 | if ok {
106 | magazines = append(magazines, magazineModel)
107 | continue
108 | }
109 |
110 | relsCache.Lock()
111 | magazine, err := CreateMagazine(magazine.Name)
112 | if err != nil {
113 | relsCache.Unlock()
114 | return err
115 | }
116 |
117 | magazineModel = &models.Magazine{ID: magazine.ID, Slug: magazine.Slug, Name: magazine.Name}
118 | relsCache.Magazines[magazine.Name] = magazineModel
119 | relsCache.Unlock()
120 |
121 | magazines = append(magazines, magazineModel)
122 | }
123 | if err := model.SetMagazines(e, false, magazines...); err != nil {
124 | return err
125 | }
126 | }
127 |
128 | if len(archive.Parodies) > 0 {
129 | var parodies []*models.Parody
130 | for _, parody := range archive.Parodies {
131 | relsCache.RLock()
132 | parodyModel, ok := relsCache.Parodies[parody.Name]
133 | relsCache.RUnlock()
134 |
135 | if ok {
136 | parodies = append(parodies, parodyModel)
137 | continue
138 | }
139 |
140 | relsCache.Lock()
141 | parody, err := CreateParody(parody.Name)
142 | if err != nil {
143 | relsCache.Unlock()
144 | return err
145 | }
146 |
147 | parodyModel = &models.Parody{ID: parody.ID, Slug: parody.Slug, Name: parody.Name}
148 | relsCache.Parodies[parody.Name] = parodyModel
149 | relsCache.Unlock()
150 |
151 | parodies = append(parodies, parodyModel)
152 | }
153 | if err := model.SetParodies(e, false, parodies...); err != nil {
154 | return err
155 | }
156 | }
157 |
158 | if len(archive.Tags) > 0 {
159 | var tags []*models.Tag
160 | for _, tag := range archive.Tags {
161 | relsCache.RLock()
162 | tagModel, ok := relsCache.Tags[tag.Name]
163 | relsCache.RUnlock()
164 |
165 | if ok {
166 | tags = append(tags, tagModel)
167 | continue
168 | }
169 |
170 | relsCache.Lock()
171 | tag, err := CreateTag(tag.Name)
172 | if err != nil {
173 | relsCache.Unlock()
174 | return err
175 | }
176 |
177 | tagModel = &models.Tag{ID: tag.ID, Slug: tag.Slug, Name: tag.Name}
178 | relsCache.Tags[tag.Name] = tagModel
179 | relsCache.Unlock()
180 |
181 | tags = append(tags, tagModel)
182 | }
183 | if err := model.SetTags(e, false, tags...); err != nil {
184 | return err
185 | }
186 | }
187 | return nil
188 | }
189 |
190 | func validateArchiveRels(rels []string) (result []string) {
191 | for _, v := range rels {
192 | if strings.EqualFold(v, ArchiveRels.Artists) {
193 | result = append(result, ArchiveRels.Artists)
194 | } else if strings.EqualFold(v, ArchiveRels.Circles) {
195 | result = append(result, ArchiveRels.Circles)
196 | } else if strings.EqualFold(v, ArchiveRels.Magazines) {
197 | result = append(result, ArchiveRels.Magazines)
198 | } else if strings.EqualFold(v, ArchiveRels.Parodies) {
199 | result = append(result, ArchiveRels.Parodies)
200 | } else if strings.EqualFold(v, ArchiveRels.Tags) {
201 | result = append(result, ArchiveRels.Tags)
202 | } else if strings.EqualFold(v, ArchiveRels.Submission) {
203 | result = append(result, ArchiveRels.Submission)
204 | }
205 | }
206 | sort.Strings(result)
207 | return
208 | }
209 |
--------------------------------------------------------------------------------
/services/archive_sql.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | const rawSqlArtistsMatch = `
4 | (
5 | SELECT COUNT(*) FROM archive_artists
6 | LEFT JOIN artist ON artist.id = archive_artists.artist_id
7 | AND archive_artists.archive_id = archive.id
8 | WHERE artist.slug = ?
9 | ) > 0`
10 |
11 | const rawSqlArtistsWildcard = `
12 | (
13 | SELECT COUNT(*) FROM archive_artists
14 | LEFT JOIN artist ON artist.id = archive_artists.artist_id
15 | AND archive_artists.archive_id = archive.id
16 | WHERE artist.slug ILIKE '%' || ? || '%'
17 | ) > 0`
18 |
19 | const rawSqlExcludeArtistsMatch = `(
20 | SELECT COUNT(*) FROM archive_artists
21 | LEFT JOIN artist ON artist.id = archive_artists.artist_id
22 | AND archive_artists.archive_id = archive.id
23 | WHERE artist.slug = ?
24 | ) = 0`
25 |
26 | const rawSqlExcludeArtistsWildcard = `(
27 | SELECT COUNT(*) FROM archive_artists
28 | LEFT JOIN artist ON artist.id = archive_artists.artist_id
29 | AND archive_artists.archive_id = archive.id
30 | WHERE artist.slug ILIKE '%' || ? || '%'
31 | ) = 0`
32 |
33 | const rawSqlCirclesMatch = `(
34 | SELECT COUNT(*) FROM archive_circles
35 | LEFT JOIN circle ON circle.id = archive_circles.circle_id
36 | AND archive_circles.archive_id = archive.id
37 | WHERE circle.slug = ?
38 | ) > 0`
39 |
40 | const rawSqlCirclesWildcard = `(
41 | SELECT COUNT(*) FROM archive_circles
42 | LEFT JOIN circle ON circle.id = archive_circles.circle_id
43 | AND archive_circles.archive_id = archive.id
44 | WHERE circle.slug ILIKE '%' || ? || '%'
45 | ) > 0`
46 |
47 | const rawSqlExcludeCirclesMatch = `(
48 | SELECT COUNT(*) FROM archive_circles
49 | LEFT JOIN circle ON circle.id = archive_circles.circle_id
50 | AND archive_circles.archive_id = archive.id
51 | WHERE circle.slug = ?
52 | ) = 0`
53 |
54 | const rawSqlExcludeCirclesWildcard = `(
55 | SELECT COUNT(*) FROM archive_circles
56 | LEFT JOIN circle ON circle.id = archive_circles.circle_id
57 | AND archive_circles.archive_id = archive.id
58 | WHERE circle.slug ILIKE '%' || ? || '%'
59 | ) = 0`
60 |
61 | const rawSqlMagazinesMatch = `(
62 | SELECT COUNT(*) FROM archive_magazines
63 | LEFT JOIN magazine ON magazine.id = archive_magazines.magazine_id
64 | AND archive_magazines.archive_id = archive.id
65 | WHERE magazine.slug = ?
66 | ) > 0`
67 |
68 | const rawSqlMagazinesWildcard = `(
69 | SELECT COUNT(*) FROM archive_magazines
70 | LEFT JOIN magazine ON magazine.id = archive_magazines.magazine_id
71 | AND archive_magazines.archive_id = archive.id
72 | WHERE magazine.slug ILIKE '%' || ? || '%'
73 | ) > 0`
74 |
75 | const rawSqlExcludeMagazinesMatch = `(
76 | SELECT COUNT(*) FROM archive_magazines
77 | LEFT JOIN magazine ON magazine.id = archive_magazines.magazine_id
78 | AND archive_magazines.archive_id = archive.id
79 | WHERE magazine.slug = ?
80 | ) = 0`
81 |
82 | const rawSqlExcludeMagazinesWildcard = `(
83 | SELECT COUNT(*) FROM archive_magazines
84 | LEFT JOIN magazine ON magazine.id = archive_magazines.magazine_id
85 | AND archive_magazines.archive_id = archive.id
86 | WHERE magazine.slug ILIKE '%' || ? || '%'
87 | ) = 0`
88 |
89 | const rawSqlParodiesMatch = `(
90 | SELECT COUNT(*) FROM archive_parodies
91 | LEFT JOIN parody ON parody.id = archive_parodies.parody_id
92 | AND archive_parodies.archive_id = archive.id
93 | WHERE parody.slug = ?
94 | ) > 0`
95 |
96 | const rawSqlParodiesWildcard = `(
97 | SELECT COUNT(*) FROM archive_parodies
98 | LEFT JOIN parody ON parody.id = archive_parodies.parody_id
99 | AND archive_parodies.archive_id = archive.id
100 | WHERE parody.slug ILIKE '%' || ? || '%'
101 | ) > 0`
102 |
103 | const rawSqlExcludeParodiesMatch = `(
104 | SELECT COUNT(*) FROM archive_parodies
105 | LEFT JOIN parody ON parody.id = archive_parodies.parody_id
106 | AND archive_parodies.archive_id = archive.id
107 | WHERE parody.slug ILIKE ?
108 | ) = 0`
109 |
110 | const rawSqlExcludeParodiesWildcard = `(
111 | SELECT COUNT(*) FROM archive_parodies
112 | LEFT JOIN parody ON parody.id = archive_parodies.parody_id
113 | AND archive_parodies.archive_id = archive.id
114 | WHERE parody.slug ILIKE '%' || ? || '%'
115 | ) = 0`
116 |
117 | const rawSqlTagsMatch = `
118 | (
119 | SELECT COUNT(*) FROM archive_tags
120 | LEFT JOIN tag ON tag.id = archive_tags.tag_id
121 | AND archive_tags.archive_id = archive.id
122 | WHERE tag.slug = ?
123 | ) > 0`
124 |
125 | const rawSqlTagsWildcard = `(
126 | SELECT COUNT(*) FROM archive_tags
127 | LEFT JOIN tag ON tag.id = archive_tags.tag_id
128 | AND archive_tags.archive_id = archive.id
129 | WHERE tag.slug ILIKE '%' || ? || '%'
130 | ) > 0`
131 |
132 | const rawSqlExcludeTagsMatch = `(
133 | SELECT COUNT(*) FROM archive_tags
134 | LEFT JOIN tag ON tag.id = archive_tags.tag_id
135 | AND archive_tags.archive_id = archive.id
136 | WHERE tag.slug = ?
137 | ) = 0`
138 |
139 | const rawSqlExcludeTagsWildcard = `(
140 | SELECT COUNT(*) FROM archive_tags
141 | LEFT JOIN tag ON tag.id = archive_tags.tag_id
142 | AND archive_tags.archive_id = archive.id
143 | WHERE tag.slug ILIKE '%' || ? || '%'
144 | ) = 0`
145 |
--------------------------------------------------------------------------------
/services/artist.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 | "log"
7 | "strings"
8 |
9 | "koushoku/cache"
10 | "koushoku/errs"
11 | "koushoku/models"
12 | "koushoku/modext"
13 |
14 | "github.com/volatiletech/sqlboiler/v4/boil"
15 |
16 | . "github.com/volatiletech/sqlboiler/v4/queries/qm"
17 | )
18 |
19 | func CreateArtist(name string) (*modext.Artist, error) {
20 | name = strings.Title(strings.TrimSpace(name))
21 | if len(name) == 0 {
22 | return nil, errs.ArtistNameRequired
23 | } else if len(name) > 128 {
24 | return nil, errs.ArtistNameTooLong
25 | }
26 |
27 | slug := Slugify(name)
28 | artist, err := models.Artists(Where("slug = ?", slug)).OneG()
29 | if err == sql.ErrNoRows {
30 | artist = &models.Artist{Name: name, Slug: slug}
31 | if err = artist.InsertG(boil.Infer()); err != nil {
32 | log.Println(err)
33 | return nil, errs.Unknown
34 | }
35 | } else if err != nil {
36 | log.Println(err)
37 | return nil, errs.Unknown
38 | }
39 |
40 | return modext.NewArtist(artist), nil
41 | }
42 |
43 | func GetArtist(slug string) (*modext.Artist, error) {
44 | artist, err := models.Artists(Where("slug = ?", slug)).OneG()
45 | if err != nil {
46 | if err == sql.ErrNoRows {
47 | return nil, errs.ArtistNotFound
48 | }
49 | log.Println(err)
50 | return nil, errs.Unknown
51 | }
52 | return modext.NewArtist(artist), nil
53 | }
54 |
55 | type GetArtistsOptions struct {
56 | Limit int `json:"1,omitempty"`
57 | Offset int `json:"2,omitempty"`
58 | }
59 |
60 | type GetArtistsResult struct {
61 | Artists []*modext.Artist
62 | Total int
63 | Err error
64 | }
65 |
66 | func GetArtists(opts GetArtistsOptions) (result *GetArtistsResult) {
67 | opts.Limit = Max(opts.Limit, 0)
68 | opts.Offset = Max(opts.Offset, 0)
69 |
70 | const prefix = "artists"
71 | cacheKey := makeCacheKey(opts)
72 | if c, err := cache.Taxonomies.GetWithPrefix(prefix, cacheKey); err == nil {
73 | return c.(*GetArtistsResult)
74 | }
75 |
76 | result = &GetArtistsResult{Artists: []*modext.Artist{}}
77 | defer func() {
78 | if len(result.Artists) > 0 || result.Total > 0 || result.Err != nil {
79 | cache.Taxonomies.RemoveWithPrefix(prefix, cacheKey)
80 | cache.Taxonomies.SetWithPrefix(prefix, cacheKey, result, 0)
81 | }
82 | }()
83 |
84 | q := []QueryMod{
85 | Select("artist.*", "COUNT(archive.artist_id) AS archive_count"),
86 | InnerJoin("archive_artists archive ON archive.artist_id = artist.id"),
87 | GroupBy("artist.id"), OrderBy("artist.name ASC"),
88 | }
89 |
90 | if opts.Limit > 0 {
91 | q = append(q, Limit(opts.Limit))
92 | if opts.Offset > 0 {
93 | q = append(q, Offset(opts.Offset))
94 | }
95 | }
96 |
97 | err := models.Artists(q...).BindG(context.Background(), &result.Artists)
98 | if err != nil {
99 | log.Println(err)
100 | result.Err = errs.Unknown
101 | return
102 | }
103 |
104 | count, err := models.Artists().CountG()
105 | if err != nil {
106 | log.Println(err)
107 | result.Err = errs.Unknown
108 | return
109 | }
110 |
111 | result.Total = int(count)
112 | return
113 | }
114 |
115 | func GetArtistCount() (int64, error) {
116 | const cachekey = "artistCount"
117 | if c, err := cache.Taxonomies.Get(cachekey); err == nil {
118 | return c.(int64), nil
119 | }
120 |
121 | count, err := models.Artists().CountG()
122 | if err != nil {
123 | log.Println(err)
124 | return 0, errs.Unknown
125 | }
126 |
127 | cache.Taxonomies.Set(cachekey, count, 0)
128 | return count, nil
129 | }
130 |
131 | var artistIndexes = IndexMap{Cache: make(map[string]bool)}
132 |
133 | func IsArtistValid(str string) (isValid bool) {
134 | str = Slugify(str)
135 | if v, ok := artistIndexes.Get(str); ok {
136 | return v
137 | }
138 |
139 | result := GetArtists(GetArtistsOptions{})
140 | if result.Err != nil {
141 | return
142 | }
143 |
144 | defer artistIndexes.Add(str, isValid)
145 | for _, artist := range result.Artists {
146 | if artist.Slug == str {
147 | isValid = true
148 | break
149 | }
150 | }
151 | return
152 | }
153 |
--------------------------------------------------------------------------------
/services/blacklist.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "bufio"
5 | "log"
6 | "os"
7 | "strings"
8 | "sync"
9 |
10 | . "koushoku/config"
11 | )
12 |
13 | var Blacklists struct {
14 | ArchiveMatches map[string]bool
15 | ArchiveWildcards []string
16 | ArtistMatches map[string]bool
17 | CircleMatches map[string]bool
18 | MagazineMatches map[string]bool
19 | TagMatches map[string]bool
20 |
21 | once sync.Once
22 | }
23 |
24 | func InitBlacklists() {
25 | Blacklists.once.Do(func() {
26 | Blacklists.ArchiveMatches = make(map[string]bool)
27 | Blacklists.ArtistMatches = make(map[string]bool)
28 | Blacklists.CircleMatches = make(map[string]bool)
29 | Blacklists.MagazineMatches = make(map[string]bool)
30 | Blacklists.TagMatches = make(map[string]bool)
31 |
32 | stat, err := os.Stat(Config.Paths.Blacklist)
33 | if os.IsNotExist(err) || stat.IsDir() {
34 | return
35 | }
36 |
37 | f, err := os.Open(Config.Paths.Blacklist)
38 | if err != nil {
39 | log.Println(err)
40 | return
41 | }
42 | defer f.Close()
43 |
44 | scanner := bufio.NewScanner(f)
45 | scanner.Split(bufio.ScanLines)
46 |
47 | for scanner.Scan() {
48 | line := strings.TrimSpace(scanner.Text())
49 | if len(line) == 0 {
50 | continue
51 | }
52 |
53 | strs := strings.Split(strings.ToLower(line), ":")
54 | if len(strs) < 2 {
55 | continue
56 | }
57 |
58 | v := Slugify(strings.Join(strs[1:], ":"))
59 |
60 | switch strings.TrimSpace(strs[0]) {
61 | case "title":
62 | Blacklists.ArchiveMatches[v] = true
63 | case "title*":
64 | Blacklists.ArchiveWildcards = append(Blacklists.ArchiveWildcards, v)
65 | case "artist":
66 | Blacklists.ArtistMatches[v] = true
67 | case "circle":
68 | Blacklists.CircleMatches[v] = true
69 | case "magazine":
70 | Blacklists.MagazineMatches[v] = true
71 | case "tag":
72 | Blacklists.TagMatches[v] = true
73 | }
74 | }
75 |
76 | if err := scanner.Err(); err != nil {
77 | log.Println(err)
78 | }
79 | })
80 | }
81 |
--------------------------------------------------------------------------------
/services/circle.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 | "log"
7 | "strings"
8 |
9 | "koushoku/cache"
10 | "koushoku/errs"
11 | "koushoku/models"
12 | "koushoku/modext"
13 |
14 | "github.com/volatiletech/sqlboiler/v4/boil"
15 | . "github.com/volatiletech/sqlboiler/v4/queries/qm"
16 | )
17 |
18 | func CreateCircle(name string) (*modext.Circle, error) {
19 | name = strings.Title(strings.TrimSpace(name))
20 | if len(name) == 0 {
21 | return nil, errs.CircleNameRequired
22 | } else if len(name) > 128 {
23 | return nil, errs.CircleNameTooLong
24 | }
25 |
26 | slug := Slugify(name)
27 | circle, err := models.Circles(Where("slug = ?", slug)).OneG()
28 | if err == sql.ErrNoRows {
29 | circle = &models.Circle{Name: name, Slug: slug}
30 | if err = circle.InsertG(boil.Infer()); err != nil {
31 | log.Println(err)
32 | return nil, errs.Unknown
33 | }
34 | } else if err != nil {
35 | log.Println(err)
36 | return nil, errs.Unknown
37 | }
38 | return modext.NewCircle(circle), nil
39 | }
40 |
41 | func GetCircle(slug string) (*modext.Circle, error) {
42 | circle, err := models.Circles(Where("slug = ?", slug)).OneG()
43 | if err != nil {
44 | if err == sql.ErrNoRows {
45 | return nil, errs.CircleNotFound
46 | }
47 | log.Println(err)
48 | return nil, errs.Unknown
49 | }
50 | return modext.NewCircle(circle), nil
51 | }
52 |
53 | type GetCirclesOptions struct {
54 | Limit int `json:"1,omitempty"`
55 | Offset int `json:"2,omitempty"`
56 | }
57 |
58 | type GetCirclesResult struct {
59 | Circles []*modext.Circle
60 | Total int
61 | Err error
62 | }
63 |
64 | func GetCircles(opts GetCirclesOptions) (result *GetCirclesResult) {
65 | opts.Limit = Max(opts.Limit, 0)
66 | opts.Offset = Max(opts.Offset, 0)
67 |
68 | const prefix = "circles"
69 | cacheKey := makeCacheKey(opts)
70 | if c, err := cache.Taxonomies.GetWithPrefix(prefix, cacheKey); err == nil {
71 | return c.(*GetCirclesResult)
72 | }
73 |
74 | result = &GetCirclesResult{Circles: []*modext.Circle{}}
75 | defer func() {
76 | if len(result.Circles) > 0 || result.Total > 0 || result.Err != nil {
77 | cache.Taxonomies.RemoveWithPrefix(prefix, cacheKey)
78 | cache.Taxonomies.SetWithPrefix(prefix, cacheKey, result, 0)
79 | }
80 | }()
81 |
82 | q := []QueryMod{
83 | Select("circle.*", "COUNT(archive.circle_id) AS archive_count"),
84 | InnerJoin("archive_circles archive ON archive.circle_id = circle.id"),
85 | GroupBy("circle.id"), OrderBy("circle.name ASC"),
86 | }
87 |
88 | if opts.Limit > 0 {
89 | q = append(q, Limit(opts.Limit))
90 | if opts.Offset > 0 {
91 | q = append(q, Offset(opts.Offset))
92 | }
93 | }
94 |
95 | err := models.Circles(q...).BindG(context.Background(), &result.Circles)
96 | if err != nil {
97 | log.Println(err)
98 | result.Err = errs.Unknown
99 | return
100 | }
101 |
102 | count, err := models.Circles().CountG()
103 | if err != nil {
104 | log.Println(err)
105 | result.Err = errs.Unknown
106 | return
107 | }
108 |
109 | result.Total = int(count)
110 | return
111 | }
112 |
113 | func GetCircleCount() (int64, error) {
114 | const cacheKey = "circleCount"
115 | if c, err := cache.Taxonomies.Get(cacheKey); err == nil {
116 | return c.(int64), nil
117 | }
118 |
119 | count, err := models.Circles().CountG()
120 | if err != nil {
121 | log.Println(err)
122 | return 0, errs.Unknown
123 | }
124 |
125 | cache.Taxonomies.Set(cacheKey, count, 0)
126 | return count, nil
127 | }
128 |
129 | var circleIndexes = IndexMap{Cache: make(map[string]bool)}
130 |
131 | func IsCircleValid(str string) (isValid bool) {
132 | str = Slugify(str)
133 | if v, ok := circleIndexes.Get(str); ok {
134 | return v
135 | }
136 |
137 | result := GetCircles(GetCirclesOptions{})
138 | if result.Err != nil {
139 | return
140 | }
141 |
142 | defer circleIndexes.Add(str, isValid)
143 | for _, circle := range result.Circles {
144 | if circle.Slug == str {
145 | isValid = true
146 | break
147 | }
148 | }
149 | return
150 | }
151 |
--------------------------------------------------------------------------------
/services/magazine.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 | "log"
7 | "strings"
8 |
9 | "koushoku/cache"
10 | "koushoku/errs"
11 | "koushoku/models"
12 | "koushoku/modext"
13 |
14 | "github.com/volatiletech/sqlboiler/v4/boil"
15 | . "github.com/volatiletech/sqlboiler/v4/queries/qm"
16 | )
17 |
18 | func CreateMagazine(name string) (*modext.Magazine, error) {
19 | name = strings.TrimSpace(name)
20 | if len(name) == 0 {
21 | return nil, errs.MagazineNameRequired
22 | } else if len(name) > 128 {
23 | return nil, errs.MagazineNameTooLong
24 | }
25 |
26 | slug := Slugify(name)
27 | magazine, err := models.Magazines(Where("slug = ?", slug)).OneG()
28 | if err == sql.ErrNoRows {
29 | magazine = &models.Magazine{Name: name, Slug: slug}
30 | if err = magazine.InsertG(boil.Infer()); err != nil {
31 | log.Println(err)
32 | return nil, errs.Unknown
33 | }
34 | } else if err != nil {
35 | log.Println(err)
36 | return nil, errs.Unknown
37 | }
38 | return modext.NewMagazine(magazine), nil
39 | }
40 |
41 | func GetMagazine(slug string) (*modext.Magazine, error) {
42 | magazine, err := models.Magazines(Where("slug = ?", slug)).OneG()
43 | if err != nil {
44 | if err == sql.ErrNoRows {
45 | return nil, errs.MagazineNotFound
46 | }
47 | log.Println(err)
48 | return nil, errs.Unknown
49 | }
50 | return modext.NewMagazine(magazine), nil
51 | }
52 |
53 | type GetMagazinesOptions struct {
54 | Limit int `json:"1,omitempty"`
55 | Offset int `json:"2,omitempty"`
56 | }
57 |
58 | type GetMagazinesResult struct {
59 | Magazines []*modext.Magazine
60 | Total int
61 | Err error
62 | }
63 |
64 | func GetMagazines(opts GetMagazinesOptions) (result *GetMagazinesResult) {
65 | opts.Limit = Max(opts.Limit, 0)
66 | opts.Offset = Max(opts.Offset, 0)
67 |
68 | const prefix = "magazines"
69 | cacheKey := makeCacheKey(opts)
70 | if c, err := cache.Taxonomies.GetWithPrefix(prefix, cacheKey); err == nil {
71 | return c.(*GetMagazinesResult)
72 | }
73 |
74 | result = &GetMagazinesResult{Magazines: []*modext.Magazine{}}
75 | defer func() {
76 | if len(result.Magazines) > 0 || result.Total > 0 || result.Err != nil {
77 | cache.Taxonomies.RemoveWithPrefix(prefix, cacheKey)
78 | cache.Taxonomies.SetWithPrefix(prefix, cacheKey, result, 0)
79 | }
80 | }()
81 |
82 | q := []QueryMod{
83 | Select("magazine.*", "COUNT(archive.magazine_id) AS archive_count"),
84 | InnerJoin("archive_magazines archive ON archive.magazine_id = magazine.id"),
85 | GroupBy("magazine.id"), OrderBy("magazine.name ASC"),
86 | }
87 |
88 | if opts.Limit > 0 {
89 | q = append(q, Limit(opts.Limit))
90 | if opts.Offset > 0 {
91 | q = append(q, Offset(opts.Offset))
92 | }
93 | }
94 |
95 | err := models.Magazines(q...).BindG(context.Background(), &result.Magazines)
96 | if err != nil {
97 | log.Println(err)
98 | result.Err = errs.Unknown
99 | return
100 | }
101 |
102 | count, err := models.Magazines().CountG()
103 | if err != nil {
104 | log.Println(err)
105 | result.Err = errs.Unknown
106 | }
107 |
108 | result.Total = int(count)
109 | return
110 | }
111 |
112 | func GetMagazineCount() (int64, error) {
113 | const cachekey = "magazineCount"
114 | if c, err := cache.Taxonomies.Get(cachekey); err == nil {
115 | return c.(int64), nil
116 | }
117 |
118 | count, err := models.Magazines().CountG()
119 | if err != nil {
120 | log.Println(err)
121 | return 0, errs.Unknown
122 | }
123 |
124 | cache.Taxonomies.Set(cachekey, count, 0)
125 | return count, nil
126 | }
127 |
128 | var magazineIndexes = IndexMap{Cache: make(map[string]bool)}
129 |
130 | func IsMagazineValid(str string) (isValid bool) {
131 | str = Slugify(str)
132 | if v, ok := magazineIndexes.Get(str); ok {
133 | return v
134 | }
135 |
136 | result := GetMagazines(GetMagazinesOptions{})
137 | if result.Err != nil {
138 | return
139 | }
140 |
141 | defer magazineIndexes.Add(str, isValid)
142 | for _, magazine := range result.Magazines {
143 | if magazine.Slug == str {
144 | isValid = true
145 | break
146 | }
147 | }
148 | return
149 | }
150 |
--------------------------------------------------------------------------------
/services/metadata.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "encoding/json"
5 | "log"
6 | "os"
7 | "path/filepath"
8 | "sync"
9 |
10 | . "koushoku/config"
11 | )
12 |
13 | type Metadata struct {
14 | Title string
15 | Artists []string
16 | Circles []string
17 | Magazines []string
18 | Parodies []string
19 | Tags []string
20 | }
21 |
22 | var Metadatas struct {
23 | Map map[string]*Metadata
24 | once sync.Once
25 | }
26 |
27 | func InitMetadatas() {
28 | Metadatas.once.Do(func() {
29 | Metadatas.Map = make(map[string]*Metadata)
30 | path := filepath.Join(Config.Paths.Metadata)
31 |
32 | stat, err := os.Stat(path)
33 | if os.IsNotExist(err) || stat.IsDir() {
34 | return
35 | }
36 |
37 | buf, err := os.ReadFile(path)
38 | if err != nil {
39 | log.Println(err)
40 | return
41 | }
42 |
43 | if err := json.Unmarshal(buf, &Metadatas.Map); err != nil {
44 | log.Println(err)
45 | }
46 | })
47 | }
48 |
--------------------------------------------------------------------------------
/services/parody.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 | "log"
7 | "strings"
8 |
9 | "koushoku/cache"
10 | "koushoku/errs"
11 | "koushoku/models"
12 | "koushoku/modext"
13 |
14 | "github.com/volatiletech/sqlboiler/v4/boil"
15 | . "github.com/volatiletech/sqlboiler/v4/queries/qm"
16 | )
17 |
18 | func CreateParody(name string) (*modext.Parody, error) {
19 | name = strings.Title(strings.TrimSpace(name))
20 | if len(name) == 0 {
21 | return nil, errs.ParodyNameRequired
22 | } else if len(name) > 128 {
23 | return nil, errs.ParodyNameTooLong
24 | }
25 |
26 | slug := Slugify(name)
27 | parody, err := models.Parodies(Where("slug = ?", slug)).OneG()
28 | if err == sql.ErrNoRows {
29 | parody = &models.Parody{Name: name, Slug: slug}
30 | if err = parody.InsertG(boil.Infer()); err != nil {
31 | log.Println(err)
32 | return nil, errs.Unknown
33 | }
34 | } else if err != nil {
35 | log.Println(err)
36 | return nil, errs.Unknown
37 | }
38 | return modext.NewParody(parody), nil
39 | }
40 |
41 | func GetParody(slug string) (*modext.Parody, error) {
42 | parody, err := models.Parodies(Where("slug = ?", slug)).OneG()
43 | if err != nil {
44 | if err == sql.ErrNoRows {
45 | return nil, errs.ParodyNotFound
46 | }
47 | log.Println(err)
48 | return nil, errs.Unknown
49 | }
50 | return modext.NewParody(parody), nil
51 | }
52 |
53 | type GetParodiesOptions struct {
54 | Limit int `json:"1,omitempty"`
55 | Offset int `json:"2,omitempty"`
56 | }
57 |
58 | type GetParodiesResult struct {
59 | Parodies []*modext.Parody
60 | Total int
61 | Err error
62 | }
63 |
64 | func GetParodies(opts GetParodiesOptions) (result *GetParodiesResult) {
65 | opts.Limit = Max(opts.Limit, 0)
66 | opts.Offset = Max(opts.Offset, 0)
67 |
68 | const prefix = "parodies"
69 | cacheKey := makeCacheKey(opts)
70 | if c, err := cache.Taxonomies.GetWithPrefix(prefix, cacheKey); err == nil {
71 | return c.(*GetParodiesResult)
72 | }
73 |
74 | result = &GetParodiesResult{Parodies: []*modext.Parody{}}
75 | defer func() {
76 | if len(result.Parodies) > 0 || result.Total > 0 || result.Err != nil {
77 | cache.Taxonomies.RemoveWithPrefix(prefix, cacheKey)
78 | cache.Taxonomies.SetWithPrefix(prefix, cacheKey, result, 0)
79 | }
80 | }()
81 |
82 | q := []QueryMod{
83 | Select("parody.*", "COUNT(archive.parody_id) AS archive_count"),
84 | InnerJoin("archive_parodies archive ON archive.parody_id = parody.id"),
85 | GroupBy("parody.id"), OrderBy("parody.name ASC"),
86 | }
87 |
88 | if opts.Limit > 0 {
89 | q = append(q, Limit(opts.Limit))
90 | if opts.Offset > 0 {
91 | q = append(q, Offset(opts.Offset))
92 | }
93 | }
94 |
95 | err := models.Parodies(q...).BindG(context.Background(), &result.Parodies)
96 | if err != nil {
97 | log.Println(err)
98 | result.Err = errs.Unknown
99 | return
100 | }
101 |
102 | count, err := models.Parodies().CountG()
103 | if err != nil {
104 | log.Println(err)
105 | result.Err = errs.Unknown
106 | return
107 | }
108 |
109 | result.Total = int(count)
110 | return
111 | }
112 |
113 | func GetParodyCount() (int64, error) {
114 | const cacheKey = "parodyCount"
115 | if c, err := cache.Taxonomies.Get(cacheKey); err == nil {
116 | return c.(int64), nil
117 | }
118 |
119 | count, err := models.Parodies().CountG()
120 | if err != nil {
121 | log.Println(err)
122 | return 0, errs.Unknown
123 | }
124 |
125 | cache.Taxonomies.Set(cacheKey, count, 0)
126 | return count, nil
127 | }
128 |
129 | var parodyIndexes = IndexMap{Cache: make(map[string]bool)}
130 |
131 | func IsParodyValid(str string) (isValid bool) {
132 | str = Slugify(str)
133 | if v, ok := parodyIndexes.Get(str); ok {
134 | return v
135 | }
136 |
137 | result := GetParodies(GetParodiesOptions{})
138 | if result.Err != nil {
139 | return
140 | }
141 |
142 | defer parodyIndexes.Add(str, isValid)
143 | for _, parody := range result.Parodies {
144 | if parody.Slug == str {
145 | isValid = true
146 | break
147 | }
148 | }
149 | return
150 | }
151 |
--------------------------------------------------------------------------------
/services/stats.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "log"
7 | "math"
8 | "net/http"
9 | "strings"
10 | "sync"
11 | "time"
12 |
13 | . "koushoku/config"
14 | )
15 |
16 | type Stats struct {
17 | ArchiveCount int64
18 | ArtistCount int64
19 | CircleCount int64
20 | MagazineCount int64
21 | ParodyCount int64
22 | TagCount int64
23 |
24 | PageCount int64
25 | AveragePageCount int64
26 | Size int64
27 | AverageSize int64
28 |
29 | Analytics *Analytics
30 |
31 | sync.RWMutex
32 | sync.Once
33 | }
34 |
35 | var stats Stats
36 |
37 | func AnalyzeStats() (err error) {
38 | log.Println("Analyzing stats...")
39 | defer func() {
40 | if err != nil {
41 | log.Println("AnalyzeStats returned an error:", err)
42 | }
43 | }()
44 |
45 | stats.ArchiveCount, err = GetArchiveCount()
46 | if err != nil {
47 | return
48 | }
49 |
50 | stats.Size, stats.PageCount, err = GetArchiveStats()
51 | if err != nil {
52 | return
53 | }
54 |
55 | stats.ArtistCount, err = GetArtistCount()
56 | if err != nil {
57 | return
58 | }
59 |
60 | stats.CircleCount, err = GetCircleCount()
61 | if err != nil {
62 | return
63 | }
64 | stats.MagazineCount, err = GetMagazineCount()
65 | if err != nil {
66 | return
67 | }
68 |
69 | stats.ParodyCount, err = GetParodyCount()
70 | if err != nil {
71 | return
72 | }
73 |
74 | stats.TagCount, err = GetTagCount()
75 | if err != nil {
76 | return
77 | }
78 |
79 | if stats.ArchiveCount > 0 {
80 | if stats.PageCount > 0 {
81 | stats.AveragePageCount = int64(math.Round(float64(stats.PageCount) / float64(stats.ArchiveCount)))
82 | }
83 | if stats.Size > 0 {
84 | stats.AverageSize = int64(math.Round(float64(stats.Size) / float64(stats.ArchiveCount)))
85 | }
86 | }
87 |
88 | if Config.Mode == "production" {
89 | err = fetchAnalytics()
90 | if err != nil {
91 | return
92 | }
93 |
94 | stats.Do(func() {
95 | go func() {
96 | for {
97 | time.Sleep(30 * time.Minute)
98 | log.Println("Refreshing analytics...")
99 |
100 | stats.Lock()
101 | if err = fetchAnalytics(); err != nil {
102 | log.Println("Failed to refresh analytics", err)
103 | }
104 | stats.Unlock()
105 | }
106 | }()
107 | })
108 | }
109 |
110 | return
111 | }
112 |
113 | func GetStats() *Stats {
114 | stats.RLock()
115 | defer stats.RUnlock()
116 | return &stats
117 | }
118 |
119 | type Analytic struct {
120 | Date string
121 | Bytes int64
122 | CachedBytes int64
123 | Requests int64
124 | CachedRequests int64
125 | }
126 |
127 | type Analytics struct {
128 | Analytic
129 | Entries []*Analytic
130 | LastUpdated time.Time
131 | }
132 |
133 | type GraphQL struct {
134 | OperationName string `json:"operationName,omitempty"`
135 | Query string `json:"query"`
136 | Variables map[string]any `json:"variables"`
137 | }
138 |
139 | func (g *GraphQL) Marshal() *strings.Reader {
140 | buf, _ := json.Marshal(g)
141 | return strings.NewReader(string(buf))
142 | }
143 |
144 | func fetchAnalytics() error {
145 | payload := &GraphQL{
146 | OperationName: "GetZoneAnalytics",
147 | Query: `query GetZoneAnalytics($zoneTag: string, $since: string, $until: string) {
148 | viewer {
149 | zones(filter: {zoneTag: $zoneTag}) {
150 | totals: httpRequests1dGroups(limit: 10000, filter: {date_geq: $since, date_lt: $until}) {
151 | sum {
152 | bytes
153 | cachedBytes
154 | requests
155 | cachedRequests
156 | }
157 | }
158 | zones: httpRequests1dGroups(orderBy: [date_ASC], limit: 10000, filter: {date_geq: $since, date_lt: $until}) {
159 | dimensions {
160 | timeslot: date
161 | }
162 | sum {
163 | bytes
164 | cachedBytes
165 | requests
166 | cachedRequests
167 | }
168 | }
169 | }
170 | }
171 | }`,
172 | Variables: map[string]any{
173 | "zoneTag": Config.Cloudflare.ZoneTag,
174 | "since": fmt.Sprintf("%d-01-01", time.Now().Year()),
175 | "until": time.Now().AddDate(0, 1, 0).Format("2006-01-02"),
176 | },
177 | }
178 |
179 | u := "https://api.cloudflare.com/client/v4/graphql"
180 | req, err := http.NewRequest("POST", u, payload.Marshal())
181 | if err != nil {
182 | return err
183 | }
184 |
185 | req.Header.Set("Content-Type", "application/json")
186 | req.Header.Set("X-Auth-Email", Config.Cloudflare.Email)
187 | req.Header.Set("X-Auth-Key", Config.Cloudflare.ApiKey)
188 |
189 | res, err := http.DefaultClient.Do(req)
190 | if err != nil {
191 | return err
192 | }
193 | defer res.Body.Close()
194 |
195 | body := &struct {
196 | Data struct {
197 | Viewer struct {
198 | Zones []struct {
199 | Totals []struct {
200 | Sum struct {
201 | Bytes int64 `json:"bytes"`
202 | CachedBytes int64 `json:"cachedBytes"`
203 | Requests int64 `json:"requests"`
204 | CachedRequests int64 `json:"cachedRequests"`
205 | } `json:"sum"`
206 | } `json:"totals"`
207 | Zones []struct {
208 | Dimensions struct {
209 | Timeslot string `json:"timeslot"`
210 | } `json:"dimensions"`
211 | Sum struct {
212 | Bytes int64 `json:"bytes"`
213 | CachedBytes int64 `json:"cachedBytes"`
214 | Requests int64 `json:"requests"`
215 | CachedRequests int64 `json:"cachedRequests"`
216 | } `json:"sum"`
217 | } `json:"zones"`
218 | } `json:"zones"`
219 | } `json:"viewer"`
220 | } `json:"data"`
221 | }{}
222 |
223 | if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
224 | return err
225 | }
226 |
227 | if stats.Analytics == nil {
228 | stats.Analytics = &Analytics{}
229 | }
230 |
231 | analytics := stats.Analytics
232 | prevBytes := analytics.Bytes
233 | prevCachedBytes := analytics.CachedBytes
234 | prevRequests := analytics.Requests
235 | prevCachedRequests := analytics.CachedRequests
236 |
237 | if len(body.Data.Viewer.Zones) > 0 {
238 | if len(body.Data.Viewer.Zones[0].Totals) > 0 {
239 | analytics.Bytes = body.Data.Viewer.Zones[0].Totals[0].Sum.Bytes
240 | analytics.CachedBytes = body.Data.Viewer.Zones[0].Totals[0].Sum.CachedBytes
241 | analytics.Requests = body.Data.Viewer.Zones[0].Totals[0].Sum.Requests
242 | analytics.CachedRequests = body.Data.Viewer.Zones[0].Totals[0].Sum.CachedRequests
243 | }
244 |
245 | analytics.Entries = []*Analytic{}
246 | for _, zone := range body.Data.Viewer.Zones {
247 | for _, entry := range zone.Zones {
248 | analytics.Entries = append(analytics.Entries, &Analytic{
249 | Date: entry.Dimensions.Timeslot,
250 | Bytes: entry.Sum.Bytes,
251 | CachedBytes: entry.Sum.CachedBytes,
252 | Requests: entry.Sum.Requests,
253 | CachedRequests: entry.Sum.CachedRequests,
254 | })
255 | }
256 | }
257 | }
258 |
259 | if prevBytes != analytics.Bytes || prevCachedBytes != analytics.CachedBytes ||
260 | prevRequests != analytics.Requests || prevCachedRequests != analytics.CachedRequests {
261 | analytics.LastUpdated = time.Now()
262 | }
263 | return nil
264 | }
265 |
--------------------------------------------------------------------------------
/services/submission.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "log"
5 | "strings"
6 | "time"
7 |
8 | "koushoku/cache"
9 | "koushoku/errs"
10 | "koushoku/models"
11 | "koushoku/modext"
12 |
13 | "github.com/volatiletech/null/v8"
14 | "github.com/volatiletech/sqlboiler/v4/boil"
15 | . "github.com/volatiletech/sqlboiler/v4/queries/qm"
16 | )
17 |
18 | var (
19 | SubmissionCols = models.SubmissionColumns
20 | SubmissionRels = models.SubmissionRels
21 | )
22 |
23 | func CreateSubmission(name, submitter, content string) (*modext.Submission, error) {
24 | name = strings.TrimSpace(name)
25 | if len(name) == 0 {
26 | return nil, errs.SubmissionNameRequired
27 | } else if len(name) > 1024 {
28 | return nil, errs.ArtistNameTooLong
29 | }
30 |
31 | submitter = strings.TrimSpace(submitter)
32 | if len(submitter) > 128 {
33 | return nil, errs.SubmissionSubmitterTooLong
34 | }
35 |
36 | content = strings.TrimSpace(content)
37 | if len(content) == 0 {
38 | return nil, errs.SubmissionContentRequired
39 | } else if len(content) > 10240 {
40 | return nil, errs.SubmissionContentTooLong
41 | }
42 |
43 | submission := &models.Submission{
44 | CreatedAt: time.Now().UTC(),
45 | Name: name,
46 | Submitter: null.StringFrom(submitter),
47 | Content: content,
48 | }
49 | if err := submission.InsertG(boil.Whitelist("created_at", "name", "submitter", "content")); err != nil {
50 | log.Println(err)
51 | return nil, errs.Unknown
52 | }
53 | return modext.NewSubmission(submission), nil
54 | }
55 |
56 | type GetSubmissionsOptions struct {
57 | Limit int `json:"1,omitempty"`
58 | Offset int `json:"2,omitempty"`
59 | }
60 |
61 | type GetSubmissionsResult struct {
62 | Submissions []*modext.Submission
63 | Total int
64 | Err error
65 | }
66 |
67 | func GetSubmissions(opts GetSubmissionsOptions) (result *GetSubmissionsResult) {
68 | opts.Limit = Max(opts.Limit, 0)
69 | opts.Offset = Max(opts.Offset, 0)
70 |
71 | const prefix = "submissions"
72 | cacheKey := makeCacheKey(opts)
73 | if c, err := cache.Submissions.GetWithPrefix(prefix, cacheKey); err == nil {
74 | return c.(*GetSubmissionsResult)
75 | }
76 |
77 | result = &GetSubmissionsResult{Submissions: []*modext.Submission{}}
78 | defer func() {
79 | if len(result.Submissions) > 0 || result.Total > 0 || result.Err != nil {
80 | cache.Submissions.RemoveWithPrefix(prefix, cacheKey)
81 | cache.Submissions.SetWithPrefix(prefix, cacheKey, result, 0)
82 | }
83 | }()
84 |
85 | selectMods := []QueryMod{Where("accepted = TRUE OR rejected = TRUE")}
86 | countMods := append([]QueryMod{}, selectMods...)
87 |
88 | if opts.Limit > 0 {
89 | selectMods = append(selectMods, Limit(opts.Limit))
90 | }
91 |
92 | if opts.Offset > 0 {
93 | selectMods = append(selectMods, Offset(opts.Offset))
94 | }
95 |
96 | selectMods = append(selectMods,
97 | OrderBy(`COALESCE(accepted_at, rejected_at) DESC, id DESC`),
98 | Load(SubmissionRels.Archives, OrderBy("id ASC")))
99 | submissions, err := models.Submissions(selectMods...).AllG()
100 | if err != nil {
101 | log.Println(err)
102 | result.Err = errs.Unknown
103 | return
104 | }
105 |
106 | count, err := models.Submissions(countMods...).CountG()
107 | if err != nil {
108 | log.Println(err)
109 | result.Err = errs.Unknown
110 | return
111 | }
112 |
113 | result.Total = int(count)
114 |
115 | result.Submissions = make([]*modext.Submission, len(submissions))
116 | for i, submission := range submissions {
117 | result.Submissions[i] = modext.NewSubmission(submission).LoadRels(submission)
118 | }
119 | return
120 | }
121 |
122 | func AcceptSubmission(id int64, notes string) error {
123 | submission, err := models.FindSubmissionG(id)
124 | if err != nil {
125 | return err
126 | }
127 |
128 | submission.Accepted = true
129 | submission.AcceptedAt = null.TimeFrom(time.Now().UTC())
130 | submission.Notes = null.StringFrom(notes)
131 |
132 | submission.Rejected = false
133 | submission.RejectedAt.Valid = false
134 |
135 | return submission.UpdateG(boil.Whitelist("accepted", "accepted_at", "rejected", "rejected_at", "notes"))
136 | }
137 |
138 | func ListSubmissions() ([]*modext.Submission, error) {
139 | submissions, err := models.Submissions(OrderBy("id ASC")).AllG()
140 | if err != nil {
141 | return nil, err
142 | }
143 |
144 | result := make([]*modext.Submission, len(submissions))
145 | for i, submission := range submissions {
146 | result[i] = modext.NewSubmission(submission)
147 | }
148 | return result, nil
149 | }
150 |
151 | func RejectSubmission(id int64, note string) error {
152 | submission, err := models.FindSubmissionG(id)
153 | if err != nil {
154 | return err
155 | }
156 |
157 | submission.Accepted = false
158 | submission.AcceptedAt.Valid = false
159 |
160 | submission.Rejected = true
161 | submission.RejectedAt = null.TimeFrom(time.Now().UTC())
162 | submission.Notes = null.StringFrom(note)
163 |
164 | return submission.UpdateG(boil.Whitelist("accepted", "accepted_at", "rejected", "rejected_at", "notes"))
165 | }
166 |
167 | func LinkSubmission(archiveId int64, submissionId int64) error {
168 | archive, err := models.FindArchiveG(archiveId)
169 | if err != nil {
170 | return err
171 | }
172 |
173 | submission, err := models.FindSubmissionG(submissionId)
174 | if err != nil {
175 | return err
176 | }
177 |
178 | archive.SubmissionID = null.Int64From(submission.ID)
179 | return archive.UpdateG(boil.Infer())
180 | }
181 |
--------------------------------------------------------------------------------
/services/tag.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "context"
5 | "database/sql"
6 | "log"
7 | "strings"
8 |
9 | "koushoku/cache"
10 | "koushoku/errs"
11 | "koushoku/models"
12 | "koushoku/modext"
13 |
14 | "github.com/volatiletech/sqlboiler/v4/boil"
15 | . "github.com/volatiletech/sqlboiler/v4/queries/qm"
16 | )
17 |
18 | func CreateTag(name string) (*modext.Tag, error) {
19 | name = strings.Title(strings.TrimSpace(name))
20 | if len(name) == 0 {
21 | return nil, errs.TagNameRequired
22 | } else if len(name) > 128 {
23 | return nil, errs.TagNameTooLong
24 | }
25 |
26 | slug := Slugify(name)
27 | tag, err := models.Tags(Where("slug = ?", slug)).OneG()
28 | if err == sql.ErrNoRows {
29 | tag = &models.Tag{Name: name, Slug: slug}
30 | if err = tag.InsertG(boil.Infer()); err != nil {
31 | log.Println(err)
32 | return nil, errs.Unknown
33 | }
34 | } else if err != nil {
35 | log.Println(err)
36 | return nil, errs.Unknown
37 | }
38 | return modext.NewTag(tag), nil
39 | }
40 |
41 | func GetTag(slug string) (*modext.Tag, error) {
42 | tag, err := models.Tags(Where("slug = ?", slug)).OneG()
43 | if err != nil {
44 | if err == sql.ErrNoRows {
45 | return nil, errs.TagNotFound
46 | }
47 | log.Println(err)
48 | return nil, errs.Unknown
49 | }
50 | return modext.NewTag(tag), nil
51 | }
52 |
53 | type GetTagsOptions struct {
54 | Limit int `json:"1,omitempty"`
55 | Offset int `json:"2,omitempty"`
56 | }
57 |
58 | type GetTagsResult struct {
59 | Tags []*modext.Tag
60 | Total int
61 | Err error
62 | }
63 |
64 | func GetTags(opts GetTagsOptions) (result *GetTagsResult) {
65 | opts.Limit = Max(opts.Limit, 0)
66 | opts.Offset = Max(opts.Offset, 0)
67 |
68 | const prefix = "tags"
69 | cacheKey := makeCacheKey(opts)
70 | if c, err := cache.Taxonomies.GetWithPrefix(prefix, cacheKey); err == nil {
71 | return c.(*GetTagsResult)
72 | }
73 |
74 | result = &GetTagsResult{Tags: []*modext.Tag{}}
75 | defer func() {
76 | if len(result.Tags) > 0 || result.Total > 0 || result.Err != nil {
77 | cache.Taxonomies.RemoveWithPrefix(prefix, cacheKey)
78 | cache.Taxonomies.SetWithPrefix(prefix, cacheKey, result, 0)
79 | }
80 | }()
81 |
82 | q := []QueryMod{
83 | Select("tag.*", "COUNT(archive.tag_id) AS archive_count"),
84 | InnerJoin("archive_tags archive ON archive.tag_id = tag.id"),
85 | GroupBy("tag.id"), OrderBy("tag.name ASC"),
86 | }
87 |
88 | if opts.Limit > 0 {
89 | q = append(q, Limit(opts.Limit))
90 | if opts.Offset > 0 {
91 | q = append(q, Offset(opts.Offset))
92 | }
93 | }
94 |
95 | err := models.Tags(q...).BindG(context.Background(), &result.Tags)
96 | if err != nil {
97 | log.Println(err)
98 | result.Err = errs.Unknown
99 | return
100 | }
101 |
102 | count, err := models.Tags().CountG()
103 | if err != nil {
104 | log.Println(err)
105 | result.Err = errs.Unknown
106 | return
107 | }
108 |
109 | result.Total = int(count)
110 | return
111 | }
112 |
113 | func GetTagCount() (int64, error) {
114 | const cacheKey = "tagCount"
115 | if c, err := cache.Taxonomies.Get(cacheKey); err == nil {
116 | return c.(int64), nil
117 | }
118 |
119 | count, err := models.Tags().CountG()
120 | if err != nil {
121 | log.Println(err)
122 | return 0, errs.Unknown
123 | }
124 |
125 | cache.Taxonomies.Set(cacheKey, count, 0)
126 | return count, nil
127 | }
128 |
129 | var tagIndexes = IndexMap{Cache: make(map[string]bool)}
130 |
131 | func IsTagValid(str string) (isValid bool) {
132 | str = Slugify(str)
133 | if v, ok := tagIndexes.Get(str); ok {
134 | return v
135 | }
136 |
137 | result := GetTags(GetTagsOptions{})
138 | if result.Err != nil {
139 | return
140 | }
141 |
142 | defer tagIndexes.Add(str, isValid)
143 | for _, tag := range result.Tags {
144 | if tag.Slug == str {
145 | isValid = true
146 | break
147 | }
148 | }
149 | return
150 | }
151 |
--------------------------------------------------------------------------------
/services/user.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "database/sql"
5 | "log"
6 | "regexp"
7 | "strings"
8 |
9 | "koushoku/cache"
10 | "koushoku/errs"
11 | "koushoku/models"
12 | "koushoku/modext"
13 |
14 | "github.com/volatiletech/sqlboiler/v4/boil"
15 | "golang.org/x/crypto/bcrypt"
16 | )
17 |
18 | var emailRgx = regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`)
19 |
20 | func isEmail(e string) bool {
21 | return emailRgx.MatchString(e)
22 | }
23 |
24 | func hashPassword(rawPassword string) (string, error) {
25 | buf, err := bcrypt.GenerateFromPassword([]byte(rawPassword), bcrypt.DefaultCost)
26 | return string(buf), err
27 | }
28 |
29 | type CreateUserOptions struct {
30 | Name string
31 | Email string
32 | Password string
33 | }
34 |
35 | func CreateUser(opts CreateUserOptions) (*modext.User, error) {
36 | opts.Name = strings.TrimSpace(opts.Name)
37 | opts.Email = strings.TrimSpace(opts.Email)
38 |
39 | switch {
40 | case len(opts.Name) < 3:
41 | return nil, errs.UserNameTooShort
42 | case len(opts.Name) > 32:
43 | return nil, errs.UserNameTooLong
44 | case len(opts.Email) == 0:
45 | return nil, errs.EmailRequired
46 | case len(opts.Email) > 255:
47 | return nil, errs.EmailTooLong
48 | case !isEmail(opts.Email):
49 | return nil, errs.EmailInvalid
50 | case len(opts.Password) < 6:
51 | return nil, errs.PasswordTooShort
52 | }
53 |
54 | hashedPassword, err := hashPassword(opts.Password)
55 | if err != nil {
56 | log.Println(err)
57 | return nil, errs.Unknown
58 | }
59 |
60 | user := &models.User{
61 | Name: opts.Name,
62 | Email: opts.Email,
63 | Password: hashedPassword,
64 | }
65 |
66 | if err := user.InsertG(boil.Infer()); err != nil {
67 | log.Println(err)
68 | return nil, errs.Unknown
69 | }
70 | return modext.NewUser(user), nil
71 | }
72 |
73 | type GetUserResult struct {
74 | User *modext.User
75 | Err error
76 | }
77 |
78 | func GetUser(id int64) (result *GetUserResult) {
79 | if c, err := cache.Users.GetWithInt64(id); err == nil {
80 | return c.(*GetUserResult)
81 | }
82 |
83 | result = &GetUserResult{}
84 | defer func() {
85 | if result.User != nil || result.Err != nil {
86 | cache.Users.RemoveWithInt64(id)
87 | cache.Users.SetWithInt64(id, result, 0)
88 | }
89 | }()
90 |
91 | user, err := models.FindUserG(id)
92 | if err != nil {
93 | if err == sql.ErrNoRows {
94 | result.Err = errs.UserNotFound
95 | } else {
96 | log.Println(err)
97 | result.Err = errs.Unknown
98 | }
99 | return
100 | }
101 |
102 | result.User = modext.NewUser(user)
103 | return
104 | }
105 |
106 | func checkPassword(hash, password string) error {
107 | return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
108 | }
109 |
110 | func UpdatePassword(id int64, password, newPassword string) error {
111 | user, err := models.FindUserG(id)
112 | if err != nil {
113 | if err == sql.ErrNoRows {
114 | return errs.UserNotFound
115 | }
116 | log.Println(err)
117 | return errs.Unknown
118 | }
119 |
120 | if err := checkPassword(user.Password, password); err != nil {
121 | log.Println(err)
122 | return errs.InvalidCredentials
123 | }
124 |
125 | hashedPassword, err := hashPassword(newPassword)
126 | if err != nil {
127 | log.Println(err)
128 | return errs.Unknown
129 | }
130 |
131 | user.Password = hashedPassword
132 | if err := user.UpdateG(boil.Infer()); err != nil {
133 | log.Println(err)
134 | return errs.Unknown
135 | }
136 | return nil
137 | }
138 |
139 | func DeleteUser(id int64, password string) error {
140 | user, err := models.FindUserG(id)
141 | if err != nil {
142 | if err == sql.ErrNoRows {
143 | return errs.UserNotFound
144 | }
145 | log.Println(err)
146 | return errs.Unknown
147 | }
148 |
149 | if err := checkPassword(user.Password, password); err != nil {
150 | log.Println(err)
151 | return errs.InvalidCredentials
152 | }
153 |
154 | if err := user.DeleteG(); err != nil {
155 | log.Println(err)
156 | return errs.Unknown
157 | }
158 | return nil
159 | }
160 |
--------------------------------------------------------------------------------
/sqlboiler.toml:
--------------------------------------------------------------------------------
1 | output = "models"
2 | pkgname = "models"
3 | wipe = true
4 | add-global-variants = true
5 | no-context = true
6 | no-tests = true
7 | no-rows-affected = true
8 |
9 | [psql]
10 | dbname = "koushoku"
11 | schema = "public"
12 | host = "localhost"
13 | port = 5432
14 | user = "koushoku"
15 | pass = "koushoku"
16 | sslmode = "disable"
--------------------------------------------------------------------------------
/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "overrides": [
3 | {
4 | "files": ["*.ts", "*.tsx"],
5 | "parserOptions": {
6 | "project": ["./tsconfig.json"]
7 | }
8 | }
9 | ],
10 | "parser": "@typescript-eslint/parser"
11 | }
12 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "ES6",
4 | "lib": ["ES6", "DOM"],
5 |
6 | "allowJs": true,
7 | "esModuleInterop": true,
8 | "moduleResolution": "node",
9 | "resolveJsonModule": true
10 | },
11 | "include": ["./web"],
12 | "exclude": ["node_modules"]
13 | }
14 |
--------------------------------------------------------------------------------
/web/fonts/noto-sans-display-v13-latin-500.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/web/fonts/noto-sans-display-v13-latin-500.eot
--------------------------------------------------------------------------------
/web/fonts/noto-sans-display-v13-latin-500.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/web/fonts/noto-sans-display-v13-latin-500.ttf
--------------------------------------------------------------------------------
/web/fonts/noto-sans-display-v13-latin-500.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/web/fonts/noto-sans-display-v13-latin-500.woff
--------------------------------------------------------------------------------
/web/fonts/noto-sans-display-v13-latin-500.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/web/fonts/noto-sans-display-v13-latin-500.woff2
--------------------------------------------------------------------------------
/web/fonts/noto-sans-display-v13-latin-600.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/web/fonts/noto-sans-display-v13-latin-600.eot
--------------------------------------------------------------------------------
/web/fonts/noto-sans-display-v13-latin-600.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/web/fonts/noto-sans-display-v13-latin-600.ttf
--------------------------------------------------------------------------------
/web/fonts/noto-sans-display-v13-latin-600.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/web/fonts/noto-sans-display-v13-latin-600.woff
--------------------------------------------------------------------------------
/web/fonts/noto-sans-display-v13-latin-600.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nayumiDEV/koushoku/f17c850a42f560b95ce8f30f1842dc696980400e/web/fonts/noto-sans-display-v13-latin-600.woff2
--------------------------------------------------------------------------------
/web/head.html:
--------------------------------------------------------------------------------
1 | {{- define "head" -}}
2 |
3 |
4 |
5 |
6 | {{- $title := .title -}}
7 | {{- if .archive -}}
8 | {{- $title = printf "%s - %s" .archive.Title .title -}}
9 | {{- else -}}
10 | {{- $title = printf "%s - %s" .name .title -}}
11 | {{- end -}}
12 | {{ $title }}
13 |
14 | {{- if .archive -}}
15 | {{- $title = .archive.Title -}}
16 | {{- if and .archive.Artists (eq (len .archive.Artists) 1) -}}
17 | {{- $artist := (index .archive.Artists 0) -}}
18 | {{- $title = printf "%s by %s" .archive.Title $artist.Name -}}
19 | {{- end -}}
20 | {{- end -}}
21 |
22 | {{- $description := "" -}}
23 | {{- if .archive -}}
24 | {{- $artists := "" -}}
25 | {{- range $i, $v := .archive.Artists -}}
26 | {{- if $i -}}
27 | {{- $artists = printf "%s," $artists -}}
28 | {{- end -}}
29 | {{- $artists = printf "%s %s" $artists .Name -}}
30 | {{- end -}}
31 |
32 | {{- $tags := "" -}}
33 | {{- range $i, $v := .archive.Tags -}}
34 | {{- if $i -}}
35 | {{- $tags = printf "%s," $tags -}}
36 | {{- end -}}
37 | {{- $tags = printf "%s %s" $tags .Name -}}
38 | {{- end -}}
39 |
40 | {{- if $tags -}}
41 | {{- $description = printf "Read or download %s by %s. %s." .archive.Title $artists $tags -}}
42 | {{- else -}}
43 | {{- $description = printf "Read or download %s by %s." .archive.Title $artists -}}
44 | {{- end -}}
45 | {{- end -}}
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | {{- if $description -}}
56 |
57 |
58 |
59 |
60 | {{- end -}}
61 |
62 |
63 |
64 |
65 |
66 | {{- if .archive -}}
67 |
68 | {{- else -}}
69 |
70 | {{- end -}}
71 |
72 | {{- $img := printf "%s/cover.jpg" .baseURL -}}
73 | {{- if .archive -}}
74 |
75 | {{- $img = printf "%s/data/%d/1/896.webp" .dataBaseURL .archive.ID -}}
76 | {{- else }}
77 |
78 | {{- end -}}
79 |
80 | {{- if $img -}}
81 |
82 |
83 |
84 | {{- if .archive -}}
85 |
86 |
87 | {{- else -}}
88 |
89 |
90 | {{- end -}}
91 | {{- end -}}
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 | {{- end }}
102 |
--------------------------------------------------------------------------------
/web/serviceWorker.ts:
--------------------------------------------------------------------------------
1 | import { CacheableResponsePlugin } from "workbox-cacheable-response";
2 | import { ExpirationPlugin } from "workbox-expiration";
3 | import { registerRoute } from "workbox-routing";
4 | import { CacheFirst, StaleWhileRevalidate } from "workbox-strategies";
5 |
6 | registerRoute(
7 | ({ request, url }) =>
8 | request.destination === "style" ||
9 | request.destination === "script" ||
10 | request.destination === "worker" ||
11 | url.pathname.startsWith("/fonts/"),
12 | new StaleWhileRevalidate({
13 | cacheName: "assets",
14 | plugins: [new CacheableResponsePlugin({ statuses: [200] })]
15 | })
16 | );
17 |
18 | registerRoute(
19 | ({ url }) => url.pathname.startsWith("/data/"),
20 | new CacheFirst({
21 | cacheName: "data",
22 | plugins: [
23 | new CacheableResponsePlugin({ statuses: [200] }),
24 | new ExpirationPlugin({
25 | maxEntries: 1024,
26 | maxAgeSeconds: 86400,
27 | purgeOnQuotaError: true
28 | })
29 | ]
30 | })
31 | );
32 |
--------------------------------------------------------------------------------
/web/settings.ts:
--------------------------------------------------------------------------------
1 | export enum Mode {
2 | Normal,
3 | Strip
4 | }
5 |
6 | export interface Settings {
7 | blacklist: string[];
8 | }
9 |
10 | export const settings: Settings = {
11 | blacklist: ["yaoi", "crossdressing"]
12 | };
13 |
14 | export const saveSettings = () => {
15 | localStorage.setItem("indexSettings", JSON.stringify(settings));
16 | };
17 |
18 | export const getSettings = () => {
19 | const localSettings = localStorage.getItem("indexSettings");
20 | if (localSettings) {
21 | const obj = JSON.parse(localSettings);
22 | Object.assign(settings, obj);
23 | } else saveSettings();
24 | };
25 |
26 | export const setSettings = (key: string, value: any) => {
27 | if (!(key in settings)) {
28 | return;
29 | }
30 |
31 | settings[key] = value;
32 | saveSettings();
33 | };
34 |
35 | export interface ReaderSettings {
36 | mode?: Mode;
37 | maxWidth?: number;
38 | zoomLevel?: number;
39 | }
40 |
41 | export const readerSettings: ReaderSettings = {
42 | mode: 0,
43 | maxWidth: 1366,
44 | zoomLevel: 1.0
45 | };
46 |
47 | export const saveReaderSettings = () => {
48 | localStorage.setItem("settings", JSON.stringify(readerSettings));
49 | };
50 |
51 | export const getReaderSettings = () => {
52 | const localSettings = localStorage.getItem("settings");
53 | if (localSettings) {
54 | const obj = JSON.parse(localSettings);
55 | Object.assign(readerSettings, obj);
56 | } else saveReaderSettings();
57 | };
58 |
59 | export const setReaderSettings = (key: string, value: any) => {
60 | if (!(key in readerSettings) || (key === "zoomLevel" && (value < 0.1 || value > 5.0))) {
61 | return;
62 | }
63 |
64 | readerSettings[key] = value;
65 | saveReaderSettings();
66 | };
67 |
--------------------------------------------------------------------------------
/web/styles/normalize.css:
--------------------------------------------------------------------------------
1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
2 |
3 | /* Document
4 | ========================================================================== */
5 |
6 | /**
7 | * 1. Correct the line height in all browsers.
8 | * 2. Prevent adjustments of font size after orientation changes in iOS.
9 | */
10 |
11 | html {
12 | line-height: 1.15; /* 1 */
13 | -webkit-text-size-adjust: 100%; /* 2 */
14 | }
15 |
16 | /* Sections
17 | ========================================================================== */
18 |
19 | /**
20 | * Remove the margin in all browsers.
21 | */
22 |
23 | body {
24 | margin: 0;
25 | }
26 |
27 | /**
28 | * Render the `main` element consistently in IE.
29 | */
30 |
31 | main {
32 | display: block;
33 | }
34 |
35 | /**
36 | * Correct the font size and margin on `h1` elements within `section` and
37 | * `article` contexts in Chrome, Firefox, and Safari.
38 | */
39 |
40 | h1 {
41 | font-size: 2em;
42 | margin: 0.67em 0;
43 | }
44 |
45 | /* Grouping content
46 | ========================================================================== */
47 |
48 | /**
49 | * 1. Add the correct box sizing in Firefox.
50 | * 2. Show the overflow in Edge and IE.
51 | */
52 |
53 | hr {
54 | box-sizing: content-box; /* 1 */
55 | height: 0; /* 1 */
56 | overflow: visible; /* 2 */
57 | }
58 |
59 | /**
60 | * 1. Correct the inheritance and scaling of font size in all browsers.
61 | * 2. Correct the odd `em` font sizing in all browsers.
62 | */
63 |
64 | pre {
65 | font-family: monospace, monospace; /* 1 */
66 | font-size: 1em; /* 2 */
67 | }
68 |
69 | /* Text-level semantics
70 | ========================================================================== */
71 |
72 | /**
73 | * Remove the gray background on active links in IE 10.
74 | */
75 |
76 | a {
77 | background-color: transparent;
78 | }
79 |
80 | /**
81 | * 1. Remove the bottom border in Chrome 57-
82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
83 | */
84 |
85 | abbr[title] {
86 | border-bottom: none; /* 1 */
87 | text-decoration: underline; /* 2 */
88 | text-decoration: underline dotted; /* 2 */
89 | }
90 |
91 | /**
92 | * Add the correct font weight in Chrome, Edge, and Safari.
93 | */
94 |
95 | b,
96 | strong {
97 | font-weight: bolder;
98 | }
99 |
100 | /**
101 | * 1. Correct the inheritance and scaling of font size in all browsers.
102 | * 2. Correct the odd `em` font sizing in all browsers.
103 | */
104 |
105 | code,
106 | kbd,
107 | samp {
108 | font-family: monospace, monospace; /* 1 */
109 | font-size: 1em; /* 2 */
110 | }
111 |
112 | /**
113 | * Add the correct font size in all browsers.
114 | */
115 |
116 | small {
117 | font-size: 80%;
118 | }
119 |
120 | /**
121 | * Prevent `sub` and `sup` elements from affecting the line height in
122 | * all browsers.
123 | */
124 |
125 | sub,
126 | sup {
127 | font-size: 75%;
128 | line-height: 0;
129 | position: relative;
130 | vertical-align: baseline;
131 | }
132 |
133 | sub {
134 | bottom: -0.25em;
135 | }
136 |
137 | sup {
138 | top: -0.5em;
139 | }
140 |
141 | /* Embedded content
142 | ========================================================================== */
143 |
144 | /**
145 | * Remove the border on images inside links in IE 10.
146 | */
147 |
148 | img {
149 | border-style: none;
150 | }
151 |
152 | /* Forms
153 | ========================================================================== */
154 |
155 | /**
156 | * 1. Change the font styles in all browsers.
157 | * 2. Remove the margin in Firefox and Safari.
158 | */
159 |
160 | button,
161 | input,
162 | optgroup,
163 | select,
164 | textarea {
165 | font-family: inherit; /* 1 */
166 | font-size: 100%; /* 1 */
167 | line-height: 1.15; /* 1 */
168 | margin: 0; /* 2 */
169 | }
170 |
171 | /**
172 | * Show the overflow in IE.
173 | * 1. Show the overflow in Edge.
174 | */
175 |
176 | button,
177 | input {
178 | /* 1 */
179 | overflow: visible;
180 | }
181 |
182 | /**
183 | * Remove the inheritance of text transform in Edge, Firefox, and IE.
184 | * 1. Remove the inheritance of text transform in Firefox.
185 | */
186 |
187 | button,
188 | select {
189 | /* 1 */
190 | text-transform: none;
191 | }
192 |
193 | /**
194 | * Correct the inability to style clickable types in iOS and Safari.
195 | */
196 |
197 | button,
198 | [type="button"],
199 | [type="reset"],
200 | [type="submit"] {
201 | -webkit-appearance: button;
202 | }
203 |
204 | /**
205 | * Remove the inner border and padding in Firefox.
206 | */
207 |
208 | button::-moz-focus-inner,
209 | [type="button"]::-moz-focus-inner,
210 | [type="reset"]::-moz-focus-inner,
211 | [type="submit"]::-moz-focus-inner {
212 | border-style: none;
213 | padding: 0;
214 | }
215 |
216 | /**
217 | * Restore the focus styles unset by the previous rule.
218 | */
219 |
220 | button:-moz-focusring,
221 | [type="button"]:-moz-focusring,
222 | [type="reset"]:-moz-focusring,
223 | [type="submit"]:-moz-focusring {
224 | outline: 1px dotted ButtonText;
225 | }
226 |
227 | /**
228 | * Correct the padding in Firefox.
229 | */
230 |
231 | fieldset {
232 | padding: 0.35em 0.75em 0.625em;
233 | }
234 |
235 | /**
236 | * 1. Correct the text wrapping in Edge and IE.
237 | * 2. Correct the color inheritance from `fieldset` elements in IE.
238 | * 3. Remove the padding so developers are not caught out when they zero out
239 | * `fieldset` elements in all browsers.
240 | */
241 |
242 | legend {
243 | box-sizing: border-box; /* 1 */
244 | color: inherit; /* 2 */
245 | display: table; /* 1 */
246 | max-width: 100%; /* 1 */
247 | padding: 0; /* 3 */
248 | white-space: normal; /* 1 */
249 | }
250 |
251 | /**
252 | * Add the correct vertical alignment in Chrome, Firefox, and Opera.
253 | */
254 |
255 | progress {
256 | vertical-align: baseline;
257 | }
258 |
259 | /**
260 | * Remove the default vertical scrollbar in IE 10+.
261 | */
262 |
263 | textarea {
264 | overflow: auto;
265 | }
266 |
267 | /**
268 | * 1. Add the correct box sizing in IE 10.
269 | * 2. Remove the padding in IE 10.
270 | */
271 |
272 | [type="checkbox"],
273 | [type="radio"] {
274 | box-sizing: border-box; /* 1 */
275 | padding: 0; /* 2 */
276 | }
277 |
278 | /**
279 | * Correct the cursor style of increment and decrement buttons in Chrome.
280 | */
281 |
282 | [type="number"]::-webkit-inner-spin-button,
283 | [type="number"]::-webkit-outer-spin-button {
284 | height: auto;
285 | }
286 |
287 | /**
288 | * 1. Correct the odd appearance in Chrome and Safari.
289 | * 2. Correct the outline style in Safari.
290 | */
291 |
292 | [type="search"] {
293 | -webkit-appearance: textfield; /* 1 */
294 | outline-offset: -2px; /* 2 */
295 | }
296 |
297 | /**
298 | * Remove the inner padding in Chrome and Safari on macOS.
299 | */
300 |
301 | [type="search"]::-webkit-search-decoration {
302 | -webkit-appearance: none;
303 | }
304 |
305 | /**
306 | * 1. Correct the inability to style clickable types in iOS and Safari.
307 | * 2. Change font properties to `inherit` in Safari.
308 | */
309 |
310 | ::-webkit-file-upload-button {
311 | -webkit-appearance: button; /* 1 */
312 | font: inherit; /* 2 */
313 | }
314 |
315 | /* Interactive
316 | ========================================================================== */
317 |
318 | /*
319 | * Add the correct display in Edge, IE 10+, and Firefox.
320 | */
321 |
322 | details {
323 | display: block;
324 | }
325 |
326 | /*
327 | * Add the correct display in all browsers.
328 | */
329 |
330 | summary {
331 | display: list-item;
332 | }
333 |
334 | /* Misc
335 | ========================================================================== */
336 |
337 | /**
338 | * Add the correct display in IE 10+.
339 | */
340 |
341 | template {
342 | display: none;
343 | }
344 |
345 | /**
346 | * Add the correct display in IE 10.
347 | */
348 |
349 | [hidden] {
350 | display: none;
351 | }
352 |
--------------------------------------------------------------------------------
/web/styles/variables.less:
--------------------------------------------------------------------------------
1 | @black: rgb(16, 16, 16);
2 | @dark: rgb(26, 26, 26, 26);
3 | @gray: rgb(84 84 84);
4 | @light: rgb(230, 230, 230);
5 | @red: rgb(220, 60, 60);
6 | @green: rgb(60, 140, 60);
7 | @blue: rgb(60, 140, 220);
8 |
9 | @bg-primary: rgb(12, 12, 12);
10 | @bg-secondary: rgb(18, 18, 18);
11 |
12 | @border: rgb(214, 214, 214);
13 | @text: rgb(140, 140, 140);
14 |
15 | @fs: 1.6rem;
16 | @max-width: 136.6rem;
17 |
--------------------------------------------------------------------------------
/webpack.base.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | const HtmlWebpackPlugin = require("html-webpack-plugin");
3 | const MiniCssExtractPlugin = require("mini-css-extract-plugin");
4 | const CopyPlugin = require("copy-webpack-plugin");
5 |
6 | module.exports = {
7 | devtool: "source-map",
8 | entry: {
9 | main: path.resolve(__dirname, "web/main.ts"),
10 | //serviceWorker: path.resolve(__dirname, "web/serviceWorker.ts")
11 | },
12 | output: {
13 | clean: true,
14 | path: path.resolve(__dirname, "bin/assets")
15 | },
16 | module: {
17 | rules: [
18 | {
19 | test: /\.tsx?$/,
20 | exclude: /node_modules/,
21 | use: ["babel-loader", "ts-loader"]
22 | },
23 | {
24 | test: /.less$/,
25 | use: [
26 | MiniCssExtractPlugin.loader,
27 | {
28 | loader: "css-loader",
29 | options: {
30 | url: false
31 | }
32 | },
33 | "postcss-loader",
34 | "less-loader"
35 | ]
36 | },
37 | {
38 | test: /.css$/,
39 | use: [
40 | MiniCssExtractPlugin.loader,
41 | {
42 | loader: "css-loader",
43 | options: {
44 | url: false
45 | }
46 | },
47 | "postcss-loader"
48 | ]
49 | }
50 | ]
51 | },
52 | plugins: [
53 | new CopyPlugin({
54 | patterns: [
55 | {
56 | from: path.resolve(__dirname, "web/fonts"),
57 | to: path.resolve(__dirname, "bin/assets/fonts")
58 | }
59 | ]
60 | }),
61 | new HtmlWebpackPlugin({
62 | filename: "../templates/head.html",
63 | template: path.resolve(__dirname, "web/head.html"),
64 | chunks: ["main"],
65 | chunksSortMode: "manual",
66 | publicPath: "/"
67 | })
68 | ],
69 | resolve: {
70 | extensions: [".ts", ".tsx", ".js", ".jsx"]
71 | },
72 | watchOptions: {
73 | ignored: /node_modules/
74 | }
75 | };
76 |
--------------------------------------------------------------------------------
/webpack.dev.js:
--------------------------------------------------------------------------------
1 | const config = require("./webpack.base.js");
2 | const MiniCssExtractPlugin = require("mini-css-extract-plugin");
3 |
4 | config.mode = "development";
5 | config.output.filename = pathData => {
6 | return pathData.chunk.name.includes("serviceWorker") ? "js/serviceWorker.js" : "js/[name].development.js";
7 | };
8 |
9 | config.plugins.push(
10 | new MiniCssExtractPlugin({
11 | filename: "css/[name].development.css",
12 | chunkFilename: "css/[id].development.css"
13 | })
14 | );
15 |
16 | module.exports = config;
17 |
--------------------------------------------------------------------------------
/webpack.prod.js:
--------------------------------------------------------------------------------
1 | const config = require("./webpack.base.js");
2 | const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
3 | const UglifyJsPlugin = require("uglifyjs-webpack-plugin");
4 | const MiniCssExtractPlugin = require("mini-css-extract-plugin");
5 |
6 | config.mode = "production";
7 | config.output.filename = pathData => {
8 | return pathData.chunk.name.includes("serviceWorker") ? "js/serviceWorker.js" : "js/[name].[contenthash].js";
9 | };
10 |
11 | config.plugins.push(
12 | new MiniCssExtractPlugin({
13 | filename: "css/[name].[contenthash].css",
14 | chunkFilename: "css/[id].[chunkhash].css"
15 | })
16 | );
17 |
18 | config.optimization = {
19 | minimize: true,
20 | minimizer: [new UglifyJsPlugin(), new CssMinimizerPlugin()]
21 | };
22 |
23 | module.exports = config;
24 |
--------------------------------------------------------------------------------