├── .dockerignore ├── .github └── workflows │ └── main.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── browser ├── .browserslistrc ├── .eslintrc.js ├── .gitignore ├── README.md ├── babel.config.js ├── browser.go ├── package.json ├── public │ ├── favicon.ico │ └── index.html ├── src │ ├── App.vue │ ├── components │ │ ├── AlertMessage.vue │ │ ├── BookEditDialog.vue │ │ ├── BookEditor.vue │ │ ├── BookList.vue │ │ ├── BookRegistrationDialog.vue │ │ ├── FileIcon.vue │ │ └── Header.vue │ ├── file.ts │ ├── main.ts │ ├── model.ts │ ├── plugins │ │ └── vuetify.ts │ ├── router │ │ └── index.ts │ ├── shims-tsx.d.ts │ ├── shims-vue.d.ts │ ├── store │ │ └── index.ts │ ├── utils.ts │ ├── views │ │ ├── About.vue │ │ └── Home.vue │ └── vuex │ │ ├── action_types.ts │ │ └── mutation_types.ts ├── tsconfig.json ├── vue.config.js └── yarn.lock ├── cmd └── main.go ├── controller ├── book.go ├── file.go ├── handler.go ├── mimes.go └── opds.go ├── data └── .gitignore ├── docker-compose.yml ├── go.mod ├── go.sum ├── main.go ├── model ├── book.go ├── file.go ├── migration.go ├── mime.go ├── model.go └── opds.go ├── opds └── opds.go ├── sql └── 00_crreate_database.sql └── storage ├── filesystem.go ├── s3.go └── storage.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | 3 | bin 4 | docker-compose.yml 5 | 6 | /browser/* 7 | !/browser/*.go 8 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | name: build 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: set up Go 1.14 9 | uses: actions/setup-go@v1 10 | with: 11 | go-version: 1.14 12 | 13 | - name: Check out source code 14 | uses: actions/checkout@v1 15 | 16 | - name: build 17 | run: make 18 | 19 | - name: test 20 | run: make test 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin 2 | .env 3 | .envrc 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.14-alpine as builder 2 | WORKDIR /build 3 | COPY . ./ 4 | RUN apk --update add --no-cache build-base 5 | RUN CGO_ENABLED=off go build . 6 | 7 | 8 | FROM alpine:latest 9 | RUN apk --update add --no-cache tzdata 10 | WORKDIR /app 11 | COPY --from=builder /build/bookshelf /app/bookshelf 12 | CMD /app/bookshelf 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 altescy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME := bookshelf 2 | PWD := $(shell pwd) 3 | GOCMD := go 4 | GOBUILD := $(GOCMD) build 5 | GOTEST := $(GOCMD) test 6 | SOURCE := $(PWD) 7 | TARGET := $(PWD)/bin/$(NAME) 8 | 9 | $(TARGET): 10 | $(GOBUILD) -o $(TARGET) $(SOURCE) 11 | 12 | .PHONY: run 13 | run: $(TARGET) 14 | $(TARGET) 15 | 16 | .PHONY: test 17 | test: 18 | $(GOTEST) $(PWD)/... 19 | 20 | 21 | .PHONY: clean 22 | clean: 23 | rm -rf $(PWD)/bin 24 | 25 | all: clean $(TARGET) 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Bookshelf 2 | ========= 3 | 4 | [![Actions Status](https://github.com/altescy/bookshelf/workflows/build/badge.svg)](https://github.com/altescy/bookshelf/actions?query=workflow%3Abuild) 5 | [![License](https://img.shields.io/github/license/altescy/bookshelf)](https://github.com/altescy/bookshelf/blob/master/LICENSE) 6 | [![Release](https://img.shields.io/github/v/release/altescy/bookshelf)](https://github.com/altescy/bookshelf/releases) 7 | 8 | [Blog post (Japanese)](https://altescy.jp/posts/works/bookshelf/) 9 | 10 | `Bookshelf` is a simple ebook management web application. 11 | You can easily store and manage your books on a local or S3 compatible storage. 12 | This software also provides a OPDS feed which enables you to read your books via any OPDS readers on your computer or smartphone. 13 | 14 | ![Screenshot_2020-09-22 bookshelf](https://user-images.githubusercontent.com/16734471/93875665-5c6a5d00-fd10-11ea-81df-3a1735aa4547.png) 15 | 16 | 17 | ### Usage 18 | 19 | ``` 20 | $ go get github.com/altescy/bookshelf 21 | $ export BOOKSHELF_DB_URL=sqlite3:///`pwd`/data/bookshelf.db 22 | $ export BOOKSHELF_STORAGE_URL=file:///`pwd`/data/files 23 | $ bookshelf 24 | ``` 25 | 26 | ### Docker 27 | 28 | ``` 29 | $ docker pull altescy/bookshelf 30 | $ docker run -d \ 31 | -v `pwd`/data:/data \ 32 | -p 8080:8080 \ 33 | -e BOOKSHELF_DB_URL=sqlite3:///data/bookshelf.db \ 34 | -e BOOKSHELF_STORAGE_URL=file:///data/files \ 35 | altescy/bookshelf 36 | ``` 37 | 38 | 39 | ### docker-compose 40 | 41 | ``` 42 | $ git clone https://github.com/altescy/bookshelf.git 43 | $ cd bookshelf 44 | $ cat << EOF > .env 45 | BOOKSHELF_PORT=80 46 | BOOKSHELF_ENABLE_CORS= 47 | BOOKSHELF_DB_URL=postgres://user:password@postgres:5432/bookshelf?sslmode=disable 48 | BOOKSHELF_STORAGE_URL=s3://books 49 | BOOKSHELF_CREATE_NEW_STORAGE=1 50 | BOOKSHELF_AWS_ACCESS_KEY_ID=minio_access 51 | BOOKSHELF_AWS_SECRET_ACCESS_KEY=minio_secret 52 | BOOKSHELF_AWS_S3_REGION=us-east-1 53 | BOOKSHELF_AWS_S3_ENDPOINT_URL=http://minio 54 | 55 | MINIO_ACCESS_KEY=minio_access 56 | MINIO_SECRET_KEY=minio_secret 57 | MINIO_HOST=0.0.0.0 58 | MINIO_PORT=9000 59 | 60 | POSTGRES_USER=user 61 | POSTGRES_PASSWORD=password 62 | POSTGRES_PORT=5432 63 | 64 | TZ=Asia/Tokyo 65 | EOF 66 | $ docker-compose up -d 67 | ``` 68 | -------------------------------------------------------------------------------- /browser/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /browser/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | 'extends': [ 7 | 'plugin:vue/essential', 8 | 'eslint:recommended', 9 | '@vue/typescript/recommended' 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 2020 13 | }, 14 | rules: { 15 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 16 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off' 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /browser/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /browser/README.md: -------------------------------------------------------------------------------- 1 | # Bookshelf Browser 2 | 3 | ## Project setup 4 | ``` 5 | yarn install 6 | go get github.com/go-bindata/go-bindata/go-bindata 7 | go get github.com/elazarl/go-bindata-assetfs/go-bindata-assetfs 8 | ``` 9 | 10 | ### Compiles and hot-reloads for development 11 | ``` 12 | yarn serve 13 | ``` 14 | 15 | ### Compiles and minifies for production 16 | ``` 17 | yarn build 18 | ``` 19 | 20 | ### Lints and fixes files 21 | ``` 22 | yarn lint 23 | ``` 24 | 25 | ### Customize configuration 26 | See [Configuration Reference](https://cli.vuejs.org/config/). 27 | -------------------------------------------------------------------------------- /browser/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /browser/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bookshelf", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build && go-bindata-assetfs -o browser.go -pkg browser dist/**", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "axios": "^0.21.1", 12 | "core-js": "^3.6.5", 13 | "vue": "^2.6.11", 14 | "vue-class-component": "^7.2.3", 15 | "vue-property-decorator": "^8.4.2", 16 | "vue-router": "^3.2.0", 17 | "vuetify": "^2.2.11", 18 | "vuex": "^3.4.0" 19 | }, 20 | "devDependencies": { 21 | "@typescript-eslint/eslint-plugin": "^2.33.0", 22 | "@typescript-eslint/parser": "^2.33.0", 23 | "@vue/cli-plugin-babel": "~4.5.0", 24 | "@vue/cli-plugin-eslint": "~4.5.0", 25 | "@vue/cli-plugin-router": "~4.5.0", 26 | "@vue/cli-plugin-typescript": "~4.5.0", 27 | "@vue/cli-plugin-vuex": "~4.5.0", 28 | "@vue/cli-service": "~4.5.0", 29 | "@vue/eslint-config-typescript": "^5.0.2", 30 | "eslint": "^6.7.2", 31 | "eslint-plugin-vue": "^6.2.2", 32 | "sass": "^1.26.5", 33 | "sass-loader": "^8.0.2", 34 | "typescript": "~3.9.3", 35 | "vue-cli-plugin-vuetify": "~2.0.7", 36 | "vue-template-compiler": "^2.6.11", 37 | "vuetify-loader": "^1.3.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /browser/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/altescy/bookshelf/07dc153ed61dd1555cb0d974b863afef5a6a2bf2/browser/public/favicon.ico -------------------------------------------------------------------------------- /browser/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 12 | 13 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /browser/src/App.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 84 | -------------------------------------------------------------------------------- /browser/src/components/AlertMessage.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 41 | -------------------------------------------------------------------------------- /browser/src/components/BookEditDialog.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 129 | -------------------------------------------------------------------------------- /browser/src/components/BookEditor.vue: -------------------------------------------------------------------------------- 1 | 89 | 90 | 131 | -------------------------------------------------------------------------------- /browser/src/components/BookList.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 90 | -------------------------------------------------------------------------------- /browser/src/components/BookRegistrationDialog.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 79 | -------------------------------------------------------------------------------- /browser/src/components/FileIcon.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 103 | -------------------------------------------------------------------------------- /browser/src/components/Header.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 71 | -------------------------------------------------------------------------------- /browser/src/file.ts: -------------------------------------------------------------------------------- 1 | export function getBaseMime(mime: string): string { 2 | return mime.split(";")[0]; 3 | } 4 | 5 | export const ExtToMime: Map = new Map([ 6 | [".azw3", "application/x-mobi8-ebook"], 7 | [".epub", "application/epub+zip"], 8 | [".fb2", "application/fb2+zip"], 9 | [".mobi", "application/x-mobipocket-ebook"], 10 | [".pdf", "application/pdf"], 11 | [".txt", "text/plain"], 12 | ]); 13 | 14 | export const MimeToAlias: Map = new Map([ 15 | ["application/x-mobi8-ebook", "azw3"], 16 | ["application/epub+zip", "epub"], 17 | ["application/fb2+zip", "fb2"], 18 | ["application/x-mobipocket-ebook", "mobi"], 19 | ["application/pdf", "pdf"], 20 | ["text/plain", "txt"], 21 | ]); 22 | 23 | export const MimeToColor: Map = new Map([ 24 | ["application/x-mobi8-ebook", "orange"], 25 | ["application/epub+zip", "lime"], 26 | ["application/fb2+zip", "light-blue"], 27 | ["application/x-mobipocket-ebook", "amber"], 28 | ["application/pdf", "cyan"], 29 | ["text/plain", "blue-grey"], 30 | ]); 31 | -------------------------------------------------------------------------------- /browser/src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | import router from './router' 4 | import store from './store' 5 | import vuetify from './plugins/vuetify'; 6 | 7 | Vue.config.productionTip = false 8 | 9 | new Vue({ 10 | router, 11 | store, 12 | vuetify, 13 | render: h => h(App) 14 | }).$mount('#app') 15 | -------------------------------------------------------------------------------- /browser/src/model.ts: -------------------------------------------------------------------------------- 1 | export type AlertMessageType = 'success' | 'warning' | 'error'; 2 | 3 | export interface AlertMessage { 4 | id: number; 5 | type: AlertMessageType; 6 | message: string; 7 | } 8 | 9 | export interface Book { 10 | ID: number; 11 | CreatedAt: string; 12 | UpdatedAt: string; 13 | ISBN: string; 14 | Title: string; 15 | Author: string; 16 | Description: string; 17 | CoverURL: string; 18 | Publisher: string; 19 | PubDate: string; // format: 2020-01-02 20 | Files: BookFile[]; 21 | } 22 | 23 | export interface BookFile { 24 | ID: number; 25 | BookID: number; 26 | MimeType: string; 27 | Path: string; 28 | } 29 | 30 | export type DialogType = 'register' | 'edit'; 31 | 32 | export interface State { 33 | alertMessages: AlertMessage[]; 34 | books: Book[]; 35 | dialog: boolean; 36 | dialogType: DialogType; 37 | mimes: Map; 38 | search: string; 39 | editingBook: Book; 40 | files: File[]; 41 | overlay: boolean; 42 | } 43 | 44 | export interface OnixTextContent { 45 | ContentAudience: string; 46 | Text: string; 47 | TextType: string; 48 | } 49 | -------------------------------------------------------------------------------- /browser/src/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuetify from 'vuetify/lib'; 3 | 4 | Vue.use(Vuetify); 5 | 6 | export default new Vuetify({ 7 | }); 8 | -------------------------------------------------------------------------------- /browser/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueRouter, { RouteConfig } from 'vue-router' 3 | import Home from '../views/Home.vue' 4 | 5 | Vue.use(VueRouter) 6 | 7 | const routes: Array = [ 8 | { 9 | path: '/', 10 | name: 'Home', 11 | component: Home 12 | }, 13 | { 14 | path: '/about', 15 | name: 'About', 16 | // route level code-splitting 17 | // this generates a separate chunk (about.[hash].js) for this route 18 | // which is lazy-loaded when the route is visited. 19 | component: () => import(/* webpackChunkName: "about" */ '../views/About.vue') 20 | } 21 | ] 22 | 23 | const router = new VueRouter({ 24 | routes 25 | }) 26 | 27 | export default router 28 | -------------------------------------------------------------------------------- /browser/src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue' 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /browser/src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue' 3 | export default Vue 4 | } 5 | -------------------------------------------------------------------------------- /browser/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | import axios, {AxiosResponse} from 'axios'; 4 | import * as Model from '@/model'; 5 | import * as VuexMutation from '@/vuex/mutation_types'; 6 | import * as VuexAction from '@/vuex/action_types'; 7 | import {getBaseMime, MimeToAlias} from '@/file'; 8 | import {deepCopy} from '@/utils'; 9 | 10 | Vue.use(Vuex) 11 | 12 | const API_ENDPOINT = '/api'; 13 | const OPENBD_ENDPOINT = 'https://api.openbd.jp/v1'; 14 | 15 | const emptyBook: Model.Book = { 16 | ID: 0, 17 | CreatedAt: '', 18 | UpdatedAt: '', 19 | ISBN: '', 20 | Title: '', 21 | Author: '', 22 | Description: '', 23 | CoverURL: '', 24 | Publisher: '', 25 | PubDate: '', 26 | Files: [], 27 | }; 28 | 29 | const initialState: Model.State = { 30 | alertMessages: [], 31 | books: [], 32 | dialog: false, 33 | dialogType: 'register', 34 | mimes: new Map(), 35 | search: '', 36 | editingBook: deepCopy(emptyBook), 37 | files: [], 38 | overlay: false, 39 | } 40 | 41 | function buildBookParams(book: Model.Book): URLSearchParams { 42 | const params = new URLSearchParams(); 43 | params.append('ISBN', book.ISBN); 44 | params.append('Title', book.Title); 45 | params.append('Author', book.Author); 46 | params.append('Publisher', book.Publisher); 47 | params.append('PubDate', book.PubDate); 48 | params.append('CoverURL', book.CoverURL); 49 | params.append('Description', book.Description); 50 | return params; 51 | } 52 | 53 | function extractBookFromOpenBDResponse(response: AxiosResponse): Model.Book { 54 | const data = response.data[0]; 55 | if (!data) { 56 | throw new Error('invalid ISBN'); 57 | } 58 | const convertPubdate = (pubdate: string): string => { 59 | const year = pubdate.slice(0, 4); 60 | const month = pubdate.slice(4, 6); 61 | const date = pubdate.slice(6, 8); 62 | return year + '-' + month + '-' + date; 63 | }; 64 | const getDescription = (): string => { 65 | const contents = data.onix.CollateralDetail.TextContent; 66 | if (!contents) return ''; 67 | const description = contents.find((c: Model.OnixTextContent) => c.TextType === '03'); 68 | return description? description.Text : ''; 69 | } 70 | const book: Model.Book = { 71 | ID: 0, 72 | CreatedAt: '', 73 | UpdatedAt: '', 74 | ISBN: data.summary.isbn, 75 | Title: data.summary.title, 76 | Author: data.summary.author, 77 | Publisher: data.summary.publisher, 78 | PubDate: convertPubdate(data.summary.pubdate), 79 | CoverURL: data.summary.cover, 80 | Description: getDescription(), 81 | Files: [], 82 | }; 83 | return book; 84 | } 85 | 86 | function validateBook(book: Model.Book) { 87 | if (!book.Title) { 88 | throw new Error('Title is empty.'); 89 | } 90 | } 91 | 92 | function validateFiles(files: File[]) { 93 | const types: string[] = []; 94 | for (const file of files) { 95 | if(types.includes(file.type)) throw new Error('file type conflict'); 96 | types.push(file.type); 97 | } 98 | } 99 | 100 | async function uploadFiles(commit: Function, bookID: number, files: File[]): Promise { 101 | const formData = new FormData(); 102 | for(const file of files) { 103 | formData.append("files", file); 104 | } 105 | 106 | const response = await axios.post(API_ENDPOINT + '/book/' + bookID + '/files', formData); 107 | 108 | let isFailed = false; 109 | if (response.status === 200) { 110 | for(const result of response.data) { 111 | if (result.status === 'ok'){ 112 | commit(VuexMutation.UPDATE_FILE, result.content) 113 | } else { 114 | isFailed = true; 115 | const msg: Model.AlertMessage = { 116 | id: 0, 117 | type: "error", 118 | message: result.file + " : " + result.content, 119 | } 120 | commit(VuexMutation.SET_ALERT_MESSAGE, msg); 121 | } 122 | } 123 | } else { 124 | throw new Error(response.data.err || 'unexpected error'); 125 | } 126 | 127 | if (isFailed) throw new Error('failed to upload some files') 128 | 129 | return response; 130 | } 131 | 132 | function handleAPIError(commit: Function, error: Error) { 133 | const alertMessage: Model.AlertMessage = { 134 | id: 0, 135 | type: 'error', 136 | message: String(error), 137 | }; 138 | commit(VuexMutation.SET_ALERT_MESSAGE, alertMessage); 139 | console.error(error) 140 | throw error 141 | } 142 | 143 | export default new Vuex.Store({ 144 | state: initialState, 145 | mutations: { [VuexMutation.SET_ALERT_MESSAGE](state: Model.State, alertMessage: Model.AlertMessage) { 146 | alertMessage.id = state.alertMessages.length; 147 | state.alertMessages = state.alertMessages.concat(alertMessage); 148 | }, 149 | [VuexMutation.DELETE_ALERT_MESSAGE](state: Model.State, alertMessage: Model.AlertMessage) { 150 | state.alertMessages = state.alertMessages.filter((m: Model.AlertMessage) => m.id != alertMessage.id); 151 | }, 152 | [VuexMutation.OPEN_DIALOG](state: Model.State) { 153 | state.dialog = true; 154 | }, 155 | [VuexMutation.CLOSE_DIALOG](state: Model.State) { 156 | state.dialog = false; 157 | }, 158 | [VuexMutation.SET_DIALOG_TYPE](state: Model.State, type: Model.DialogType) { 159 | state.dialogType = type; 160 | }, 161 | [VuexMutation.SET_OVERLAY](state: Model.State, overlay: boolean) { 162 | state.overlay = overlay; 163 | }, 164 | [VuexMutation.SET_EDITING_BOOK](state: Model.State, book) { 165 | state.editingBook = deepCopy(book); 166 | }, 167 | [VuexMutation.UNSET_EDITING_BOOK](state: Model.State) { 168 | state.editingBook = deepCopy(emptyBook); 169 | }, 170 | [VuexMutation.SET_BOOKS](state: Model.State, books) { 171 | state.books = books; 172 | }, 173 | [VuexMutation.ADD_BOOK](state: Model.State, book) { 174 | state.books = [deepCopy(book)].concat(state.books); 175 | }, 176 | [VuexMutation.UPDATE_BOOK](state: Model.State, book: Model.Book) { 177 | const books = deepCopy(state.books) 178 | for(const i in books) { 179 | if(books[i].ID == book.ID) { 180 | books[i] = book; 181 | break; 182 | } 183 | } 184 | state.books = books; 185 | }, 186 | [VuexMutation.DELETE_BOOK_BY_ID](state: Model.State, bookID: number) { 187 | const books = deepCopy(state.books); 188 | state.books = books.filter((b: Model.Book) => b.ID != bookID); 189 | }, 190 | [VuexMutation.DELEFTE_FILE_BY_ID](state: Model.State, fileID: number) { 191 | // delete file from book list 192 | state.books = deepCopy(state.books).map((b: Model.Book) => { 193 | b.Files = b.Files.filter((f: Model.BookFile) => f.ID !== fileID); 194 | return b; 195 | }); 196 | // delete file from editing book 197 | const editingBook = deepCopy(state.editingBook); 198 | editingBook.Files = editingBook.Files.filter((f: Model.BookFile) => f.ID !== fileID); 199 | state.editingBook = editingBook; 200 | }, 201 | [VuexMutation.UPDATE_FILE](state: Model.State, file: Model.BookFile) { 202 | // update file in book list 203 | state.books = deepCopy(state.books).map((b: Model.Book): Model.Book => { 204 | if (b.ID !== file.BookID) return b; 205 | if (!b.Files) b.Files = [] 206 | b.Files = b.Files.filter((f: Model.BookFile): boolean => f.MimeType !== file.MimeType) 207 | b.Files.push(file) 208 | return b; 209 | }); 210 | // update file in editing book 211 | if (state.editingBook.ID === file.BookID) { 212 | const book = state.editingBook; 213 | book.Files = book.Files.filter((f: Model.BookFile): boolean => f.MimeType !== file.MimeType) 214 | book.Files.push(file); 215 | state.editingBook = book; 216 | } 217 | }, 218 | [VuexMutation.SET_FILES](state: Model.State, files: File[]) { 219 | state.files = files; 220 | }, 221 | [VuexMutation.SET_MIMES](state: Model.State, mimes: Map) { 222 | state.mimes = mimes; 223 | }, 224 | [VuexMutation.SET_SEARCH_QUERY](state: Model.State, query: string) { 225 | state.search = query; 226 | }, 227 | }, 228 | actions: { 229 | [VuexAction.OPEN_DIALOG]({ commit }, type: Model.DialogType) { 230 | commit(VuexMutation.OPEN_DIALOG, type); 231 | }, 232 | async [VuexAction.AUTOCOMPLETE_EDITING_BOOK_BY_ISBN]({ commit }) { 233 | const isbn = this.state.editingBook.ISBN.replace(/-/g, ''); 234 | await axios.get(OPENBD_ENDPOINT + '/get?isbn=' + isbn).then(response => { 235 | if (response.status === 200) { 236 | const completedBook = extractBookFromOpenBDResponse(response); 237 | const book = deepCopy(this.state.editingBook); 238 | book.ISBN = completedBook.ISBN; 239 | book.Title = completedBook.Title; 240 | book.Author = completedBook.Author; 241 | book.Publisher = completedBook.Publisher; 242 | book.PubDate = completedBook.PubDate; 243 | book.CoverURL = completedBook.CoverURL; 244 | book.Description = completedBook.Description; 245 | commit(VuexMutation.SET_EDITING_BOOK, book); 246 | } else { 247 | throw new Error('failed to fetch book information'); 248 | } 249 | }).catch(error => handleAPIError(commit, error)); 250 | }, 251 | async [VuexAction.FETCH_ALL_BOOKS]({ commit }) { 252 | await axios.get(API_ENDPOINT + '/books').then(response => { 253 | if (response.status === 200) { 254 | commit(VuexMutation.SET_BOOKS, response.data); 255 | } else { 256 | throw new Error(response.data.err || 'unexpected error'); 257 | } 258 | }).catch(error => handleAPIError(commit, error)); 259 | }, 260 | async [VuexAction.REGISTER_EDITING_BOOK]({ commit }) { 261 | const book = this.state.editingBook; 262 | const files = this.state.files; 263 | 264 | try { 265 | validateBook(book); 266 | validateFiles(files); 267 | } catch (error) { 268 | handleAPIError(commit, error); 269 | } 270 | 271 | const params = buildBookParams(book); 272 | await axios.post(API_ENDPOINT + '/book', params).then(response => { 273 | if (response.status === 200) { 274 | commit(VuexMutation.ADD_BOOK, response.data); 275 | } else { 276 | throw new Error(response.data.err || 'unexpected error'); 277 | } 278 | return response; 279 | }).then(response => { 280 | const bookID = response.data.ID; 281 | return uploadFiles(commit, bookID, files); 282 | }).catch(error => handleAPIError(commit, error)); 283 | }, 284 | async [VuexAction.UPDATE_BOOK]({ commit }) { 285 | const book = this.state.editingBook; 286 | const files = this.state.files; 287 | 288 | try { 289 | validateBook(book); 290 | validateFiles(files); 291 | } catch (error) { 292 | handleAPIError(commit, error); 293 | } 294 | 295 | const updateParams = buildBookParams(this.state.editingBook); 296 | await Promise.all([ 297 | uploadFiles(commit, book.ID, files), 298 | axios.put(API_ENDPOINT + '/book/' + book.ID, updateParams), 299 | ]).then(([_uploadFilesResponse, updateBookResponse]) => { // eslint-disable-line 300 | if (updateBookResponse.status === 200) { 301 | commit(VuexMutation.UPDATE_BOOK, updateBookResponse.data); 302 | commit(VuexMutation.SET_EDITING_BOOK, updateBookResponse.data); 303 | } else { 304 | throw new Error(updateBookResponse.data.err || 'unexpected error'); 305 | } 306 | }).catch(error => handleAPIError(commit, error)); 307 | }, 308 | async [VuexAction.DELETE_EDITING_BOOK]({ commit }) { 309 | const book = this.state.editingBook; 310 | await axios.delete(API_ENDPOINT + '/book/' + book.ID).then(response => { 311 | if (response.status === 200) { 312 | commit(VuexMutation.DELETE_BOOK_BY_ID, this.state.editingBook.ID); 313 | } else { 314 | throw new Error(response.data.err || 'unexpected error'); 315 | } 316 | 317 | }).catch(error => handleAPIError(commit, error)); 318 | }, 319 | async [VuexAction.FETCH_MIMES]({ commit }) { 320 | await axios.get(API_ENDPOINT + '/mimes').then(response => { 321 | if (response.status === 200) { 322 | commit(VuexMutation.SET_MIMES, response.data); 323 | } else { 324 | throw new Error(response.data.err || 'unexpected error'); 325 | } 326 | }).catch(error => handleAPIError(commit, error)); 327 | }, 328 | async [VuexAction.DELETE_FILE]({ commit }, file: Model.BookFile) { 329 | const mime = MimeToAlias.get(getBaseMime(file.MimeType)); 330 | await axios.delete(API_ENDPOINT + '/book/' + file.BookID + '/file/' + mime).then(response => { 331 | if (response.status === 200) { 332 | commit(VuexMutation.DELEFTE_FILE_BY_ID, file.ID); 333 | } else { 334 | throw new Error(response.data.err || 'unexpected error'); 335 | } 336 | }).catch(error => handleAPIError(commit, error)); 337 | }, 338 | }, 339 | modules: { 340 | } 341 | }) 342 | -------------------------------------------------------------------------------- /browser/src/utils.ts: -------------------------------------------------------------------------------- 1 | export function deepCopy(src: T): T { 2 | return JSON.parse(JSON.stringify(src)); 3 | } 4 | -------------------------------------------------------------------------------- /browser/src/views/About.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /browser/src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | -------------------------------------------------------------------------------- /browser/src/vuex/action_types.ts: -------------------------------------------------------------------------------- 1 | export const AUTOCOMPLETE_EDITING_BOOK_BY_ISBN = 'AUTOCOMPLETE_EDITING_BOOK_BY_ISBN'; 2 | 3 | export const FETCH_ALL_BOOKS = 'FETCH_ALL_BOOKS'; 4 | export const FETCH_BOOK_DETAILS = 'FETCH_BOOK_DETAILS'; 5 | 6 | export const REGISTER_EDITING_BOOK = 'REGISTER_EDITING_BOOK'; 7 | 8 | export const FETCH_MIMES = 'FETCH_MIMES'; 9 | 10 | export const UPDATE_BOOK = 'UPDATE_BOOK'; 11 | export const DELETE_EDITING_BOOK = 'DELETE_EDITING_BOOK'; 12 | export const DELETE_FILE = 'DELETE_FILE'; 13 | 14 | export const OPEN_DIALOG = 'OPEN_DIALOG'; 15 | -------------------------------------------------------------------------------- /browser/src/vuex/mutation_types.ts: -------------------------------------------------------------------------------- 1 | export const SET_ALERT_MESSAGE = 'SET_ALERT_MESSAGE'; 2 | export const DELETE_ALERT_MESSAGE = 'DELETE_ALERT_MESSAGE'; 3 | 4 | export const OPEN_DIALOG = 'OPEN_DIALOG'; 5 | export const CLOSE_DIALOG = 'CLOSE_DIALOG'; 6 | export const SET_DIALOG_TYPE = 'SET_DIALOG_TYPE'; 7 | export const SET_OVERLAY = 'SET_OVERLAY'; 8 | 9 | export const SET_EDITING_BOOK = 'SET_EDITING_BOOK'; 10 | export const UNSET_EDITING_BOOK = 'UNSET_EDITING_BOOK'; 11 | 12 | export const SET_BOOKS = 'SET_BOOKS'; 13 | export const ADD_BOOK = 'ADD_BOOK'; 14 | export const UPDATE_BOOK = 'UPDATE_BOOK'; 15 | export const DELETE_BOOK_BY_ID = 'DELETE_BOOK_BY_ID'; 16 | export const DELEFTE_FILE_BY_ID = 'DELETE_FILE_BY_ID'; 17 | export const UPDATE_FILE = 'UPDATE_FILE'; 18 | 19 | export const SET_FILES = 'SET_FILES'; 20 | 21 | export const SET_MIMES = 'SET_MIMES'; 22 | 23 | export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY'; 24 | -------------------------------------------------------------------------------- /browser/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "sourceMap": true, 14 | "baseUrl": ".", 15 | "types": [ 16 | "webpack-env", 17 | "vuetify" 18 | ], 19 | "paths": { 20 | "@/*": [ 21 | "src/*" 22 | ] 23 | }, 24 | "lib": [ 25 | "esnext", 26 | "dom", 27 | "dom.iterable", 28 | "scripthost" 29 | ] 30 | }, 31 | "include": [ 32 | "src/**/*.ts", 33 | "src/**/*.tsx", 34 | "src/**/*.vue", 35 | "tests/**/*.ts", 36 | "tests/**/*.tsx" 37 | ], 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /browser/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "transpileDependencies": [ 3 | "vuetify" 4 | ] 5 | } -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "net/url" 7 | "os" 8 | "time" 9 | 10 | "github.com/altescy/bookshelf/browser" 11 | "github.com/altescy/bookshelf/controller" 12 | "github.com/altescy/bookshelf/model" 13 | "github.com/altescy/bookshelf/storage" 14 | "github.com/aws/aws-sdk-go/aws" 15 | "github.com/aws/aws-sdk-go/aws/awserr" 16 | "github.com/aws/aws-sdk-go/aws/credentials" 17 | "github.com/aws/aws-sdk-go/aws/session" 18 | "github.com/aws/aws-sdk-go/service/s3" 19 | assetfs "github.com/elazarl/go-bindata-assetfs" 20 | "github.com/jinzhu/gorm" 21 | "github.com/julienschmidt/httprouter" 22 | _ "github.com/lib/pq" 23 | _ "github.com/mattn/go-sqlite3" 24 | ) 25 | 26 | // EnvPrefix is a prefix for environment variables. 27 | const EnvPrefix = "BOOKSHELF_" 28 | 29 | func init() { 30 | var err error 31 | 32 | tz := getEnv("TZ", "Asia/Tokyo") 33 | 34 | loc, err := time.LoadLocation(tz) 35 | if err != nil { 36 | log.Panicln(err) 37 | } 38 | time.Local = loc 39 | } 40 | 41 | func getEnv(key, def string) string { 42 | if v, ok := os.LookupEnv(EnvPrefix + key); ok { 43 | return v 44 | } 45 | return def 46 | } 47 | 48 | func createGormDB() *gorm.DB { 49 | var ( 50 | dbURL = getEnv("DB_URL", "") 51 | ) 52 | 53 | parsedURL, err := url.Parse(dbURL) 54 | if err != nil { 55 | log.Fatalf("cannot parse database url. err: %v", err) 56 | } 57 | 58 | var db *gorm.DB 59 | 60 | switch parsedURL.Scheme { 61 | case "sqlite3": 62 | db, err = gorm.Open("sqlite3", parsedURL.Path) 63 | if err != nil { 64 | log.Fatalf("sqlite3 connect failed. err: %s", err) 65 | } 66 | case "postgres": 67 | db, err = gorm.Open("postgres", dbURL) 68 | if err != nil { 69 | log.Fatalf("postgres connect failed. err: %s", err) 70 | } 71 | default: 72 | log.Fatal("invalid db") 73 | } 74 | 75 | return db 76 | } 77 | 78 | func createStorage() storage.Storage { 79 | var ( 80 | storageURL = getEnv("STORAGE_URL", "") 81 | createNewStorage = getEnv("CREATE_NEW_STORAGE", "") 82 | ) 83 | 84 | var store storage.Storage 85 | 86 | parsedURL, err := url.Parse(storageURL) 87 | if err != nil { 88 | log.Fatalf("cannot parse storage url: %v", err) 89 | } 90 | 91 | doCreateNewStorage := createNewStorage != "" 92 | 93 | switch parsedURL.Scheme { 94 | case "s3": 95 | bucket := parsedURL.Host 96 | root := parsedURL.Path 97 | store = createS3Storage(bucket, root, doCreateNewStorage) 98 | case "file": 99 | root := parsedURL.Path 100 | store = createFileSysteStorage(root, doCreateNewStorage) 101 | default: 102 | log.Fatalf("invalid storage url scheme: %s", parsedURL.Scheme) 103 | } 104 | 105 | return store 106 | } 107 | 108 | func createFileSysteStorage(root string, createNewStorage bool) *storage.FileSystemStorage { 109 | perm := os.FileMode(0777) 110 | 111 | if err := os.MkdirAll(root, perm); err != nil { 112 | log.Fatalf("faield to create storage dir. err: %v", err) 113 | } 114 | 115 | return storage.NewFileSystemStorage(root, perm) 116 | } 117 | 118 | func createS3Storage(bucket, root string, createNewStorage bool) *storage.S3Storage { 119 | var ( 120 | awsAccessKey = getEnv("AWS_ACCESS_KEY_ID", "") 121 | awsSecretKey = getEnv("AWS_SECRET_ACCESS_KEY", "") 122 | awsSessionToken = getEnv("AWS_SESSION_TOKEN", "") 123 | s3Region = getEnv("AWS_S3_REGION", "") 124 | s3EndpointURL = getEnv("AWS_S3_ENDPOINT_URL", "") 125 | ) 126 | 127 | sess, err := session.NewSession(&aws.Config{ 128 | Credentials: credentials.NewStaticCredentials(awsAccessKey, awsSecretKey, awsSessionToken), 129 | Endpoint: aws.String(s3EndpointURL), 130 | Region: aws.String(s3Region), 131 | S3ForcePathStyle: aws.Bool(true), 132 | }) 133 | if err != nil { 134 | log.Fatalf("cannot create aws session: %v", err) 135 | } 136 | svc := s3.New(sess) 137 | 138 | // create bucket if not exists 139 | if createNewStorage { 140 | log.Printf("[INFO] check bucket existence") 141 | _, err := svc.HeadBucket(&s3.HeadBucketInput{ 142 | Bucket: aws.String(bucket), 143 | }) 144 | if err != nil { 145 | if awsErr, ok := err.(awserr.Error); ok { 146 | switch awsErr.Code() { 147 | case "NotFound": 148 | log.Printf("[INFO] create a new bucket") 149 | _, err = svc.CreateBucket(&s3.CreateBucketInput{Bucket: aws.String(bucket)}) 150 | if err != nil { 151 | log.Fatalf("failed to create bucket: %v", err) 152 | } 153 | default: 154 | log.Fatal(err) 155 | } 156 | } else { 157 | log.Fatal(err) 158 | } 159 | } 160 | } 161 | 162 | return storage.NewS3Storage(svc, bucket, root) 163 | } 164 | 165 | func autoMigrate(db *gorm.DB) { 166 | if err := model.AutoMigrate(db); err != nil { 167 | log.Fatalf("migration failed: %v", err) 168 | } 169 | } 170 | 171 | func Main() { 172 | var ( 173 | port = getEnv("PORT", "8080") 174 | enableCors = getEnv("ENABLE_CORS", "") 175 | ) 176 | 177 | isEnableCors := enableCors != "" 178 | log.Printf("[INFO] enable CORS: %v", isEnableCors) 179 | 180 | db := createGormDB() 181 | defer db.Close() 182 | 183 | autoMigrate(db) 184 | 185 | store := createStorage() 186 | 187 | h := controller.NewHandler(db, store, isEnableCors) 188 | 189 | router := httprouter.New() 190 | router.POST("/api/book", h.AddBook) 191 | router.GET("/api/book/:bookid", h.GetBook) 192 | router.PUT("/api/book/:bookid", h.UpdateBook) 193 | router.DELETE("/api/book/:bookid", h.DeleteBook) 194 | router.GET("/api/book/:bookid/file/:ext", h.DownloadFile) 195 | router.DELETE("/api/book/:bookid/file/:ext", h.DeleteFile) 196 | router.POST("/api/book/:bookid/files", h.UploadFiles) 197 | router.GET("/api/books", h.GetBooks) 198 | router.GET("/api/mime/:ext", h.GetMime) 199 | router.GET("/api/mimes", h.GetMimes) 200 | router.GET("/opds", h.GetOPDSFeed) 201 | router.NotFound = http.FileServer(&assetfs.AssetFS{ 202 | Asset: browser.Asset, 203 | AssetDir: browser.AssetDir, 204 | AssetInfo: browser.AssetInfo, 205 | Prefix: "/dist", 206 | Fallback: "index.html", 207 | }) 208 | 209 | addr := ":" + port 210 | log.Printf("[INFO] start server %s", addr) 211 | log.Fatal(http.ListenAndServe(addr, h.CommonMiddleware(router))) 212 | } 213 | -------------------------------------------------------------------------------- /controller/book.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/altescy/bookshelf/model" 9 | "github.com/julienschmidt/httprouter" 10 | ) 11 | 12 | // AddBook add a new book into database 13 | func (h *Handler) AddBook(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 14 | book := model.Book{ 15 | ISBN: r.FormValue("ISBN"), 16 | Title: r.FormValue("Title"), 17 | Author: r.FormValue("Author"), 18 | Description: r.FormValue("Description"), 19 | CoverURL: r.FormValue("CoverURL"), 20 | Publisher: r.FormValue("Publisher"), 21 | PubDate: r.FormValue("PubDate"), 22 | Files: []model.File{}, 23 | } 24 | 25 | if err := model.AddBook(h.db, &book); err != nil { 26 | h.handleError(w, err, http.StatusInternalServerError) 27 | return 28 | } 29 | 30 | h.handleSuccess(w, book) 31 | } 32 | 33 | //DeleteBook delete specified book from DB 34 | func (h *Handler) DeleteBook(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { 35 | bookidString := ps.ByName("bookid") 36 | bookID, err := strconv.ParseUint(bookidString, 10, 64) 37 | if err != nil { 38 | h.handleError(w, errors.New("invalid bookid"), http.StatusBadRequest) 39 | return 40 | } 41 | if err := model.DeleteBook(h.db, &model.Book{ID: bookID}); err != nil { 42 | h.handleError(w, err, http.StatusInternalServerError) 43 | return 44 | } 45 | h.handleSuccess(w, "successfully deleted") 46 | } 47 | 48 | // GetBook return a book having a specified bookid 49 | func (h *Handler) GetBook(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { 50 | bookidString := ps.ByName("bookid") 51 | bookID, err := strconv.ParseUint(bookidString, 10, 64) 52 | if err != nil { 53 | h.handleError(w, errors.New("invalid bookid"), http.StatusBadRequest) 54 | return 55 | } 56 | 57 | book, err := model.GetBookByID(h.db, bookID) 58 | switch { 59 | case err == model.ErrBookNotFound: 60 | h.handleError(w, err, http.StatusNotFound) 61 | return 62 | case err != nil: 63 | h.handleError(w, err, http.StatusInternalServerError) 64 | return 65 | } 66 | 67 | h.handleSuccess(w, book) 68 | } 69 | 70 | // GetBooks returns list of books where next <= bookid < next+count 71 | func (h *Handler) GetBooks(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 72 | q := r.URL.Query() 73 | 74 | nextString := q.Get("next") 75 | next, err := strconv.ParseUint(nextString, 10, 64) 76 | if err != nil && nextString != "" { 77 | h.handleError(w, errors.New("invalid next value"), http.StatusBadRequest) 78 | return 79 | } 80 | 81 | countString := q.Get("count") 82 | count, err := strconv.ParseUint(countString, 10, 64) 83 | if err != nil && countString != "" { 84 | h.handleError(w, errors.New("invalid count value"), http.StatusBadRequest) 85 | return 86 | } 87 | 88 | respond := func(books *[]model.Book, err error) { 89 | switch { 90 | case err == model.ErrBookNotFound: 91 | h.handleError(w, err, http.StatusNotFound) 92 | return 93 | case err != nil: 94 | h.handleError(w, err, http.StatusInternalServerError) 95 | return 96 | } 97 | h.handleSuccess(w, books) 98 | } 99 | 100 | switch { 101 | case countString != "" && nextString != "": 102 | respond(model.GetBooksWithNextCount(h.db, next, count)) 103 | return 104 | case nextString != "" && countString == "": 105 | respond(model.GetBooksWithNext(h.db, next)) 106 | return 107 | case countString != "" && nextString == "": 108 | respond(model.GetBooksWithCount(h.db, count)) 109 | return 110 | default: 111 | respond(model.GetBooks(h.db)) 112 | return 113 | } 114 | } 115 | 116 | // UpdateBook update book properties 117 | func (h *Handler) UpdateBook(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { 118 | bookidString := ps.ByName("bookid") 119 | bookID, err := strconv.ParseUint(bookidString, 10, 64) 120 | if err != nil { 121 | h.handleError(w, errors.New("invalid bookid"), http.StatusBadRequest) 122 | } 123 | 124 | book, err := model.GetBookByID(h.db, bookID) 125 | switch { 126 | case err == model.ErrBookNotFound: 127 | h.handleError(w, err, http.StatusNotFound) 128 | return 129 | case err != nil: 130 | h.handleError(w, err, http.StatusInternalServerError) 131 | return 132 | } 133 | 134 | updateString := func(field string, value *string) { 135 | newValue := r.FormValue(field) 136 | if newValue != *value { 137 | *value = newValue 138 | } 139 | } 140 | 141 | updateString("ISBN", &book.ISBN) 142 | updateString("Title", &book.Title) 143 | updateString("Author", &book.Author) 144 | updateString("Description", &book.Description) 145 | updateString("CoverURL", &book.CoverURL) 146 | updateString("Publisher", &book.Publisher) 147 | updateString("PubDate", &book.PubDate) 148 | 149 | err = model.UpdateBook(h.db, book) 150 | if err != nil { 151 | h.handleError(w, err, http.StatusInternalServerError) 152 | return 153 | } 154 | 155 | book, err = model.GetBookByID(h.db, bookID) 156 | if err != nil { 157 | h.handleError(w, err, http.StatusInternalServerError) 158 | return 159 | } 160 | 161 | h.handleSuccess(w, book) 162 | } 163 | -------------------------------------------------------------------------------- /controller/file.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "strconv" 11 | 12 | "github.com/altescy/bookshelf/model" 13 | "github.com/julienschmidt/httprouter" 14 | ) 15 | 16 | func (h *Handler) DeleteFile(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { 17 | ext := "." + ps.ByName("ext") 18 | bookidString := ps.ByName("bookid") 19 | bookID, err := strconv.ParseUint(bookidString, 10, 64) 20 | if err != nil { 21 | h.handleError(w, errors.New("invalid bookid"), http.StatusBadRequest) 22 | return 23 | } 24 | 25 | mime, err := model.MimeByExt(ext) 26 | switch { 27 | case err == model.ErrMimeNotFound: 28 | h.handleError(w, err, http.StatusNotFound) 29 | return 30 | case err != nil: 31 | h.handleError(w, err, http.StatusInternalServerError) 32 | return 33 | } 34 | 35 | err = model.DeleteFile(h.db, bookID, mime) 36 | switch { 37 | case err == model.ErrFileNotFound: 38 | h.handleError(w, err, http.StatusNotFound) 39 | return 40 | case err != nil: 41 | h.handleError(w, err, http.StatusInternalServerError) 42 | return 43 | } 44 | 45 | h.handleSuccess(w, "successfully deleted") 46 | } 47 | 48 | func (h *Handler) DownloadFile(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { 49 | ext := "." + ps.ByName("ext") 50 | bookidString := ps.ByName("bookid") 51 | bookID, err := strconv.ParseUint(bookidString, 10, 64) 52 | if err != nil { 53 | h.handleError(w, errors.New("invalid bookid"), http.StatusBadRequest) 54 | return 55 | } 56 | 57 | mime, err := model.MimeByExt(ext) 58 | switch { 59 | case err == model.ErrMimeNotFound: 60 | h.handleError(w, err, http.StatusNotFound) 61 | return 62 | case err != nil: 63 | h.handleError(w, err, http.StatusInternalServerError) 64 | return 65 | } 66 | 67 | file, err := model.GetFile(h.db, bookID, mime) 68 | switch { 69 | case err == model.ErrFileNotFound: 70 | h.handleError(w, err, http.StatusNotFound) 71 | return 72 | case err != nil: 73 | h.handleError(w, err, http.StatusInternalServerError) 74 | return 75 | } 76 | 77 | w.Header().Set("Content-Type", file.MimeType) 78 | w.WriteHeader(http.StatusOK) 79 | if err := h.storage.Download(w, file.Path); err != nil { 80 | h.handleError(w, err, http.StatusInternalServerError) 81 | return 82 | } 83 | } 84 | 85 | func (h *Handler) UploadFiles(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { 86 | bookidString := ps.ByName("bookid") 87 | bookID, err := strconv.ParseUint(bookidString, 10, 64) 88 | if err != nil { 89 | h.handleError(w, errors.New("invalid bookid"), http.StatusBadRequest) 90 | return 91 | } 92 | 93 | _, err = model.GetBookByID(h.db, bookID) 94 | switch { 95 | case err == model.ErrBookNotFound: 96 | h.handleError(w, err, http.StatusNotFound) 97 | return 98 | case err != nil: 99 | h.handleError(w, err, http.StatusInternalServerError) 100 | return 101 | } 102 | 103 | reader, err := r.MultipartReader() 104 | if err != nil { 105 | h.handleError(w, err, http.StatusInternalServerError) 106 | return 107 | } 108 | 109 | results := []map[string]interface{}{} 110 | 111 | for { 112 | file := model.File{BookID: bookID} 113 | 114 | part, err := reader.NextPart() 115 | if err == io.EOF { 116 | break 117 | } 118 | 119 | filename := part.FileName() 120 | if filename == "" { 121 | continue 122 | } 123 | 124 | // set MIME type 125 | file.MimeType, err = model.MimeByFilename(filename) 126 | if err != nil { 127 | result := map[string]interface{}{ 128 | "file": filename, 129 | "status": "error", 130 | "content": err.Error(), 131 | } 132 | results = append(results, result) 133 | log.Printf("[ERROR] %+v", err) 134 | continue 135 | } 136 | 137 | // set file path 138 | mimeAlias, err := model.GetMimeAlias(file.MimeType) 139 | if err != nil { 140 | result := map[string]interface{}{ 141 | "file": filename, 142 | "status": "error", 143 | "content": err.Error(), 144 | } 145 | results = append(results, result) 146 | log.Printf("[ERROR] %+v", err) 147 | continue 148 | } 149 | file.Path = model.GenerateFilePath(bookID, mimeAlias) 150 | 151 | // read file 152 | b, err := ioutil.ReadAll(part) 153 | if err != nil { 154 | result := map[string]interface{}{ 155 | "file": filename, 156 | "status": "error", 157 | "content": err.Error(), 158 | } 159 | results = append(results, result) 160 | log.Printf("[ERROR] %+v", err) 161 | continue 162 | } 163 | 164 | // upload file to storage 165 | err = h.storage.Upload(file.Path, bytes.NewReader(b)) 166 | if err != nil { 167 | result := map[string]interface{}{ 168 | "file": filename, 169 | "status": "error", 170 | "content": err.Error(), 171 | } 172 | results = append(results, result) 173 | log.Printf("[ERROR] %+v", err) 174 | continue 175 | } 176 | 177 | // add file to database 178 | err = model.AddFile(h.db, &file) 179 | if err != nil { 180 | result := map[string]interface{}{ 181 | "file": filename, 182 | "status": "error", 183 | "content": err.Error(), 184 | } 185 | results = append(results, result) 186 | log.Printf("[ERROR] %+v", err) 187 | continue 188 | } 189 | 190 | result := map[string]interface{}{ 191 | "file": filename, 192 | "status": "ok", 193 | "content": file, 194 | } 195 | results = append(results, result) 196 | } 197 | 198 | h.handleSuccess(w, results) 199 | } 200 | -------------------------------------------------------------------------------- /controller/handler.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/altescy/bookshelf/storage" 9 | "github.com/jinzhu/gorm" 10 | ) 11 | 12 | type key int 13 | 14 | const ( 15 | keyUserID key = iota 16 | ) 17 | 18 | type Handler struct { 19 | db *gorm.DB 20 | storage storage.Storage 21 | enableCors bool 22 | } 23 | 24 | func NewHandler(db *gorm.DB, storage storage.Storage, enableCors bool) *Handler { 25 | return &Handler{ 26 | db: db, 27 | storage: storage, 28 | enableCors: enableCors, 29 | } 30 | } 31 | 32 | func (h *Handler) CommonMiddleware(f http.Handler) http.Handler { 33 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 34 | if r.Method == http.MethodPost { 35 | if err := r.ParseForm(); err != nil { 36 | h.handleError(w, err, http.StatusBadRequest) 37 | return 38 | } 39 | } 40 | if h.enableCors { 41 | enableCors(&w) 42 | } 43 | f.ServeHTTP(w, r) 44 | }) 45 | } 46 | 47 | func (h *Handler) handleSuccess(w http.ResponseWriter, data interface{}) { 48 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 49 | w.WriteHeader(http.StatusOK) 50 | if err := json.NewEncoder(w).Encode(data); err != nil { 51 | log.Printf("[WARN] write response json failed. %s", err) 52 | } 53 | } 54 | 55 | func (h *Handler) handleError(w http.ResponseWriter, err error, code int) { 56 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 57 | w.Header().Set("X-Content-Type-Options", "nosniff") 58 | w.WriteHeader(code) 59 | log.Printf("[WARN] err: %s", err.Error()) 60 | data := map[string]interface{}{ 61 | "code": code, 62 | "err": err.Error(), 63 | } 64 | if err := json.NewEncoder(w).Encode(data); err != nil { 65 | log.Printf("[WARN] write error response json failed. %s", err) 66 | } 67 | } 68 | 69 | func enableCors(w *http.ResponseWriter) { 70 | (*w).Header().Set("Access-Control-Allow-Origin", "*") 71 | } 72 | -------------------------------------------------------------------------------- /controller/mimes.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/altescy/bookshelf/model" 7 | "github.com/julienschmidt/httprouter" 8 | ) 9 | 10 | func (h *Handler) GetMime(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { 11 | ext := "." + ps.ByName("ext") 12 | mime, err := model.MimeByExt(ext) 13 | switch { 14 | case err == model.ErrMimeNotFound: 15 | h.handleError(w, err, http.StatusNotFound) 16 | return 17 | case err != nil: 18 | h.handleError(w, err, http.StatusInternalServerError) 19 | } 20 | h.handleSuccess(w, mime) 21 | } 22 | 23 | func (h *Handler) GetMimes(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 24 | mimes := model.GetMimes() 25 | h.handleSuccess(w, mimes) 26 | } 27 | -------------------------------------------------------------------------------- /controller/opds.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "encoding/xml" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | 10 | "github.com/altescy/bookshelf/model" 11 | "github.com/altescy/bookshelf/opds" 12 | "github.com/julienschmidt/httprouter" 13 | ) 14 | 15 | const opdsTitle = "Bookshelf - OPDS" 16 | 17 | func (h *Handler) GetOPDSFeed(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { 18 | books, err := model.GetBooks(h.db) 19 | if err != nil { 20 | h.handleError(w, err, http.StatusInternalServerError) 21 | return 22 | } 23 | 24 | opdsURL := "http://" + r.Host + r.URL.Path 25 | href := r.URL.Path 26 | 27 | for i, book := range *books { 28 | for j, file := range book.Files { 29 | alias, _ := model.GetMimeAlias(file.MimeType) 30 | (*books)[i].Files[j].Link = fmt.Sprintf("/api/book/%d/file/%s", book.ID, alias) 31 | } 32 | } 33 | 34 | entries := model.EntriesFromBooks(books) 35 | feed := opds.BuildFeed(opdsURL, opdsTitle, href, entries) 36 | 37 | enc := xml.NewEncoder(w) 38 | 39 | w.Header().Set("Content-Type", "application/xml") 40 | fmt.Fprint(w, xml.Header) 41 | err = enc.Encode(&feed) 42 | if err != nil { 43 | log.Printf("[error] cannot encode xml feed: %v", err) 44 | if err != io.ErrClosedPipe { 45 | h.handleError(w, err, http.StatusInternalServerError) 46 | return 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /data/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | waitfordb: 4 | image: dadarek/wait-for-dependencies 5 | depends_on: 6 | - postgres 7 | command: postgres:5432 8 | 9 | waitforminio: 10 | image: dadarek/wait-for-dependencies 11 | depends_on: 12 | - minio 13 | command: minio:${MINIO_PORT} 14 | 15 | postgres: 16 | image: postgres:12.3-alpine 17 | environment: 18 | TZ : ${TZ} 19 | POSTGRES_USER : 'user' 20 | POSTGRES_PASSWORD : 'password' 21 | POSTGRES_DB : "bookshelf" 22 | POSTGRES_INITDB_ARGS: "--encoding=UTF-8" 23 | ports: 24 | - "${POSTGRES_PORT}:5432" 25 | volumes: 26 | - postgres:/var/lib/postgresql/data 27 | - ./sql:/docker-entrypoint-initdb.d 28 | hostname: postgres 29 | restart: always 30 | 31 | minio: 32 | image: minio/minio 33 | environment: 34 | MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY} 35 | MINIO_SECRET_KEY: ${MINIO_SECRET_KEY} 36 | command: 37 | - "server" 38 | - "--address" 39 | - "${MINIO_HOST}:80" 40 | - "/data" 41 | ports: 42 | - "${MINIO_PORT}:80" 43 | volumes: 44 | - minio:/data 45 | hostname: minio 46 | restart: always 47 | 48 | api: 49 | build: . 50 | depends_on: 51 | - postgres 52 | - minio 53 | - waitfordb 54 | - waitforminio 55 | ports: 56 | - "${BOOKSHELF_PORT}:80" 57 | environment: 58 | BOOKSHELF_PORT : 80 59 | BOOKSHELF_STORAGE_URL : ${BOOKSHELF_STORAGE_URL} 60 | BOOKSHELF_CREATE_NEW_STORAGE : ${BOOKSHELF_CREATE_NEW_STORAGE} 61 | BOOKSHELF_DB_URL : ${BOOKSHELF_DB_URL} 62 | BOOKSHELF_AWS_ACCESS_KEY_ID : ${BOOKSHELF_AWS_ACCESS_KEY_ID} 63 | BOOKSHELF_AWS_SECRET_ACCESS_KEY: ${BOOKSHELF_AWS_SECRET_ACCESS_KEY} 64 | BOOKSHELF_AWS_S3_REGION : ${BOOKSHELF_AWS_S3_REGION} 65 | BOOKSHELF_AWS_S3_ENDPOINT_URL : ${BOOKSHELF_AWS_S3_ENDPOINT_URL} 66 | BOOKSHELF_ENABLE_CORS : ${BOOKSHELF_ENABLE_CORS} 67 | TZ : ${TZ} 68 | hostname: api 69 | restart: always 70 | 71 | volumes: 72 | postgres: 73 | minio: 74 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/altescy/bookshelf 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go v1.34.7 7 | github.com/elazarl/go-bindata-assetfs v1.0.1 8 | github.com/google/uuid v1.1.1 9 | github.com/jinzhu/gorm v1.9.16 10 | github.com/julienschmidt/httprouter v1.3.0 11 | github.com/lib/pq v1.8.0 12 | github.com/mattn/go-sqlite3 v1.14.0 13 | github.com/oklog/ulid v1.3.1 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= 2 | github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= 3 | github.com/aws/aws-sdk-go v1.34.7 h1:74UoHD376AS93rcGRr2Ec6hG/mTJEKT9373xiGijWzI= 4 | github.com/aws/aws-sdk-go v1.34.7/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= 7 | github.com/elazarl/go-bindata-assetfs v1.0.1 h1:m0kkaHRKEu7tUIUFVwhGGGYClXvyl4RE03qmvRTNfbw= 8 | github.com/elazarl/go-bindata-assetfs v1.0.1/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4= 9 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= 10 | github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 11 | github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= 12 | github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= 13 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 14 | github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o= 15 | github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs= 16 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 17 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 18 | github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 19 | github.com/jmespath/go-jmespath v0.3.0 h1:OS12ieG61fsCg5+qLJ+SsW9NicxNkg3b25OyT2yCeUc= 20 | github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= 21 | github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= 22 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 23 | github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 24 | github.com/lib/pq v1.8.0 h1:9xohqzkUwzR4Ga4ivdTcawVS89YSDVxXMa3xJX3cGzg= 25 | github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 26 | github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/QA= 27 | github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= 28 | github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= 29 | github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= 30 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 31 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 32 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 33 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 34 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 35 | golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 36 | golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 37 | golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 38 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 39 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 40 | golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 41 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 42 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 43 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 44 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 45 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 46 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 47 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import bookshelf "github.com/altescy/bookshelf/cmd" 4 | 5 | func main() { 6 | bookshelf.Main() 7 | } 8 | -------------------------------------------------------------------------------- /model/book.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | "github.com/jinzhu/gorm" 8 | "github.com/lib/pq" 9 | ) 10 | 11 | type Book struct { 12 | ID uint64 `json:"ID" gorm:"primary_key"` 13 | CreatedAt time.Time `json:"CreatedAt"` 14 | UpdatedAt time.Time `json:"UpdatedAt"` 15 | DeletedAt *time.Time `json:"-" sql:"index"` 16 | UUID string `json:"UUID" gorm:"not null"` 17 | ISBN string `json:"ISBN"` 18 | Title string `json:"Title"` 19 | Author string `json:"Author"` 20 | Description string `json:"Description"` 21 | CoverURL string `json:"CoverURL"` 22 | Publisher string `json:"Publisher"` 23 | PubDate string `json:"PubDate"` 24 | Files []File `json:"Files"` 25 | } 26 | 27 | func AddBook(db *gorm.DB, book *Book) error { 28 | uid, err := uuid.NewRandom() 29 | if err != nil { 30 | return err 31 | } 32 | book.UUID = uid.String() 33 | return db.Transaction(func(tx *gorm.DB) error { 34 | return handleBookError(tx.Save(book).Error) 35 | }) 36 | } 37 | 38 | func DeleteBook(db *gorm.DB, book *Book) error { 39 | return db.Transaction(func(tx *gorm.DB) error { 40 | return handleBookError(tx.Delete(book).Error) 41 | }) 42 | } 43 | 44 | func GetBookByID(db *gorm.DB, bookID uint64) (*Book, error) { 45 | book := Book{} 46 | if err := db.Preload("Files").First(&book, bookID).Error; err != nil { 47 | return nil, handleBookError(err) 48 | } 49 | return &book, nil 50 | } 51 | 52 | func GetBooks(db *gorm.DB) (*[]Book, error) { 53 | books := []Book{} 54 | err := db.Preload("Files").Order("updated_at desc").Find(&books).Error 55 | if err != nil { 56 | return nil, handleBookError(err) 57 | } 58 | return &books, nil 59 | } 60 | 61 | func GetBooksWithCount(db *gorm.DB, count uint64) (*[]Book, error) { 62 | books := []Book{} 63 | if err := db.Preload("Files").Limit(count).Find(&books).Error; err != nil { 64 | return nil, handleBookError(err) 65 | } 66 | return &books, nil 67 | } 68 | 69 | func GetBooksWithNext(db *gorm.DB, next uint64) (*[]Book, error) { 70 | books := []Book{} 71 | if err := db.Preload("Files").Where("id > ?", next).Find(&books).Error; err != nil { 72 | return nil, handleBookError(err) 73 | } 74 | return &books, nil 75 | } 76 | 77 | func GetBooksWithNextCount(db *gorm.DB, next, count uint64) (*[]Book, error) { 78 | books := []Book{} 79 | if err := db.Preload("Files").Where("id > ?", next).Limit(count).Find(&books).Error; err != nil { 80 | return nil, handleBookError(err) 81 | } 82 | return &books, nil 83 | } 84 | 85 | func UpdateBook(db *gorm.DB, book *Book) error { 86 | return db.Transaction(func(tx *gorm.DB) error { 87 | if err := db.Save(book).Error; err != nil { 88 | return handleBookError(err) 89 | } 90 | return nil 91 | }) 92 | } 93 | 94 | func handleBookError(err error) error { 95 | if pgError, ok := err.(*pq.Error); ok { 96 | switch pgError.Code { 97 | case "23505": 98 | return ErrBookConflict 99 | } 100 | } 101 | 102 | switch { 103 | case gorm.IsRecordNotFoundError(err): 104 | return ErrBookNotFound 105 | default: 106 | return err 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /model/file.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "time" 7 | 8 | "github.com/jinzhu/gorm" 9 | "github.com/oklog/ulid" 10 | ) 11 | 12 | type File struct { 13 | ID uint64 `json:"ID"` 14 | CreatedAt time.Time `json:"CreatedAt"` 15 | UpdatedAt time.Time `json:"UpdatedAt"` 16 | DeletedAt *time.Time `json:"-" sql:"index"` 17 | BookID uint64 `json:"BookID"` 18 | MimeType string `json:"MimeType"` 19 | Path string `json:"-"` 20 | Link string `json:"Link" gorm:"-"` 21 | } 22 | 23 | func AddFile(db *gorm.DB, file *File) error { 24 | // check the same MimeType existence 25 | err := db.Take(&File{}, "book_id=? and mime_type=?", file.BookID, file.MimeType).Error 26 | switch { 27 | case err == nil: 28 | return ErrFileConflict 29 | case !gorm.IsRecordNotFoundError(err): 30 | return err 31 | } 32 | 33 | // add file to database 34 | return db.Transaction(func(tx *gorm.DB) error { 35 | return handleFileError(tx.Save(&file).Error) 36 | }) 37 | } 38 | 39 | func DeleteFile(db *gorm.DB, bookID uint64, mime string) error { 40 | return db.Transaction(func(tx *gorm.DB) error { 41 | err := db.Delete(File{}, "book_id=? and mime_type=?", bookID, mime).Error 42 | return handleFileError(err) 43 | }) 44 | } 45 | 46 | func GetFile(db *gorm.DB, bookID uint64, mime string) (*File, error) { 47 | file := File{} 48 | err := db.Last(&file, "book_id=? and mime_type=?", bookID, mime).Error 49 | switch { 50 | case gorm.IsRecordNotFoundError(err): 51 | return nil, ErrFileNotFound 52 | case err != nil: 53 | return nil, err 54 | } 55 | return &file, nil 56 | } 57 | 58 | func GenerateFilePath(bookID uint64, mimeAlias string) string { 59 | filename := generateULID() 60 | path := fmt.Sprintf("%d/%s/%s", bookID, mimeAlias, filename) 61 | return path 62 | } 63 | 64 | func generateULID() string { 65 | t := time.Now() 66 | entropy := ulid.Monotonic(rand.New(rand.NewSource(t.UnixNano())), 0) 67 | id := ulid.MustNew(ulid.Timestamp(t), entropy) 68 | return id.String() 69 | } 70 | 71 | func handleFileError(err error) error { 72 | switch { 73 | case gorm.IsRecordNotFoundError(err): 74 | return ErrFileNotFound 75 | default: 76 | return err 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /model/migration.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "github.com/jinzhu/gorm" 4 | 5 | func AutoMigrate(db *gorm.DB) (err error) { 6 | err = db.AutoMigrate(&Book{}).AutoMigrate(&File{}).Error 7 | return 8 | } 9 | -------------------------------------------------------------------------------- /model/mime.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "mime" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | var extToMime = map[string]string{ 10 | ".azw3": "application/x-mobi8-ebook", 11 | ".epub": "application/epub+zip", 12 | ".fb2": "application/fb2+zip", 13 | ".mobi": "application/x-mobipocket-ebook", 14 | ".pdf": "application/pdf", 15 | ".txt": "text/plain", 16 | } 17 | 18 | var MimeAlias = map[string]string{ 19 | "application/x-mobi8-ebook": "azw3", 20 | "application/epub+zip": "epub", 21 | "application/fb2+zip": "fb2", 22 | "application/x-mobipocket-ebook": "mobi", 23 | "application/pdf": "pdf", 24 | "text/plain": "txt", 25 | } 26 | 27 | func GetMimeAlias(mime string) (string, error) { 28 | alias := MimeAlias[mime] 29 | if alias == "" { 30 | return "", ErrMimeNotFound 31 | } 32 | return alias, nil 33 | 34 | } 35 | 36 | func GetMimeAliasByFilename(filename string) (string, error) { 37 | mime, err := MimeByFilename(filename) 38 | if err != nil { 39 | return "", nil 40 | } 41 | 42 | mimeAlias, err := GetMimeAlias(mime) 43 | if err != nil { 44 | return "", nil 45 | } 46 | 47 | return mimeAlias, nil 48 | } 49 | 50 | func GetMimes() map[string]string { 51 | return copyMimes(extToMime) 52 | } 53 | 54 | func MimeByExt(ext string) (string, error) { 55 | mime := extToMime[ext] 56 | if mime == "" { 57 | return "", ErrMimeNotFound 58 | } 59 | return mime, nil 60 | } 61 | 62 | func MimeByFilename(filename string) (string, error) { 63 | ext := strings.ToLower(filepath.Ext(filename)) 64 | 65 | mimeType, ok := extToMime[ext] 66 | if ok { 67 | return mimeType, nil 68 | } 69 | 70 | mimeType = mime.TypeByExtension(ext) 71 | if mimeType == "" { 72 | return "", ErrInvalidExt 73 | } 74 | // The mimeType returned by TypeByExtension may contain 75 | // some arguments like "text/plain; charset=utf-8". But 76 | // in this statement, we ignore such arguments. 77 | mimeType = strings.Split(mimeType, ";")[0] 78 | 79 | return mimeType, nil 80 | } 81 | 82 | func copyMimes(m map[string]string) map[string]string { 83 | cp := map[string]string{} 84 | for k, v := range m { 85 | cp[k] = v 86 | } 87 | return cp 88 | } 89 | -------------------------------------------------------------------------------- /model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrBookConflict = errors.New("book conflict") 7 | ErrBookNotFound = errors.New("book not found") 8 | ErrMimeNotFound = errors.New("mime not found") 9 | ErrFileConflict = errors.New("file conflict") 10 | ErrFileNotFound = errors.New("file not found") 11 | ErrInvalidExt = errors.New("invalid ext") 12 | ) 13 | -------------------------------------------------------------------------------- /model/opds.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/altescy/bookshelf/opds" 5 | ) 6 | 7 | func EntriesFromBooks(books *[]Book) []opds.Entry { 8 | entries := make([]opds.Entry, 0, len(*books)) 9 | 10 | for _, book := range *books { 11 | author := opds.Author{Name: book.Author} 12 | summary := opds.Summary{Type: "text", Text: book.Description} 13 | coverType, _ := MimeByFilename(book.CoverURL) 14 | links := []opds.Link{ 15 | {Href: book.CoverURL, Type: coverType, Rel: opds.CoverRel}, 16 | } 17 | for _, file := range book.Files { 18 | link := opds.Link{ 19 | Href: file.Link, 20 | Type: file.MimeType, 21 | Rel: opds.FileRel, 22 | } 23 | links = append(links, link) 24 | } 25 | entry := opds.Entry{ 26 | ID: "urn:uuid:" + book.UUID, 27 | Updated: book.UpdatedAt.UTC().Format(opds.AtomTime), 28 | Title: book.Title, 29 | Author: author, 30 | Summary: summary, 31 | Link: links, 32 | } 33 | 34 | entries = append(entries, entry) 35 | } 36 | return entries 37 | } 38 | -------------------------------------------------------------------------------- /opds/opds.go: -------------------------------------------------------------------------------- 1 | package opds 2 | 3 | import ( 4 | "encoding/xml" 5 | "time" 6 | ) 7 | 8 | const ( 9 | AtomTime = "2006-01-02T15:04:05Z" 10 | DirMime = "application/atom+xml;profile=opds-catalog;kind=navigation" 11 | DirRel = "subsection" 12 | FileRel = "http://opds-spec.org/acquisition" 13 | CoverRel = "http://opds-spec.org/cover" 14 | ) 15 | 16 | // Feed is a main frame of OPDS. 17 | type Feed struct { 18 | XMLName xml.Name `xml:"feed"` 19 | ID string `xml:"id"` 20 | Title string `xml:"title"` 21 | Xmlns string `xml:"xmlns,attr"` 22 | Updated string `xml:"updated"` 23 | Link []Link `xml:"link"` 24 | Entry []Entry `xml:"entry"` 25 | } 26 | 27 | // Link is link properties. 28 | type Link struct { 29 | Href string `xml:"href,attr"` 30 | Type string `xml:"type,attr"` 31 | Rel string `xml:"rel,attr,ommitempty"` 32 | } 33 | 34 | // Entry is a struct of OPDS entry properties. 35 | type Entry struct { 36 | ID string `xml:"id"` 37 | Updated string `xml:"updated"` 38 | Title string `xml:"title"` 39 | Author Author `xml:"author"` 40 | Summary Summary `xml:"summary"` 41 | Link []Link `xml:"link"` 42 | } 43 | 44 | type Author struct { 45 | Name string `xml:"name"` 46 | } 47 | 48 | type Summary struct { 49 | Type string `xml:"type,attr"` 50 | Text string `xml:",chardata"` 51 | } 52 | 53 | func BuildFeed(id, title, href string, entries []Entry) *Feed { 54 | return &Feed{ 55 | ID: id, 56 | Title: title, 57 | Xmlns: "http://www.w3.org/2005/Atom", 58 | Updated: time.Now().UTC().Format(AtomTime), 59 | Link: []Link{ 60 | { 61 | Href: href, 62 | Type: DirMime, 63 | Rel: "start", 64 | }, 65 | { 66 | Href: href, 67 | Type: DirMime, 68 | Rel: "self", 69 | }, 70 | }, 71 | Entry: entries, 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /sql/00_crreate_database.sql: -------------------------------------------------------------------------------- 1 | create database if not exists bookshelf default character set utf8mb4; 2 | -------------------------------------------------------------------------------- /storage/filesystem.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | type FileSystemStorage struct { 10 | root string 11 | perm os.FileMode 12 | } 13 | 14 | func NewFileSystemStorage(root string, perm os.FileMode) *FileSystemStorage { 15 | return &FileSystemStorage{root: root, perm: perm} 16 | } 17 | 18 | func (s *FileSystemStorage) Upload(path string, body io.ReadSeeker) (err error) { 19 | path = filepath.Join(s.root, path) 20 | 21 | dir := filepath.Dir(path) 22 | err = os.MkdirAll(dir, s.perm) 23 | if err != nil { 24 | return 25 | } 26 | 27 | f, err := os.Create(path) 28 | if err != nil { 29 | return 30 | } 31 | 32 | defer f.Close() 33 | 34 | _, err = io.Copy(f, body) 35 | return 36 | } 37 | 38 | func (s *FileSystemStorage) Download(w io.Writer, path string) (err error) { 39 | path = filepath.Join(s.root, path) 40 | 41 | f, err := os.Open(path) 42 | if err != nil { 43 | return 44 | } 45 | 46 | defer f.Close() 47 | 48 | _, err = io.Copy(w, f) 49 | return 50 | } 51 | -------------------------------------------------------------------------------- /storage/s3.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "io" 5 | "path/filepath" 6 | 7 | "github.com/aws/aws-sdk-go/aws" 8 | "github.com/aws/aws-sdk-go/service/s3" 9 | ) 10 | 11 | type S3Storage struct { 12 | client *s3.S3 13 | bucket string 14 | root string 15 | } 16 | 17 | func NewS3Storage(client *s3.S3, bucket, root string) *S3Storage { 18 | return &S3Storage{ 19 | client: client, 20 | bucket: bucket, 21 | root: root, 22 | } 23 | } 24 | 25 | func (s *S3Storage) Upload(path string, body io.ReadSeeker) error { 26 | key := filepath.Join(s.root, path) 27 | _, err := s.client.PutObject(&s3.PutObjectInput{ 28 | Bucket: aws.String(s.bucket), 29 | Key: aws.String(key), 30 | Body: body, 31 | }) 32 | return err 33 | } 34 | 35 | func (s *S3Storage) Download(w io.Writer, path string) (err error) { 36 | key := filepath.Join(s.root, path) 37 | resp, err := s.client.GetObject(&s3.GetObjectInput{ 38 | Bucket: aws.String(s.bucket), 39 | Key: aws.String(key), 40 | }) 41 | if err != nil { 42 | return 43 | } 44 | 45 | defer resp.Body.Close() 46 | 47 | _, err = io.Copy(w, resp.Body) 48 | return 49 | } 50 | -------------------------------------------------------------------------------- /storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import "io" 4 | 5 | type Storage interface { 6 | Upload(path string, body io.ReadSeeker) error 7 | Download(w io.Writer, path string) error 8 | } 9 | --------------------------------------------------------------------------------