Initial commit

This commit is contained in:
Maksim Karasev 2021-03-23 17:12:51 +03:00
commit 7da5465d56
100 changed files with 4525 additions and 0 deletions

11
.dockerignore Normal file
View File

@ -0,0 +1,11 @@
**/.git
**/tmp
**/node_modules
.yarn/*
!.yarn/releases
!.yarn/plugins
!.yarn/sdks
!.yarn/versions
.pnp.*
packages/server/dist
**/.env

52
.gitignore vendored Normal file
View File

@ -0,0 +1,52 @@
lib-cov
*.seed
*.log
*.csv
*.dat
*.out
*.pid
*.gz
*.swp
pids
logs
results
tmp
# Build
public/css/main.css
# Coverage reports
coverage
# API keys and secrets
.env
# Dependency directory
node_modules
bower_components
# Editors
.idea
*.iml
# OS metadata
.DS_Store
Thumbs.db
# Ignore built ts files
dist/**/*
# ignore yarn.lock
yarn.lock
.yarn/*
!.yarn/patches
!.yarn/releases
!.yarn/plugins
!.yarn/sdks
!.yarn/versions
.pnp.*
packages/server/dist

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

@ -0,0 +1,8 @@
{
"prettier.enable": false,
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"eslint.workingDirectories": [
"./packages/web",
"./packages/server"
]
}

55
.yarn/releases/yarn-berry.cjs vendored Executable file

File diff suppressed because one or more lines are too long

2
.yarnrc.yml Normal file
View File

@ -0,0 +1,2 @@
yarnPath: '.yarn/releases/yarn-berry.cjs'
nodeLinker: 'node-modules'

37
Dockerfile Normal file
View File

@ -0,0 +1,37 @@
FROM node:lts-alpine as frontend-build
WORKDIR /app
COPY ./ /app
RUN yarn plugin import workspace-tools \
&& yarn workspaces focus web \
&& yarn workspace web build
FROM node:lts-alpine
WORKDIR /app
COPY ./ /app
COPY --from=frontend-build /app/packages/web/build /app/packages/server/public
RUN apk add --update-cache \
ffmpeg \
python3 \
build-base \
&& yarn --version\
&& yarn plugin import workspace-tools \
&& yarn workspaces focus server\
&& yarn workspace server build \
&& yarn cache clean --all\
&& rm -rf /var/cache/apk/* \
&& apk del \
python3 \
build-base
VOLUME ["/images", "/db"]
ENV IMAGE_DIR="/images"
ENV DB_DIR="/db"
ENV PORT=80
ENTRYPOINT [ "yarn", "start" ]

87
README.md Normal file
View File

@ -0,0 +1,87 @@
# Personal image gallery
A simple to use, modern place to store your images. Perfect for storing large
amounts of screenshots and screen recordings.
## Uploading
After searching for a solution that would allow me to upload easily from every
kind of device, I've decided to make my own. If you are using a relatively modern
device, you probably can upload images from it.
### Dialog
Click the upload icon and then click the upload area to trigger a system file
pick dialog.
### Drag-n-drop
Simply drag any file into the window, and the upload area should magically appear.
![](README/drag.gif)
### Paste
Got a screenshot in your clipboard, Ctrl-V and now you-ve got it online.
![](README/paste.gif)
### API
Want use your own uploader? You can. Get the API key, put it into the `Authorization`
header and that's it.
### ShareX
While we're on the topic of APIs, if you're using ShareX you can simply get
a ready made custom uploader config.
![](README/sharex.gif)
### Android
Sharing on mobile is easier than ever thanks to the PWA Share Target APIs.
![](README/android.gif)
## Installation
### Docker (Reccommended)
The image is available on dockerhub, you can start with an example
docker-compose below:
````yaml
version: "3.9"
services:
gallery:
image: PLACEHOLDER
ports:
- 80:80
volumes:
- ./data:/images
- ./db:/db
````
**Environment Variables**
All docker environment variables are optional
`PORT` - which port to listen on, by default set to 3001
`PROXY` - [express proxy settings](https://expressjs.com/en/guide/behind-proxies.html), by default set to `true`
`USERNAME` and `PASSWORD` - used to reset user credentials, if set, all sessions and API key are invalidated
`BASE_URL` - base directory if app isn't in root
To use Redis instead of built-it memory cache:
`REDIS_HOST` - Redis host
`REDIS_PASSWORD` - Redis password
### On bare machine
You'll need node 14+ and yarn to complete the build.
Copy the .env.example to .env, changing it according to your needs. In the root directory run `yarn build` then `yarn start`, if everything went well, you'll have the server running.
## Develompent
Clone the repo, set the server environment with .env, install dependencies, start with `yarn start-dev`.
REST API documentation is available [here](packages/server/docs/api/README.md)
## Contributing
Easies way to contribute is to submit a translation, use `./packages/web/public/locales/en.json`
as a template and submit a pull request with your language.
## License
[MIT](LICENSE.md)

BIN
README/android.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

BIN
README/drag.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

BIN
README/paste.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 KiB

BIN
README/sharex.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 KiB

33
package.json Normal file
View File

@ -0,0 +1,33 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "personal-gallery",
"version": "1.0.0",
"description": "",
"scripts": {
"server-dev": "yarn workspace server dev",
"web-dev": "yarn workspace web start",
"start-dev": "run-p server-dev web-dev",
"web-build": "yarn workspace web build",
"copy": "yarn copyfiles -u 3 \"packages/web/build/*\" \"packages/web/build/**/*\" packages/server/public",
"build": "yarn web-build && yarn workspace server build && yarn copy",
"build-dev": "NODE_ENV=development yarn web-build && yarn workspace server build && yarn copy",
"start": "yarn workspace server start"
},
"author": {
"name": "Maksim Karasev"
},
"license": "MIT",
"private": true,
"workspaces": {
"packages": [
"packages/*"
]
},
"installConfig": {
"hoistingLimits": "dependencies"
},
"devDependencies": {
"copyfiles": "^2.4.1",
"npm-run-all": "^4.1.5"
}
}

View File

@ -0,0 +1,9 @@
#PORT=3002 # (optional) port to listen on, by default set to 3001
#IMAGE_DIR=/tmp/images/ # image directory
#DB_DIR=/tmp/db/ # database directory
#REDIS_HOST=localhost # (optional) redis host
#REDIS_PASSWORD=test # (optional) redis password
#PROXY='127.0.0.1/8' # (optional) express formatted reverse proxy settings
# USERNAME=test # (optional) used to reset user credentials
# PASSWORD=test # (optional) used to reset user credentials
# BASE_URL=gallery # (optional) base directory if app isn't in root

View File

@ -0,0 +1,17 @@
module.exports = {
extends: ['airbnb-typescript/base'],
parserOptions: {
project: './tsconfig.json'
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'no-warning-comments': [
'warn',
{
terms: ['todo', 'fixme', 'any other term'],
location: 'anywhere'
}
],
'no-unused-vars': ['error', { varsIgnorePattern: '^_' }]
}
};

