├── .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 |
4 | {{- range .archives }} 5 |
9 | 10 |
11 | Thumbnail for {{ .Title }} 17 | 24 |
25 | 49 |
50 |
51 | {{- end }} 52 |
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 |
3 | 52 |
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 | 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 |
20 | 21 | {{ .Name }} 22 | {{ .Count }} 23 | 24 |
25 | {{- end }} 26 |
27 | 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 | 26 | 27 | 28 | {{- end }} 29 | -------------------------------------------------------------------------------- /bin/templates/reader_pagination.html: -------------------------------------------------------------------------------- 1 | {{- define "reader_pagination" }} 2 | 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 |
3 | ID 10 | Title 17 | Created 24 | Published 31 | Pages 38 | ASC 39 | DESC 40 | 58 |
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 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |
Number of archives{{ .stats.ArchiveCount }}
Total number of pages{{ .stats.PageCount }}
Average number of pages{{ .stats.AveragePageCount }}
Total archive filesize{{ .stats.Size }} bytes ({{ formatBytes .stats.Size }})
Average archive filesize{{ .stats.AverageSize }} bytes ({{ formatBytes .stats.AverageSize }})
Number of artists{{ .stats.ArtistCount }}
Number of circles{{ .stats.CircleCount }}
Number of magazines{{ .stats.MagazineCount }}
Number of parodies{{ .stats.ParodyCount }}
Number of tags{{ .stats.TagCount }}
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 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | {{- $format := "Mon, 02 Jan 2006 15:04:05 MST" }} 27 | {{- range .data }} 28 | 29 | 32 | 35 | 42 | 45 | 46 | 47 | 56 | {{- if or .Archives .Notes }} 57 | 72 | {{- end }} 73 | 74 | {{- end }} 75 | 76 |
IDNameSubmitterSubmitted
30 | {{ .ID }} 31 | 33 | {{ .Name }} 34 | 36 | {{- if .Submitter }} 37 | {{ .Submitter }} 38 | {{- else }} 39 | anonymous 40 | {{- end }} 41 | 43 | {{ formatUnix .CreatedAt $format }} 44 |
48 |
49 | {{- if .Accepted }} 50 | Accepted {{ formatUnix .AcceptedAt $format }} 51 | {{- else }} 52 | Rejected {{ formatUnix .RejectedAt $format }} 53 | {{- end }} 54 |
55 |
58 | {{- if .Notes }} 59 |

60 | Note: 61 | {{ .Notes }} 62 |

63 | {{- end }} 64 | {{- if .Archives }} 65 | 70 | {{- end }} 71 |
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 | 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 |
30 | 39 | 47 | 50 | 51 |
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 | --------------------------------------------------------------------------------