Merge pull request 'base/build-core' (#1) from base/build-core into main

Reviewed-on: sam/fuware#1
This commit is contained in:
Sam Liu 2024-05-31 08:23:40 +00:00
commit 71d4afcc5e
109 changed files with 7168 additions and 313 deletions

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
PRODUCTION=false
TESTING=false

1
.gitignore vendored
View File

@ -8,3 +8,4 @@
node_modules
node_modules/
.sqlite
*.db

26
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,26 @@
{
"editor.codeActionsOnSave": {
"source.organizeImports": "always",
"source.fixAll": "always",
"source.fixAll.eslint": "explicit"
},
"editor.suggest.preview": true,
"editor.inlayHints.enabled": "offUnlessPressed",
"javascript.inlayHints.parameterNames.enabled": "all",
"editor.formatOnSaveMode": "modificationsIfAvailable",
"eslint.workingDirectories": ["./fuware-fe"],
"editor.insertSpaces": true,
"editor.tabSize": 2,
"python.analysis.autoImportCompletions": true,
"python.analysis.indexing": true,
"python.analysis.fixAll": ["source.unusedImports"],
"python.analysis.packageIndexDepths": [
{
"name": "fuware",
"depth": 3,
"includeAllSymbols": false
}
]
// "python.analysis.extraPaths": ["./fuware"],
// "python.autoComplete.extraPaths": ["./fuware"]
}

View File

@ -1,3 +1,70 @@
# fuware
the warehouse use to tracking item in house
the warehouse use to tracking item in house
# Common CLI
### alembic
generate migration
```bash
alembic revision -m "version message"
```
```bash
alembic upgrade head
```
```bash
alembic history
```
```bash
alembic current
```
```bash
alembic downgrade -1
```
```bash
alembic upgrade +1
```
generate migration auto code
```bash
alembic revision --autogenerate -m "version message"
```
# Front-end
## Usage
```bash
$ npm install # or pnpm install or yarn install
```
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
## Available Scripts
In the project directory, you can run:
### `npm run dev`
Runs the app in the development mode.<br>
Open [http://localhost:5173](http://localhost:5173) to view it in the browser.
### `npm run build`
Builds the app for production to the `dist` folder.<br>
It correctly bundles Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
## Deployment
Learn more about deploying your application with the [documentations](https://vitejs.dev/guide/static-deploy.html)

23
Taskfile.yml Normal file
View File

@ -0,0 +1,23 @@
version: "3"
vars:
GREETING: Hello, World!
env:
DEFAULT_GROUP: Home
PRODUCTION: false
API_PORT: 9000
API_DOCS: True
dotenv:
- .env
- .dev.env
tasks:
py:dev:
desc: runs the backend server
cmds:
- poetry run python fuware/app.py
ui:dev:
desc: runs the frontend server
dir: fuware-fe
cmds:
- pnpm dev

116
alembic.ini Normal file
View File

@ -0,0 +1,116 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url =
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
alembic/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration.

87
alembic/env.py Normal file
View File

@ -0,0 +1,87 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from fuware.core.config import get_app_settings
from fuware.db.models._model_base import SqlAlchemyBase
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = SqlAlchemyBase.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
settings = get_app_settings()
if not settings.DB_URL:
raise Exception("DB URL not set in config")
config.set_main_option("sqlalchemy.url", settings.DB_URL.replace("%", "%%"))
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

26
alembic/script.py.mako Normal file
View File

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,50 @@
"""create user table
Revision ID: 68d05d045e6e
Revises:
Create Date: 2024-05-24 04:12:25.599139
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '68d05d045e6e'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('password', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('is_admin', sa.Boolean(), nullable=True),
sa.Column('is_lock', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_created_at'), 'users', ['created_at'], unique=False)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_name'), 'users', ['name'], unique=False)
op.create_index(op.f('ix_users_password'), 'users', ['password'], unique=False)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_password'), table_name='users')
op.drop_index(op.f('ix_users_name'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_created_at'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###

0
dev/data/.gitkeep Normal file
View File

7
fuware-fe/.dockerignore Normal file
View File

@ -0,0 +1,7 @@
Dockerfile
.dockerignore
node_modules
README.md
.git
.gitignore
.env

32
fuware-fe/.eslintrc.cjs Normal file
View File

@ -0,0 +1,32 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
},
plugins: ['solid'],
extends: ['eslint:recommended', 'plugin:solid/recommended'],
overrides: [
{
env: {
node: true,
},
files: ['.eslintrc.{js,cjs}'],
parserOptions: {
sourceType: 'script',
},
},
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {
indent: ['warn', 2],
quotes: ['error', 'single'],
semi: ['error', 'never'],
'max-lines-per-function': [1, 1000],
'no-unused-vars': ['warn'],
},
}

24
fuware-fe/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

24
fuware-fe/.gitignore copy Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
fuware-fe/.lintstagedrc Normal file
View File

@ -0,0 +1,3 @@
{
"src/**/*.{js,jsx}": ["pnpm eslint", "pnpm prettier"]
}

View File

@ -0,0 +1,4 @@
/node_modules
/public
/build

7
fuware-fe/.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"trailingComma": "all",
"useTabs": false,
"tabWidth": 2,
"semi": false,
"singleQuote": true
}

14
fuware-fe/Dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM node:18.19.0-alpine3.19
WORKDIR /app/client
COPY package*.json .
RUN npm i
COPY . .
ENV NODE_ENV=production
ENV VITE_LOGIN_KEY=7fo24CMyIc
EXPOSE 5000
CMD ["npm", "run", "dev"]

17
fuware-fe/index.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500"
rel="stylesheet"
/>
<title>Vite + Solid</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>

16
fuware-fe/jsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"extends": "./jsconfig.paths.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"resolveJsonModule": true,
"isolatedModules": false,
"allowJs": true,
"skipLibCheck": true
}
}

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@api/*": ["src/api/*"],
"@pages/*": ["src/pages/*"],
"@components/*": ["src/components/*"],
"@routes/*": ["src/routes/*"],
"@utils/*": ["src/utils/*"],
"@assets/*": ["src/assets/*"],
"@context/*": ["src/context/*"],
"@lang/*": ["src/lang/*"],
"@hooks/*": ["src/hooks/*"],
"@/*": ["src/*"]
}
}
}

40
fuware-fe/package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "fuware-fe",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"eslint": "eslint \"src/**/*.{js,jsx}\" --fix",
"prettier": "prettier \"src/**/*.{js,jsx}\" --write"
},
"dependencies": {
"@solidjs/router": "^0.13.3",
"@stitches/core": "^1.2.8",
"@tabler/icons-solidjs": "^3.3.0",
"axios": "^1.6.8",
"crypto-js": "^4.2.0",
"solid-form-handler": "^1.2.3",
"solid-js": "^1.8.15",
"solid-styled-components": "^0.28.5",
"solid-toast": "^0.5.0",
"yup": "^1.4.0"
},
"devDependencies": {
"autoprefixer": "^10.4.19",
"daisyui": "^4.11.1",
"eslint": "^8.56.0",
"eslint-plugin-solid": "^0.14.0",
"lint-staged": "15.2.2",
"postcss": "^8.4.38",
"prettier": "3.2.5",
"tailwindcss": "^3.4.3",
"vite": "^5.2.0",
"vite-plugin-mkcert": "^1.17.5",
"vite-plugin-solid": "^2.10.2"
},
"proxy": "http://localhost:9000"
}

3327
fuware-fe/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 909 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

28
fuware-fe/src/App.css Normal file
View File

@ -0,0 +1,28 @@
#root {
margin: 0 auto;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#main-page {
height: calc(100svh - 56px);
display: flex;
}
#main-page.login-page {
height: 100svh;
}
#main-page .navbar {
width: 350px;
border-right: 1px solid #dedede;
}
#main-page .main-content {
width: calc(100vw - 350px);
overflow-y: auto;
}

20
fuware-fe/src/App.jsx Normal file
View File

@ -0,0 +1,20 @@
import { Toaster } from 'solid-toast'
import './App.css'
import { SiteContextProvider } from './context/SiteContext'
function App(props) {
return (
<SiteContextProvider>
<Toaster
containerStyle={
props.location?.pathname.indexOf('/login') >= 0
? null
: { 'margin-top': '60px' }
}
/>
{props.children}
</SiteContextProvider>
)
}
export default App

14
fuware-fe/src/api/auth.js Normal file
View File

@ -0,0 +1,14 @@
import { protocol } from './index'
import { POST_LOGIN, POST_LOGOUT, POST_REFRESH } from './url'
export const postLogin = (payload) => {
return protocol.post(POST_LOGIN, payload)
}
export const getLogout = () => {
return protocol.get(POST_LOGOUT, {})
}
export const refreshToken = () => {
return protocol.get(POST_REFRESH, {})
}

View File

@ -0,0 +1,80 @@
import { LOGIN_KEY } from '@utils/enum'
import axios from 'axios'
import { Helpers } from '../utils/helper'
import { refreshToken } from './auth'
const protocol = axios.create({
baseURL: '/',
})
const forceLogout = () => {
Helpers.clearStorage()
window.location.href = '/login'
}
protocol.interceptors.request.use(async (config) => {
config.headers.set(
'Content-Type',
config.headers.get('Content-Type') ?? 'application/json',
)
if (
config.url.indexOf('/login') >= 0 ||
config.url.indexOf('/refresh') >= 0
) {
return config
}
const { accessToken, exp } = await JSON.parse(
Helpers.decrypt(localStorage.getItem(LOGIN_KEY)),
)
if (accessToken && !Helpers.checkTokenExpired(exp)) {
config.headers.set('Authorization', `Bearer ${accessToken}`)
}
return config
})
protocol.interceptors.response.use(
(response) => {
return response.data || {}
},
async (error) => {
const {
response: { status, data },
config,
} = error
if (
config.url.indexOf('/login') >= 0 ||
config.url.indexOf('/refresh') >= 0
) {
return Promise.reject(data)
}
if (status === 401 && !config._retry) {
config._retry = true
try {
// call refresh token
const resp = await refreshToken()
if (resp.status === 200) {
const { data } = resp
localStorage.setItem(LOGIN_KEY, Helpers.encrypt(JSON.stringify(data)))
config.headers['Authorization'] = `Bearer ${data.accessToken}`
return protocol(config)
}
} catch (error) {
forceLogout()
return Promise.reject(error)
}
}
if (status === 403) {
forceLogout()
}
return Promise.reject(data)
},
)
export { protocol }

4
fuware-fe/src/api/url.js Normal file
View File

@ -0,0 +1,4 @@
export const POST_LOGIN = '/api/auth/login'
export const POST_LOGOUT = '/api/auth/logout'
export const POST_REFRESH = '/api/auth/refresh'
export const GET_USER_PROFILE = '/api/user/me'

View File

@ -0,0 +1,6 @@
import { protocol } from './index'
import { GET_USER_PROFILE } from './url'
export const getProfile = () => {
return protocol.get(GET_USER_PROFILE, {})
}

View File

@ -0,0 +1,585 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1835.000000pt" height="2012.000000pt" viewBox="0 0 1835.000000 2012.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,2012.000000) scale(0.100000,-0.100000)"
fill="#ffffff" stroke="none">
<path d="M17135 19631 c-3 -5 -20 -13 -38 -16 -17 -4 -36 -10 -42 -14 -5 -4
-30 -13 -55 -20 -25 -7 -53 -17 -62 -22 -10 -5 -25 -9 -33 -9 -9 0 -26 -6 -38
-14 -12 -8 -44 -18 -72 -21 -27 -4 -55 -11 -60 -16 -11 -8 -67 -27 -145 -49
-25 -6 -56 -18 -70 -24 -14 -7 -45 -18 -70 -25 -25 -7 -53 -17 -62 -22 -10 -5
-25 -9 -33 -9 -8 0 -23 -4 -33 -9 -47 -26 -87 -41 -109 -41 -12 0 -23 -4 -23
-10 0 -5 -16 -10 -35 -10 -19 0 -35 -4 -35 -10 0 -5 -5 -10 -11 -10 -6 0 -27
-7 -47 -16 -72 -31 -141 -54 -164 -54 -9 0 -21 -7 -28 -15 -7 -8 -20 -15 -30
-15 -19 0 -108 -33 -151 -56 -14 -7 -45 -16 -67 -20 -23 -4 -42 -10 -42 -14 0
-5 -15 -12 -32 -15 -18 -4 -37 -10 -43 -14 -5 -4 -28 -13 -50 -20 -22 -7 -49
-16 -60 -21 -11 -5 -38 -16 -60 -25 -22 -9 -48 -21 -57 -26 -10 -5 -25 -9 -33
-9 -8 0 -23 -4 -33 -9 -9 -5 -35 -17 -57 -26 -22 -10 -48 -22 -57 -26 -10 -5
-24 -9 -32 -9 -8 0 -36 -11 -63 -24 -26 -13 -75 -34 -108 -46 -33 -12 -78 -31
-101 -41 -22 -11 -48 -19 -57 -19 -9 0 -19 -4 -22 -10 -3 -5 -17 -10 -30 -10
-13 0 -26 -4 -30 -9 -8 -14 -65 -41 -85 -41 -20 0 -77 -27 -85 -41 -4 -5 -15
-9 -26 -9 -19 0 -28 -4 -76 -28 -13 -7 -29 -12 -37 -12 -7 0 -19 -7 -26 -15
-7 -8 -23 -15 -36 -15 -13 0 -24 -4 -24 -10 0 -5 -9 -10 -19 -10 -11 0 -23 -4
-26 -10 -3 -5 -16 -10 -27 -10 -12 0 -29 -6 -37 -13 -21 -18 -119 -57 -142
-57 -11 0 -19 -4 -19 -8 0 -11 -95 -52 -119 -52 -10 0 -24 -7 -31 -14 -6 -8
-28 -20 -48 -27 -20 -6 -41 -15 -47 -20 -5 -5 -21 -9 -36 -9 -15 0 -29 -7 -33
-15 -3 -8 -12 -15 -21 -15 -8 0 -24 -4 -35 -10 -11 -5 -29 -14 -40 -20 -11 -5
-29 -10 -39 -10 -11 0 -22 -4 -25 -9 -9 -14 -107 -61 -127 -61 -9 0 -22 -7
-29 -15 -7 -8 -18 -15 -25 -15 -6 0 -27 -7 -46 -16 -52 -25 -122 -56 -144 -64
-11 -4 -45 -19 -76 -33 -31 -15 -70 -30 -87 -33 -18 -4 -32 -10 -32 -15 0 -5
-11 -9 -24 -9 -13 0 -26 -6 -29 -13 -3 -7 -24 -19 -48 -27 -24 -7 -48 -17 -54
-22 -5 -4 -19 -8 -32 -8 -13 0 -23 -4 -23 -9 0 -11 -57 -41 -77 -41 -12 0 -29
-7 -73 -31 -8 -5 -31 -16 -50 -25 -19 -8 -43 -20 -52 -25 -10 -5 -27 -9 -37
-9 -11 0 -23 -4 -26 -10 -5 -8 -45 -28 -100 -50 -11 -4 -50 -22 -86 -39 -37
-17 -74 -31 -84 -31 -10 0 -23 -6 -29 -14 -6 -8 -40 -24 -76 -36 -36 -12 -72
-28 -80 -34 -8 -7 -28 -18 -45 -25 -16 -7 -38 -17 -47 -22 -10 -5 -27 -9 -37
-9 -11 0 -23 -4 -26 -10 -5 -8 -43 -27 -100 -50 -22 -9 -88 -39 -207 -94 -43
-20 -83 -36 -90 -36 -7 0 -28 -9 -46 -20 -18 -11 -37 -20 -43 -20 -6 0 -17 -7
-25 -15 -8 -8 -31 -17 -52 -21 -20 -4 -44 -12 -52 -18 -8 -7 -26 -17 -40 -23
-14 -6 -59 -27 -101 -47 -42 -20 -84 -36 -93 -36 -9 0 -16 -4 -16 -10 0 -5
-11 -10 -25 -10 -14 0 -25 -3 -25 -7 0 -12 -59 -43 -80 -43 -10 0 -22 -4 -25
-10 -3 -5 -13 -10 -21 -10 -8 0 -31 -10 -52 -23 -20 -13 -86 -43 -147 -67 -60
-24 -117 -49 -125 -55 -8 -6 -28 -17 -45 -24 -16 -7 -38 -17 -47 -22 -10 -5
-27 -9 -37 -9 -11 0 -22 -4 -26 -9 -7 -13 -64 -41 -82 -41 -7 0 -13 -4 -13
-10 0 -5 -11 -10 -25 -10 -14 0 -25 -4 -25 -10 0 -5 -4 -10 -10 -10 -10 0
-153 -63 -200 -89 -46 -25 -62 -31 -79 -31 -11 0 -23 -4 -26 -10 -3 -5 -15
-10 -25 -10 -10 0 -20 -4 -22 -9 -3 -9 -159 -84 -198 -95 -39 -12 -89 -36 -95
-46 -3 -6 -15 -10 -26 -10 -10 0 -19 -4 -19 -10 0 -5 -11 -10 -25 -10 -14 0
-25 -4 -25 -10 0 -5 -6 -10 -12 -10 -7 0 -38 -12 -68 -26 -30 -15 -66 -31 -80
-37 -14 -6 -32 -16 -40 -23 -8 -7 -46 -22 -85 -35 -38 -13 -74 -29 -78 -36 -4
-7 -13 -13 -20 -13 -7 0 -39 -14 -72 -31 -33 -16 -89 -40 -125 -51 -36 -12
-69 -28 -73 -35 -4 -7 -17 -13 -27 -13 -11 0 -20 -4 -20 -10 0 -5 -7 -10 -15
-10 -14 0 -35 -9 -75 -32 -8 -4 -22 -11 -30 -14 -8 -3 -24 -9 -35 -14 -11 -4
-38 -17 -60 -28 -22 -11 -56 -28 -75 -37 -19 -9 -43 -21 -52 -26 -10 -5 -27
-9 -37 -9 -11 0 -23 -4 -26 -10 -3 -5 -23 -17 -43 -26 -20 -9 -48 -21 -62 -27
-14 -6 -31 -16 -38 -21 -19 -14 -113 -46 -134 -46 -10 0 -18 -4 -18 -10 0 -12
-97 -60 -122 -60 -10 0 -18 -4 -18 -8 0 -9 -70 -42 -88 -42 -13 0 -53 -20 -62
-31 -5 -6 -65 -33 -105 -48 -11 -5 -45 -20 -75 -35 -30 -14 -61 -26 -67 -26
-7 0 -13 -4 -13 -10 0 -5 -9 -10 -19 -10 -11 0 -22 -4 -26 -9 -8 -14 -65 -41
-85 -41 -9 0 -51 -18 -93 -40 -41 -22 -84 -40 -95 -40 -11 0 -22 -7 -26 -15
-3 -8 -12 -15 -21 -15 -8 0 -24 -4 -35 -10 -11 -5 -29 -14 -40 -20 -11 -5 -27
-10 -35 -10 -8 0 -15 -4 -15 -8 0 -5 -19 -16 -42 -26 -24 -9 -51 -21 -60 -27
-10 -5 -24 -9 -32 -9 -7 0 -19 -7 -26 -15 -7 -8 -21 -15 -31 -15 -11 0 -19 -4
-19 -10 0 -5 -11 -10 -25 -10 -14 0 -25 -4 -25 -10 0 -5 -9 -10 -20 -10 -11 0
-20 -4 -20 -8 0 -10 -71 -42 -93 -42 -9 0 -19 -4 -22 -10 -3 -5 -13 -10 -21
-10 -8 0 -32 -11 -53 -25 -22 -13 -52 -27 -68 -31 -15 -4 -35 -12 -43 -19 -8
-7 -33 -19 -55 -26 -22 -7 -44 -17 -49 -21 -6 -4 -18 -8 -27 -8 -10 0 -19 -4
-21 -8 -5 -12 -109 -62 -130 -62 -10 0 -18 -4 -18 -9 0 -5 -13 -12 -30 -16
-16 -4 -30 -11 -30 -16 0 -5 -11 -9 -25 -9 -14 0 -25 -3 -25 -7 0 -7 -94 -54
-145 -74 -11 -4 -54 -23 -96 -43 -42 -20 -82 -36 -88 -36 -6 0 -11 -4 -11 -10
0 -5 -5 -10 -10 -10 -6 0 -30 -9 -52 -20 -23 -11 -47 -20 -54 -20 -12 0 -73
-29 -84 -40 -14 -14 -52 -30 -70 -30 -11 0 -20 -4 -20 -10 0 -5 -11 -12 -25
-16 -14 -3 -25 -10 -25 -15 0 -5 -7 -9 -15 -9 -8 0 -23 -4 -33 -9 -9 -5 -44
-21 -77 -36 -33 -15 -77 -36 -98 -46 -22 -10 -45 -19 -52 -19 -7 0 -15 -7 -19
-15 -3 -8 -12 -15 -19 -15 -8 0 -27 -6 -43 -14 -16 -8 -51 -24 -79 -36 -27
-12 -77 -35 -110 -51 -33 -16 -66 -29 -73 -29 -6 0 -12 -4 -12 -10 0 -5 -6
-10 -13 -10 -18 0 -75 -28 -82 -41 -4 -5 -15 -9 -26 -9 -10 0 -19 -4 -19 -10
0 -5 -6 -10 -14 -10 -8 0 -38 -12 -68 -26 -29 -15 -64 -31 -78 -36 -34 -14
-47 -21 -103 -51 -26 -15 -54 -27 -62 -27 -7 0 -15 -3 -17 -7 -3 -8 -129 -73
-141 -73 -3 0 -23 -11 -43 -25 -20 -14 -47 -25 -60 -25 -13 0 -24 -4 -24 -9 0
-5 -8 -11 -17 -15 -10 -3 -25 -9 -33 -14 -37 -22 -61 -32 -74 -32 -8 0 -16 -3
-18 -7 -3 -8 -32 -22 -193 -98 -44 -20 -96 -45 -115 -55 -19 -11 -51 -26 -70
-35 -97 -46 -105 -52 -108 -73 -2 -12 -8 -22 -13 -22 -5 0 -9 -10 -9 -23 0
-13 -6 -32 -14 -42 -12 -17 -12 -23 0 -47 8 -15 14 -40 14 -57 0 -16 5 -33 10
-36 6 -3 10 -15 10 -26 0 -18 18 -58 31 -69 6 -5 22 -35 51 -90 7 -14 17 -28
23 -32 5 -4 19 -31 30 -61 21 -57 85 -137 109 -137 8 0 36 -21 62 -47 42 -41
53 -46 91 -45 42 0 103 20 103 34 0 9 59 38 77 38 7 0 13 5 13 10 0 6 11 10
25 10 14 0 25 4 25 10 0 5 26 21 58 35 31 14 64 30 72 35 8 6 38 22 65 36 28
14 73 38 100 54 28 16 56 29 63 29 7 1 28 12 46 26 18 14 39 25 47 26 8 0 28
9 44 19 52 32 88 50 102 50 7 0 13 5 13 10 0 6 9 10 19 10 11 0 22 4 26 9 7
13 64 41 82 41 7 0 13 5 13 10 0 6 9 10 19 10 11 0 22 4 25 9 3 5 59 34 123
66 65 31 127 61 138 67 62 34 115 58 129 58 9 0 16 4 16 9 0 5 24 19 53 31 56
24 213 97 237 111 8 4 40 20 70 34 30 15 66 33 80 41 52 31 114 55 127 50 11
-5 13 -34 9 -153 -3 -82 -11 -337 -18 -568 -7 -240 -17 -426 -23 -435 -5 -8
-13 -109 -17 -225 -4 -115 -12 -313 -18 -440 -6 -126 -14 -327 -18 -445 -4
-141 -11 -223 -19 -237 -9 -15 -13 -82 -13 -230 0 -121 -4 -213 -10 -219 -5
-5 -12 -108 -14 -229 -11 -449 -21 -680 -31 -695 -10 -15 -20 -254 -31 -690
-2 -124 -9 -229 -14 -234 -6 -6 -10 -87 -10 -187 0 -148 -3 -179 -16 -190 -28
-23 -196 -109 -214 -109 -6 0 -10 -4 -10 -8 0 -5 -28 -21 -62 -37 -35 -16 -79
-39 -98 -51 -19 -11 -52 -26 -72 -33 -21 -7 -38 -16 -38 -19 0 -6 -52 -34 -70
-38 -11 -2 -57 -26 -85 -43 -16 -11 -38 -20 -47 -20 -10 -1 -18 -5 -18 -11 0
-5 -9 -10 -19 -10 -11 0 -22 -4 -26 -9 -7 -13 -64 -41 -82 -41 -7 0 -13 -4
-13 -10 0 -5 -9 -10 -20 -10 -11 0 -23 -7 -26 -15 -4 -8 -15 -15 -25 -15 -10
0 -19 -4 -19 -10 0 -5 -7 -10 -15 -10 -8 0 -36 -12 -62 -27 -53 -29 -64 -34
-108 -54 -16 -7 -37 -19 -45 -25 -8 -7 -18 -13 -22 -14 -14 -2 -83 -30 -98
-40 -14 -9 -50 -26 -110 -53 -14 -7 -39 -22 -56 -34 -17 -13 -36 -23 -42 -23
-6 0 -33 -12 -59 -27 -26 -15 -66 -36 -88 -47 -22 -12 -42 -23 -45 -26 -3 -3
-41 -24 -85 -46 -44 -22 -89 -46 -100 -52 -45 -25 -191 -92 -202 -92 -6 0 -13
-4 -15 -8 -4 -11 -108 -62 -125 -62 -7 0 -13 -4 -13 -10 0 -5 -11 -12 -25 -16
-14 -3 -25 -10 -25 -15 0 -5 -9 -9 -19 -9 -11 0 -23 -4 -26 -10 -3 -5 -14 -10
-25 -10 -10 0 -20 -7 -24 -15 -3 -8 -11 -15 -18 -15 -7 0 -24 -9 -38 -20 -14
-11 -31 -20 -39 -20 -8 0 -28 -10 -45 -23 -31 -22 -53 -34 -113 -59 -18 -7
-33 -16 -33 -20 0 -3 -17 -12 -37 -18 -21 -7 -55 -23 -75 -36 -21 -13 -42 -24
-47 -24 -10 0 -62 -28 -153 -81 -27 -16 -55 -29 -63 -29 -8 0 -15 -4 -15 -9 0
-11 -56 -41 -77 -41 -7 0 -13 -4 -13 -10 0 -5 -9 -10 -20 -10 -11 0 -23 -7
-26 -15 -4 -8 -12 -15 -20 -15 -7 0 -29 -9 -49 -20 -20 -11 -42 -20 -50 -20
-8 0 -15 -4 -15 -9 0 -6 -21 -21 -47 -34 -100 -51 -129 -66 -163 -82 -64 -30
-140 -74 -156 -90 -8 -8 -22 -15 -30 -15 -8 0 -14 -4 -14 -10 0 -5 -6 -10 -13
-10 -18 0 -75 -28 -82 -41 -4 -5 -15 -9 -25 -9 -10 0 -20 -4 -22 -9 -6 -16
-165 -101 -188 -101 -6 0 -10 -4 -10 -9 0 -5 -24 -21 -52 -35 -29 -15 -71 -38
-93 -52 -22 -13 -50 -29 -62 -34 -13 -5 -23 -14 -23 -20 0 -5 -7 -10 -15 -10
-17 0 -76 -32 -120 -66 -18 -13 -39 -24 -47 -24 -8 0 -23 -9 -33 -20 -9 -10
-40 -31 -69 -46 -28 -14 -55 -33 -59 -40 -4 -8 -14 -14 -22 -14 -8 0 -30 -13
-48 -30 -19 -16 -39 -30 -43 -30 -21 -1 -64 -51 -64 -74 0 -14 -5 -28 -10 -31
-13 -8 -13 -132 0 -140 6 -3 10 -22 10 -41 0 -19 4 -34 9 -34 11 0 41 -56 41
-76 0 -7 11 -21 25 -32 14 -11 25 -24 25 -29 0 -11 42 -97 57 -115 6 -7 16
-29 22 -49 13 -40 81 -118 121 -139 27 -14 59 -41 93 -77 39 -43 129 -21 194
45 10 10 38 30 63 43 25 13 54 32 65 42 11 10 38 27 60 37 22 10 49 28 59 39
11 12 25 21 33 21 16 0 50 18 78 42 11 9 52 33 90 53 39 20 74 40 80 46 5 5
17 9 27 9 10 0 18 4 18 9 0 11 56 41 77 41 7 0 13 5 13 10 0 6 5 10 11 10 6 0
25 11 43 25 18 14 40 25 49 25 10 0 17 5 17 10 0 6 9 10 20 10 11 0 20 4 20
10 0 5 27 22 60 37 33 14 60 30 60 35 0 4 5 8 12 8 6 0 19 6 27 13 9 8 32 20
51 27 19 8 37 17 40 21 3 3 30 19 60 33 30 15 78 40 105 56 28 16 58 29 68 29
9 1 17 5 17 10 0 11 56 41 77 41 7 0 13 5 13 10 0 6 11 10 24 10 13 0 26 7 30
15 3 8 31 27 62 42 86 42 94 47 94 55 0 5 7 8 15 8 8 0 36 12 62 27 54 30 64
35 113 58 35 17 114 59 203 108 26 15 54 27 62 27 8 0 15 3 15 8 0 10 58 42
76 42 8 0 14 5 14 10 0 6 11 10 25 10 14 0 25 4 25 9 0 4 39 29 88 53 154 80
202 106 215 116 6 5 23 13 37 17 14 4 43 19 64 33 22 13 44 25 50 27 17 3 58
23 91 44 17 11 36 20 43 20 7 1 29 12 50 26 39 26 145 83 252 136 36 18 67 36
68 41 2 4 10 8 18 8 7 0 29 9 49 20 20 11 42 20 49 20 8 0 16 6 19 14 3 7 18
17 34 21 15 3 53 22 84 41 31 19 64 34 73 34 9 0 16 4 16 9 0 5 8 11 18 15 9
3 31 14 47 25 17 11 38 20 48 20 9 1 17 5 17 10 0 5 8 11 18 15 9 3 24 9 32
14 38 22 61 32 75 32 8 0 15 4 15 9 0 5 8 11 18 15 9 3 24 10 32 15 8 5 40 21
70 36 30 15 82 42 115 60 106 59 124 67 134 60 18 -11 14 -450 -4 -503 -11
-32 -15 -92 -14 -257 0 -118 -3 -221 -8 -227 -8 -12 -16 -244 -28 -733 -3
-115 -10 -240 -15 -277 -6 -37 -10 -148 -10 -247 0 -99 -4 -224 -10 -278 -5
-54 -12 -217 -15 -363 -3 -146 -9 -429 -13 -630 -4 -214 -11 -371 -17 -380 -6
-9 -13 -190 -18 -450 -38 -2158 -40 -2240 -54 -2273 -9 -19 -13 -86 -13 -209
0 -161 2 -182 19 -204 18 -22 19 -23 35 -5 12 13 16 37 16 94 0 42 5 79 10 82
6 4 10 54 10 119 0 61 4 116 9 121 5 6 11 61 13 123 2 73 7 115 15 120 7 5 14
48 17 112 14 259 18 306 27 315 5 5 9 48 9 95 0 56 5 92 14 105 9 14 16 72 21
190 5 112 12 179 21 197 8 16 14 58 14 106 0 43 4 82 9 87 5 6 12 53 15 105 4
52 9 137 12 189 3 57 11 103 20 115 9 14 14 48 14 108 0 48 5 90 10 93 6 3 10
48 10 100 0 52 4 97 10 100 6 3 10 50 10 105 0 55 4 102 10 105 6 3 10 42 10
87 0 49 6 92 15 111 8 18 17 77 20 132 3 55 8 145 11 200 3 55 10 104 15 110
5 5 9 50 9 100 0 65 4 97 15 111 10 14 14 47 15 121 0 57 4 105 10 108 6 3 10
52 10 110 0 58 4 107 10 110 6 3 10 52 10 110 0 76 4 107 14 118 14 14 15 30
32 357 3 58 9 109 15 115 5 5 9 53 9 106 0 60 5 109 14 130 10 23 16 101 21
244 4 116 11 215 15 220 4 6 10 60 12 121 2 65 8 117 15 125 6 8 14 63 17 124
3 61 10 166 16 235 5 69 13 180 17 247 5 75 13 127 21 135 8 8 12 49 12 128 0
64 4 120 9 125 6 6 12 62 15 125 14 307 20 353 46 391 14 22 39 42 60 49 19 7
46 21 59 32 14 10 43 28 65 38 23 11 64 33 91 49 28 16 57 30 66 30 9 1 19 8
23 16 3 8 12 15 20 15 9 0 31 9 51 20 20 11 42 20 50 20 8 0 15 4 15 10 0 5
26 21 58 36 31 14 59 29 62 32 11 15 89 52 109 52 11 0 21 4 21 10 0 10 95 60
114 60 6 0 22 12 35 25 13 14 32 25 42 25 10 0 19 3 19 8 0 8 18 18 145 79 17
7 44 22 60 33 17 10 38 19 48 19 9 1 17 4 17 9 0 8 7 12 90 55 30 16 78 41
105 57 28 15 60 29 73 29 12 1 22 5 22 10 0 11 87 61 106 61 8 0 14 4 14 9 0
10 97 61 117 61 7 0 13 3 13 8 0 8 57 42 71 42 5 0 32 15 61 33 29 18 79 46
110 61 32 15 60 31 63 36 3 5 20 12 38 15 17 4 34 11 37 15 10 14 90 60 104
60 8 0 16 4 18 8 5 14 97 62 118 62 11 0 20 4 20 10 0 5 19 19 43 30 23 11 60
31 82 44 22 14 78 44 125 68 47 23 103 54 125 67 60 38 86 51 100 51 7 0 15 4
17 8 3 9 107 62 120 62 5 0 19 9 31 20 25 24 74 50 94 50 7 0 13 3 13 8 0 8
11 14 77 45 21 10 45 23 53 28 140 85 152 89 180 64 11 -10 20 -24 20 -32 0
-8 11 -26 25 -41 14 -15 25 -32 25 -39 0 -7 5 -13 10 -13 6 0 10 -9 10 -19 0
-11 3 -21 8 -23 12 -5 62 -87 62 -102 0 -8 3 -16 8 -18 8 -3 62 -84 62 -94 0
-3 15 -29 34 -57 18 -29 44 -71 56 -94 13 -24 27 -43 32 -43 4 0 8 -11 8 -25
0 -14 5 -25 10 -25 6 0 18 -16 27 -35 9 -19 20 -35 25 -35 4 0 8 -7 8 -15 0
-8 16 -37 35 -64 19 -26 35 -52 35 -55 0 -4 15 -31 33 -59 18 -29 41 -68 51
-87 10 -19 26 -44 36 -55 9 -11 24 -36 33 -56 9 -20 23 -42 32 -49 8 -7 15
-21 15 -31 0 -11 5 -19 10 -19 6 0 10 -6 10 -14 0 -8 11 -28 25 -44 14 -17 25
-37 25 -46 0 -9 5 -16 10 -16 6 0 10 -6 10 -13 0 -7 11 -24 25 -39 14 -15 25
-31 25 -36 0 -15 30 -69 45 -82 18 -15 45 -68 45 -87 0 -7 7 -16 16 -20 8 -3
26 -30 40 -59 13 -29 28 -55 33 -59 12 -7 41 -64 41 -80 0 -7 7 -15 15 -19 8
-3 26 -29 39 -58 14 -30 34 -62 45 -72 12 -11 21 -27 21 -38 0 -10 5 -18 10
-18 6 0 10 -9 10 -19 0 -10 6 -21 13 -24 13 -5 57 -81 57 -99 0 -6 11 -19 25
-30 14 -11 25 -27 26 -36 0 -9 8 -29 17 -43 29 -44 56 -87 77 -121 11 -18 28
-44 37 -58 9 -14 17 -35 17 -47 1 -13 6 -23 12 -23 16 0 38 -31 39 -52 0 -10
5 -18 10 -18 6 0 10 -7 10 -16 0 -9 11 -25 25 -36 14 -11 25 -26 25 -34 0 -17
18 -50 43 -78 21 -25 47 -74 47 -91 0 -7 7 -15 15 -19 8 -3 26 -30 40 -60 14
-30 30 -58 36 -62 6 -3 20 -25 30 -48 10 -23 27 -51 39 -61 11 -10 20 -26 20
-37 0 -10 5 -18 10 -18 6 0 10 -8 10 -18 0 -10 11 -29 25 -42 14 -13 25 -32
25 -42 0 -10 5 -18 10 -18 6 0 10 -6 10 -13 0 -7 11 -24 25 -39 14 -15 25 -32
25 -38 0 -6 15 -32 32 -58 18 -26 44 -67 57 -92 13 -25 27 -47 31 -50 6 -5 21
-31 52 -90 7 -14 15 -27 18 -30 4 -3 12 -20 19 -37 8 -18 18 -33 23 -33 5 0
18 -20 29 -45 11 -24 25 -50 32 -57 19 -20 47 -66 47 -78 0 -6 8 -20 18 -30
10 -11 29 -40 41 -65 12 -25 28 -51 34 -58 19 -20 36 -49 48 -80 6 -15 15 -27
19 -27 4 0 13 -12 19 -28 17 -41 45 -86 59 -95 6 -4 12 -15 12 -23 0 -8 9 -24
20 -36 11 -12 20 -30 20 -40 0 -10 4 -18 9 -18 9 0 35 -39 74 -110 10 -19 28
-44 38 -56 10 -11 19 -26 19 -32 0 -7 16 -35 35 -62 19 -27 35 -55 35 -60 0
-6 8 -20 18 -30 10 -11 28 -40 41 -65 12 -24 26 -47 31 -50 11 -7 40 -64 40
-79 0 -6 11 -20 25 -30 14 -10 25 -27 25 -37 0 -11 5 -19 10 -19 6 0 10 -6 10
-14 0 -7 11 -25 25 -40 14 -15 25 -36 25 -47 0 -10 4 -19 8 -19 4 0 16 -15 27
-32 10 -18 21 -35 24 -38 4 -3 12 -16 19 -30 7 -14 21 -38 32 -55 35 -54 49
-80 62 -108 7 -15 16 -27 20 -27 5 0 19 -24 33 -53 13 -30 31 -60 40 -67 9 -7
24 -32 35 -54 10 -23 28 -50 39 -60 12 -11 21 -27 21 -37 0 -10 5 -21 10 -24
15 -10 60 -82 60 -97 0 -7 11 -22 25 -32 14 -10 25 -25 25 -33 0 -13 65 -121
90 -150 5 -7 10 -19 10 -28 0 -8 4 -15 9 -15 10 0 61 -86 61 -102 0 -6 11 -19
25 -30 14 -11 25 -28 25 -38 0 -10 3 -20 8 -22 13 -6 62 -76 62 -89 0 -7 10
-23 21 -35 24 -26 42 -55 61 -96 7 -16 16 -28 20 -28 4 0 21 -26 37 -58 16
-32 36 -64 45 -71 9 -8 16 -19 16 -26 0 -7 11 -23 24 -37 12 -14 26 -32 29
-39 4 -8 18 -32 32 -54 14 -22 28 -47 32 -55 4 -8 15 -24 25 -34 10 -11 18
-26 18 -34 0 -8 11 -23 25 -34 14 -11 25 -27 25 -37 0 -10 11 -27 25 -39 14
-12 25 -28 25 -35 0 -8 9 -22 20 -32 11 -10 20 -23 20 -30 0 -6 11 -22 25 -35
14 -13 25 -29 25 -35 0 -7 11 -24 24 -38 14 -15 27 -36 31 -47 3 -11 11 -20
16 -20 5 0 9 -7 9 -15 0 -8 5 -15 11 -15 14 0 4 77 -13 97 -17 20 -58 105 -58
118 0 7 -7 18 -15 25 -8 7 -15 23 -15 36 0 12 -4 26 -10 29 -5 3 -10 15 -10
26 0 10 -4 19 -10 19 -5 0 -10 9 -10 20 0 11 -7 23 -15 26 -8 4 -15 15 -15 25
0 10 -4 19 -10 19 -5 0 -10 9 -10 19 0 11 -4 23 -10 26 -5 3 -10 14 -10 25 0
10 -7 20 -15 24 -8 3 -15 12 -15 21 0 8 -4 23 -10 33 -33 61 -81 170 -86 199
-3 18 -11 37 -18 41 -6 4 -17 21 -25 37 -7 17 -17 38 -22 47 -5 10 -9 25 -9
33 0 8 -4 15 -9 15 -11 0 -41 56 -41 77 0 7 -4 13 -10 13 -5 0 -10 11 -10 25
0 14 -4 25 -9 25 -11 0 -41 56 -41 77 0 7 -4 13 -10 13 -5 0 -10 9 -10 19 0
11 -4 22 -9 26 -6 3 -22 31 -36 63 -15 31 -31 64 -36 72 -18 30 -59 121 -59
130 0 6 -3 10 -8 10 -9 0 -52 93 -52 114 0 9 -4 16 -9 16 -11 0 -41 56 -41 77
0 7 -4 13 -10 13 -5 0 -10 11 -10 25 0 14 -4 25 -9 25 -11 0 -41 56 -41 77 0
7 -4 13 -10 13 -5 0 -10 9 -10 19 0 11 -4 23 -10 26 -5 3 -23 34 -40 68 -16
34 -51 98 -76 142 -25 44 -52 96 -60 115 -8 19 -19 42 -24 50 -23 37 -60 115
-60 126 0 7 -4 14 -8 16 -11 4 -62 108 -62 125 0 7 -4 13 -10 13 -5 0 -12 11
-16 25 -3 14 -10 25 -15 25 -5 0 -9 7 -9 15 0 14 -38 87 -70 135 -9 14 -24 41
-34 60 -9 19 -21 42 -26 50 -5 8 -21 41 -35 72 -14 31 -29 59 -34 63 -5 3 -13
20 -17 38 -3 17 -17 48 -30 67 -13 19 -24 40 -24 47 0 7 -4 13 -10 13 -5 0
-10 7 -10 17 0 9 -10 30 -23 47 -13 17 -36 57 -51 88 -15 32 -31 60 -37 63 -5
4 -9 15 -9 26 0 10 -4 19 -10 19 -5 0 -10 6 -10 13 0 14 -6 26 -70 142 -22 39
-51 95 -66 125 -15 30 -29 57 -33 60 -3 3 -12 19 -20 35 -7 17 -17 38 -22 47
-5 10 -9 25 -9 33 0 8 -4 15 -9 15 -11 0 -41 56 -41 76 0 7 -4 14 -9 16 -13 5
-61 86 -61 103 0 8 -4 15 -10 15 -5 0 -10 6 -10 14 0 7 -7 19 -15 26 -8 7 -15
21 -15 31 0 11 -4 19 -10 19 -5 0 -10 8 -11 18 0 9 -9 31 -19 47 -11 17 -31
53 -45 80 -15 28 -39 73 -55 100 -16 28 -29 58 -29 68 -1 10 -8 20 -16 23 -8
4 -15 10 -15 16 0 5 -9 28 -20 50 -11 23 -20 47 -20 54 0 6 -6 14 -12 17 -7 2
-26 29 -41 58 -16 30 -37 68 -47 84 -10 17 -19 38 -19 48 -1 9 -5 17 -11 17
-5 0 -10 8 -10 18 0 10 -5 23 -11 29 -6 6 -23 35 -38 64 -51 100 -92 174 -102
183 -5 6 -9 18 -9 28 0 10 -4 18 -8 18 -4 0 -14 16 -21 35 -7 19 -28 58 -47
86 -19 28 -34 57 -34 65 0 8 -4 14 -10 14 -5 0 -12 11 -16 25 -3 14 -10 25
-15 25 -5 0 -9 9 -9 19 0 11 -4 23 -10 26 -5 3 -10 14 -10 25 0 10 -7 20 -15
24 -8 3 -15 14 -15 25 0 11 -3 21 -7 23 -10 4 -33 45 -85 148 -23 47 -45 87
-50 88 -4 2 -8 15 -8 28 0 13 -4 24 -10 24 -5 0 -10 7 -10 15 0 8 -4 15 -8 15
-5 0 -23 28 -41 63 -18 34 -45 85 -61 112 -15 28 -29 58 -29 68 -1 9 -5 17
-10 17 -5 0 -14 12 -20 28 -16 38 -60 114 -77 133 -8 8 -14 20 -14 26 0 11
-74 156 -90 178 -20 27 -60 107 -60 120 0 8 -4 15 -10 15 -5 0 -12 11 -16 25
-3 14 -10 25 -15 25 -5 0 -9 6 -9 13 0 20 -28 73 -45 87 -9 7 -22 29 -31 49
-19 45 -24 54 -57 114 -15 26 -27 54 -27 62 0 8 -4 15 -9 15 -5 0 -21 24 -36
53 -15 28 -35 66 -45 82 -10 17 -19 38 -19 48 -1 9 -5 17 -11 17 -5 0 -10 7
-10 15 0 8 -16 39 -35 69 -19 30 -35 58 -35 61 0 4 -7 13 -15 22 -8 8 -13 19
-9 24 3 5 -2 12 -10 15 -9 3 -16 12 -16 20 0 13 -43 97 -63 122 -11 14 14 48
48 64 33 15 66 35 78 47 6 6 19 11 29 11 10 0 21 7 24 15 4 8 10 15 16 15 5 0
28 9 50 20 23 11 47 20 54 20 6 0 14 6 17 13 2 6 29 25 58 40 30 16 68 37 84
47 17 10 38 19 48 19 9 1 17 6 17 11 0 6 8 10 19 10 10 0 24 7 31 15 7 8 17
15 22 15 6 0 29 13 52 28 23 16 69 41 101 57 33 16 82 42 108 57 26 15 54 28
62 28 8 0 15 4 15 9 0 10 97 61 116 61 7 0 17 6 21 13 10 16 49 37 68 37 8 0
15 5 15 10 0 6 5 10 11 10 6 0 25 11 43 25 18 14 39 25 47 26 8 0 28 9 44 19
47 29 87 50 97 50 6 0 22 8 36 19 43 30 102 61 116 61 8 0 16 7 20 15 3 8 15
15 26 15 11 0 20 3 20 8 0 7 48 32 62 32 5 0 19 9 31 20 12 12 32 23 44 26 13
4 23 10 23 15 0 5 9 9 19 9 11 0 23 5 26 10 8 13 104 60 122 60 7 0 13 4 13 8
0 5 10 14 23 20 12 7 49 28 82 47 33 20 67 38 75 41 46 18 165 82 207 112 17
12 38 22 47 22 9 0 16 5 16 10 0 6 11 10 25 10 14 0 25 4 25 9 0 5 26 22 58
37 31 16 59 31 62 34 3 4 12 10 20 13 8 4 29 16 45 26 17 11 38 20 48 20 9 1
17 5 17 10 0 5 8 11 18 15 9 3 24 9 32 14 8 5 35 20 60 32 57 29 137 72 196
105 24 14 51 25 59 25 8 0 15 4 15 10 0 5 11 12 25 16 14 3 25 9 25 13 0 3 15
12 33 20 17 8 34 17 37 20 7 9 41 28 75 45 94 44 92 42 266 138 75 42 109 60
164 86 22 11 58 30 80 42 22 13 51 30 65 37 14 8 39 23 55 33 17 10 38 19 48
19 9 1 17 6 17 11 0 6 9 10 20 10 11 0 23 7 26 15 4 8 12 15 20 15 7 0 30 10
51 21 21 12 67 38 103 57 36 19 72 41 80 47 8 6 38 20 65 30 28 10 52 21 55
25 3 4 23 15 45 25 22 10 51 26 65 35 46 31 121 70 134 70 8 0 16 7 20 15 3 8
15 15 26 15 11 0 20 5 20 10 0 6 8 10 18 11 9 0 31 9 47 20 17 10 37 22 45 26
8 3 47 25 85 48 39 23 87 48 108 54 20 7 37 16 37 19 0 4 15 13 33 20 58 24
82 36 112 58 17 11 46 29 65 38 19 10 58 30 85 46 28 15 58 29 68 29 9 1 17 5
17 11 0 5 11 12 25 16 14 3 25 10 25 14 0 4 11 11 24 14 14 3 31 12 39 20 16
16 87 56 100 56 5 0 29 12 55 27 26 16 50 28 54 28 3 0 10 3 14 8 4 4 15 7 25
7 10 0 22 6 26 13 11 19 104 71 213 120 14 7 39 21 55 32 17 12 44 28 60 36
17 7 36 20 43 27 7 6 20 12 28 12 8 0 14 5 14 10 0 6 6 10 13 10 17 0 75 28
82 40 5 8 34 23 85 44 8 3 33 18 55 31 22 14 48 28 58 32 9 3 17 9 17 13 0 10
57 40 75 40 7 0 20 9 31 21 10 11 37 29 60 40 23 10 48 25 56 32 7 7 34 23 60
36 25 12 52 29 58 37 7 7 17 14 23 14 6 0 19 8 29 17 11 9 42 29 71 44 40 20
53 32 55 53 3 21 -1 26 -17 26 -12 0 -21 -4 -21 -10 0 -5 -17 -10 -38 -10 -21
0 -46 -6 -57 -14 -11 -7 -40 -16 -65 -19 -102 -14 -130 -20 -136 -28 -3 -5
-18 -9 -33 -9 -16 0 -34 -7 -41 -15 -7 -8 -27 -15 -46 -15 -18 0 -36 -4 -39
-10 -3 -5 -22 -10 -41 -10 -19 0 -34 -4 -34 -10 0 -5 -14 -10 -30 -10 -18 0
-33 -6 -36 -15 -4 -8 -19 -15 -34 -15 -16 0 -32 -4 -35 -10 -3 -5 -15 -10 -26
-10 -19 0 -27 -3 -86 -34 -17 -9 -40 -16 -51 -16 -12 0 -24 -4 -27 -10 -3 -5
-14 -10 -24 -10 -9 0 -55 -16 -101 -35 -47 -19 -102 -38 -122 -41 -21 -4 -43
-13 -49 -20 -6 -8 -22 -14 -35 -14 -13 0 -24 -4 -24 -10 0 -5 -11 -10 -25 -10
-23 0 -41 -7 -85 -32 -8 -4 -22 -11 -30 -14 -8 -3 -23 -10 -32 -15 -10 -5 -29
-9 -43 -9 -14 0 -25 -4 -25 -10 0 -5 -11 -10 -24 -10 -13 0 -26 -7 -30 -15 -3
-9 -18 -15 -36 -15 -16 0 -30 -4 -30 -10 0 -5 -11 -10 -25 -10 -14 0 -25 -4
-25 -10 0 -5 -11 -10 -24 -10 -13 0 -29 -7 -36 -15 -7 -8 -19 -15 -26 -15 -8
0 -22 -4 -32 -9 -9 -5 -35 -14 -57 -21 -22 -7 -47 -18 -55 -24 -8 -7 -42 -20
-75 -30 -33 -10 -67 -23 -75 -30 -8 -7 -33 -18 -55 -25 -46 -14 -83 -29 -105
-42 -8 -5 -22 -12 -30 -15 -8 -3 -26 -11 -40 -16 -14 -6 -36 -15 -50 -21 -14
-5 -37 -16 -52 -23 -14 -8 -33 -14 -42 -14 -9 0 -16 -4 -16 -10 0 -5 -7 -10
-15 -10 -8 0 -23 -4 -33 -9 -25 -13 -134 -62 -157 -71 -11 -4 -31 -12 -45 -18
-90 -39 -132 -58 -188 -83 -34 -16 -76 -32 -92 -36 -17 -3 -30 -9 -30 -12 0
-8 -52 -31 -115 -52 -22 -8 -44 -20 -48 -26 -4 -7 -17 -13 -27 -13 -11 0 -20
-4 -20 -10 0 -5 -11 -10 -25 -10 -14 0 -25 -4 -25 -10 0 -5 -13 -10 -29 -10
-16 0 -31 -6 -35 -15 -3 -8 -12 -15 -20 -15 -9 0 -31 -9 -51 -20 -20 -11 -42
-20 -50 -20 -8 0 -15 -3 -15 -7 0 -5 -21 -16 -48 -26 -26 -10 -84 -36 -128
-57 -45 -22 -88 -40 -95 -40 -8 0 -23 -7 -33 -15 -11 -8 -28 -15 -38 -15 -10
0 -18 -4 -18 -10 0 -5 -9 -10 -19 -10 -11 0 -23 -4 -26 -10 -3 -5 -15 -10 -26
-10 -10 0 -19 -4 -19 -9 0 -12 -58 -41 -82 -41 -10 0 -18 -4 -18 -10 0 -5 -13
-10 -30 -10 -16 0 -30 -3 -30 -8 0 -4 -17 -16 -37 -26 -21 -10 -46 -22 -55
-27 -10 -5 -25 -9 -33 -9 -8 0 -15 -4 -15 -9 0 -5 -15 -15 -32 -22 -18 -6 -46
-17 -63 -24 -67 -25 -115 -49 -115 -57 0 -4 -6 -8 -14 -8 -8 0 -49 -18 -92
-40 -43 -22 -84 -40 -91 -40 -7 0 -44 -16 -82 -35 -37 -19 -71 -35 -75 -35 -3
0 -24 -11 -46 -25 -22 -14 -46 -25 -55 -25 -8 0 -15 -4 -15 -10 0 -5 -11 -10
-24 -10 -14 0 -28 -4 -31 -10 -3 -5 -14 -10 -24 -10 -10 0 -24 -7 -31 -15 -7
-8 -21 -15 -31 -15 -11 0 -19 -4 -19 -10 0 -5 -8 -10 -17 -11 -10 0 -31 -9
-48 -19 -45 -28 -161 -82 -227 -106 -20 -7 -63 -26 -95 -42 -32 -16 -71 -36
-88 -43 -16 -8 -32 -17 -35 -20 -3 -3 -23 -14 -45 -24 -45 -19 -141 -63 -167
-76 -10 -5 -25 -9 -33 -9 -8 0 -15 -4 -15 -10 0 -5 -9 -10 -19 -10 -11 0 -22
-4 -25 -9 -8 -12 -106 -61 -122 -61 -7 0 -17 -6 -21 -13 -4 -8 -30 -22 -58
-32 -27 -10 -68 -28 -90 -40 -22 -12 -60 -28 -85 -36 -25 -7 -49 -19 -53 -26
-4 -7 -17 -13 -27 -13 -11 0 -20 -4 -20 -10 0 -5 -9 -10 -19 -10 -11 0 -23 -4
-26 -10 -3 -5 -14 -10 -25 -10 -10 0 -20 -7 -24 -15 -3 -8 -15 -15 -26 -15
-11 0 -20 -4 -20 -10 0 -5 -6 -10 -13 -10 -18 0 -75 -28 -82 -41 -4 -5 -15 -9
-26 -9 -10 0 -19 -4 -19 -10 0 -5 -7 -10 -15 -10 -8 0 -23 -4 -33 -9 -9 -5
-42 -20 -72 -34 -78 -35 -121 -56 -150 -73 -41 -23 -60 -18 -60 15 0 17 5 33
10 36 6 4 28 34 50 68 22 34 43 67 48 72 4 6 26 39 47 75 22 36 45 72 50 81 6
9 27 42 48 73 20 31 37 61 37 67 0 5 7 12 15 15 8 4 15 11 15 18 0 6 9 22 20
36 11 14 20 32 20 41 0 9 11 22 25 29 14 7 25 22 25 32 0 18 46 89 63 96 4 2
7 11 7 20 0 8 7 24 17 34 17 19 33 44 55 88 7 14 15 27 19 30 3 3 14 20 24 38
11 17 23 32 27 32 4 0 8 9 8 19 0 11 11 28 25 39 14 11 25 29 25 41 0 12 5 21
10 21 6 0 10 6 10 14 0 7 11 25 25 40 14 15 25 33 25 40 0 8 7 20 16 27 9 7
20 22 25 34 5 11 21 38 35 60 13 22 27 48 31 58 3 9 9 17 13 17 5 0 20 24 34
53 13 29 34 61 45 71 12 11 21 26 21 34 0 8 16 36 35 62 19 26 35 51 35 55 0
4 16 29 35 55 19 26 35 53 35 59 0 7 11 24 25 39 14 15 25 32 25 39 0 7 5 13
10 13 6 0 10 9 10 19 0 11 11 28 25 39 14 11 25 28 26 38 0 11 8 30 17 44 9
14 26 41 37 59 11 18 30 47 42 65 12 17 34 55 49 84 15 30 33 56 41 59 7 3 13
12 13 19 0 12 44 88 60 104 3 3 16 25 29 50 13 25 30 52 37 61 8 8 14 21 14
27 0 7 4 12 8 12 4 0 13 12 20 28 7 15 23 45 37 67 13 22 29 49 35 60 6 11 19
34 30 50 11 17 25 41 32 55 7 14 18 31 25 38 7 7 13 20 13 28 0 8 4 14 8 14 5
0 17 15 27 34 14 26 38 44 94 72 42 21 78 41 81 44 9 11 72 40 87 40 7 0 13 4
13 9 0 5 8 11 18 15 9 3 31 14 47 25 17 11 40 20 51 20 12 1 24 7 27 14 3 8
30 26 61 41 31 15 58 31 61 37 4 5 13 9 21 9 8 0 38 15 67 33 29 18 66 38 82
46 17 7 37 17 45 22 8 5 22 12 30 16 8 4 17 9 20 13 3 3 21 14 40 23 19 10 42
21 50 26 8 5 29 15 45 22 17 8 44 24 61 36 17 13 46 28 64 35 19 6 49 21 67
32 96 62 123 77 152 82 17 4 31 10 31 14 0 5 26 22 58 37 31 16 73 39 92 51
19 12 40 22 47 22 7 0 23 10 37 23 14 13 44 30 68 37 24 7 48 19 53 25 6 6 25
18 43 26 17 7 32 17 32 20 0 4 8 9 18 13 9 3 24 10 32 15 8 5 29 15 45 22 17
8 44 24 61 36 17 13 38 23 47 23 8 0 17 3 19 8 2 4 30 20 63 37 33 16 67 34
75 41 30 24 91 54 109 54 10 0 21 7 25 15 3 8 12 15 21 15 8 0 15 4 15 9 0 5
8 11 18 14 9 3 33 14 52 25 19 11 49 26 65 33 17 8 32 17 35 21 3 3 31 20 63
36 31 16 63 34 70 39 6 5 28 16 47 23 19 8 40 19 47 25 31 26 40 32 73 45 19
7 42 18 50 25 8 6 29 17 45 25 17 8 44 24 60 35 17 12 45 27 63 33 18 6 45 20
60 30 15 11 43 27 62 36 19 9 67 35 105 56 39 22 87 47 108 57 20 10 37 21 37
25 0 4 16 14 35 23 36 15 92 42 126 60 10 5 19 13 19 17 0 5 9 8 20 8 11 0 20
5 20 10 0 10 22 21 98 52 17 7 32 16 32 20 0 3 25 19 55 33 30 15 59 33 66 41
6 8 19 14 28 15 9 0 30 9 46 20 17 11 38 22 48 25 9 4 17 9 17 13 0 4 16 13
35 21 19 8 35 17 35 21 0 5 17 15 38 24 20 10 53 26 72 37 19 10 43 22 53 25
9 3 17 9 17 14 0 5 9 9 19 9 10 0 21 6 24 13 3 8 29 26 59 41 29 15 60 31 68
35 8 5 36 19 63 32 26 12 52 28 58 36 6 7 15 13 20 13 5 0 36 18 70 40 34 22
70 40 79 40 10 0 20 6 23 13 2 6 49 34 103 62 55 27 108 58 119 68 11 10 30
20 42 23 13 4 23 10 23 15 0 5 7 9 14 9 8 0 27 11 42 25 15 14 35 25 45 25 11
0 19 5 19 10 0 6 7 10 15 10 7 0 18 6 22 13 4 7 29 23 56 36 26 13 52 30 58
38 6 7 18 13 25 13 8 0 14 5 14 10 0 6 9 10 20 10 11 0 20 4 20 8 0 5 18 17
40 28 22 10 40 22 40 27 0 4 6 7 14 7 7 0 19 6 25 13 6 8 32 25 58 38 27 13
52 29 56 36 4 7 13 13 19 13 5 0 27 13 47 29 20 16 60 41 89 56 29 15 57 36
63 46 5 11 17 19 25 19 9 0 22 6 29 13 31 27 44 37 51 37 4 0 31 20 60 45 28
25 57 45 63 45 11 0 31 32 31 49 0 18 -90 13 -124 -8 -17 -10 -53 -24 -81 -31
-27 -7 -57 -19 -66 -27 -8 -7 -23 -13 -32 -13 -9 0 -19 -4 -22 -10 -3 -5 -17
-10 -29 -10 -13 0 -29 -7 -36 -15 -7 -8 -23 -15 -36 -15 -12 0 -26 -4 -29 -10
-3 -5 -17 -10 -29 -10 -13 0 -29 -7 -36 -15 -7 -8 -21 -15 -31 -15 -10 0 -21
-4 -24 -10 -3 -5 -17 -10 -29 -10 -13 0 -29 -7 -36 -15 -7 -8 -17 -15 -22 -15
-5 0 -28 -9 -50 -20 -23 -11 -47 -20 -54 -20 -6 0 -17 -7 -24 -15 -7 -8 -23
-15 -36 -15 -12 0 -26 -4 -29 -10 -3 -5 -17 -10 -30 -10 -12 0 -25 -7 -29 -15
-3 -8 -12 -15 -19 -15 -8 0 -44 -16 -81 -35 -37 -19 -73 -35 -80 -35 -7 0 -34
-11 -60 -25 -26 -14 -56 -25 -67 -25 -10 0 -19 -4 -19 -10 0 -5 -6 -10 -14
-10 -7 0 -19 -7 -26 -15 -7 -8 -23 -15 -36 -15 -13 0 -24 -4 -24 -10 0 -5 -11
-10 -24 -10 -13 0 -29 -7 -36 -15 -7 -8 -23 -15 -35 -15 -13 0 -25 -3 -27 -7
-5 -12 -111 -63 -131 -63 -9 0 -17 -4 -17 -10 0 -5 -6 -10 -14 -10 -17 0 -80
-32 -84 -42 -2 -5 -14 -8 -27 -8 -12 0 -28 -7 -35 -15 -7 -8 -21 -15 -31 -15
-10 0 -21 -4 -24 -10 -3 -5 -15 -10 -25 -10 -10 0 -20 -3 -22 -7 -5 -11 -67
-43 -84 -43 -16 0 -56 -20 -65 -34 -8 -10 -77 -36 -96 -36 -7 0 -24 -11 -39
-25 -15 -14 -34 -25 -43 -25 -10 0 -48 -16 -85 -35 -38 -19 -77 -35 -86 -35
-10 0 -20 -6 -23 -13 -3 -7 -23 -19 -46 -27 -23 -7 -46 -19 -52 -27 -6 -7 -22
-13 -35 -13 -13 0 -24 -3 -24 -7 0 -9 -48 -33 -66 -33 -6 0 -17 -7 -24 -15 -7
-8 -23 -15 -36 -15 -13 0 -24 -4 -24 -10 0 -5 -9 -10 -19 -10 -10 0 -21 -7
-25 -15 -3 -8 -14 -15 -25 -15 -19 0 -104 -42 -125 -61 -6 -5 -18 -9 -28 -9
-22 0 -60 -20 -71 -37 -4 -7 -19 -13 -32 -13 -13 0 -27 -4 -30 -10 -3 -5 -15
-10 -26 -10 -10 0 -19 -4 -19 -8 0 -5 -19 -16 -42 -26 -89 -37 -108 -47 -108
-56 0 -6 -11 -10 -24 -10 -14 0 -28 -4 -31 -10 -3 -5 -14 -10 -25 -10 -10 0
-20 -7 -24 -15 -3 -8 -14 -15 -25 -15 -11 0 -23 -4 -26 -10 -3 -5 -15 -10 -25
-10 -10 0 -20 -3 -22 -7 -3 -8 -33 -23 -110 -57 -20 -9 -40 -21 -43 -26 -3 -6
-17 -10 -31 -10 -13 0 -24 -4 -24 -10 0 -5 -7 -10 -16 -10 -8 0 -22 -7 -30
-15 -9 -8 -24 -15 -35 -15 -10 0 -19 -4 -19 -10 0 -5 -6 -10 -13 -10 -18 0
-75 -28 -82 -41 -4 -5 -17 -9 -31 -9 -13 0 -24 -4 -24 -10 0 -5 -9 -10 -19
-10 -10 0 -21 -7 -25 -15 -3 -8 -14 -15 -25 -15 -11 0 -23 -4 -26 -10 -3 -5
-15 -10 -25 -10 -10 0 -20 -3 -22 -7 -1 -5 -21 -16 -43 -27 -22 -10 -48 -22
-57 -27 -10 -5 -24 -9 -32 -9 -8 0 -16 -7 -20 -15 -3 -8 -14 -15 -25 -15 -11
0 -23 -4 -26 -10 -3 -5 -14 -10 -24 -10 -10 0 -24 -7 -31 -15 -7 -8 -21 -15
-31 -15 -11 0 -19 -3 -19 -7 0 -11 -50 -33 -71 -33 -10 0 -22 -6 -26 -12 -11
-18 -49 -38 -72 -38 -10 0 -24 -7 -31 -15 -7 -8 -17 -15 -22 -15 -14 0 -163
-73 -166 -82 -2 -4 -14 -8 -27 -8 -13 0 -27 -4 -30 -10 -3 -5 -23 -17 -43 -26
-20 -8 -44 -20 -52 -25 -8 -5 -26 -14 -40 -20 -14 -6 -38 -16 -55 -24 -16 -7
-37 -16 -45 -19 -8 -3 -28 -15 -45 -25 -16 -11 -40 -20 -52 -20 -13 -1 -23 -5
-23 -10 0 -12 -57 -41 -81 -41 -9 0 -19 -3 -21 -7 -1 -5 -28 -20 -58 -36 -89
-45 -114 -36 -71 27 15 22 36 59 46 81 10 22 26 47 37 56 10 9 18 21 18 26 0
18 29 69 50 88 11 10 20 24 20 32 0 14 18 50 35 69 6 6 22 33 35 60 14 27 27
51 30 54 8 8 30 43 46 75 8 17 24 44 34 60 11 17 26 42 32 58 7 15 15 27 19
27 4 0 10 11 13 25 4 14 11 25 16 25 6 0 10 8 10 18 0 10 9 28 20 40 11 12 20
25 20 30 0 5 7 15 15 22 8 7 15 21 15 31 0 11 5 19 10 19 6 0 10 7 10 15 0 9
11 27 25 41 14 14 25 29 25 34 0 12 37 78 61 109 11 13 19 30 19 37 0 7 6 19
13 26 16 18 41 62 68 121 12 26 25 47 29 47 4 0 10 8 14 18 7 22 42 82 56 96
5 5 10 18 10 28 0 10 5 18 10 18 6 0 10 6 10 14 0 7 11 25 25 40 14 15 25 33
25 41 0 8 5 17 10 20 6 3 10 13 10 21 0 8 4 20 10 27 47 58 60 77 61 88 0 8 9
28 19 44 10 17 31 53 46 80 14 28 30 52 33 55 4 3 13 21 21 40 7 19 23 46 35
60 11 14 26 36 32 50 6 14 18 42 27 63 10 20 21 37 25 37 5 0 21 25 36 55 14
30 31 55 36 55 5 0 9 8 9 18 0 9 9 28 20 42 11 14 20 31 20 38 0 7 7 15 15 18
8 3 17 15 21 27 10 32 74 118 121 164 24 22 43 48 43 57 0 9 7 19 15 22 8 4
15 18 15 33 0 14 9 37 20 51 11 14 20 30 20 37 0 7 7 16 16 21 14 8 14 12 3
30 -11 18 -9 24 19 56 24 28 32 46 32 75 0 22 6 44 15 51 8 7 15 26 15 42 0
31 21 58 60 74 14 6 43 21 65 32 22 12 65 32 95 47 30 14 62 30 70 35 25 16
120 60 129 60 5 0 11 3 13 8 5 11 93 52 112 52 9 0 16 4 16 9 0 11 56 41 77
41 7 0 13 5 13 10 0 6 11 10 25 10 14 0 25 4 25 9 0 11 56 41 77 41 7 0 13 5
13 10 0 6 9 10 19 10 11 0 22 4 25 9 7 10 105 61 118 61 5 0 25 10 46 23 20
13 53 29 72 37 19 8 37 16 40 19 10 12 98 51 113 51 9 0 20 6 24 13 10 16 49
37 68 37 8 0 15 5 15 10 0 6 11 10 25 10 14 0 25 4 25 9 0 11 56 41 77 41 7 0
13 5 13 10 0 6 11 10 25 10 14 0 25 4 25 8 0 4 26 21 58 36 31 16 73 37 92 47
19 10 51 25 70 34 19 9 44 22 55 27 11 6 43 22 70 35 28 13 59 29 70 36 11 6
45 24 75 41 30 16 82 44 115 63 33 18 68 36 78 39 9 4 17 10 17 15 0 5 11 9
25 9 14 0 25 5 25 10 0 6 9 10 20 10 11 0 20 3 20 8 0 11 124 82 144 82 8 0
16 7 20 15 3 8 30 26 59 40 30 13 60 31 67 40 7 8 18 15 25 15 7 0 23 10 37
23 13 12 37 28 54 36 49 23 57 27 108 55 64 36 76 49 76 85 0 30 -21 43 -35
22z m-3845 -2094 c0 -19 -30 -77 -40 -77 -4 0 -10 -8 -13 -17 -4 -10 -16 -34
-29 -54 -13 -19 -38 -61 -56 -91 -18 -31 -40 -62 -47 -68 -8 -7 -15 -18 -15
-24 0 -6 -16 -35 -35 -63 -19 -29 -35 -56 -35 -60 0 -5 -11 -20 -25 -35 -14
-15 -25 -32 -25 -39 0 -6 -16 -34 -35 -60 -19 -27 -35 -54 -35 -59 0 -6 -4
-10 -8 -10 -5 0 -14 -16 -22 -35 -8 -19 -17 -35 -21 -35 -4 0 -14 -12 -22 -27
-34 -66 -46 -86 -54 -92 -5 -3 -21 -32 -36 -63 -15 -32 -31 -58 -35 -58 -5 0
-16 -19 -26 -42 -10 -24 -29 -54 -41 -68 -12 -14 -29 -41 -38 -61 -9 -19 -25
-43 -37 -54 -11 -10 -20 -24 -20 -32 0 -16 -44 -88 -60 -98 -5 -3 -10 -13 -10
-21 0 -7 -16 -36 -35 -63 -19 -26 -35 -52 -35 -55 0 -4 -13 -27 -30 -51 -16
-24 -30 -47 -30 -51 0 -4 -16 -31 -35 -60 -19 -29 -35 -57 -35 -62 0 -5 -9
-17 -20 -27 -12 -11 -28 -35 -37 -54 -21 -44 -65 -115 -75 -119 -5 -2 -8 -12
-8 -21 0 -10 -9 -26 -20 -36 -11 -10 -20 -24 -20 -30 0 -7 -7 -18 -15 -25 -8
-7 -15 -20 -15 -30 0 -10 -7 -24 -16 -31 -9 -7 -20 -22 -25 -34 -11 -22 -21
-40 -62 -102 -15 -24 -27 -46 -27 -49 0 -4 -16 -31 -35 -60 -19 -29 -35 -56
-35 -60 0 -3 -16 -29 -35 -55 -19 -27 -35 -56 -35 -64 0 -8 -4 -15 -8 -15 -5
0 -16 -16 -25 -35 -9 -19 -21 -35 -27 -35 -5 0 -10 -11 -10 -25 0 -14 -3 -25
-8 -25 -4 0 -16 -16 -26 -35 -11 -19 -23 -35 -28 -35 -4 0 -8 -7 -8 -16 0 -9
-14 -36 -30 -61 -17 -25 -42 -67 -56 -94 -13 -27 -28 -49 -32 -49 -4 0 -14
-17 -21 -37 -7 -21 -23 -49 -35 -63 -27 -32 -46 -66 -46 -83 0 -8 -9 -22 -21
-33 -11 -10 -31 -41 -44 -70 -13 -28 -29 -57 -37 -65 -8 -8 -26 -40 -42 -71
-15 -32 -31 -58 -36 -58 -4 0 -13 -14 -19 -31 -9 -27 -36 -75 -98 -174 -7 -11
-20 -35 -29 -52 -9 -18 -20 -33 -25 -33 -5 0 -9 -9 -9 -20 0 -11 -7 -23 -15
-26 -8 -4 -15 -12 -15 -20 0 -13 -45 -92 -59 -104 -12 -10 -31 -50 -31 -64 0
-8 -11 -23 -25 -34 -14 -11 -25 -27 -25 -35 0 -9 -4 -19 -10 -22 -5 -3 -10
-13 -10 -22 0 -9 -11 -30 -23 -47 -13 -17 -32 -49 -42 -71 -10 -22 -24 -47
-31 -55 -16 -20 -73 -120 -80 -142 -4 -10 -10 -18 -15 -18 -5 0 -9 -6 -9 -14
0 -7 -11 -25 -25 -40 -14 -15 -25 -36 -25 -47 0 -10 -4 -19 -10 -19 -5 0 -10
-7 -10 -17 0 -9 -11 -30 -24 -48 -34 -44 -66 -103 -66 -120 0 -8 -6 -18 -13
-22 -6 -4 -22 -28 -35 -53 -49 -95 -91 -165 -101 -168 -6 -2 -11 -9 -11 -16 0
-20 -49 -112 -70 -131 -11 -10 -20 -26 -20 -37 0 -10 -4 -18 -10 -18 -5 0 -10
-9 -10 -19 0 -11 -4 -22 -9 -26 -6 -3 -22 -30 -36 -60 -15 -30 -33 -59 -41
-66 -8 -6 -14 -18 -14 -25 0 -8 -4 -14 -10 -14 -5 0 -10 -8 -10 -17 0 -10 -10
-34 -22 -53 -43 -67 -47 -75 -53 -90 -4 -8 -10 -22 -15 -30 -5 -8 -23 -42 -39
-75 -16 -33 -39 -70 -50 -82 -12 -12 -21 -28 -22 -35 0 -7 -13 -35 -29 -63
-15 -27 -40 -74 -56 -103 -15 -30 -33 -56 -41 -59 -7 -3 -13 -13 -13 -23 0
-18 -29 -77 -96 -193 -19 -32 -34 -62 -34 -68 0 -5 -4 -9 -10 -9 -5 0 -10 -7
-10 -15 0 -9 -11 -33 -25 -55 -14 -22 -25 -46 -25 -55 0 -8 -4 -15 -10 -15 -5
0 -10 -7 -10 -15 0 -9 -16 -41 -35 -71 -19 -30 -35 -60 -35 -67 0 -8 -10 -23
-22 -34 -30 -27 -60 -16 -78 29 -8 18 -20 37 -27 41 -7 4 -13 17 -13 27 0 11
-4 20 -10 20 -5 0 -10 9 -10 19 0 11 -4 23 -10 26 -5 3 -10 14 -10 25 0 10 -6
20 -12 23 -7 2 -26 29 -41 58 -16 30 -41 77 -57 104 -15 28 -29 57 -29 66 -1
9 -8 23 -17 30 -16 13 -35 47 -89 159 -15 30 -33 62 -40 70 -20 23 -55 89 -55
104 0 8 -7 16 -15 20 -8 3 -15 15 -15 26 0 11 -4 20 -8 20 -5 0 -14 16 -22 35
-8 19 -16 35 -20 35 -5 0 -19 26 -54 100 -9 19 -34 62 -56 95 -21 33 -39 68
-39 78 -1 9 -5 17 -10 17 -5 0 -14 14 -20 32 -6 17 -17 39 -25 47 -21 24 -56
90 -56 106 0 8 -11 22 -25 31 -14 9 -25 24 -25 33 0 9 -20 43 -45 76 -51 67
-58 113 -25 155 11 14 20 31 20 38 0 7 7 15 15 18 8 4 15 17 15 30 0 13 5 24
10 24 6 0 10 7 10 15 0 14 26 62 40 75 3 3 11 16 18 30 7 14 21 39 32 55 11
17 22 37 26 45 9 25 53 111 89 175 18 33 47 87 64 120 17 33 42 78 56 100 14
22 30 50 35 63 5 12 14 22 20 22 5 0 10 11 10 25 0 14 5 25 10 25 6 0 10 6 10
14 0 16 25 66 37 75 5 3 21 32 36 64 15 31 31 57 36 57 4 0 8 6 8 13 0 18 41
105 54 112 5 4 21 30 35 58 14 29 33 66 42 82 10 17 24 46 32 65 8 19 20 40
26 45 17 14 54 83 54 100 0 8 7 15 15 15 9 0 15 9 15 25 0 14 5 25 10 25 6 0
10 6 10 14 0 17 25 67 38 75 5 3 21 31 35 61 15 30 33 66 41 80 7 14 22 42 32
63 11 21 27 45 37 54 9 9 17 23 18 32 0 9 9 30 19 46 10 17 31 55 46 85 16 30
38 69 51 86 13 17 23 36 23 42 0 7 5 12 10 12 6 0 10 6 10 14 0 15 55 122 75
146 7 8 30 51 51 95 21 44 47 89 56 99 10 11 18 26 18 33 0 8 7 16 15 19 8 4
15 12 15 20 0 8 4 22 9 32 5 9 17 34 27 55 10 20 21 37 25 37 3 0 16 21 29 48
35 73 53 107 60 112 4 3 19 29 34 58 14 28 31 52 36 52 6 0 10 9 10 20 0 11 9
29 20 40 11 11 20 23 20 27 0 14 73 158 82 161 4 2 8 11 8 21 0 22 51 71 73
71 9 0 17 4 17 10 0 5 11 12 25 16 14 3 25 10 25 15 0 5 8 9 18 10 9 0 31 9
47 19 17 11 46 26 65 35 38 18 96 47 139 69 14 7 49 25 76 38 28 14 126 64
218 112 93 47 175 86 182 86 7 0 15 4 17 9 2 5 39 26 83 48 44 22 89 45 100
51 11 6 70 35 130 65 61 30 130 65 155 77 25 13 56 28 70 34 14 6 27 14 30 17
9 10 73 39 87 39 7 0 13 5 13 10 0 6 9 10 19 10 11 0 22 4 26 9 7 13 64 41 82
41 7 0 13 5 13 10 0 6 6 10 14 10 7 0 19 7 26 15 7 8 21 15 31 15 11 0 19 5
19 10 0 6 8 10 18 11 9 0 31 9 47 20 17 10 41 24 55 29 14 6 65 31 114 56 49
24 95 44 102 44 7 0 14 4 16 8 4 10 108 62 124 62 6 0 14 7 18 15 3 8 15 15
26 15 11 0 20 5 20 10 0 6 6 10 13 10 18 0 75 28 82 41 4 5 15 9 26 9 10 0 19
5 19 10 0 6 8 10 18 11 9 0 31 9 47 19 17 11 45 26 63 35 72 34 101 49 192 96
97 50 130 59 130 36z m-3972 -1998 c3 -18 -46 -132 -59 -137 -5 -2 -9 -9 -9
-16 0 -16 -52 -120 -62 -124 -4 -2 -8 -10 -8 -17 0 -17 -50 -109 -62 -113 -4
-2 -8 -11 -9 -20 0 -9 -14 -37 -30 -62 -16 -25 -32 -53 -35 -62 -4 -10 -10
-18 -15 -18 -5 0 -9 -9 -9 -21 0 -11 -11 -37 -25 -57 -14 -20 -25 -40 -25 -45
0 -11 -33 -78 -80 -162 -22 -38 -47 -86 -57 -105 -10 -19 -21 -36 -25 -38 -5
-2 -8 -12 -8 -23 0 -11 -7 -22 -15 -25 -8 -4 -15 -12 -15 -18 0 -7 -9 -31 -20
-54 -11 -22 -20 -45 -20 -50 0 -6 -7 -12 -15 -16 -8 -3 -15 -11 -15 -19 0 -20
-50 -117 -61 -117 -5 0 -9 -5 -9 -12 0 -13 -31 -76 -60 -123 -10 -16 -19 -38
-19 -47 -1 -10 -5 -18 -11 -18 -5 0 -10 -8 -10 -19 0 -10 -7 -24 -15 -31 -8
-7 -15 -19 -15 -26 0 -8 -4 -14 -10 -14 -5 0 -10 -11 -10 -25 0 -14 -4 -25
-10 -25 -5 0 -10 -6 -10 -14 0 -8 -6 -21 -13 -28 -7 -7 -26 -40 -42 -72 -45
-89 -49 -96 -57 -96 -5 0 -8 -6 -8 -13 0 -21 -30 -77 -41 -77 -5 0 -9 -8 -9
-18 0 -10 -4 -22 -9 -28 -5 -5 -14 -26 -21 -46 -6 -21 -15 -38 -19 -38 -4 0
-13 -17 -20 -37 -8 -21 -28 -60 -45 -88 -18 -27 -40 -66 -49 -85 -10 -19 -26
-48 -37 -65 -10 -16 -19 -38 -19 -47 -1 -10 -5 -18 -11 -18 -5 0 -10 -9 -10
-20 0 -11 -7 -23 -15 -26 -8 -4 -15 -12 -15 -18 0 -12 -19 -52 -92 -186 -22
-41 -49 -93 -59 -115 -11 -22 -24 -47 -29 -55 -5 -8 -21 -40 -35 -70 -14 -30
-30 -62 -35 -70 -5 -8 -17 -31 -27 -50 -9 -19 -31 -57 -47 -85 -17 -27 -45
-77 -61 -110 -17 -33 -33 -61 -37 -63 -5 -2 -8 -10 -8 -18 0 -7 -10 -30 -21
-51 -12 -21 -51 -94 -86 -163 -36 -69 -71 -132 -79 -141 -8 -8 -14 -20 -14
-25 0 -15 -50 -113 -61 -120 -5 -3 -9 -14 -9 -25 0 -10 -4 -19 -10 -19 -5 0
-10 -6 -10 -14 0 -18 -32 -76 -42 -76 -5 0 -8 -7 -8 -14 0 -8 -10 -34 -23 -58
-25 -45 -79 -145 -96 -175 -6 -10 -19 -36 -29 -58 -26 -55 -71 -146 -83 -167
-9 -17 -29 -55 -119 -223 -23 -44 -46 -81 -51 -83 -5 -2 -9 -12 -9 -23 0 -10
-4 -19 -10 -19 -5 0 -10 -8 -11 -17 0 -10 -9 -31 -20 -48 -11 -16 -22 -38 -25
-47 -8 -22 -98 -200 -149 -293 -21 -38 -54 -100 -73 -137 -19 -38 -38 -68 -43
-68 -5 0 -9 -11 -9 -25 0 -14 -4 -25 -10 -25 -5 0 -10 -6 -10 -13 0 -21 -30
-77 -41 -77 -5 0 -9 -11 -9 -25 0 -14 -4 -25 -10 -25 -5 0 -10 -7 -10 -15 0
-19 -21 -58 -37 -68 -7 -4 -13 -14 -13 -22 0 -21 -42 -115 -52 -115 -4 0 -8
-4 -8 -10 0 -10 -44 -104 -60 -130 -5 -8 -21 -41 -35 -72 -14 -31 -30 -60 -35
-63 -6 -3 -10 -15 -10 -26 0 -10 -4 -19 -10 -19 -5 0 -10 -9 -10 -20 0 -14 -7
-20 -21 -20 -21 0 -21 1 -14 218 4 119 11 227 16 240 5 13 9 71 9 128 0 66 5
113 13 127 11 21 20 127 33 397 3 69 10 129 15 135 5 5 9 64 9 131 0 96 3 124
15 134 12 10 15 38 15 136 0 71 4 125 10 129 6 4 10 60 10 135 0 75 4 131 10
135 6 4 10 56 10 124 0 88 4 122 15 137 11 14 14 50 15 144 0 70 4 130 9 135
6 6 12 69 15 140 4 72 9 187 12 257 4 93 9 130 20 138 11 10 14 41 14 141 0
74 4 130 10 134 6 4 10 60 10 135 0 75 4 131 10 135 6 4 10 58 10 129 0 96 3
126 14 136 11 8 16 45 20 143 3 73 8 168 11 212 3 44 9 155 14 247 7 125 13
173 25 190 9 12 16 38 16 58 0 33 4 37 58 63 31 15 65 27 74 27 9 0 18 4 20 8
4 11 108 62 125 62 7 0 13 4 13 9 0 5 11 12 25 15 14 4 25 11 25 16 0 6 6 10
13 10 17 0 121 51 125 62 2 4 9 8 16 8 7 0 26 6 42 14 16 8 56 26 89 41 90 40
208 95 227 106 10 5 25 9 33 9 8 0 15 4 15 9 0 11 56 41 77 41 7 0 13 5 13 10
0 6 11 10 24 10 13 0 26 7 30 15 3 8 15 15 26 15 11 0 20 5 20 10 0 6 8 10 18
11 9 0 31 9 47 20 17 10 37 21 45 25 8 3 24 9 35 14 34 13 178 81 205 97 44
24 132 63 145 63 7 0 15 7 19 15 3 8 12 15 20 15 9 0 31 9 51 20 20 11 42 20
49 20 8 0 16 7 20 15 3 8 15 15 26 15 11 0 20 5 20 10 0 6 8 10 18 11 9 0 31
9 47 19 17 11 44 26 60 33 70 33 102 49 135 66 19 10 76 37 125 61 185 87 240
113 255 121 8 5 40 20 70 34 30 14 62 30 70 35 8 5 31 17 50 26 83 39 97 46
125 64 35 22 58 21 63 -1z m-1168 -3709 c7 -22 22 -47 32 -57 10 -9 28 -35 39
-57 11 -23 36 -68 54 -101 19 -33 39 -69 44 -80 5 -12 16 -27 25 -34 9 -7 16
-22 16 -32 0 -11 5 -19 10 -19 6 0 15 -10 20 -22 5 -13 21 -41 34 -63 14 -22
37 -62 52 -90 14 -27 33 -54 40 -58 8 -4 14 -15 14 -24 0 -8 14 -35 30 -60 26
-39 51 -81 95 -164 5 -11 13 -19 17 -19 5 0 8 -10 8 -23 0 -12 11 -31 25 -41
14 -10 25 -27 25 -37 0 -11 5 -19 10 -19 6 0 10 -8 10 -18 0 -10 8 -27 19 -38
10 -11 31 -42 46 -70 15 -27 38 -63 51 -81 13 -17 24 -37 24 -44 0 -6 15 -32
33 -58 18 -25 39 -60 46 -77 15 -39 5 -84 -19 -84 -11 0 -22 -4 -25 -10 -3 -5
-23 -17 -43 -26 -20 -9 -106 -50 -190 -90 -84 -41 -161 -74 -172 -74 -11 0
-20 -4 -20 -9 0 -11 -56 -41 -77 -41 -7 0 -13 -4 -13 -10 0 -5 -11 -10 -25
-10 -14 0 -25 -4 -25 -9 0 -11 -56 -41 -77 -41 -7 0 -13 -4 -13 -10 0 -5 -9
-10 -19 -10 -11 0 -22 -4 -25 -9 -7 -10 -105 -61 -118 -61 -5 0 -25 -10 -46
-23 -20 -13 -53 -29 -72 -37 -19 -8 -37 -17 -40 -20 -4 -6 -62 -34 -110 -54
-8 -3 -28 -15 -45 -25 -16 -11 -38 -20 -47 -20 -10 -1 -18 -5 -18 -11 0 -5
-11 -10 -25 -10 -14 0 -25 -4 -25 -9 0 -11 -56 -41 -77 -41 -7 0 -13 -4 -13
-10 0 -5 -7 -10 -15 -10 -9 0 -33 -11 -55 -25 -22 -14 -46 -25 -55 -25 -8 0
-15 -4 -15 -10 0 -5 -7 -10 -15 -10 -15 0 -55 -19 -65 -31 -3 -3 -23 -14 -45
-23 -22 -10 -49 -23 -60 -29 -54 -30 -231 -117 -238 -117 -4 0 -25 -11 -47
-25 -22 -14 -46 -25 -55 -25 -8 0 -15 -4 -15 -10 0 -5 -6 -10 -13 -10 -18 0
-75 -28 -82 -41 -4 -5 -15 -9 -26 -9 -10 0 -19 -4 -19 -10 0 -5 -6 -10 -13
-10 -18 0 -75 -28 -82 -41 -9 -14 -41 -11 -54 5 -8 10 -8 16 3 25 8 7 26 36
41 66 14 30 31 55 36 55 5 0 9 6 9 14 0 21 43 98 58 103 6 3 12 11 12 19 0 7
9 29 20 49 11 20 20 42 20 50 0 8 6 18 13 22 12 8 27 33 80 138 17 33 38 71
48 85 31 46 59 100 59 115 0 8 4 15 9 15 5 0 24 29 42 65 18 36 41 74 51 84
10 11 18 29 18 41 0 11 5 20 10 20 6 0 10 8 10 19 0 10 7 24 15 31 8 7 15 19
15 26 0 8 4 14 8 14 4 0 13 12 20 28 7 15 23 47 37 72 14 25 29 57 34 73 6 15
15 27 20 27 6 0 11 6 11 14 0 7 9 25 20 39 11 14 20 32 20 41 0 9 7 19 15 22
8 4 15 12 15 18 0 15 37 90 65 131 12 17 34 56 50 88 16 31 33 57 37 57 4 0 8
8 8 18 0 23 28 82 40 82 4 0 17 21 29 48 31 67 83 163 106 197 12 17 27 44 34
60 7 17 17 37 22 45 5 8 16 31 25 50 10 19 32 56 51 83 18 26 33 51 33 55 0
16 52 112 61 112 5 0 9 7 9 16 0 9 13 38 28 63 15 25 36 62 46 81 17 35 33 63
50 88 12 20 50 -19 66 -68z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 166 155.3"><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" fill="#76b3e1"/><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="27.5" y1="3" x2="152" y2="63.5"><stop offset=".1" stop-color="#76b3e1"/><stop offset=".3" stop-color="#dcf2fd"/><stop offset="1" stop-color="#76b3e1"/></linearGradient><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" opacity=".3" fill="url(#a)"/><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" fill="#518ac8"/><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="95.8" y1="32.6" x2="74" y2="105.2"><stop offset="0" stop-color="#76b3e1"/><stop offset=".5" stop-color="#4377bb"/><stop offset="1" stop-color="#1f3b77"/></linearGradient><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" opacity=".3" fill="url(#b)"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="18.4" y1="64.2" x2="144.3" y2="149.8"><stop offset="0" stop-color="#315aa9"/><stop offset=".5" stop-color="#518ac8"/><stop offset="1" stop-color="#315aa9"/></linearGradient><path d="M134 80a45 45 0 00-48-15L24 85 4 120l112 19 20-36c4-7 3-15-2-23z" fill="url(#c)"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="75.2" y1="74.5" x2="24.4" y2="260.8"><stop offset="0" stop-color="#4377bb"/><stop offset=".5" stop-color="#1a336b"/><stop offset="1" stop-color="#1a336b"/></linearGradient><path d="M114 115a45 45 0 00-48-15L4 120s53 40 94 30l3-1c17-5 23-21 13-34z" fill="url(#d)"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,88 @@
import { getProfile } from '@api/user'
import fuwareLogo from '@assets/logo-fuware.svg'
import { useSiteContext } from '@context/SiteContext'
import useAuth from '@hooks/useAuth'
import useToast from '@hooks/useToast'
import { A } from '@solidjs/router'
import { IconLogout, IconMenuDeep } from '@tabler/icons-solidjs'
import { Show, onMount } from 'solid-js'
import { css } from 'solid-styled-components'
export default function Header() {
const { store, setAuth, setUser } = useSiteContext()
const { clickLogOut } = useAuth(setAuth)
const notify = useToast()
onMount(async () => {
try {
const resp = await getProfile()
if (resp.status === 200) {
setUser(resp.data)
}
} catch (error) {
notify.error({
title: 'Get profile fail!',
closable: false,
description: error?.data || 'Can not get user profile!',
})
}
})
const logOut = async () => {
try {
await clickLogOut()
} catch (error) {
console.log({
status: 'danger',
title: 'Logout fail!',
closable: false,
})
}
}
return (
<header>
<div class="flex py-3 px-4 items-center justify-between bg-emerald-500">
<div class="flex items-center justify-end">
<A href="/" class="text-white flex items-center hover:text-white">
<img
src={fuwareLogo}
class={css`
width: 30px;
`}
alt="Fuware logo"
/>
<span class="ml-2 text-2xl">Fuware</span>
</A>
</div>
<Show when={store.auth}>
<div class="flex items-center justify-end">
<div class="avatar hidden lg:block">
<div class="w-9 mask mask-hexagon">
<img
src={`https://ui-avatars.com/api/?name=${store.userInfo?.name}`}
alt="avatar"
/>
</div>
</div>
<span class="mx-3 text-white hidden lg:block">
{store.userInfo?.name}
</span>
<button
class="btn btn-ghost btn-sm hidden lg:block"
onClick={logOut}
>
<IconLogout size={16} />
</button>
<label
for="nav-menu"
class="btn btn-ghost btn-sm drawer-button pr-0 lg:hidden"
>
<IconMenuDeep size={25} color="white" />
</label>
</div>
</Show>
</div>
</header>
)
}

View File

@ -0,0 +1,75 @@
// import { styled } from 'solid-styled-components'
import { useSiteContext } from '@context/SiteContext'
import useAuth from '@hooks/useAuth'
import useLanguage from '@hooks/useLanguage'
import { A } from '@solidjs/router'
import { IconDashboard, IconLogout, IconTriangle } from '@tabler/icons-solidjs'
import { For, Show } from 'solid-js'
import { Dynamic } from 'solid-js/web'
const language = useLanguage('vi')
const NAVBAR_ITEM = [
{
path: '/dashboard',
icon: IconDashboard,
text: language?.ui.dashboard,
},
]
export default function Navbar() {
const { store, setAuth } = useSiteContext()
const { clickLogOut } = useAuth(setAuth)
const logOut = async () => {
try {
await clickLogOut()
} catch (error) {
console.log({
status: 'danger',
title: 'Logout fail!',
closable: false,
})
}
}
return (
<div class="drawer-side">
<label for="nav-menu" aria-label="close sidebar" class="drawer-overlay" />
<div class="bg-base-200 w-80 min-h-full">
<Show when={store.auth}>
<div class="flex items-center justify-between px-5 pt-5 lg:hidden">
<div class="avatar">
<div class="w-9 mask mask-hexagon">
<img
src={`https://ui-avatars.com/api/?name=${store.userInfo?.name}`}
alt="avatar"
/>
</div>
</div>
<span class="mx-3 line-clamp-1">{store.userInfo?.name}</span>
<button class="btn btn-ghost btn-sm" onClick={logOut}>
<IconLogout size={16} />
</button>
</div>
<div class="divider divider-success mb-0 lg:hidden">
<IconTriangle size={30} />
</div>
</Show>
<ul class="menu p-4 w-80 text-base-content">
<For each={NAVBAR_ITEM}>
{(item) => (
<li>
<A href={item.path}>
<Dynamic component={item.icon} />
{item.text}
</A>
</li>
)}
</For>
</ul>
</div>
</div>
)
}

View File

@ -0,0 +1,62 @@
import {
IconCircleCheck,
IconFaceIdError,
IconInfoCircle,
IconX,
} from '@tabler/icons-solidjs'
import { Show } from 'solid-js'
import { Dynamic } from 'solid-js/web'
const STATUS = Object.freeze(
new Proxy(
{
success: {
icon: IconCircleCheck,
color: 'text-green-500',
},
error: {
icon: IconFaceIdError,
color: 'text-red-500',
},
info: {
icon: IconInfoCircle,
color: 'text-blue-500',
},
},
{
get: (target, prop) =>
target[prop] ?? { icon: IconInfoCircle, color: 'text-blue-500' },
},
),
)
export default function Notify(props) {
return (
<div class="bg-white border border-slate-300 w-max h-20 shadow-lg rounded-md gap-4 p-4 flex flex-row items-center justify-center">
<section class="w-6 h-full flex flex-col items-center justify-start">
<Dynamic
component={STATUS[props.status].icon}
size={30}
class={STATUS[props.status].color}
/>
</section>
<section class="h-full flex flex-col items-start justify-end gap-1">
<Show when={props.title}>
<h1
class={`text-base font-semibold text-zinc-800 antialiased ${STATUS[props.status].color}`}
>
{props.title}
</h1>
</Show>
<p class="text-sm font-medium text-black antialiased">
{props.description}
</p>
</section>
<Show when={props.onClose}>
<section class="w-5 h-full flex flex-col items-center justify-start">
<IconX size={20} class="cursor-pointer" onclick={props.onClose} />
</section>
</Show>
</div>
)
}

View File

@ -0,0 +1,55 @@
import { STORE_KEY } from '@utils/enum'
import { Helpers } from '@utils/helper'
import { createContext, onMount, useContext } from 'solid-js'
import { createStore, produce } from 'solid-js/store'
export const SiteContext = createContext()
export function SiteContextProvider(props) {
const [store, setStore] = createStore({
auth: false,
userInfo: null,
})
onMount(() => {
const storeData = Helpers.decrypt(localStorage.getItem(STORE_KEY))
if (!storeData) return
setStore(storeData)
})
const setLocalStore = () => {
if (store.auth) {
localStorage.setItem(STORE_KEY, Helpers.encrypt(store))
} else {
localStorage.removeItem(STORE_KEY)
}
}
const setAuth = ({ auth, user }) => {
setStore(
produce((s) => {
s.auth = auth
s.userInfo = user
}),
)
setLocalStore()
}
const setUser = (user) => {
setStore(
produce((s) => {
s.userInfo = user
}),
)
}
return (
<SiteContext.Provider value={{ store, setAuth, setUser }}>
{props.children}
</SiteContext.Provider>
)
}
export function useSiteContext() {
return useContext(SiteContext)
}

View File

@ -0,0 +1,41 @@
import { getLogout, postLogin } from '@api/auth'
import { useNavigate } from '@solidjs/router'
import { LOGIN_KEY } from '@utils/enum'
import { Helpers } from '@utils/helper'
export default function useAuth(setAuth) {
const navigate = useNavigate()
const clickLogIn = async (username, password, cbFormReset) => {
const loginData = {
username: username,
password: password,
}
const resp = await postLogin(loginData)
if (resp.status === 200) {
const token = resp.data || {}
if (token) {
const { name, ...rest } = token
setAuth({ auth: true, user: { name } })
localStorage.setItem(LOGIN_KEY, Helpers.encrypt(JSON.stringify(rest)))
}
cbFormReset()
navigate('/', { replace: true })
}
}
const clickLogOut = async () => {
await getLogout()
Helpers.clearStorage()
setAuth({ auth: false, user: null })
navigate('/login', { replace: false })
}
return {
clickLogOut,
clickLogIn,
}
}

View File

@ -0,0 +1,15 @@
export default function useLanguage(selectLanguage = 'vi') {
const data = import.meta.glob('@lang/*.json', {
import: 'default',
eager: true,
})
const imp = {}
for (const path in data) {
const keypath = path.match(/\/[a-zA-Z]+\./)[0].replace(/\/(\w+)\./, '$1')
imp[keypath] = data[path]
}
return imp[selectLanguage]
}

View File

@ -0,0 +1,31 @@
import Notify from '@components/Notify'
import toast from 'solid-toast'
export default function useToast() {
const notify = {}
notify.show = ({ status, title, description, closable = false }) => {
return toast.custom((t) => (
<Notify
status={status}
title={title}
description={description}
onClose={closable ? () => toast.dismiss(t.id) : null}
/>
))
}
notify.success = ({ title, description, closable = false }) => {
return notify.show({ status: 'success', title, description, closable })
}
notify.error = ({ title, description, closable = false }) => {
return notify.show({ status: 'error', title, description, closable })
}
notify.info = ({ title, description, closable = false }) => {
return notify.show({ status: 'info', title, description, closable })
}
return notify
}

35
fuware-fe/src/index.css Normal file
View File

@ -0,0 +1,35 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(0, 0, 0, 1);
background-color: #ffffff;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
padding: 0;
min-width: 320px;
min-height: 100vh;
font-size: 14px;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}

30
fuware-fe/src/index.jsx Normal file
View File

@ -0,0 +1,30 @@
import Layout from '@pages/Layout'
import { Route, Router } from '@solidjs/router'
import { For, lazy } from 'solid-js'
import { render } from 'solid-js/web'
import App from './App'
import './index.css'
import { ROUTES } from './routes'
const root = document.getElementById('root')
render(
() => (
<Router root={App}>
<Route path="/login" component={lazy(() => import('@pages/Login'))} />
<Route path="/" component={Layout}>
<For each={ROUTES}>
{(route) => (
<Route
path={route.path}
component={route.components}
matchFilters={route.filter}
/>
)}
</For>
</Route>
<Route path="*" component={lazy(() => import('@pages/NotFound'))} />
</Router>
),
root,
)

View File

@ -0,0 +1,4 @@
{
"login": "Login",
"logout": "Logout"
}

View File

@ -0,0 +1,14 @@
{
"ui": {
"username": "Tên người dùng",
"password": "Mật khẩu",
"login": "Đăng Nhập",
"logout": "Đăng xuất",
"dashboard": "Bảng điều khiển"
},
"message": {
"CREATED_USER": "Username already registered!",
"LOGIN_WRONG": "Your username or password input is wrong!",
"USER_LOCK": "Your Account was locked"
}
}

View File

@ -0,0 +1,3 @@
export default function Dashboard() {
return <>Dashboard</>
}

View File

@ -0,0 +1,12 @@
import { useNavigate } from '@solidjs/router'
import { onMount } from 'solid-js'
export default function Home() {
const navigate = useNavigate()
onMount(() => {
navigate('/dashboard', { replace: true })
})
return <></>
}

View File

@ -0,0 +1,31 @@
import Header from '@components/Header'
import Navbar from '@components/Navbar'
import { useSiteContext } from '@context/SiteContext'
import { useNavigate } from '@solidjs/router'
import { onMount } from 'solid-js'
export default function Layout(props) {
const { store } = useSiteContext()
const navigate = useNavigate()
onMount(() => {
if (!store.auth) {
navigate('/login', { replace: true })
}
})
return (
<div class="layer-top">
<Header />
<div id="main-page">
<div class="drawer lg:drawer-open">
<input id="nav-menu" type="checkbox" class="drawer-toggle" />
<div class="drawer-content">
<main class="main-content p-3">{props.children}</main>
</div>
<Navbar />
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,223 @@
import { useSiteContext } from '@context/SiteContext'
import useLanguage from '@hooks/useLanguage'
import { useNavigate } from '@solidjs/router'
import { Field, useFormHandler } from 'solid-form-handler'
import { yupSchema } from 'solid-form-handler/yup'
import { Show, onMount } from 'solid-js'
import { styled } from 'solid-styled-components'
import * as yup from 'yup'
import logo from '@assets/logo-fuware.svg'
import useAuth from '@hooks/useAuth'
import useToast from '@hooks/useToast'
const LoginPage = styled('div')`
width: 100%;
height: 100svh;
display: flex;
padding-left: 15px;
padding-right: 15px;
background: #fff url('/images/bg-login.jpg') no-repeat fixed center;
background-size: cover;
place-items: center;
.login-wrap {
width: 40%;
max-width: 500px;
min-width: 320px;
margin: 0 auto;
overflow: hidden;
position: relative;
&:after {
content: '';
display: block;
width: 500px;
height: 500px;
border-radius: 15px;
position: absolute;
z-index: 2;
top: -120px;
left: -285px;
background: #10b981;
transform: rotate(45deg);
}
&:before {
content: '';
display: block;
width: 500px;
height: 500px;
border-radius: 15px;
position: absolute;
z-index: 2;
top: -40px;
left: -130px;
background: #ff6600;
transform: rotate(20deg);
}
.card-body {
position: relative;
z-index: 4;
}
.logo {
position: relative;
z-index: 4;
display: block;
width: 40%;
max-width: 150px;
min-width: 100px;
margin: 0 auto;
margin-top: 15px;
}
.login-box {
box-shadow: 0 0 10px 2px rgba(0, 0, 0, 0.25);
border-radius: 5px;
padding: 1rem;
}
}
`
const loginSchema = yup.object({
username: yup.string().required('Username is required'),
password: yup.string().required('Password is required'),
})
const language = useLanguage()
export default function Login() {
const { store, setAuth } = useSiteContext()
const navigate = useNavigate()
const { clickLogIn } = useAuth(setAuth)
const notify = useToast()
const formHandler = useFormHandler(yupSchema(loginSchema))
const { formData } = formHandler
onMount(() => {
if (store.auth) {
navigate('/', { replace: true })
}
})
const submit = async (event) => {
event.preventDefault()
await formHandler.validateForm()
try {
const { username, password } = formData()
await clickLogIn(username, password, formHandler.resetForm)
notify.success({
title: 'Login success!',
description: 'Welcome back!',
closable: true,
})
} catch (error) {
notify.error({
title: 'Login fail!',
description: error?.data
? language.message[error.data]
: 'Your username or password input is wrong!',
closable: true,
})
}
}
return (
<LoginPage>
<div class="card glass card-compact login-wrap shadow-xl">
<div class="h-44">
<picture class="logo">
<source srcSet={logo} type="image/png" media="(min-width: 600px)" />
<img src={logo} alt="logo" />
</picture>
</div>
<div class="card-body">
<h1 class="card-title">{language.ui.login}</h1>
<form autoComplete="off" onSubmit={submit}>
<Field
mode="input"
name="username"
formHandler={formHandler}
render={(field) => (
<label class="form-control w-full pb-5">
<label
class="input input-bordered flex items-center gap-2 w-full"
classList={{ 'input-error': field.helpers.error }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4 opacity-70"
>
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM12.735 14c.618 0 1.093-.561.872-1.139a6.002 6.002 0 0 0-11.215 0c-.22.578.254 1.139.872 1.139h9.47Z" />
</svg>
<input
id="username"
type="text"
class="grow w-full"
placeholder="Username"
{...field.props}
/>
</label>
<Show when={field.helpers.error}>
<div class="label">
<span class="label-text-alt text-red-600">
{field.helpers.errorMessage}
</span>
</div>
</Show>
</label>
)}
/>
<Field
mode="input"
name="password"
formHandler={formHandler}
render={(field) => (
<label class="form-control w-full">
<label
class="input input-bordered flex items-center gap-2 w-full"
classList={{ 'input-error': field.helpers.error }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4 opacity-70"
>
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM12.735 14c.618 0 1.093-.561.872-1.139a6.002 6.002 0 0 0-11.215 0c-.22.578.254 1.139.872 1.139h9.47Z" />
</svg>
<input
id="password"
type="password"
class="grow w-full"
placeholder="Password"
{...field.props}
/>
</label>
<Show when={field.helpers.error}>
<div class="label">
<span class="label-text-alt text-red-600">
{field.helpers.errorMessage}
</span>
</div>
</Show>
</label>
)}
/>
<div class="card-actions justify-end mt-5">
<button type="submit" class="btn btn-primary">
{language.ui.login}
</button>
</div>
</form>
</div>
</div>
</LoginPage>
)
}

View File

@ -0,0 +1,3 @@
export default function NotFound() {
return <>404</>
}

View File

@ -0,0 +1 @@
export * from './routes'

View File

@ -0,0 +1,21 @@
import { lazy } from 'solid-js'
export const ROUTES = [
{
path: '/',
components: lazy(() => import('@pages/Home')),
filter: {},
},
{
path: '/dashboard',
components: lazy(() => import('@pages/Dashboard')),
filter: {},
},
// {
// path: '/champions/:tab',
// components: lazy(() => import('@pages/Champion')),
// filter: {
// tab: ['list', 'favourite'],
// },
// },
]

View File

@ -0,0 +1,5 @@
// const PRODUCTION = import.meta.env.NODE_ENV === 'production'
export const SECRET_KEY = 'bGV0IGRvIGl0IGZvciBlbmNyeXRo'
export const STORE_KEY = 'dXNlciBsb2dpbiBpbmZv'
export const LOGIN_KEY = '7fo24CMyIc'

View File

@ -0,0 +1,54 @@
import { AES, enc } from 'crypto-js'
import { LOGIN_KEY, SECRET_KEY, STORE_KEY } from './enum'
export class Helpers {
static setCookie = (cname, cvalue, exdays) => {
const d = new Date()
d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000)
let expires = `expires=${d.toUTCString()}`
document.cookie = `${cname}=${cvalue};${expires};path=/`
}
static getCookie = (cname) => {
let name = cname + '='
let ca = document.cookie.split(';')
for (let i = 0; i < ca.length; i++) {
let c = ca[i]
while (c.charAt(0) == ' ') {
c = c.substring(1)
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length)
}
}
return ''
}
static deleteCookie = (cname) => {
document.cookie = `${cname}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`
}
static clearStorage = () => {
localStorage.removeItem(LOGIN_KEY)
localStorage.removeItem(STORE_KEY)
}
static checkTokenExpired = (exp) => {
const currentTime = Math.floor(new Date().getTime() / 1000)
return exp < currentTime
}
static checkAuth = () => {
return !!this.getCookie(LOGIN_KEY) && !!localStorage.getItem(STORE_KEY)
}
static encrypt = (obj) => {
return AES.encrypt(JSON.stringify(obj), SECRET_KEY).toString()
}
static decrypt = (hash, defaultValue = {}) => {
return hash
? JSON.parse(AES.decrypt(hash, SECRET_KEY).toString(enc.Utf8))
: defaultValue
}
}

View File

@ -0,0 +1,10 @@
import daisyui from 'daisyui'
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{js,jsx}'],
theme: {
extend: {},
},
plugins: [daisyui],
}

73
fuware-fe/vite.config.js Normal file
View File

@ -0,0 +1,73 @@
import path, { dirname } from 'path'
import { fileURLToPath } from 'url'
import { defineConfig, loadEnv } from 'vite'
import mkcert from 'vite-plugin-mkcert'
import solid from 'vite-plugin-solid'
const _dirname = dirname(fileURLToPath(import.meta.url))
// https://vitejs.dev/config/
// production
export default defineConfig(({ command, mode }) => {
// eslint-disable-next-line no-undef
const env = loadEnv(mode, process.cwd(), '')
if (env.NODE_ENV === 'production') {
return {
resolve: {
alias: {
'@': path.resolve(_dirname, './src'),
'@lang': path.resolve(_dirname, './src/lang'),
'@api': path.resolve(_dirname, './src/api'),
'@hooks': path.resolve(_dirname, './src/hooks'),
'@pages': path.resolve(_dirname, './src/pages'),
'@components': path.resolve(_dirname, './src/components'),
'@routes': path.resolve(_dirname, './src/routes'),
'@utils': path.resolve(_dirname, './src/utils'),
'@assets': path.resolve(_dirname, './src/assets'),
'@context': path.resolve(_dirname, './src/context'),
},
},
plugins: [solid(), mkcert()],
server: {
https: true,
host: true,
port: 5001,
strictPort: true,
watch: {
usePolling: true,
},
},
}
}
return {
resolve: {
alias: {
'@': path.resolve(_dirname, './src'),
'@lang': path.resolve(_dirname, './src/lang'),
'@api': path.resolve(_dirname, './src/api'),
'@hooks': path.resolve(_dirname, './src/hooks'),
'@pages': path.resolve(_dirname, './src/pages'),
'@components': path.resolve(_dirname, './src/components'),
'@routes': path.resolve(_dirname, './src/routes'),
'@utils': path.resolve(_dirname, './src/utils'),
'@assets': path.resolve(_dirname, './src/assets'),
'@context': path.resolve(_dirname, './src/context'),
},
},
plugins: [solid(), mkcert()],
server: {
https: true,
host: true,
port: 5001,
strictPort: true,
watch: {
usePolling: true,
},
proxy: {
'/api': 'http://localhost:9000',
},
},
}
})

83
fuware/app.py Normal file
View File

@ -0,0 +1,83 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from mimetypes import init
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fuware.core.config import get_app_settings
from fuware.core.root_logger import get_logger
from fuware.routes import router
from fuware import __version__
import uvicorn
settings = get_app_settings()
logger = get_logger()
description = f"""
fuware is a web application for managing your hours items and tracking them.
"""
@asynccontextmanager
async def lifespan_fn(_: FastAPI) -> AsyncGenerator[None, None]:
logger.info("start: database initialization")
import fuware.db.init_db as init_db
init_db.main()
logger.info("end: database initialization")
logger.info("-----SYSTEM STARTUP-----")
# logger.info("------APP SETTINGS------")
# logger.info(
# settings.model_dump_json(
# indent=4,
# exclude={
# "SECRET",
# "DB_URL", # replace by DB_URL_PUBLIC for logs
# "DB_PROVIDER",
# },
# )
# )
yield
logger.info("-----SYSTEM SHUTDOWN-----")
app = FastAPI(
title="Fuware",
description=description,
version=__version__,
docs_url=settings.DOCS_URL,
redoc_url=settings.REDOC_URL,
lifespan=lifespan_fn,
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
if not settings.PRODUCTION:
allowed_origins = ["http://localhost:3000"]
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.exception_handler(HTTPException)
async def unicorn_exception_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=exc.status_code,
content={"status": exc.status_code, "data": exc.detail},
)
def api_routers():
app.include_router(router)
api_routers()
def main():
uvicorn.run("app:app", host="0.0.0.0", port=settings.API_PORT, reload=True, workers=1, forwarded_allow_ips="*")
if __name__ == "__main__":
main()

View File

@ -1,15 +0,0 @@
import os
from dotenv import load_dotenv
load_dotenv()
SERCET_KEY = b"oWNhXlfo666JlMHk6UHYxeNB6z_CA2MisDDZJe4N0yc="
COOKIE_KEY = os.getenv('VITE_LOGIN_KEY') or '7fo24CMyIc'
# URL_DATABASE = "postgresql://{0}:{1}@{2}:{3}/{4}".format(
# os.getenv('LOL_DB_USER'),
# os.getenv('LOL_DB_PASSWORD'),
# os.getenv('LOL_DB_HOST'),
# os.getenv('LOL_DB_PORT'),
# os.getenv('LOL_DB_NAME'),
# )
URL_DATABASE = "sqlite:///./test.db"

1
fuware/core/__init__.py Normal file
View File

@ -0,0 +1 @@
from .message_code import *

31
fuware/core/config.py Normal file
View File

@ -0,0 +1,31 @@
import os
from functools import lru_cache
from pathlib import Path
from dotenv import load_dotenv
from fuware.core.settings.settings import AppSettings, app_settings_constructor
CWD = Path(__file__).parent
BASE_DIR = CWD.parent.parent
ENV = BASE_DIR.joinpath(".env")
load_dotenv()
PRODUCTION = os.getenv("PRODUCTION", "True").lower() in ["true", "1"]
TESTING = os.getenv("TESTING", "False").lower() in ["true", "1"]
DATA_DIR = os.getenv("DATA_DIR")
def determine_data_dir() -> Path:
global PRODUCTION, TESTING, BASE_DIR, DATA_DIR
if TESTING:
return BASE_DIR.joinpath(DATA_DIR if DATA_DIR else "tests/.temp")
if PRODUCTION:
return Path(DATA_DIR if DATA_DIR else "/app/data")
return BASE_DIR.joinpath("dev", "data")
@lru_cache
def get_app_settings() -> AppSettings:
return app_settings_constructor(env_file=ENV, production=PRODUCTION, data_dir=determine_data_dir())

View File

@ -0,0 +1 @@
from .dependencies import *

View File

@ -0,0 +1,76 @@
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer
from fuware.core.config import get_app_settings
from fuware.core import MessageCode
import jwt
from fuware.services.user.user_service import UserService
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
oauth2_scheme_soft_fail = OAuth2PasswordBearer(tokenUrl="/api/auth/token", auto_error=False)
ALGORITHM = "HS256"
settings = get_app_settings()
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
async def is_logged_in(token: str = Depends(oauth2_scheme_soft_fail)) -> bool:
try:
payload = jwt.decode(token, settings.SECRET, algorithms=[ALGORITHM])
user_id: str = payload.get("sub")
exp: int = payload.get("exp")
if exp is not None:
try:
user_service = UserService()
user = user_service.get_by_id(user_id)
if not user:
raise credentials_exception
if user.is_lock is True:
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail=MessageCode.ACCOUNT_LOCK)
except Exception:
return credentials_exception
return user
except Exception:
raise credentials_exception
async def get_current_user(request: Request, token: str | None = Depends(oauth2_scheme_soft_fail)):
"""verify that user has a valid session"""
if token is None and settings.COOKIE_KEY in request.cookies:
# Try extract from cookie
token = request.cookies.get(settings.COOKIE_KEY, "")
else:
token = token or ""
try:
payload = jwt.decode(token, settings.SECRET, algorithms=[ALGORITHM])
user_id: str = payload.get("sub")
exp: int = payload.get("exp")
if user_id is None or exp is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="credentials have expired",
)
user_service = UserService()
user = user_service.get_by_id(user_id)
if not user:
raise credentials_exception
if user.is_lock is True:
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail=MessageCode.ACCOUNT_LOCK)
return user
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="credentials have expired",
)
except Exception:
raise credentials_exception

View File

@ -0,0 +1,66 @@
import json
import logging
import pathlib
import typing
from logging import config as logging_config
__dir = pathlib.Path(__file__).parent
__conf: dict[str, str] | None = None
def _load_config(path: pathlib.Path, substitutions: dict[str, str] | None = None) -> dict[str, typing.Any]:
with open(path) as file:
if substitutions:
contents = file.read()
for key, value in substitutions.items():
# Replaces the key matches
#
# Example:
# {"key": "value"}
# "/path/to/${key}/file" -> "/path/to/value/file"
contents = contents.replace(f"${{{key}}}", value)
json_data = json.loads(contents)
else:
json_data = json.load(file)
return json_data
def log_config() -> dict[str, str]:
if __conf is None:
raise ValueError("logger not configured, must call configured_logger first")
return __conf
def configured_logger(
*,
mode: str,
config_override: pathlib.Path | None = None,
substitutions: dict[str, str] | None = None,
) -> logging.Logger:
"""
Configure the logger based on the mode and return the root logger
Args:
mode (str): The mode to configure the logger for (production, development, testing)
config_override (pathlib.Path, optional): A path to a custom logging config. Defaults to None.
substitutions (dict[str, str], optional): A dictionary of substitutions to apply to the logging config.
"""
global __conf
if config_override:
__conf = _load_config(config_override, substitutions)
else:
if mode == "production":
__conf = _load_config(__dir / "logconf.prod.json", substitutions)
elif mode == "development":
__conf = _load_config(__dir / "logconf.dev.json", substitutions)
elif mode == "testing":
__conf = _load_config(__dir / "logconf.test.json", substitutions)
else:
raise ValueError(f"Invalid mode: {mode}")
logging_config.dictConfig(config=__conf)
return logging.getLogger()

View File

@ -0,0 +1,15 @@
{
"version": 1,
"disable_existing_loggers": false,
"handlers": {
"rich": {
"class": "rich.logging.RichHandler"
}
},
"loggers": {
"root": {
"level": "DEBUG",
"handlers": ["rich"]
}
}
}

View File

@ -0,0 +1,63 @@
{
"version": 1,
"disable_existing_loggers": false,
"formatters": {
"simple": {
"format": "%(levelname)-8s %(asctime)s - %(message)s",
"datefmt": "%Y-%m-%dT%H:%M:%S"
},
"detailed": {
"format": "[%(levelname)s|%(module)s|L%(lineno)d] %(asctime)s: %(message)s",
"datefmt": "%Y-%m-%dT%H:%M:%S"
},
"access": {
"()": "uvicorn.logging.AccessFormatter",
"fmt": "%(levelname)-8s %(asctime)s - [%(client_addr)s] %(status_code)s \"%(request_line)s\"",
"datefmt": "%Y-%m-%dT%H:%M:%S"
}
},
"handlers": {
"stderr": {
"class": "logging.StreamHandler",
"level": "WARNING",
"formatter": "simple",
"stream": "ext://sys.stderr"
},
"stdout": {
"class": "logging.StreamHandler",
"level": "${LOG_LEVEL}",
"formatter": "simple",
"stream": "ext://sys.stdout"
},
"access": {
"class": "logging.StreamHandler",
"level": "${LOG_LEVEL}",
"formatter": "access",
"stream": "ext://sys.stdout"
},
"file": {
"class": "logging.handlers.RotatingFileHandler",
"level": "DEBUG",
"formatter": "detailed",
"filename": "${DATA_DIR}/mealie.log",
"maxBytes": 10000,
"backupCount": 3
}
},
"loggers": {
"root": {
"level": "${LOG_LEVEL}",
"handlers": ["stderr", "file", "stdout"]
},
"uvicorn.error": {
"handlers": ["stderr", "file", "stdout"],
"level": "${LOG_LEVEL}",
"propagate": false
},
"uvicorn.access": {
"handlers": ["access", "file"],
"level": "${LOG_LEVEL}",
"propagate": false
}
}
}

View File

@ -0,0 +1,24 @@
{
"version": 1,
"disable_existing_loggers": false,
"formatters": {
"detailed": {
"format": "[%(levelname)s|%(module)s|L%(lineno)d] %(asctime)s: %(message)s",
"datefmt": "%Y-%m-%dT%H:%M:%S"
}
},
"handlers": {
"stdout": {
"class": "logging.StreamHandler",
"level": "DEBUG",
"formatter": "detailed",
"stream": "ext://sys.stdout"
}
},
"loggers": {
"root": {
"level": "${LOG_LEVEL}",
"handlers": ["stdout"]
}
}
}

View File

@ -0,0 +1,6 @@
class MessageCode():
CREATED_USER: str = 'CREATED_USER'
WRONG_INPUT: str = 'LOGIN_WRONG'
ACCOUNT_LOCK: str = 'USER_LOCK'
REFRESH_TOKEN_EXPIRED: str = 'REFRESH_TOKEN_EXPIRED'

View File

@ -0,0 +1,43 @@
import logging
from .config import get_app_settings
from .logger.config import configured_logger
__root_logger: None | logging.Logger = None
def get_logger(module=None) -> logging.Logger:
"""
Get a logger instance for a module, in most cases module should not be
provided. Simply using the root logger is sufficient.
Cases where you would want to use a module specific logger might be a background
task or a long running process where you want to easily identify the source of
those messages
"""
global __root_logger
if __root_logger is None:
app_settings = get_app_settings()
mode = "development"
if app_settings.TESTING:
mode = "testing"
elif app_settings.PRODUCTION:
mode = "production"
substitutions = {
"LOG_LEVEL": app_settings.LOG_LEVEL.upper(),
}
__root_logger = configured_logger(
mode=mode,
config_override=app_settings.LOG_CONFIG_OVERRIDE,
substitutions=substitutions,
)
if module is None:
return __root_logger
return __root_logger.getChild(module)

View File

@ -0,0 +1 @@
from .security import *

View File

@ -0,0 +1,34 @@
from functools import lru_cache
from typing import Protocol
import bcrypt
from fuware.core.config import get_app_settings
class Hasher(Protocol):
def hash(self, password: str) -> str: ...
def verify(self, password: str, hashed: str) -> bool: ...
class FakeHasher:
def hash(self, password: str) -> str:
return password
def verify(self, password: str, hashed: str) -> bool:
return password == hashed
class BcryptHasher:
def hash(self, password: str) -> str:
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def verify(self, password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
@lru_cache(maxsize=1)
def get_hasher() -> Hasher:
settings = get_app_settings()
if settings.TESTING:
return FakeHasher()
return BcryptHasher()

View File

@ -0,0 +1,46 @@
import secrets
from datetime import datetime, timedelta, timezone
from pathlib import Path
import jwt
from fuware.core.config import get_app_settings
from fuware.core import root_logger
from fuware.core.security.hasher import get_hasher
ALGORITHM = "HS256"
logger = root_logger.get_logger("security")
settings = get_app_settings()
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
settings = get_app_settings()
to_encode = data.copy()
expires_delta = expires_delta or timedelta(minutes=settings.EXP_TOKEN)
expire = datetime.now(timezone.utc) + expires_delta
to_encode["exp"] = expire
return jwt.encode(to_encode, settings.SECRET, algorithm=ALGORITHM)
def create_refresh_token(data: dict) -> str:
return create_access_token(data, expires_delta=timedelta(days=settings.EXP_REFRESH))
def create_file_token(file_path: Path) -> str:
token_data = {"file": str(file_path)}
return create_access_token(token_data, expires_delta=timedelta(minutes=30))
def hash_password(password: str) -> str:
"""Takes in a raw password and hashes it. Used prior to saving a new password to the database."""
return get_hasher().hash(password)
def url_safe_token() -> str:
"""Generates a cryptographic token without embedded data. Used for password reset tokens and invitation tokens"""
return secrets.token_urlsafe(24)
def verify_token(exp: int):
expried = datetime.fromtimestamp(exp / 1e3)
return expried < datetime.now(timezone.utc)

View File

@ -0,0 +1 @@
from .settings import *

View File

@ -0,0 +1,28 @@
from abc import ABC, abstractmethod
from pathlib import Path
from pydantic import BaseModel
class AbstractDBProvider(ABC):
@property
@abstractmethod
def db_url(self) -> str: ...
@property
@abstractmethod
def db_url_public(self) -> str: ...
class SQLiteProvider(AbstractDBProvider, BaseModel):
data_dir: Path
prefix: str = ""
@property
def db_path(self):
return self.data_dir / f"{self.prefix}fuware.db"
@property
def db_url(self) -> str:
return f"sqlite:///{str(self.db_path.absolute())}"
@property
def db_url_public(self) -> str:
return self.db_url

View File

@ -0,0 +1,80 @@
from pathlib import Path
from fuware.core.settings.db_providers import AbstractDBProvider, SQLiteProvider
from pydantic_settings import BaseSettings # type: ignore
def determine_secrets(production: bool) -> str:
if not production:
return "shh-secret-test-key"
return "1d00e664fb3b07aff5a191755ea72f9c4bc85a3f36868308d0b2c417aed3419e"
def determine_cookie(production: bool) -> str:
if not production:
return "logcook"
return "7fo24CMyIc"
class AppSettings(BaseSettings):
PRODUCTION: bool
TESTING: bool
BASE_URL: str = "http://localhost:8080"
"""trailing slashes are trimmed (ex. `http://localhost:8080/` becomes ``http://localhost:8080`)"""
HOST_IP: str = "*"
API_HOST: str = "0.0.0.0"
API_PORT: int = 9000
API_DOCS: bool = True
ALLOW_SIGNUP: bool = False
SECRET: str
COOKIE_KEY: str
EXP_TOKEN: int = 30
"""in minutes, default is 30 minutes"""
EXP_REFRESH: int = 7
"""in days, default is 7 days"""
LOG_CONFIG_OVERRIDE: Path | None = None
"""path to custom logging configuration file"""
LOG_LEVEL: str = "info"
"""corresponds to standard Python log levels"""
@property
def DOCS_URL(self) -> str | None:
return "/docs" if self.API_DOCS else None
@property
def REDOC_URL(self) -> str | None:
return "/redoc" if self.API_DOCS else None
# ===============================================
# Database Configuration
DB_PROVIDER: AbstractDBProvider | None = None
@property
def DB_URL(self) -> str | None:
return self.DB_PROVIDER.db_url if self.DB_PROVIDER else None
@property
def DB_URL_PUBLIC(self) -> str | None:
return self.DB_PROVIDER.db_url_public if self.DB_PROVIDER else None
def app_settings_constructor(data_dir: Path, production: bool, env_file: Path, env_encoding="utf-8") -> AppSettings:
"""
app_settings_constructor is a factory function that returns an AppSettings object. It is used to inject the
required dependencies into the AppSettings object and nested child objects. AppSettings should not be substantiated
directly, but rather through this factory function.
"""
app_settings = AppSettings(
_env_file=env_file, # type: ignore
_env_file_encoding=env_encoding, # type: ignore
**{"SECRET": determine_secrets(production), 'COOKIE_KEY': determine_cookie(production)},
)
app_settings.DB_PROVIDER = SQLiteProvider(data_dir=data_dir)
return app_settings

View File

@ -1,20 +0,0 @@
from sqlalchemy.orm import Session
from db.models import User
from ultis import get_password_hash
import schemas
def get_user(db: Session, user_id: str):
return db.query(User).filter(User.id == user_id).first()
def get_user_by_username(db: Session, usn: str):
return db.query(User).filter(User.username == usn).first()
def get_users(db: Session, skip: int = 0, limit: int = 100):
return db.query(User).offset(skip).limit(limit).all()
def create_user(db: Session, user: schemas.UserCreate):
db_user = User(username=user.username, password=get_password_hash(user.password), name=user.name)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user

View File

@ -1,17 +1,48 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.declarative import declarative_base
from const import URL_DATABASE
from collections.abc import Generator
from contextlib import contextmanager
from sqlalchemy.orm.session import Session
from sqlalchemy import create_engine, event, Engine
from sqlalchemy.orm import scoped_session, sessionmaker
from fuware.core.config import get_app_settings
engine = create_engine(URL_DATABASE)
settings = get_app_settings()
SessionLocal = sessionmaker(autocommit=False ,autoflush=False, bind=engine)
def sql_global_init(db_url: str):
connect_args = {"check_same_thread": False}
Base = declarative_base()
engine = create_engine(db_url, echo=False, connect_args=connect_args, pool_pre_ping=True, future=True)
def get_db():
SessionLocal = scoped_session(sessionmaker(autocommit=False, autoflush=False, bind=engine, future=True))
return SessionLocal, engine
SessionLocal, engine = sql_global_init(settings.DB_URL)
@event.listens_for(Engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
@contextmanager
def session_context() -> Session: # type: ignore
"""
session_context() provides a managed session to the database that is automatically
closed when the context is exited. This is the preferred method of accessing the
database.
Note: use `generate_session` when using the `Depends` function from FastAPI
"""
global SessionLocal
sess = SessionLocal()
try:
yield sess
finally:
sess.close()
def generate_session() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
yield db
finally:
db.close()
db.close()

87
fuware/db/init_db.py Normal file
View File

@ -0,0 +1,87 @@
import os
from pathlib import Path
from time import sleep
from sqlalchemy import engine, orm, text
from alembic import command, config, script
from alembic.config import Config
from alembic.runtime import migration
from fuware.core import root_logger
from fuware.core.config import get_app_settings
from fuware.db.db_setup import session_context
from fuware.repos.repository_users import RepositoryUsers
from fuware.repos.seeder import default_users_init
from fuware.db.models._model_base import Model
# from fuware.db.models import User
PROJECT_DIR = Path(__file__).parent.parent.parent
logger = root_logger.get_logger()
def init_db(db) -> None:
logger.info("Initializing user data...")
default_users_init(db)
def db_is_at_head(alembic_cfg: config.Config) -> bool:
settings = get_app_settings()
url = settings.DB_URL
if not url:
raise ValueError("No database url found")
connectable = engine.create_engine(url)
directory = script.ScriptDirectory.from_config(alembic_cfg)
with connectable.begin() as connection:
context = migration.MigrationContext.configure(connection)
return set(context.get_current_heads()) == set(directory.get_heads())
def connect(session: orm.Session) -> bool:
try:
session.execute(text("SELECT 1"))
return True
except Exception as e:
logger.error(f"Error connecting to database: {e}")
return False
def main():
max_retry = 10
wait_seconds = 1
with session_context() as session:
while True:
if connect(session):
logger.info("Database connection established.")
break
logger.error(f"Database connection failed. Retrying in {wait_seconds} seconds...")
max_retry -= 1
sleep(wait_seconds)
if max_retry == 0:
raise ConnectionError("Database connection failed - exiting application.")
alembic_cfg_path = os.getenv("ALEMBIC_CONFIG_FILE", default=str(PROJECT_DIR / "alembic.ini"))
if not os.path.isfile(alembic_cfg_path):
raise Exception("Provided alembic config path doesn't exist")
alembic_cfg = Config(alembic_cfg_path)
if db_is_at_head(alembic_cfg):
logger.debug("Migration not needed.")
else:
logger.info("Migration needed. Performing migration...")
command.upgrade(alembic_cfg, "head")
if session.get_bind().name == "postgresql": # needed for fuzzy search and fast GIN text indices
session.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm;"))
users = RepositoryUsers()
if users.get_all():
logger.info("Database already seeded.")
else:
logger.info("Seeding database...")
init_db(session)
if __name__ == "__main__":
main()

View File

@ -1 +1 @@
from .user import *
from .users import *

View File

@ -0,0 +1,20 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer
from sqlalchemy.orm import declarative_base, Mapped, mapped_column
from text_unidecode import unidecode
from fuware.db.db_setup import SessionLocal
Model = declarative_base()
Model.query = SessionLocal.query_property()
class SqlAlchemyBase(Model):
__abstract__ = True
created_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow(), index=True)
updated_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow(), onupdate=datetime.utcnow())
@classmethod
def normalize(cls, val: str) -> str:
return unidecode(val).lower().strip()

View File

@ -1,8 +0,0 @@
from datetime import datetime
from sqlalchemy import Column, DateTime
from sqlalchemy.orm import declarative_mixin
@declarative_mixin
class Timestamp:
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, nullable=False)

View File

@ -1,15 +0,0 @@
from db import Base
from sqlalchemy import Boolean, Column, String
from .mixins import Timestamp
from sqlalchemy.dialects.postgresql import UUID
import uuid
class User(Base, Timestamp):
__tablename__ = 'users'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
username = Column(String(100), unique=True, index=True, nullable=False)
password = Column(String, index=True, nullable=False)
name = Column(String, index=True, nullable=True)
is_admin = Column(Boolean, default=False)
is_lock = Column(Boolean, default=False)

View File

@ -0,0 +1 @@
from .users import *

View File

@ -0,0 +1,20 @@
from uuid import uuid4
from sqlalchemy import Boolean, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from .._model_base import SqlAlchemyBase
class User(SqlAlchemyBase):
__tablename__ = 'users'
id: Mapped[UUID] = mapped_column(UUID, primary_key=True, default=uuid4, index=True)
username: Mapped[str | None] = mapped_column(String, unique=True, index=True, nullable=False)
password: Mapped[str | None] = mapped_column(String, index=True, nullable=False)
name: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
is_admin: Mapped[bool | None] = mapped_column(Boolean, default=False)
is_lock: Mapped[bool | None] = mapped_column(Boolean, default=False)
def __repr__(self):
return f"{self.__class__.__name__}, name: {self.name}, username: {self.username}"

View File

@ -1,34 +1,10 @@
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from routes import authR, userR
# from db import engine, models
# from sqlalchemy import event
# from db.seeds import initialize_table
import uvicorn
from fuware.app import settings
# event.listen(models.User.__table__, 'after_create', initialize_table)
app = FastAPI()
# models.Base.metadata.create_all(bind=engine)
@app.exception_handler(HTTPException)
async def unicorn_exception_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=exc.status_code,
content={"status": exc.status_code, "data": exc.detail},
)
app.include_router(authR.authRouter)
app.include_router(userR.userRouter)
def main():
uvicorn.run(
"main:app",
port=8000,
host="0.0.0.0",
reload=True
)
uvicorn.run("app:app", host=settings.API_HOST, port=settings.API_PORT, reload=True, workers=1, forwarded_allow_ips=settings.HOST_IP)
if __name__ == '__main__':
if __name__ == "__main__":
main()

1
fuware/repos/__init__.py Normal file
View File

@ -0,0 +1 @@
from .repository_users import *

View File

@ -0,0 +1,36 @@
from fuware.core.config import get_app_settings
from fuware.core.security.security import hash_password
from fuware.db.models import User
from fuware.schemas import UserCreate
from sqlalchemy.orm import Session
from uuid import UUID
from fuware.schemas.user.user import UserSeeds
settings = get_app_settings()
class RepositoryUsers:
def __init__(self):
self.user = User()
def get_all(self, skip: int = 0, limit: int = 100):
return self.user.query.offset(skip).limit(limit).all()
def get_by_username(self, username: str):
return self.user.query.filter_by(username=username).first()
def get_by_id(self, user_id: str):
return self.user.query.filter_by(id=UUID(user_id)).first()
def create(self, db: Session, user: UserCreate | UserSeeds):
try:
password = getattr(user, "password")
db_user = User(**user.dict(exclude={"password"}), password=hash_password(password))
db.add(db_user)
db.commit()
except Exception:
db.rollback()
raise
db.refresh(db_user)
return db_user

View File

@ -0,0 +1 @@
from .init_users import default_users_init

View File

@ -0,0 +1,33 @@
from fuware.core.config import get_app_settings
from fuware.core.root_logger import get_logger
from fuware.repos.repository_users import RepositoryUsers
from sqlalchemy.orm import Session
from fuware.schemas.user import UserSeeds
logger = get_logger("init_users")
settings = get_app_settings()
def dev_users() -> list[dict]:
return [
{
"username": "sam",
"password": "admin",
"name": "Sam",
"is_admin": True,
"is_lock": False,
},
{
"username": "sam1",
"password": "admin",
"name": "Sam1",
"is_admin": False,
"is_lock": False,
},
]
def default_users_init(session: Session):
users = RepositoryUsers()
for user in dev_users():
users.create(session, UserSeeds(**user))

View File

@ -0,0 +1,9 @@
from fastapi import APIRouter
from . import (auth, user)
router = APIRouter(prefix='/api')
router.include_router(auth.router)
router.include_router(user.router)

View File

@ -0,0 +1,9 @@
from enum import Enum
from fastapi import APIRouter, Depends
from fuware.core.dependencies import get_auth_user
class PrivateAPIRouter(APIRouter):
def __init__(self, tags: list[str | Enum] | None = None, prefix: str = "", **kwargs):
super().__init__(tags=tags, prefix=prefix, dependencies=[Depends(get_auth_user)], **kwargs)

View File

@ -0,0 +1,7 @@
from fastapi import APIRouter
from . import auth
router = APIRouter(prefix='/auth')
router.include_router(auth.auth_router)

View File

@ -0,0 +1,70 @@
from datetime import datetime, timedelta, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Response, status
# from fastapi.encoders import jsonable_encoder
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from fuware.core.config import get_app_settings
from fuware.core.dependencies.dependencies import get_current_user
from fuware.core import MessageCode
from fuware.db.db_setup import generate_session
from fuware.schemas import ReturnValue, UserRequest, LoginResponse, UserCreate, PrivateUser
from fuware.services.user import UserService
auth_router = APIRouter(tags=["Users: Authentication"])
user_service = UserService()
settings = get_app_settings()
db_dependency = Annotated[Session, Depends(generate_session)]
current_user_token = Annotated[PrivateUser, Depends(get_current_user)]
@auth_router.post('/token')
async def get_token(form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: db_dependency):
user = user_service.check_exist(user=UserRequest(username=form_data.username, password=form_data.password))
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=MessageCode.WRONG_INPUT)
token = user_service.get_access_token(user_id=user.id)
return {'access_token': token, 'token_type': 'bearer'}
@auth_router.put('/register')
def register_user(user: UserCreate, db: db_dependency) -> ReturnValue[Any]:
db_user = user_service.get_by_username(username=user.username)
if db_user:
raise HTTPException(status_code=400, detail=MessageCode.CREATED_USER)
user_service.create(db=db, user=user)
return ReturnValue(status=200, data="created")
@auth_router.post('/login', response_model=ReturnValue[LoginResponse])
def user_login(user: UserRequest, response: Response) -> ReturnValue[Any]:
db_user = user_service.check_exist(user=user)
if not db_user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=MessageCode.WRONG_INPUT)
if db_user.is_lock is True:
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail=MessageCode.ACCOUNT_LOCK)
access_token, refresh_token = user_service.generate_token(user_id=db_user.id)
duration_access = datetime.now(timezone.utc) + timedelta(minutes=settings.EXP_TOKEN)
duration_refresh = int(timedelta(days=settings.EXP_REFRESH).total_seconds())
response.set_cookie(
key=settings.COOKIE_KEY,
value=refresh_token,
max_age=duration_refresh,
expires=duration_refresh,
httponly=True,
samesite="strict",
)
return ReturnValue(status=200, data=dict(access_token=access_token, exp=int(duration_access.timestamp()), name=db_user.name))
@auth_router.get('/refresh')
def user_check(current_user: current_user_token) -> ReturnValue[Any]:
access_token = user_service.get_access_token(user_id=current_user.id)
duration_access = datetime.now(timezone.utc) + timedelta(minutes=settings.EXP_TOKEN)
return ReturnValue(status=200, data=dict(accessToken=access_token, exp=int(duration_access.timestamp())))
@auth_router.get('/logout')
def user_logout(response: Response, current_user: current_user_token) -> ReturnValue[Any]:
if current_user:
response.delete_cookie(key=settings.COOKIE_KEY)
return ReturnValue(status=200, data='Logged out')