2
packages/server/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
test/i/*
public/*

View File

@ -0,0 +1,29 @@
# REST API Documentation
Where full URLs are provided in responses they will be rendered as if service
is running on 'http://testserver/'.
## Open Endpoints
Open endpoints require no Authentication.
* [Login](login/post.md) : `POST /api/login/`
* [Register](login/register/post.md) : `POST /api/login/register/`
* [Logout](login/logout.md) : `POST /api/login/logout/`
## Endpoints that require Authentication
### Auth options
* Token in cookie `personal-gallery_auth`, can be set via the login endpoint.
* Bearer token in `Authorization` header, can be aquiered via the getApiKey endpoint.
### Image related
* [Image list](images/get.md) : `GET /api/images/`
* [Add image](images/post.md) : `POST /api/images/`
* [Get thumbnail](thumbnails/get.md) : `GET /api/thumbnails/:image/:type/`
### User related
* [API key](user/getApiKey/post.md) : `POST /api/user/getApiKey/`
* [User credentials update](user/updateCredentials/post.md) : `POST /api/user/updateCredentials/`

View File

@ -0,0 +1,72 @@
# List all images
Used to get all images on the server.
**URL** : `/api/images/`
**Method** : `GET`
**Auth required** : YES
**Optional query parameters**
| Parameter | Possible values | Default | Description |
|------ | ----- | --- | --- |
|`sortBy`| `filename` or `added`|`filename`| Specifies how to sort the output|
|`sortOrder`| `ASC` or `DSC`|`ASC`|Specifies sort direction |
|`page`| integers >= 0|0| Specifies page offset|
## Success Responses
**Condition** : There are no images on the server.
**Code** : `200 OK`
**Content** : `[]`
### OR
**Condition** : There are images on the server.
**Code** : `200 OK`
**Content**
````json
[
{
"url": "/4bNPpMw.png",
"filename": "4bNPpMw.png",
"thumbnails": [
{
"filetype": "image/avif",
"url": "/api/thumbnails/4bNPpMw.png/avif"
},
{
"filetype": "image/webp",
"url": "/api/thumbnails/4bNPpMw.png/webp"
},
{
"filetype": "image/jpeg",
"url": "/api/thumbnails/4bNPpMw.png/jpeg"
}
]
},
{
"url": "/82dyCV7.png",
"filename": "82dyCV7.png",
"thumbnails": [
{
"filetype": "image/avif",
"url": "/api/thumbnails/82dyCV7.png/avif"
},
{
"filetype": "image/webp",
"url": "/api/thumbnails/82dyCV7.png/webp"
},
{
"filetype": "image/jpeg",
"url": "/api/thumbnails/82dyCV7.png/jpeg"
}
]
}
]
````

View File

@ -0,0 +1,108 @@
# Add image
Used to upload images to the server.
**URL** : `/api/images/`
**Method** : `POST`
**Auth required** : YES
**Request type**: `multipart/form-data`
**File key**: `file`
## Success Response
**Code** : `200 OK`
**Content example**
```json
{
"url": "/zVV3DFY.png",
"filename": "zVV3DFY.png",
"thumbnails": [
{
"filetype": "image/avif",
"url": "/api/thumbnails/zVV3DFY.png/avif"
},
{
"filetype": "image/webp",
"url": "/api/thumbnails/zVV3DFY.png/webp"
},
{
"filetype": "image/jpeg",
"url": "/api/thumbnails/zVV3DFY.png/jpeg"
}
]
}
```
## Error Response
**Condition** : If no file was sent.
**Code** : `400 Bad Request`
**Content** :
```json
{
"status": "error",
"error": "File missing."
}
```
**Condition** : If multiple files were sent at once.
**Code** : `400 Bad Request`
**Content** :
```json
{
"status": "error",
"error": "Send one file at a time."
}
```
**Condition** : If filetype is impossible to extract or it mismatches the extension.
**Code** : `400 Bad Request`
**Content** :
```json
{
"status": "error",
"error": "Malformed file."
}
```
**Condition** : If filetype is unsupported by server.
**Code** : `400 Bad Request`
**Content** :
```json
{
"status": "error",
"error": "Unsupported file type."
}
```
**Condition** : If server can't write the file to disk.
**Code** : `500 Internal Server Error`
**Content** :
```json
{
"status": "error",
"error": "Error saving uploaded image."
}
```

View File

@ -0,0 +1,42 @@
# Logout
Used to logout and invalidate session cookie.
**URL** : `/api/login/logout/`
**Method** : `POST`
**Auth required** : NO
## Success Response
**Code** : `200 OK`
**Header example**
````http
Set-Cookie: personal-gallery_auth=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax
Clear-Site-Data: "cache", "cookies", "storage"
````
**Content example**
```json
{
"status": "success"
}
```
## Error Response
**Condition** : If 'username' or 'password' is empty.
**Code** : `400 Bad Request`
**Content** :
```json
{
"status": "error"
}
```

View File

@ -0,0 +1,70 @@
# Login
Used to set a session cookie.
**URL** : `/api/login/`
**Method** : `POST`
**Auth required** : NO
**Data constraints**
```json
{
"username": "[valid username]",
"password": "[password in plain text]"
}
```
**Data example**
```json
{
"username": "iloveauth",
"password": "abcd1234"
}
```
## Success Response
**Code** : `200 OK`
**Header example**
````http
Set-Cookie: personal-gallery_auth=c944ee5bf6ee313191d0c242b79840e5a84967c3798a3526a1b356f6a04b; Path=/; Expires=Fri, 22 Mar 2024 10:33:50 GMT; HttpOnly; SameSite=Lax
````
**Content example**
```json
{
"status": "success"
}
```
## Error Response
**Condition** : If 'username' and 'password' combination is wrong.
**Code** : `401 Unauthorized`
**Content** :
```json
{
"status": "error"
}
```
**Condition** : If registration proccess isn't finished.
**Code** : `500 Internal Server Error`
**Content** :
```json
{
"status": "error"
}
```

View File

@ -0,0 +1,70 @@
# Registration
Used to initially set user credentials.
**URL** : `/api/login/register/`
**Method** : `POST`
**Auth required** : NO
**Data constraints**
```json
{
"username": "[valid username]",
"password": "[password in plain text]"
}
```
**Data example**
```json
{
"username": "iloveauth",
"password": "abcd1234"
}
```
## Success Response
**Code** : `200 OK`
**Header example**
````http
Set-Cookie: personal-gallery_auth=c944ee5bf6ee313191d0c242b79840e5a84967c3798a3526a1b356f6a04b; Path=/; Expires=Fri, 22 Mar 2024 10:33:50 GMT; HttpOnly; SameSite=Lax
````
**Content example**
```json
{
"status": "success"
}
```
## Error Response
**Condition** : If 'username' or 'password' is empty.
**Code** : `400 Bad Request`
**Content** :
```json
{
"status": "error"
}
```
**Condition** : If registration proccess failed.
**Code** : `500 Internal Server Error`
**Content** :
```json
{
"status": "error"
}
```

View File

@ -0,0 +1,29 @@
# Meta information
Used to get server meta info.
**URL** : `/api/meta/`
**Method** : `GET`
**Auth required** : NO
## Success Responses
**Code** : `200 OK`
**Content**
````json
{
"accepted": [
"image/png",
"image/webp",
"image/gif",
"image/avif",
"image/jpeg",
"video/mp4",
"video/webm"
],
"setupFinished": true
}
````

View File

@ -0,0 +1,19 @@
# Get thumbnail
Get a thumbnail for the specified image in the specified format.
**URL** : `/api/thumbnails/:image/:type`
**URL Parameters** :
- `image=[string]` where `image` is the name of the requested image
- `type=['jpeg'|'webp'|'avif']` where `type` is the requested thumbnail type
**Method** : `GET`
**Auth required** : YES
## Success Response
**Code** : `200 OK`
**Content**: the requested thumbnail.

View File

@ -0,0 +1,22 @@
# API Keys
Used to reset and get a new API key.
**URL** : `/api/user/getApiKey/`
**Method** : `POST`
**Auth required** : YES
## Success Response
**Code** : `200 OK`
**Content example**
```json
{
"token": "cd37949572cdea54ec5479d0cbe1ebfa6562a7ea33a4ea71e52609d232b5"
}
```

View File

@ -0,0 +1,67 @@
# User credentials
Used to change username and password.
**URL** : `/api/user/updateCredentials/`
**Method** : `POST`
**Auth required** : YES
**Data constraints**
```json
{
"username": "[OPTIOANAL, valid username]",
"password": "[OPTIOANAL, password in plain text]",
"oldPassword": "[old password in plain text]"
}
```
**Data example**
```json
{
"username": "iloveauth",
"oldPassword": "abcd1234"
}
```
## Success Response
**Code** : `200 OK`
**Content example**
```json
{
"token": "cd37949572cdea54ec5479d0cbe1ebfa6562a7ea33a4ea71e52609d232b5"
}
```
## Error Response
**Condition** : If neither 'username' or 'password' are set.
**Code** : `400 Bad Request`
**Content** :
```json
{
"status": "error"
}
```
**Condition** : If 'oldPassword' is incorrect.
**Code** : `401 Unauthorized`
**Content** :
```json
{
"status": "error"
}
```

View File

@ -0,0 +1,4 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

View File

@ -0,0 +1,68 @@
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "tsc",
"_prestart": "yarn build",
"start": "NODE_ENV=production node dist/src/server.js",
"dev": "NODE_ENV=dev ts-node-dev src/server.ts",
"test": "NODE_ENV=test jest --verbose --runInBand --rootDir=test",
"lint": "eslint \"./src/**/*.{ts,tsx}\""
},
"author": "",
"license": "MIT",
"devDependencies": {
"@types/bcryptjs": "^2.4.2",
"@types/better-sqlite3": "^5.4.1",
"@types/cache-manager": "^3.4.0",
"@types/cache-manager-ioredis": "^2.0.1",
"@types/command-exists": "^1.2.0",
"@types/cookie-parser": "^1.4.2",
"@types/cors": "^2.8.9",
"@types/express": "^4.17.10",
"@types/express-fileupload": "^1.1.6",
"@types/express-rate-limit": "^5.1.1",
"@types/file-type": "^10.9.1",
"@types/fluent-ffmpeg": "^2.1.16",
"@types/jest": "^26.0.21",
"@types/morgan": "^1.9.2",
"@types/node": "^14.14.35",
"@types/sharp": "^0.27.1",
"@types/supertest": "^2.0.10",
"@types/tmp": "^0.2.0",
"@typescript-eslint/eslint-plugin": "latest",
"eslint": "^7",
"eslint-config-airbnb-base": "latest",
"eslint-config-airbnb-typescript": "^12.3.1",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-import": "^2.22.1",
"jest": "^26.6.3",
"supertest": "^6.0.1",
"ts-jest": "^26.4.4",
"ts-node": "^9.1.1",
"ts-node-dev": "^1.1.1",
"typescript": "^4.1.3"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^7.1.2",
"cache-manager": "^3.4.0",
"cache-manager-ioredis": "^2.1.0",
"command-exists": "^1.2.9",
"cookie-parser": "^1.4.5",
"cors": "^2.8.5",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"express-fileupload": "^1.2.1",
"express-rate-limit": "^5.2.6",
"file-type": "^16.1.0",
"fluent-ffmpeg": "^2.1.2",
"morgan": "^1.10.0",
"nanoid": "^3.1.22",
"sharp": "^0.27.0",
"tmp": "^0.2.1",
"winston": "^3.3.3"
}
}

View File

@ -0,0 +1,60 @@
import express from 'express';
import cors from 'cors';
import morgan from 'morgan';
import cookieParser from 'cookie-parser';
import rateLimit from 'express-rate-limit';
import fileUpload from 'express-fileupload';
import thumbnailsRouter from './routes/thumbnails';
import imagesRouter from './routes/images';
import metaRouter from './routes/meta';
import loginRouter from './routes/login';
import userRouter from './routes/user';
import { getImage, registerFileInFolder } from './services/imageService';
import { requireAuth } from './utils/middlewares';
import { register } from './services/authService';
import logger from './utils/logger';
import * as config from './utils/config';
import { isNonEmptyString } from './utils/misc';
const app = express();
app.use(cors());
if (process.env.NODE_ENV === 'dev') {
app.use(morgan('dev'));
} else {
app.use(morgan('combined'));
}
const rateLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 10,
handler: (_req, res) => {
res.status(429).json({ status: 'ratelimit' });
},
onLimitReached: (req) => logger.warn(`${req.ip} hit rate limit`),
});
app.use(express.json());
app.use(cookieParser());
app.use(express.urlencoded({ extended: true }));
app.set('trust proxy', config.PROXY);
app.use(express.static('public'));
app.use('/api/images', requireAuth, fileUpload(), imagesRouter);
app.use('/api/thumbnails', requireAuth, thumbnailsRouter);
app.use('/api/meta', metaRouter);
app.use('/api/login', rateLimiter, loginRouter);
app.use('/api/user', requireAuth, userRouter);
app.get('/:id', async (req, res) => {
try {
const result = (await getImage(req.params.id)).imagebuffer;
res.end(result, 'binary');
} catch (err) {
res.redirect('/');
}
});
registerFileInFolder();
if (isNonEmptyString(config.USERNAME) && isNonEmptyString(config.PASSWORD)) {
logger.warn('Using credentials passend in the environment, sessions cleared');
register(config.USERNAME, config.PASSWORD);
}
export default app;

View File

@ -0,0 +1,126 @@
import { Request, Router } from 'express';
import fileType from 'file-type';
import sharp from 'sharp';
import { SortBy, SortOrder, ThumbnailMeta } from '../types';
import {
addImage,
getImages,
registerFileInFolder,
} from '../services/imageService';
import logger from '../utils/logger';
import { ACCEPTED_MIME } from '../utils/consts';
import { BASE_URL } from '../utils/config';
const imagesRouter = Router();
imagesRouter.post('/refresh', async (_, res) => {
await registerFileInFolder();
res.status(200).end();
});
class FrontEndImage {
url: string;
filename: string;
thumbnails: ThumbnailMeta[];
constructor(req: Request, filename: string) {
this.url = encodeURI(`${BASE_URL}/${filename}`);
this.filename = filename;
this.thumbnails = [
{
filetype: 'image/avif',
url: `${BASE_URL}/api/thumbnails/${filename}/avif`,
},
{
filetype: 'image/webp',
url: `${BASE_URL}/api/thumbnails/${filename}/webp`,
},
{
filetype: 'image/jpeg',
url: `${BASE_URL}/api/thumbnails/${filename}/jpeg`,
},
];
}
}
// Image listing
imagesRouter.get('/', async (req, res) => {
let sortBy = SortBy.Name;
switch (req.query.sortBy) {
case 'filename':
sortBy = SortBy.Name;
break;
case 'added':
sortBy = SortBy.Date;
break;
default:
break;
}
let sortOrder = SortOrder.Ascending;
switch (req.query.sortOrder) {
case 'ASC':
sortOrder = SortOrder.Ascending;
break;
case 'DESC':
sortOrder = SortOrder.Descending;
break;
default:
break;
}
let page = 0;
if (
typeof req.query.page === 'string'
&& !Number.isNaN(parseInt(req.query.page, 10))
&& parseInt(req.query.page, 10) > 0
) {
page = parseInt(req.query.page, 10);
}
logger.verbose(`Sort order:${sortOrder}`);
res.set('Cache-Control', 'no-store, max-age=0');
res.json(
(await getImages(sortBy, 10, sortOrder, page)).map(
(val) => new FrontEndImage(req, val),
),
);
});
// Image upload
imagesRouter.post('/', async (req, res) => {
if (req.files === undefined) {
res.status(400).json({ status: 'error', error: 'File missing.' });
} else if (Array.isArray(req.files.file)) {
res.status(400).json({ status: 'error', error: 'Send one file at a time.' });
} else {
let imageBuffer = req.files.file.data;
const imageName = req.files.file.name;
const imageType = await fileType.fromBuffer(imageBuffer);
if (imageType === undefined) {
res.status(400).json({ status: 'error', error: 'Malformed file.' });
return;
}
const { mime, ext } = imageType;
if (mime !== req.files.file.mimetype) {
res.status(400).json({ status: 'error', error: 'Malformed file.' });
return;
}
if (!ACCEPTED_MIME.includes(mime)) {
res.status(400).json({ status: 'error', error: 'Unsupported file type.' });
}
if (mime === 'image/jpeg') {
// Strip exif from jpeg files and compress them with q=80
imageBuffer = await sharp(req.files.file.data).jpeg().toBuffer();
}
try {
// hande images here
logger.verbose(`Got file ${imageName}`);
const serverFileName = await addImage(imageBuffer, ext);
res.json(new FrontEndImage(req, serverFileName));
} catch {
res.status(500).json({ status: 'error', error: 'Error saving uploaded image.' });
}
}
});
export default imagesRouter;

View File

@ -0,0 +1,64 @@
import { Router } from 'express';
import * as authService from '../services/authService';
import { isSetupFinished, isNonEmptyString } from '../utils/misc';
const loginRouter = Router();
loginRouter.post('/', async (req, res) => {
if (!isSetupFinished()) {
res.status(500).json({ status: 'error' });
return;
}
const { username, password } = req.body;
const result = await authService.login(username, password);
if (result !== null) {
res.cookie('personal-gallery_auth', result, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
expires: new Date(new Date().setFullYear(new Date().getFullYear() + 3)),
});
res.status(200).json({ status: 'success' });
} else {
res.status(401).send({ status: 'error' });
}
});
loginRouter.post('/register', async (req, res) => {
if (isSetupFinished()) {
res.status(403).json({ status: 'already_registered' });
return;
}
const { username, password } = req.body;
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
res.status(400).send({ status: 'error' });
return;
}
await authService.register(username, password);
const result = await authService.login(username, password);
if (result !== null) {
res.cookie('personal-gallery_auth', result, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production' && req.protocol === 'https',
sameSite: 'lax',
expires: new Date(new Date().setFullYear(new Date().getFullYear() + 3)),
});
res.status(200).json({ status: 'success' });
} else {
res.status(500).send({ status: 'error' });
}
});
loginRouter.post('/logout', async (req, res) => {
authService.logout(req.cookies['personal-gallery_auth']);
res.cookie('personal-gallery_auth', '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production' && req.protocol === 'https',
sameSite: 'lax',
expires: new Date(0),
});
res.set('Clear-Site-Data', '"cache", "cookies", "storage"');
res.status(200).json({ status: 'success' });
});
export default loginRouter;

