feat(web)!: SPA (#5069)

* feat(web): SPA

* chore: remove unnecessary prune

* feat(web): merge with immich-server

* Correct method name

* fix: bugs, docs, workflows, etc.

* chore: keep dockerignore for dev

* chore: remove license

* fix: expose 2283

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen
2023-11-17 23:13:36 -05:00
committed by GitHub
parent 5118d261ab
commit adae5dd758
115 changed files with 730 additions and 1446 deletions
-5
View File
@@ -1,5 +0,0 @@
node_modules/
upload/
dist/
coverage/
.reverse-geocoding-dump
+22 -13
View File
@@ -1,33 +1,42 @@
FROM ghcr.io/immich-app/base-server-dev:20231109 as builder
# dev build
FROM ghcr.io/immich-app/base-server-dev:20231109 as dev
COPY package.json package-lock.json ./
WORKDIR /usr/src/app
COPY server/package.json server/package-lock.json ./
RUN npm ci
COPY . .
COPY server .
FROM builder as prod
FROM dev AS prod
RUN npm run build
RUN npm prune --omit=dev --omit=optional
# web build
FROM node:20.8-alpine3.18 as web
WORKDIR /usr/src/app
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web .
RUN npm run build
# prod build
FROM ghcr.io/immich-app/base-server-prod:20231109
WORKDIR /usr/src/app
ENV NODE_ENV=production
COPY --from=prod /usr/src/app/node_modules ./node_modules
COPY --from=prod /usr/src/app/dist ./dist
COPY --from=prod /usr/src/app/bin ./bin
COPY ./assets ./assets
COPY --from=web /usr/src/app/build ./www
COPY server/assets assets
COPY server/package.json server/package-lock.json ./
COPY server/start*.sh ./
RUN npm link && npm cache clean --force
COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE
COPY package.json package-lock.json ./
COPY start*.sh ./
RUN npm link && npm cache clean --force
VOLUME /usr/src/app/upload
EXPOSE 3001
ENTRYPOINT ["tini", "--", "/bin/sh"]
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2022 Hau Tran
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -15,7 +15,7 @@ export class EnablePasswordLoginCommand extends CommandRunner {
const config = await this.configService.getConfig();
config.passwordLogin.enabled = true;
await this.configService.updateConfig(config);
await axios.post('http://localhost:3001/refresh-config');
await axios.post('http://localhost:3001/api/refresh-config');
console.log('Password login has been enabled.');
}
}
@@ -33,7 +33,7 @@ export class DisablePasswordLoginCommand extends CommandRunner {
const config = await this.configService.getConfig();
config.passwordLogin.enabled = false;
await this.configService.updateConfig(config);
await axios.post('http://localhost:3001/refresh-config');
await axios.post('http://localhost:3001/api/refresh-config');
console.log('Password login has been disabled.');
}
}
@@ -306,9 +306,9 @@ describe(SystemConfigService.name, () => {
});
});
describe('getTheme', () => {
describe('getCustomCss', () => {
it('should return the default theme', async () => {
await expect(sut.getTheme()).resolves.toEqual(defaults.theme);
await expect(sut.getCustomCss()).resolves.toEqual(defaults.theme.customCss);
});
});
});
@@ -1,7 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { JobName } from '../job';
import { CommunicationEvent, ICommunicationRepository, IJobRepository, ISystemConfigRepository } from '../repositories';
import { SystemConfigThemeDto } from './dto/system-config-theme.dto';
import { SystemConfigDto, mapConfig } from './dto/system-config.dto';
import { SystemConfigTemplateStorageOptionDto } from './response-dto/system-config-template-storage-option.dto';
import {
@@ -31,11 +30,6 @@ export class SystemConfigService {
return this.core.config$;
}
async getTheme(): Promise<SystemConfigThemeDto> {
const { theme } = await this.core.getConfig();
return theme;
}
async getConfig(): Promise<SystemConfigDto> {
const config = await this.core.getConfig();
return mapConfig(config);
@@ -87,4 +81,9 @@ export class SystemConfigService {
return JSON.parse(await this.repository.readFile(`./assets/style-${theme}.json`));
}
async getCustomCss(): Promise<string> {
const { theme } = await this.core.getConfig();
return theme.customCss;
}
}
+15
View File
@@ -13,6 +13,7 @@ import {
SwaggerDocumentOptions,
SwaggerModule,
} from '@nestjs/swagger';
import { NextFunction, Request, Response } from 'express';
import { writeFileSync } from 'fs';
import path from 'path';
@@ -56,6 +57,12 @@ const patchOpenAPI = (document: OpenAPIObject) => {
document.components.schemas = sortKeys(document.components.schemas);
}
for (const [key, value] of Object.entries(document.paths)) {
const newKey = key.replace('/api/', '/');
delete document.paths[key];
document.paths[newKey] = value;
}
for (const path of Object.values(document.paths)) {
const operations = {
get: path.get,
@@ -94,6 +101,14 @@ const patchOpenAPI = (document: OpenAPIObject) => {
return document;
};
export const indexFallback = (excludePaths: string[]) => (req: Request, res: Response, next: NextFunction) => {
if (req.url.startsWith('/api') || req.method.toLowerCase() !== 'get' || excludePaths.indexOf(req.url) !== -1) {
next();
} else {
res.sendFile('/www/index.html', { root: process.cwd() });
}
};
export const useSwagger = (app: INestApplication, isDev: boolean) => {
const config = new DocumentBuilder()
.setTitle('Immich')
@@ -1,15 +1,34 @@
import { SystemConfigService } from '@app/domain';
import { Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { Controller, Get, Header, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiExcludeEndpoint } from '@nestjs/swagger';
import { PublicRoute } from '../app.guard';
@Controller()
export class AppController {
constructor(private configService: SystemConfigService) {}
constructor(private service: SystemConfigService) {}
@ApiExcludeEndpoint()
@Get('.well-known/immich')
getImmichWellKnown() {
return {
api: {
endpoint: '/api',
},
};
}
@ApiExcludeEndpoint()
@PublicRoute()
@Get('custom.css')
@Header('Content-Type', 'text/css')
getCustomCss() {
return this.service.getCustomCss();
}
@ApiExcludeEndpoint()
@Post('refresh-config')
@HttpCode(HttpStatus.OK)
public reloadConfig() {
return this.configService.refreshConfig();
return this.service.refreshConfig();
}
}
+6 -1
View File
@@ -6,7 +6,7 @@ import { NestExpressApplication } from '@nestjs/platform-express';
import { json } from 'body-parser';
import cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
import { useSwagger } from './app.utils';
import { indexFallback, useSwagger } from './app.utils';
const logger = new Logger('ImmichServer');
const port = Number(process.env.SERVER_PORT) || 3001;
@@ -24,6 +24,11 @@ export async function bootstrap() {
app.useWebSocketAdapter(new RedisIoAdapter(app));
useSwagger(app, isDev);
const excludePaths = ['/.well-known/immich', '/custom.css'];
app.setGlobalPrefix('api', { exclude: excludePaths });
app.useStaticAssets('www');
app.use(indexFallback(excludePaths));
const server = await app.listen(port);
server.requestTimeout = 30 * 60 * 1000;
@@ -3,7 +3,7 @@ import { Logger } from '@nestjs/common';
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
@WebSocketGateway({ cors: true })
@WebSocketGateway({ cors: true, path: '/api/socket.io' })
export class CommunicationRepository implements OnGatewayConnection, OnGatewayDisconnect, ICommunicationRepository {
private logger = new Logger(CommunicationRepository.name);
private onConnectCallbacks: Callback[] = [];