Compare commits

...

106 Commits

Author SHA1 Message Date
Steven 12a48ae2db chore: release v0.4.2 2022-09-02 20:20:00 +08:00
Steven bcb684d1cc chore: fix daily memo style 2022-09-02 20:13:56 +08:00
Steven d3b26f7126 chore: update about dialog 2022-09-02 20:12:13 +08:00
boojack 422e190c96 chore: support uploads multi files (#191) 2022-09-02 20:05:40 +08:00
Steven 3e13fa1ce6 chore: update marked helper 2022-09-02 09:07:32 +08:00
boojack dc9f531447 fix: find latest migration history (#190)
* fix: auth action button

* fix: find latest migration history
2022-09-02 00:01:08 +08:00
Steven e330159f55 chore: update resources file format 2022-09-01 07:36:14 +08:00
boojack b88846fff5 chore: update readme 2022-08-31 21:55:18 +08:00
Steven 93b6a910ae chore: update logo resources 2022-08-31 21:51:49 +08:00
boojack 8d161b4526 fix: create shortcut input (#186)
* fix: create shortcut input

* chore: add yarn build
2022-08-31 20:21:56 +08:00
boojack b6acf62aab chore: add eslint check in action (#185)
* chore: add eslint check in action

* chore: update cache path
2022-08-31 19:27:17 +08:00
Steven 2a11aed881 chore: update dialog event listener 2022-08-30 07:41:28 +08:00
zburu e7b287902b chore: update mobile style (#181) 2022-08-29 20:03:07 +08:00
TheNexter c012ce0481 Simple docker-compose.yml (#179)
* Create docker-compose.yml

* Add docker compose in readme.md

* Good service name in docker-compose.yml

* Update docker-compose.yml

Change 2.1 to 3.0 version + path

* Update docker-compose.yml

Change 2.1 to 3.0 version + path

* Update readme

Change docker-compose to 2.0 + path

* Update README.md

* Update README.md

Co-authored-by: boojack <imrealleonardo@gmail.com>
2022-08-29 19:59:24 +08:00
steven c97399a8ea fix: update isFetching default value 2022-08-28 00:30:26 +08:00
steven 75d622f4a2 chore: update version v0.4.1 2022-08-27 08:58:13 +08:00
steven 5bdf7654fc chore: update detail styles 2022-08-27 08:57:29 +08:00
steven 62657f7f4e chore: update build folder 2022-08-27 08:57:05 +08:00
Steven 64332c3e6a chore: update tag regexp 2022-08-25 23:58:03 +08:00
Steven 57f51d1c58 feat: allow updating memo createdTs 2022-08-25 22:02:32 +08:00
Steven 9f3f730723 chore: update selector style 2022-08-25 21:05:31 +08:00
boojack e9d303326f feat: set editor font style (#174)
feat: editor font style
2022-08-25 20:44:32 +08:00
Steven 20d7112a05 chore: update config files 2022-08-25 20:44:17 +08:00
Steven 922cc21abc chore: update seed data 2022-08-25 19:53:30 +08:00
boojack cdbd934c4e fix: tag regexp (#173) 2022-08-25 19:24:21 +08:00
Steven ca1170583e chore: update i18n 2022-08-24 22:27:19 +08:00
Steven f5629c8227 fix: overflow style 2022-08-24 22:14:46 +08:00
Steven 2f58032ad8 fix: text overflow style 2022-08-24 22:11:56 +08:00
boojack 466bfe4b49 chore: update golangci-lint config (#168)
chore: update ci lint
2022-08-24 22:03:07 +08:00
Steven 7d0407013e chore: make golangci-lint happy 2022-08-24 21:53:12 +08:00
Steven 0e4e2e4bc5 chore: add golangci-lint action 2022-08-24 21:12:08 +08:00
Steven a8a3cf31b4 chore: make golangci-lint happy 2022-08-24 20:40:56 +08:00
boojack f784516470 chore: update word-break style (#164) 2022-08-23 23:23:20 +08:00
Steven 2aed7c70aa chore: update cancel editing button 2022-08-23 21:33:53 +08:00
Lim Ding Wen b1062f5387 fix: Fix Typo (Editting -> Editing) (#161) 2022-08-23 07:50:57 +08:00
Steven 29c835d27a chore: update readme 2022-08-22 20:52:54 +08:00
Steven 0698c9c853 chore: set default flags in dockerfile 2022-08-22 20:52:30 +08:00
boojack e54ff5ec9e fix: unescape filename (#158) 2022-08-22 20:26:47 +08:00
Steven f0a23f4620 fix: generate uuid when creating new user 2022-08-21 19:10:02 +08:00
Steven c60bb12424 chore: update user setting validator 2022-08-20 21:51:28 +08:00
Steven 3b1bb4a95d chore: disable setting memo visibility when creating 2022-08-20 21:22:36 +08:00
Steven 05a5c59a7e chore: update user create validator 2022-08-20 21:03:15 +08:00
Steven 734d5f3aed chore: update create tag tip style 2022-08-20 11:48:56 +08:00
Steven 2cf07753e7 chore: update version 0.4.0 2022-08-20 11:37:19 +08:00
Steven 2935057ca7 chore: update i18n 2022-08-20 11:36:33 +08:00
Steven 68b30063a9 chore: update prod schema 2022-08-20 11:36:24 +08:00
Steven f06a3d171b chore: update error message handler 2022-08-20 07:34:39 +08:00
Steven a98e64cf0a chore: update i18n dialog 2022-08-19 22:12:24 +08:00
Steven dd04bc9e1d chore: add beta badge 2022-08-19 22:08:47 +08:00
Steven 2f33eceada chore: set default memo visibility 2022-08-19 21:59:50 +08:00
Steven d5b88775d9 chore: update user setting keys 2022-08-19 21:56:22 +08:00
Steven a7a01df79a chore: update i18n 2022-08-19 21:30:31 +08:00
Steven e3fac742c5 chore: update logger 2022-08-19 00:45:02 +08:00
Steven ce390f3f79 chore: add memo visibility with user setting 2022-08-19 00:00:47 +08:00
Steven 43a8b7d0e1 chore: fix lint 2022-08-14 18:44:20 +08:00
Steven b596d04939 chore: update i18n for auth page 2022-08-14 18:43:46 +08:00
Steven 84a3548232 chore: update scripts 2022-08-14 17:55:36 +08:00
Steven 63388a7d97 chore: update .gitignore 2022-08-14 16:44:00 +08:00
Steven 35f980d2b8 chore: go mod tidy 2022-08-14 16:35:09 +08:00
boojack 90b881502d feat: add user_setting model (#145)
* feat: add `user_setting` model

* chore: add global store

* chore: update settings in web

* chore: update `i18n` example
2022-08-13 14:35:33 +08:00
XQ dfac877957 feat: add manifest.json for pwa (#144)
* chore: update `i18nStore`

* feat: add pwa `manifest.json`
2022-08-12 21:30:59 +08:00
leopold 87f5ac8b71 chore: add docker-compose.yaml (#142)
Adding docker-compose.yaml
2022-08-11 20:15:46 +08:00
XQ 3c1a416afc chore: update i18nStore (#141) 2022-08-09 21:57:56 +08:00
XQ 972a49d6aa chore: code clean (#140) 2022-08-09 21:36:48 +08:00
XQ cea16fac88 chore: remove useAppContext in dialog props 2022-08-08 23:33:53 +08:00
boojack 7e994c8f11 chore: remove test action 2022-08-08 23:32:56 +08:00
boojack 646a41e931 chore: add i18n based with useContext 2022-08-07 22:48:22 +08:00
boojack 735938395b chore: use transaction for migration history 2022-08-07 10:17:56 +08:00
boojack d8e10ba399 chore: use tx for stores 2022-08-07 10:17:12 +08:00
boojack 8c28721839 chore: use tx for user store 2022-08-07 09:23:46 +08:00
boojack da333b0b1e chore: add store cache service 2022-08-07 08:09:43 +08:00
boojack 44d07ac401 chore: hide resource blob field 2022-08-07 07:07:04 +08:00
boojack d08ba56c28 chore: release v0.3.1 (#138) 2022-08-07 02:20:39 +08:00
boojack c991a48df6 chore: add upload resource button 2022-08-07 01:56:10 +08:00
boojack fd44255668 chore: use dropdown in member section 2022-08-07 01:35:20 +08:00
boojack 84564891be feat: add view resource dialog 2022-08-07 01:30:48 +08:00
boojack 8c8bb9e59f chore: update search bar styles 2022-07-31 09:10:30 +08:00
boojack 99df4acfe9 chore: use cors middleware 2022-07-30 14:52:37 +08:00
boojack 47ffd99c3b chore: fix typo 2022-07-30 07:47:18 +08:00
boojack c703f281d9 chore: update feather icon 2022-07-30 00:29:20 +08:00
boojack e179c65e52 chore: fix typo 2022-07-29 21:41:56 +08:00
boojack 29da70be56 chore: update readme 2022-07-29 21:39:52 +08:00
boojack a9dce26099 chore: remove build dev image 2022-07-29 21:26:08 +08:00
boojack 2c27f5d425 chore: release v0.3.0 (#136) 2022-07-29 21:09:48 +08:00
boojack df7b4d54c6 chore: show inline image in daily review dialog (#135) 2022-07-29 20:11:14 +08:00
boojack 9994b1fabc chore: update member setting styles 2022-07-29 19:52:16 +08:00
boojack 2d093d5be0 chore: update daily review dialog style 2022-07-28 23:38:09 +08:00
boojack 12b373701b chore: fix shortcut list buttons style 2022-07-28 21:08:18 +08:00
boojack 2b8078a19b chore: add CommonDialog 2022-07-28 20:19:14 +08:00
boojack 5617118fa8 fix: acl middleware 2022-07-28 20:09:25 +08:00
boojack fa93d0fd6e chore: update visibility selector style 2022-07-27 20:02:00 +08:00
boojack dc436490f8 chore: update build.sh 2022-07-27 19:47:13 +08:00
boojack d83f204d8c chore: update acl middleware 2022-07-27 19:45:37 +08:00
boojack 873973a088 chore: update favicon 2022-07-26 22:40:29 +08:00
boojack d371cfd78d chore: update member list action buttons 2022-07-26 22:36:24 +08:00
boojack 7b1bad5b29 feat: update delete user api 2022-07-26 22:32:26 +08:00
boojack 0c2adfa1d2 feat: add delete user api 2022-07-26 21:41:20 +08:00
boojack 07d9649b22 chore: add visibility selector 2022-07-26 21:24:52 +08:00
boojack b7339e00ba feat: update finding memo with visibility 2022-07-26 21:12:20 +08:00
boojack 58e68f8f80 chore: update signin button in visitor mode 2022-07-25 21:50:25 +08:00
boojack cfa4151cff chore: update migration folder 2022-07-25 21:17:46 +08:00
boojack 3d33b5d564 chore: update memo visibility field (#132)
chore: update `memo` visibility field in schema
2022-07-24 21:01:56 +08:00
boojack b516a8561f chore: update readme 2022-07-24 13:37:07 +08:00
boojack 38383a426f chore: update error message (#129) 2022-07-24 00:29:19 +08:00
boojack 7e34de23f1 chore: update live demo link 2022-07-23 19:47:14 +08:00
boojack f02ec375a6 chore: release v0.2.2 (#127) 2022-07-22 23:51:58 +08:00
178 changed files with 4253 additions and 1793 deletions
@@ -1,37 +0,0 @@
name: build-and-push-dev-image
on:
push:
branches:
- "main"
jobs:
build-and-push-dev-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: neosmemo
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
install: true
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v3
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: neosmemo/memos:dev
@@ -1,37 +0,0 @@
name: build-and-push-test-image
on:
push:
branches:
- "test/*"
jobs:
build-and-push-dev-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: neosmemo
password: ${{ secrets.DOCKER_NEOSMEMO_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
install: true
- name: Build and Push
id: docker_build
uses: docker/build-push-action@v3
with:
context: ./
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: neosmemo/memos:test
+29 -29
View File
@@ -13,12 +13,12 @@ name: "CodeQL"
on:
push:
branches: [ main ]
branches: [main]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
branches: [main]
schedule:
- cron: '27 12 * * 0'
- cron: "27 12 * * 0"
jobs:
analyze:
@@ -32,39 +32,39 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'go', 'javascript' ]
language: ["go", "javascript"]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://git.io/codeql-language-support
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
+73
View File
@@ -0,0 +1,73 @@
name: Test
on:
push:
branches:
- main
- "release/v*.*.*"
pull_request:
branches: [main]
jobs:
go-static-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: 1.18
check-latest: true
cache: true
- name: Verify go.mod is tidy
run: |
go mod tidy
git diff --exit-code
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
args: -v
skip-cache: true
eslint-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: "16"
cache: yarn
cache-dependency-path: "web/yarn.lock"
- run: yarn
working-directory: web
- name: Run eslint check
run: yarn lint
working-directory: web
frontend-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: "16"
cache: yarn
cache-dependency-path: "web/yarn.lock"
- run: yarn
working-directory: web
- name: Run frontend build
run: yarn build
working-directory: web
go-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: 1.18
check-latest: true
cache: true
- name: Run all tests
run: go test -v ./... | tee test.log; exit ${PIPESTATUS[0]}
- name: Pretty print tests running time
run: grep --color=never -e '--- PASS:' -e '--- FAIL:' test.log | sed 's/[:()]//g' | awk '{print $2,$3,$4}' | sort -t' ' -nk3 -r | awk '{sum += $3; print $1,$2,$3,sum"s"}'
+4 -1
View File
@@ -1,10 +1,13 @@
# Air (hot reload) generated
.air
# temp folder
tmp
# Frontend asset
web/dist
# build folder
memos-build
build
.DS_Store
+64
View File
@@ -0,0 +1,64 @@
linters:
enable:
- goimports
- revive
- govet
- staticcheck
- misspell
- gocritic
- sqlclosecheck
- rowserrcheck
- nilerr
- godot
issues:
exclude:
- Rollback
- fmt.Printf
- fmt.Print
linters-settings:
revive:
enable-all-rules: true
rules:
- name: file-header
disabled: true
- name: line-length-limit
disabled: true
- name: function-length
disabled: true
- name: max-public-structs
disabled: true
- name: function-result-limit
disabled: true
- name: banned-characters
disabled: true
- name: argument-limit
disabled: true
- name: cognitive-complexity
disabled: true
- name: cyclomatic
disabled: true
- name: confusing-results
disabled: true
- name: add-constant
disabled: true
- name: flag-parameter
disabled: true
- name: nested-structs
disabled: true
- name: import-shadowing
disabled: true
- name: early-return
disabled: true
gocritic:
disabled-checks:
- ifElseChain
govet:
settings:
printf:
funcs:
- common.Errorf
forbidigo:
forbid:
- 'fmt\.Errorf(# Please use errors\.Wrap\|Wrapf\|Errorf instead)?'
+4
View File
@@ -0,0 +1,4 @@
{
"go.lintOnSave": "workspace",
"go.lintTool": "golangci-lint"
}
+2 -4
View File
@@ -17,9 +17,7 @@ RUN apk --no-cache add gcc musl-dev
COPY . .
COPY --from=frontend /frontend-build/dist ./server/dist
RUN go build \
-o memos \
./bin/server/main.go
RUN go build -o memos ./bin/server/main.go
# Make workspace with above generated files.
FROM alpine:3.16.0 AS monolithic
@@ -30,4 +28,4 @@ COPY --from=backend /backend-build/memos /usr/local/memos/
# Directory to store the data, which can be referenced as the mounting point.
RUN mkdir -p /var/opt/memos
ENTRYPOINT ["./memos"]
ENTRYPOINT ["./memos", "--mode", "prod", "--port", "5230"]
+30 -21
View File
@@ -1,21 +1,21 @@
<h1 align="center">✍️ Memos</h1>
<p align="center"><a href="https://usememos.com"><img height="64px" src="https://raw.githubusercontent.com/usememos/memos/main/resources/logo-full.webp" alt="✍️ memos" /></a></p>
<p align="center">An open source, self-hosted knowledge base that works with a SQLite db file.</p>
<p align="center">
<img alt="GitHub stars" src="https://img.shields.io/github/stars/usememos/memos" />
<a href="https://github.com/usememos/memos/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/usememos/memos" /></a>
<a href="https://hub.docker.com/r/neosmemo/memos"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/neosmemo/memos.svg" /></a>
<img alt="Go report" src="https://goreportcard.com/badge/github.com/usememos/memos" />
</p>
<p align="center">
<a href="https://memos.onrender.com/">Live Demo</a> •
<a href="https://demo.usememos.com/">Live Demo</a> •
<a href="https://t.me/+-_tNF1k70UU4ZTc9">Discuss in Telegram 👾</a>
</p>
![demo](https://raw.githubusercontent.com/usememos/memos/main/resources/demo.webp)
## Features
## Features
- 🦄 Fully open source;
- 📜 Writing in plain textarea without any burden,
@@ -24,23 +24,31 @@
- 🚀 Fast self-hosting with `Docker`;
- 🤠 Pleasant UI and UX;
## ⚓️ Deploy with Docker
## Deploy with Docker
#### Docker Run
```docker
docker run \
--name memos \
--publish 5230:5230 \
--volume ~/.memos/:/var/opt/memos \
neosmemo/memos:latest \
--mode prod \
--port 5230
docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos neosmemo/memos:latest
```
Memos should now be running at [http://localhost:5230](http://localhost:5230). If the `~/.memos/` does not have a `memos_prod.db` file, then `memos` will auto generate it.
#### Docker Compose
⚠️ Please DO NOT use `dev` tag of docker image if you have no experience.
```
version: "3.0"
services:
memos:
image: neosmemo/memos:latest
container_name: memos
volumes:
- ~/.memos/:/var/opt/memos
ports:
- 5230:5230
```
## 🏗 Development
Memos should be running at [http://localhost:5230](http://localhost:5230). If the `~/.memos/` does not have a `memos_prod.db` file, then memos will auto generate it.
## Development
Memos is built with a curated tech stack. It is optimized for developer experience and is very easy to start working on the code:
@@ -54,8 +62,9 @@ Memos is built with a curated tech stack. It is optimized for developer experien
### Prerequisites
- [Go](https://golang.org/doc/install) (1.16 or later)
- [Go](https://golang.org/doc/install)
- [Air](https://github.com/cosmtrek/air#installation) for backend live reload
- [Node.js](https://nodejs.org/)
- [yarn](https://yarnpkg.com/getting-started/install)
### Steps
@@ -80,10 +89,10 @@ Memos is built with a curated tech stack. It is optimized for developer experien
Memos should now be running at [http://localhost:3000](http://localhost:3000) and change either frontend or backend code would trigger live reload.
## 🌟 Star history
### Contributing
Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are greatly appreciated. 🥰
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=usememos/memos&type=Date)](https://star-history.com/#usememos/memos&Date)
---
Just enjoy it.
+22
View File
@@ -0,0 +1,22 @@
package api
// CacheNamespace is the type of a cache.
type CacheNamespace string
const (
// UserCache is the cache type of users.
UserCache CacheNamespace = "u"
// MemoCache is the cache type of memos.
MemoCache CacheNamespace = "m"
// ShortcutCache is the cache type of shortcuts.
ShortcutCache CacheNamespace = "s"
// ResourceCache is the cache type of resources.
ResourceCache CacheNamespace = "r"
)
// CacheService is the service for caches.
type CacheService interface {
FindCache(namespace CacheNamespace, id int, entry interface{}) (bool, error)
UpsertCache(namespace CacheNamespace, id int, entry interface{}) error
DeleteCache(namespace CacheNamespace, id int)
}
+10 -7
View File
@@ -6,6 +6,8 @@ type Visibility string
const (
// Public is the PUBLIC visibility.
Public Visibility = "PUBLIC"
// Protected is the PROTECTED visibility.
Protected Visibility = "PROTECTED"
// Privite is the PRIVATE visibility.
Privite Visibility = "PRIVATE"
)
@@ -14,6 +16,8 @@ func (e Visibility) String() string {
switch e {
case Public:
return "PUBLIC"
case Protected:
return "PROTECTED"
case Privite:
return "PRIVATE"
}
@@ -38,18 +42,17 @@ type Memo struct {
type MemoCreate struct {
// Standard fields
CreatorID int
// Used to import memos with a clearly created ts.
CreatedTs *int64 `json:"createdTs"`
// Domain specific fields
Content string `json:"content"`
Visibility *Visibility `json:"visibility"`
Visibility Visibility `json:"visibility"`
Content string `json:"content"`
}
type MemoPatch struct {
ID int
// Standard fields
CreatedTs *int64 `json:"createdTs"`
RowStatus *RowStatus `json:"rowStatus"`
// Domain specific fields
@@ -65,9 +68,9 @@ type MemoFind struct {
CreatorID *int `json:"creatorId"`
// Domain specific fields
Pinned *bool
ContentSearch *string
Visibility *Visibility
Pinned *bool
ContentSearch *string
VisibilityList []Visibility
// Pagination
Limit int
+4 -1
View File
@@ -10,7 +10,7 @@ type Resource struct {
// Domain specific fields
Filename string `json:"filename"`
Blob []byte `json:"blob"`
Blob []byte `json:"-"`
Type string `json:"type"`
Size int64 `json:"size"`
}
@@ -38,4 +38,7 @@ type ResourceFind struct {
type ResourceDelete struct {
ID int
// Standard fields
CreatorID int
}
+30 -5
View File
@@ -1,5 +1,11 @@
package api
import (
"fmt"
"github.com/usememos/memos/common"
)
// Role is the type of a role.
type Role string
@@ -29,11 +35,12 @@ type User struct {
UpdatedTs int64 `json:"updatedTs"`
// Domain specific fields
Email string `json:"email"`
Role Role `json:"role"`
Name string `json:"name"`
PasswordHash string `json:"-"`
OpenID string `json:"openId"`
Email string `json:"email"`
Role Role `json:"role"`
Name string `json:"name"`
PasswordHash string `json:"-"`
OpenID string `json:"openId"`
UserSettingList []*UserSetting `json:"userSettingList"`
}
type UserCreate struct {
@@ -46,6 +53,20 @@ type UserCreate struct {
OpenID string
}
func (create UserCreate) Validate() error {
if !common.ValidateEmail(create.Email) {
return fmt.Errorf("invalid email format")
}
if len(create.Email) < 6 {
return fmt.Errorf("email is too short, minimum length is 6")
}
if len(create.Password) < 6 {
return fmt.Errorf("password is too short, minimum length is 6")
}
return nil
}
type UserPatch struct {
ID int
@@ -73,3 +94,7 @@ type UserFind struct {
Name *string `json:"name"`
OpenID *string
}
type UserDelete struct {
ID int
}
+114
View File
@@ -0,0 +1,114 @@
package api
import (
"encoding/json"
"fmt"
)
type UserSettingKey string
const (
// UserSettingLocaleKey is the key type for user locale.
UserSettingLocaleKey UserSettingKey = "locale"
// UserSettingMemoVisibilityKey is the key type for user preference memo default visibility.
UserSettingMemoVisibilityKey UserSettingKey = "memoVisibility"
// UserSettingEditorFontStyleKey is the key type for editor font style.
UserSettingEditorFontStyleKey UserSettingKey = "editorFontStyle"
)
// String returns the string format of UserSettingKey type.
func (key UserSettingKey) String() string {
switch key {
case UserSettingLocaleKey:
return "locale"
case UserSettingMemoVisibilityKey:
return "memoVisibility"
case UserSettingEditorFontStyleKey:
return "editorFontFamily"
}
return ""
}
var (
UserSettingLocaleValue = []string{"en", "zh"}
UserSettingMemoVisibilityValue = []Visibility{Privite, Protected, Public}
UserSettingEditorFontStyleValue = []string{"normal", "mono"}
)
type UserSetting struct {
UserID int
Key UserSettingKey `json:"key"`
// Value is a JSON string with basic value
Value string `json:"value"`
}
type UserSettingUpsert struct {
UserID int
Key UserSettingKey `json:"key"`
Value string `json:"value"`
}
func (upsert UserSettingUpsert) Validate() error {
if upsert.Key == UserSettingLocaleKey {
localeValue := "en"
err := json.Unmarshal([]byte(upsert.Value), &localeValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting locale value")
}
invalid := true
for _, value := range UserSettingLocaleValue {
if localeValue == value {
invalid = false
break
}
}
if invalid {
return fmt.Errorf("invalid user setting locale value")
}
} else if upsert.Key == UserSettingMemoVisibilityKey {
memoVisibilityValue := Privite
err := json.Unmarshal([]byte(upsert.Value), &memoVisibilityValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting memo visibility value")
}
invalid := true
for _, value := range UserSettingMemoVisibilityValue {
if memoVisibilityValue == value {
invalid = false
break
}
}
if invalid {
return fmt.Errorf("invalid user setting memo visibility value")
}
} else if upsert.Key == UserSettingEditorFontStyleKey {
editorFontStyleValue := "normal"
err := json.Unmarshal([]byte(upsert.Value), &editorFontStyleValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting editor font style")
}
invalid := true
for _, value := range UserSettingEditorFontStyleValue {
if editorFontStyleValue == value {
invalid = false
break
}
}
if invalid {
return fmt.Errorf("invalid user setting editor font style value")
}
} else {
return fmt.Errorf("invalid user setting key")
}
return nil
}
type UserSettingFind struct {
UserID int
Key *UserSettingKey `json:"key"`
}
+16 -16
View File
@@ -1,8 +1,8 @@
package cmd
import (
"context"
"fmt"
"os"
"github.com/usememos/memos/server"
"github.com/usememos/memos/server/profile"
@@ -21,31 +21,29 @@ const (
`
)
type Main struct {
profile *profile.Profile
}
func Run(profile *profile.Profile) error {
ctx := context.Background()
func (m *Main) Run() error {
db := DB.NewDB(m.profile)
if err := db.Open(); err != nil {
db := DB.NewDB(profile)
if err := db.Open(ctx); err != nil {
return fmt.Errorf("cannot open db: %w", err)
}
s := server.NewServer(m.profile)
s := server.NewServer(profile)
storeInstance := store.New(db.Db, m.profile)
storeInstance := store.New(db.Db, profile)
s.Store = storeInstance
println(greetingBanner)
fmt.Printf("Version %s has started at :%d\n", m.profile.Version, m.profile.Port)
fmt.Printf("Version %s has started at :%d\n", profile.Version, profile.Port)
return s.Run()
}
func Execute() {
profile := profile.GetProfile()
m := Main{
profile: profile,
func Execute() error {
profile, err := profile.GetProfile()
if err != nil {
return err
}
println("---")
@@ -56,8 +54,10 @@ func Execute() {
println("version:", profile.Version)
println("---")
if err := m.Run(); err != nil {
if err := Run(profile); err != nil {
fmt.Printf("error: %+v\n", err)
os.Exit(1)
return err
}
return nil
}
+10 -2
View File
@@ -1,7 +1,15 @@
package main
import "github.com/usememos/memos/bin/server/cmd"
import (
"os"
_ "github.com/mattn/go-sqlite3"
"github.com/usememos/memos/bin/server/cmd"
)
func main() {
cmd.Execute()
if err := cmd.Execute(); err != nil {
os.Exit(1)
}
}
+1 -6
View File
@@ -9,7 +9,7 @@ type Code int
// Application error codes.
const (
// 0 ~ 99 general error
// 0 ~ 99 general error.
Ok Code = 0
Internal Code = 1
NotAuthorized Code = 2
@@ -17,11 +17,6 @@ const (
NotFound Code = 4
Conflict Code = 5
NotImplemented Code = 6
// 101 ~ 199 db error
DbConnectionFailure Code = 101
DbStatementSyntaxError Code = 102
DbExecutionError Code = 103
)
// Error represents an application-specific error. Application errors can be
+9
View File
@@ -1,6 +1,7 @@
package common
import (
"net/mail"
"strings"
"github.com/google/uuid"
@@ -16,6 +17,14 @@ func HasPrefixes(src string, prefixes ...string) bool {
return false
}
// ValidateEmail validates the email.
func ValidateEmail(email string) bool {
if _, err := mail.ParseAddress(email); err != nil {
return false
}
return true
}
func GenUUID() string {
return uuid.New().String()
}
+31
View File
@@ -0,0 +1,31 @@
package common
import (
"testing"
)
func TestValidateEmail(t *testing.T) {
tests := []struct {
email string
want bool
}{
{
email: "t@gmail.com",
want: true,
},
{
email: "@qq.com",
want: false,
},
{
email: "1@gmail",
want: true,
},
}
for _, test := range tests {
result := ValidateEmail(test.email)
if result != test.want {
t.Errorf("Validate Email %s: got result %v, want %v.", test.email, result, test.want)
}
}
}
+2 -2
View File
@@ -7,10 +7,10 @@ import (
// Version is the service current released version.
// Semantic versioning: https://semver.org/
var Version = "0.2.1"
var Version = "0.4.2"
// DevVersion is the service current development version.
var DevVersion = "0.2.1"
var DevVersion = "0.4.2"
func GetCurrentVersion(mode string) string {
if mode == "dev" {
+10
View File
@@ -0,0 +1,10 @@
version: "3.0"
services:
memos:
image: neosmemo/memos:latest
container_name: memos
volumes:
- ~/.memos/:/var/opt/memos
ports:
- 5230:5230
restart: unless-stopped
+7 -1
View File
@@ -14,7 +14,7 @@ require (
github.com/valyala/fasttemplate v1.2.1 // indirect
golang.org/x/crypto v0.0.0-20210920023735-84f357641f63
golang.org/x/net v0.0.0-20210917221730-978cfadd31cf // indirect
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect
golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 // indirect
)
@@ -26,7 +26,13 @@ require (
)
require (
github.com/VictoriaMetrics/fastcache v1.10.0
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/sessions v1.2.1
github.com/labstack/echo-contrib v0.12.0
)
require (
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
)
+9 -1
View File
@@ -37,12 +37,16 @@ github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXY
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
github.com/Shopify/sarama v1.30.0/go.mod h1:zujlQQx1kzHsh4jfV1USnptCQrHAEZ2Hk8fTKCulPVs=
github.com/Shopify/toxiproxy/v2 v2.1.6-0.20210914104332-15ea381dcdae/go.mod h1:/cvHQkZ1fst0EmZnA5dFtiQdWCNCFYzb+uE2vqVgvx0=
github.com/VictoriaMetrics/fastcache v1.10.0 h1:5hDJnLsKLpnUEToub7ETuRu8RCkb40woBZAUiKonXzY=
github.com/VictoriaMetrics/fastcache v1.10.0/go.mod h1:tjiYeEfYXCqacuvYw/7UoDIeJaNxq6132xHICNP77w8=
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156 h1:eMwmnE/GDgah4HI848JfFxHt+iPb26b4zyfspmqY0/8=
github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/appleboy/gofight/v2 v2.1.2/go.mod h1:frW+U1QZEdDgixycTj4CygQ48yLTUhplt43+Wczp3rw=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@@ -51,6 +55,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/casbin/casbin/v2 v2.40.6/go.mod h1:sEL80qBYTbd+BPeL4iyvwYzFT3qwLaESq5aFKVLbLfA=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -120,6 +126,7 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
@@ -433,8 +440,9 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64 h1:D1v9ucDTYBtbz5vNuBbAhIMAGhQhJ6Ym5ah3maMVNX4=
golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+10
View File
@@ -0,0 +1,10 @@
version: "3.2"
services:
portainer:
image: neosmemo/memos:latest
container_name: memos
ports:
- "5230:5230"
volumes:
- ~/.memos/:/var/opt/memos
command: --mode=prod --port=5230
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+1 -1
View File
@@ -5,7 +5,7 @@ tmp_dir = ".air"
bin = "./.air/memos"
cmd = "go build -o ./.air/memos ./bin/server/main.go"
delay = 1000
exclude_dir = [".air", "web"]
exclude_dir = [".air", "web", "build"]
exclude_file = []
exclude_regex = []
exclude_unchanged = false
Regular → Executable
+7 -4
View File
@@ -1,10 +1,13 @@
# Usage: sh ./scripts/build.sh
#!/bin/bash
# Usage: ./scripts/build.sh
set -e
cd "$(dirname "$0")/../"
echo "Start building..."
echo "Start building backend..."
go build -o ./memos-build/memos ./bin/server/main.go
go build -o ./build/memos ./bin/server/main.go
echo "Build finished"
echo "Backend built!"
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# Usage: ./scripts/start.sh
set -e
cd "$(dirname "$0")/../"
air -c ./scripts/.air.toml
+118
View File
@@ -0,0 +1,118 @@
package server
import (
"fmt"
"net/http"
"strconv"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
)
var (
userIDContextKey = "user-id"
)
func getUserIDContextKey() string {
return userIDContextKey
}
func setUserSession(ctx echo.Context, user *api.User) error {
sess, _ := session.Get("session", ctx)
sess.Options = &sessions.Options{
Path: "/",
MaxAge: 1000 * 3600 * 24 * 30,
HttpOnly: true,
}
sess.Values[userIDContextKey] = user.ID
err := sess.Save(ctx.Request(), ctx.Response())
if err != nil {
return fmt.Errorf("failed to set session, err: %w", err)
}
return nil
}
func removeUserSession(ctx echo.Context) error {
sess, _ := session.Get("session", ctx)
sess.Options = &sessions.Options{
Path: "/",
MaxAge: 0,
HttpOnly: true,
}
sess.Values[userIDContextKey] = nil
err := sess.Save(ctx.Request(), ctx.Response())
if err != nil {
return fmt.Errorf("failed to set session, err: %w", err)
}
return nil
}
func aclMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
path := c.Path()
// Skip auth.
if common.HasPrefixes(path, "/api/auth") {
return next(c)
}
if common.HasPrefixes(path, "/api/ping", "/api/status", "/api/user/:id") && c.Request().Method == http.MethodGet {
return next(c)
}
// If there is openId in query string and related user is found, then skip auth.
openID := c.QueryParam("openId")
if openID != "" {
userFind := &api.UserFind{
OpenID: &openID,
}
user, err := s.Store.FindUser(ctx, userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by open_id").SetInternal(err)
}
if user != nil {
// Stores userID into context.
c.Set(getUserIDContextKey(), user.ID)
return next(c)
}
}
{
sess, _ := session.Get("session", c)
userIDValue := sess.Values[userIDContextKey]
if userIDValue != nil {
userID, _ := strconv.Atoi(fmt.Sprintf("%v", userIDValue))
userFind := &api.UserFind{
ID: &userID,
}
user, err := s.Store.FindUser(ctx, userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by ID: %d", userID)).SetInternal(err)
}
if user != nil {
if user.RowStatus == api.Archived {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with email %s", user.Email))
}
c.Set(getUserIDContextKey(), userID)
}
}
}
if common.HasPrefixes(path, "/api/memo", "/api/tag", "/api/shortcut") && c.Request().Method == http.MethodGet {
if _, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
return next(c)
}
}
userID := c.Get(getUserIDContextKey())
if userID == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
return next(c)
}
}
+15 -16
View File
@@ -14,6 +14,7 @@ import (
func (s *Server) registerAuthRoutes(g *echo.Group) {
g.POST("/auth/signin", func(c echo.Context) error {
ctx := c.Request().Context()
signin := &api.Signin{}
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err)
@@ -22,7 +23,7 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
userFind := &api.UserFind{
Email: &signin.Email,
}
user, err := s.Store.FindUser(userFind)
user, err := s.Store.FindUser(ctx, userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by email %s", signin.Email)).SetInternal(err)
}
@@ -60,12 +61,13 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
})
g.POST("/auth/signup", func(c echo.Context) error {
ctx := c.Request().Context()
// Don't allow to signup by this api if site host existed.
hostUserType := api.Host
hostUserFind := api.UserFind{
Role: &hostUserType,
}
hostUser, err := s.Store.FindUser(&hostUserFind)
hostUser, err := s.Store.FindUser(ctx, &hostUserFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err)
}
@@ -78,13 +80,15 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signup request").SetInternal(err)
}
// Validate signup form.
// We can do stricter checks later.
if len(signup.Email) < 6 {
return echo.NewHTTPError(http.StatusBadRequest, "Email is too short, minimum length is 6.")
userCreate := &api.UserCreate{
Email: signup.Email,
Role: api.Role(signup.Role),
Name: signup.Name,
Password: signup.Password,
OpenID: common.GenUUID(),
}
if len(signup.Password) < 6 {
return echo.NewHTTPError(http.StatusBadRequest, "Password is too short, minimum length is 6.")
if err := userCreate.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format.").SetInternal(err)
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(signup.Password), bcrypt.DefaultCost)
@@ -92,14 +96,9 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
}
userCreate := &api.UserCreate{
Email: signup.Email,
Role: api.Role(signup.Role),
Name: signup.Name,
PasswordHash: string(passwordHash),
OpenID: common.GenUUID(),
}
user, err := s.Store.CreateUser(userCreate)
userCreate.PasswordHash = string(passwordHash)
user, err := s.Store.CreateUser(ctx, userCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
}
-119
View File
@@ -1,119 +0,0 @@
package server
import (
"fmt"
"net/http"
"strconv"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
)
var (
userIDContextKey = "user-id"
)
func getUserIDContextKey() string {
return userIDContextKey
}
func setUserSession(c echo.Context, user *api.User) error {
sess, _ := session.Get("session", c)
sess.Options = &sessions.Options{
Path: "/",
MaxAge: 1000 * 3600 * 24 * 30,
HttpOnly: true,
}
sess.Values[userIDContextKey] = user.ID
err := sess.Save(c.Request(), c.Response())
if err != nil {
return fmt.Errorf("failed to set session, err: %w", err)
}
return nil
}
func removeUserSession(c echo.Context) error {
sess, _ := session.Get("session", c)
sess.Options = &sessions.Options{
Path: "/",
MaxAge: 0,
HttpOnly: true,
}
sess.Values[userIDContextKey] = nil
err := sess.Save(c.Request(), c.Response())
if err != nil {
return fmt.Errorf("failed to set session, err: %w", err)
}
return nil
}
// Use session to store user.id.
func BasicAuthMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Skip auth for some paths.
if common.HasPrefixes(c.Path(), "/api/auth", "/api/ping", "/api/status", "/api/user/:userId") {
return next(c)
}
// If there is openId in query string and related user is found, then skip auth.
openID := c.QueryParam("openId")
if openID != "" {
userFind := &api.UserFind{
OpenID: &openID,
}
user, err := s.Store.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by open_id").SetInternal(err)
}
if user != nil {
// Stores userID into context.
c.Set(getUserIDContextKey(), user.ID)
return next(c)
}
}
if common.HasPrefixes(c.Path(), "/api/memo", "/api/tag", "/api/shortcut") && c.Request().Method == http.MethodGet {
if _, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
return next(c)
}
}
sess, err := session.Get("session", c)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing session").SetInternal(err)
}
userIDValue := sess.Values[userIDContextKey]
if userIDValue == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing userID in session")
}
userID, err := strconv.Atoi(fmt.Sprintf("%v", userIDValue))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to malformatted user id in the session.").SetInternal(err)
}
// Even if there is no error, we still need to make sure the user still exists.
userFind := &api.UserFind{
ID: &userID,
}
user, err := s.Store.FindUser(userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by ID: %d", userID)).SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Not found user ID: %d", userID))
} else if user.RowStatus == api.Archived {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with email %s", user.Email))
}
// Stores userID into context.
c.Set(getUserIDContextKey(), userID)
return next(c)
}
}
+68 -27
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
@@ -14,20 +15,42 @@ import (
func (s *Server) registerMemoRoutes(g *echo.Group) {
g.POST("/memo", func(c echo.Context) error {
userID := c.Get(getUserIDContextKey()).(int)
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
memoCreate := &api.MemoCreate{
CreatorID: userID,
// Private is the default memo visibility.
Visibility: api.Privite,
}
if err := json.NewDecoder(c.Request().Body).Decode(memoCreate); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo request").SetInternal(err)
}
if memoCreate.Visibility == nil || *memoCreate.Visibility == "" {
private := api.Privite
memoCreate.Visibility = &private
if memoCreate.Content == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Memo content shouldn't be empty")
}
memo, err := s.Store.CreateMemo(memoCreate)
userSettingMemoVisibilityKey := api.UserSettingMemoVisibilityKey
userMemoVisibilitySetting, err := s.Store.FindUserSetting(ctx, &api.UserSettingFind{
UserID: userID,
Key: &userSettingMemoVisibilityKey,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user setting").SetInternal(err)
}
if userMemoVisibilitySetting != nil {
memoVisibility := api.Privite
err := json.Unmarshal([]byte(userMemoVisibilitySetting.Value), &memoVisibility)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal user setting value").SetInternal(err)
}
memoCreate.Visibility = memoVisibility
}
memo, err := s.Store.CreateMemo(ctx, memoCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create memo").SetInternal(err)
}
@@ -40,6 +63,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
})
g.PATCH("/memo/:memoId", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
@@ -52,7 +76,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch memo request").SetInternal(err)
}
memo, err := s.Store.PatchMemo(memoPatch)
memo, err := s.Store.PatchMemo(ctx, memoPatch)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch memo").SetInternal(err)
}
@@ -65,24 +89,25 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
})
g.GET("/memo", func(c echo.Context) error {
ctx := c.Request().Context()
memoFind := &api.MemoFind{}
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
memoFind.CreatorID = &userID
} else {
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo")
}
memoFind.CreatorID = &userID
}
// Only can get PUBLIC memos in visitor mode
_, ok := c.Get(getUserIDContextKey()).(int)
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
publicVisibility := api.Public
memoFind.Visibility = &publicVisibility
if memoFind.CreatorID == nil {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo")
}
memoFind.VisibilityList = []api.Visibility{api.Public}
} else {
if memoFind.CreatorID == nil {
memoFind.CreatorID = &currentUserID
} else {
memoFind.VisibilityList = []api.Visibility{api.Public, api.Protected}
}
}
rowStatus := api.RowStatus(c.QueryParam("rowStatus"))
@@ -99,6 +124,14 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
contentSearch := "#" + tag + " "
memoFind.ContentSearch = &contentSearch
}
visibilitListStr := c.QueryParam("visibility")
if visibilitListStr != "" {
visibilityList := []api.Visibility{}
for _, visibility := range strings.Split(visibilitListStr, ",") {
visibilityList = append(visibilityList, api.Visibility(visibility))
}
memoFind.VisibilityList = visibilityList
}
if limit, err := strconv.Atoi(c.QueryParam("limit")); err == nil {
memoFind.Limit = limit
}
@@ -106,7 +139,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
memoFind.Offset = offset
}
list, err := s.Store.FindMemoList(memoFind)
list, err := s.Store.FindMemoList(ctx, memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch memo list").SetInternal(err)
}
@@ -119,12 +152,16 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
})
g.POST("/memo/:memoId/organizer", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
}
userID := c.Get(getUserIDContextKey()).(int)
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
memoOrganizerUpsert := &api.MemoOrganizerUpsert{
MemoID: memoID,
UserID: userID,
@@ -133,12 +170,12 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post memo organizer request").SetInternal(err)
}
err = s.Store.UpsertMemoOrganizer(memoOrganizerUpsert)
err = s.Store.UpsertMemoOrganizer(ctx, memoOrganizerUpsert)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo organizer").SetInternal(err)
}
memo, err := s.Store.FindMemo(&api.MemoFind{
memo, err := s.Store.FindMemo(ctx, &api.MemoFind{
ID: &memoID,
})
if err != nil {
@@ -157,6 +194,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
})
g.GET("/memo/:memoId", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
@@ -165,7 +203,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
memoFind := &api.MemoFind{
ID: &memoID,
}
memo, err := s.Store.FindMemo(memoFind)
memo, err := s.Store.FindMemo(ctx, memoFind)
if err != nil {
if common.ErrorCode(err) == common.NotFound {
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Memo ID not found: %d", memoID)).SetInternal(err)
@@ -182,6 +220,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
})
g.DELETE("/memo/:memoId", func(c echo.Context) error {
ctx := c.Request().Context()
memoID, err := strconv.Atoi(c.Param("memoId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("memoId"))).SetInternal(err)
@@ -190,9 +229,7 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
memoDelete := &api.MemoDelete{
ID: memoID,
}
err = s.Store.DeleteMemo(memoDelete)
if err != nil {
if err := s.Store.DeleteMemo(ctx, memoDelete); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete memo ID: %v", memoID)).SetInternal(err)
}
@@ -200,14 +237,18 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
})
g.GET("/memo/amount", func(c echo.Context) error {
userID := c.Get(getUserIDContextKey()).(int)
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
normalRowStatus := api.Normal
memoFind := &api.MemoFind{
CreatorID: &userID,
RowStatus: &normalRowStatus,
}
memoList, err := s.Store.FindMemoList(memoFind)
memoList, err := s.Store.FindMemoList(ctx, memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
}
+3 -3
View File
@@ -45,7 +45,7 @@ func checkDSN(dataDir string) (string, error) {
}
// GetDevProfile will return a profile for dev or prod.
func GetProfile() *Profile {
func GetProfile() (*Profile, error) {
profile := Profile{}
flag.StringVar(&profile.Mode, "mode", "dev", "mode of server")
flag.IntVar(&profile.Port, "port", 8080, "port of server")
@@ -63,12 +63,12 @@ func GetProfile() *Profile {
dataDir, err := checkDSN(profile.Data)
if err != nil {
fmt.Printf("Failed to check dsn: %s, err: %+v\n", dataDir, err)
os.Exit(1)
return nil, err
}
profile.Data = dataDir
profile.DSN = fmt.Sprintf("%s/memos_%s.db", dataDir, profile.Mode)
profile.Version = common.GetCurrentVersion(profile.Mode)
return &profile
return &profile, nil
}
+35 -12
View File
@@ -3,7 +3,7 @@ package server
import (
"encoding/json"
"fmt"
"io/ioutil"
"io"
"net/http"
"strconv"
@@ -14,7 +14,11 @@ import (
func (s *Server) registerResourceRoutes(g *echo.Group) {
g.POST("/resource", func(c echo.Context) error {
userID := c.Get(getUserIDContextKey()).(int)
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
err := c.Request().ParseMultipartForm(64 << 20)
if err != nil {
@@ -35,7 +39,7 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
}
defer src.Close()
fileBytes, err := ioutil.ReadAll(src)
fileBytes, err := io.ReadAll(src)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to read file").SetInternal(err)
}
@@ -48,7 +52,7 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
CreatorID: userID,
}
resource, err := s.Store.CreateResource(resourceCreate)
resource, err := s.Store.CreateResource(ctx, resourceCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create resource").SetInternal(err)
}
@@ -61,11 +65,15 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
})
g.GET("/resource", func(c echo.Context) error {
userID := c.Get(getUserIDContextKey()).(int)
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
resourceFind := &api.ResourceFind{
CreatorID: &userID,
}
list, err := s.Store.FindResourceList(resourceFind)
list, err := s.Store.FindResourceList(ctx, resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource list").SetInternal(err)
}
@@ -78,17 +86,21 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
})
g.GET("/resource/:resourceId", func(c echo.Context) error {
ctx := c.Request().Context()
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
userID := c.Get(getUserIDContextKey()).(int)
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
resourceFind := &api.ResourceFind{
ID: &resourceID,
CreatorID: &userID,
}
resource, err := s.Store.FindResource(resourceFind)
resource, err := s.Store.FindResource(ctx, resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
}
@@ -101,17 +113,21 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
})
g.GET("/resource/:resourceId/blob", func(c echo.Context) error {
ctx := c.Request().Context()
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
userID := c.Get(getUserIDContextKey()).(int)
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
resourceFind := &api.ResourceFind{
ID: &resourceID,
CreatorID: &userID,
}
resource, err := s.Store.FindResource(resourceFind)
resource, err := s.Store.FindResource(ctx, resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch resource").SetInternal(err)
}
@@ -126,15 +142,22 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
})
g.DELETE("/resource/:resourceId", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
resourceDelete := &api.ResourceDelete{
ID: resourceID,
ID: resourceID,
CreatorID: userID,
}
if err := s.Store.DeleteResource(resourceDelete); err != nil {
if err := s.Store.DeleteResource(ctx, resourceDelete); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)
}
+6 -2
View File
@@ -29,9 +29,13 @@ func NewServer(profile *profile.Profile) *Server {
e.HidePort = true
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
Format: "${method} ${uri} ${status}\n",
Format: `{"time":"${time_rfc3339}",` +
`"method":"${method}","uri":"${uri}",` +
`"status":${status},"error":"${error}"}` + "\n",
}))
e.Use(middleware.CORS())
e.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{
Skipper: middleware.DefaultSkipper,
ErrorMessage: "Request timeout",
@@ -58,7 +62,7 @@ func NewServer(profile *profile.Profile) *Server {
apiGroup := e.Group("/api")
apiGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return BasicAuthMiddleware(s, next)
return aclMiddleware(s, next)
})
s.registerSystemRoutes(apiGroup)
s.registerAuthRoutes(apiGroup)
+14 -6
View File
@@ -13,7 +13,11 @@ import (
func (s *Server) registerShortcutRoutes(g *echo.Group) {
g.POST("/shortcut", func(c echo.Context) error {
userID := c.Get(getUserIDContextKey()).(int)
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
shortcutCreate := &api.ShortcutCreate{
CreatorID: userID,
}
@@ -21,7 +25,7 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post shortcut request").SetInternal(err)
}
shortcut, err := s.Store.CreateShortcut(shortcutCreate)
shortcut, err := s.Store.CreateShortcut(ctx, shortcutCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create shortcut").SetInternal(err)
}
@@ -34,6 +38,7 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
})
g.PATCH("/shortcut/:shortcutId", func(c echo.Context) error {
ctx := c.Request().Context()
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
@@ -46,7 +51,7 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch shortcut request").SetInternal(err)
}
shortcut, err := s.Store.PatchShortcut(shortcutPatch)
shortcut, err := s.Store.PatchShortcut(ctx, shortcutPatch)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch shortcut").SetInternal(err)
}
@@ -59,6 +64,7 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
})
g.GET("/shortcut", func(c echo.Context) error {
ctx := c.Request().Context()
shortcutFind := &api.ShortcutFind{}
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
@@ -72,7 +78,7 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
shortcutFind.CreatorID = &userID
}
list, err := s.Store.FindShortcutList(shortcutFind)
list, err := s.Store.FindShortcutList(ctx, shortcutFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch shortcut list").SetInternal(err)
}
@@ -85,6 +91,7 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
})
g.GET("/shortcut/:shortcutId", func(c echo.Context) error {
ctx := c.Request().Context()
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
@@ -93,7 +100,7 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
shortcutFind := &api.ShortcutFind{
ID: &shortcutID,
}
shortcut, err := s.Store.FindShortcut(shortcutFind)
shortcut, err := s.Store.FindShortcut(ctx, shortcutFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to fetch shortcut by ID %d", *shortcutFind.ID)).SetInternal(err)
}
@@ -106,6 +113,7 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
})
g.DELETE("/shortcut/:shortcutId", func(c echo.Context) error {
ctx := c.Request().Context()
shortcutID, err := strconv.Atoi(c.Param("shortcutId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("shortcutId"))).SetInternal(err)
@@ -114,7 +122,7 @@ func (s *Server) registerShortcutRoutes(g *echo.Group) {
shortcutDelete := &api.ShortcutDelete{
ID: shortcutID,
}
if err := s.Store.DeleteShortcut(shortcutDelete); err != nil {
if err := s.Store.DeleteShortcut(ctx, shortcutDelete); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete shortcut").SetInternal(err)
}
+3 -2
View File
@@ -21,11 +21,12 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
})
g.GET("/status", func(c echo.Context) error {
ctx := c.Request().Context()
hostUserType := api.Host
hostUserFind := api.UserFind{
Role: &hostUserType,
}
hostUser, err := s.Store.FindUser(&hostUserFind)
hostUser, err := s.Store.FindUser(ctx, &hostUserFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err)
}
@@ -36,7 +37,7 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
}
systemStatus := api.SystemStatus{
Host: hostUser,
Host: hostUser,
Profile: s.Profile,
}
+14 -13
View File
@@ -14,6 +14,7 @@ import (
func (s *Server) registerTagRoutes(g *echo.Group) {
g.GET("/tag", func(c echo.Context) error {
ctx := c.Request().Context()
contentSearch := "#"
normalRowStatus := api.Normal
memoFind := api.MemoFind{
@@ -22,31 +23,31 @@ func (s *Server) registerTagRoutes(g *echo.Group) {
}
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
memoFind.CreatorID = &userID
} else {
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find tag")
}
memoFind.CreatorID = &userID
}
// Only can get PUBLIC memos in visitor mode
_, ok := c.Get(getUserIDContextKey()).(int)
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
publicVisibility := api.Public
memoFind.Visibility = &publicVisibility
if memoFind.CreatorID == nil {
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find memo")
}
memoFind.VisibilityList = []api.Visibility{api.Public}
} else {
if memoFind.CreatorID == nil {
memoFind.CreatorID = &currentUserID
} else {
memoFind.VisibilityList = []api.Visibility{api.Public, api.Protected}
}
}
memoList, err := s.Store.FindMemoList(&memoFind)
memoList, err := s.Store.FindMemoList(ctx, &memoFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find memo list").SetInternal(err)
}
tagMapSet := make(map[string]bool)
r, err := regexp.Compile("#(.+?) ")
r := regexp.MustCompile(`#([^\s#]+?) `)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compile regexp").SetInternal(err)
}
+128 -55
View File
@@ -15,18 +15,39 @@ import (
func (s *Server) registerUserRoutes(g *echo.Group) {
g.POST("/user", func(c echo.Context) error {
userCreate := &api.UserCreate{}
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
currentUser, err := s.Store.FindUser(ctx, &api.UserFind{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err)
}
if currentUser.Role != api.Host {
return echo.NewHTTPError(http.StatusUnauthorized, "Only Host user can create member.")
}
userCreate := &api.UserCreate{
OpenID: common.GenUUID(),
}
if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err)
}
if err := userCreate.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format.").SetInternal(err)
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(userCreate.Password), bcrypt.DefaultCost)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
}
userCreate.PasswordHash = string(passwordHash)
user, err := s.Store.CreateUser(userCreate)
user, err := s.Store.CreateUser(ctx, userCreate)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
}
@@ -39,11 +60,17 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
})
g.GET("/user", func(c echo.Context) error {
userList, err := s.Store.FindUserList(&api.UserFind{})
ctx := c.Request().Context()
userList, err := s.Store.FindUserList(ctx, &api.UserFind{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user list").SetInternal(err)
}
for _, user := range userList {
// data desensitize
user.OpenID = ""
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(userList)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user list response").SetInternal(err)
@@ -51,13 +78,73 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
return nil
})
// GET /api/user/me is used to check if the user is logged in.
g.GET("/user/me", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
userFind := &api.UserFind{
ID: &userID,
}
user, err := s.Store.FindUser(ctx, userFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
userSettingList, err := s.Store.FindUserSettingList(ctx, &api.UserSettingFind{
UserID: userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find userSettingList").SetInternal(err)
}
user.UserSettingList = userSettingList
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
}
return nil
})
g.POST("/user/setting", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
userSettingUpsert := &api.UserSettingUpsert{}
if err := json.NewDecoder(c.Request().Body).Decode(userSettingUpsert); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user setting upsert request").SetInternal(err)
}
if err := userSettingUpsert.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user setting format").SetInternal(err)
}
userSettingUpsert.UserID = userID
userSetting, err := s.Store.UpsertUserSetting(ctx, userSettingUpsert)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert user setting").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(userSetting)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user setting response").SetInternal(err)
}
return nil
})
g.GET("/user/:id", func(c echo.Context) error {
ctx := c.Request().Context()
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted user id").SetInternal(err)
}
user, err := s.Store.FindUser(&api.UserFind{
user, err := s.Store.FindUser(ctx, &api.UserFind{
ID: &id,
})
if err != nil {
@@ -76,31 +163,28 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
return nil
})
// GET /api/user/me is used to check if the user is logged in.
g.GET("/user/me", func(c echo.Context) error {
userSessionID := c.Get(getUserIDContextKey())
if userSessionID == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session")
}
userID := userSessionID.(int)
userFind := &api.UserFind{
ID: &userID,
}
user, err := s.Store.FindUser(userFind)
g.PATCH("/user/:id", func(c echo.Context) error {
ctx := c.Request().Context()
userID, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user").SetInternal(err)
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("id"))).SetInternal(err)
}
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
currentUser, err := s.Store.FindUser(ctx, &api.UserFind{
ID: &currentUserID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if currentUser == nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Current session user not found with ID: %d", currentUserID)).SetInternal(err)
} else if currentUser.Role != api.Host && currentUserID != userID {
return echo.NewHTTPError(http.StatusForbidden, "Access forbidden for current session user").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
}
return nil
})
g.PATCH("/user/me", func(c echo.Context) error {
userID := c.Get(getUserIDContextKey()).(int)
userPatch := &api.UserPatch{
ID: userID,
}
@@ -108,6 +192,10 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch user request").SetInternal(err)
}
if userPatch.Email != nil && !common.ValidateEmail(*userPatch.Email) {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid email format")
}
if userPatch.Password != nil && *userPatch.Password != "" {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*userPatch.Password), bcrypt.DefaultCost)
if err != nil {
@@ -123,7 +211,7 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
userPatch.OpenID = &openID
}
user, err := s.Store.PatchUser(userPatch)
user, err := s.Store.PatchUser(ctx, userPatch)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch user").SetInternal(err)
}
@@ -135,9 +223,13 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
return nil
})
g.PATCH("/user/:userId", func(c echo.Context) error {
currentUserID := c.Get(getUserIDContextKey()).(int)
currentUser, err := s.Store.FindUser(&api.UserFind{
g.DELETE("/user/:id", func(c echo.Context) error {
ctx := c.Request().Context()
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
currentUser, err := s.Store.FindUser(ctx, &api.UserFind{
ID: &currentUserID,
})
if err != nil {
@@ -149,37 +241,18 @@ func (s *Server) registerUserRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusForbidden, "Access forbidden for current session user").SetInternal(err)
}
userID, err := strconv.Atoi(c.Param("userId"))
userID, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("userId"))).SetInternal(err)
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("id"))).SetInternal(err)
}
userPatch := &api.UserPatch{
userDelete := &api.UserDelete{
ID: userID,
}
if err := json.NewDecoder(c.Request().Body).Decode(userPatch); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted patch user request").SetInternal(err)
if err := s.Store.DeleteUser(ctx, userDelete); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete user").SetInternal(err)
}
if userPatch.Password != nil && *userPatch.Password != "" {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(*userPatch.Password), bcrypt.DefaultCost)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
}
passwordHashStr := string(passwordHash)
userPatch.PasswordHash = &passwordHashStr
}
user, err := s.Store.PatchUser(userPatch)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch user").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err)
}
return nil
return c.JSON(http.StatusOK, true)
})
}
+4 -2
View File
@@ -2,6 +2,7 @@ package server
import (
"fmt"
"html"
"net/http"
"strconv"
@@ -16,17 +17,18 @@ func (s *Server) registerWebhookRoutes(g *echo.Group) {
})
g.GET("/r/:resourceId/:filename", func(c echo.Context) error {
ctx := c.Request().Context()
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
filename := c.Param("filename")
filename := html.UnescapeString(c.Param("filename"))
resourceFind := &api.ResourceFind{
ID: &resourceID,
Filename: &filename,
}
resource, err := s.Store.FindResource(resourceFind)
resource, err := s.Store.FindResource(ctx, resourceFind)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to fetch resource ID: %v", resourceID)).SetInternal(err)
}
+72
View File
@@ -0,0 +1,72 @@
package store
import (
"bytes"
"encoding/binary"
"encoding/gob"
"fmt"
"github.com/VictoriaMetrics/fastcache"
"github.com/usememos/memos/api"
)
var (
// 64 MiB.
cacheSize = 1024 * 1024 * 64
_ api.CacheService = (*CacheService)(nil)
)
// CacheService implements a cache.
type CacheService struct {
cache *fastcache.Cache
}
// NewCacheService creates a cache service.
func NewCacheService() *CacheService {
return &CacheService{
cache: fastcache.New(cacheSize),
}
}
// FindCache finds the value in cache.
func (s *CacheService) FindCache(namespace api.CacheNamespace, id int, entry interface{}) (bool, error) {
buf1 := []byte{0, 0, 0, 0, 0, 0, 0, 0}
binary.LittleEndian.PutUint64(buf1, uint64(id))
buf2, has := s.cache.HasGet(nil, append([]byte(namespace), buf1...))
if has {
dec := gob.NewDecoder(bytes.NewReader(buf2))
if err := dec.Decode(entry); err != nil {
return false, fmt.Errorf("failed to decode entry for cache namespace: %s, error: %w", namespace, err)
}
return true, nil
}
return false, nil
}
// UpsertCache upserts the value to cache.
func (s *CacheService) UpsertCache(namespace api.CacheNamespace, id int, entry interface{}) error {
buf1 := []byte{0, 0, 0, 0, 0, 0, 0, 0}
binary.LittleEndian.PutUint64(buf1, uint64(id))
var buf2 bytes.Buffer
enc := gob.NewEncoder(&buf2)
if err := enc.Encode(entry); err != nil {
return fmt.Errorf("failed to encode entry for cache namespace: %s, error: %w", namespace, err)
}
s.cache.Set(append([]byte(namespace), buf1...), buf2.Bytes())
return nil
}
// DeleteCache deletes the cache.
func (s *CacheService) DeleteCache(namespace api.CacheNamespace, id int) {
buf1 := []byte{0, 0, 0, 0, 0, 0, 0, 0}
binary.LittleEndian.PutUint64(buf1, uint64(id))
_, has := s.cache.HasGet(nil, append([]byte(namespace), buf1...))
if has {
s.cache.Del(append([]byte(namespace), buf1...))
}
}
+40 -30
View File
@@ -1,12 +1,12 @@
package db
import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
"io/fs"
"io/ioutil"
"os"
"regexp"
"sort"
@@ -14,8 +14,6 @@ import (
"github.com/usememos/memos/common"
"github.com/usememos/memos/server/profile"
_ "github.com/mattn/go-sqlite3"
)
//go:embed migration
@@ -38,7 +36,7 @@ func NewDB(profile *profile.Profile) *DB {
return db
}
func (db *DB) Open() (err error) {
func (db *DB) Open(ctx context.Context) (err error) {
// Ensure a DSN is set before attempting to open the database.
if db.profile.DSN == "" {
return fmt.Errorf("dsn required")
@@ -53,32 +51,32 @@ func (db *DB) Open() (err error) {
db.Db = sqlDB
// If mode is dev, we should migrate and seed the database.
if db.profile.Mode == "dev" {
if err := db.applyLatestSchema(); err != nil {
if err := db.applyLatestSchema(ctx); err != nil {
return fmt.Errorf("failed to apply latest schema: %w", err)
}
if err := db.seed(); err != nil {
if err := db.seed(ctx); err != nil {
return fmt.Errorf("failed to seed: %w", err)
}
} else {
// If db file not exists, we should migrate the database.
if _, err := os.Stat(db.profile.DSN); errors.Is(err, os.ErrNotExist) {
err := db.applyLatestSchema()
err := db.applyLatestSchema(ctx)
if err != nil {
return fmt.Errorf("failed to apply latest schema: %w", err)
}
} else {
err := db.createMigrationHistoryTable()
err := db.createMigrationHistoryTable(ctx)
if err != nil {
return fmt.Errorf("failed to create migration_history table: %w", err)
}
currentVersion := common.GetCurrentVersion(db.profile.Mode)
migrationHistory, err := findMigrationHistory(db.Db, &MigrationHistoryFind{})
migrationHistory, err := db.FindMigrationHistory(ctx, &MigrationHistoryFind{})
if err != nil {
return err
}
if migrationHistory == nil {
migrationHistory, err = upsertMigrationHistory(db.Db, &MigrationHistoryUpsert{
migrationHistory, err = db.UpsertMigrationHistory(ctx, &MigrationHistoryUpsert{
Version: currentVersion,
})
if err != nil {
@@ -90,12 +88,12 @@ func (db *DB) Open() (err error) {
minorVersionList := getMinorVersionList()
// backup the raw database file before migration
rawBytes, err := ioutil.ReadFile(db.profile.DSN)
rawBytes, err := os.ReadFile(db.profile.DSN)
if err != nil {
return fmt.Errorf("failed to read raw database file, err: %w", err)
}
backupDBFilePath := fmt.Sprintf("%s/memos_%s_%d_backup.db", db.profile.Data, db.profile.Version, time.Now().Unix())
if err := ioutil.WriteFile(backupDBFilePath, rawBytes, 0644); err != nil {
if err := os.WriteFile(backupDBFilePath, rawBytes, 0644); err != nil {
return fmt.Errorf("failed to write raw database file, err: %w", err)
}
@@ -105,7 +103,7 @@ func (db *DB) Open() (err error) {
normalizedVersion := minorVersion + ".0"
if common.IsVersionGreaterThan(normalizedVersion, migrationHistory.Version) && common.IsVersionGreaterOrEqualThan(currentVersion, normalizedVersion) {
println("applying migration for", normalizedVersion)
if err := db.applyMigrationForMinorVersion(minorVersion); err != nil {
if err := db.applyMigrationForMinorVersion(ctx, minorVersion); err != nil {
return fmt.Errorf("failed to apply minor version migration: %w", err)
}
}
@@ -127,21 +125,21 @@ const (
latestSchemaFileName = "LATEST__SCHEMA.sql"
)
func (db *DB) applyLatestSchema() error {
latestSchemaPath := fmt.Sprintf("%s/%s", "migration", latestSchemaFileName)
func (db *DB) applyLatestSchema(ctx context.Context) error {
latestSchemaPath := fmt.Sprintf("%s/%s/%s", "migration", db.profile.Mode, latestSchemaFileName)
buf, err := migrationFS.ReadFile(latestSchemaPath)
if err != nil {
return fmt.Errorf("failed to read latest schema %q, error %w", latestSchemaPath, err)
}
stmt := string(buf)
if err := db.execute(stmt); err != nil {
if err := db.execute(ctx, stmt); err != nil {
return fmt.Errorf("migrate error: statement:%s err=%w", stmt, err)
}
return nil
}
func (db *DB) applyMigrationForMinorVersion(minorVersion string) error {
filenames, err := fs.Glob(migrationFS, fmt.Sprintf("%s/%s/*.sql", "migration", minorVersion))
func (db *DB) applyMigrationForMinorVersion(ctx context.Context, minorVersion string) error {
filenames, err := fs.Glob(migrationFS, fmt.Sprintf("%s/%s/*.sql", "migration/prod", minorVersion))
if err != nil {
return err
}
@@ -157,22 +155,28 @@ func (db *DB) applyMigrationForMinorVersion(minorVersion string) error {
}
stmt := string(buf)
migrationStmt += stmt
if err := db.execute(stmt); err != nil {
if err := db.execute(ctx, stmt); err != nil {
return fmt.Errorf("migrate error: statement:%s err=%w", stmt, err)
}
}
tx, err := db.Db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// upsert the newest version to migration_history
if _, err = upsertMigrationHistory(db.Db, &MigrationHistoryUpsert{
if _, err = upsertMigrationHistory(ctx, tx, &MigrationHistoryUpsert{
Version: minorVersion + ".0",
}); err != nil {
return err
}
return nil
return tx.Commit()
}
func (db *DB) seed() error {
func (db *DB) seed(ctx context.Context) error {
filenames, err := fs.Glob(seedFS, fmt.Sprintf("%s/*.sql", "seed"))
if err != nil {
return err
@@ -187,22 +191,22 @@ func (db *DB) seed() error {
return fmt.Errorf("failed to read seed file, filename=%s err=%w", filename, err)
}
stmt := string(buf)
if err := db.execute(stmt); err != nil {
if err := db.execute(ctx, stmt); err != nil {
return fmt.Errorf("seed error: statement:%s err=%w", stmt, err)
}
}
return nil
}
// excecute runs a single SQL statement within a transaction.
func (db *DB) execute(stmt string) error {
// execute runs a single SQL statement within a transaction.
func (db *DB) execute(ctx context.Context, stmt string) error {
tx, err := db.Db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(stmt); err != nil {
if _, err := tx.ExecContext(ctx, stmt); err != nil {
return err
}
@@ -210,7 +214,7 @@ func (db *DB) execute(stmt string) error {
}
// minorDirRegexp is a regular expression for minor version directory.
var minorDirRegexp = regexp.MustCompile(`^migration/[0-9]+\.[0-9]+$`)
var minorDirRegexp = regexp.MustCompile(`^migration/prod/[0-9]+\.[0-9]+$`)
func getMinorVersionList() []string {
minorVersionList := []string{}
@@ -234,8 +238,14 @@ func getMinorVersionList() []string {
}
// createMigrationHistoryTable creates the migration_history table if it doesn't exist.
func (db *DB) createMigrationHistoryTable() error {
if err := createTable(db.Db, `
func (db *DB) createMigrationHistoryTable(ctx context.Context) error {
tx, err := db.Db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := createTable(ctx, tx, `
CREATE TABLE IF NOT EXISTS migration_history (
version TEXT NOT NULL PRIMARY KEY,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now'))
@@ -244,5 +254,5 @@ func (db *DB) createMigrationHistoryTable() error {
return err
}
return nil
return tx.Commit()
}
@@ -3,6 +3,7 @@ DROP TABLE IF EXISTS `memo_organizer`;
DROP TABLE IF EXISTS `memo`;
DROP TABLE IF EXISTS `shortcut`;
DROP TABLE IF EXISTS `resource`;
DROP TABLE IF EXISTS `user_setting`;
DROP TABLE IF EXISTS `user`;
-- user
@@ -44,7 +45,7 @@ CREATE TABLE memo (
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
content TEXT NOT NULL DEFAULT '',
visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PRIVATE')) DEFAULT 'PRIVATE',
visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PROTECTED', 'PRIVATE')) DEFAULT 'PRIVATE',
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
@@ -139,3 +140,13 @@ SET
WHERE
rowid = old.rowid;
END;
-- user_setting
CREATE TABLE user_setting (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX user_setting_key_user_id_index ON user_setting(key, user_id);
@@ -4,8 +4,7 @@ PRAGMA foreign_keys = off;
DROP TABLE IF EXISTS _user_old;
ALTER TABLE
user RENAME TO _user_old;
ALTER TABLE user RENAME TO _user_old;
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -0,0 +1,37 @@
-- change memo visibility field from "PRIVATE"/"PUBLIC" to "PRIVATE"/"PROTECTED"/"PUBLIC".
PRAGMA foreign_keys = off;
DROP TABLE IF EXISTS _memo_old;
ALTER TABLE memo RENAME TO _memo_old;
CREATE TABLE memo (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
content TEXT NOT NULL DEFAULT '',
visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PROTECTED', 'PRIVATE')) DEFAULT 'PRIVATE',
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
INSERT INTO memo (
id, creator_id, created_ts, updated_ts,
row_status, content, visibility
)
SELECT
id,
creator_id,
created_ts,
updated_ts,
row_status,
content,
visibility
FROM
_memo_old;
DROP TABLE IF EXISTS _memo_old;
PRAGMA foreign_keys = on;
@@ -0,0 +1,9 @@
-- user_setting
CREATE TABLE user_setting (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX user_setting_key_user_id_index ON user_setting(key, user_id);
+152
View File
@@ -0,0 +1,152 @@
-- drop all tables
DROP TABLE IF EXISTS `memo_organizer`;
DROP TABLE IF EXISTS `memo`;
DROP TABLE IF EXISTS `shortcut`;
DROP TABLE IF EXISTS `resource`;
DROP TABLE IF EXISTS `user_setting`;
DROP TABLE IF EXISTS `user`;
-- user
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
-- allowed row status are 'NORMAL', 'ARCHIVED'.
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
email TEXT NOT NULL UNIQUE,
role TEXT NOT NULL CHECK (role IN ('HOST', 'USER')) DEFAULT 'USER',
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
open_id TEXT NOT NULL UNIQUE
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('user', 100);
CREATE TRIGGER IF NOT EXISTS `trigger_update_user_modification_time`
AFTER
UPDATE
ON `user` FOR EACH ROW BEGIN
UPDATE
`user`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
-- memo
CREATE TABLE memo (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
content TEXT NOT NULL DEFAULT '',
visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PROTECTED', 'PRIVATE')) DEFAULT 'PRIVATE',
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('memo', 1000);
CREATE TRIGGER IF NOT EXISTS `trigger_update_memo_modification_time`
AFTER
UPDATE
ON `memo` FOR EACH ROW BEGIN
UPDATE
`memo`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
-- memo_organizer
CREATE TABLE memo_organizer (
id INTEGER PRIMARY KEY AUTOINCREMENT,
memo_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
pinned INTEGER NOT NULL CHECK (pinned IN (0, 1)) DEFAULT 0,
FOREIGN KEY(memo_id) REFERENCES memo(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE,
UNIQUE(memo_id, user_id)
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('memo_organizer', 1000);
-- shortcut
CREATE TABLE shortcut (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
title TEXT NOT NULL DEFAULT '',
payload TEXT NOT NULL DEFAULT '{}',
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('shortcut', 10000);
CREATE TRIGGER IF NOT EXISTS `trigger_update_shortcut_modification_time`
AFTER
UPDATE
ON `shortcut` FOR EACH ROW BEGIN
UPDATE
`shortcut`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
-- resource
CREATE TABLE resource (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
filename TEXT NOT NULL DEFAULT '',
blob BLOB NOT NULL,
type TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(creator_id) REFERENCES user(id) ON DELETE CASCADE
);
INSERT INTO
sqlite_sequence (name, seq)
VALUES
('resource', 10000);
CREATE TRIGGER IF NOT EXISTS `trigger_update_resource_modification_time`
AFTER
UPDATE
ON `resource` FOR EACH ROW BEGIN
UPDATE
`resource`
SET
updated_ts = (strftime('%s', 'now'))
WHERE
rowid = old.rowid;
END;
-- user_setting
CREATE TABLE user_setting (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX user_setting_key_user_id_index ON user_setting(key, user_id);
+56 -22
View File
@@ -1,6 +1,7 @@
package db
import (
"context"
"database/sql"
"strings"
)
@@ -18,23 +19,62 @@ type MigrationHistoryFind struct {
Version *string
}
func findMigrationHistoryList(db *sql.DB, find *MigrationHistoryFind) ([]*MigrationHistory, error) {
func (db *DB) FindMigrationHistory(ctx context.Context, find *MigrationHistoryFind) (*MigrationHistory, error) {
tx, err := db.Db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
list, err := findMigrationHistoryList(ctx, tx, find)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, nil
}
migrationHistory := list[0]
return migrationHistory, nil
}
func (db *DB) UpsertMigrationHistory(ctx context.Context, upsert *MigrationHistoryUpsert) (*MigrationHistory, error) {
tx, err := db.Db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
migrationHistory, err := upsertMigrationHistory(ctx, tx, upsert)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return migrationHistory, nil
}
func findMigrationHistoryList(ctx context.Context, tx *sql.Tx, find *MigrationHistoryFind) ([]*MigrationHistory, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.Version; v != nil {
where, args = append(where, "version = ?"), append(args, *v)
}
rows, err := db.Query(`
query := `
SELECT
version,
created_ts
FROM
migration_history
WHERE `+strings.Join(where, " AND ")+`
ORDER BY created_ts DESC`,
args...,
)
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY version DESC
`
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
@@ -53,24 +93,15 @@ func findMigrationHistoryList(db *sql.DB, find *MigrationHistoryFind) ([]*Migrat
migrationHistoryList = append(migrationHistoryList, &migrationHistory)
}
return migrationHistoryList, nil
}
func findMigrationHistory(db *sql.DB, find *MigrationHistoryFind) (*MigrationHistory, error) {
list, err := findMigrationHistoryList(db, find)
if err != nil {
if err := rows.Err(); err != nil {
return nil, err
}
if len(list) == 0 {
return nil, nil
} else {
return list[0], nil
}
return migrationHistoryList, nil
}
func upsertMigrationHistory(db *sql.DB, upsert *MigrationHistoryUpsert) (*MigrationHistory, error) {
row, err := db.Query(`
func upsertMigrationHistory(ctx context.Context, tx *sql.Tx, upsert *MigrationHistoryUpsert) (*MigrationHistory, error) {
query := `
INSERT INTO migration_history (
version
)
@@ -79,9 +110,8 @@ func upsertMigrationHistory(db *sql.DB, upsert *MigrationHistoryUpsert) (*Migrat
SET
version=EXCLUDED.version
RETURNING version, created_ts
`,
upsert.Version,
)
`
row, err := tx.QueryContext(ctx, query, upsert.Version)
if err != nil {
return nil, err
}
@@ -96,5 +126,9 @@ func upsertMigrationHistory(db *sql.DB, upsert *MigrationHistoryUpsert) (*Migrat
return nil, err
}
if err := row.Err(); err != nil {
return nil, err
}
return &migrationHistory, nil
}
+22
View File
@@ -37,3 +37,25 @@ VALUES
-- raw password: secret
'$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK'
);
INSERT INTO
user (
`id`,
`row_status`,
`email`,
`role`,
`name`,
`open_id`,
`password_hash`
)
VALUES
(
103,
'ARCHIVED',
'bob@usememos.com',
'USER',
'Bob',
'bob_open_id',
-- raw password: secret
'$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK'
);
+8 -4
View File
@@ -16,7 +16,8 @@ INSERT INTO
memo (
`id`,
`content`,
`creator_id`
`creator_id`,
`visibility`
)
VALUES
(
@@ -26,7 +27,8 @@ VALUES
- [x] Clean the room;
- [x] Read *📖 The Little Prince*;
(👆 click to toggle status)',
101
101,
'PROTECTED'
);
INSERT INTO
@@ -48,7 +50,8 @@ INSERT INTO
memo (
`id`,
`content`,
`creator_id`
`creator_id`,
`visibility`
)
VALUES
(
@@ -59,7 +62,8 @@ VALUES
- [ ] Watch *👦 The Boys*;
(👆 click to toggle status)
',
102
102,
'PROTECTED'
);
INSERT INTO
+5 -3
View File
@@ -1,10 +1,12 @@
INSERT INTO
shortcut (
`title`,
`creator_id`
`creator_id`,
`payload`
)
VALUES
(
'All my memos',
101
'inbox',
101,
'[{"type":"TYPE","value":{"operator":"IS","value":"NOT_TAGGED"},"relation":"AND"}]'
);
+9 -10
View File
@@ -1,6 +1,7 @@
package db
import (
"context"
"database/sql"
"strings"
)
@@ -11,20 +12,20 @@ type Table struct {
}
//lint:ignore U1000 Ignore unused function temporarily for debugging
func findTable(db *sql.DB, tableName string) (*Table, error) {
//nolint:all
func findTable(ctx context.Context, tx *sql.Tx, tableName string) (*Table, error) {
where, args := []string{"1 = 1"}, []interface{}{}
where, args = append(where, "type = ?"), append(args, "table")
where, args = append(where, "name = ?"), append(args, tableName)
rows, err := db.Query(`
query := `
SELECT
tbl_name,
sql
FROM sqlite_schema
WHERE `+strings.Join(where, " AND "),
args...,
)
WHERE ` + strings.Join(where, " AND ")
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
@@ -54,13 +55,11 @@ func findTable(db *sql.DB, tableName string) (*Table, error) {
}
}
func createTable(db *sql.DB, sql string) error {
result, err := db.Exec(sql)
func createTable(ctx context.Context, tx *sql.Tx, stmt string) error {
_, err := tx.ExecContext(ctx, stmt)
if err != nil {
return err
}
_, err = result.RowsAffected()
return err
return nil
}
+136 -85
View File
@@ -1,6 +1,7 @@
package store
import (
"context"
"database/sql"
"fmt"
"strings"
@@ -43,43 +44,93 @@ func (raw *memoRaw) toMemo() *api.Memo {
}
}
func (s *Store) CreateMemo(create *api.MemoCreate) (*api.Memo, error) {
memoRaw, err := createMemoRaw(s.db, create)
func (s *Store) composeMemo(ctx context.Context, raw *memoRaw) (*api.Memo, error) {
memo := raw.toMemo()
memoOrganizer, err := s.FindMemoOrganizer(ctx, &api.MemoOrganizerFind{
MemoID: memo.ID,
UserID: memo.CreatorID,
})
if err != nil && common.ErrorCode(err) != common.NotFound {
return nil, err
} else if memoOrganizer != nil {
memo.Pinned = memoOrganizer.Pinned
}
return memo, nil
}
func (s *Store) CreateMemo(ctx context.Context, create *api.MemoCreate) (*api.Memo, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
memoRaw, err := createMemoRaw(ctx, tx, create)
if err != nil {
return nil, err
}
memo, err := s.composeMemo(memoRaw)
if err := tx.Commit(); err != nil {
return nil, FormatError(err)
}
memo, err := s.composeMemo(ctx, memoRaw)
if err != nil {
return nil, err
}
if err := s.cache.UpsertCache(api.MemoCache, memo.ID, memo); err != nil {
return nil, err
}
return memo, nil
}
func (s *Store) PatchMemo(patch *api.MemoPatch) (*api.Memo, error) {
memoRaw, err := patchMemoRaw(s.db, patch)
func (s *Store) PatchMemo(ctx context.Context, patch *api.MemoPatch) (*api.Memo, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
memoRaw, err := patchMemoRaw(ctx, tx, patch)
if err != nil {
return nil, err
}
memo, err := s.composeMemo(memoRaw)
if err := tx.Commit(); err != nil {
return nil, FormatError(err)
}
memo, err := s.composeMemo(ctx, memoRaw)
if err != nil {
return nil, err
}
if err := s.cache.UpsertCache(api.MemoCache, memo.ID, memo); err != nil {
return nil, err
}
return memo, nil
}
func (s *Store) FindMemoList(find *api.MemoFind) ([]*api.Memo, error) {
memoRawList, err := findMemoRawList(s.db, find)
func (s *Store) FindMemoList(ctx context.Context, find *api.MemoFind) ([]*api.Memo, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
memoRawList, err := findMemoRawList(ctx, tx, find)
if err != nil {
return nil, err
}
list := []*api.Memo{}
for _, raw := range memoRawList {
memo, err := s.composeMemo(raw)
memo, err := s.composeMemo(ctx, raw)
if err != nil {
return nil, err
}
@@ -90,8 +141,25 @@ func (s *Store) FindMemoList(find *api.MemoFind) ([]*api.Memo, error) {
return list, nil
}
func (s *Store) FindMemo(find *api.MemoFind) (*api.Memo, error) {
list, err := findMemoRawList(s.db, find)
func (s *Store) FindMemo(ctx context.Context, find *api.MemoFind) (*api.Memo, error) {
if find.ID != nil {
memo := &api.Memo{}
has, err := s.cache.FindCache(api.MemoCache, *find.ID, memo)
if err != nil {
return nil, err
}
if has {
return memo, nil
}
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
list, err := findMemoRawList(ctx, tx, find)
if err != nil {
return nil, err
}
@@ -100,52 +168,52 @@ func (s *Store) FindMemo(find *api.MemoFind) (*api.Memo, error) {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
memo, err := s.composeMemo(list[0])
memo, err := s.composeMemo(ctx, list[0])
if err != nil {
return nil, err
}
if err := s.cache.UpsertCache(api.MemoCache, memo.ID, memo); err != nil {
return nil, err
}
return memo, nil
}
func (s *Store) DeleteMemo(delete *api.MemoDelete) error {
err := deleteMemo(s.db, delete)
func (s *Store) DeleteMemo(ctx context.Context, delete *api.MemoDelete) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return FormatError(err)
}
defer tx.Rollback()
if err := deleteMemo(ctx, tx, delete); err != nil {
return FormatError(err)
}
if err := tx.Commit(); err != nil {
return FormatError(err)
}
s.cache.DeleteCache(api.MemoCache, delete.ID)
return nil
}
func createMemoRaw(db *sql.DB, create *api.MemoCreate) (*memoRaw, error) {
set := []string{"creator_id", "content"}
placeholder := []string{"?", "?"}
args := []interface{}{create.CreatorID, create.Content}
if v := create.Visibility; v != nil {
set, placeholder, args = append(set, "visibility"), append(placeholder, "?"), append(args, *v)
}
if v := create.CreatedTs; v != nil {
set, placeholder, args = append(set, "created_ts"), append(placeholder, "?"), append(args, *v)
}
func createMemoRaw(ctx context.Context, tx *sql.Tx, create *api.MemoCreate) (*memoRaw, error) {
set := []string{"creator_id", "content", "visibility"}
args := []interface{}{create.CreatorID, create.Content, create.Visibility}
placeholder := []string{"?", "?", "?"}
query := `
INSERT INTO memo (
` + strings.Join(set, ", ") + `
)
VALUES (` + strings.Join(placeholder, ",") + `)
RETURNING id, creator_id, created_ts, updated_ts, row_status, content, visibility`
row, err := db.Query(query,
args...,
)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
row.Next()
INSERT INTO memo (
` + strings.Join(set, ", ") + `
)
VALUES (` + strings.Join(placeholder, ",") + `)
RETURNING id, creator_id, created_ts, updated_ts, row_status, content, visibility
`
var memoRaw memoRaw
if err := row.Scan(
if err := tx.QueryRowContext(ctx, query, args...).Scan(
&memoRaw.ID,
&memoRaw.CreatorID,
&memoRaw.CreatedTs,
@@ -160,36 +228,32 @@ func createMemoRaw(db *sql.DB, create *api.MemoCreate) (*memoRaw, error) {
return &memoRaw, nil
}
func patchMemoRaw(db *sql.DB, patch *api.MemoPatch) (*memoRaw, error) {
func patchMemoRaw(ctx context.Context, tx *sql.Tx, patch *api.MemoPatch) (*memoRaw, error) {
set, args := []string{}, []interface{}{}
if v := patch.Content; v != nil {
set, args = append(set, "content = ?"), append(args, *v)
if v := patch.CreatedTs; v != nil {
set, args = append(set, "created_ts = ?"), append(args, *v)
}
if v := patch.RowStatus; v != nil {
set, args = append(set, "row_status = ?"), append(args, *v)
}
if v := patch.Content; v != nil {
set, args = append(set, "content = ?"), append(args, *v)
}
if v := patch.Visibility; v != nil {
set, args = append(set, "visibility = ?"), append(args, *v)
}
args = append(args, patch.ID)
row, err := db.Query(`
query := `
UPDATE memo
SET `+strings.Join(set, ", ")+`
SET ` + strings.Join(set, ", ") + `
WHERE id = ?
RETURNING id, creator_id, created_ts, updated_ts, row_status, content, visibility
`, args...)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
row.Next()
`
var memoRaw memoRaw
if err := row.Scan(
if err := tx.QueryRowContext(ctx, query, args...).Scan(
&memoRaw.ID,
&memoRaw.CreatorID,
&memoRaw.CreatedTs,
@@ -204,7 +268,7 @@ func patchMemoRaw(db *sql.DB, patch *api.MemoPatch) (*memoRaw, error) {
return &memoRaw, nil
}
func findMemoRawList(db *sql.DB, find *api.MemoFind) ([]*memoRaw, error) {
func findMemoRawList(ctx context.Context, tx *sql.Tx, find *api.MemoFind) ([]*memoRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.ID; v != nil {
@@ -222,8 +286,13 @@ func findMemoRawList(db *sql.DB, find *api.MemoFind) ([]*memoRaw, error) {
if v := find.ContentSearch; v != nil {
where, args = append(where, "content LIKE ?"), append(args, "%"+*v+"%")
}
if v := find.Visibility; v != nil {
where, args = append(where, "visibility = ?"), append(args, *v)
if v := find.VisibilityList; len(v) != 0 {
list := []string{}
for _, visibility := range v {
list = append(list, fmt.Sprintf("$%d", len(args)+1))
args = append(args, visibility)
}
where = append(where, fmt.Sprintf("visibility in (%s)", strings.Join(list, ",")))
}
pagination := ""
@@ -234,7 +303,7 @@ func findMemoRawList(db *sql.DB, find *api.MemoFind) ([]*memoRaw, error) {
}
}
rows, err := db.Query(`
query := `
SELECT
id,
creator_id,
@@ -244,10 +313,10 @@ func findMemoRawList(db *sql.DB, find *api.MemoFind) ([]*memoRaw, error) {
content,
visibility
FROM memo
WHERE `+strings.Join(where, " AND ")+`
ORDER BY created_ts DESC`+pagination,
args...,
)
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY created_ts DESC
` + pagination
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, FormatError(err)
}
@@ -278,32 +347,14 @@ func findMemoRawList(db *sql.DB, find *api.MemoFind) ([]*memoRaw, error) {
return memoRawList, nil
}
func deleteMemo(db *sql.DB, delete *api.MemoDelete) error {
result, err := db.Exec(`DELETE FROM memo WHERE id = ?`, delete.ID)
func deleteMemo(ctx context.Context, tx *sql.Tx, delete *api.MemoDelete) error {
_, err := tx.ExecContext(ctx, `
PRAGMA foreign_keys = ON;
DELETE FROM memo WHERE id = ?
`, delete.ID)
if err != nil {
return FormatError(err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("memo ID not found: %d", delete.ID)}
}
return nil
}
func (s *Store) composeMemo(raw *memoRaw) (*api.Memo, error) {
memo := raw.toMemo()
memoOrganizer, err := s.FindMemoOrganizer(&api.MemoOrganizerFind{
MemoID: memo.ID,
UserID: memo.CreatorID,
})
if err != nil && common.ErrorCode(err) != common.NotFound {
return nil, err
} else if memoOrganizer != nil {
memo.Pinned = memoOrganizer.Pinned
}
return memo, nil
}
+32 -22
View File
@@ -1,6 +1,7 @@
package store
import (
"context"
"database/sql"
"fmt"
@@ -29,8 +30,14 @@ func (raw *memoOrganizerRaw) toMemoOrganizer() *api.MemoOrganizer {
}
}
func (s *Store) FindMemoOrganizer(find *api.MemoOrganizerFind) (*api.MemoOrganizer, error) {
memoOrganizerRaw, err := findMemoOrganizer(s.db, find)
func (s *Store) FindMemoOrganizer(ctx context.Context, find *api.MemoOrganizerFind) (*api.MemoOrganizer, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
memoOrganizerRaw, err := findMemoOrganizer(ctx, tx, find)
if err != nil {
return nil, err
}
@@ -40,17 +47,26 @@ func (s *Store) FindMemoOrganizer(find *api.MemoOrganizerFind) (*api.MemoOrganiz
return memoOrganizer, nil
}
func (s *Store) UpsertMemoOrganizer(upsert *api.MemoOrganizerUpsert) error {
err := upsertMemoOrganizer(s.db, upsert)
func (s *Store) UpsertMemoOrganizer(ctx context.Context, upsert *api.MemoOrganizerUpsert) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return FormatError(err)
}
defer tx.Rollback()
if err := upsertMemoOrganizer(ctx, tx, upsert); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return FormatError(err)
}
return nil
}
func findMemoOrganizer(db *sql.DB, find *api.MemoOrganizerFind) (*memoOrganizerRaw, error) {
row, err := db.Query(`
func findMemoOrganizer(ctx context.Context, tx *sql.Tx, find *api.MemoOrganizerFind) (*memoOrganizerRaw, error) {
query := `
SELECT
id,
memo_id,
@@ -58,7 +74,8 @@ func findMemoOrganizer(db *sql.DB, find *api.MemoOrganizerFind) (*memoOrganizerR
pinned
FROM memo_organizer
WHERE memo_id = ? AND user_id = ?
`, find.MemoID, find.UserID)
`
row, err := tx.QueryContext(ctx, query, find.MemoID, find.UserID)
if err != nil {
return nil, FormatError(err)
}
@@ -78,11 +95,15 @@ func findMemoOrganizer(db *sql.DB, find *api.MemoOrganizerFind) (*memoOrganizerR
return nil, FormatError(err)
}
if err := row.Err(); err != nil {
return nil, err
}
return &memoOrganizerRaw, nil
}
func upsertMemoOrganizer(db *sql.DB, upsert *api.MemoOrganizerUpsert) error {
row, err := db.Query(`
func upsertMemoOrganizer(ctx context.Context, tx *sql.Tx, upsert *api.MemoOrganizerUpsert) error {
query := `
INSERT INTO memo_organizer (
memo_id,
user_id,
@@ -93,20 +114,9 @@ func upsertMemoOrganizer(db *sql.DB, upsert *api.MemoOrganizerUpsert) error {
SET
pinned = EXCLUDED.pinned
RETURNING id, memo_id, user_id, pinned
`,
upsert.MemoID,
upsert.UserID,
upsert.Pinned,
)
if err != nil {
return FormatError(err)
}
defer row.Close()
row.Next()
`
var memoOrganizer api.MemoOrganizer
if err := row.Scan(
if err := tx.QueryRowContext(ctx, query, upsert.MemoID, upsert.UserID, upsert.Pinned).Scan(
&memoOrganizer.ID,
&memoOrganizer.MemoID,
&memoOrganizer.UserID,
+81 -38
View File
@@ -1,6 +1,7 @@
package store
import (
"context"
"database/sql"
"fmt"
"strings"
@@ -43,19 +44,39 @@ func (raw *resourceRaw) toResource() *api.Resource {
}
}
func (s *Store) CreateResource(create *api.ResourceCreate) (*api.Resource, error) {
resourceRaw, err := createResource(s.db, create)
func (s *Store) CreateResource(ctx context.Context, create *api.ResourceCreate) (*api.Resource, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
resourceRaw, err := createResource(ctx, tx, create)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, FormatError(err)
}
resource := resourceRaw.toResource()
if err := s.cache.UpsertCache(api.ResourceCache, resource.ID, resource); err != nil {
return nil, err
}
return resource, nil
}
func (s *Store) FindResourceList(find *api.ResourceFind) ([]*api.Resource, error) {
resourceRawList, err := findResourceList(s.db, find)
func (s *Store) FindResourceList(ctx context.Context, find *api.ResourceFind) ([]*api.Resource, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
resourceRawList, err := findResourceList(ctx, tx, find)
if err != nil {
return nil, err
}
@@ -68,8 +89,25 @@ func (s *Store) FindResourceList(find *api.ResourceFind) ([]*api.Resource, error
return resourceList, nil
}
func (s *Store) FindResource(find *api.ResourceFind) (*api.Resource, error) {
list, err := findResourceList(s.db, find)
func (s *Store) FindResource(ctx context.Context, find *api.ResourceFind) (*api.Resource, error) {
if find.ID != nil {
resource := &api.Resource{}
has, err := s.cache.FindCache(api.ResourceCache, *find.ID, resource)
if err != nil {
return nil, err
}
if has {
return resource, nil
}
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
list, err := findResourceList(ctx, tx, find)
if err != nil {
return nil, err
}
@@ -80,20 +118,36 @@ func (s *Store) FindResource(find *api.ResourceFind) (*api.Resource, error) {
resource := list[0].toResource()
if err := s.cache.UpsertCache(api.ResourceCache, resource.ID, resource); err != nil {
return nil, err
}
return resource, nil
}
func (s *Store) DeleteResource(delete *api.ResourceDelete) error {
err := deleteResource(s.db, delete)
func (s *Store) DeleteResource(ctx context.Context, delete *api.ResourceDelete) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return FormatError(err)
}
defer tx.Rollback()
err = deleteResource(ctx, tx, delete)
if err != nil {
return err
}
if err := tx.Commit(); err != nil {
return FormatError(err)
}
s.cache.DeleteCache(api.ResourceCache, delete.ID)
return nil
}
func createResource(db *sql.DB, create *api.ResourceCreate) (*resourceRaw, error) {
row, err := db.Query(`
func createResource(ctx context.Context, tx *sql.Tx, create *api.ResourceCreate) (*resourceRaw, error) {
query := `
INSERT INTO resource (
filename,
blob,
@@ -102,27 +156,16 @@ func createResource(db *sql.DB, create *api.ResourceCreate) (*resourceRaw, error
creator_id
)
VALUES (?, ?, ?, ?, ?)
RETURNING id, filename, blob, type, size, created_ts, updated_ts
`,
create.Filename,
create.Blob,
create.Type,
create.Size,
create.CreatorID,
)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
row.Next()
RETURNING id, filename, blob, type, size, creator_id, created_ts, updated_ts
`
var resourceRaw resourceRaw
if err := row.Scan(
if err := tx.QueryRowContext(ctx, query, create.Filename, create.Blob, create.Type, create.Size, create.CreatorID).Scan(
&resourceRaw.ID,
&resourceRaw.Filename,
&resourceRaw.Blob,
&resourceRaw.Type,
&resourceRaw.Size,
&resourceRaw.CreatorID,
&resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs,
); err != nil {
@@ -132,7 +175,7 @@ func createResource(db *sql.DB, create *api.ResourceCreate) (*resourceRaw, error
return &resourceRaw, nil
}
func findResourceList(db *sql.DB, find *api.ResourceFind) ([]*resourceRaw, error) {
func findResourceList(ctx context.Context, tx *sql.Tx, find *api.ResourceFind) ([]*resourceRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.ID; v != nil {
@@ -145,20 +188,21 @@ func findResourceList(db *sql.DB, find *api.ResourceFind) ([]*resourceRaw, error
where, args = append(where, "filename = ?"), append(args, *v)
}
rows, err := db.Query(`
query := `
SELECT
id,
filename,
blob,
type,
size,
creator_id,
created_ts,
updated_ts
FROM resource
WHERE `+strings.Join(where, " AND ")+`
ORDER BY created_ts DESC`,
args...,
)
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY created_ts DESC
`
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, FormatError(err)
}
@@ -173,6 +217,7 @@ func findResourceList(db *sql.DB, find *api.ResourceFind) ([]*resourceRaw, error
&resourceRaw.Blob,
&resourceRaw.Type,
&resourceRaw.Size,
&resourceRaw.CreatorID,
&resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs,
); err != nil {
@@ -189,16 +234,14 @@ func findResourceList(db *sql.DB, find *api.ResourceFind) ([]*resourceRaw, error
return resourceRawList, nil
}
func deleteResource(db *sql.DB, delete *api.ResourceDelete) error {
result, err := db.Exec(`DELETE FROM resource WHERE id = ?`, delete.ID)
func deleteResource(ctx context.Context, tx *sql.Tx, delete *api.ResourceDelete) error {
_, err := tx.ExecContext(ctx, `
PRAGMA foreign_keys = ON;
DELETE FROM resource WHERE id = ? AND creator_id = ?
`, delete.ID, delete.CreatorID)
if err != nil {
return FormatError(err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("resource ID not found: %d", delete.ID)}
}
return nil
}
+101 -54
View File
@@ -1,6 +1,7 @@
package store
import (
"context"
"database/sql"
"fmt"
"strings"
@@ -39,30 +40,64 @@ func (raw *shortcutRaw) toShortcut() *api.Shortcut {
}
}
func (s *Store) CreateShortcut(create *api.ShortcutCreate) (*api.Shortcut, error) {
shortcutRaw, err := createShortcut(s.db, create)
func (s *Store) CreateShortcut(ctx context.Context, create *api.ShortcutCreate) (*api.Shortcut, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
shortcutRaw, err := createShortcut(ctx, tx, create)
if err != nil {
return nil, err
}
shortcut := shortcutRaw.toShortcut()
return shortcut, nil
}
func (s *Store) PatchShortcut(patch *api.ShortcutPatch) (*api.Shortcut, error) {
shortcutRaw, err := patchShortcut(s.db, patch)
if err != nil {
return nil, err
if err := tx.Commit(); err != nil {
return nil, FormatError(err)
}
shortcut := shortcutRaw.toShortcut()
if err := s.cache.UpsertCache(api.ShortcutCache, shortcut.ID, shortcut); err != nil {
return nil, err
}
return shortcut, nil
}
func (s *Store) FindShortcutList(find *api.ShortcutFind) ([]*api.Shortcut, error) {
shortcutRawList, err := findShortcutList(s.db, find)
func (s *Store) PatchShortcut(ctx context.Context, patch *api.ShortcutPatch) (*api.Shortcut, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
shortcutRaw, err := patchShortcut(ctx, tx, patch)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, FormatError(err)
}
shortcut := shortcutRaw.toShortcut()
if err := s.cache.UpsertCache(api.ShortcutCache, shortcut.ID, shortcut); err != nil {
return nil, err
}
return shortcut, nil
}
func (s *Store) FindShortcutList(ctx context.Context, find *api.ShortcutFind) ([]*api.Shortcut, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
shortcutRawList, err := findShortcutList(ctx, tx, find)
if err != nil {
return nil, err
}
@@ -75,8 +110,25 @@ func (s *Store) FindShortcutList(find *api.ShortcutFind) ([]*api.Shortcut, error
return list, nil
}
func (s *Store) FindShortcut(find *api.ShortcutFind) (*api.Shortcut, error) {
list, err := findShortcutList(s.db, find)
func (s *Store) FindShortcut(ctx context.Context, find *api.ShortcutFind) (*api.Shortcut, error) {
if find.ID != nil {
shortcut := &api.Shortcut{}
has, err := s.cache.FindCache(api.ShortcutCache, *find.ID, shortcut)
if err != nil {
return nil, err
}
if has {
return shortcut, nil
}
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
list, err := findShortcutList(ctx, tx, find)
if err != nil {
return nil, err
}
@@ -87,20 +139,36 @@ func (s *Store) FindShortcut(find *api.ShortcutFind) (*api.Shortcut, error) {
shortcut := list[0].toShortcut()
if err := s.cache.UpsertCache(api.ShortcutCache, shortcut.ID, shortcut); err != nil {
return nil, err
}
return shortcut, nil
}
func (s *Store) DeleteShortcut(delete *api.ShortcutDelete) error {
err := deleteShortcut(s.db, delete)
func (s *Store) DeleteShortcut(ctx context.Context, delete *api.ShortcutDelete) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return FormatError(err)
}
defer tx.Rollback()
err = deleteShortcut(ctx, tx, delete)
if err != nil {
return FormatError(err)
}
if err := tx.Commit(); err != nil {
return FormatError(err)
}
s.cache.DeleteCache(api.ShortcutCache, delete.ID)
return nil
}
func createShortcut(db *sql.DB, create *api.ShortcutCreate) (*shortcutRaw, error) {
row, err := db.Query(`
func createShortcut(ctx context.Context, tx *sql.Tx, create *api.ShortcutCreate) (*shortcutRaw, error) {
query := `
INSERT INTO shortcut (
title,
payload,
@@ -108,19 +176,9 @@ func createShortcut(db *sql.DB, create *api.ShortcutCreate) (*shortcutRaw, error
)
VALUES (?, ?, ?)
RETURNING id, title, payload, creator_id, created_ts, updated_ts, row_status
`,
create.Title,
create.Payload,
create.CreatorID,
)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
row.Next()
`
var shortcutRaw shortcutRaw
if err := row.Scan(
if err := tx.QueryRowContext(ctx, query, create.Title, create.Payload, create.CreatorID).Scan(
&shortcutRaw.ID,
&shortcutRaw.Title,
&shortcutRaw.Payload,
@@ -135,7 +193,7 @@ func createShortcut(db *sql.DB, create *api.ShortcutCreate) (*shortcutRaw, error
return &shortcutRaw, nil
}
func patchShortcut(db *sql.DB, patch *api.ShortcutPatch) (*shortcutRaw, error) {
func patchShortcut(ctx context.Context, tx *sql.Tx, patch *api.ShortcutPatch) (*shortcutRaw, error) {
set, args := []string{}, []interface{}{}
if v := patch.Title; v != nil {
@@ -150,23 +208,14 @@ func patchShortcut(db *sql.DB, patch *api.ShortcutPatch) (*shortcutRaw, error) {
args = append(args, patch.ID)
row, err := db.Query(`
query := `
UPDATE shortcut
SET `+strings.Join(set, ", ")+`
SET ` + strings.Join(set, ", ") + `
WHERE id = ?
RETURNING id, title, payload, created_ts, updated_ts, row_status
`, args...)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
if !row.Next() {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
`
var shortcutRaw shortcutRaw
if err := row.Scan(
if err := tx.QueryRowContext(ctx, query, args...).Scan(
&shortcutRaw.ID,
&shortcutRaw.Title,
&shortcutRaw.Payload,
@@ -180,7 +229,7 @@ func patchShortcut(db *sql.DB, patch *api.ShortcutPatch) (*shortcutRaw, error) {
return &shortcutRaw, nil
}
func findShortcutList(db *sql.DB, find *api.ShortcutFind) ([]*shortcutRaw, error) {
func findShortcutList(ctx context.Context, tx *sql.Tx, find *api.ShortcutFind) ([]*shortcutRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.ID; v != nil {
@@ -193,7 +242,7 @@ func findShortcutList(db *sql.DB, find *api.ShortcutFind) ([]*shortcutRaw, error
where, args = append(where, "title = ?"), append(args, *v)
}
rows, err := db.Query(`
rows, err := tx.QueryContext(ctx, `
SELECT
id,
title,
@@ -237,16 +286,14 @@ func findShortcutList(db *sql.DB, find *api.ShortcutFind) ([]*shortcutRaw, error
return shortcutRawList, nil
}
func deleteShortcut(db *sql.DB, delete *api.ShortcutDelete) error {
result, err := db.Exec(`DELETE FROM shortcut WHERE id = ?`, delete.ID)
func deleteShortcut(ctx context.Context, tx *sql.Tx, delete *api.ShortcutDelete) error {
_, err := tx.ExecContext(ctx, `
PRAGMA foreign_keys = ON;
DELETE FROM shortcut WHERE id = ?
`, delete.ID)
if err != nil {
return FormatError(err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("shortcut ID not found: %d", delete.ID)}
}
return nil
}
+7 -2
View File
@@ -3,19 +3,24 @@ package store
import (
"database/sql"
"github.com/usememos/memos/api"
"github.com/usememos/memos/server/profile"
)
// Store provides database access to all raw objects
// Store provides database access to all raw objects.
type Store struct {
db *sql.DB
profile *profile.Profile
cache api.CacheService
}
// New creates a new instance of Store
// New creates a new instance of Store.
func New(db *sql.DB, profile *profile.Profile) *Store {
cacheService := NewCacheService()
return &Store{
db: db,
profile: profile,
cache: cacheService,
}
}
+124 -38
View File
@@ -1,6 +1,7 @@
package store
import (
"context"
"database/sql"
"fmt"
"strings"
@@ -43,30 +44,64 @@ func (raw *userRaw) toUser() *api.User {
}
}
func (s *Store) CreateUser(create *api.UserCreate) (*api.User, error) {
userRaw, err := createUser(s.db, create)
func (s *Store) CreateUser(ctx context.Context, create *api.UserCreate) (*api.User, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
userRaw, err := createUser(ctx, tx, create)
if err != nil {
return nil, err
}
user := userRaw.toUser()
return user, nil
}
func (s *Store) PatchUser(patch *api.UserPatch) (*api.User, error) {
userRaw, err := patchUser(s.db, patch)
if err != nil {
return nil, err
if err := tx.Commit(); err != nil {
return nil, FormatError(err)
}
user := userRaw.toUser()
if err := s.cache.UpsertCache(api.UserCache, user.ID, user); err != nil {
return nil, err
}
return user, nil
}
func (s *Store) FindUserList(find *api.UserFind) ([]*api.User, error) {
userRawList, err := findUserList(s.db, find)
func (s *Store) PatchUser(ctx context.Context, patch *api.UserPatch) (*api.User, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
userRaw, err := patchUser(ctx, tx, patch)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, FormatError(err)
}
user := userRaw.toUser()
if err := s.cache.UpsertCache(api.UserCache, user.ID, user); err != nil {
return nil, err
}
return user, nil
}
func (s *Store) FindUserList(ctx context.Context, find *api.UserFind) ([]*api.User, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
userRawList, err := findUserList(ctx, tx, find)
if err != nil {
return nil, err
}
@@ -79,8 +114,25 @@ func (s *Store) FindUserList(find *api.UserFind) ([]*api.User, error) {
return list, nil
}
func (s *Store) FindUser(find *api.UserFind) (*api.User, error) {
list, err := findUserList(s.db, find)
func (s *Store) FindUser(ctx context.Context, find *api.UserFind) (*api.User, error) {
if find.ID != nil {
user := &api.User{}
has, err := s.cache.FindCache(api.UserCache, *find.ID, user)
if err != nil {
return nil, err
}
if has {
return user, nil
}
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
list, err := findUserList(ctx, tx, find)
if err != nil {
return nil, err
}
@@ -93,11 +145,36 @@ func (s *Store) FindUser(find *api.UserFind) (*api.User, error) {
user := list[0].toUser()
if err := s.cache.UpsertCache(api.UserCache, user.ID, user); err != nil {
return nil, err
}
return user, nil
}
func createUser(db *sql.DB, create *api.UserCreate) (*userRaw, error) {
row, err := db.Query(`
func (s *Store) DeleteUser(ctx context.Context, delete *api.UserDelete) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return FormatError(err)
}
defer tx.Rollback()
err = deleteUser(ctx, tx, delete)
if err != nil {
return FormatError(err)
}
if err := tx.Commit(); err != nil {
return FormatError(err)
}
s.cache.DeleteCache(api.UserCache, delete.ID)
return nil
}
func createUser(ctx context.Context, tx *sql.Tx, create *api.UserCreate) (*userRaw, error) {
query := `
INSERT INTO user (
email,
role,
@@ -107,21 +184,15 @@ func createUser(db *sql.DB, create *api.UserCreate) (*userRaw, error) {
)
VALUES (?, ?, ?, ?, ?)
RETURNING id, email, role, name, password_hash, open_id, created_ts, updated_ts, row_status
`,
`
var userRaw userRaw
if err := tx.QueryRowContext(ctx, query,
create.Email,
create.Role,
create.Name,
create.PasswordHash,
create.OpenID,
)
if err != nil {
return nil, FormatError(err)
}
defer row.Close()
row.Next()
var userRaw userRaw
if err := row.Scan(
).Scan(
&userRaw.ID,
&userRaw.Email,
&userRaw.Role,
@@ -138,7 +209,7 @@ func createUser(db *sql.DB, create *api.UserCreate) (*userRaw, error) {
return &userRaw, nil
}
func patchUser(db *sql.DB, patch *api.UserPatch) (*userRaw, error) {
func patchUser(ctx context.Context, tx *sql.Tx, patch *api.UserPatch) (*userRaw, error) {
set, args := []string{}, []interface{}{}
if v := patch.RowStatus; v != nil {
@@ -159,12 +230,13 @@ func patchUser(db *sql.DB, patch *api.UserPatch) (*userRaw, error) {
args = append(args, patch.ID)
row, err := db.Query(`
query := `
UPDATE user
SET `+strings.Join(set, ", ")+`
SET ` + strings.Join(set, ", ") + `
WHERE id = ?
RETURNING id, email, role, name, password_hash, open_id, created_ts, updated_ts, row_status
`, args...)
`
row, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, FormatError(err)
}
@@ -186,13 +258,17 @@ func patchUser(db *sql.DB, patch *api.UserPatch) (*userRaw, error) {
return nil, FormatError(err)
}
if err := row.Err(); err != nil {
return nil, err
}
return &userRaw, nil
}
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("user ID not found: %d", patch.ID)}
}
func findUserList(db *sql.DB, find *api.UserFind) ([]*userRaw, error) {
func findUserList(ctx context.Context, tx *sql.Tx, find *api.UserFind) ([]*userRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.ID; v != nil {
@@ -211,7 +287,7 @@ func findUserList(db *sql.DB, find *api.UserFind) ([]*userRaw, error) {
where, args = append(where, "open_id = ?"), append(args, *v)
}
rows, err := db.Query(`
query := `
SELECT
id,
email,
@@ -223,10 +299,10 @@ func findUserList(db *sql.DB, find *api.UserFind) ([]*userRaw, error) {
updated_ts,
row_status
FROM user
WHERE `+strings.Join(where, " AND ")+`
ORDER BY created_ts DESC`,
args...,
)
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY created_ts DESC, row_status DESC
`
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, FormatError(err)
}
@@ -246,7 +322,6 @@ func findUserList(db *sql.DB, find *api.UserFind) ([]*userRaw, error) {
&userRaw.UpdatedTs,
&userRaw.RowStatus,
); err != nil {
fmt.Println(err)
return nil, FormatError(err)
}
@@ -259,3 +334,14 @@ func findUserList(db *sql.DB, find *api.UserFind) ([]*userRaw, error) {
return userRawList, nil
}
func deleteUser(ctx context.Context, tx *sql.Tx, delete *api.UserDelete) error {
if _, err := tx.ExecContext(ctx, `
PRAGMA foreign_keys = ON;
DELETE FROM user WHERE id = ?
`, delete.ID); err != nil {
return FormatError(err)
}
return nil
}
+151
View File
@@ -0,0 +1,151 @@
package store
import (
"context"
"database/sql"
"strings"
"github.com/usememos/memos/api"
)
type userSettingRaw struct {
UserID int
Key api.UserSettingKey
Value string
}
func (raw *userSettingRaw) toUserSetting() *api.UserSetting {
return &api.UserSetting{
UserID: raw.UserID,
Key: raw.Key,
Value: raw.Value,
}
}
func (s *Store) UpsertUserSetting(ctx context.Context, upsert *api.UserSettingUpsert) (*api.UserSetting, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
userSettingRaw, err := upsertUserSetting(ctx, tx, upsert)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
userSetting := userSettingRaw.toUserSetting()
return userSetting, nil
}
func (s *Store) FindUserSettingList(ctx context.Context, find *api.UserSettingFind) ([]*api.UserSetting, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
userSettingRawList, err := findUserSettingList(ctx, tx, find)
if err != nil {
return nil, err
}
list := []*api.UserSetting{}
for _, raw := range userSettingRawList {
list = append(list, raw.toUserSetting())
}
return list, nil
}
func (s *Store) FindUserSetting(ctx context.Context, find *api.UserSettingFind) (*api.UserSetting, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
list, err := findUserSettingList(ctx, tx, find)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, nil
}
userSetting := list[0].toUserSetting()
return userSetting, nil
}
func upsertUserSetting(ctx context.Context, tx *sql.Tx, upsert *api.UserSettingUpsert) (*userSettingRaw, error) {
query := `
INSERT INTO user_setting (
user_id, key, value
)
VALUES (?, ?, ?)
ON CONFLICT(user_id, key) DO UPDATE
SET
value = EXCLUDED.value
RETURNING user_id, key, value
`
var userSettingRaw userSettingRaw
if err := tx.QueryRowContext(ctx, query, upsert.UserID, upsert.Key, upsert.Value).Scan(
&userSettingRaw.UserID,
&userSettingRaw.Key,
&userSettingRaw.Value,
); err != nil {
return nil, FormatError(err)
}
return &userSettingRaw, nil
}
func findUserSettingList(ctx context.Context, tx *sql.Tx, find *api.UserSettingFind) ([]*userSettingRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.Key; v != nil {
where, args = append(where, "key = ?"), append(args, v.String())
}
where, args = append(where, "user_id = ?"), append(args, find.UserID)
query := `
SELECT
user_id,
key,
value
FROM user_setting
WHERE ` + strings.Join(where, " AND ")
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, FormatError(err)
}
defer rows.Close()
userSettingRawList := make([]*userSettingRaw, 0)
for rows.Next() {
var userSettingRaw userSettingRaw
if err := rows.Scan(
&userSettingRaw.UserID,
&userSettingRaw.Key,
&userSettingRaw.Value,
); err != nil {
return nil, FormatError(err)
}
userSettingRawList = append(userSettingRawList, &userSettingRaw)
}
if err := rows.Err(); err != nil {
return nil, FormatError(err)
}
return userSettingRawList, nil
}
+10 -3
View File
@@ -2,14 +2,21 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" sizes="64x64" type="image/*" />
<link rel="icon" href="/logo.webp" type="image/*" />
<meta name="theme-color" content="#f6f5f4" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<link rel="manifest" href="/manifest.json" />
<title>Memos</title>
<script src="https://kit.fontawesome.com/41e3aaa6af.js" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
window.addEventListener("load", () => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js");
}
});
</script>
</body>
</html>
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "memos",
"version": "0.2.1",
"version": "0.4.2",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -15,6 +15,7 @@
"qs": "^6.11.0",
"react": "^18.1.0",
"react-dom": "^18.1.0",
"react-feather": "^2.0.10",
"react-redux": "^8.0.1"
},
"devDependencies": {
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text x="0.1em" y=".9em" font-size="90">✍️</text></svg>

Before

Width:  |  Height:  |  Size: 121 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+17
View File
@@ -0,0 +1,17 @@
{
"short_name": "Memos",
"name": "Memos",
"icons": [
{
"src": "/logo.webp",
"type": "image/*",
"sizes": "200x200"
}
],
"start_url": "/",
"background_color": "#f6f5f4",
"display": "standalone",
"scope": "/",
"theme_color": "#f6f5f4",
"description": "usememos/memos"
}
+10
View File
@@ -0,0 +1,10 @@
self.addEventListener("install", (event) => {
event.waitUntil((async () => {})());
});
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {})());
});
self.addEventListener("fetch", (event) => {});
+32 -1
View File
@@ -1,10 +1,41 @@
import { useEffect, useState } from "react";
import useI18n from "./hooks/useI18n";
import { appRouterSwitch } from "./routers";
import { globalService, locationService } from "./services";
import { useAppSelector } from "./store";
import * as storage from "./helpers/storage";
function App() {
const { setLocale } = useI18n();
const user = useAppSelector((state) => state.user.user);
const global = useAppSelector((state) => state.global);
const pathname = useAppSelector((state) => state.location.pathname);
const [isLoading, setLoading] = useState(true);
return <>{appRouterSwitch(pathname)}</>;
useEffect(() => {
locationService.updateStateWithLocation();
window.onpopstate = () => {
locationService.updateStateWithLocation();
};
globalService.initialState().then(() => {
setLoading(false);
});
}, []);
useEffect(() => {
if (user?.setting.locale) {
globalService.setLocale(user.setting.locale);
}
}, [user?.setting.locale]);
useEffect(() => {
setLocale(global.locale);
storage.set({
locale: global.locale,
});
}, [global.locale]);
return <>{isLoading ? null : appRouterSwitch(pathname)}</>;
}
export default App;
+11 -8
View File
@@ -1,13 +1,16 @@
import { useEffect, useState } from "react";
import * as api from "../helpers/api";
import useI18n from "../hooks/useI18n";
import Only from "./common/OnlyWhen";
import { showDialog } from "./Dialog";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import GitHubBadge from "./GitHubBadge";
import "../less/about-site-dialog.less";
interface Props extends DialogProps {}
const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
const { t } = useI18n();
const [profile, setProfile] = useState<Profile>();
useEffect(() => {
@@ -34,22 +37,22 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
<>
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🤠</span>About <b>Memos</b>
<span className="icon-text">🤠</span>
{t("common.about")}
</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<i className="fa-solid fa-xmark fa-lg"></i>
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
<p>
Memos is an <i>open source</i>, <i>self-hosted</i> knowledge base that works with a SQLite db file.
</p>
<img className="logo-img" src="/logo-full.webp" alt="" />
<p>{t("slogan")}</p>
<br />
<div className="addtion-info-container">
<GitHubBadge />
<Only when={profile !== undefined}>
<>
version:
{t("common.version")}:
<span className="pre-text">
{profile?.version}-{profile?.mode}
</span>
@@ -63,7 +66,7 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
};
export default function showAboutSiteDialog(): void {
showDialog(
generateDialog(
{
className: "about-site-dialog",
},
+11 -6
View File
@@ -1,11 +1,12 @@
import { IMAGE_URL_REG } from "../helpers/consts";
import { IMAGE_URL_REG } from "../helpers/marked";
import * as utils from "../helpers/utils";
import useI18n from "../hooks/useI18n";
import useToggle from "../hooks/useToggle";
import { memoService } from "../services";
import { formatMemoContent } from "../helpers/marked";
import Only from "./common/OnlyWhen";
import Image from "./Image";
import toastHelper from "./Toast";
import { formatMemoContent } from "./Memo";
import "../less/memo.less";
interface Props {
@@ -19,6 +20,7 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
createdAtStr: utils.getDateTimeString(propsMemo.createdTs),
archivedAtStr: utils.getDateTimeString(propsMemo.updatedTs ?? Date.now()),
};
const { t } = useI18n();
const [showConfirmDeleteBtn, toggleConfirmDeleteBtn] = useToggle(false);
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []).map((s) => s.replace(IMAGE_URL_REG, "$1"));
@@ -28,7 +30,8 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
await memoService.deleteMemoById(memo.id);
await memoService.fetchAllMemos();
} catch (error: any) {
toastHelper.error(error.message);
console.error(error);
toastHelper.error(error.response.data.message);
}
} else {
toggleConfirmDeleteBtn();
@@ -44,7 +47,8 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
await memoService.fetchAllMemos();
toastHelper.info("Restored successfully");
} catch (error: any) {
toastHelper.error(error.message);
console.error(error);
toastHelper.error(error.response.data.message);
}
};
@@ -60,10 +64,11 @@ const ArchivedMemo: React.FC<Props> = (props: Props) => {
<span className="time-text">Archived at {memo.archivedAtStr}</span>
<div className="btns-container">
<span className="btn restore-btn" onClick={handleRestoreMemoClick}>
Restore
{t("common.restore")}
</span>
<span className={`btn delete-btn ${showConfirmDeleteBtn ? "final-confirm" : ""}`} onClick={handleDeleteMemoClick}>
{showConfirmDeleteBtn ? "Delete!" : "Delete"}
{t("common.delete")}
{showConfirmDeleteBtn ? "!" : ""}
</span>
</div>
</div>
+8 -7
View File
@@ -2,7 +2,8 @@ import { useEffect, useState } from "react";
import useLoading from "../hooks/useLoading";
import { memoService } from "../services";
import { useAppSelector } from "../store";
import { showDialog } from "./Dialog";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
import ArchivedMemo from "./ArchivedMemo";
import "../less/archived-memo-dialog.less";
@@ -22,7 +23,8 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
setArchivedMemos(result);
})
.catch((error) => {
toastHelper.error("Failed to fetch archived memos: ", error);
console.error(error);
toastHelper.error(error.response.data.message);
})
.finally(() => {
loadingState.setFinish();
@@ -37,7 +39,7 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
Archived Memos
</p>
<button className="btn close-btn" onClick={destroy}>
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
<Icon.X className="icon-img" />
</button>
</div>
<div className="dialog-content-container">
@@ -47,7 +49,7 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
</div>
) : archivedMemos.length === 0 ? (
<div className="tip-text-container">
<p className="tip-text">Here is No Zettels.</p>
<p className="tip-text">No archived memos.</p>
</div>
) : (
<div className="archived-memos-container">
@@ -61,11 +63,10 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
);
};
export default function showArchivedMemo(): void {
showDialog(
export default function showArchivedMemoDialog(): void {
generateDialog(
{
className: "archived-memo-dialog",
useAppContext: true,
},
ArchivedMemoDialog,
{}
+13
View File
@@ -0,0 +1,13 @@
import "../less/beta-badge.less";
interface Props {
className?: string;
}
const BetaBadge: React.FC<Props> = (props: Props) => {
const { className } = props;
return <span className={`beta-badge ${className ?? ""}`}>beta</span>;
};
export default BetaBadge;
@@ -0,0 +1,99 @@
import { useEffect, useState } from "react";
import dayjs from "dayjs";
import useI18n from "../hooks/useI18n";
import { memoService } from "../services";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
import "../less/change-memo-created-ts-dialog.less";
interface Props extends DialogProps {
memoId: MemoId;
}
const ChangeMemoCreatedTsDialog: React.FC<Props> = (props: Props) => {
const { t } = useI18n();
const { destroy, memoId } = props;
const [createdAt, setCreatedAt] = useState("");
const maxDatetimeValue = dayjs().format("YYYY-MM-DDTHH:mm");
useEffect(() => {
const memo = memoService.getMemoById(memoId);
if (memo) {
const datetime = dayjs(memo.createdTs).format("YYYY-MM-DDTHH:mm");
setCreatedAt(datetime);
} else {
toastHelper.error("Memo not found.");
destroy();
}
}, []);
const handleCloseBtnClick = () => {
destroy();
};
const handleDatetimeInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const datetime = e.target.value as string;
setCreatedAt(datetime);
};
const handleSaveBtnClick = async () => {
const nowTs = dayjs().unix();
const createdTs = dayjs(createdAt).unix();
if (createdTs > nowTs) {
toastHelper.error("Invalid created datetime.");
return;
}
try {
await memoService.patchMemo({
id: memoId,
createdTs,
});
toastHelper.info("Memo created datetime changed.");
handleCloseBtnClick();
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
}
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">Change memo created time</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
<label className="form-label input-form-label">
<input type="datetime-local" value={createdAt} max={maxDatetimeValue} onChange={handleDatetimeInputChange} />
</label>
<div className="btns-container">
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
{t("common.cancel")}
</span>
<span className="btn confirm-btn" onClick={handleSaveBtnClick}>
{t("common.save")}
</span>
</div>
</div>
</>
);
};
function showChangeMemoCreatedTsDialog(memoId: MemoId) {
generateDialog(
{
className: "change-memo-created-ts-dialog",
},
ChangeMemoCreatedTsDialog,
{
memoId,
}
);
}
export default showChangeMemoCreatedTsDialog;
+12 -6
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react";
import { validate, ValidatorConfig } from "../helpers/validator";
import useI18n from "../hooks/useI18n";
import { userService } from "../services";
import { showDialog } from "./Dialog";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
import "../less/change-password-dialog.less";
@@ -15,6 +17,7 @@ const validateConfig: ValidatorConfig = {
interface Props extends DialogProps {}
const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
const { t } = useI18n();
const [newPassword, setNewPassword] = useState("");
const [newPasswordAgain, setNewPasswordAgain] = useState("");
@@ -55,13 +58,16 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
}
try {
const user = userService.getState().user as User;
await userService.patchUser({
id: user.id,
password: newPassword,
});
toastHelper.info("Password changed.");
handleCloseBtnClick();
} catch (error: any) {
toastHelper.error(error);
console.error(error);
toastHelper.error(error.response.data.message);
}
};
@@ -70,7 +76,7 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
<div className="dialog-header-container">
<p className="title-text">Change Password</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<i className="fa-solid fa-xmark fa-lg"></i>
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
@@ -82,10 +88,10 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
</label>
<div className="btns-container">
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
Cancel
{t("common.cancel")}
</span>
<span className="btn confirm-btn" onClick={handleSaveBtnClick}>
Save
{t("common.save")}
</span>
</div>
</div>
@@ -94,7 +100,7 @@ const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
};
function showChangePasswordDialog() {
showDialog(
generateDialog(
{
className: "change-password-dialog",
},
@@ -1,73 +0,0 @@
import { useEffect } from "react";
import { userService } from "../services";
import useLoading from "../hooks/useLoading";
import { showDialog } from "./Dialog";
import toastHelper from "./Toast";
import "../less/confirm-reset-openid-dialog.less";
interface Props extends DialogProps {}
const ConfirmResetOpenIdDialog: React.FC<Props> = ({ destroy }: Props) => {
const resetBtnClickLoadingState = useLoading(false);
useEffect(() => {
// do nth
}, []);
const handleCloseBtnClick = () => {
destroy();
};
const handleConfirmBtnClick = async () => {
if (resetBtnClickLoadingState.isLoading) {
return;
}
resetBtnClickLoadingState.setLoading();
try {
await userService.patchUser({
resetOpenId: true,
});
} catch (error) {
toastHelper.error("Request reset open API failed.");
return;
}
toastHelper.success("Reset open API succeeded.");
handleCloseBtnClick();
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">Reset Open API</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<i className="fa-solid fa-xmark fa-lg"></i>
</button>
</div>
<div className="dialog-content-container">
<p className="warn-text">
The existing API will be invalidated and a new one will be generated, are you sure you want to reset?
</p>
<div className="btns-container">
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
Cancel
</span>
<span className={`btn confirm-btn ${resetBtnClickLoadingState.isLoading ? "loading" : ""}`} onClick={handleConfirmBtnClick}>
Reset!
</span>
</div>
</div>
</>
);
};
function showConfirmResetOpenIdDialog() {
showDialog(
{
className: "confirm-reset-openid-dialog",
},
ConfirmResetOpenIdDialog
);
}
export default showConfirmResetOpenIdDialog;
+71 -118
View File
@@ -1,8 +1,9 @@
import { memo, useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { memoService, shortcutService } from "../services";
import { checkShouldShowMemoWithFilters, filterConsts, getDefaultFilter, relationConsts } from "../helpers/filter";
import useLoading from "../hooks/useLoading";
import { showDialog } from "./Dialog";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
import Selector from "./common/Selector";
import "../less/create-shortcut-dialog.less";
@@ -60,7 +61,8 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
});
}
} catch (error: any) {
toastHelper.error(error.message);
console.error(error);
toastHelper.error(error.response.data.message);
}
destroy();
};
@@ -100,7 +102,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
{shortcutId ? "Edit Shortcut" : "Create Shortcut"}
</p>
<button className="btn close-btn" onClick={destroy}>
<i className="fa-solid fa-xmark fa-lg"></i>
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
@@ -150,129 +152,66 @@ interface MemoFilterInputerProps {
handleFilterRemove: (index: number) => void;
}
const FilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInputerProps) => {
const MemoFilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInputerProps) => {
const { index, filter, handleFilterChange, handleFilterRemove } = props;
const [value, setValue] = useState<string>(filter.value.value);
const tags = Array.from(memoService.getState().tags);
const { type } = filter;
const [inputElements, setInputElements] = useState<JSX.Element>(<></>);
const dataSource =
type === "TYPE"
? filterConsts["TYPE"].values
: tags.sort().map((t) => {
return { text: t, value: t };
});
useEffect(() => {
let operatorElement = <></>;
if (Object.keys(filterConsts).includes(type)) {
operatorElement = (
<Selector
className="operator-selector"
dataSource={Object.values(filterConsts[type as FilterType].operators)}
value={filter.value.operator}
handleValueChanged={handleOperatorChange}
/>
);
}
setValue(filter.value.value);
}, [type]);
let valueElement = <></>;
switch (type) {
case "TYPE": {
valueElement = (
<Selector
className="value-selector"
dataSource={filterConsts["TYPE"].values}
value={filter.value.value}
handleValueChanged={handleValueChange}
/>
);
break;
}
case "TAG": {
valueElement = (
<Selector
className="value-selector"
dataSource={tags.sort().map((t) => {
return { text: t, value: t };
})}
value={filter.value.value}
handleValueChanged={handleValueChange}
/>
);
break;
}
case "TEXT": {
valueElement = (
<input
type="text"
className="value-inputer"
value={filter.value.value}
onChange={(event) => {
handleValueChange(event.target.value);
event.target.focus();
}}
/>
);
break;
}
}
setInputElements(
<>
{operatorElement}
{valueElement}
</>
);
}, [type, filter]);
const handleRelationChange = useCallback(
(value: string) => {
if (["AND", "OR"].includes(value)) {
handleFilterChange(index, {
...filter,
relation: value as MemoFilterRalation,
});
}
},
[filter]
);
const handleTypeChange = useCallback(
(value: string) => {
if (filter.type !== value) {
const ops = Object.values(filterConsts[value as FilterType].operators);
handleFilterChange(index, {
...filter,
type: value as FilterType,
value: {
operator: ops[0].value,
value: "",
},
});
}
},
[filter]
);
const handleOperatorChange = useCallback(
(value: string) => {
const handleRelationChange = (value: string) => {
if (["AND", "OR"].includes(value)) {
handleFilterChange(index, {
...filter,
value: {
...filter.value,
operator: value,
},
relation: value as MemoFilterRalation,
});
},
[filter]
);
}
};
const handleValueChange = useCallback(
(value: string) => {
const handleTypeChange = (value: string) => {
if (filter.type !== value) {
const ops = Object.values(filterConsts[value as FilterType].operators);
handleFilterChange(index, {
...filter,
type: value as FilterType,
value: {
...filter.value,
value,
operator: ops[0].value,
value: "",
},
});
},
[filter]
);
}
};
const handleOperatorChange = (value: string) => {
handleFilterChange(index, {
...filter,
value: {
...filter.value,
operator: value,
},
});
};
const handleValueChange = (value: string) => {
setValue(value);
handleFilterChange(index, {
...filter,
value: {
...filter.value,
value,
},
});
};
const handleRemoveBtnClick = () => {
handleFilterRemove(index);
@@ -294,17 +233,31 @@ const FilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInpute
value={filter.type}
handleValueChanged={handleTypeChange}
/>
{inputElements}
<i className="fa-solid fa-xmark remove-btn" onClick={handleRemoveBtnClick}></i>
<Selector
className="operator-selector"
dataSource={Object.values(filterConsts[type as FilterType].operators)}
value={filter.value.operator}
handleValueChanged={handleOperatorChange}
/>
{type === "TEXT" ? (
<input
type="text"
className="value-inputer"
value={value}
onChange={(event) => {
handleValueChange(event.target.value);
}}
/>
) : (
<Selector className="value-selector" dataSource={dataSource} value={value} handleValueChanged={handleValueChange} />
)}
<Icon.X className="remove-btn" onClick={handleRemoveBtnClick} />
</div>
);
};
const MemoFilterInputer: React.FC<MemoFilterInputerProps> = memo(FilterInputer);
export default function showCreateShortcutDialog(shortcutId?: ShortcutId): void {
showDialog(
generateDialog(
{
className: "create-shortcut-dialog",
},
+9 -12
View File
@@ -1,7 +1,5 @@
import { IMAGE_URL_REG } from "../helpers/consts";
import * as utils from "../helpers/utils";
import Only from "./common/OnlyWhen";
import { formatMemoContent } from "./Memo";
import { formatMemoContent } from "../helpers/marked";
import "../less/daily-memo.less";
interface DailyMemo extends Memo {
@@ -20,7 +18,6 @@ const DailyMemo: React.FC<Props> = (props: Props) => {
createdAtStr: utils.getDateTimeString(propsMemo.createdTs),
timeStr: utils.getTimeString(propsMemo.createdTs),
};
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []).map((s) => s.replace(IMAGE_URL_REG, "$1"));
return (
<div className="daily-memo-wrapper">
@@ -28,14 +25,14 @@ const DailyMemo: React.FC<Props> = (props: Props) => {
<span className="normal-text">{memo.timeStr}</span>
</div>
<div className="memo-content-container">
<div className="memo-content-text" dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}></div>
<Only when={imageUrls.length > 0}>
<div className="images-container">
{imageUrls.map((imgUrl, idx) => (
<img key={idx} src={imgUrl} decoding="async" />
))}
</div>
</Only>
<div
className="memo-content-text"
dangerouslySetInnerHTML={{
__html: formatMemoContent(memo.content, {
inlineImage: true,
}),
}}
></div>
</div>
<div className="split-line"></div>
</div>
+12 -9
View File
@@ -1,10 +1,12 @@
import { useRef, useState } from "react";
import { useAppSelector } from "../store";
import toImage from "../labs/html2image";
import useI18n from "../hooks/useI18n";
import useToggle from "../hooks/useToggle";
import { DAILY_TIMESTAMP } from "../helpers/consts";
import * as utils from "../helpers/utils";
import { showDialog } from "./Dialog";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import DatePicker from "./common/DatePicker";
import showPreviewImageDialog from "./PreviewImageDialog";
import DailyMemo from "./DailyMemo";
@@ -18,6 +20,7 @@ const monthChineseStrArray = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "
const weekdayChineseStrArray = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const DailyReviewDialog: React.FC<Props> = (props: Props) => {
const { t } = useI18n();
const memos = useAppSelector((state) => state.memo.memos);
const [currentDateStamp, setCurrentDateStamp] = useState(utils.getDateStampByDate(utils.getDateString(props.currentDateStamp)));
const [showDatePicker, toggleShowDatePicker] = useToggle(false);
@@ -60,20 +63,21 @@ const DailyReviewDialog: React.FC<Props> = (props: Props) => {
<>
<div className="dialog-header-container">
<p className="title-text" onClick={() => toggleShowDatePicker()}>
<span className="icon-text">📅</span> Daily Review
<span className="icon-text">📅</span> {t("sidebar.daily-review")}
</p>
<div className="btns-container">
<button className="btn-text" onClick={() => setCurrentDateStamp(currentDateStamp - DAILY_TIMESTAMP)}>
<i className="fa-solid fa-chevron-left icon-img"></i>
<Icon.ChevronLeft className="icon-img" />
</button>
<button className="btn-text" onClick={() => setCurrentDateStamp(currentDateStamp + DAILY_TIMESTAMP)}>
<i className="fa-solid fa-chevron-right icon-img"></i>
<Icon.ChevronRight className="icon-img" />
</button>
<button className="btn-text" onClick={handleShareBtnClick}>
<i className="fa-solid fa-share-nodes icon-img"></i>
<button className="btn-text share" onClick={handleShareBtnClick}>
<Icon.Share className="icon-img" />
</button>
<span className="split-line">/</span>
<button className="btn-text" onClick={() => props.destroy()}>
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
<Icon.X className="icon-img" />
</button>
</div>
<DatePicker
@@ -108,10 +112,9 @@ const DailyReviewDialog: React.FC<Props> = (props: Props) => {
};
export default function showDailyReviewDialog(datestamp: DateStamp = Date.now()): void {
showDialog(
generateDialog(
{
className: "daily-review-dialog",
useAppContext: true,
},
DailyReviewDialog,
{ currentDateStamp: datestamp }
@@ -1,12 +1,12 @@
import { useEffect } from "react";
import { createRoot } from "react-dom/client";
import { ANIMATION_DURATION } from "../helpers/consts";
import { Provider } from "react-redux";
import store from "../store";
import "../less/dialog.less";
import { ANIMATION_DURATION } from "../../helpers/consts";
import store from "../../store";
import "../../less/base-dialog.less";
interface DialogConfig {
className: string;
useAppContext?: boolean;
clickSpaceDestroy?: boolean;
}
@@ -17,6 +17,20 @@ interface Props extends DialogConfig, DialogProps {
const BaseDialog: React.FC<Props> = (props: Props) => {
const { children, className, clickSpaceDestroy, destroy } = props;
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code === "Escape") {
destroy();
}
};
document.body.addEventListener("keydown", handleKeyDown);
return () => {
document.body.removeEventListener("keydown", handleKeyDown);
};
}, []);
const handleSpaceClicked = () => {
if (clickSpaceDestroy) {
destroy();
@@ -32,7 +46,7 @@ const BaseDialog: React.FC<Props> = (props: Props) => {
);
};
export function showDialog<T extends DialogProps>(
export function generateDialog<T extends DialogProps>(
config: DialogConfig,
DialogComponent: React.FC<T>,
props?: Omit<T, "destroy">
@@ -61,16 +75,14 @@ export function showDialog<T extends DialogProps>(
destroy: cbs.destroy,
} as T;
let Fragment = (
<BaseDialog destroy={cbs.destroy} clickSpaceDestroy={true} {...config}>
<DialogComponent {...dialogProps} />
</BaseDialog>
const Fragment = (
<Provider store={store}>
<BaseDialog destroy={cbs.destroy} clickSpaceDestroy={true} {...config}>
<DialogComponent {...dialogProps} />
</BaseDialog>
</Provider>
);
if (config.useAppContext) {
Fragment = <Provider store={store}>{Fragment}</Provider>;
}
dialog.render(Fragment);
return cbs;
@@ -0,0 +1,85 @@
import Icon from "../Icon";
import { generateDialog } from "./BaseDialog";
import "../../less/common-dialog.less";
type DialogStyle = "info" | "warning";
interface Props extends DialogProps {
title: string;
content: string;
style?: DialogStyle;
closeBtnText?: string;
confirmBtnText?: string;
onClose?: () => void;
onConfirm?: () => void;
}
const defaultProps = {
title: "",
content: "",
style: "info",
closeBtnText: "Close",
confirmBtnText: "Confirm",
onClose: () => null,
onConfirm: () => null,
};
const CommonDialog: React.FC<Props> = (props: Props) => {
const { title, content, destroy, closeBtnText, confirmBtnText, onClose, onConfirm, style } = {
...defaultProps,
...props,
};
const handleCloseBtnClick = () => {
onClose();
destroy();
};
const handleConfirmBtnClick = async () => {
onConfirm();
destroy();
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">{title}</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
<p className="content-text">{content}</p>
<div className="btns-container">
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
{closeBtnText}
</span>
<span className={`btn confirm-btn ${style}`} onClick={handleConfirmBtnClick}>
{confirmBtnText}
</span>
</div>
</div>
</>
);
};
interface CommonDialogProps {
title: string;
content: string;
className?: string;
style?: DialogStyle;
closeBtnText?: string;
confirmBtnText?: string;
onClose?: () => void;
onConfirm?: () => void;
}
export const showCommonDialog = (props: CommonDialogProps) => {
generateDialog(
{
className: `common-dialog ${props?.className ?? ""}`,
},
CommonDialog,
props
);
};
+1
View File
@@ -0,0 +1 @@
export { generateDialog } from "./BaseDialog";
+12 -16
View File
@@ -1,4 +1,5 @@
import { forwardRef, ReactNode, useCallback, useEffect, useImperativeHandle, useRef } from "react";
import useI18n from "../../hooks/useI18n";
import useRefresh from "../../hooks/useRefresh";
import Only from "../common/OnlyWhen";
import "../../less/editor.less";
@@ -9,6 +10,7 @@ export interface EditorRefActions {
insertText: (text: string) => void;
setContent: (text: string) => void;
getContent: () => string;
getCursorPosition: () => number;
}
interface EditorProps {
@@ -17,10 +19,8 @@ interface EditorProps {
placeholder: string;
fullscreen: boolean;
showConfirmBtn: boolean;
showCancelBtn: boolean;
tools?: ReactNode;
onConfirmBtnClick: (content: string) => void;
onCancelBtnClick: () => void;
onContentChange: (content: string) => void;
}
@@ -32,11 +32,10 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
placeholder,
fullscreen,
showConfirmBtn,
showCancelBtn,
onConfirmBtnClick: handleConfirmBtnClickCallback,
onCancelBtnClick: handleCancelBtnClickCallback,
onContentChange: handleContentChangeCallback,
} = props;
const { t } = useI18n();
const editorRef = useRef<HTMLTextAreaElement>(null);
const refresh = useRefresh();
@@ -66,14 +65,17 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
}
const prevValue = editorRef.current.value;
editorRef.current.value =
prevValue.slice(0, editorRef.current.selectionStart) + rawText + prevValue.slice(editorRef.current.selectionStart);
const cursorPosition = editorRef.current.selectionStart;
editorRef.current.value = prevValue.slice(0, cursorPosition) + rawText + prevValue.slice(cursorPosition);
editorRef.current.focus();
editorRef.current.selectionEnd = cursorPosition + rawText.length;
handleContentChangeCallback(editorRef.current.value);
refresh();
},
setContent: (text: string) => {
if (editorRef.current) {
editorRef.current.value = text;
editorRef.current.focus();
handleContentChangeCallback(editorRef.current.value);
refresh();
}
@@ -81,6 +83,9 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
getContent: (): string => {
return editorRef.current?.value ?? "";
},
getCursorPosition: (): number => {
return editorRef.current?.selectionStart ?? 0;
},
}),
[]
);
@@ -109,10 +114,6 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
editorRef.current.value = "";
}, []);
const handleCommonCancelBtnClick = useCallback(() => {
handleCancelBtnClickCallback();
}, []);
return (
<div className={"common-editor-wrapper " + className}>
<textarea
@@ -128,14 +129,9 @@ const Editor = forwardRef((props: EditorProps, ref: React.ForwardedRef<EditorRef
<Only when={props.tools !== undefined}>{props.tools}</Only>
</div>
<div className="btns-container">
<Only when={showCancelBtn}>
<button className="action-btn cancel-btn" onClick={handleCommonCancelBtnClick}>
Cancel editting
</button>
</Only>
<Only when={showConfirmBtn}>
<button className="action-btn confirm-btn" disabled={editorRef.current?.value === ""} onClick={handleCommonConfirmBtnClick}>
Save <span className="icon-text"></span>
{t("editor.save")} <span className="icon-text"></span>
</button>
</Only>
</div>
+3 -5
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import * as api from "../helpers/api";
import Icon from "./Icon";
import "../less/github-badge.less";
interface Props {}
@@ -16,13 +17,10 @@ const GitHubBadge: React.FC<Props> = () => {
return (
<a className="github-badge-container" href="https://github.com/usememos/memos">
<div className="github-icon">
<i className="fa-brands fa-github fa-lg icon-img"></i>
<Icon.GitHub className="icon-img" />
Star
</div>
<div className="count-text">
{starCount || ""}
<span className="icon-text">🌟</span>
</div>
<div className="count-text">{starCount || ""}</div>
</a>
);
};
+3
View File
@@ -0,0 +1,3 @@
import * as Icon from "react-feather";
export default Icon;
+47 -45
View File
@@ -1,10 +1,13 @@
import { memo, useEffect, useRef, useState } from "react";
import { escape, indexOf } from "lodash-es";
import { indexOf } from "lodash-es";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { IMAGE_URL_REG, LINK_URL_REG, MEMO_LINK_REG, TAG_REG, UNKNOWN_ID } from "../helpers/consts";
import { DONE_BLOCK_REG, parseMarkedToHtml, TODO_BLOCK_REG } from "../helpers/marked";
import "dayjs/locale/zh";
import useI18n from "../hooks/useI18n";
import { UNKNOWN_ID } from "../helpers/consts";
import { DONE_BLOCK_REG, formatMemoContent, IMAGE_URL_REG, TODO_BLOCK_REG } from "../helpers/marked";
import { editorStateService, locationService, memoService, userService } from "../services";
import Icon from "./Icon";
import Only from "./common/OnlyWhen";
import toastHelper from "./Toast";
import Image from "./Image";
@@ -26,42 +29,49 @@ interface State {
expandButtonStatus: ExpandButtonStatus;
}
export const getFormatedMemoCreatedAtStr = (createdTs: number): string => {
export const getFormatedMemoCreatedAtStr = (createdTs: number, locale = "en"): string => {
if (Date.now() - createdTs < 1000 * 60 * 60 * 24) {
return dayjs(createdTs).fromNow();
return dayjs(createdTs).locale(locale).fromNow();
} else {
return dayjs(createdTs).format("YYYY/MM/DD HH:mm:ss");
return dayjs(createdTs).locale(locale).format("YYYY/MM/DD HH:mm:ss");
}
};
const Memo: React.FC<Props> = (props: Props) => {
const memo = props.memo;
const { t, locale } = useI18n();
const [state, setState] = useState<State>({
expandButtonStatus: -1,
});
const [createdAtStr, setCreatedAtStr] = useState<string>(getFormatedMemoCreatedAtStr(memo.createdTs));
const [createdAtStr, setCreatedAtStr] = useState<string>(getFormatedMemoCreatedAtStr(memo.createdTs, locale));
const memoContainerRef = useRef<HTMLDivElement>(null);
const memoContentContainerRef = useRef<HTMLDivElement>(null);
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []).map((s) => s.replace(IMAGE_URL_REG, "$1"));
const isVisitorMode = userService.isVisitorMode();
useEffect(() => {
if (!memoContainerRef) {
if (!memoContentContainerRef) {
return;
}
if (Number(memoContainerRef.current?.clientHeight) > MAX_MEMO_CONTAINER_HEIGHT) {
if (Number(memoContentContainerRef.current?.clientHeight) > MAX_MEMO_CONTAINER_HEIGHT) {
setState({
...state,
expandButtonStatus: 0,
});
}
let intervalFlag = -1;
if (Date.now() - memo.createdTs < 1000 * 60 * 60 * 24) {
setInterval(() => {
setCreatedAtStr(dayjs(memo.createdTs).fromNow());
intervalFlag = setInterval(() => {
setCreatedAtStr(getFormatedMemoCreatedAtStr(memo.createdTs, locale));
}, 1000 * 1);
}
}, []);
return () => {
clearInterval(intervalFlag);
};
}, [locale]);
const handleShowMemoStoryDialog = () => {
showMemoCardDialog(memo);
@@ -94,7 +104,8 @@ const Memo: React.FC<Props> = (props: Props) => {
rowStatus: "ARCHIVED",
});
} catch (error: any) {
toastHelper.error(error.message);
console.error(error);
toastHelper.error(error.response.data.message);
}
if (editorStateService.getState().editMemoId === memo.id) {
@@ -116,7 +127,7 @@ const Memo: React.FC<Props> = (props: Props) => {
if (memoTemp) {
showMemoCardDialog(memoTemp);
} else {
toastHelper.error("MEMO Not Found");
toastHelper.error("Memo Not Found");
targetEl.classList.remove("memo-link-text");
}
} else if (targetEl.className === "tag-span") {
@@ -133,7 +144,7 @@ const Memo: React.FC<Props> = (props: Props) => {
}
const status = targetEl.dataset?.value;
const todoElementList = [...(memoContainerRef.current?.querySelectorAll(`span.todo-block[data-value=${status}]`) ?? [])];
const todoElementList = [...(memoContentContainerRef.current?.querySelectorAll(`span.todo-block[data-value=${status}]`) ?? [])];
for (const element of todoElementList) {
if (element === targetEl) {
const index = indexOf(todoElementList, element);
@@ -162,58 +173,60 @@ const Memo: React.FC<Props> = (props: Props) => {
};
const handleExpandBtnClick = () => {
const expandButtonStatus = Boolean(!state.expandButtonStatus);
if (!expandButtonStatus) {
memoContainerRef.current?.scrollIntoView();
}
setState({
expandButtonStatus: Number(Boolean(!state.expandButtonStatus)) as ExpandButtonStatus,
expandButtonStatus: Number(expandButtonStatus) as ExpandButtonStatus,
});
};
return (
<div className={`memo-wrapper ${"memos-" + memo.id} ${memo.pinned ? "pinned" : ""}`}>
<div className={`memo-wrapper ${"memos-" + memo.id} ${memo.pinned ? "pinned" : ""}`} ref={memoContainerRef}>
<div className="memo-top-wrapper">
<div className="status-text-container" onClick={handleShowMemoStoryDialog}>
<span className="time-text">{createdAtStr}</span>
<Only when={memo.pinned}>
<span className="status-text">PINNED</span>
</Only>
<Only when={memo.visibility === "PUBLIC" && !isVisitorMode}>
<span className="status-text">PUBLIC</span>
<Only when={memo.visibility !== "PRIVATE" && !isVisitorMode}>
<span className={`status-text ${memo.visibility.toLocaleLowerCase()}`}>{memo.visibility}</span>
</Only>
</div>
<div className={`btns-container ${userService.isVisitorMode() ? "!hidden" : ""}`}>
<span className="btn more-action-btn">
<i className="fa-solid fa-ellipsis icon-img"></i>
<Icon.MoreHorizontal className="icon-img" />
</span>
<div className="more-action-btns-wrapper">
<div className="more-action-btns-container">
<div className="btns-container">
<div className="btn" onClick={handleTogglePinMemoBtnClick}>
<i className={`fa-solid fa-thumbtack icon-img ${memo.pinned ? "" : "opacity-20"}`}></i>
<span className="tip-text">{memo.pinned ? "Unpin" : "Pin"}</span>
<Icon.Flag className={`icon-img ${memo.pinned ? "" : "opacity-20"}`} />
<span className="tip-text">{memo.pinned ? t("common.unpin") : t("common.pin")}</span>
</div>
<div className="btn" onClick={handleEditMemoClick}>
<i className="fa-solid fa-pen-to-square icon-img"></i>
<span className="tip-text">Edit</span>
<Icon.Edit3 className="icon-img" />
<span className="tip-text">{t("common.edit")}</span>
</div>
<div className="btn" onClick={handleGenMemoImageBtnClick}>
<i className="fa-solid fa-share-nodes icon-img"></i>
<span className="tip-text">Share</span>
<Icon.Share className="icon-img" />
<span className="tip-text">{t("common.share")}</span>
</div>
</div>
<span className="btn" onClick={handleMarkMemoClick}>
Mark
{t("common.mark")}
</span>
<span className="btn" onClick={handleShowMemoStoryDialog}>
View Story
{t("memo.view-story")}
</span>
<span className="btn archive-btn" onClick={handleArchiveMemoClick}>
Archive
{t("common.archive")}
</span>
</div>
</div>
</div>
</div>
<div
ref={memoContainerRef}
ref={memoContentContainerRef}
className={`memo-content-text ${state.expandButtonStatus === 0 ? "expanded" : ""}`}
onClick={handleMemoContentClick}
dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}
@@ -222,7 +235,7 @@ const Memo: React.FC<Props> = (props: Props) => {
<div className="expand-btn-container">
<span className={`btn ${state.expandButtonStatus === 0 ? "expand-btn" : "fold-btn"}`} onClick={handleExpandBtnClick}>
{state.expandButtonStatus === 0 ? "Expand" : "Fold"}
<i className="fa-solid fa-chevron-right icon-img"></i>
<Icon.ChevronRight className="icon-img" />
</span>
</div>
)}
@@ -237,15 +250,4 @@ const Memo: React.FC<Props> = (props: Props) => {
);
};
export function formatMemoContent(content: string) {
const tempElement = document.createElement("div");
tempElement.innerHTML = parseMarkedToHtml(escape(content));
return tempElement.innerHTML
.replace(IMAGE_URL_REG, "")
.replace(MEMO_LINK_REG, "<span class='memo-link-text' data-value='$2'>$1</span>")
.replace(LINK_URL_REG, "<a class='link' target='_blank' rel='noreferrer' href='$2'>$1</a>")
.replace(TAG_REG, "<span class='tag-span'>#$1</span> ");
}
export default memo(Memo);
+41 -31
View File
@@ -1,13 +1,16 @@
import { useState, useEffect, useCallback } from "react";
import { editorStateService, memoService, userService } from "../services";
import { IMAGE_URL_REG, MEMO_LINK_REG, UNKNOWN_ID } from "../helpers/consts";
import { useAppSelector } from "../store";
import { UNKNOWN_ID, VISIBILITY_SELECTOR_ITEMS } from "../helpers/consts";
import * as utils from "../helpers/utils";
import { parseHtmlToRawText } from "../helpers/marked";
import { formatMemoContent, IMAGE_URL_REG, MEMO_LINK_REG, parseHtmlToRawText } from "../helpers/marked";
import Only from "./common/OnlyWhen";
import toastHelper from "./Toast";
import { showDialog } from "./Dialog";
import { generateDialog } from "./Dialog";
import Image from "./Image";
import { formatMemoContent } from "./Memo";
import Icon from "./Icon";
import Selector from "./common/Selector";
import showChangeMemoCreatedTsDialog from "./ChangeMemoCreatedTsDialog";
import "../less/memo-card-dialog.less";
interface LinkedMemo extends Memo {
@@ -20,6 +23,7 @@ interface Props extends DialogProps {
}
const MemoCardDialog: React.FC<Props> = (props: Props) => {
const memos = useAppSelector((state) => state.memo.memos);
const [memo, setMemo] = useState<Memo>({
...props.memo,
});
@@ -68,7 +72,12 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
};
fetchLinkedMemos();
}, [memo.id]);
setMemo(memoService.getMemoById(memo.id) as Memo);
}, [memos, memo.id]);
const handleMemoCreatedAtClick = () => {
showChangeMemoCreatedTsDialog(memo.id);
};
const handleMemoContentClick = useCallback(async (e: React.MouseEvent) => {
const targetEl = e.target as HTMLElement;
@@ -86,7 +95,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
setLinkedMemos([]);
setMemo(nextMemo);
} else {
toastHelper.error("MEMO Not Found");
toastHelper.error("Memo Not Found");
targetEl.classList.remove("memo-link-text");
}
}
@@ -98,25 +107,16 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
setMemo(memo);
}, []);
const handleEditMemoBtnClick = useCallback(() => {
const handleEditMemoBtnClick = () => {
props.destroy();
editorStateService.setEditMemoWithId(memo.id);
}, [memo.id]);
const handlePinClick = async () => {
if (memo.pinned) {
await memoService.unpinMemo(memo.id);
} else {
await memoService.pinMemo(memo.id);
}
setMemo({
...memo,
pinned: !memo.pinned,
});
};
const handleVisibilityClick = async () => {
const visibility = memo.visibility === "PRIVATE" ? "PUBLIC" : "PRIVATE";
const handleVisibilitySelectorChange = async (visibility: Visibility) => {
if (memo.visibility === visibility) {
return;
}
await memoService.patchMemo({
id: memo.id,
visibility: visibility,
@@ -129,25 +129,35 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
return (
<>
<Only when={!userService.isVisitorMode()}>
<div className="card-header-container">
<div className="visibility-selector-container">
<Icon.Eye className="icon-img" />
<Selector
className="visibility-selector"
dataSource={VISIBILITY_SELECTOR_ITEMS}
value={memo.visibility}
handleValueChanged={(value) => handleVisibilitySelectorChange(value as Visibility)}
/>
</div>
</div>
</Only>
<div className="memo-card-container">
<div className="header-container">
<p className="time-text">{utils.getDateTimeString(memo.createdTs)}</p>
<p className="time-text" onClick={handleMemoCreatedAtClick}>
{utils.getDateTimeString(memo.createdTs)}
</p>
<div className="btns-container">
<Only when={!userService.isVisitorMode()}>
<>
<button className="btn" onClick={handlePinClick}>
<i className={`fa-solid fa-thumbtack icon-img ${memo.pinned ? "" : "opacity-20"}`}></i>
</button>
<button className="btn" onClick={handleVisibilityClick}>
<i className={`fa-solid fa-eye icon-img ${memo.visibility === "PUBLIC" ? "" : "opacity-20"}`}></i>
</button>
<button className="btn edit-btn" onClick={handleEditMemoBtnClick}>
<i className="fa-solid fa-pen-to-square icon-img"></i>
<Icon.Edit3 className="icon-img" />
</button>
<span className="split-line">/</span>
</>
</Only>
<button className="btn close-btn" onClick={props.destroy}>
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
<Icon.X className="icon-img" />
</button>
</div>
</div>
@@ -218,7 +228,7 @@ const MemoCardDialog: React.FC<Props> = (props: Props) => {
};
export default function showMemoCardDialog(memo: Memo): void {
showDialog(
generateDialog(
{
className: "memo-card-dialog",
},
+82 -22
View File
@@ -1,8 +1,10 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { UNKNOWN_ID } from "../helpers/consts";
import { editorStateService, locationService, memoService, resourceService } from "../services";
import useI18n from "../hooks/useI18n";
import { useAppSelector } from "../store";
import * as storage from "../helpers/storage";
import Icon from "./Icon";
import toastHelper from "./Toast";
import Editor, { EditorRefActions } from "./Editor/Editor";
import "../less/memo-editor.less";
@@ -15,6 +17,8 @@ interface State {
}
const MemoEditor: React.FC<Props> = () => {
const { t, locale } = useI18n();
const user = useAppSelector((state) => state.user.user);
const editorState = useAppSelector((state) => state.editor);
const tags = useAppSelector((state) => state.memo.tags);
const [state, setState] = useState<State>({
@@ -24,6 +28,7 @@ const MemoEditor: React.FC<Props> = () => {
const editorRef = useRef<EditorRefActions>(null);
const prevGlobalStateRef = useRef(editorState);
const tagSeletorRef = useRef<HTMLDivElement>(null);
const editorFontStyle = user?.setting.editorFontStyle || "normal";
useEffect(() => {
if (editorState.markMemoId && editorState.markMemoId !== UNKNOWN_ID) {
@@ -107,16 +112,17 @@ const MemoEditor: React.FC<Props> = () => {
const { type } = file;
if (!type.startsWith("image")) {
toastHelper.error("Only image file supported.");
return;
}
try {
const image = await resourceService.upload(file);
const url = `/h/r/${image.id}/${image.filename}`;
return url;
} catch (error: any) {
toastHelper.error(error);
console.error(error);
toastHelper.error(error.response.data.message);
} finally {
setState({
...state,
@@ -152,7 +158,8 @@ const MemoEditor: React.FC<Props> = () => {
locationService.clearQuery();
}
} catch (error: any) {
toastHelper.error(error.message);
console.error(error);
toastHelper.error(error.response.data.message);
}
setState({
@@ -162,7 +169,7 @@ const MemoEditor: React.FC<Props> = () => {
setEditorContentCache("");
};
const handleCancelBtnClick = useCallback(() => {
const handleCancelEditingBtnClick = useCallback(() => {
editorStateService.clearEditMemo();
editorRef.current?.setContent("");
setEditorContentCache("");
@@ -172,23 +179,57 @@ const MemoEditor: React.FC<Props> = () => {
setEditorContentCache(content);
}, []);
const handleCheckBoxBtnClick = () => {
if (!editorRef.current) {
return;
}
const cursorPosition = editorRef.current.getCursorPosition();
const prevValue = editorRef.current.getContent().slice(0, cursorPosition);
if (prevValue === "" || prevValue.endsWith("\n")) {
editorRef.current?.insertText("- [ ] ");
} else {
editorRef.current?.insertText("\n- [ ] ");
}
};
const handleCodeBlockBtnClick = () => {
if (!editorRef.current) {
return;
}
const cursorPosition = editorRef.current.getCursorPosition();
const prevValue = editorRef.current.getContent().slice(0, cursorPosition);
if (prevValue === "" || prevValue.endsWith("\n")) {
editorRef.current?.insertText("```\n\n```");
} else {
editorRef.current?.insertText("\n```\n\n```");
}
};
const handleUploadFileBtnClick = useCallback(() => {
const inputEl = document.createElement("input");
inputEl.style.position = "fixed";
inputEl.style.top = "-100vh";
inputEl.style.left = "-100vw";
document.body.appendChild(inputEl);
inputEl.type = "file";
inputEl.multiple = false;
inputEl.accept = "image/png, image/gif, image/jpeg";
inputEl.multiple = true;
inputEl.accept = "image/*";
inputEl.onchange = async () => {
if (!inputEl.files || inputEl.files.length === 0) {
return;
}
const file = inputEl.files[0];
const url = await handleUploadFile(file);
if (url) {
editorRef.current?.insertText(`![](${url})`);
for (const file of inputEl.files) {
const url = await handleUploadFile(file);
if (url) {
editorRef.current?.insertText(`![](${url})`);
}
}
};
inputEl.click();
document.body.removeChild(inputEl);
}, []);
const handleFullscreenBtnClick = () => {
@@ -209,41 +250,60 @@ const MemoEditor: React.FC<Props> = () => {
const editorConfig = useMemo(
() => ({
className: "memo-editor",
className: `memo-editor ${editorFontStyle}`,
initialContent: getEditorContentCache(),
placeholder: "Any thoughts...",
placeholder: t("editor.placeholder"),
fullscreen: state.fullscreen,
showConfirmBtn: true,
showCancelBtn: isEditing,
onConfirmBtnClick: handleSaveBtnClick,
onCancelBtnClick: handleCancelBtnClick,
onContentChange: handleContentChange,
}),
[isEditing, state.fullscreen]
[isEditing, state.fullscreen, locale, editorFontStyle]
);
return (
<div className={`memo-editor-container ${isEditing ? "edit-ing" : ""} ${state.fullscreen ? "fullscreen" : ""}`}>
<p className={"tip-text " + (isEditing ? "" : "hidden")}>Editting...</p>
<div className={`tip-container ${isEditing ? "" : "!hidden"}`}>
<span className="tip-text">{t("editor.editing")}</span>
<button className="cancel-btn" onClick={handleCancelEditingBtnClick}>
{t("common.cancel")}
</button>
</div>
<Editor
ref={editorRef}
{...editorConfig}
tools={
<>
<div className="action-btn tag-action">
<i className="fa-solid fa-hashtag icon-img"></i>
<Icon.Hash className="icon-img" />
<div ref={tagSeletorRef} className="tag-list" onClick={handleTagSeletorClick}>
{tags.map((t) => {
return <span key={t}>{t}</span>;
})}
{tags.length > 0 ? (
tags.map((tag) => {
return (
<span className="item-container" key={tag}>
{tag}
</span>
);
})
) : (
<p className="tip-text" onClick={(e) => e.stopPropagation()}>
{t("common.null")}
</p>
)}
</div>
</div>
<button className="action-btn">
<i className="fa-solid fa-image icon-img" onClick={handleUploadFileBtnClick}></i>
<Icon.CheckSquare className="icon-img" onClick={handleCheckBoxBtnClick} />
</button>
<button className="action-btn">
<Icon.Code className="icon-img" onClick={handleCodeBlockBtnClick} />
</button>
<button className="action-btn">
<Icon.Image className="icon-img" onClick={handleUploadFileBtnClick} />
<span className={`tip-text ${state.isUploadingResource ? "!block" : ""}`}>Uploading</span>
</button>
<button className="action-btn" onClick={handleFullscreenBtnClick}>
<i className={`fa-solid fa-${state.fullscreen ? "compress" : "expand"} icon-img`}></i>
{state.fullscreen ? <Icon.Minimize className="icon-img" /> : <Icon.Maximize className="icon-img" />}
</button>
</>
}
+24 -15
View File
@@ -1,19 +1,21 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef } from "react";
import { memoService, shortcutService } from "../services";
import useI18n from "../hooks/useI18n";
import { useAppSelector } from "../store";
import { IMAGE_URL_REG, LINK_URL_REG, MEMO_LINK_REG, TAG_REG } from "../helpers/consts";
import { IMAGE_URL_REG, LINK_URL_REG, MEMO_LINK_REG, TAG_REG } from "../helpers/marked";
import * as utils from "../helpers/utils";
import { checkShouldShowMemoWithFilters } from "../helpers/filter";
import toastHelper from "./Toast";
import Only from "./common/OnlyWhen";
import Memo from "./Memo";
import "../less/memo-list.less";
interface Props {}
const MemoList: React.FC<Props> = () => {
const { t } = useI18n();
const query = useAppSelector((state) => state.location.query);
const memos = useAppSelector((state) => state.memo.memos);
const [isFetching, setFetchStatus] = useState(true);
const { memos, isFetching } = useAppSelector((state) => state.memo);
const wrapperElement = useRef<HTMLDivElement>(null);
const { tag: tagQuery, duration, type: memoType, text: textQuery, shortcutId } = query ?? {};
@@ -82,28 +84,35 @@ const MemoList: React.FC<Props> = () => {
memoService
.fetchAllMemos()
.then(() => {
setFetchStatus(false);
memoService.updateTagsState();
// do nth
})
.catch(() => {
toastHelper.error("😭 Fetching failed, please try again later.");
.catch((error) => {
console.error(error);
toastHelper.error(error.response.data.message);
});
}, []);
useEffect(() => {
wrapperElement.current?.scrollTo({ top: 0 });
wrapperElement.current?.scrollTo({
top: 0,
});
}, [query]);
return (
<div className={`memo-list-container ${isFetching ? "" : "completed"}`} ref={wrapperElement}>
<Only when={isFetching}>
<div className="status-text-container fetching-tip">
<p className="status-text">{t("memo-list.fetching-data")}</p>
</div>
</Only>
{sortedMemos.map((memo) => (
<Memo key={`${memo.id}-${memo.updatedTs}`} memo={memo} />
<Memo key={`${memo.id}-${memo.createdTs}-${memo.updatedTs}`} memo={memo} />
))}
<div className="status-text-container">
<p className="status-text">
{isFetching ? "Fetching data..." : sortedMemos.length === 0 ? "No memos 🌃" : showMemoFilter ? "" : "All memos are ready 🎉"}
</p>
</div>
<Only when={!isFetching}>
<div className="status-text-container">
<p className="status-text">{sortedMemos.length === 0 ? "no memos 🌃" : showMemoFilter ? "" : "all memos are ready 🎉"}</p>
</div>
</Only>
</div>
);
};
+3 -2
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { memoService, shortcutService } from "../services";
import { useAppSelector } from "../store";
import Icon from "./Icon";
import SearchBar from "./SearchBar";
import { toggleSiderbar } from "./Sidebar";
import "../less/memos-header.less";
@@ -28,7 +29,7 @@ const MemosHeader: React.FC<Props> = () => {
const handleTitleTextClick = useCallback(() => {
const now = Date.now();
if (now - prevRequestTimestamp > 10 * 1000) {
if (now - prevRequestTimestamp > 1 * 1000) {
prevRequestTimestamp = now;
memoService.fetchAllMemos().catch(() => {
// do nth
@@ -40,7 +41,7 @@ const MemosHeader: React.FC<Props> = () => {
<div className="section-header-container memos-header-container">
<div className="title-container">
<div className="action-btn" onClick={toggleSiderbar}>
<i className="fa-solid fa-bars icon-img"></i>
<Icon.Menu className="icon-img" />
</div>
<span className="title-text" onClick={handleTitleTextClick}>
{titleText}
+15 -8
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef } from "react";
import * as api from "../helpers/api";
import { locationService, userService } from "../services";
import useI18n from "../hooks/useI18n";
import toastHelper from "./Toast";
import Only from "./common/OnlyWhen";
import showAboutSiteDialog from "./AboutSiteDialog";
@@ -13,6 +14,7 @@ interface Props {
const MenuBtnsPopup: React.FC<Props> = (props: Props) => {
const { shownStatus, setShownStatus } = props;
const { t } = useI18n();
const popupElRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -40,7 +42,8 @@ const MenuBtnsPopup: React.FC<Props> = (props: Props) => {
toastHelper.info(JSON.stringify(profile, null, 4));
})
.catch((error) => {
toastHelper.error("Failed to ping\n" + JSON.stringify(error, null, 4));
console.error(error);
toastHelper.error(error.response.data.message);
});
};
@@ -49,24 +52,28 @@ const MenuBtnsPopup: React.FC<Props> = (props: Props) => {
};
const handleSignOutBtnClick = async () => {
userService.doSignOut().catch(() => {
// do nth
});
locationService.replaceHistory("/signin");
window.location.reload();
userService
.doSignOut()
.then(() => {
locationService.replaceHistory("/auth");
window.location.reload();
})
.catch(() => {
// do nth
});
};
return (
<div className={`menu-btns-popup ${shownStatus ? "" : "hidden"}`} ref={popupElRef}>
<button className="btn action-btn" onClick={handleAboutBtnClick}>
<span className="icon">🤠</span> About
<span className="icon">🤠</span> {t("common.about")}
</button>
<button className="btn action-btn" onClick={handlePingBtnClick}>
<span className="icon">🎯</span> Ping
</button>
<Only when={!userService.isVisitorMode()}>
<button className="btn action-btn" onClick={handleSignOutBtnClick}>
<span className="icon">👋</span> Sign out
<span className="icon">👋</span> {t("common.sign-out")}
</button>
</Only>
</div>
+11 -6
View File
@@ -1,5 +1,6 @@
import { showDialog } from "./Dialog";
import * as utils from "../helpers/utils";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import "../less/preview-image-dialog.less";
interface Props extends DialogProps {
@@ -18,25 +19,29 @@ const PreviewImageDialog: React.FC<Props> = ({ destroy, imgUrl }: Props) => {
a.click();
};
const handleImgContainerClick = () => {
destroy();
};
return (
<>
<div className="btns-container">
<button className="btn" onClick={handleCloseBtnClick}>
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
<Icon.X className="icon-img" />
</button>
<button className="btn" onClick={handleDownloadBtnClick}>
<i className="fa-solid fa-download icon-img"></i>
<Icon.Download className="icon-img" />
</button>
</div>
<div className="img-container">
<img src={imgUrl} />
<div className="img-container" onClick={handleImgContainerClick}>
<img onClick={(e) => e.stopPropagation()} src={imgUrl} />
</div>
</>
);
};
export default function showPreviewImageDialog(imgUrl: string): void {
showDialog(
generateDialog(
{
className: "preview-image-dialog",
},
+180
View File
@@ -0,0 +1,180 @@
import { useEffect, useState } from "react";
import * as utils from "../helpers/utils";
import useI18n from "../hooks/useI18n";
import useLoading from "../hooks/useLoading";
import { resourceService } from "../services";
import Dropdown from "./common/Dropdown";
import { generateDialog } from "./Dialog";
import { showCommonDialog } from "./Dialog/CommonDialog";
import showPreviewImageDialog from "./PreviewImageDialog";
import Icon from "./Icon";
import toastHelper from "./Toast";
import "../less/resources-dialog.less";
interface Props extends DialogProps {}
interface State {
resources: Resource[];
isUploadingResource: boolean;
}
const ResourcesDialog: React.FC<Props> = (props: Props) => {
const { destroy } = props;
const { t } = useI18n();
const loadingState = useLoading();
const [state, setState] = useState<State>({
resources: [],
isUploadingResource: false,
});
useEffect(() => {
fetchResources()
.catch((error) => {
console.error(error);
toastHelper.error(error.response.data.message);
})
.finally(() => {
loadingState.setFinish();
});
}, []);
const fetchResources = async () => {
const data = await resourceService.getResourceList();
setState({
...state,
resources: data,
});
};
const handleUploadFileBtnClick = async () => {
if (state.isUploadingResource) {
return;
}
const inputEl = document.createElement("input");
inputEl.style.position = "fixed";
inputEl.style.top = "-100vh";
inputEl.style.left = "-100vw";
document.body.appendChild(inputEl);
inputEl.type = "file";
inputEl.multiple = true;
inputEl.accept = "image/*";
inputEl.onchange = async () => {
if (!inputEl.files || inputEl.files.length === 0) {
return;
}
setState({
...state,
isUploadingResource: true,
});
for (const file of inputEl.files) {
await resourceService.upload(file);
try {
await resourceService.upload(file);
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
} finally {
setState({
...state,
isUploadingResource: false,
});
}
}
await fetchResources();
};
inputEl.click();
document.body.removeChild(inputEl);
};
const handlPreviewBtnClick = (resource: Resource) => {
showPreviewImageDialog(`${window.location.origin}/h/r/${resource.id}/${resource.filename}`);
};
const handleCopyResourceLinkBtnClick = (resource: Resource) => {
utils.copyTextToClipboard(`${window.location.origin}/h/r/${resource.id}/${resource.filename}`);
toastHelper.success("Succeed to copy resource link to clipboard");
};
const handleDeleteResourceBtnClick = (resource: Resource) => {
showCommonDialog({
title: `Delete Resource`,
content: `Are you sure to delete this resource? THIS ACTION IS IRREVERSIABLE.❗️`,
style: "warning",
onConfirm: async () => {
await resourceService.deleteResourceById(resource.id);
await fetchResources();
},
});
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🌄</span>
{t("sidebar.resources")}
</p>
<button className="btn close-btn" onClick={destroy}>
<Icon.X className="icon-img" />
</button>
</div>
<div className="dialog-content-container">
<div className="tip-text-container">(👨💻WIP) View your static resources in memos. e.g. images</div>
<div className="upload-resource-container" onClick={() => handleUploadFileBtnClick()}>
<div className="upload-resource-btn">
<Icon.File className="icon-img" />
<span>Upload</span>
</div>
</div>
{loadingState.isLoading ? (
<div className="loading-text-container">
<p className="tip-text">fetching data...</p>
</div>
) : (
<div className="resource-table-container">
<div className="fields-container">
<span className="field-text">ID</span>
<span className="field-text name-text">NAME</span>
<span className="field-text">TYPE</span>
<span></span>
</div>
{state.resources.length === 0 ? (
<p className="tip-text">No resource.</p>
) : (
state.resources.map((resource) => (
<div key={resource.id} className="resource-container">
<span className="field-text">{resource.id}</span>
<span className="field-text name-text">{resource.filename}</span>
<span className="field-text">{resource.type}</span>
<div className="buttons-container">
<Dropdown className="actions-dropdown">
<button onClick={() => handlPreviewBtnClick(resource)}>Preview</button>
<button onClick={() => handleCopyResourceLinkBtnClick(resource)}>Copy Link</button>
<button className="delete-btn" onClick={() => handleDeleteResourceBtnClick(resource)}>
Delete
</button>
</Dropdown>
</div>
</div>
))
)}
</div>
)}
</div>
</>
);
};
export default function showResourcesDialog() {
generateDialog(
{
className: "resources-dialog",
},
ResourcesDialog,
{}
);
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { locationService } from "../services";
import { useAppSelector } from "../store";
import { memoSpecialTypes } from "../helpers/filter";
import Icon from "./Icon";
import "../less/search-bar.less";
interface Props {}
@@ -24,7 +25,7 @@ const SearchBar: React.FC<Props> = () => {
return (
<div className="search-bar-container">
<div className="search-bar-inputer">
<i className="fa-solid fa-magnifying-glass fa-sm icon-img"></i>
<Icon.Search className="icon-img" />
<input className="text-input" type="text" placeholder="" onChange={handleTextQueryInput} />
</div>
<div className="quickly-action-wrapper">
+11 -9
View File
@@ -1,6 +1,8 @@
import { useState } from "react";
import { useAppSelector } from "../store";
import { showDialog } from "./Dialog";
import useI18n from "../hooks/useI18n";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import MyAccountSection from "./Settings/MyAccountSection";
import PreferencesSection from "./Settings/PreferencesSection";
import MemberSection from "./Settings/MemberSection";
@@ -16,6 +18,7 @@ interface State {
const SettingDialog: React.FC<Props> = (props: Props) => {
const { destroy } = props;
const { t } = useI18n();
const user = useAppSelector((state) => state.user.user);
const [state, setState] = useState<State>({
selectedSection: "my-account",
@@ -30,33 +33,33 @@ const SettingDialog: React.FC<Props> = (props: Props) => {
return (
<div className="dialog-content-container">
<button className="btn close-btn" onClick={destroy}>
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
<Icon.X className="icon-img" />
</button>
<div className="section-selector-container">
<span className="section-title">Basic</span>
<span className="section-title">{t("common.basic")}</span>
<div className="section-items-container">
<span
onClick={() => handleSectionSelectorItemClick("my-account")}
className={`section-item ${state.selectedSection === "my-account" ? "selected" : ""}`}
>
<span className="icon-text">🤠</span> My account
<span className="icon-text">🤠</span> {t("setting.my-account")}
</span>
<span
onClick={() => handleSectionSelectorItemClick("preferences")}
className={`section-item ${state.selectedSection === "preferences" ? "selected" : ""}`}
>
<span className="icon-text">🏟</span> Preferences
<span className="icon-text">🏟</span> {t("setting.preference")}
</span>
</div>
{user?.role === "HOST" ? (
<>
<span className="section-title">Admin</span>
<span className="section-title">{t("common.admin")}</span>
<div className="section-items-container">
<span
onClick={() => handleSectionSelectorItemClick("member")}
className={`section-item ${state.selectedSection === "member" ? "selected" : ""}`}
>
<span className="icon-text">👤</span> Member
<span className="icon-text">👤</span> {t("setting.member")}
</span>
</div>
</>
@@ -76,10 +79,9 @@ const SettingDialog: React.FC<Props> = (props: Props) => {
};
export default function showSettingDialog(): void {
showDialog(
generateDialog(
{
className: "setting-dialog",
useAppContext: true,
},
SettingDialog,
{}
+72 -12
View File
@@ -1,7 +1,12 @@
import React, { useEffect, useState } from "react";
import { isEmpty } from "lodash-es";
import useI18n from "../../hooks/useI18n";
import { userService } from "../../services";
import { useAppSelector } from "../../store";
import * as api from "../../helpers/api";
import toastHelper from "../Toast";
import Dropdown from "../common/Dropdown";
import { showCommonDialog } from "../Dialog/CommonDialog";
import "../../less/settings/member-section.less";
interface Props {}
@@ -12,6 +17,8 @@ interface State {
}
const PreferencesSection: React.FC<Props> = () => {
const { t } = useI18n();
const currentUser = useAppSelector((state) => state.user.user);
const [state, setState] = useState<State>({
createUserEmail: "",
createUserPassword: "",
@@ -57,7 +64,8 @@ const PreferencesSection: React.FC<Props> = () => {
try {
await api.createUser(userCreate);
} catch (error: any) {
toastHelper.error(error.message);
console.error(error);
toastHelper.error(error.response.data.message);
}
await fetchUserList();
setState({
@@ -66,35 +74,87 @@ const PreferencesSection: React.FC<Props> = () => {
});
};
const handleArchiveUserClick = (user: User) => {
showCommonDialog({
title: `Archive Member`,
content: `❗️Are you sure to archive ${user.name}?`,
style: "warning",
onConfirm: async () => {
await userService.patchUser({
id: user.id,
rowStatus: "ARCHIVED",
});
fetchUserList();
},
});
};
const handleRestoreUserClick = async (user: User) => {
await userService.patchUser({
id: user.id,
rowStatus: "NORMAL",
});
fetchUserList();
};
const handleDeleteUserClick = (user: User) => {
showCommonDialog({
title: `Delete Member`,
content: `Are you sure to delete ${user.name}? THIS ACTION IS IRREVERSIABLE.❗️`,
style: "warning",
onConfirm: async () => {
await userService.deleteUser({
id: user.id,
});
fetchUserList();
},
});
};
return (
<div className="section-container member-section-container">
<p className="title-text">Create a member</p>
<p className="title-text">{t("setting.member-section.create-a-member")}</p>
<div className="create-member-container">
<div className="input-form-container">
<span className="field-text">Email</span>
<input type="email" placeholder="Email" value={state.createUserEmail} onChange={handleEmailInputChange} />
<span className="field-text">{t("common.email")}</span>
<input type="email" placeholder={t("common.email")} value={state.createUserEmail} onChange={handleEmailInputChange} />
</div>
<div className="input-form-container">
<span className="field-text">Password</span>
<input type="text" placeholder="Password" value={state.createUserPassword} onChange={handlePasswordInputChange} />
<span className="field-text">{t("common.password")}</span>
<input type="text" placeholder={t("common.password")} value={state.createUserPassword} onChange={handlePasswordInputChange} />
</div>
<div className="btns-container">
<button onClick={handleCreateUserBtnClick}>Create</button>
<button onClick={handleCreateUserBtnClick}>{t("common.create")}</button>
</div>
</div>
<p className="title-text">Member list</p>
<div className="member-container field-container">
<span className="field-text">ID</span>
<span className="field-text">EMAIL</span>
<span></span>
</div>
{userList.map((user) => (
<div key={user.id} className="member-container">
<div key={user.id} className={`member-container ${user.rowStatus === "ARCHIVED" ? "archived" : ""}`}>
<span className="field-text id-text">{user.id}</span>
<span className="field-text email-text">{user.email}</span>
{/* TODO */}
{/* <div className="buttons-container">
<span>delete</span>
</div> */}
<div className="buttons-container">
{currentUser?.id === user.id ? (
<span className="tip-text">Yourself</span>
) : (
<Dropdown className="actions-dropdown">
{user.rowStatus === "NORMAL" ? (
<button onClick={() => handleArchiveUserClick(user)}>{t("common.archive")}</button>
) : (
<>
<button onClick={() => handleRestoreUserClick(user)}>{t("common.restore")}</button>
<button className="delete" onClick={() => handleDeleteUserClick(user)}>
{t("common.delete")}
</button>
</>
)}
</Dropdown>
)}
</div>
</div>
))}
</div>
@@ -1,10 +1,11 @@
import { useState } from "react";
import useI18n from "../../hooks/useI18n";
import { useAppSelector } from "../../store";
import { userService } from "../../services";
import { validate, ValidatorConfig } from "../../helpers/validator";
import toastHelper from "../Toast";
import { showCommonDialog } from "../Dialog/CommonDialog";
import showChangePasswordDialog from "../ChangePasswordDialog";
import showConfirmResetOpenIdDialog from "../ConfirmResetOpenIdDialog";
import "../../less/settings/my-account-section.less";
const validateConfig: ValidatorConfig = {
@@ -17,6 +18,7 @@ const validateConfig: ValidatorConfig = {
interface Props {}
const MyAccountSection: React.FC<Props> = () => {
const { t } = useI18n();
const user = useAppSelector((state) => state.user.user as User);
const [username, setUsername] = useState<string>(user.name);
const openAPIRoute = `${window.location.origin}/api/memo?openId=${user.openId}`;
@@ -39,11 +41,13 @@ const MyAccountSection: React.FC<Props> = () => {
try {
await userService.patchUser({
id: user.id,
name: username,
});
toastHelper.info("Username changed");
} catch (error: any) {
toastHelper.error(error.message);
console.error(error);
toastHelper.error(error.response.data.message);
}
};
@@ -52,7 +56,11 @@ const MyAccountSection: React.FC<Props> = () => {
};
const handleResetOpenIdBtnClick = async () => {
showConfirmResetOpenIdDialog();
showCommonDialog({
title: "Reset Open API",
content: "❗️The existing API will be invalidated and a new one will be generated, are you sure you want to reset?",
style: "warning",
});
};
const handlePreventDefault = (e: React.MouseEvent) => {
@@ -63,17 +71,17 @@ const MyAccountSection: React.FC<Props> = () => {
return (
<>
<div className="section-container account-section-container">
<p className="title-text">Account Information</p>
<p className="title-text">{t("setting.account-section.title")}</p>
<label className="form-label">
<span className="normal-text">Email:</span>
<span className="normal-text">{t("common.email")}:</span>
<span className="normal-text">{user.email}</span>
</label>
<label className="form-label input-form-label username-label">
<span className="normal-text">Username:</span>
<span className="normal-text">{t("common.username")}:</span>
<input type="text" value={username} onChange={handleUsernameChanged} />
<div className={`btns-container ${username === user.name ? "!hidden" : ""}`} onClick={handlePreventDefault}>
<span className="btn confirm-btn" onClick={handleConfirmEditUsernameBtnClick}>
Save
{t("common.save")}
</span>
<span
className="btn cancel-btn"
@@ -81,14 +89,14 @@ const MyAccountSection: React.FC<Props> = () => {
setUsername(user.name);
}}
>
Cancel
{t("common.cancel")}
</span>
</div>
</label>
<label className="form-label password-label">
<span className="normal-text">Password:</span>
<span className="normal-text">{t("common.password")}:</span>
<span className="btn" onClick={handleChangePasswordBtnClick}>
Change it
{t("common.change")}
</span>
</label>
</div>
@@ -96,10 +104,9 @@ const MyAccountSection: React.FC<Props> = () => {
<p className="title-text">Open API</p>
<p className="value-text">{openAPIRoute}</p>
<span className="reset-btn" onClick={handleResetOpenIdBtnClick}>
Reset API
{t("common.reset")} API
</span>
<div className="usage-guide-container">
<p className="title-text">Usage guide:</p>
<pre>{`POST ${openAPIRoute}\nContent-type: application/json\n{\n "content": "Hello #memos from ${window.location.origin}"\n}`}</pre>
</div>
</div>
@@ -1,80 +1,81 @@
import { memoService } from "../../services";
import * as utils from "../../helpers/utils";
import toastHelper from "../Toast";
import { globalService, userService } from "../../services";
import { useAppSelector } from "../../store";
import { VISIBILITY_SELECTOR_ITEMS } from "../../helpers/consts";
import useI18n from "../../hooks/useI18n";
import Selector from "../common/Selector";
import BetaBadge from "../BetaBadge";
import "../../less/settings/preferences-section.less";
interface Props {}
const PreferencesSection: React.FC<Props> = () => {
const handleExportBtnClick = async () => {
const formatedMemos = memoService.getState().memos.map((m) => {
return {
content: m.content,
createdTs: m.createdTs,
};
});
const localeSelectorItems = [
{
text: "English",
value: "en",
},
{
text: "中文",
value: "zh",
},
];
const jsonStr = JSON.stringify(formatedMemos);
const element = document.createElement("a");
element.setAttribute("href", "data:text/json;charset=utf-8," + encodeURIComponent(jsonStr));
element.setAttribute("download", `memos-${utils.getDateTimeString(Date.now())}.json`);
element.style.display = "none";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
const editorFontStyleSelectorItems = [
{
text: "Normal",
value: "normal",
},
{
text: "Mono",
value: "mono",
},
];
const PreferencesSection: React.FC<Props> = () => {
const { t } = useI18n();
const { setting } = useAppSelector((state) => state.user.user as User);
const handleLocaleChanged = async (value: string) => {
await userService.upsertUserSetting("locale", value);
globalService.setLocale(value as Locale);
};
const handleImportBtnClick = async () => {
const fileInputEl = document.createElement("input");
fileInputEl.type = "file";
fileInputEl.accept = "application/JSON";
fileInputEl.onchange = () => {
if (fileInputEl.files?.length && fileInputEl.files.length > 0) {
const reader = new FileReader();
reader.readAsText(fileInputEl.files[0]);
reader.onload = async (event) => {
const memoList = JSON.parse(event.target?.result as string) as Memo[];
if (!Array.isArray(memoList)) {
toastHelper.error("Unexpected data type.");
}
const handleDefaultMemoVisibilityChanged = async (value: string) => {
await userService.upsertUserSetting("memoVisibility", value);
};
let succeedAmount = 0;
for (const memo of memoList) {
const content = memo.content || "";
const createdTs = (memo as any).createdAt || memo.createdTs || Date.now();
try {
const memoCreate = {
content,
createdTs: Math.floor(utils.getTimeStampByDate(createdTs) / 1000),
};
await memoService.createMemo(memoCreate);
succeedAmount++;
} catch (error) {
// do nth
}
}
await memoService.fetchAllMemos();
toastHelper.success(`${succeedAmount} memos successfully imported.`);
};
}
};
fileInputEl.click();
const handleEditorFontStyleChanged = async (value: string) => {
await userService.upsertUserSetting("editorFontStyle", value);
};
return (
<div className="section-container preferences-section-container">
<p className="title-text">Others</p>
<div className="btns-container">
<button className="btn" onClick={handleExportBtnClick}>
Export data as JSON
</button>
<button className="btn" onClick={handleImportBtnClick}>
Import from JSON
</button>
</div>
<p className="title-text">{t("common.basic")}</p>
<label className="form-label selector">
<span className="normal-text">
{t("common.language")}
<BetaBadge className="ml-2" />
</span>
<Selector className="ml-2 w-32" value={setting.locale} dataSource={localeSelectorItems} handleValueChanged={handleLocaleChanged} />
</label>
<p className="title-text">{t("setting.preference")}</p>
<label className="form-label selector">
<span className="normal-text">{t("setting.preference-section.default-memo-visibility")}</span>
<Selector
className="ml-2 w-32"
value={setting.memoVisibility}
dataSource={VISIBILITY_SELECTOR_ITEMS}
handleValueChanged={handleDefaultMemoVisibilityChanged}
/>
</label>
<label className="form-label selector">
<span className="normal-text">{t("setting.preference-section.editor-font-style")}</span>
<Selector
className="ml-2 w-32"
value={setting.editorFontStyle}
dataSource={editorFontStyleSelectorItems}
handleValueChanged={handleEditorFontStyleChanged}
/>
</label>
</div>
);
};
+14 -10
View File
@@ -1,12 +1,14 @@
import { useEffect, useRef, useState } from "react";
import { userService } from "../services";
import toImage from "../labs/html2image";
import { ANIMATION_DURATION, IMAGE_URL_REG } from "../helpers/consts";
import { ANIMATION_DURATION } from "../helpers/consts";
import useI18n from "../hooks/useI18n";
import * as utils from "../helpers/utils";
import { showDialog } from "./Dialog";
import { formatMemoContent, IMAGE_URL_REG } from "../helpers/marked";
import Only from "./common/OnlyWhen";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
import { formatMemoContent } from "./Memo";
import "../less/share-memo-image-dialog.less";
interface Props extends DialogProps {
@@ -15,6 +17,7 @@ interface Props extends DialogProps {
const ShareMemoImageDialog: React.FC<Props> = (props: Props) => {
const { memo: propsMemo, destroy } = props;
const { t } = useI18n();
const { user: userinfo } = userService.getState();
const memo = {
...propsMemo,
@@ -53,10 +56,10 @@ const ShareMemoImageDialog: React.FC<Props> = (props: Props) => {
destroy();
};
const handleImageOnLoad = (ev: React.SyntheticEvent<HTMLImageElement>) => {
if (ev.type === "error") {
toastHelper.error("😟 Image load failed");
(ev.target as HTMLImageElement).remove();
const handleImageOnLoad = (event: React.SyntheticEvent<HTMLImageElement>) => {
if (event.type === "error") {
toastHelper.error("Image load failed");
(event.target as HTMLImageElement).remove();
}
setImgAmount(imgAmount - 1);
};
@@ -72,10 +75,11 @@ const ShareMemoImageDialog: React.FC<Props> = (props: Props) => {
<>
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🌄</span>Share Memo
<span className="icon-text">🌄</span>
{t("common.share")} Memo
</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<i className="fa-solid fa-xmark fa-lg icon-img"></i>
<Icon.X className="icon-img" />
</button>
</div>
<div className="dialog-content-container">
@@ -107,7 +111,7 @@ const ShareMemoImageDialog: React.FC<Props> = (props: Props) => {
};
export default function showShareMemoImageDialog(memo: Memo): void {
showDialog(
generateDialog(
{
className: "share-memo-image-dialog",
},
+11 -6
View File
@@ -1,9 +1,11 @@
import { useEffect } from "react";
import { locationService, shortcutService } from "../services";
import { useAppSelector } from "../store";
import useI18n from "../hooks/useI18n";
import * as utils from "../helpers/utils";
import useToggle from "../hooks/useToggle";
import useLoading from "../hooks/useLoading";
import Icon from "./Icon";
import toastHelper from "./Toast";
import showCreateShortcutDialog from "./CreateShortcutDialog";
import "../less/shortcut-list.less";
@@ -39,7 +41,7 @@ const ShortcutList: React.FC<Props> = () => {
<p className="title-text">
<span className="normal-text">Shortcuts</span>
<button className="btn" onClick={() => showCreateShortcutDialog()}>
<i className="fa-solid fa-plus icon-img fa-xs"></i>
<Icon.Plus className="icon-img" />
</button>
</p>
<div className="shortcuts-container">
@@ -58,6 +60,7 @@ interface ShortcutContainerProps {
const ShortcutContainer: React.FC<ShortcutContainerProps> = (props: ShortcutContainerProps) => {
const { shortcut, isActive } = props;
const { t } = useI18n();
const [showConfirmDeleteBtn, toggleConfirmDeleteBtn] = useToggle(false);
const handleShortcutClick = () => {
@@ -75,7 +78,8 @@ const ShortcutContainer: React.FC<ShortcutContainerProps> = (props: ShortcutCont
try {
await shortcutService.deleteShortcutById(shortcut.id);
} catch (error: any) {
toastHelper.error(error.message);
console.error(error);
toastHelper.error(error.response.data.message);
}
} else {
toggleConfirmDeleteBtn();
@@ -113,22 +117,23 @@ const ShortcutContainer: React.FC<ShortcutContainerProps> = (props: ShortcutCont
</div>
<div className="btns-container">
<span className="action-btn toggle-btn">
<i className="fa-solid fa-ellipsis fa-sm icon-img"></i>
<Icon.MoreHorizontal className="icon-img" />
</span>
<div className="action-btns-wrapper">
<div className="action-btns-container">
<span className="btn" onClick={handlePinShortcutBtnClick}>
{shortcut.rowStatus === "ARCHIVED" ? "Unpin" : "Pin"}
{shortcut.rowStatus === "ARCHIVED" ? t("common.unpin") : t("common.pin")}
</span>
<span className="btn" onClick={handleEditShortcutBtnClick}>
Edit
{t("common.edit")}
</span>
<span
className={`btn delete-btn ${showConfirmDeleteBtn ? "final-confirm" : ""}`}
onClick={handleDeleteMemoClick}
onMouseLeave={handleDeleteBtnMouseLeave}
>
{showConfirmDeleteBtn ? "Delete!" : "Delete"}
{t("common.delete")}
{showConfirmDeleteBtn ? "!" : ""}
</span>
</div>
</div>

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