View File

@ -0,0 +1,11 @@
import { Router } from 'express';
import { isSetupFinished } from '../utils/misc';
import { ACCEPTED_MIME } from '../utils/consts';
const meta = Router();
// Meta route should not expose any sensetive information, as it is used for initial app state.
meta.get('/', async (_, res) => {
res.json({ accepted: ACCEPTED_MIME, setupFinished: isSetupFinished() });
});
export default meta;

View File

@ -0,0 +1,40 @@
import { Router } from 'express';
import logger from '../utils/logger';
import { imageExists } from '../services/imageService';
import { thumbnailCache } from '../utils/cache';
import { getThumbnail } from '../services/thumbnailService';
const thumbnailsRouter = Router();
thumbnailsRouter.get('/:image/:type', async (req, res) => {
try {
if (!imageExists(req.params.image)) {
throw new Error('Not found.');
}
const result = (await thumbnailCache.wrap(
`${req.params.image}${req.params.type}`,
() => getThumbnail(req.params.image, req.params.type),
)) as any;
// cache-manager returns different types based on the backend,
// so we manually check how we should treat the result
if (result instanceof Buffer) {
res.set('Cache-Control', 'private, max-age=2592000');
res.end(result, 'binary');
} else if (result?.type === 'Buffer') {
// hack for handling object returned from redis
res.set('Cache-Control', 'private, max-age=2592000');
res.end(Buffer.from(result.data), 'binary');
} else {
throw new Error('Unknow object type');
}
} catch (err) {
logger.error(err);
res.status(404).json({ error: 'Not found.' });
}
});
thumbnailsRouter.get('/', async (req, res) => {
res.status(400).json({ error: 'No file specified.' });
});
export default thumbnailsRouter;

View File

@ -0,0 +1,39 @@
import { Router } from 'express';
import * as authService from '../services/authService';
import { isNonEmptyString } from '../utils/misc';
import { validatePassword } from '../utils/authorization';
const userRouter = Router();
userRouter.post('/getApiKey', async (req, res) => {
const token = await authService.generateToken();
res.json({ token });
});
userRouter.post('/updateCredentials', async (req, res) => {
const { username, password, oldPassword } = req.body;
if (
(typeof username !== 'undefined' && !isNonEmptyString(username))
|| (typeof password !== 'undefined' && !isNonEmptyString(password))
) {
res.status(400).send({ status: 'error' });
return;
}
if (
!isNonEmptyString(oldPassword)
|| !(await validatePassword(oldPassword))
) {
res.status(401).send({ status: 'error' });
return;
}
if (typeof username !== 'undefined') {
await authService.updateUsername(username);
}
if (typeof password !== 'undefined') {
await authService.updatePassword(password);
}
await authService.clearSessions();
res.json({ status: 'success' });
});
export default userRouter;

View File

@ -0,0 +1,10 @@
import http from 'http';
import app from './app';
import * as config from './utils/config';
import logger from './utils/logger';
const server = http.createServer(app);
server.listen(config.PORT, () => {
logger.info(`Server running on port ${config.PORT}`);
});

View File

@ -0,0 +1,71 @@
import crypto from 'crypto';
import bcrypt from 'bcryptjs';
import * as db from '../utils/db';
import { validateCredentials } from '../utils/authorization';
/**
* Checks for username and password validity,
* generates new token, and stores it in the session store
* @param {string} username Username
* @param {string} password Plaintext password
*/
export const login = async (username: string, password: string) => {
if (await validateCredentials(username, password)) {
const token = crypto.randomBytes(30).toString('hex');
db.insertSessionIntoDB(token);
return token;
}
return null;
};
/**
* Generates new api token, and stores it in the meta store
*/
export const generateToken = async () => {
const token = crypto.randomBytes(30).toString('hex');
db.setMeta('apiToken', token);
return token;
};
/**
* Clears sessions db and resets api token to random value
*/
export const clearSessions = async () => {
db.clearSessionsDB();
generateToken();
};
/**
* Removes token from session store
* @param {string} token login token to remove
*/
export const logout = async (token: string) => {
db.removeSessionFromDB(token);
};
/**
* Stores username and password hash in the meta store
* @param {string} username Username
* @param {string} password Plaintext password
*/
export const register = async (username: string, password: string) => {
clearSessions();
db.setMeta('username', username);
db.setMeta('password', await bcrypt.hash(password, 12));
};
/**
* Stores username in the meta store
* @param {string} username Username
*/
export const updateUsername = async (username: string) => {
db.setMeta('username', username);
};
/**
* Stores password hash in the meta store
* @param {string} password Plaintext password
*/
export const updatePassword = async (password: string) => {
db.setMeta('password', await bcrypt.hash(password, 12));
};

View File

@ -0,0 +1,123 @@
import fs from 'fs';
import path from 'path';
import fileType from 'file-type';
import { customAlphabet } from 'nanoid/async';
import { getImagesFromDB, insertImageIntoDB } from '../utils/db';
import logger from '../utils/logger';
import { IMAGE_DIR } from '../utils/config';
import { Image, SortBy, SortOrder } from '../types';
import { ACCEPTED_MIME } from '../utils/consts';
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const nanoid = customAlphabet(alphabet, 7);
/**
* Reads all files in the directory and tries to add them to the database.
*/
export const registerFileInFolder = async () => {
const files = fs.readdirSync(IMAGE_DIR);
const filesWithStats = await Promise.all(
files.map((filename) => fs.promises
.stat(path.join(IMAGE_DIR, filename))
.then((stat) => ({ filename, stat }))),
);
const filteredFilesWithStats:{
filename: string;
stat: fs.Stats;
}[] = [];
const filePromises = filesWithStats.map(async (file) => {
const result = await fileType.fromFile(
path.join(IMAGE_DIR, file.filename),
);
if (result !== undefined && ACCEPTED_MIME.includes(result.mime)) {
filteredFilesWithStats.push(file);
}
});
await Promise.all(filePromises);
let newCount = 0;
filteredFilesWithStats.forEach((file) => {
try {
insertImageIntoDB(file.filename, file.stat.mtime.getTime());
newCount += 1;
} catch (e) { logger.error(`${e.name}: ${e.message}`); }
});
logger.info(
`Found ${filesWithStats.length} files, ${filteredFilesWithStats.length} valid images, ${newCount} new`,
);
};
/**
* Saves image to disk and registers it into the database
* @param fileData Raw file data
* @param extension File extension
* @returns a promise with the new file name for the file
* @throws On file write error
*/
export const addImage = async (
fileData: Buffer,
extension: string,
): Promise<string> => {
const newFileName = `${await nanoid()}.${extension}`;
const newFilePath = path.join(IMAGE_DIR, newFileName);
logger.verbose(`Saving buffer into ${newFilePath}`);
fs.writeFileSync(newFilePath, fileData);
insertImageIntoDB(newFileName);
return newFileName;
};
/**
* Gets images from the database
* @param sortBy Attribute by which images will be sorted
* @param count Image count on one page
* @param sortOrder Images sort direction
* @param page Page number
* @returns a promise with a list of images
*/
export const getImages = async (
sortBy: SortBy = SortBy.Name,
count: number = 10,
sortOrder: SortOrder = SortOrder.Descending,
page?: number,
): Promise<string[]> => getImagesFromDB(sortBy, sortOrder, count, page).map(
(entry) => entry.filename,
);
/**
* Get an image from the disk
* @param filename image filename
* @returns a promise with an Image object
* @throws if no image was found
*/
export const getImage = async (filename: string): Promise<Image> => {
try {
logger.verbose(`Looking for file ${filename}...`);
const file = fs.readFileSync(path.join(IMAGE_DIR, filename));
logger.verbose('Found.');
const type = await fileType.fromBuffer(file);
if (type === undefined) {
throw new Error('Error processing file.');
}
return { filename, imagebuffer: file, filetype: type.mime };
} catch (err) {
logger.verbose('Not found.');
throw new Error(`File not found. \n${err}`);
}
};
/**
* Check if image exists on disk
* @param filename image filename
* @returns a promise with boolean, which is true if image exists, false otherwise
*/
export const imageExists = async (filename: string): Promise<boolean> => {
try {
if (fs.existsSync(path.join(IMAGE_DIR, filename))) {
return true;
}
return false;
} catch (err) {
return false;
}
};

View File

@ -0,0 +1,100 @@
/* eslint-disable import/prefer-default-export */
import sharp from 'sharp';
import ffmpeg from 'fluent-ffmpeg';
import tmp from 'tmp';
import fs from 'fs';
import util from 'util';
import path from 'path';
import { FFMPEG_EXISTS, IMAGE_DIR } from '../utils/config';
import { getImage } from './imageService';
import logger from '../utils/logger';
/**
* Promisified fluent-ffmpeg screenshots call
* @param filepath path to the source file
* @param folder path to the output folder
* @returns a promise with an array of created filenames
*/
const ffmpegScreenshotPromise = (
filepath: string,
folder: string,
): Promise<string[]> => new Promise((resolve) => {
const ffmpegCommand = ffmpeg(`${filepath}`);
let resultArray: string[] = [];
ffmpegCommand
.screenshots({
count: 1,
filename: 'temp.png',
folder,
})
.on('end', () => {
resolve(resultArray);
})
.on('error', () => {
throw new Error('FFmpeg error.');
})
.on('filenames', (arr) => {
resultArray = arr;
});
});
/**
* Asyncronously generate thumbnail for file with given dimensions
* @param filename Input file name
* @param thumbnailFormat Thumbnail format
* @param width Thumbnail width
* @param height Thumbnail height
* @return a promise with the thumbnail buffer
* @throws If called on video file with no ffmpeg available
*/
export const getThumbnail = async (
filename: string,
thumbnailFormat: string,
width: number = 210,
height: number = 160,
): Promise<Buffer> => {
const image = await getImage(filename);
const readFilePromise = util.promisify(fs.readFile);
const unlinkFilePromise = util.promisify(fs.unlink);
let { imagebuffer } = image;
logger.verbose('Thumbnail cache miss.');
// Take a screenshot from videos
if (image.filetype.startsWith('video')) {
if (!FFMPEG_EXISTS) {
throw new Error(
'Attempted to create thumbnail for video without ffmpeg installed.',
);
}
const tmpDir = tmp.dirSync();
try {
await ffmpegScreenshotPromise(
path.join(IMAGE_DIR, filename),
tmpDir.name,
);
imagebuffer = await readFilePromise(`${tmpDir.name}/temp.png`);
await unlinkFilePromise(`${tmpDir.name}/temp.png`);
} catch (e) {
logger.error(`${e.name}:${e.message}`);
}
tmpDir.removeCallback();
}
// Convert the buffer into requested format
switch (thumbnailFormat) {
case 'jpeg':
return sharp(imagebuffer)
.resize(width, height, { fit: 'outside' })
.jpeg({ quality: 60 })
.toBuffer();
case 'webp':
return sharp(imagebuffer)
.resize(width, height, { fit: 'outside' })
.webp()
.toBuffer();
case 'avif':
return sharp(imagebuffer)
.resize(width, height, { fit: 'outside' })
.avif()
.toBuffer();
default:
throw new Error(`Unknown filetype ${thumbnailFormat}.`);
}
};

View File

@ -0,0 +1,31 @@
export interface Image {
filename: string;
imagebuffer: Buffer;
filetype: string;
}
export interface ImageDbEntry {
id: number;
filename: string;
added: number;
}
export interface ThumbnailMeta {
filetype: string;
url: string;
}
export enum SortBy {
Name = 'filename',
Date = 'added',
}
export enum SortOrder {
Ascending = 'ASC',
Descending = 'DESC',
}
export interface Config {
sortBy: SortBy;
sortOrder: SortOrder;
}

View File

@ -0,0 +1,29 @@
import bcrypt from 'bcryptjs';
import * as db from './db';
import logger from './logger';
/**
* Validates passed credentials
* @param username Username
* @param password Plaintext password
* @returns true if credentials are valid, false if they are not
*/
export const validateCredentials = async (
username: string,
password: string,
) => {
try {
return (await bcrypt.compare(password, db.getMeta('password')))
&& username === db.getMeta('username');
} catch (e) {
logger.error(`validateCredentials: ${e.message}`);
return false;
}
};
/**
* Validates passed password
* @param password Plaintext password
* @returns true if password is valid, false if it is not
*/
export const validatePassword = async (password: string) => bcrypt.compare(password, db.getMeta('password'));