View File

@ -1,50 +0,0 @@
from typing import Any
from fastapi import APIRouter, HTTPException, Response, Request, Depends
from fastapi.encoders import jsonable_encoder
from schemas import ReturnValue, User, UserCreate, UserRequest
from ultis import root_api_path_build, encryptString, decryptString, verify_password
from const import COOKIE_KEY
from sqlalchemy.orm import Session
from db.controller import get_user_by_username, create_user
from db import get_db
authRouter=APIRouter(prefix=root_api_path_build('/auth'))
@authRouter.put('/register')
def register_user(user: UserCreate, db: Session = Depends(get_db)) -> ReturnValue[Any]:
db_user = get_user_by_username(db=db, usn=user.username)
if db_user:
raise HTTPException(status_code=400, detail="Username already registered!")
user_return = create_user(db=db, user=user)
return ReturnValue(status=200, data=jsonable_encoder(user_return))
@authRouter.post('/login', response_model=ReturnValue[User])
def user_login(user: UserRequest, response: Response, db: Session = Depends(get_db)) -> ReturnValue[Any]:
db_user = get_user_by_username(db, user.username)
if not db_user:
raise HTTPException(status_code=401, detail="Your username or password input is wrong!")
if not verify_password(user.password, db_user.password):
raise HTTPException(status_code=401, detail="Your username or password input is wrong!")
if db_user.is_lock is True:
raise HTTPException(status_code=401, detail="Your Account is banned")
cookieEncode = encryptString(user.username + ',' + user.password)
response.set_cookie(key=COOKIE_KEY, value=cookieEncode.decode('utf-8'))
return ReturnValue(status=200, data=jsonable_encoder(db_user))
@authRouter.get('/logout')
def user_logout(response: Response) -> ReturnValue[Any]:
response.delete_cookie(key=COOKIE_KEY)
return ReturnValue(status=200, data='Logged out')
def get_auth_user(request: Request, db: Session = Depends(get_db)):
"""verify that user has a valid session"""
session_id = request.cookies.get(COOKIE_KEY)
if not session_id:
raise HTTPException(status_code=401, detail="Unauthorized")
decrypt_user = decryptString(session_id).split(',')
db_user = get_user_by_username(db, decrypt_user[0])
if not db_user:
raise HTTPException(status_code=403)
if not verify_password(decrypt_user[1], db_user.password):
raise HTTPException(status_code=401, detail="Your username or password input is wrong!")
return True

