├── .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 | [](https://github.com/altescy/bookshelf/actions?query=workflow%3Abuild)
5 | [](https://github.com/altescy/bookshelf/blob/master/LICENSE)
6 | [](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 | 
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
17 | mdi-plus
18 |
19 |
24 |
25 |
26 |
27 |
28 |
29 |
34 |
35 |
36 |
37 |
38 |
84 |
--------------------------------------------------------------------------------
/browser/src/components/AlertMessage.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 | {{ msg.message }}
12 |
13 |
14 |
15 |
16 |
41 |
--------------------------------------------------------------------------------
/browser/src/components/BookEditDialog.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Edit Book
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | Delete
17 |
18 | Update
23 | Close
27 |
28 |
32 |
33 |
34 | Delete this book?
35 |
36 |
37 | Are you sure you want to delete this book?
38 |
39 |
40 | OK
45 | Cancel
49 |
50 |
51 |
52 |
53 |
54 |
55 |
129 |
--------------------------------------------------------------------------------
/browser/src/components/BookEditor.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
12 |
13 |
14 | auto complete
17 |
18 |
19 |
20 |
21 |
26 |
27 |
28 |
33 |
34 |
35 |
40 |
41 |
42 |
48 |
49 |
50 |
55 |
56 |
57 |
63 |
64 |
65 | mdi-file-document-multiple-outline
68 |
76 |
77 |
78 |
85 |
86 |
87 |
88 |
89 |
90 |
131 |
--------------------------------------------------------------------------------
/browser/src/components/BookList.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
12 |
13 |
14 | {{ book.Author }}
15 |
19 |
20 | mdi-pencil
21 |
22 |
23 |
24 |
25 | {{ book.Title }}
26 | {{ book.Description }}
27 |
28 |
29 |
30 |
31 |
38 |
39 |
40 |
41 |
42 |
43 |
90 |
--------------------------------------------------------------------------------
/browser/src/components/BookRegistrationDialog.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Register Book
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | Register
18 | Cancel
22 |
23 |
24 |
25 |
26 |
79 |
--------------------------------------------------------------------------------
/browser/src/components/FileIcon.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 | mdi-close
13 | {{ getAlias(file.MimeType) }}
14 |
15 |
16 |
20 |
21 |
22 | Delete this file?
23 |
24 |
25 | Are you sure you want to delete {{ getAlias(file.MimeType) }} file?
26 |
27 |
28 | OK
33 | Cancel
37 |
38 |
39 |
40 |
41 |
42 |
43 |
103 |
--------------------------------------------------------------------------------
/browser/src/components/Header.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
12 | mdi-bookshelf
13 |
14 |
15 |
19 | Bookshelf
20 |
21 |
22 |
31 |
32 |
33 |
34 |
35 |
36 | mdi-rss
37 |
38 |
39 |
40 |
41 |
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 |
2 |
3 |
This is an about page
4 |
5 |
6 |
--------------------------------------------------------------------------------
/browser/src/views/Home.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
--------------------------------------------------------------------------------