View File

@ -0,0 +1,22 @@
import cacheManager from 'cache-manager';
import redisStore from 'cache-manager-ioredis';
import { REDIS_HOST, REDIS_PORT, REDIS_PASSWORD } from './config';
import logger from './logger';
// eslint-disable-next-line import/prefer-default-export
export const thumbnailCache = typeof REDIS_HOST === 'undefined'
? cacheManager.caching({
store: 'memory',
max: 300,
ttl: 604800,
})
: cacheManager.caching({
store: redisStore,
host: REDIS_HOST,
port: REDIS_PORT,
password: REDIS_PASSWORD,
db: 0,
ttl: 600,
});
logger.info(`Using cache store '${(thumbnailCache.store as any).name}'`);

View File

@ -0,0 +1,19 @@
import * as dotenv from 'dotenv';
import { sync as commandExists } from 'command-exists';
import path from 'path';
dotenv.config();
export const PORT: number = parseInt(process.env.PORT || '3001', 10);
export const IMAGE_DIR: string = (process.env.NODE_ENV === 'test')
? path.join(process.cwd(), 'test', 'i')
: process.env.IMAGE_DIR || path.join(process.cwd(), 'tmp');
export const DB_DIR: string = process.env.DB_DIR || path.join(process.cwd(), 'tmp');
export const FFMPEG_EXISTS: boolean = commandExists('ffmpeg');
export const BASE_URL: string = process.env.BASE_URL || '';
export const { REDIS_HOST } = process.env;
export const REDIS_PORT = parseInt(process.env.REDIS_PORT || '6379', 10);
export const { REDIS_PASSWORD } = process.env;
export const PROXY = process.env.PROXY || true;
export const { USERNAME } = process.env;
export const { PASSWORD } = process.env;

View File

@ -0,0 +1,15 @@
import { FFMPEG_EXISTS } from './config';
export const VIDEO_MIME = ['video/mp4', 'video/webm'];
export const IMAGE_MIME = [
'image/png',
'image/webp',
'image/gif',
'image/avif',
'image/jpeg',
];
export const ACCEPTED_MIME = FFMPEG_EXISTS
? IMAGE_MIME.concat(VIDEO_MIME)
: IMAGE_MIME;

View File

@ -0,0 +1,124 @@
import sqlite3 from 'better-sqlite3';
import path from 'path';
import { DB_DIR } from './config';
import { ImageDbEntry } from '../types';
import logger from './logger';
export const db = sqlite3(
process.env.NODE_ENV === 'test' ? ':memory:' : path.join(DB_DIR, 'app.db'),
);
logger.info(`Using db ${db.name}`);
process.on('exit', () => db.close());
process.on('SIGHUP', () => process.exit(128 + 1));
process.on('SIGINT', () => process.exit(128 + 2));
process.on('SIGTERM', () => process.exit(128 + 15));
db.prepare(
'CREATE TABLE IF NOT EXISTS images (id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT UNIQUE, added INTEGER)',
).run();
db.prepare(
'CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY AUTOINCREMENT, session TEXT)',
).run();
db.prepare(
'CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)',
).run();
/**
* Inserts provided image data into the database
* @param filename Name of the image file
* @param addedTimestamp Image upload timestamp
* @throws Will throw if filename already exists in the database
*/
export const insertImageIntoDB = (
filename: string,
addedTimestamp: number = Date.now(),
) => {
const result = db
.prepare('SELECT EXISTS(SELECT filename FROM images WHERE filename=?)')
.get(filename);
if (result['EXISTS(SELECT session FROM sessions WHERE session=?)'] === 1) {
throw new Error('file already exists');
}
const stmt = db.prepare('INSERT INTO images (filename,added) VALUES (?,?)');
stmt.run(filename, addedTimestamp);
};
/**
* Gets a list of images from the database according to the parameters
* @param order Attribute by which images will be sorted
* @param orderDirection Images sort direction
* @param limit Image count on one page
* @param page Page number
* @returns Array of images according to the requested parameters
*/
export const getImagesFromDB = (
order: 'added' | 'filename',
orderDirection: 'ASC' | 'DESC',
limit: number = 10,
page: number = 0,
): ImageDbEntry[] => {
logger.verbose(`${order}, ${orderDirection}, ${limit}, ${page}`);
const stmt = db.prepare(
`SELECT * FROM images ORDER BY ${order} ${orderDirection} LIMIT ? OFFSET ?`,
);
return stmt.all(limit, page * limit);
};
/**
* Inserts session into the sessions table
* @param session Session to insert
*/
export const insertSessionIntoDB = (session: string) => {
db.prepare('INSERT INTO sessions (session) VALUES (?)').run(session);
};
/**
* Removes a single session from the sessions table
* @param session Session to remove
*/
export const removeSessionFromDB = (session: string) => {
db.prepare('DELETE FROM sessions WHERE session = (?)').run(session);
};
/**
* Checks if session key exist in the sessions table
* @param session Session to check
* @returns true if exists, false otherwise
*/
export const sessionExists = (session: string) => {
const result = db
.prepare('SELECT EXISTS(SELECT session FROM sessions WHERE session=?)')
.get(session);
if (result['EXISTS(SELECT session FROM sessions WHERE session=?)'] === 1) {
return true;
}
return false;
};
/**
* Removes all sessions from the sessions table
*/
export const clearSessionsDB = () => {
db.prepare('DELETE FROM sessions').run();
};
// Meta - key value store backed by a sqlite table
/**
* Sets a meta value
*/
export const setMeta = (key: string, value: string) => {
db.prepare('REPLACE INTO meta (key, value) values (?,?)').run(key, value);
};
/**
* Gets a meta value
* @returns Value assigned to key, undefined otherwise
*/
export const getMeta = (key: string) => db.prepare('SELECT value FROM meta WHERE key = ?').get(key)?.value;
/**
* Deletes a meta key value pair
*/
export const deleteMeta = (key: string) => {
db.prepare('DELETE FROM meta WHERE key = ?').run(key);
};

View File

@ -0,0 +1,20 @@
import winston from 'winston';
const logger = winston.createLogger();
if (process.env.NODE_ENV !== 'production') {
logger.add(
new winston.transports.Console({
format: winston.format.cli(),
level: 'silly',
}),
);
} else {
logger.add(
new winston.transports.Console({
format: winston.format.cli(),
}),
);
}
export default logger;

View File

@ -0,0 +1,33 @@
import express from 'express';
import { getMeta, sessionExists } from './db';
export const requireAuth = (
req: express.Request,
res: express.Response,
next: express.NextFunction,
) => {
if (
req.headers.authorization
&& req.headers.authorization.split(' ')[0] === 'Bearer'
) {
if (req.headers.authorization.split(' ')[1] === getMeta('apiToken')) {
next();
return;
}
}
const token = req.cookies['personal-gallery_auth'];
if (sessionExists(token)) {
next();
} else {
res.status(401).json({ error: 'Unauthorized' });
}
};
export const globalHeaders = (
_req: express.Request,
res: express.Response,
next: express.NextFunction,
) => {
res.set('Referrer-Policy', 'no-referrer');
next();
};

View File

@ -0,0 +1,5 @@
import * as database from './db';
export const isNonEmptyString = (input: any): input is string => typeof input === 'string' && input.length > 0;
export const isSetupFinished = () => typeof database.getMeta('username') !== 'undefined';

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,194 @@
/* eslint-disable arrow-body-style */
import supertest from 'supertest';
import fs from 'fs';
import path from 'path';
import fileType from 'file-type';
import app from '../src/app';
import { register } from '../src/services/authService';
import * as database from '../src/utils/db';
const API_URL = '/api';
const clearTempDir = () => {
const directory = path.join(process.cwd(), 'test', 'i');
fs.readdir(directory, (err, files) => {
if (err) throw err;
files.forEach((file) => {
fs.unlink(path.join(directory, file), (e) => {
if (e) throw e;
});
});
});
};
const getFileFromTempDir = (fullPath = false) => {
const directory = path.join(process.cwd(), 'test', 'i');
const files = fs.readdirSync(directory);
return fullPath ? path.join(directory, files[0]) : files[0];
};
beforeAll(async () => {
clearTempDir();
database.db.exec('DELETE FROM sessions');
database.db.exec('DELETE FROM meta');
database.db.exec('DELETE FROM images');
await register('testing', 'testing');
});
describe('Logged in', () => {
const api = supertest.agent(app);
beforeAll(async () => {
await api
.post(`${API_URL}/login`)
.send({ username: 'testing', password: 'testing' });
});
describe('Images', () => {
describe('Upload', () => {
test('200 valid image', async () => {
return api
.post(`${API_URL}/images`)
.attach('file', `${__dirname}/good.jpg`)
.expect(200);
});
test('400 invalid image', async () => {
return api
.post(`${API_URL}/images`)
.attach('file', `${__dirname}/bad.jpg`)
.expect(400);
});
test('400 non image', async () => {
return api
.post(`${API_URL}/images`)
.attach('file', `${__dirname}/test.txt`)
.expect(400);
});
test('400 image with .txt extension', async () => {
return api
.post(`${API_URL}/images`)
.attach('file', `${__dirname}/good.txt`)
.expect(400);
});
});
describe('Get', () => {
let imageUrl = '';
let imageName = '';
test('Image list', (done) => {
api
.get(`${API_URL}/images`)
.expect(200)
.end((err, res) => {
if (typeof err !== 'undefined') {
done(err);
}
try {
expect(res.body.length).toBe(1);
imageUrl = res.body[0].url;
imageName = res.body[0].filename;
done();
} catch (e) {
done(e);
}
});
});
test('200 specific image', (done) => {
api
.get(imageUrl)
.expect(200)
.end(async (err, res) => {
if (typeof err !== 'undefined') {
done(err);
}
try {
expect(await fileType.fromBuffer(Buffer.from(res.text))).toBe('image/jpeg');
done();
} catch (e) {
done(e);
}
});
});
test('200 specific image thumbnail', (done) => {
api
.get(`${API_URL}/thumbnails/${imageName}/webp`)
.expect(200)
.end(async (err, res) => {
if (typeof err !== 'undefined') {
done(err);
}
try {
expect(await fileType.fromBuffer(Buffer.from(res.text))).toBe('image/webp');
done();
} catch (e) {
done(e);
}
});
});
});
});
});
describe('Not logged in', () => {
const api = supertest.agent(app);
describe('Images', () => {
describe('Upload', () => {
test('401 valid image', async () => {
return api
.post(`${API_URL}/images`)
.attach('file', `${__dirname}/good.jpg`)
.expect(401);
});
test('401 invalid image', async () => {
return api
.post(`${API_URL}/images`)
.attach('file', `${__dirname}/bad.jpg`)
.expect(401);
});
test('401 non image', async () => {
return api
.post(`${API_URL}/images`)
.attach('file', `${__dirname}/test.txt`)
.expect(401);
});
test('401 image with .txt extension', async () => {
return api
.post(`${API_URL}/images`)
.attach('file', `${__dirname}/good.txt`)
.expect(401);
});
});
describe('Get', () => {
test('401 Image list', () => {
return api
.get(`${API_URL}/images`)
.expect(401);
});
test('200 Specific image', (done) => {
api
.get(`/${getFileFromTempDir()}`)
.expect(200)
.end(async (err, res) => {
if (typeof err !== 'undefined') {
done(err);
}
try {
expect(await fileType.fromBuffer(Buffer.from(res.text))).toBe('image/jpeg');
done();
} catch (e) {
done(e);
}
});
});
test('401 Specific image thumbnail', () => {
return api
.get(`${API_URL}/thumbnails/${getFileFromTempDir()}/webp`)
.expect(401);
});
});
});
});
afterAll(() => {
clearTempDir();
database.db.exec('DELETE FROM sessions');
database.db.exec('DELETE FROM meta');
database.db.exec('DELETE FROM images');
});

View File