View File

@ -0,0 +1,7 @@
from fastapi import APIRouter
from . import user
router = APIRouter(prefix='/user')
router.include_router(user.public_router)

View File

@ -0,0 +1,21 @@
from typing import Annotated, Any
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from fuware.core.config import get_app_settings
from fuware.core.dependencies import is_logged_in
from fuware.db.db_setup import generate_session
from fuware.schemas.common import ReturnValue
from fuware.schemas.user import ProfileResponse
from fuware.services.user import UserService
public_router = APIRouter(tags=["Users: Info"])
user_service = UserService()
settings = get_app_settings()
db_dependency = Annotated[Session, Depends(generate_session)]
current_user_token = Annotated[ProfileResponse, Depends(is_logged_in)]
@public_router.get("/me", response_model=ReturnValue[ProfileResponse])
def get_user(current_user: current_user_token) -> ReturnValue[Any]:
return ReturnValue(status=200, data=current_user)

View File

@ -1,11 +0,0 @@
from typing import Any
from fastapi import APIRouter, Depends
from schemas import ReturnValue
from ultis import root_api_path_build
from routes import authR
userRouter=APIRouter(prefix=root_api_path_build('/user'))
@userRouter.get('/get-data/', dependencies=[Depends(authR.get_auth_user)])
def get_data(url: str = '') -> ReturnValue[Any]:
return ReturnValue(status=200, data=url)

View File

@ -0,0 +1,27 @@
from typing import ClassVar, TypeVar
from humps import camelize
from enum import Enum
from pydantic import BaseModel, ConfigDict
T = TypeVar("T", bound=BaseModel)
class SearchType(Enum):
fuzzy = "fuzzy"
tokenized = "tokenized"
class FuwareModel(BaseModel):
_searchable_properties: ClassVar[list[str]] = []
"""
Searchable properties for the search API.
The first property will be used for sorting (order_by)
"""
model_config = ConfigDict(alias_generator=camelize, populate_by_name=True)
def cast(self, cls: type[T], **kwargs) -> T:
"""
Cast the current model to another with additional arguments. Useful for
transforming DTOs into models that are saved to a database
"""
create_data = {field: getattr(self, field) for field in self.__fields__ if field in cls.__fields__}
create_data.update(kwargs or {})
return cls(**create_data)

View File

@ -1,24 +0,0 @@
from datetime import datetime
from pydantic import BaseModel
from fastapi import Form
class UserBase(BaseModel):
username: str = Form(...)
class UserRequest(UserBase):
password: str = Form(...)
class UserCreate(UserRequest):
password: str = Form(...)
name: str
class User(UserBase):
id: str
name: str
is_admin: bool
is_lock: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

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