@ -0,0 +1,99 @@
/* eslint-disable arrow-body-style */
import supertest from 'supertest';
import app from '../src/app';
import * as database from '../src/utils/db';
const api = supertest(app);
const API_URL = '/api';
describe('Login API', () => {
describe('Registration', () => {
beforeAll(() => {
database.db.exec('DELETE FROM sessions');
database.db.exec('DELETE FROM meta');
database.db.exec('DELETE FROM images');
});
test('meta status correct', (done) => {
api
.get(`${API_URL}/meta`)
.expect(200)
.end((err, res) => {
if (typeof err !== 'undefined') {
done(err);
}
try {
expect(res.body.setupFinished).toBe(false);
done();
} catch (error) {
done(error);
}
});
});
test('400 on registering with empty username', () => {
return api
.post(`${API_URL}/login/register`)
.send({ username: '', password: 'test' })
.expect(400);
});
test('200 on registering with good credentials', (done) => {
api
.post(`${API_URL}/login/register`)
.send({ username: 'test', password: 'test' })
.expect(200)
.end((err) => {
if (typeof err !== 'undefined') {
done(err);
}
try {
expect(database.getMeta('username')).toBe('test');
done();
} catch (e) {
done(e);
}
});
});
test('403 on trying to register second time with good credentials', (done) => {
api
.post(`${API_URL}/login/register`)
.send({ username: 'test', password: 'test' })
.expect(403)
.end((err) => {
if (typeof err !== 'undefined') {
done(err);
}
try {
expect(database.getMeta('username')).toBe('test');
done();
} catch (e) {
done(e);
}
});
});
});
describe('Authorization', () => {
test('401 on login with incorrect username', () => {
return api
.post(`${API_URL}/login`)
.send({ username: 'wrong', password: 'test' })
.expect(401);
});
test('401 on login with incorrect password', () => {
return api
.post(`${API_URL}/login`)
.send({ username: 'test', password: 'wrong' })
.expect(401);
});
test('401 on login with incorrect credentials', () => {
return api
.post(`${API_URL}/login`)
.send({ username: 'wrong', password: 'wrong' })
.expect(401);
});
test('200 on login with correct credentials', () => {
return api
.post(`${API_URL}/login`)
.send({ username: 'test', password: 'test' })
.expect(200);
});
});
});

View File

View File

@ -0,0 +1,70 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
"types": ["jest", "node"],
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es2015", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./dist/", /* Redirect output structure to the directory. */
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
"skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
}
}

File diff suppressed because one or more lines are too long

23
packages/web/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

46
packages/web/README.md Normal file
View File

@ -0,0 +1,46 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `yarn start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `yarn test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `yarn build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `yarn eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

85
packages/web/package.json Normal file
View File

@ -0,0 +1,85 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"dependencies": {
"@fontsource/roboto": "^4.2.1",
"@material-ui/core": "^4.11.3",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/jest": "^26.0.15",
"@types/node": "^12.0.0",
"@types/react": "^16.9.53",
"@types/react-dom": "^16.9.8",
"axios": "^0.21.1",
"i18next": "^19.9.1",
"i18next-browser-languagedetector": "^6.0.1",
"i18next-http-backend": "^1.1.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-dropzone": "^11.3.0",
"react-i18next": "^11.8.9",
"react-infinite-scroller": "^1.2.4",
"react-scripts": "~4.0.1",
"typescript": "^4.0.3",
"web-vitals": "^0.2.4",
"workbox-core": "^6.1.2",
"workbox-expiration": "^6.1.2",
"workbox-precaching": "^6.1.2",
"workbox-routing": "^6.1.2",
"workbox-strategies": "^6.1.2"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"proxy": "http://localhost:3002",
"eslintConfig": {
"extends": [
"airbnb-typescript"
],
"parserOptions": {
"project": "./tsconfig.json",
"includes": [
"./packages/web/**/*.{js,jsx,ts,tsx}"
]
},
"rules": {
"react/require-default-props": "off",
"no-warning-comments": [
"warn",
{
"terms": [
"todo",
"fixme"
],
"location": "anywhere"
}
]
}
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/react-infinite-scroller": "^1.2.1",
"@typescript-eslint/eslint-plugin": "^4.4.1",
"eslint-config-airbnb-typescript": "^12.3.1",
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-jsx-a11y": "^6.3.1",
"eslint-plugin-react": "^7.20.3",
"eslint-plugin-react-hooks": "^4.0.8"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Personal Gallery"
/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Gallery</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@ -0,0 +1,42 @@
{
"Pictures": "Pictures",
"Upload your first image": "Upload your first image",
"Settings": "Settings",
"Sort By": "Sort By",
"Name": "Name",
"Upload date": "Upload date",
"Sort Direction": "Sort Direction",
"Ascending": "Ascending",
"Descending": "Descending",
"Change username/password": "Change username/password",
"Get API key": "Get API key",
"You must first get a new API key": "You must first get a new API key",
"Get ShareX config": "Get ShareX config",
"Save": "Save",
"Cancel": "Cancel",
"Confirm": "Confirm",
"Update user data": "Update user data",
"enter_new_username": "Enter either new username, password, or both.",
"Username": "Username",
"Password": "Password",
"Old password": "Old password",
"Copied link to clipboard": "Copied link to clipboard",
"Login": "Login",
"Register": "Register",
"Logout": "Logout",
"Upload": "Upload",
"Drag and drop some files here, or click to select files": "Drag and drop some files here, or click to select files",
"Authorization error, please login": "Authorization error, please login",
"Error getting image list from server": "Error getting image list from server",
"Error uploading image": "Error uploading image",
"Settings changed": "Settings changed",
"Icorrect username or password": "Icorrect username or password",
"Too many attempts, try again later": "Too many attempts, try again later",
"Unknown error occured, try again later": "Unknown error occured, try again later",
"API token copied to clipboard": "API token copied to clipboard",
"Please login with your new credentials": "Please login with your new credentials",
"Check your old password and try again": "Check your old password and try again",
"At least one field must be filled": "At least one field must be filled",
"Get new API key?": "Get new API key?",
"This will invalidate your previous API key, continue?": "This will invalidate your previous API key, continue?"
}

View File

@ -0,0 +1,42 @@
{
"Pictures": "Изорбражения",
"Upload your first image": "Загрузите ваш первый файл",
"Settings": "Настройки",
"Sort By": "Сортировка",
"Name": "Имя",
"Upload date": "Дата загрузки",
"Sort Direction": "Направление сортировки",
"Ascending": "Прямое",
"Descending": "Обратное",
"Change username/password": "Сменить имя пользователя/пароль",
"Get API key": "Получить новый API ключ",
"You must first get a new API key": "Необходимо сначала получить новый API ключ",
"Get ShareX config": "Скачать конфигурацию для ShareX",
"Save": "Сохранить",
"Cancel": "Отменить",
"Confirm": "Подтвердить",
"Update user data": "Обновить данные пользователя",
"enter_new_username": "Введите новое имя пользователя и/или пароль",
"Username": "Имя пользователя",
"Password": "Пароль",
"Old password": "Старый пароль",
"Copied link to clipboard": "Ссылка скопирована",
"Login": "Войти",
"Register": "Зарегистрироваться",
"Logout": "Выйти",
"Upload": "Загрузить",
"Drag and drop some files here, or click to select files": "Перетащите сюда файлы, или нажмите чтобы выбрать файлы.",
"Authorization error, please login": "Ошибка авторизации, пожалуйста войдите снова",
"Error getting image list from server": "Ошибка при получении списка изображений с сервера",
"Error uploading image": "Ошибка загрузки файла",
"Settings changed": "Настройки сохранены",
"Icorrect username or password": "Неверное имя пользователя или пароль",
"Too many attempts, try again later": "Слишком много попыток входа, попробуйте позже",
"Unknown error occured, try again later": "Произошла неизвестная ошибка, попробуйте позже",
"API token copied to clipboard": "API ключ скопирован",
"Please login with your new credentials": "Пожалуйста войдите с новыми данными",
"Check your old password and try again": "Проверьте ваш старый пароль и попробуйте снова",
"At least one field must be filled": "Как минимум одно поле должно быть заполнено",
"Get new API key?": "Получить новый API ключ?",
"This will invalidate your previous API key, continue?": "Ваш предыдущий API ключ станет недействительным, продолжить?"
}

View File

@ -0,0 +1,45 @@
{
"$schema": "https://json.schemastore.org/web-manifest.json",
"short_name": "Gallery",
"name": "Personal Gallery App",
"icons": [
{
"src": "favicon.ico",
"sizes": "48x48",
"type": "image/x-icon"
},
{
"src": "android-chrome-192x192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "android-chrome-512x512.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "maskable_icon.png",
"sizes": "1024x1024",
"type": "image/png",
"purpose": "any maskable"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff",
"share_target": {
"action": "/api/images",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"files": [
{
"name": "file",
"accept": ["image/png","image/webp","image/gif","image/avif","image/jpeg"]
}
]
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@ -0,0 +1,9 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

429
packages/web/src/App.tsx Normal file
View File

@ -0,0 +1,429 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Container,
makeStyles,
Theme,
createStyles,
withWidth,
WithWidth,
Dialog,
CircularProgress,
IconButton,
Snackbar,
Grid,
Typography,
} from '@material-ui/core';
import InfiniteScroll from 'react-infinite-scroller';
import { AxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import * as imageService from './services/images';
import * as settingsService from './services/settings';
import * as loginService from './services/login';
import * as metaService from './services/meta';
import * as userService from './services/user';
import ImageGridListTile from './components/ImageGridList';
import {
Config, Image, SortBy, SortOrder,
} from './types';
import PicturesAppBar from './components/PicturesAppBar';
import UploadDialog from './components/UploadDialog';
import ConfigurationDialog from './components/ConfiguarionDialog';
import LoginView from './components/LoginView';
import ConfirmationDialog from './components/ConfirmationDialog';
import CredentialChangeDialog from './components/CredentialChangeDialog';
const useStyles = makeStyles((theme: Theme) => createStyles({
root: {
height: '100vh',
},
titleBar: {
background:
'linear-gradient(to bottom, rgba(0,0,0,0) 0%, '
+ 'rgba(0,0,0,0) 70%, rgba(0,0,0,0) 100%)',
transition: 'background 2s ease-out',
'&:hover': {
background:
'linear-gradient(to bottom, rgba(0,0,0,0) 0%, '
+ 'rgba(0,0,0,0) 70%, rgba(0,0,0,0) 100%)',
},
},
icon: {
color: 'white',
filter: 'drop-shadow(2px 4px 3px #222222)',
},
listItem: {
cursor: 'pointer',
'&:hover': {
opacity: '0.9',
},
},
dialogImage: {
maxHeight: '80vh',
},
loader: {
margin: '1rem',
},
toolbarTitle: {
flexGrow: 1,
},
toolbarButton: {
flexGrow: 1,
},
placeholderText: {
textAlign: 'center',
margin: theme.spacing(1),
color: '#696969',
},
placeholderIconContainer: {
textAlign: 'center',
color: '#696969',
},
placeholderIcon: {
fontSize: '96px',
verticalAlign: '-25%',
},
}));
function App(props: WithWidth) {
const [modalImage, setModalImage] = useState('');
const [modalVideo, setModalVideo] = useState('');
const [imagesData, setImagesData] = useState<any>(undefined);
const imagesPage = useRef(0);
const [hasMore, setHasMore] = useState(true);
const [userSettings, setUserSettings] = useState<Config | undefined>(
undefined,
);
const [dragOpen, setDragOpen] = useState(false);
const [configurationDialogOpen, setConfigurationDialogOpen] = useState(false);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [credentialChangeDialogOpen, setCredentialChangeDialogOpen] = useState(
false,
);
const [notification, setNotification] = useState('');
const [userLoggedIn, setUserLoggedIn] = useState<boolean | undefined>(
undefined,
);
const [acceptedUploadFiletypes, setAcceptedUploadFiletypes] = useState([
'image/webp',
'image/avif',
'image/gif',
'image/png',
'image/jpeg',
'image/bmp',
]);
const [setupFinished, setSetupFinished] = useState(true);
const [apiKey, setApiKey] = useState<string | undefined>();
const { width } = props;
const { t } = useTranslation();
const widthMap = {
xs: 3, sm: 4, md: 5, lg: 6, xl: 6,
};
const cols = widthMap[width];
const classes = useStyles();
useEffect(() => {
// imageService.getAll().then(result => setImagesData(result));
metaService.getMeta().then((result) => {
setAcceptedUploadFiletypes(result.accepted);
setSetupFinished(result.setupFinished);
setUserSettings(settingsService.getSettings());
setUserLoggedIn(settingsService.getUserState());
});
}, []);
const imageTileClickHandler = (url: string) => {
if (/\.(mp4|webm)$/.test(url)) {
setModalVideo(url);
} else {
setModalImage(url);
}
};
const handleLogout = async (clientOnly: boolean = false) => {
if (!clientOnly) {
loginService.doLogout();
}
setUserLoggedIn(false);
setDragOpen(false);
setConfirmDialogOpen(false);
setConfigurationDialogOpen(false);
setCredentialChangeDialogOpen(false);
localStorage.clear();
sessionStorage.clear();
settingsService.setUserState(false);
};
const onDataNext = () => {
const page = imagesPage.current;
imageService
.getPage(
page,
userSettings?.sortBy,
userSettings?.sortOrder,
)
.then((result) => {
if (result.length === 0) {
setHasMore(false);
} else {
setImagesData((data: any) => ({ ...data, [page]: result }));
}
})
.catch((e: AxiosError) => {
if (e.response?.status === 401) {
handleLogout(true);
setNotification(t('Authorization error, please login'));
} else {
setNotification(t('Error getting image list from server'));
}
imagesPage.current -= 1;
});
imagesPage.current += 1;
};
const refreshData = () => {
imagesPage.current = 0;
setHasMore(true);
setImagesData({});
};
const handleUpload = async (images: File[]) => {
setDragOpen(false);
const promises = images.map(async (image) => imageService.uploadImage(image));
try {
const combinedResult = await Promise.all(promises);
if (combinedResult.length > 0) {
setImagesData((data: { [key:number]: Image[] }) => {
if (data === undefined || Object.keys(data).length === 0) {
return { [-1]: combinedResult };
}
if (data[-1] === undefined || Object.keys(data[-1]).length === 0) {
return { ...data, [-1]: [...combinedResult] };
}
return { ...data, [-1]: [...combinedResult, ...data[-1]] };
});
}
} catch (e) {
setNotification(t('Error uploading image'));
// refreshData();
}
};
const handlePaste = (e: React.ClipboardEvent) => {
console.log(e.clipboardData);
if (e.clipboardData.items.length !== 0) {
Array.from(e.clipboardData.items).forEach((item) => {
console.log(item);
if (acceptedUploadFiletypes.includes(item.type)) {
const pasteAsFile = item.getAsFile();
if (pasteAsFile !== null) {
handleUpload([pasteAsFile]);
}
}
});
}
};
const handleSettingsChange = (sortBy: SortBy, sortOrder: SortOrder) => {
setUserSettings({ ...userSettings, sortBy, sortOrder });
setConfigurationDialogOpen(false);
settingsService.saveSettings({ ...userSettings, sortBy, sortOrder });
setNotification(t('Settings changed'));
refreshData();
};
const handleLogin = async (username: string, password: string) => {
const result = setupFinished
? await loginService.doLogin(username, password)
: await loginService.doRegister(username, password);
switch (result) {
case 200:
setUserLoggedIn(true);
settingsService.setUserState(true);
if (!setupFinished) {
setSetupFinished(true);
}
break;
case 401:
setNotification(t('Icorrect username or password'));
break;
case 429:
setNotification(t('Too many attempts, try again later'));
break;
default:
setNotification(t('Unknown error occured, try again later'));
break;
}
};
const handleApiKeyChange = async () => {
try {
const key = await userService.getApiKey();
await navigator.clipboard.writeText(key);
setNotification(t('API token copied to clipboard'));
setApiKey(key);
console.log(key);
} catch (e) {
console.log(e);
}
};
const handleCredentialsChange = async (
oldPassword: string,
username: string,
password: string,
) => {
try {
await userService.updateCredentials(oldPassword, username, password);
handleLogout();
setNotification(t('Please login with your new credentials'));
} catch (e) {
if (e.response.status === 401) {
setNotification(t('Check your old password and try again'));
}
}
};
console.log('current settings:', userSettings);
if (userSettings === undefined || userLoggedIn === undefined) {
return (
<Grid container justify="center">
<CircularProgress className={classes.loader} />
</Grid>
);
}
console.log('Images data:', imagesData);
return (
<div
className={classes.root}
onDragEnter={() => setDragOpen(true)}
onPaste={handlePaste}
>
{userLoggedIn ? (
<>
<PicturesAppBar
onUploadClick={() => setDragOpen(true)}
onSettingsClick={() => setConfigurationDialogOpen(true)}
onLogoutClick={handleLogout}
/>
<Container>
<InfiniteScroll
loadMore={onDataNext}
pageStart={-1}
hasMore={hasMore}
loader={(
<Grid container justify="center">
<CircularProgress className={classes.loader} />
</Grid>
)}
>
{typeof imagesData === 'object' && Object.keys(imagesData).length !== 0 ? (
<ImageGridListTile
images={[...new Set(Object.keys(imagesData).sort().reduce(
(r, k) => (r.concat(imagesData[k])),
[],
))]}
cols={cols}
onTileClick={imageTileClickHandler}
onNotification={setNotification}
/>
) : (
<>
<div className={classes.placeholderIconContainer}>
<span className={`material-icons-outlined ${classes.placeholderIcon}`}>
insert_photo
</span>
</div>
<Typography className={classes.placeholderText}>
{t('Upload your first image')}
</Typography>
</>
)}
</InfiniteScroll>
</Container>
</>
) : (
<LoginView onLogin={handleLogin} setupFinished={setupFinished} />
)}
<Dialog
open={modalImage !== '' || modalVideo !== ''}
onClose={() => {
setModalImage('');
setModalVideo('');
}}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
maxWidth="lg"
>
{modalImage !== '' ? (
<img className={classes.dialogImage} src={modalImage} alt="" />
) : null}
{modalVideo !== '' ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
className={classes.dialogImage}
autoPlay
controls
src={modalVideo}
/>
) : null}
</Dialog>
<UploadDialog
isOpen={dragOpen}
onClose={() => setDragOpen(false)}
onDrop={handleUpload}
accept={acceptedUploadFiletypes}
/>
<ConfigurationDialog
open={configurationDialogOpen}
onDialogClose={() => setConfigurationDialogOpen(false)}
currentSettings={userSettings}
onSave={handleSettingsChange}
onApiKeyChange={() => setConfirmDialogOpen(true)}
apiKey={apiKey}
onCredentialsChange={() => setCredentialChangeDialogOpen(true)}
/>
<ConfirmationDialog
header={t('Get new API key?')}
content={t('This will invalidate your previous API key, continue?')}
open={confirmDialogOpen}
onConfirm={() => {
handleApiKeyChange();
setConfirmDialogOpen(false);
}}
onCancel={() => setConfirmDialogOpen(false)}
/>
<CredentialChangeDialog
open={credentialChangeDialogOpen}
onDialogClose={() => setCredentialChangeDialogOpen(false)}
onCredentialsUpdate={handleCredentialsChange}
onNotification={(text) => setNotification(text)}
/>
<Snackbar
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
open={notification.length !== 0}
autoHideDuration={4000}
onClose={() => setNotification('')}
message={notification}
action={(
<>
<IconButton
size="small"
aria-label="close"
color="inherit"
onClick={() => setNotification('')}
>
<span className="material-icons">close</span>
</IconButton>
</>
)}
/>
</div>
);
}
export default withWidth()(App);

View File

@ -0,0 +1,219 @@
import {
Button,
createStyles,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
FormControlLabel,
FormLabel,
InputLabel,
makeStyles,
MenuItem,
Radio,
RadioGroup,
Select,
Theme,
Tooltip,
} from '@material-ui/core';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import generateConfig from '../utils/ShareX';
import { availableLanguages } from '../i18n';
import { Config, SortBy, SortOrder } from '../types';
const useStyles = makeStyles((theme: Theme) => createStyles({
form: {
display: 'flex',
flexDirection: 'column',
margin: 'auto',
width: 'fit-content',
},
formControl: {
margin: theme.spacing(1),
},
wide: {
width: '100%',
},
buttonSpan: {
display: 'inline-flex',
flexGrow: 1,
},
button: {
flexGrow: 1,
},
}));
const ConfigurationDialog = ({
open,
onDialogClose,
currentSettings,
onSave,
onApiKeyChange,
onCredentialsChange,
apiKey,
}: {
open: boolean;
onDialogClose: () => void;
currentSettings: Config;
onSave: (sortBy: SortBy, sortOrder: SortOrder) => void;
onApiKeyChange: () => void;
onCredentialsChange: () => void;
apiKey?: string;
}) => {
const [sortBy, setSortBy] = useState(currentSettings.sortBy);
const [sortOrder, setSortOrder] = useState(currentSettings.sortOrder);
const classes = useStyles();
const { t, i18n } = useTranslation();
const handleSave = () => {
onSave(sortBy, sortOrder);
};
const downloadSharexConfig = () => {
if (typeof apiKey === 'undefined') {
console.log('This should never happen');
return;
}
const configText = generateConfig(
window.location.href.replace(/\/$/, ''),
apiKey,
);
const element = document.createElement('a');
element.setAttribute(
'href',
`data:text/plain;charset=utf-8,${encodeURIComponent(configText)}`,
);
element.setAttribute(
'download',
`${window.location.href.replace(/\/$/, '')}-sharex.sxcu`,
);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
};
console.log();
return (
<Dialog onClose={onDialogClose} open={open} aria-labelledby="dialog-title">
<DialogTitle id="dialog-title">{t('Settings')}</DialogTitle>
<DialogContent>
{/* <DialogContentText>
Configure how you want your list to be displayed.
</DialogContentText> */}
<form className={classes.form} noValidate>
<FormControl className={classes.formControl}>
<FormLabel component="legend">{t('Sort By')}</FormLabel>
<RadioGroup
row
aria-label="position"
name="position"
value={sortBy}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => (
setSortBy(e.target.value as SortBy)
)}
>
<FormControlLabel
value={SortBy.Name}
control={<Radio color="primary" />}
label={t('Name')}
/>
<FormControlLabel
value={SortBy.Date}
control={<Radio color="primary" />}
label={t('Upload date')}
/>
</RadioGroup>
</FormControl>
<FormControl className={classes.formControl}>
<FormLabel component="legend">{t('Sort Direction')}</FormLabel>
<RadioGroup
row
aria-label="position"
name="position"
value={sortOrder}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => (
setSortOrder(e.target.value as SortOrder)
)}
>
<FormControlLabel
value={SortOrder.Ascending}
control={<Radio color="primary" />}
label={t('Ascending')}
/>
<FormControlLabel
value={SortOrder.Descending}
control={<Radio color="primary" />}
label={t('Descending')}
/>
</RadioGroup>
</FormControl>
</form>
<div className={classes.form}>
<FormControl className={classes.formControl}>
<InputLabel id="demo-simple-select-label">Language</InputLabel>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={i18n.language.slice(0, 2)}
onChange={(e) => i18n.changeLanguage(e.target.value as string)}
>
{availableLanguages.map((lang) => (
<MenuItem value={lang.short} key={lang.short}>
{lang.longLocal}
</MenuItem>
))}
</Select>
</FormControl>
<Button
className={classes.formControl}
variant="contained"
color="primary"
onClick={onCredentialsChange}
>
{t('Change username/password')}
</Button>
<Button
className={classes.formControl}
variant="contained"
color="primary"
onClick={onApiKeyChange}
>
{t('Get API key')}
</Button>
<Tooltip
title={(
<>
{t('You must first get a new API key')}
</>
)}
disableFocusListener={typeof apiKey !== 'undefined'}
disableHoverListener={typeof apiKey !== 'undefined'}
disableTouchListener={typeof apiKey !== 'undefined'}
>
<span className={classes.buttonSpan}>
<Button
disabled={typeof apiKey === 'undefined'}
className={`${classes.formControl} ${classes.button}`}
variant="contained"
color="primary"
onClick={downloadSharexConfig}
>
{t('Get ShareX config')}
</Button>
</span>
</Tooltip>
</div>
</DialogContent>
<DialogActions>
<Button color="primary" onClick={handleSave}>
{t('Save')}
</Button>
<Button color="primary" onClick={onDialogClose}>
{t('Cancel')}
</Button>
</DialogActions>
</Dialog>
);
};
export default ConfigurationDialog;

View File

@ -0,0 +1,41 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from '@material-ui/core';
import React from 'react';
import { useTranslation } from 'react-i18next';
const ConfirmationDialog = ({
open, header, content, onConfirm, onCancel,
}:{
open: boolean;
header?: string;
content?: string;
onConfirm: () => void;
onCancel: () => void;
}) => {
const { t } = useTranslation();
return (
<Dialog onClose={onCancel} open={open} aria-labelledby="dialog-title">
<DialogTitle id="dialog-title">{header}</DialogTitle>
<DialogContent>
<DialogContentText>{content}</DialogContentText>
</DialogContent>
<DialogActions>
<Button color="primary" onClick={onConfirm}>
{t('Confirm')}
</Button>
<Button color="primary" onClick={onCancel}>
{t('Cancel')}
</Button>
</DialogActions>
</Dialog>
);
};
export default ConfirmationDialog;

View File

@ -0,0 +1,109 @@
import {
Button,
createStyles,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
makeStyles,
TextField,
Theme,
} from '@material-ui/core';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
const useStyles = makeStyles((theme: Theme) => createStyles({
form: {
display: 'flex',
flexDirection: 'column',
},
margin: {
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
},
}));
const CredentialChangeDialog = ({
open, onDialogClose, onCredentialsUpdate, onNotification,
}: {
open: boolean;
onDialogClose: () => void;
onCredentialsUpdate: (
oldPassword: string,
username: string,
password: string
) => void;
onNotification: (text: string) => void;
}) => {
const classes = useStyles();
const { t } = useTranslation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [oldPassword, setOldPassword] = useState('');
const handleSubmit = async () => {
if (username === '' && password === '') {
onNotification(t('At least one field must be filled'));
return;
}
onCredentialsUpdate(oldPassword, username, password);
};
return (
<Dialog onClose={onDialogClose} open={open} aria-labelledby="dialog-title">
<DialogTitle id="dialog-title">{t('Update user data')}</DialogTitle>
<DialogContent>
<DialogContentText>{t('enter_new_username')}</DialogContentText>
<form
className={classes.form}
onSubmit={(e: React.FormEvent) => {
e.preventDefault();
handleSubmit();
}}
>
<TextField
id="outlined-username-input"
label={t('Username')}
fullWidth
type="text"
autoComplete="new-username"
variant="outlined"
value={username}
onChange={(e) => setUsername(e.target.value)}
className={classes.margin}
/>
<TextField
label={t('Password')}
fullWidth
type="password"
autoComplete="new-password"
variant="outlined"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={classes.margin}
/>
<TextField
label={t('Old password')}
fullWidth
type="password"
autoComplete="current-password"
variant="outlined"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
className={classes.margin}
/>
</form>
</DialogContent>
<DialogActions>
<Button color="primary" onClick={handleSubmit}>
{t('Save')}
</Button>
<Button color="primary" onClick={onDialogClose}>
{t('Cancel')}
</Button>
</DialogActions>
</Dialog>
);
};
export default CredentialChangeDialog;

View File

@ -0,0 +1,38 @@
import React from 'react';
import { GridList, GridListTile } from '@material-ui/core';
import { Image } from '../types';
import ImageThumbnail from './ImageThumbnail';
const ImageGridListTile = ({
images,
cols,
onTileClick,
onNotification,
}:{
images: Image[];
cols: number;
onTileClick: (url: string) => void;
onNotification: (text: string) => void;
}) => (
<GridList cellHeight={160} cols={cols}>
{images.map((image) => (
<GridListTile key={image.filename}>
<ImageThumbnail
image={image}
onTileClick={onTileClick}
onNotification={onNotification}
/>
</GridListTile>
))}
</GridList>
);
export default React.memo(ImageGridListTile, (prevProps, nextProps) => {
if (
prevProps.images.length === nextProps.images.length
&& prevProps.cols === nextProps.cols
) {
return true;
}
return false;
});

View File

@ -0,0 +1,118 @@
import { ButtonBase, GridListTileBar, IconButton } from '@material-ui/core';
import { makeStyles, createStyles } from '@material-ui/core/styles';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Image } from '../types';
const useStyles = makeStyles(() => createStyles({
image: {
cursor: 'pointer',
width: '100%',
height: '100%',
objectFit: 'cover',
},
link: {
width: '100%',
height: '100%',
'&:hover': {
opacity: '0.9',
},
'&:focus': {
opacity: '0.9',
},
},
item: {
opacity: '0',
transition: 'opacity 0.3s cubic-bezier(0.22, 1, 0.36, 1)',
width: '100%',
height: '100%',
},
fadeIn: {
opacity: '100',
backgroundColor: '#fff',
},
titleBar: {
background: 'rgba(0, 0, 0, 0)',
width: '48px',
},
icon: {
color: 'white',
filter: 'drop-shadow(2px 4px 3px #222222)',
},
container: {
width: '100%',
height: '100%',
backgroundColor: '#ebebeb',
},
}));
const ImageThumbnail = ({ image, onTileClick, onNotification }:{
image: Image;
onTileClick: (url: string) => void;
onNotification: (text: string) => void;
}) => {
const [loaded, setLoaded] = useState(false);
const classes = useStyles();
const { t } = useTranslation();
const copyToClipboard = async (stringToCopy: string) => {
await navigator.clipboard.writeText(stringToCopy);
onNotification(t('Copied link to clipboard'));
};
return (
<div className={classes.container}>
<div className={`${classes.item} ${loaded ? classes.fadeIn : null}`}>
<ButtonBase disableTouchRipple className={classes.link} tabIndex="-1">
<a
href={image.url}
className={classes.link}
onClick={(e: React.MouseEvent) => {
e.preventDefault();
onTileClick(image.url);
}}
>
<picture>
{image.thumbnails.map((thumb) => (
<source
key={encodeURI(thumb.url)}
srcSet={encodeURI(thumb.url)}
type={thumb.filetype}
/>
))}
<img
loading="lazy"
className={classes.image}
src={
image.thumbnails.filter(
(thumb) => thumb.filetype === 'image/jpeg',
)[0].url
}
alt=""
onLoad={() => setLoaded(true)}
/>
</picture>
</a>
</ButtonBase>
<GridListTileBar
titlePosition="top"
actionIcon={(
<IconButton
className={classes.icon}
onClick={() => {
copyToClipboard(
`${window.location.href.replace(/\/$/, '')}${image.url}`,
);
}}
>
{/* <FileCopyOutlinedIcon /> */}
<span className="material-icons">content_copy</span>
</IconButton>
)}
actionPosition="left"
className={classes.titleBar}
/>
</div>
</div>
);
};
export default ImageThumbnail;

View File

@ -0,0 +1,100 @@
import {
AppBar,
Button,
createStyles,
Grid,
makeStyles,
TextField,
Theme,
Toolbar,
Typography,
} from '@material-ui/core';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
const useStyles = makeStyles((theme: Theme) => createStyles({
margin: {
margin: theme.spacing(1),
},
header: {
textAlign: 'center',
fontWeight: 300,
},
}));
const LoginView = (
{ onLogin, setupFinished }:
{
onLogin: (username: string, password: string) => void;
setupFinished: boolean;
},
) => {
const classes = useStyles();
const { t } = useTranslation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
return (
<div className="App">
<AppBar position="static">
<Toolbar>
<Typography variant="h6">{t('Pictures')}</Typography>
</Toolbar>
</AppBar>
<Grid
container
spacing={0}
direction="column"
alignItems="center"
justify="center"
style={{ minHeight: '80vh' }}
>
<Grid item xs={10} md={4}>
<Typography variant="h3" className={classes.header}>
{setupFinished ? t('Login') : t('Register')}
</Typography>
<form
onSubmit={(e: React.FormEvent) => {
e.preventDefault();
onLogin(username, password);
}}
>
<TextField
id="outlined-username-input"
label={t('Username')}
fullWidth
type="text"
autoComplete={setupFinished ? 'current-username' : 'new-username'}
variant="outlined"
value={username}
onChange={(e) => setUsername(e.target.value)}
className={classes.margin}
/>
<TextField
id="outlined-password-input"
label={t('Password')}
fullWidth
type="password"
autoComplete={setupFinished ? 'current-password' : 'new-password'}
variant="outlined"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={classes.margin}
/>
<Button
variant="contained"
color="primary"
type="submit"
className={classes.margin}
fullWidth
>
{setupFinished ? t('Login') : t('Register')}
</Button>
</form>
</Grid>
</Grid>
</div>
);
};
export default LoginView;

View File

@ -0,0 +1,110 @@
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles';
import React from 'react';
import {
AppBar,
Button,
IconButton,
Slide,
Toolbar,
Typography,
useScrollTrigger,
} from '@material-ui/core';
import { useTranslation } from 'react-i18next';
const useStyles = makeStyles((theme: Theme) => createStyles({
root: {
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-around',
overflow: 'hidden',
backgroundColor: theme.palette.background.paper,
},
titleBar: {
background:
'linear-gradient(to bottom, rgba(0,0,0,0) 0%, '
+ 'rgba(0,0,0,0) 70%, rgba(0,0,0,0) 100%)',
transition: 'background 2s ease-out',
'&:hover': {
background:
'linear-gradient(to bottom, rgba(0,0,0,0) 0%, '
+ 'rgba(0,0,0,0) 70%, rgba(0,0,0,0) 100%)',
},
},
icon: {
color: 'white',
filter: 'drop-shadow(2px 4px 3px #222222)',
},
listItem: {
cursor: 'pointer',
'&:hover': {
opacity: '0.9',
},
},
dialogImage: {
maxHeight: '80vh',
},
loader: {
margin: '1rem',
},
toolbarTitle: {
flexGrow: 1,
textAlign: 'left',
},
toolbarButton: {
flexGrow: 1,
},
}));
const PicturesAppBar = (
{ onUploadClick, onSettingsClick, onLogoutClick }:
{
onUploadClick: () => void;
onSettingsClick: () => void;
onLogoutClick: () => void;
},
) => {
const classes = useStyles();
const trigger = useScrollTrigger();
const { t } = useTranslation();
return (
<>
<Slide appear={false} direction="down" in={!trigger}>
<AppBar>
<Toolbar>
{/* <IconButton
edge="start"
color="inherit"
aria-label="menu"
onClick={onDrawer}
>
<MenuIcon />
</IconButton> */}
<Typography variant="h6" className={classes.toolbarTitle}>
{t('Pictures')}
</Typography>
<IconButton
color="inherit"
aria-label="upload"
onClick={onUploadClick}
>
<span className="material-icons">cloud_upload</span>
</IconButton>
<IconButton
color="inherit"
aria-label="settings"
onClick={onSettingsClick}
>
<span className="material-icons">settings</span>
</IconButton>
<Button color="inherit" onClick={() => onLogoutClick()}>
{t('Logout')}
</Button>
</Toolbar>
</AppBar>
</Slide>
<Toolbar />
</>
);
};
export default PicturesAppBar;

View File

@ -0,0 +1,104 @@
/* eslint-disable react/jsx-props-no-spreading */
import React from 'react';
import {
createStyles,
Dialog,
DialogTitle,
IconButton,
makeStyles,
Theme,
} from '@material-ui/core';
import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
const useStyles = makeStyles((theme: Theme) => createStyles({
dropzone: {
textAlign: 'center',
padding: '20px',
border: '3px dashed #eeeeee',
backgroundColor: '#fafafa',
color: '#bdbdbd',
marginBottom: '20px',
marginLeft: '20px',
marginRight: '20px',
height: '20vh',
minWidth: '40vw',
transition: 'border-color 0.4s cubic-bezier(0.22, 1, 0.36, 1)',
},
icon: {
fontSize: '2rem',
},
accept: {
borderColor: 'green !important',
},
reject: {
borderColor: 'red !important',
},
closeButton: {
position: 'absolute',
right: theme.spacing(1),
top: theme.spacing(1),
color: theme.palette.grey[500],
},
}));
const UploadDialog = ({
isOpen, onClose, onDrop, accept,
}:{
isOpen: boolean;
onClose: () => void;
onDrop: (files: File[]) => void;
accept: string[];
}) => {
const classes = useStyles();
const { t } = useTranslation();
const {
getRootProps,
getInputProps,
isDragActive,
isDragAccept,
isDragReject,
} = useDropzone({
onDropAccepted: (acceptedFiles) => onDrop(acceptedFiles),
accept,
});
return (
<Dialog
open={isOpen}
onClose={onClose}
// onDragLeave={() => setDragOpen(false)}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
maxWidth="xl"
>
<DialogTitle id="alert-dialog-title">
{t('Upload')}
<IconButton
aria-label="close"
className={classes.closeButton}
onClick={onClose}
>
<span className="material-icons">close</span>
</IconButton>
</DialogTitle>
<section>
<div
{...getRootProps({
className: `dropzone ${classes.dropzone} ${
isDragAccept ? classes.accept : null
} ${isDragReject ? classes.reject : null}`,
})}
>
<input {...getInputProps()} />
<span className={classes.icon}>
{isDragActive ? null : '📁'}
{isDragAccept ? '📂' : null}
{isDragReject ? '❌' : null}
</span>
<p>{t('Drag and drop some files here, or click to select files')}</p>
</div>
</section>
</Dialog>
);
};
export default UploadDialog;

View File

@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export const API_BASE_URL = '/api';

37
packages/web/src/i18n.ts Normal file
View File

@ -0,0 +1,37 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next) // passes i18n down to react-i18next
.init({
backend: {
loadPath: '/locales/{{lng}}.json',
},
fallbackLng: 'en',
interpolation: {
escapeValue: false, // react already safes from xss
},
react: {
useSuspense: false,
},
});
export const availableLanguages = [
{
short: 'en',
long: 'English',
longLocal: 'English',
},
{
short: 'ru',
long: 'Russian',
longLocal: 'Русский',
},
];
export default i18n;

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,53 @@
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: local('Material Icons'),
local('MaterialIcons-Regular'),
url(MaterialIcons-Regular.woff2) format('woff2'),
url(MaterialIcons-Regular.ttf) format('truetype');
}
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
font-feature-settings: 'liga';
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
@font-face {
font-family: 'Material Icons Outlined';
font-style: normal;
font-weight: 400;
src: local('Material Icons Outlined'),
local('MaterialIconsOutlined-Regular'),
url(MaterialIconsOutlined-Regular.woff2) format('woff2'),
url(MaterialIconsOutlined-Regular.otf) format('opentype');
}
.material-icons-outlined {
font-family: 'Material Icons Outlined';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
font-feature-settings: 'liga';
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}

View File

@ -0,0 +1,32 @@
import React from 'react';
import ReactDOM from 'react-dom';
import CssBaseline from '@material-ui/core/CssBaseline';
import App from './App';
import reportWebVitals from './reportWebVitals';
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import '@fontsource/roboto/cyrillic-ext-300.css';
import '@fontsource/roboto/cyrillic-ext-400.css';
import '@fontsource/roboto/cyrillic-ext-500.css';
import '@fontsource/roboto/cyrillic-ext-700.css';
// import 'material-design-icons/iconfont/material-icons.css';
import './icons/icons.css';
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
import './i18n';
serviceWorkerRegistration.register();
ReactDOM.render(
<React.StrictMode>
<CssBaseline>
<App />
</CssBaseline>
</React.StrictMode>,
document.getElementById('root'),
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

1
packages/web/src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@ -0,0 +1,17 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({
getCLS, getFID, getFCP, getLCP, getTTFB,
}) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@ -0,0 +1,113 @@
/* eslint-disable no-underscore-dangle */
/// <reference lib="webworker" />
/* eslint-disable no-restricted-globals */
// This service worker can be customized!
// See https://developers.google.com/web/tools/workbox/modules
// for the list of available Workbox modules, or add any other
// code you'd like.
// You can also remove this file if you'd prefer not to use a
// service worker, and the Workbox build step will be skipped.
import { clientsClaim } from 'workbox-core';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, Strategy } from 'workbox-strategies';
declare const self: ServiceWorkerGlobalScope;
clientsClaim();
// Precache all of the assets generated by your build process.
// Their URLs are injected into the manifest variable below.
// This variable must be present somewhere in your service worker file,
// even if you decide not to use precaching. See https://cra.link/PWA
console.log('wbcache', self.__WB_MANIFEST);
precacheAndRoute(self.__WB_MANIFEST);
// Set up App Shell-style routing, so that all navigation requests
// are fulfilled with your index.html shell. Learn more at
// https://developers.google.com/web/fundamentals/architecture/app-shell
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
registerRoute(
// Return false to exempt requests from being fulfilled by index.html.
({ request, url }: { request: Request; url: URL }) => {
// If this isn't a navigation, skip.
if (request.mode !== 'navigate') {
return false;
}
// If this is a URL that starts with /_, skip.
if (url.pathname.startsWith('/_')) {
return false;
}
// If this looks like a URL for a resource, because it contains
// a file extension, skip.
if (url.pathname.match(fileExtensionRegexp)) {
return false;
}
// Return true to signal that we want to use the handler.
return true;
},
createHandlerBoundToURL(`${process.env.PUBLIC_URL}/index.html`),
);
registerRoute(
({ url }) => url.origin === self.location.origin && url.pathname.includes('/api/thumbnails/'),
new CacheFirst({
cacheName: 'thumbnails',
plugins: [
new ExpirationPlugin({ maxEntries: 50 }),
],
}),
);
registerRoute(
new RegExp('https?:\\/\\/localhost:3002\\/\\w+\\.\\w+$'),
new CacheFirst({
cacheName: 'fulls',
plugins: [
new ExpirationPlugin({ maxEntries: 30 }),
],
}),
);
registerRoute(
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.json'),
new CacheFirst({
cacheName: 'json',
}),
);
registerRoute(
({ url }) => url.origin === self.location.origin && (url.pathname.includes('/api/images') || url.pathname.includes('/api/meta')),
new NetworkFirst({
cacheName: 'imageData',
}),
);
class MyStrategy extends Strategy {
// eslint-disable-next-line class-methods-use-this
async _handle(request: any, handler: { fetch: (arg0: any) => any; }) {
await handler.fetch(request);
return Response.redirect('/', 302);
// return handler.fetch(request);
}
}
registerRoute(
({ url }) => url.origin === self.location.origin && url.pathname.includes('/api/images'),
new MyStrategy(),
'POST',
);
// This allows the web app to trigger skipWaiting via
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Any other custom service worker logic can go here.

View File

@ -0,0 +1,144 @@
/* eslint-disable @typescript-eslint/no-use-before-define */
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://cra.link/PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost'
// [::1] is the IPv6 localhost address.
|| window.location.hostname === '[::1]'
// 127.0.0.0/8 are considered localhost for IPv4.
|| window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/),
);
type Config = {
onSuccess?: (registration: ServiceWorkerRegistration) => void;
onUpdate?: (registration: ServiceWorkerRegistration) => void;
};
export function register(config?: Config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service '
+ 'worker. To learn more, visit https://cra.link/PWA',
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
// eslint-disable-next-line no-param-reassign
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all '
+ 'tabs for this page are closed. See https://cra.link/PWA.',
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch((error) => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl: string, config?: Config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' },
})
.then((response) => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404
|| (contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log('No internet connection found. App is running in offline mode.');
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then((registration) => {
registration.unregister();
})
.catch((error) => {
console.error(error.message);
});
}
}

View File

@ -0,0 +1,83 @@
import axios from 'axios';
import {
Image, SortBy, SortOrder, Thumbnail,
} from '../types';
import { API_BASE_URL } from '../consts';
// Type guards
const isThumbnail = (thumb: any): thumb is Thumbnail => {
if (
'url' in thumb
&& typeof thumb.url === 'string'
&& 'filetype' in thumb
&& typeof thumb.filetype === 'string'
) {
return true;
}
return false;
};
const isThumbnailArray = (thumbnails: any): thumbnails is Thumbnail[] => (
Array.isArray(thumbnails) && thumbnails.every(isThumbnail)
);
const isImage = (image: any): image is Image => {
if (
'url' in image
&& typeof image.url === 'string'
&& 'filename' in image
&& typeof image.url === 'string'
&& 'thumbnails' in image
&& isThumbnailArray(image.thumbnails)
) {
return true;
}
return false;
};
const isImageArray = (images: any): images is Image[] => (
Array.isArray(images) && images.every(isImage)
);
/**
* Fetches list of images from the server
* @param sortBy Attribute by which images will be sorted
* @param sortOrder Images sort direction
* @param page Page number
* @return List of images
*/
export const getPage = async (
page: number = 0,
sortBy: SortBy = SortBy.Date,
sortOrder: SortOrder = SortOrder.Descending,
): Promise<Image[]> => {
const { data } = await axios.get(`${API_BASE_URL}/images`, {
params: { page, sortBy, sortOrder },
timeout: 5000,
});
if (isImageArray(data)) {
return data;
}
throw new Error('Malformed response from server');
};
/**
* Uploads an image to the server
* @param image File object of image you want to upload
* @returns Meta of uploaded image
* @throws Will throw if error occurs during upload
*/
export const uploadImage = async (image: File): Promise<Image> => {
const formData = new FormData();
formData.append('file', image);
const { data } = await axios.post(`${API_BASE_URL}/images`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
timeout: 10000,
});
if (isImage(data)) {
return data;
}
throw new Error('Malformed response from server');
};

View File

@ -0,0 +1,61 @@
import axios from 'axios';
import { API_BASE_URL } from '../consts';
/**
*
* @param username Plaintext username
* @param password Plaintext password
* @returns http status code
*/
export const doLogin = async (
username: string,
password: string,
): Promise<number> => {
try {
const { status } = await axios.post(
`${API_BASE_URL}/login`,
{
username,
password,
},
{
validateStatus: (statusCode) => statusCode >= 200 && statusCode < 500,
},
);
return status;
} catch (e) {
console.log(e);
return e.response.status || 500;
}
};
export const doRegister = async (
username: string,
password: string,
): Promise<number> => {
try {
const { status } = await axios.post(
`${API_BASE_URL}/login/register`,
{
username,
password,
},
{
validateStatus: (statusCode) => statusCode >= 200 && statusCode < 500,
},
);
return status;
} catch (e) {
console.log(e);
return e.response.status || 500;
}
};
export const doLogout = async () => {
try {
const result = await axios.post(`${API_BASE_URL}/login/logout`);
console.log(result.data);
} catch (e) {
console.log(e);
}
};

View File

@ -0,0 +1,18 @@
import axios from 'axios';
import { API_BASE_URL } from '../consts';
import { Meta } from '../types';
// eslint-disable-next-line import/prefer-default-export
export const getMeta = async (): Promise<Meta> => {
const { data } = await axios.get(`${API_BASE_URL}/meta`);
if (
Array.isArray(data.accepted)
&& data.accepted.every((x: any): x is string => typeof x === 'string')
&& typeof data.setupFinished === 'boolean'
) {
return { accepted: data.accepted, setupFinished: data.setupFinished };
}
console.log('truth:', Object.prototype.toString.call(data.accepted));
console.log(data.setupFinished);
throw new Error('Malformed server response');
};

View File

@ -0,0 +1,29 @@
import { Config, SortBy, SortOrder } from '../types';
export const saveSettings = (settings: Config) => {
localStorage.setItem('picturesapp-settings', JSON.stringify(settings));
};
export const getSettings = (): Config => {
const serializedSettings = localStorage.getItem('picturesapp-settings');
if (serializedSettings === null) {
// If no data was found return default values
return { sortBy: SortBy.Name, sortOrder: SortOrder.Ascending };
}
return JSON.parse(serializedSettings) as Config;
};
export const getUserState = (): boolean => {
const serializedUserState = localStorage.getItem('picturesapp-userState');
if (serializedUserState === null) {
return false;
}
const userState = JSON.parse(serializedUserState);
if (typeof userState !== 'boolean') {
return false;
}
return userState;
};
export const setUserState = (state: boolean): void => {
localStorage.setItem('picturesapp-userState', JSON.stringify(state));
};

View File

@ -0,0 +1,30 @@
import axios from 'axios';
import { API_BASE_URL } from '../consts';
export const getApiKey = async (): Promise<string> => {
const { data } = await axios.post(`${API_BASE_URL}/user/getApiKey`);
if (typeof data?.token === 'string') {
return data.token;
}
throw new Error('Malformed server response');
};
export const updateCredentials = async (
oldPassword: string,
username: string,
password: string,
) => {
console.log('username:', username, ' password:', password);
const { data } = await axios.post(
`${API_BASE_URL}/user/updateCredentials`,
{
...(username !== '' ? { username } : {}),
...(password !== '' ? { password } : {}),
oldPassword,
},
{},
);
if (data.status !== 'success') {
throw new Error('Malformed server response');
}
};

View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

36
packages/web/src/types.ts Normal file
View File

@ -0,0 +1,36 @@
export interface Image {
url: string;
filename: string;
thumbnails: Thumbnail[];
}
export interface Thumbnail {
url: string;
filetype: string;
}
export interface ImageTile {
thumbnails: Thumbnail[];
url: string;
filename: string;
}
export enum SortBy {
Name = 'filename',
Date = 'added',
}
export enum SortOrder {
Ascending = 'ASC',
Descending = 'DESC',
}
export interface Config {
sortBy: SortBy;
sortOrder: SortOrder;
}
export interface Meta {
accepted: string[];
setupFinished: boolean;
}

View File

@ -0,0 +1,17 @@
const generateConfig = (baseUrl: string, token: string) => `
{
"Version": "13.4.0",
"Name": "${baseUrl}",
"DestinationType": "ImageUploader",
"RequestMethod": "POST",
"RequestURL": "${baseUrl}/api/images",
"Headers": {
"Authorization": "Bearer ${token}"
},
"Body": "MultipartFormData",
"FileFormName": "file",
"URL": "${baseUrl}$json:url$",
"ErrorMessage": "$json:error$"
}`;
export default generateConfig;

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es6",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}